Compare commits

..
Author SHA1 Message Date
fetch 79dd0ba3b4 Just reset rustflag 2025-09-08 21:48:48 +01:00
fetch 2343f82560 Use lld actually 2025-09-08 21:42:29 +01:00
fetch 59972e0401 Back to mold 2025-09-08 21:38:49 +01:00
fetch 6d0c3ff9db Vs ld 2025-09-08 21:38:32 +01:00
fetch 2c7d58575c Use mold 2025-09-08 21:06:27 +01:00
2292 changed files with 56505 additions and 362431 deletions
+4 -1
View File
@@ -1,6 +1,9 @@
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
[target.'cfg(windows)']
rustflags = ["-C", "link-args=/STACK:16777220"]
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
[build]
rustflags = ["--cfg", "tokio_unstable"]
-18
View File
@@ -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: <path-to-openapi.yml>
---
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`.
@@ -1,25 +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: <path-to-page>
---
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.
6. **Verify** the page renders correctly by checking for missing imports and that all DI contracts are satisfied.
-22
View File
@@ -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: <figma-url>
---
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.
-24
View File
@@ -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:**
- `<template>`: inner text, `alt`, `placeholder`, `aria-label`, button labels, tooltip text.
- `<script>`: string literals passed to user-visible UI (notification messages, dropdown labels, error messages).
- Skip: dynamic expressions, HTML tag names, CSS classes, internal identifiers, log messages.
5. **Define messages** with `defineMessages` — use descriptive, stable `id`s based on the component's domain (e.g. `project.settings.title`).
6. **Replace strings in templates** with `formatMessage()` calls, or `<IntlFormatted>` for strings containing links or markup.
7. **Handle ICU edge cases** — add a space before `}}` if an ICU placeholder ends at a Vue template delimiter boundary.
8. **Verify** no hard-coded English strings remain in the changed templates. Do not alter logic, layout, or reactivity.
-27
View File
@@ -1,27 +0,0 @@
---
name: tanstack-query
description: Convert a page or component from useAsyncData/manual ref patterns to TanStack Query for server state management. Use when migrating data fetching to useQuery/useMutation, adding cache invalidation, or replacing useAsyncData with TanStack Query.
argument-hint: <path-to-file>
---
Refer to the standard: @standards/frontend/FETCHING_DATA.md
## Steps
1. **Read the target file** at `$ARGUMENTS` and identify all data-fetching patterns: `useAsyncData`, `useFetch`, manual `ref()` + `await`, or `onMounted` fetch calls.
2. **Read the standard above** for the query/mutation patterns, query key conventions, and optimistic update approach.
3. **Convert queries:**
- Replace `useAsyncData` / `useFetch` / manual fetches with `useQuery`.
- Use the `api-client` via `injectModrinthClient()` for the `queryFn`.
- Design query keys with the `['resource', 'version', ...params]` convention.
- Use `computed` query keys for reactive parameters.
- Use the `enabled` option for conditional queries that depend on other data.
4. **Convert mutations:**
- Replace manual `try/catch` + `ref` patterns with `useMutation`.
- Add `onSuccess` handlers that invalidate or update related query caches.
- Consider optimistic updates for UI-critical mutations (follow the pattern in the standard).
5. **Clean up:**
- Remove manual loading/error `ref()`s that are now handled by TanStack Query's return values (`isPending`, `isError`, `error`).
- Remove manual `onMounted` fetch calls.
- Ensure SSR compatibility — queries in Nuxt pages are automatically awaited during SSR.
6. **Verify** the page still renders correctly and that cache invalidation triggers re-fetches where expected.
+3 -4
View File
@@ -1,7 +1,6 @@
name: 🎮 Bug with Modrinth App
description: For issues with Modrinth App.
labels: [app]
type: 'bug'
name: 🎮 Modrinth App bug
description: Report an issue in the Modrinth Launcher.
labels: [bug, app]
body:
- type: checkboxes
attributes:
+3 -4
View File
@@ -1,7 +1,6 @@
name: 🌐 Bug with Modrinth.com
description: For issues with the Modrinth website.
labels: [website]
type: 'bug'
name: 🌐 Website bug (modrinth.com)
description: Report an issue on the Modrinth website.
labels: [bug, web]
body:
- type: checkboxes
attributes:
@@ -1,7 +1,6 @@
name: 🛠 Bug with Modrinth API
description: For issues with the Modrinth API for developers.
labels: [api, backend]
type: 'bug'
name: 🛠 API issue (api.modrinth.com)
description: Report an issue regarding the Modrinth API.
labels: [bug, backend]
body:
- type: checkboxes
attributes:
-63
View File
@@ -1,63 +0,0 @@
name: 👥 Bug with Modrinth Hosting
description: For issues with a Modrinth Hosting product.
labels: [hosting]
type: 'bug'
body:
- type: checkboxes
attributes:
label: Please confirm the following.
options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems
required: true
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
required: true
- type: dropdown
id: issue-location
attributes:
label: Is this an issue in the control panel or with the Minecraft server itself?
options:
- Control panel (on Modrinth.com)
- Minecraft server
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browsers are you seeing the problem on? (if a panel issue)
multiple: true
options:
- N/A
- Chrome (including Arc, Brave, Opera, Vivaldi)
- Microsoft Edge
- Firefox
- Safari
- Other (please specify)
- type: textarea
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is. Include screenshots if applicable.
validations:
required: false
- type: textarea
attributes:
label: Steps to reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
validations:
required: false
- type: textarea
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen.
validations:
required: false
- type: textarea
attributes:
label: Additional context
description: Add any other context about the problem here.
validations:
required: false
@@ -1,6 +1,6 @@
name: 💡 Feature request
name: 💡 Feature Request
description: Suggest an idea
type: 'feature'
labels: [enhancement]
body:
- type: checkboxes
@@ -17,10 +17,9 @@ body:
label: What parts of Modrinth is your feature request related too?
multiple: true
options:
- Modrinth App
- Modrinth.com website
- Modrinth Hosting
- Modrinth API for developers
- App
- Website
- API
- type: textarea
attributes:
label: Is your suggested feature related to a problem? Please describe.
+10 -4
View File
@@ -1,8 +1,14 @@
blank_issues_enabled: false
blank_issues_enabled: true
contact_links:
- name: 🫶 Support portal
about: Get support using through our support website.
- name: 🫶 Support Portal
about: Get support using through our portal.
url: https://support.modrinth.com
- name: 💬 Chat on Discord
- name: 💬 Chat
about: Join our Discord server to chat about Modrinth.
url: https://discord.modrinth.com
- name: 🛣️ Roadmap
about: View our Roadmap. Please do not open issues for items on our roadmap.
url: https://roadmap.modrinth.com
- name: 📚 Documentation
about: Useful documentation about Modrinth's API
url: https://docs.modrinth.com
@@ -1,86 +0,0 @@
---
applyTo: '**/*.vue'
---
You are given a Nuxt/Vue single-file component (.vue). Your task is to convert every hard-coded natural-language string in the <template> into our localization system using vue-i18n with utilities from `@modrinth/ui`.
Please follow these rules precisely:
1. Identify translatable strings
- Scan the <template> for all user-visible strings (inner text, alt attributes, placeholders, button labels, etc.). Do not extract dynamic expressions (like {{ user.name }}) or HTML tags. Only extract static human-readable text.
- There may be strings within the <script> block, e.g dropdown option labels, notifications etc.
2. Create message definitions
- In the <script setup> block, import `defineMessage` or `defineMessages` from `@modrinth/ui`.
- For each extracted string, define a message with a unique `id` (use a descriptive prefix based on the component path, e.g. `auth.welcome.long-title`) and a `defaultMessage` equal to the original English string.
Example:
const messages = defineMessages({
welcomeTitle: { id: 'auth.welcome.title', defaultMessage: 'Welcome' },
welcomeDescription: { id: 'auth.welcome.description', defaultMessage: 'You're now part of the community…' },
})
3. Handle variables and ICU formats
- Replace dynamic parts with ICU placeholders: "Hello, ${user.name}!" → `{name}` and defaultMessage: 'Hello, {name}!'
- For numbers/dates/times, use ICU options (e.g., currency): `{price, number, ::currency/USD}`
- For plurals/selects, use ICU: `'{count, plural, one {# message} other {# messages}}'`
4. Rich-text messages (links/markup)
- In `defaultMessage`, wrap link/markup ranges with tags, e.g.:
"By creating an account, you agree to our <terms-link>Terms</terms-link> and <privacy-link>Privacy Policy</privacy-link>."
- Render rich-text messages with `<IntlFormatted>` from `@modrinth/ui` using named slots:
<IntlFormatted :message-id="messages.tosLabel">
<template #terms-link="{ children }">
<NuxtLink to="/terms">
<component :is="() => children" />
</NuxtLink>
</template>
<template #privacy-link="{ children }">
<NuxtLink to="/privacy">
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
- For simple emphasis: `'Welcome to <strong>Modrinth</strong>!'` with a slot:
<template #strong="{ children }">
<strong><component :is="() => children" /></strong>
</template>
- For more complex child handling, use `normalizeChildren` from `@modrinth/ui`:
<template #bold="{ children }">
<strong><component :is="() => normalizeChildren(children)" /></strong>
</template>
5. Formatting in templates
- Import and use `useVIntl()` from `@modrinth/ui`; prefer `formatMessage` for simple strings:
`const { formatMessage } = useVIntl()`
`<button>{{ formatMessage(messages.welcomeTitle) }}</button>`
- Pass variables as a second argument:
`{{ formatMessage(messages.greeting, { name: user.name }) }}`
6. Naming conventions and id stability
- Make `id`s descriptive and stable (e.g., `error.generic.default.title`). Group related messages with `defineMessages`.
7. Avoid Vue/ICU delimiter collisions
- If an ICU placeholder would end right before `}}` in a Vue template, insert a space so it becomes `} }` to avoid parsing issues.
8. Update imports and remove literals
- Ensure imports from `@modrinth/ui` are present: `defineMessage`/`defineMessages`, `useVIntl`, `IntlFormatted`, and optionally `normalizeChildren`.
- Replace all hard-coded strings with `formatMessage(...)` or `<IntlFormatted>` and remove the literals.
9. Preserve functionality
- Do not change logic, layout, reactivity, or bindings—only refactor strings into i18n.
Use existing patterns from our codebase:
- Variables/plurals: see `apps/frontend/src/pages/frog.vue`
- Rich-text link tags: see `apps/frontend/src/pages/auth/welcome.vue` and `apps/frontend/src/error.vue`
When you finish, there should be no hard-coded English strings left in the template—everything comes from `formatMessage` or `<IntlFormatted>`.
-3
View File
@@ -1,3 +0,0 @@
# Copying
Modrinth's Github workflows are licensed under the MIT License, which is provided in the file [LICENSE](./LICENSE).
-7
View File
@@ -1,7 +0,0 @@
Copyright 2025 Rinth, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-121
View File
@@ -1,121 +0,0 @@
name: Changelog Comment
on:
pull_request:
types: [opened, reopened]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to post the changelog comment on (for testing)'
required: true
type: number
jobs:
comment:
name: Post changelog comment
runs-on: ubuntu-latest
steps:
- name: 💬 Post or update changelog comment
uses: actions/github-script@v7
with:
github-token: ${{ secrets.CROWDIN_GH_TOKEN }}
script: |
const marker = '<!-- changelog -->';
const mergedMarker = '<!-- changelog-merged -->';
const sections = ['### Added', '', '### Changed', '', '### Deprecated', '', '### Removed', '', '### Fixed', '', '### Security'].join('\n');
const productBlock = (name) => `<details>\n<summary>${name}</summary>\n\n${sections}\n\n</details>`;
const template = [
marker,
'## Pull request changelog',
'',
'<!-- Fill in the changelog under each product area this PR affects.',
' Empty sections are ignored. Leave a product collapsed/empty',
' if it doesn\'t apply. -->',
'',
productBlock('App'),
'',
productBlock('Website'),
'',
productBlock('Hosting'),
].join('\n');
// Resolve PR number from event or workflow_dispatch input
const prNumber = context.payload.pull_request?.number
?? parseInt('${{ github.event.inputs.pr_number }}', 10);
if (!prNumber || isNaN(prNumber)) {
core.setFailed('Could not determine PR number');
return;
}
// Get PR details (need base ref for child PR detection)
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
// Check if bot comment already exists
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existingComment = comments.find(c => c.body.includes(marker));
if (existingComment) {
core.info('Changelog comment already exists, skipping');
return;
}
// Post the template comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: template,
});
core.info(`Posted changelog comment on PR #${prNumber}`);
// Detect child PR: check if this PR's base branch is another open PR's head branch
const baseRef = pr.base.ref;
if (baseRef === 'main' || baseRef === 'prod') {
return;
}
// Look for a parent PR whose head branch matches our base branch
const { data: candidatePRs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${baseRef}`,
});
if (candidatePRs.length === 0) {
return;
}
const parentPR = candidatePRs[0];
core.info(`Detected parent PR #${parentPR.number} for child PR #${prNumber}`);
// Add admonition to child PR's changelog comment
const { data: childComments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const childChangelogComment = childComments.find(c => c.body.includes(marker));
if (childChangelogComment && !childChangelogComment.body.includes(mergedMarker)) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: childChangelogComment.id,
body: `${mergedMarker}\n> [!NOTE]\n> This changelog has been merged into the changelog for #${parentPR.number}\n\n${childChangelogComment.body}`,
});
}
-27
View File
@@ -1,27 +0,0 @@
on:
pull_request:
push:
branches:
- master
env:
CARGO_TERM_COLOR: always
SQLX_OFFLINE: true
jobs:
typos:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: crate-ci/typos@v1.43.1
# see <https://github.com/influxdata/datafusion-udf-wasm/pull/275>
tombi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: taiki-e/install-action@v2
with:
tool: tombi
- run: tombi lint
- run: tombi fmt --check
-19
View File
@@ -1,19 +0,0 @@
on:
pull_request:
push:
branches:
- master
env:
CARGO_TERM_COLOR: always
SQLX_OFFLINE: true
jobs:
shear:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: cargo-bins/cargo-binstall@main
- run: cargo binstall --no-confirm cargo-shear
- run: cargo shear
-143
View File
@@ -1,143 +0,0 @@
name: Deploy frontend
on:
push:
branches:
- main
- prod
paths:
- 'apps/frontend/**/*'
- 'packages/ui/**/*'
- 'packages/utils/**/*'
- 'packages/assets/**/*'
- '**/wrangler.jsonc'
- '**/pnpm-*.yaml'
- '.github/workflows/frontend-deploy.yml'
workflow_dispatch:
workflow_call:
inputs:
environment:
required: true
type: string
description: 'The environment to deploy to (staging-preview or production-preview)'
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure environment
id: meta
run: |
echo "cmd=deploy" >> $GITHUB_OUTPUT
ENV_INPUT="${{ inputs.environment }}"
REF="${{ github.ref }}"
SHA_SHORT="${GITHUB_SHA::8}"
if [ "$ENV_INPUT" = "staging-preview" ]; then
echo "env=staging" >> $GITHUB_OUTPUT
echo "url=https://git-$SHA_SHORT-frontend-staging.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true" >> $GITHUB_OUTPUT
elif [ "$ENV_INPUT" = "production-preview" ]; then
echo "env=production" >> $GITHUB_OUTPUT
echo "url=https://git-$SHA_SHORT-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true" >> $GITHUB_OUTPUT
elif [ "$REF" = "refs/heads/main" ]; then
echo "env=staging" >> $GITHUB_OUTPUT
echo "url=https://staging.modrinth.com" >> $GITHUB_OUTPUT
else
# Production env (no preview)
echo "env=production" >> $GITHUB_OUTPUT
echo "url=https://modrinth.com" >> $GITHUB_OUTPUT
fi
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: Inject build variables
working-directory: ./apps/frontend
run: |
if [ "${{ steps.meta.outputs.env }}" == "staging" ]; then
echo "Injecting staging variables from wrangler.jsonc..."
jq -r '.env.staging.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV
else
echo "Injecting production variables from wrangler.jsonc..."
jq -r '.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV
fi
- name: Install dependencies
working-directory: ./apps/frontend
run: pnpm install
- name: Build frontend
working-directory: ./apps/frontend
run: pnpm build
env:
CF_PAGES_BRANCH: ${{ github.ref_name }}
CF_PAGES_COMMIT_SHA: ${{ github.sha }}
CF_PAGES_URL: ${{ steps.meta.outputs.url }}
BUILD_ENV: ${{ steps.meta.outputs.env }}
PREVIEW: ${{ inputs.environment != '' && 'true' || 'false' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
- name: Create Sentry release and upload sourcemaps
uses: getsentry/action-release@v3
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: modrinth
SENTRY_PROJECT: knossos-server
with:
environment: ${{ steps.meta.outputs.env }}
sourcemaps: ./apps/frontend/.output/server
url_prefix: '~/'
- name: Deploy Cloudflare Worker
id: wrangler
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
environment: ${{ steps.meta.outputs.env != 'production' && steps.meta.outputs.env || '' }}
workingDirectory: ./apps/frontend
packageManager: pnpm
wranglerVersion: '4.54.0'
command: ${{ steps.meta.outputs.cmd }}
- name: Purge cache
if: github.ref == 'refs/heads/prod'
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
-H "Content-Type: application/json" \
--data '{"hosts": ["modrinth.com", "www.modrinth.com", "staging.modrinth.com"]}' \
https://api.cloudflare.com/client/v4/zones/e39df17b9c4ef44cbce2646346ee6d33/purge_cache
- name: Write deployment URL to file
if: ${{ inputs.environment != '' }}
run: |
echo "${{ steps.meta.outputs.url }}" > deployment-url-${{ inputs.environment }}.txt
- name: Upload deployment URL
if: ${{ inputs.environment != '' }}
uses: actions/upload-artifact@v6
with:
name: deployment-url-${{ inputs.environment }}
path: deployment-url-${{ inputs.environment }}.txt
+30
View File
@@ -0,0 +1,30 @@
name: Clear pages cache
on:
push:
branches:
- prod
jobs:
wait:
if: github.repository_owner == 'modrinth'
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Cloudflare Pages deployment
uses: WalshyDev/cf-pages-await@v1
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: '9ddae624c98677d68d93df6e524a6061'
project: 'frontend'
commitHash: ${{ steps.push-changes.outputs.commit-hash }}
- name: Purge cache
if: github.ref == 'refs/heads/prod'
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
-H "Content-Type: application/json" \
--data '{"hosts": ["modrinth.com", "www.modrinth.com"]}' \
https://api.cloudflare.com/client/v4/zones/e39df17b9c4ef44cbce2646346ee6d33/purge_cache
-73
View File
@@ -1,73 +0,0 @@
name: Deploy frontend preview
on:
pull_request:
paths:
- 'apps/frontend/**/*'
- 'packages/ui/**/*'
- 'packages/utils/**/*'
- 'packages/assets/**/*'
- '**/wrangler.jsonc'
- '**/pnpm-*.yaml'
- '.github/workflows/frontend-preview.yml'
jobs:
deploy:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
uses: ./.github/workflows/frontend-deploy.yml
secrets: inherit
strategy:
matrix:
environment: [staging-preview, production-preview]
with:
environment: ${{ matrix.environment }}
comment:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
needs: deploy
steps:
- name: Download deployment URLs
uses: actions/download-artifact@v7
with:
pattern: deployment-url-*
merge-multiple: true
- name: Read deployment URLs
id: urls
run: |
STAGING_PREVIEW_URL=$(cat deployment-url-staging-preview.txt)
PRODUCTION_PREVIEW_URL=$(cat deployment-url-production-preview.txt)
echo "Production preview URL: $PRODUCTION_PREVIEW_URL"
echo "Staging preview URL: $STAGING_PREVIEW_URL"
echo "staging-preview-url=$STAGING_PREVIEW_URL" >> $GITHUB_OUTPUT
echo "production-preview-url=$PRODUCTION_PREVIEW_URL" >> $GITHUB_OUTPUT
- name: Find comment
if: github.event_name == 'pull_request'
uses: peter-evans/find-comment@v3
id: fc
with:
token: ${{ secrets.CROWDIN_GH_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
body-includes: Frontend previews
- name: Comment deploy URL on PR
if: github.event_name == 'pull_request'
uses: peter-evans/create-or-update-comment@v5
with:
token: ${{ secrets.CROWDIN_GH_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.fc.outputs.comment-id }}
body: |
## Frontend previews
Last deployed commit is [${{ github.sha }}](${{ github.event.pull_request.head.repo.html_url }}/commit/${{ github.sha }})
| Environment | URL |
|-------------|-----|
| staging | ${{ steps.urls.outputs.staging-preview-url }} |
| production | ${{ steps.urls.outputs.production-preview-url }} |
edit-mode: replace
+1 -1
View File
@@ -11,7 +11,7 @@ concurrency:
jobs:
pull_translations:
name: 'Pull translations from Crowdin'
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
if: github.ref == 'refs/heads/main'
concurrency:
group: i18n-pull:${{ github.ref }}
+1 -1
View File
@@ -18,7 +18,7 @@ concurrency:
jobs:
push_translations:
name: Push sources to Crowdin
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
if: github.ref == 'refs/heads/main'
concurrency:
group: i18n-push:${{ github.ref }}
-4
View File
@@ -7,15 +7,11 @@ on:
paths:
- .github/workflows/labrinth-docker.yml
- 'apps/labrinth/**'
- Cargo.toml
- Cargo.lock
pull_request:
types: [opened, synchronize]
paths:
- .github/workflows/labrinth-docker.yml
- 'apps/labrinth/**'
- Cargo.toml
- Cargo.lock
merge_group:
types: [checks_requested]
+22 -53
View File
@@ -19,15 +19,7 @@ on:
sign-windows-binaries:
description: Sign Windows binaries
type: boolean
default: false
required: false
environment:
description: Environment
type: choice
options:
- prod
- staging
default: prod
default: true
required: false
jobs:
@@ -36,60 +28,45 @@ jobs:
strategy:
fail-fast: false
matrix:
platform: [macos-latest, windows-latest, ubuntu-latest]
platform: [macos-latest, windows-latest, ubuntu-22.04]
include:
- platform: macos-latest
artifact-target-name: universal-apple-darwin
- platform: windows-latest
artifact-target-name: x86_64-pc-windows-msvc
- platform: ubuntu-latest
- platform: ubuntu-22.04
artifact-target-name: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.platform }}
steps:
- name: Check out code
- name: 📥 Check out code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Rust toolchain
- name: 🧰 Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
rustflags: ''
target: ${{ startsWith(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
- name: Install pnpm
- name: 🧰 Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
- name: 🧰 Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: Generate tauri-dev.conf.json
shell: bash
run: |
GIT_HASH=$(git rev-parse --short HEAD)
cat > apps/app/tauri-dev.conf.json <<EOF
{
"productName": "Modrinth App (dev-${GIT_HASH})",
"mainBinaryName": "Modrinth App (dev-${GIT_HASH})",
"identifier": "ModrinthApp-dev-${GIT_HASH}",
"bundle": {
"fileAssociations": []
}
}
EOF
- name: Install Linux build dependencies
- name: 🧰 Install Linux build dependencies
if: startsWith(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
- name: Setup Dasel
- name: 🧰 Setup Dasel
uses: jaxxstorm/action-install-gh-release@v2.1.0
with:
repo: TomWright/dasel
@@ -98,26 +75,24 @@ jobs:
rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
chmod: 0755
- name: Set application version and environment
- name: ⚙️ Set application version and environment
shell: bash
run: |
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
BUILD_ENVIRONMENT="${{ inputs.environment || 'prod' }}"
echo "Setting application version to $APP_VERSION"
echo "Using environment $BUILD_ENVIRONMENT"
dasel put -f apps/app/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version'
cp "packages/app-lib/.env.${BUILD_ENVIRONMENT}" packages/app-lib/.env
cp packages/app-lib/.env.prod packages/app-lib/.env
- name: Setup Turbo cache
- name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8
- name: Install dependencies
- name: 🧰 Install dependencies
run: pnpm install
- name: Set up Windows code signing
- name: ✍️ Set up Windows code signing
if: startsWith(matrix.platform, 'windows')
shell: bash
run: |
@@ -127,8 +102,8 @@ jobs:
dasel delete -f apps/app/tauri-release.conf.json 'bundle.windows.signCommand'
fi
- name: Build macOS app
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-dev.conf.json' }}
- name: 🔨 Build macOS app
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
if: startsWith(matrix.platform, 'macos')
env:
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
@@ -141,19 +116,19 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: Build Linux app
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json' }}
- name: 🔨 Build Linux app
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
if: startsWith(matrix.platform, 'ubuntu')
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: Build Windows app
- name: 🔨 Build Windows app
run: |
[System.Convert]::FromBase64String("$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64") | Set-Content -Path signer-client-cert.p12 -AsByteStream
$env:DIGICERT_ONE_SIGNER_CREDENTIALS = "$env:DIGICERT_ONE_SIGNER_API_KEY|$PWD\signer-client-cert.p12|$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD"
$env:JAVA_HOME = "$env:JAVA_HOME_17_X64"
${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles "nsis,updater"' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json --verbose --bundles "nsis,updater"' }}
$env:JAVA_HOME = "$env:JAVA_HOME_11_X64"
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis,updater'
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
if: startsWith(matrix.platform, 'windows')
env:
@@ -163,7 +138,7 @@ jobs:
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64 }}
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD }}
- name: Upload app bundles
- name: 📤 Upload app bundles
uses: actions/upload-artifact@v4
with:
name: App bundle (${{ matrix.artifact-target-name }})
@@ -171,13 +146,7 @@ jobs:
target/release/bundle/appimage/Modrinth App_*.AppImage*
target/release/bundle/deb/Modrinth App_*.deb*
target/release/bundle/rpm/Modrinth App-*.rpm*
target/release/bundle/appimage/Modrinth App (dev-*)_*.AppImage*
target/release/bundle/deb/Modrinth App (dev-*)_*.deb*
target/release/bundle/rpm/Modrinth App (dev-*)-*.rpm*
target/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz*
target/universal-apple-darwin/release/bundle/macos/Modrinth App (dev-*)*.app.tar.gz
target/universal-apple-darwin/release/bundle/dmg/Modrinth App (dev-*)*.dmg
target/universal-apple-darwin/release/bundle/dmg/Modrinth App_*.dmg*
target/release/bundle/nsis/Modrinth App_*-setup.exe*
target/release/bundle/nsis/Modrinth App (dev-*)_*-setup.exe*
target/release/bundle/nsis/Modrinth App_*-setup.nsis.zip*
+17 -27
View File
@@ -1,43 +1,47 @@
name: Modrinth App release
on:
workflow_run:
workflows: ['Modrinth App build']
types: [completed]
workflow_dispatch:
inputs:
version-tag:
description: Version tag to release to the wide public
type: string
required: true
release-notes:
description: Release notes to include in the Tauri version manifest
default: A new release of the Modrinth App is available!
type: string
required: true
jobs:
release:
name: Release Modrinth App
if: >-
github.event.workflow_run.conclusion == 'success' &&
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: ubuntu-latest
env:
VERSION_TAG: ${{ github.event.workflow_run.head_branch }}
LINUX_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-unknown-linux-gnu)
WINDOWS_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-pc-windows-msvc)
MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME: App bundle (universal-apple-darwin)
LAUNCHER_FILES_BUCKET_BASE_URL: https://launcher-files.modrinth.com
steps:
- name: 📥 Check out code
uses: actions/checkout@v4
- name: 📥 Download Modrinth App artifacts
uses: dawidd6/action-download-artifact@v11
with:
workflow: theseus-build.yml
workflow_conclusion: success
event: push
branch: ${{ env.VERSION_TAG }}
branch: ${{ inputs.version-tag }}
use_unzip: true
- name: 🛠️ Generate version manifest
env:
VERSION_TAG: ${{ inputs.version-tag }}
RELEASE_NOTES: ${{ inputs.release-notes }}
run: |
# Reference: https://tauri.app/plugin/updater/#server-support
jq -nc \
--arg versionTag "${VERSION_TAG#v}" \
--arg releaseNotes "See the full changelog at https://modrinth.com/news/changelog" \
--arg releaseNotes "$RELEASE_NOTES" \
--rawfile macOsAarch64UpdateArtifactSignature "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz.sig" \
--rawfile macOsX64UpdateArtifactSignature "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz.sig" \
--rawfile linuxX64UpdateArtifactSignature "${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/appimage/Modrinth App_${VERSION_TAG#v}_amd64.AppImage.tar.gz.sig" \
@@ -79,6 +83,7 @@ jobs:
- name: 📤 Upload release artifacts
env:
VERSION_TAG: ${{ inputs.version-tag }}
AWS_ACCESS_KEY_ID: ${{ secrets.LAUNCHER_FILES_BUCKET_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.LAUNCHER_FILES_BUCKET_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ secrets.LAUNCHER_FILES_BUCKET_NAME }}
@@ -111,18 +116,3 @@ jobs:
done
aws s3 cp updates.json "s3://${AWS_BUCKET}"
- name: 🏷️ Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${VERSION_TAG#v}"
gh release create "$VERSION_TAG" \
--title "Modrinth App ${VERSION}" \
--notes "See the full changelog at https://modrinth.com/news/changelog" \
"${WINDOWS_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/nsis/Modrinth App_${VERSION}_x64-setup.exe" \
"${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/dmg/Modrinth App_${VERSION}_universal.dmg" \
"${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/appimage/Modrinth App_${VERSION}_amd64.AppImage" \
"${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/deb/Modrinth App_${VERSION}_amd64.deb" \
"${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/rpm/Modrinth App-${VERSION}-1.x86_64.rpm"
+6 -25
View File
@@ -11,18 +11,13 @@ on:
jobs:
build:
name: Lint and Test
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
env:
# Ensure pnpm output is colored in GitHub Actions logs
FORCE_COLOR: 3
# Make cargo nextest successfully ignore projects without tests
NEXTEST_NO_TESTS: pass
# Fail on warnings in CI
# (but don't do this in the root `Cargo.toml`,
# since we don't want warnings to become errors
# while developing)
RUSTFLAGS: -Dwarnings
steps:
- name: 📥 Check out code
@@ -70,37 +65,23 @@ jobs:
- name: 🧰 Install dependencies
run: pnpm install
- name: ⚙️ Set app environment
working-directory: packages/app-lib
run: cp .env.staging .env
# check if labrinth tests will actually run (cache miss)
- name: 🔍 Check if labrinth tests need to run
id: check-labrinth
run: |
LABRINTH_TEST_STATUS=$(pnpm turbo run test --filter=@modrinth/labrinth --dry-run=json | jq -r '.tasks[] | select(.task == "test") | .cache.status')
echo "Labrinth test cache status: $LABRINTH_TEST_STATUS"
if [ "$LABRINTH_TEST_STATUS" = "HIT" ]; then
echo "needs_services=false" >> $GITHUB_OUTPUT
else
echo "needs_services=true" >> $GITHUB_OUTPUT
fi
- name: ⚙️ Start services
if: steps.check-labrinth.outputs.needs_services == 'true'
run: docker compose up --wait
- name: ⚙️ Setup labrinth environment and database
if: steps.check-labrinth.outputs.needs_services == 'true'
working-directory: apps/labrinth
run: |
cp .env.local .env
sqlx database setup
- name: ⚙️ Set app environment
working-directory: packages/app-lib
run: cp .env.staging .env
- name: 🔍 Lint and test
run: pnpm run ci
- name: 🔍 Verify intl:extract has been run
run: |
pnpm turbo run intl:extract --force
pnpm intl:extract
git diff --exit-code --color */*/src/locales/en-US/index.json
-15
View File
@@ -27,7 +27,6 @@ node_modules
.idea/*
!.idea/code.iml
!.idea/gradle.xml
!.idea/icon.svg
!.idea/modules.xml
!.idea/vcs.xml
@@ -64,17 +63,3 @@ generated
app-playground-data/*
.astro
.claude/*
!.claude/skills/
.letta
# labrinth demo fixtures
apps/labrinth/fixtures/demo
*storybook.log
storybook-static
.wrangler/
# frontend robots.txt
apps/frontend/src/public/robots.txt
+1 -7
View File
@@ -11,15 +11,9 @@
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/path-util/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/modrinth-log/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/modrinth-maxmind/examples" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/modrinth-maxmind/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/modrinth-util/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/muralpay/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
</module>
-4
View File
@@ -1,4 +0,0 @@
<svg width="512" height="514" viewBox="0 0 512 514" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M503.16 323.56C514.55 281.47 515.32 235.91 503.2 190.76C466.57 54.2299 326.04 -26.8001 189.33 9.77991C83.8101 38.0199 11.3899 128.07 0.689941 230.47H43.99C54.29 147.33 113.74 74.7298 199.75 51.7098C306.05 23.2598 415.13 80.6699 453.17 181.38L411.03 192.65C391.64 145.8 352.57 111.45 306.3 96.8198L298.56 140.66C335.09 154.13 364.72 184.5 375.56 224.91C391.36 283.8 361.94 344.14 308.56 369.17L320.09 412.16C390.25 383.21 432.4 310.3 422.43 235.14L464.41 223.91C468.91 252.62 467.35 281.16 460.55 308.07L503.16 323.56Z" fill="#00af5c"/>
<path d="M321.99 504.22C185.27 540.8 44.7501 459.77 8.11011 323.24C3.84011 307.31 1.17 291.33 0 275.46H43.27C44.36 287.37 46.4699 299.35 49.6799 311.29C53.0399 323.8 57.45 335.75 62.79 347.07L101.38 323.92C98.1299 316.42 95.39 308.6 93.21 300.47C69.17 210.87 122.41 118.77 212.13 94.7601C229.13 90.2101 246.23 88.4401 262.93 89.1501L255.19 133C244.73 133.05 234.11 134.42 223.53 137.25C157.31 154.98 118.01 222.95 135.75 289.09C136.85 293.16 138.13 297.13 139.59 300.99L188.94 271.38L174.07 231.95L220.67 184.08L279.57 171.39L296.62 192.38L269.47 219.88L245.79 227.33L228.87 244.72L237.16 267.79C237.16 267.79 253.95 285.63 253.98 285.64L277.7 279.33L294.58 260.79L331.44 249.12L342.42 273.82L304.39 320.45L240.66 340.63L212.08 308.81L162.26 338.7C187.8 367.78 226.2 383.93 266.01 380.56L277.54 423.55C218.13 431.41 160.1 406.82 124.05 361.64L85.6399 384.68C136.25 451.17 223.84 484.11 309.61 461.16C371.35 444.64 419.4 402.56 445.42 349.38L488.06 364.88C457.17 431.16 398.22 483.82 321.99 504.22Z" fill="#00af5c"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

+1 -19
View File
@@ -11,23 +11,5 @@
"source.fixAll.eslint": "explicit",
"source.organizeImports": "always"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
}
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
-1
View File
@@ -1 +0,0 @@
See [CLAUDE.md](./CLAUDE.md) for all project instructions and guidelines.
-109
View File
@@ -1,109 +0,0 @@
# Modrinth Monorepo
This is the Modrinth monorepo — it contains all Modrinth projects, both frontend and backend. When entering a project, either to edit or analyse, you should read it's CLAUDE.md.
## Architecture
- **Monorepo tooling:** [Turborepo](https://turbo.build/) (`turbo.jsonc`) + [pnpm workspaces](https://pnpm.io/workspaces) (`pnpm-workspace.yaml`)
- **Frontend:** Vue 3 / Nuxt 3, Tailwind CSS v3
- **Backend:** Rust (Labrinth API), Postgres, Clickhouse
- **Indentation:** Use TAB everywhere, never spaces
### Apps (`apps/`)
| App | Description |
| ----------------- | ------------------------------ |
| `frontend` | Main Modrinth website (Nuxt 3) |
| `app-frontend` | Desktop/app frontend (Vue 3) |
| `app` | Desktop/app shell (Tauri) |
| `app-playground` | Testing playground for app |
| `labrinth` | Backend API service |
| `daedalus_client` | Daedalus client implementation |
| `docs` | Documentation site (Astro) |
### Packages (`packages/`)
| Package | Description |
| ------------------ | ----------------------------------------------------- |
| `ui` | Shared Vue component library (`@modrinth/ui`) |
| `assets` | Styling and auto-generated icons (`@modrinth/assets`) |
| `api-client` | API client for Nuxt, Tauri, and Node/browser |
| `app-lib` | Shared app library |
| `blog` | Blog system and changelog data |
| `utils` | Shared utility functions (mostly deprecated) |
| `moderation` | Moderation utilities |
| `daedalus` | Daedalus protocol |
| `tooling-config` | ESLint, Prettier, TypeScript configs |
| `ariadne` | Analytics library |
| `modrinth-log` | Logging utilities |
| `modrinth-maxmind` | MaxMind GeoIP |
| `modrinth-util` | General utilities |
| `muralpay` | Payment processing |
| `path-util` | Path utilities |
| `sqlx-tracing` | SQLx query tracing |
## Pre-PR Commands
Run these from the **root** folder before opening a pull request - do not run these after each prompt the user gives you, only run when asked, ask the user a question if they want to run it if the user indicates that they are about to create a pull request.
- **Website:** `pnpm prepr:frontend:web`
- **App frontend:** `pnpm prepr:frontend:app`
- **Frontend libs:** `pnpm prepr:frontend:lib`
- **All frontend (app+web):** `pnpm prepr`
- **Labrinth (backend):** See `apps/labrinth/CLAUDE.md`
The website and app `prepr` commands
## Dev Commands
- **Website:** `pnpm web:dev` (copy `.env` template in `apps/frontend/` first)
- **App:** `pnpm app:dev` (copy `.env` template in `packages/app-lib/` first)
- **Storybook (packages/ui):** `pnpm storybook`
## Project-Specific Instructions
Each project may have its own `CLAUDE.md` with detailed instructions:
- [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
## Code Guidelines
### Comments
- DO NOT use "heading" comments like: `=== Helper methods ===`.
- Use doc comments, but avoid inline comments unless ABSOLUTELY necessary for clarity. Code should aim to be self documenting!
## Bash Guidelines
### Output handling
- DO NOT pipe output through `head`, `tail`, `less`, or `more`
- NEVER use `| head -n X` or `| tail -n X` to truncate output
- IMPORTANT: Run commands directly without pipes when possible
- IMPORTANT: If you need to limit output, use command-specific flags (e.g. `git log -n 10` instead of `git log | head -10`)
- ALWAYS read the full output — never pipe through filters
### General
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to
- For Frontend, when doing lint checks, only use the `prepr` commands, do not use `typecheck` or `tsc` etc.
- Types in `@modrinth/utils` are considered highly outdated, if a component needs them, check if you can switch said component to use types from `packages/api-client`
## Edit Tool - Whitespace Handling (CLAUDE ONLY)
The Read tool uses `→` to mark where line numbers end and file content begins.
**Rule:** Copy the EXACT whitespace that appears after the `→` marker.
- Whatever appears between `→` and the code text is what's actually in the file
- That whitespace must be used EXACTLY in Edit tool's old_string
- Don't count arrows, don't interpret - just copy what's after the `→`
**Example:**
14→ private byte tag;
For Edit, use: ` private byte tag;` (copy everything after →, including the two tabs)
**If Edit fails:** Stop and explain the problem. Do not attempt sed/awk/bash workarounds.
**IMPORTANT**: Trust the Read tool output. Copy what's after `→` into Edit immediately. DO NOT verify with sed/od/grep first - that's wasting time and the instructions already tell you to stop if Edit fails, not to pre-verify.
## Standards
Standards available at the @standards/ folder.
+1 -9
View File
@@ -8,14 +8,6 @@ For detailed information, consult each package's COPYING.md, LICENSE.txt, or LIC
The use of Modrinth branding elements, including but not limited to the wrench-in-labyrinth logo, the landing image, and any variations thereof, is strictly prohibited without explicit written permission from Rinth, Inc. This includes trademarks, logos, or other branding elements.
> All rights reserved. © 2020-2025 Rinth, Inc.
This includes, but may not be limited to, the following files:
- .idea/icon.svg
- .github/api_cover.png
- .github/app_cover.png
- .github/monorepo_cover.png
- .github/web_cover.png
All rights reserved. © 2020-2024 Rinth, Inc.
If you fork this repository, you must remove all Modrinth branding assets from your fork.
Generated
+1260 -2650
View File
File diff suppressed because it is too large Load Diff
+114 -155
View File
@@ -1,196 +1,160 @@
[workspace]
resolver = "2"
members = [
"apps/app",
"apps/app-playground",
"apps/daedalus_client",
"apps/labrinth",
"packages/app-lib",
"packages/ariadne",
"packages/daedalus",
"packages/labrinth-derive",
"packages/modrinth-log",
"packages/modrinth-maxmind",
"packages/modrinth-util",
"packages/path-util",
"apps/app",
"apps/app-playground",
"apps/daedalus_client",
"apps/labrinth",
"packages/app-lib",
"packages/ariadne",
"packages/daedalus",
]
[workspace.package]
edition = "2024"
rust-version = "1.90.0"
repository = "https://github.com/modrinth/code"
[workspace.dependencies]
actix-cors = "0.7.1"
actix-files = "0.6.8"
actix-http = "3.11.2"
actix-files = "0.6.6"
actix-http = "3.11.0"
actix-multipart = "0.7.2"
actix-rt = "2.11.0"
actix-rt = "2.10.0"
actix-web = "4.11.0"
actix-web-prom = "0.10.0"
actix-ws = "0.3.0"
arc-swap = "1.7.1"
argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" }
async-compression = { version = "0.4.32", default-features = false }
async-minecraft-ping = { path = "packages/async-minecraft-ping" }
async_zip = "0.0.17"
async-compression = { version = "0.4.27", default-features = false }
async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
] }
async-trait = "0.1.89"
async-tungstenite = { version = "0.31.0", default-features = false, features = [
"futures-03-sink"
"runtime-tokio-hyper-rustls",
] }
async-trait = "0.1.88"
async-tungstenite = { version = "0.30.0", default-features = false, features = ["futures-03-sink"] }
async-walkdir = "2.1.0"
async_zip = "0.0.18"
base64 = "0.22.1"
bitflags = "2.9.4"
bytemuck = "1.24.0"
bitflags = "2.9.1"
bytemuck = "1.23.1"
bytes = "1.10.1"
censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.42"
cidre = { version = "0.11.3", default-features = false, features = [
"macos_15_0"
] }
clap = "4.5.48"
clickhouse = "0.14.0"
color-eyre = "0.6.5"
chrono = "0.4.41"
clap = "4.5.43"
clickhouse = "0.13.3"
color-thief = "0.2.2"
const_format = "0.2.34"
console-subscriber = "0.4.1"
daedalus = { path = "packages/daedalus" }
darling = { version = "0.23" }
dashmap = "6.1.0"
data-url = "0.3.2"
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "db5fb00b036ecc8fe5f18853c559b745ffe47bde", version = "0.22.1" }
data-url = "0.3.1"
deadpool-redis = "0.22.0"
derive_more = "2.0.1"
directories = "6.0.0"
dirs = "6.0.0"
discord-rich-presence = "1.0.0"
discord-rich-presence = "0.2.5"
dotenv-build = "0.1.1"
dotenvy = "0.15.7"
dunce = "1.0.5"
either = "1.15.0"
elasticsearch = "9.1.0-alpha.1"
encoding_rs = "0.8.35"
enumset = "1.1.10"
eyre = "0.6.12"
flate2 = "1.1.4"
enumset = "1.1.7"
flate2 = "1.1.2"
fs4 = { version = "0.13.1", default-features = false }
futures = "0.3.31"
futures = { version = "0.3.31", default-features = false }
futures-util = "0.3.31"
heck = "0.5.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
hyper = "1.7.0"
hyper = "1.6.0"
hyper-rustls = { version = "0.27.7", default-features = false, features = [
"aws-lc-rs",
"http1",
"native-tokio",
"tls12",
"http1",
"native-tokio",
"ring",
"tls12",
] }
hyper-util = "0.1.17"
iana-time-zone = "0.1.64"
image = { version = "0.25.8", default-features = false, features = ["rayon"] }
indexmap = "2.11.4"
hyper-util = "0.1.16"
iana-time-zone = "0.1.63"
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
indexmap = "2.10.0"
indicatif = "0.18.0"
itertools = "0.14.0"
jemalloc_pprof = "0.8.1"
json-patch = { version = "4.1.0", default-features = false }
lettre = { version = "0.11.19", default-features = false, features = [
"aws-lc-rs",
"builder",
"hostname",
"pool",
"rustls",
"rustls-native-certs",
"smtp-transport",
"tokio1",
"tokio1-rustls",
json-patch = { version = "4.0.0", default-features = false }
lettre = { version = "0.11.18", default-features = false, features = [
"builder",
"hostname",
"pool",
"ring",
"rustls",
"rustls-native-certs",
"smtp-transport",
] }
maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.30.0", default-features = false }
modrinth-log = { path = "packages/modrinth-log" }
modrinth-util = { path = "packages/modrinth-util" }
muralpay = { path = "packages/muralpay" }
meilisearch-sdk = { version = "0.29.1", default-features = false }
murmur2 = "0.1.0"
native-dialog = "0.9.2"
native-dialog = "0.9.0"
notify = { version = "8.2.0", default-features = false }
notify-debouncer-mini = { version = "0.7.0", default-features = false }
p256 = "0.13.2"
parking_lot = "0.12.5"
paste = "1.0.15"
path-util = { path = "packages/path-util" }
phf = { version = "0.13.1", features = ["macros"] }
png = "0.18.0"
proc-macro2 = { version = "1.0" }
phf = { version = "0.12.1", features = ["macros"] }
png = "0.17.16"
prometheus = "0.14.0"
quartz_nbt = "0.2.9"
quick-xml = "0.38.3"
quote = { version = "1.0" }
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
redis = "0.32.7"
regex = "1.12.2"
reqwest = { version = "0.12.24", default-features = false }
quick-xml = "0.38.1"
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
redis = "0.32.4"
regex = "1.11.1"
reqwest = { version = "0.12.22", default-features = false }
rgb = "0.8.52"
rust_decimal = { version = "1.39.0", features = [
"serde-with-float",
"serde-with-str"
] }
rust_decimal = { version = "1.37.2", features = ["serde-with-float", "serde-with-str"] }
rust_iso3166 = "0.1.14"
rust-s3 = { version = "0.37.0", default-features = false, features = [
"fail-on-err",
"tags",
"tokio-rustls-tls",
rust-s3 = { version = "0.35.1", default-features = false, features = [
"fail-on-err",
"tags",
"tokio-rustls-tls",
] }
rustls = "0.23.32"
rusty-money = "0.4.1"
secrecy = "0.10.3"
sentry = { version = "0.45.0", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
"panic",
"reqwest",
"rustls",
sentry = { version = "0.42.0", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
"panic",
"reqwest",
"rustls",
] }
serde = "1.0.228"
serde_bytes = "0.11.19"
sentry-actix = "0.42.0"
serde = "1.0.219"
serde_bytes = "0.11.17"
serde_cbor = "0.11.2"
serde_ini = "0.2.0"
serde_json = "1.0.145"
serde_with = "3.15.0"
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
serde_json = "1.0.142"
serde_with = "3.14.0"
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
shlex = "1.3.0"
spdx = "0.12.0"
spdx = "0.10.9"
sqlx = { version = "0.8.6", default-features = false }
sqlx-tracing = { path = "packages/sqlx-tracing" }
strum = "0.27.2"
syn = { version = "2.0" }
sysinfo = { version = "0.37.2", default-features = false }
sysinfo = { version = "0.36.1", default-features = false }
tar = "0.4.44"
tauri = "2.8.5"
tauri-build = "2.4.1"
tauri-plugin-deep-link = "2.4.3"
tauri-plugin-dialog = "2.4.0"
tauri-plugin-http = "2.5.7"
tauri-plugin-opener = "2.5.0"
tauri-plugin-os = "2.3.1"
tauri-plugin-single-instance = "2.3.4"
tauri = "2.7.0"
tauri-build = "2.3.1"
tauri-plugin-deep-link = "2.4.1"
tauri-plugin-dialog = "2.3.2"
tauri-plugin-http = "2.5.1"
tauri-plugin-opener = "2.4.0"
tauri-plugin-os = "2.3.0"
tauri-plugin-single-instance = "2.3.2"
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
"rustls-tls",
"zip",
"rustls-tls",
"zip",
] }
tauri-plugin-window-state = "2.4.0"
tempfile = "3.23.0"
tempfile = "3.20.0"
theseus = { path = "packages/app-lib" }
thiserror = "2.0.17"
thiserror = "2.0.12"
tikv-jemalloc-ctl = "0.6.0"
tikv-jemallocator = "0.6.0"
tokio = "1.47.1"
@@ -198,32 +162,23 @@ tokio-stream = "0.1.17"
tokio-util = "0.7.16"
totp-rs = "5.7.0"
tracing = "0.1.41"
tracing-actix-web = { version = "0.7.19", default-features = false }
tracing-ecs = "0.5.0"
tracing-actix-web = "0.7.19"
tracing-error = "0.2.1"
tracing-subscriber = "0.3.20"
typed-path = "0.12.0"
url = "2.5.7"
tracing-subscriber = "0.3.19"
url = "2.5.4"
urlencoding = "2.1.3"
utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "decimal"] }
utoipa-actix-web = { version = "0.1.2" }
utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web", "vendored"] }
uuid = "1.18.1"
uuid = "1.17.0"
validator = "0.20.0"
webp = { version = "0.3.1", default-features = false }
webview2-com = "0.38.0" # Should be updated in lockstep with wry
whoami = "1.6.1"
windows = "=0.61.3" # Locked on 0.61 until we can update windows-core to 0.62
windows-core = "=0.61.2" # Locked on 0.61 until webview2-com updates to 0.62
webp = { version = "0.3.0", default-features = false }
whoami = "1.6.0"
winreg = "0.55.0"
woothee = "0.13.0"
yaserde = "0.12.0"
zbus = "5.11.0"
zip = { version = "6.0.0", default-features = false, features = [
"bzip2",
"deflate",
"deflate64",
"zstd",
zip = { version = "4.3.0", default-features = false, features = [
"bzip2",
"deflate",
"deflate64",
"zstd",
] }
zxcvbn = "3.1.0"
@@ -247,7 +202,7 @@ manual_assert = "warn"
manual_instant_elapsed = "warn"
manual_is_variant_and = "warn"
manual_let_else = "warn"
map_unwrap_or = "allow"
map_unwrap_or = "warn"
match_bool = "warn"
needless_collect = "warn"
negative_feature_names = "warn"
@@ -258,24 +213,28 @@ redundant_clone = "warn"
redundant_feature_names = "warn"
redundant_type_annotations = "warn"
todo = "warn"
too_many_arguments = "allow"
uninlined_format_args = "warn"
unnested_or_patterns = "warn"
wildcard_dependencies = "warn"
[profile.dev.package.sqlx-macros]
opt-level = 3
[workspace.lints.rust]
# Turn warnings into errors by default
warnings = "deny"
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "f2ce0b0" }
# Optimize for speed and reduce size on release builds
[profile.release]
opt-level = "s" # Optimize for binary size
strip = true # Remove debug symbols
lto = true # Enables link to optimizations
panic = "abort" # Strip expensive panic clean-up logic
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
opt-level = "s" # Optimize for binary size
strip = true # Remove debug symbols
lto = true # Enables link to optimizations
panic = "abort" # Strip expensive panic clean-up logic
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
# Specific profile for labrinth production builds
[profile.release-labrinth]
inherits = "release"
strip = false # Keep debug symbols for Sentry
panic = "unwind" # Don't exit the whole app on panic in production
panic = "unwind" # Don't exit the whole app on panic in production
[profile.dev.package.sqlx-macros]
opt-level = 3
-23
View File
@@ -1,23 +0,0 @@
[files]
extend-exclude = [
"**/src/locales/",
"apps/frontend/",
"patches/",
"packages/utils/",
"packages/ui/",
"packages/blog/",
# contains licenses like `CC-BY-ND-4.0`
"packages/moderation/src/data/stages/license.ts",
# contains payment card IDs like `IY1VMST1MOXS` which are flagged
"apps/labrinth/src/queue/payouts/mod.rs",
]
[default.extend-words]
# Terms Of Use in `tou-link`
tou = "tou"
# Google Ad Manager
gam = "gam"
# short for "constants"
consts = "consts"
# short for "Copy"
Cpy = "Cpy"
+6 -2
View File
@@ -1,9 +1,13 @@
# Copying
The source code of Modrinth App's frontend is licensed under the GNU General Public License, Version 3 only, which is provided in the file [LICENSE](./LICENSE). However, some files listed below are licensed under a different license.
The source code of the theseus repository is licensed under the GNU General Public License, Version 3 only, which is provided in the file [LICENSE](./LICENSE). However, some files listed below are licensed under a different license.
## Modrinth logo
The use of Modrinth branding elements, including but not limited to the wrench-in-labyrinth logo, the landing image, and any variations thereof, is strictly prohibited without explicit written permission from Rinth, Inc. This includes trademarks, logos, or other branding elements.
> All rights reserved. © 2020-2025 Rinth, Inc.
> All rights reserved. © 2020-2023 Rinth, Inc.
This includes, but may not be limited to, the following files:
- theseus_gui/src-tauri/icons/\*
+9 -13
View File
@@ -13,43 +13,40 @@
"test": "vue-tsc --noEmit"
},
"dependencies": {
"@modrinth/api-client": "workspace:^",
"@geometrically/minecraft-motd-parser": "^1.1.4",
"@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*",
"@sentry/vue": "^8.27.0",
"@sfirew/minecraft-motd-parser": "^1.1.6",
"@tanstack/vue-query": "^5.90.7",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-http": "~2.5.7",
"@tauri-apps/plugin-http": "^2.5.0",
"@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2",
"@types/three": "^0.172.0",
"@vintl/vintl": "^4.4.1",
"@vueuse/core": "^11.1.0",
"dayjs": "^1.11.10",
"floating-vue": "^5.2.2",
"fuse.js": "^6.6.2",
"intl-messageformat": "^10.7.7",
"ofetch": "^1.3.4",
"pinia": "^3.0.0",
"pinia": "^2.1.7",
"posthog-js": "^1.158.2",
"three": "^0.172.0",
"vite-svg-loader": "^5.1.0",
"vue": "^3.5.13",
"vue-i18n": "^10.0.0",
"vue-router": "^4.6.0",
"vue-multiselect": "3.0.0",
"vue-router": "4.3.0",
"vue-virtual-scroller": "v2.0.0-beta.8"
},
"devDependencies": {
"@modrinth/tooling-config": "workspace:*",
"@eslint/compat": "^1.1.1",
"@formatjs/cli": "^6.2.12",
"@modrinth/tooling-config": "workspace:*",
"@nuxt/eslint-config": "^0.5.6",
"@taijased/vue-render-tracker": "^1.0.7",
"@vitejs/plugin-vue": "^6.0.3",
"@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.19",
"eslint": "^9.9.1",
"eslint-plugin-turbo": "^2.5.4",
@@ -58,8 +55,7 @@
"sass": "^1.74.1",
"tailwindcss": "^3.4.4",
"typescript": "^5.5.4",
"vite": "^6.0.0",
"vue-component-type-helpers": "^3.1.8",
"vite": "^5.4.6",
"vue-tsc": "^2.1.6"
},
"packageManager": "pnpm@9.4.0",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:serif="http://www.serif.com/" version="1.1" viewBox="0 0 1793 199">
<g>
<g id="Layer_1">
<g id="green" fill="var(--color-brand)">
<path d="M1184.1,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"/>
<path d="M1291.1,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"/>
<path d="M1357.2,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"/>
<path d="M1460,165.3l-40.8-95.1h23.2l35.1,83.9h-11.4l36.3-83.9h21.4l-40.8,95.1h-23Z"/>
<path d="M1579.6,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"/>
<path d="M1645.7,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"/>
<path d="M1749.9,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"/>
<g>
<path d="M9.8,143l63.4-38.1-5.8-15.3,18.1-18.6,22.9-4.9,6.6,8.2-10.6,10.7-9.2,2.9-6.6,6.8,3.2,9,6.5,6.9,9.2-2.5,6.6-7.2,14.3-4.5,4.3,9.6-14.8,18.1-24.8,7.8-11.1-12.4-63.6,38.2c-3-3.9-6.5-9.4-8.8-14.7ZM192.8,65.4l-50.4,13.6c2.8,7.4,3.7,11.7,4.5,16.5l50.3-13.6c-.8-5.4-2.2-10.8-4.4-16.5Z" fill-rule="evenodd"/>
<path d="M17.3,106.5c3.6,42.1,38.9,75.2,82,75.2s60.7-18.9,74-46.3l16.4,5.7c-15.8,34.1-50.3,57.9-90.4,57.9S3.6,158.2,0,106.5h17.3ZM.3,89.4C5.3,39.2,47.8,0,99.3,0s99.5,44.6,99.5,99.5-1.1,17.4-3.3,25.5l-16.3-5.7c1.6-6.5,2.4-13.1,2.4-19.8,0-45.4-36.9-82.3-82.3-82.3S22.6,48.7,17.6,89.4H.3Z" fill-rule="evenodd"/>
<path d="M99,51.6c-26.4,0-47.9,21.5-47.9,48s21.5,48,48,48,2.7,0,4-.2l4.8,16.8c-2.9.4-5.8.6-8.8.6-36,0-65.2-29.2-65.2-65.2S63.1,34.4,99,34.4s1.8,0,2.7,0l-2.7,17.1ZM118.6,37.4c26.4,8.3,45.6,33,45.6,62.2s-16.4,50.2-39.8,60l-4.8-16.7c16.2-7.7,27.4-24.2,27.4-43.3s-13-38.1-31.1-44.9l2.7-17.2Z" fill-rule="evenodd"/>
</g>
</g>
<g id="black" fill="currentColor">
<path d="M354.8,69.2c12,0,21.7,3.4,28.6,10.4,7,7.2,10.6,17.5,10.6,31.5v54.8h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,5-6.8,12.2-6.8,21.3v48.5h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,4.8-6.8,12-6.8,21.3v48.5h-22.4v-95.6h21.3v12.2c3.6-4.3,8.1-7.5,13.4-9.8,5.4-2.3,11.3-3.4,17.9-3.4s13.6,1.3,19.2,3.9c5.5,2.9,9.8,6.8,13.1,12,3.9-5,8.9-8.9,15.2-11.8,6.3-2.7,13.1-4.1,20.6-4.1ZM466,167.2c-9.7,0-18.4-2.1-26.1-6.3-7.6-4-13.8-10.1-18.1-17.5-4.5-7.3-6.6-15.7-6.6-25.2s2.1-17.9,6.6-25.2c4.3-7.4,10.6-13.4,18.1-17.4,7.7-4.1,16.5-6.3,26.1-6.3s18.6,2.1,26.3,6.3c7.7,4.1,13.8,10,18.3,17.4,4.3,7.3,6.4,15.7,6.4,25.2s-2.1,17.9-6.4,25.2c-4.5,7.5-10.6,13.4-18.3,17.5-7.7,4.1-16.5,6.3-26.3,6.3h0ZM466,148c8.2,0,15-2.7,20.4-8.2,5.4-5.5,8.1-12.7,8.1-21.7s-2.7-16.1-8.1-21.7c-5.4-5.5-12.2-8.2-20.4-8.2s-15,2.7-20.2,8.2c-5.4,5.5-8.1,12.7-8.1,21.7s2.7,16.1,8.1,21.7c5.2,5.5,12,8.2,20.2,8.2ZM631.5,33.1v132.8h-21.5v-12.3c-3.7,4.4-8.3,7.9-13.6,10.2-5.5,2.3-11.5,3.4-18.1,3.4s-17.4-2-24.7-6.1c-7.3-4.1-13.2-9.8-17.4-17.4-4.1-7.3-6.3-15.9-6.3-25.6s2.1-18.3,6.3-25.6c4.1-7.3,10-13.1,17.4-17.2,7.3-4.1,15.6-6.1,24.7-6.1s12.2,1.1,17.4,3.2c5.2,2.1,9.8,5.4,13.4,9.7v-49h22.4ZM581.1,148c5.4,0,10.2-1.3,14.5-3.8,4.3-2.3,7.7-5.9,10.2-10.4,2.5-4.5,3.8-9.8,3.8-15.7s-1.3-11.3-3.8-15.7c-2.5-4.5-5.9-8.1-10.2-10.6-4.3-2.3-9.1-3.6-14.5-3.6s-10.2,1.3-14.5,3.6c-4.3,2.5-7.7,6.1-10.2,10.6-2.5,4.5-3.8,9.8-3.8,15.7s1.3,11.3,3.8,15.7c2.5,4.5,5.9,8.1,10.2,10.4,4.3,2.5,9.1,3.8,14.5,3.8ZM681.6,84.3c6.4-10,17.7-15,34-15v21.3c-1.7-.3-3.4-.5-5.2-.5-8.8,0-15.6,2.5-20.4,7.5-4.8,5.2-7.3,12.5-7.3,22v46.4h-22.4v-95.6h21.3v14h0ZM734.1,70.3h22.4v95.6h-22.4v-95.6ZM745.4,54.6c-4.1,0-7.5-1.3-10.2-3.9-2.7-2.4-4.2-5.9-4.1-9.5,0-3.8,1.4-7,4.1-9.7,2.7-2.5,6.1-3.8,10.2-3.8s7.5,1.3,10.2,3.6c2.7,2.5,4.1,5.5,4.1,9.3s-1.3,7.2-3.9,9.8c-2.7,2.7-6.3,4.1-10.4,4.1ZM839.5,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4v-95.6h21.3v12.3c3.8-4.5,8.4-7.7,14-10,5.5-2.3,12-3.4,19-3.4ZM964.8,160.7c-2.8,2.2-6,3.9-9.5,4.8-3.9,1.1-7.9,1.6-12,1.6-10.6,0-18.6-2.7-24.3-8.2-5.7-5.5-8.6-13.4-8.6-24v-46h-15.7v-17.9h15.7v-21.8h22.4v21.8h25.6v17.9h-25.6v45.5c0,4.7,1.1,8.2,3.4,10.6,2.3,2.5,5.5,3.8,9.8,3.8s9.1-1.3,12.5-3.9l6.3,15.9ZM1036.9,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4V33.1h22.4v48.3c3.8-3.9,8.2-7,13.8-9.1,5.4-2,11.5-3,18.1-3Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

@@ -2,8 +2,6 @@
@tailwind components;
@tailwind utilities;
@import '@modrinth/ui/src/styles/tailwind-utilities.css';
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: normal;
@@ -85,6 +83,10 @@ a {
}
}
input {
border: none !important;
}
.badge {
display: flex;
border-radius: var(--radius-md);
@@ -155,23 +157,4 @@ 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';
@@ -8,27 +8,20 @@ import {
SearchIcon,
StopCircleIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import {
DropdownSelect,
formatLoader,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
import { Button, DropdownSelect, injectNotificationManager } from '@modrinth/ui'
import { formatCategoryHeader } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { duplicate, remove } from '@/helpers/profile.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const props = defineProps({
instances: {
type: Array,
@@ -128,50 +121,40 @@ const handleOptionsClick = async (args) => {
}
}
const state = useStorage(
`${props.label}-grid-display-state`,
{
group: 'Group',
sortBy: 'Name',
},
localStorage,
{ mergeDefaults: true },
)
const search = ref('')
const group = ref('Group')
const sortBy = ref('Name')
const filteredResults = computed(() => {
const { group = 'Group', sortBy = 'Name' } = state.value
const instances = props.instances.filter((instance) => {
return instance.name.toLowerCase().includes(search.value.toLowerCase())
})
if (sortBy === 'Name') {
if (sortBy.value === 'Name') {
instances.sort((a, b) => {
return a.name.localeCompare(b.name)
})
}
if (sortBy === 'Game version') {
if (sortBy.value === 'Game version') {
instances.sort((a, b) => {
return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
})
}
if (sortBy === 'Last played') {
if (sortBy.value === 'Last played') {
instances.sort((a, b) => {
return dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0))
})
}
if (sortBy === 'Date created') {
if (sortBy.value === 'Date created') {
instances.sort((a, b) => {
return dayjs(b.date_created).diff(dayjs(a.date_created))
})
}
if (sortBy === 'Date modified') {
if (sortBy.value === 'Date modified') {
instances.sort((a, b) => {
return dayjs(b.date_modified).diff(dayjs(a.date_modified))
})
@@ -179,16 +162,16 @@ const filteredResults = computed(() => {
const instanceMap = new Map()
if (group === 'Loader') {
if (group.value === 'Loader') {
instances.forEach((instance) => {
const loader = formatLoader(formatMessage, instance.loader)
const loader = formatCategoryHeader(instance.loader)
if (!instanceMap.has(loader)) {
instanceMap.set(loader, [])
}
instanceMap.get(loader).push(instance)
})
} else if (group === 'Game version') {
} else if (group.value === 'Game version') {
instances.forEach((instance) => {
if (!instanceMap.has(instance.game_version)) {
instanceMap.set(instance.game_version, [])
@@ -196,7 +179,7 @@ const filteredResults = computed(() => {
instanceMap.get(instance.game_version).push(instance)
})
} else if (group === 'Group') {
} else if (group.value === 'Group') {
instances.forEach((instance) => {
if (instance.groups.length === 0) {
instance.groups.push('None')
@@ -216,7 +199,7 @@ const filteredResults = computed(() => {
// For 'name', we intuitively expect the sorting to apply to the name of the group first, not just the name of the instance
// ie: Category A should come before B, even if the first instance in B comes before the first instance in A
if (sortBy === 'Name') {
if (sortBy.value === 'Name') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
// None should always be first
if (a[0] === 'None' && b[0] !== 'None') {
@@ -234,7 +217,7 @@ const filteredResults = computed(() => {
}
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
if (group === 'Game version') {
if (group.value === 'Game version') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
return a[0].localeCompare(b[0], undefined, { numeric: true })
})
@@ -249,17 +232,16 @@ const filteredResults = computed(() => {
</script>
<template>
<div class="flex gap-2">
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
placeholder="Search"
clearable
wrapper-class="flex-1"
/>
<div class="iconified-input flex-1">
<SearchIcon />
<input v-model="search" type="text" placeholder="Search" />
<Button class="r-btn" @click="() => (search = '')">
<XIcon />
</Button>
</div>
<DropdownSelect
v-slot="{ selected }"
v-model="state.sortBy"
v-model="sortBy"
name="Sort Dropdown"
class="max-w-[16rem]"
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
@@ -270,7 +252,7 @@ const filteredResults = computed(() => {
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="state.group"
v-model="group"
class="max-w-[16rem]"
name="Group Dropdown"
:options="['Group', 'Loader', 'Game version', 'None']"
@@ -302,7 +284,14 @@ const filteredResults = computed(() => {
/>
</section>
</div>
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteProfile" />
<ConfirmModalWrapper
ref="confirmModal"
title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be removed. You will not be able to recover it."
:has-to-type="false"
proceed-label="Delete"
@proceed="deleteProfile"
/>
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
@@ -18,17 +18,16 @@ import { useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import ProjectCard from '@/components/ui/ProjectCard.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()
@@ -239,7 +238,14 @@ onUnmounted(() => {
</script>
<template>
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="deleteProfile" />
<ConfirmModalWrapper
ref="deleteConfirmModal"
title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be removed. You will not be able to recover it."
:has-to-type="false"
proceed-label="Delete"
@proceed="deleteProfile"
/>
<div ref="rowContainer" class="flex flex-col gap-4">
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row">
<HeadingLink class="mt-1" :to="row.route">
@@ -264,7 +270,7 @@ onUnmounted(() => {
/>
</section>
<section v-else ref="modsRow" class="projects">
<LegacyProjectCard
<ProjectCard
v-for="project in row.instances.slice(0, maxProjectsPerRow)"
:key="project?.project_id"
ref="instanceComponents"
@@ -284,12 +284,12 @@ onUnmounted(() => {
z-index: 11;
gap: 0.5rem;
padding: 1rem;
border: 1px solid var(--color-divider);
border: 1px solid var(--color-button-bg);
width: max-content;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
max-height: calc(100vh - 300px);
max-height: 98vh;
overflow-y: auto;
&::-webkit-scrollbar-track {
@@ -380,7 +380,7 @@ onUnmounted(() => {
text-align: left;
&.expanded {
border: 1px solid var(--color-divider);
border: 1px solid var(--color-button-bg);
padding: 1rem;
}
}
@@ -1,147 +1,64 @@
<template>
<div
ref="outerRef"
data-tauri-drag-region
class="min-w-0 overflow-hidden pl-3"
:style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
>
<div
ref="innerRef"
data-tauri-drag-region
class="flex w-fit items-center gap-1"
:class="{ 'breadcrumbs-scroll': isAnimating }"
@animationiteration="onAnimationIteration"
<div data-tauri-drag-region class="flex items-center gap-1 pl-3">
<Button v-if="false" class="breadcrumbs__back transparent" icon-only @click="$router.back()">
<ChevronLeftIcon />
</Button>
<Button
v-if="false"
class="breadcrumbs__forward transparent"
icon-only
@click="$router.forward()"
>
{{ breadcrumbData.resetToNames(breadcrumbs) }}
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name">
<router-link
v-if="breadcrumb.link"
:to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id as string)),
query: breadcrumb.query,
}"
class="shrink-0 whitespace-nowrap text-primary"
>
{{ resolveLabel(breadcrumb.name) }}
</router-link>
<span
v-else
data-tauri-drag-region
class="shrink-0 whitespace-nowrap text-contrast font-semibold cursor-default select-none"
>
{{ resolveLabel(breadcrumb.name) }}
</span>
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5 shrink-0" />
</template>
</div>
<ChevronRightIcon />
</Button>
{{ breadcrumbData.resetToNames(breadcrumbs) }}
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name">
<router-link
v-if="breadcrumb.link"
:to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id)),
query: breadcrumb.query,
}"
class="text-primary"
>{{
breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name
}}
</router-link>
<span
v-else
data-tauri-drag-region
class="text-contrast font-semibold cursor-default select-none"
>{{
breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name
}}</span
>
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5" />
</template>
</div>
</template>
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
<script setup>
import { ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useBreadcrumbs } from '@/store/breadcrumbs'
interface Breadcrumb {
name: string
link?: string
query?: Record<string, string>
}
const route = useRoute()
const breadcrumbData = useBreadcrumbs()
const breadcrumbs = computed<Breadcrumb[]>(() => {
const breadcrumbData = useBreadcrumbs()
const breadcrumbs = computed(() => {
const additionalContext =
route.meta.useContext === true
? breadcrumbData.context
: route.meta.useRootContext === true
? breadcrumbData.rootContext
: null
const crumbs = (route.meta.breadcrumb ?? []) as Breadcrumb[]
return additionalContext ? [additionalContext as Breadcrumb, ...crumbs] : crumbs
})
function resolveLabel(name: string): string {
return name.charAt(0) === '?' ? breadcrumbData.getName(name.slice(1)) : name
}
// Overflow detection
const outerRef = ref<HTMLDivElement | null>(null)
const innerRef = ref<HTMLDivElement | null>(null)
const isOverflowing = ref(false)
const isAnimating = ref(false)
const overflowAmount = ref(0)
let hovered = false
let stopping = false
function checkOverflow() {
if (!outerRef.value || !innerRef.value) return
const overflow = innerRef.value.scrollWidth - outerRef.value.clientWidth
isOverflowing.value = overflow > 0
overflowAmount.value = overflow + 12
}
function onMouseEnter() {
hovered = true
stopping = false
if (isOverflowing.value) {
isAnimating.value = true
}
}
function onMouseLeave() {
hovered = false
if (isAnimating.value) {
stopping = true
}
}
function onAnimationIteration() {
if (stopping && !hovered) {
isAnimating.value = false
stopping = false
}
}
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
checkOverflow()
resizeObserver = new ResizeObserver(checkOverflow)
if (outerRef.value) resizeObserver.observe(outerRef.value)
if (innerRef.value) resizeObserver.observe(innerRef.value)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
watch(breadcrumbs, () => {
requestAnimationFrame(checkOverflow)
return additionalContext ? [additionalContext, ...route.meta.breadcrumb] : route.meta.breadcrumb
})
</script>
<style scoped>
.breadcrumbs-scroll {
animation: breadcrumb-scroll 10s ease-in-out infinite;
}
@keyframes breadcrumb-scroll {
0% {
transform: translateX(0);
}
35%,
65% {
transform: translateX(var(--scroll-distance));
}
100% {
transform: translateX(0);
}
}
</style>
@@ -24,7 +24,7 @@
</template>
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
const emit = defineEmits(['menu-closed', 'option-clicked'])
@@ -40,27 +40,22 @@ defineExpose({
item.value = passedItem
options.value = passedOptions
// show to get dimensions
const menuWidth = contextMenu.value.clientWidth
const menuHeight = contextMenu.value.clientHeight
if (menuWidth + event.pageX >= window.innerWidth) {
left.value = event.pageX - menuWidth + 2 + 'px'
} else {
left.value = event.pageX - 2 + 'px'
}
if (menuHeight + event.pageY >= window.innerHeight) {
top.value = event.pageY - menuHeight + 2 + 'px'
} else {
top.value = event.pageY - 2 + 'px'
}
shown.value = true
// then, adjust position if overflowing
nextTick(() => {
const menuWidth = contextMenu.value?.clientWidth || 200
const menuHeight = contextMenu.value?.clientHeight || 100
const minFromEdge = 10
if (event.pageX + menuWidth + minFromEdge >= window.innerWidth) {
left.value = Math.max(minFromEdge, event.pageX - menuWidth - minFromEdge) + 'px'
} else {
left.value = event.pageX + minFromEdge + 'px'
}
if (event.pageY + menuHeight + minFromEdge >= window.innerHeight) {
top.value = Math.max(minFromEdge, event.pageY - menuHeight - minFromEdge) + 'px'
} else {
top.value = event.pageY + minFromEdge + 'px'
}
})
},
})
@@ -119,7 +114,7 @@ onBeforeUnmount(() => {
background-color: var(--color-raised-bg);
border-radius: var(--radius-md);
box-shadow: var(--shadow-floating);
border: 1px solid var(--color-divider);
border: 1px solid var(--color-button-bg);
margin: 0;
position: fixed;
z-index: 1000000;
@@ -163,7 +158,7 @@ onBeforeUnmount(() => {
}
.divider {
border: 1px solid var(--color-divider);
border: 1px solid var(--color-button-bg);
margin: var(--gap-sm);
pointer-events: none;
}
@@ -33,7 +33,6 @@ 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:')) {
@@ -1,14 +1,6 @@
<script setup>
import { PlusIcon, XIcon } from '@modrinth/assets'
import {
Button,
Checkbox,
commonMessages,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { Button, Checkbox, injectNotificationManager } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue'
@@ -17,33 +9,6 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { export_profile_mrpack, get_pack_export_candidates } from '@/helpers/profile.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: { id: 'app.export-modal.header', defaultMessage: 'Export modpack' },
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack Name' },
modpackNamePlaceholder: {
id: 'app.export-modal.modpack-name-placeholder',
defaultMessage: 'Modpack name',
},
versionNumberLabel: {
id: 'app.export-modal.version-number-label',
defaultMessage: 'Version number',
},
versionNumberPlaceholder: {
id: 'app.export-modal.version-number-placeholder',
defaultMessage: '1.0.0',
},
descriptionPlaceholder: {
id: 'app.export-modal.description-placeholder',
defaultMessage: 'Enter modpack description...',
},
selectFilesLabel: {
id: 'app.export-modal.select-files-label',
defaultMessage: 'Select files and folders to include in pack',
},
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
})
const props = defineProps({
instance: {
@@ -141,44 +106,42 @@ const exportPack = async () => {
</script>
<template>
<ModalWrapper ref="exportModal" :header="formatMessage(messages.header)">
<ModalWrapper ref="exportModal" header="Export modpack">
<div class="modal-body">
<div class="labeled_input">
<p>{{ formatMessage(messages.modpackNameLabel) }}</p>
<StyledInput
v-model="nameInput"
:icon="PackageIcon"
type="text"
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
clearable
/>
<p>Modpack Name</p>
<div class="iconified-input">
<PackageIcon />
<input v-model="nameInput" type="text" placeholder="Modpack name" class="input" />
<Button class="r-btn" @click="nameInput = ''">
<XIcon />
</Button>
</div>
</div>
<div class="labeled_input">
<p>{{ formatMessage(messages.versionNumberLabel) }}</p>
<StyledInput
v-model="versionInput"
:icon="VersionIcon"
type="text"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
clearable
/>
<p>Version number</p>
<div class="iconified-input">
<VersionIcon />
<input v-model="versionInput" type="text" placeholder="1.0.0" class="input" />
<Button class="r-btn" @click="versionInput = ''">
<XIcon />
</Button>
</div>
</div>
<div class="adjacent-input">
<div class="labeled_input">
<p>{{ formatMessage(commonMessages.descriptionLabel) }}</p>
<p>Description</p>
<StyledInput
v-model="exportDescription"
multiline
:placeholder="formatMessage(messages.descriptionPlaceholder)"
/>
<div class="textarea-wrapper">
<textarea v-model="exportDescription" placeholder="Enter modpack description..." />
</div>
</div>
</div>
<div class="table">
<div class="table-head">
<div class="table-cell row-wise">
{{ formatMessage(messages.selectFilesLabel) }}
Select files and folders to include in pack
<Button
class="sleek-primary collapsed-button"
icon-only
@@ -237,11 +200,11 @@ const exportPack = async () => {
<div class="button-row push-right">
<Button @click="exportModal.hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
Cancel
</Button>
<Button color="primary" @click="exportPack">
<PackageIcon />
{{ formatMessage(messages.exportButton) }}
Export
</Button>
</div>
</div>
@@ -326,4 +289,17 @@ const exportPack = async () => {
align-items: center;
gap: 1rem;
}
.textarea-wrapper {
// margin-top: 1rem;
height: 12rem;
textarea {
max-height: 12rem;
}
.preview {
overflow-y: auto;
}
}
</style>
@@ -69,7 +69,7 @@ const play = async (e, context) => {
await run(props.instance.path)
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
.finally(() => {
trackEvent('InstanceStart', {
trackEvent('InstancePlay', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
@@ -0,0 +1,664 @@
<template>
<ModalWrapper ref="modal" header="Creating an instance">
<div class="modal-header">
<Chips v-model="creationType" :items="['custom', 'from file', 'import from launcher']" />
</div>
<hr class="card-divider" />
<div v-if="creationType === 'custom'" class="modal-body">
<div class="image-upload">
<Avatar :src="display_icon" size="md" :rounded="true" />
<div class="image-input">
<Button @click="upload_icon()">
<UploadIcon />
Select icon
</Button>
<Button :disabled="!display_icon" @click="reset_icon">
<XIcon />
Remove icon
</Button>
</div>
</div>
<div class="input-row">
<p class="input-label">Name</p>
<input
v-model="profile_name"
autocomplete="off"
class="text-input"
type="text"
maxlength="100"
/>
</div>
<div class="input-row">
<p class="input-label">Loader</p>
<Chips v-model="loader" :items="loaders" />
</div>
<div class="input-row">
<p class="input-label">Game version</p>
<div class="versions">
<multiselect
v-model="game_version"
class="selector"
:options="game_versions"
:multiple="false"
:searchable="true"
placeholder="Select game version"
open-direction="top"
:show-labels="false"
/>
<Checkbox v-model="showSnapshots" class="filter-checkbox" label="Show all versions" />
</div>
</div>
<div v-if="loader !== 'vanilla'" class="input-row">
<p class="input-label">Loader version</p>
<Chips v-model="loader_version" :items="['stable', 'latest', 'other']" />
</div>
<div v-if="loader_version === 'other' && loader !== 'vanilla'">
<div v-if="game_version" class="input-row">
<p class="input-label">Select version</p>
<multiselect
v-model="specified_loader_version"
class="selector"
:options="selectable_versions"
:searchable="true"
placeholder="Select loader version"
open-direction="top"
:show-labels="false"
/>
</div>
<div v-else class="input-row">
<p class="warning">Select a game version before you select a loader version</p>
</div>
</div>
<div class="input-group push-right">
<Button @click="hide()">
<XIcon />
Cancel
</Button>
<Button color="primary" :disabled="!check_valid || creating" @click="create_instance()">
<PlusIcon v-if="!creating" />
{{ creating ? 'Creating...' : 'Create' }}
</Button>
</div>
</div>
<div v-else-if="creationType === 'from file'" class="modal-body">
<Button @click="openFile"> <FolderOpenIcon /> Import from file </Button>
<div class="info"><InfoIcon /> Or drag and drop your .mrpack file</div>
</div>
<div v-else class="modal-body">
<Chips
v-model="selectedProfileType"
:items="profileOptions"
:format-label="(profile) => profile?.name"
/>
<div class="path-selection">
<h3>{{ selectedProfileType.name }} path</h3>
<div class="path-input">
<div class="iconified-input">
<FolderOpenIcon />
<input
v-model="selectedProfileType.path"
type="text"
placeholder="Path to launcher"
@change="setPath"
/>
<Button class="r-btn" @click="() => (selectedProfileType.path = '')">
<XIcon />
</Button>
</div>
<Button icon-only @click="selectLauncherPath">
<FolderSearchIcon />
</Button>
<Button icon-only @click="reload">
<UpdatedIcon />
</Button>
</div>
</div>
<div class="table">
<div class="table-head table-row">
<div class="toggle-all table-cell">
<Checkbox
class="select-checkbox"
:model-value="
profiles.get(selectedProfileType.name)?.every((child) => child.selected)
"
@update:model-value="
(newValue) =>
profiles
.get(selectedProfileType.name)
?.forEach((child) => (child.selected = newValue))
"
/>
</div>
<div class="name-cell table-cell">Profile name</div>
</div>
<div
v-if="
profiles.get(selectedProfileType.name) &&
profiles.get(selectedProfileType.name).length > 0
"
class="table-content"
>
<div
v-for="(profile, index) in profiles.get(selectedProfileType.name)"
:key="index"
class="table-row"
>
<div class="checkbox-cell table-cell">
<Checkbox v-model="profile.selected" class="select-checkbox" />
</div>
<div class="name-cell table-cell">
{{ profile.name }}
</div>
</div>
</div>
<div v-else class="table-content empty">No profiles found</div>
</div>
<div class="button-row">
<Button
:disabled="
loading ||
!Array.from(profiles.values())
.flatMap((e) => e)
.some((e) => e.selected)
"
color="primary"
@click="next"
>
{{
loading
? 'Importing...'
: Array.from(profiles.values())
.flatMap((e) => e)
.some((e) => e.selected)
? `Import ${
Array.from(profiles.values())
.flatMap((e) => e)
.filter((e) => e.selected).length
} profiles`
: 'Select profiles to import'
}}
</Button>
<ProgressBar
v-if="loading"
:progress="(importedProfiles / (totalProfiles + 0.0001)) * 100"
/>
</div>
</div>
</ModalWrapper>
</template>
<script setup>
import {
FolderOpenIcon,
FolderSearchIcon,
InfoIcon,
PlusIcon,
UpdatedIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { Avatar, Button, Checkbox, Chips, injectNotificationManager } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { open } from '@tauri-apps/plugin-dialog'
import { computed, onUnmounted, ref, shallowRef } from 'vue'
import Multiselect from 'vue-multiselect'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { trackEvent } from '@/helpers/analytics'
import {
get_default_launcher_path,
get_importable_instances,
import_instance,
} from '@/helpers/import.js'
import { get_game_versions, get_loader_versions } from '@/helpers/metadata'
import { create_profile_and_install_from_file } from '@/helpers/pack.js'
import { create } from '@/helpers/profile'
import { get_loaders } from '@/helpers/tags'
const { handleError } = injectNotificationManager()
const profile_name = ref('')
const game_version = ref('')
const loader = ref('vanilla')
const loader_version = ref('stable')
const specified_loader_version = ref('')
const icon = ref(null)
const display_icon = ref(null)
const creating = ref(false)
const showSnapshots = ref(false)
const creationType = ref('custom')
const isShowing = ref(false)
defineExpose({
show: async () => {
game_version.value = ''
specified_loader_version.value = ''
profile_name.value = ''
creating.value = false
showSnapshots.value = false
loader.value = 'vanilla'
loader_version.value = 'stable'
icon.value = null
display_icon.value = null
isShowing.value = true
modal.value.show()
unlistener.value = await getCurrentWebview().onDragDropEvent(async (event) => {
// Only if modal is showing
if (!isShowing.value) return
if (event.payload.type !== 'drop') return
if (creationType.value !== 'from file') return
hide()
const { paths } = event.payload
if (paths && paths.length > 0 && paths[0].endsWith('.mrpack')) {
await create_profile_and_install_from_file(paths[0]).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModalFileDrop',
})
}
})
trackEvent('InstanceCreateStart', { source: 'CreationModal' })
},
})
const unlistener = ref(null)
const hide = () => {
isShowing.value = false
modal.value.hide()
if (unlistener.value) {
unlistener.value()
unlistener.value = null
}
}
onUnmounted(() => {
if (unlistener.value) {
unlistener.value()
unlistener.value = null
}
})
const [
fabric_versions,
forge_versions,
quilt_versions,
neoforge_versions,
all_game_versions,
loaders,
] = await Promise.all([
get_loader_versions('fabric').then(shallowRef).catch(handleError),
get_loader_versions('forge').then(shallowRef).catch(handleError),
get_loader_versions('quilt').then(shallowRef).catch(handleError),
get_loader_versions('neo').then(shallowRef).catch(handleError),
get_game_versions().then(shallowRef).catch(handleError),
get_loaders()
.then((value) =>
ref(
value
.filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
),
)
.catch((err) => {
handleError(err)
return ref([])
}),
])
loaders.value.unshift('vanilla')
const game_versions = computed(() => {
return all_game_versions.value.versions
.filter((item) => {
let defaultVal = item.type === 'release' || showSnapshots.value
if (loader.value === 'fabric') {
defaultVal &= fabric_versions.value.gameVersions.some((x) => item.id === x.id)
} else if (loader.value === 'forge') {
defaultVal &= forge_versions.value.gameVersions.some((x) => item.id === x.id)
} else if (loader.value === 'quilt') {
defaultVal &= quilt_versions.value.gameVersions.some((x) => item.id === x.id)
} else if (loader.value === 'neoforge') {
defaultVal &= neoforge_versions.value.gameVersions.some((x) => item.id === x.id)
}
return defaultVal
})
.map((item) => item.id)
})
const modal = ref(null)
const check_valid = computed(() => {
return (
profile_name.value.trim() &&
game_version.value &&
game_versions.value.includes(game_version.value)
)
})
const create_instance = async () => {
creating.value = true
const loader_version_value =
loader_version.value === 'other' ? specified_loader_version.value : loader_version.value
const loaderVersion = loader.value === 'vanilla' ? null : loader_version_value ?? 'stable'
hide()
creating.value = false
await create(
profile_name.value,
game_version.value,
loader.value,
loader.value === 'vanilla' ? null : loader_version_value ?? 'stable',
icon.value,
).catch(handleError)
trackEvent('InstanceCreate', {
profile_name: profile_name.value,
game_version: game_version.value,
loader: loader.value,
loader_version: loaderVersion,
has_icon: !!icon.value,
source: 'CreationModal',
})
}
const upload_icon = async () => {
const res = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'svg', 'webp', 'gif', 'jpg'],
},
],
})
icon.value = res.path ?? res
if (!icon.value) return
display_icon.value = convertFileSrc(icon.value)
}
const reset_icon = () => {
icon.value = null
display_icon.value = null
}
const selectable_versions = computed(() => {
if (game_version.value) {
if (loader.value === 'fabric') {
return fabric_versions.value.gameVersions[0].loaders.map((item) => item.id)
} else if (loader.value === 'forge') {
return forge_versions.value.gameVersions
.find((item) => item.id === game_version.value)
.loaders.map((item) => item.id)
} else if (loader.value === 'quilt') {
return quilt_versions.value.gameVersions[0].loaders.map((item) => item.id)
} else if (loader.value === 'neoforge') {
return neoforge_versions.value.gameVersions
.find((item) => item.id === game_version.value)
.loaders.map((item) => item.id)
}
}
return []
})
const openFile = async () => {
const newProject = await open({ multiple: false })
if (!newProject) return
hide()
await create_profile_and_install_from_file(newProject.path ?? newProject).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModalFileOpen',
})
}
const profiles = ref(
new Map([
['MultiMC', []],
['GDLauncher', []],
['ATLauncher', []],
['Curseforge', []],
['PrismLauncher', []],
]),
)
const loading = ref(false)
const importedProfiles = ref(0)
const totalProfiles = ref(0)
const selectedProfileType = ref('MultiMC')
const profileOptions = ref([
{ name: 'MultiMC', path: '' },
{ name: 'GDLauncher', path: '' },
{ name: 'ATLauncher', path: '' },
{ name: 'Curseforge', path: '' },
{ name: 'PrismLauncher', path: '' },
])
// Attempt to get import profiles on default paths
const promises = profileOptions.value.map(async (option) => {
const path = await get_default_launcher_path(option.name).catch(handleError)
if (!path || path === '') return
// Try catch to allow failure and simply ignore default path attempt
try {
const instances = await get_importable_instances(option.name, path)
if (!instances) return
profileOptions.value.find((profile) => profile.name === option.name).path = path
profiles.value.set(
option.name,
instances.map((name) => ({ name, selected: false })),
)
} catch {
// Allow failure silently
}
})
await Promise.all(promises)
const selectLauncherPath = async () => {
selectedProfileType.value.path = await open({ multiple: false, directory: true })
if (selectedProfileType.value.path) {
await reload()
}
}
const reload = async () => {
const instances = await get_importable_instances(
selectedProfileType.value.name,
selectedProfileType.value.path,
).catch(handleError)
if (instances) {
profiles.value.set(
selectedProfileType.value.name,
instances.map((name) => ({ name, selected: false })),
)
} else {
profiles.value.set(selectedProfileType.value.name, [])
}
}
const setPath = () => {
profileOptions.value.find((profile) => profile.name === selectedProfileType.value.name).path =
selectedProfileType.value.path
}
const next = async () => {
importedProfiles.value = 0
totalProfiles.value = Array.from(profiles.value.values())
.map((profiles) => profiles.filter((profile) => profile.selected).length)
.reduce((a, b) => a + b, 0)
loading.value = true
for (const launcher of Array.from(profiles.value.entries()).map(([launcher, profiles]) => ({
launcher,
path: profileOptions.value.find((option) => option.name === launcher).path,
profiles,
}))) {
for (const profile of launcher.profiles.filter((profile) => profile.selected)) {
await import_instance(launcher.launcher, launcher.path, profile.name)
.catch(handleError)
.then(() => console.log(`Successfully Imported ${profile.name} from ${launcher.launcher}`))
profile.selected = false
importedProfiles.value++
}
}
loading.value = false
}
</script>
<style lang="scss" scoped>
.modal-body {
display: flex;
flex-direction: column;
gap: var(--gap-md);
margin-top: var(--gap-lg);
}
.input-label {
font-size: 1rem;
font-weight: bolder;
color: var(--color-contrast);
margin-bottom: 0.5rem;
}
.text-input {
width: 20rem;
}
.image-upload {
display: flex;
gap: 1rem;
}
.image-input {
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: center;
}
.warning {
font-style: italic;
}
.versions {
display: flex;
flex-direction: row;
gap: 1rem;
}
:deep(button.checkbox) {
border: none;
}
.selector {
max-width: 20rem;
}
.labeled-divider {
text-align: center;
}
.labeled-divider:after {
background-color: var(--color-raised-bg);
content: 'Or';
color: var(--color-base);
padding: var(--gap-sm);
position: relative;
top: -0.5rem;
}
.info {
display: flex;
flex-direction: row;
gap: 0.5rem;
align-items: center;
}
.modal-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding-bottom: 0;
}
.path-selection {
padding: var(--gap-xl);
background-color: var(--color-bg);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
gap: var(--gap-md);
h3 {
margin: 0;
}
.path-input {
display: flex;
align-items: center;
width: 100%;
flex-direction: row;
gap: var(--gap-sm);
.iconified-input {
flex-grow: 1;
:deep(input) {
width: 100%;
flex-basis: auto;
}
}
}
}
.table {
border: 1px solid var(--color-bg);
}
.table-row {
grid-template-columns: min-content auto;
}
.table-content {
max-height: calc(5 * (18px + 2rem));
height: calc(5 * (18px + 2rem));
overflow-y: auto;
}
.select-checkbox {
button.checkbox {
border: none;
}
}
.button-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: var(--gap-md);
.transparent {
padding: var(--gap-sm) 0;
}
}
.empty {
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: bolder;
color: var(--color-contrast);
}
.card-divider {
margin: var(--gap-md) var(--gap-lg) 0 var(--gap-lg);
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, FormattedTag } from '@modrinth/ui'
import { Avatar, ButtonStyled } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
type Instance = {
@@ -36,8 +37,7 @@ defineProps<{
</span>
<span class="text-secondary flex items-center gap-2 font-semibold">
<GameIcon class="h-5 w-5 text-secondary" />
<FormattedTag :tag="instance.loader" enforce-type="loader" />
{{ instance.game_version }}
{{ formatCategory(instance.loader) }} {{ instance.game_version }}
</span>
</span>
</span>
@@ -1,17 +1,18 @@
<template>
<JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', val)" />
<div class="toggle-setting" :class="{ compact }">
<StyledInput
<input
autocomplete="off"
:disabled="props.disabled"
:model-value="props.modelValue ? props.modelValue.path : ''"
:value="props.modelValue ? props.modelValue.path : ''"
type="text"
class="installation-input"
:placeholder="placeholder ?? '/path/to/java'"
wrapper-class="installation-input"
@update:model-value="
@input="
(val) => {
emit('update:modelValue', {
...props.modelValue,
path: val,
path: val.target.value,
})
}
"
@@ -59,7 +60,7 @@ import {
SearchIcon,
XIcon,
} from '@modrinth/assets'
import { Button, injectNotificationManager, StyledInput } from '@modrinth/ui'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue'
@@ -6,7 +6,6 @@
:class="{
'router-link-active': isPrimary && isPrimary(route),
'subpage-active': isSubpage && isSubpage(route),
disabled: disabled,
}"
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
>
@@ -16,7 +15,6 @@
v-else
v-bind="$attrs"
class="button-animation border-none text-primary cursor-pointer w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
:disabled="disabled"
@click="to"
>
<slot />
@@ -31,18 +29,12 @@ const route = useRoute()
type RouteFunction = (route: RouteLocationNormalizedLoaded) => boolean
withDefaults(
defineProps<{
to: (() => void) | string
isPrimary?: RouteFunction
isSubpage?: RouteFunction
highlightOverride?: boolean
disabled?: boolean
}>(),
{
disabled: false,
},
)
defineProps<{
to: (() => void) | string
isPrimary?: RouteFunction
isSubpage?: RouteFunction
highlightOverride?: boolean
}>()
defineOptions({
inheritAttrs: false,
+39 -64
View File
@@ -1,7 +1,5 @@
<template>
<nav
v-if="filteredLinks.length > 1"
ref="scrollContainer"
class="card-shadow experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<RouterLink
@@ -16,17 +14,13 @@
<span class="text-nowrap">{{ link.label }}</span>
</RouterLink>
<div
:class="[
'pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1',
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
{ 'navtabs-transition': transitionsEnabled },
]"
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'}`"
:style="{
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: sliderReady && activeIndex !== -1 ? 1 : 0,
opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
></div>
@@ -34,7 +28,7 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { RouterLink, useRoute } from 'vue-router'
@@ -53,16 +47,13 @@ const props = defineProps<{
query?: string
}>()
const scrollContainer = ref<HTMLElement | null>(null)
const sliderLeft = ref(4)
const sliderTop = ref(4)
const sliderRight = ref(4)
const sliderBottom = ref(4)
const activeIndex = ref(-1)
const oldIndex = ref(-1)
const subpageSelected = ref(false)
const sliderReady = ref(false)
const transitionsEnabled = ref(false)
const sliderDelays = ref({ left: '0ms', top: '0ms', right: '0ms', bottom: '0ms' })
const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
@@ -72,11 +63,6 @@ const sliderTopPx = computed(() => `${sliderTop.value}px`)
const sliderRightPx = computed(() => `${sliderRight.value}px`)
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
const leftDelay = computed(() => sliderDelays.value.left)
const rightDelay = computed(() => sliderDelays.value.right)
const topDelay = computed(() => sliderDelays.value.top)
const bottomDelay = computed(() => sliderDelays.value.bottom)
function pickLink() {
let index = -1
subpageSelected.value = false
@@ -97,60 +83,62 @@ function pickLink() {
if (activeIndex.value !== -1) {
startAnimation()
} else {
oldIndex.value = -1
sliderLeft.value = 0
sliderRight.value = 0
}
}
function getTabElement(index: number): HTMLElement | null {
if (index === -1) return null
const container = scrollContainer.value
if (!container) return null
const tabs = container.querySelectorAll('.button-animation')
return (tabs[index] as HTMLElement) ?? null
}
const tabLinkElements = ref()
function startAnimation() {
const el = getTabElement(activeIndex.value)
if (!el?.offsetParent) return
const el = tabLinkElements.value[activeIndex.value].$el
if (!el || !el.offsetParent) return
const parent = el.offsetParent as HTMLElement
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: parent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: parent.offsetHeight - el.offsetTop - el.offsetHeight,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
}
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
if (isInitialPosition) {
if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
sliderReady.value = true
requestAnimationFrame(() => {
transitionsEnabled.value = true
})
} else {
const STAGGER_DELAY = '200ms'
sliderDelays.value = {
left: newValues.left < sliderLeft.value ? '0ms' : STAGGER_DELAY,
right: newValues.left < sliderLeft.value ? STAGGER_DELAY : '0ms',
top: newValues.top < sliderTop.value ? '0ms' : STAGGER_DELAY,
bottom: newValues.top < sliderTop.value ? STAGGER_DELAY : '0ms',
const delay = 200
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left
setTimeout(() => {
sliderRight.value = newValues.right
}, delay)
} else {
sliderRight.value = newValues.right
setTimeout(() => {
sliderLeft.value = newValues.left
}, delay)
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top
setTimeout(() => {
sliderBottom.value = newValues.bottom
}, delay)
} else {
sliderBottom.value = newValues.bottom
setTimeout(() => {
sliderTop.value = newValues.top
}, delay)
}
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
}
}
onMounted(async () => {
onMounted(() => {
window.addEventListener('resize', pickLink)
await nextTick()
pickLink()
})
@@ -158,17 +146,7 @@ onUnmounted(() => {
window.removeEventListener('resize', pickLink)
})
watch(
filteredLinks,
async () => {
await nextTick()
pickLink()
},
{ deep: true },
)
watch(route, async () => {
await nextTick()
watch(route, () => {
pickLink()
})
</script>
@@ -176,10 +154,7 @@ watch(route, async () => {
.navtabs-transition {
/* Delay on opacity is to hide any jankiness as the page loads */
transition:
left 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(leftDelay),
right 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(rightDelay),
top 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(topDelay),
bottom 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(bottomDelay),
all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s,
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
</style>
@@ -1,12 +1,6 @@
<template>
<div class="progress-bar">
<div
class="progress-bar__fill"
:style="{
width: `${progress}%`,
'background-color': error ? 'var(--color-red)' : 'var(--color-brand)',
}"
></div>
<div class="progress-bar__fill" :style="{ width: `${progress}%` }"></div>
</div>
</template>
@@ -19,10 +13,6 @@ defineProps({
return value >= 0 && value <= 100
},
},
error: {
type: Boolean,
default: false,
},
})
</script>
@@ -37,6 +27,7 @@ defineProps({
.progress-bar__fill {
height: 100%;
background-color: var(--color-brand);
transition: width 0.3s;
}
</style>
@@ -1,6 +1,7 @@
<script setup>
import { DownloadIcon, HeartIcon, TagIcon } from '@modrinth/assets'
import { Avatar, FormattedTag, TagItem, useCompactNumber } from '@modrinth/ui'
import { Avatar, TagItem } from '@modrinth/ui'
import { formatCategory, formatNumber } from '@modrinth/utils'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed } from 'vue'
@@ -10,8 +11,6 @@ dayjs.extend(relativeTime)
const router = useRouter()
const { formatCompactNumber } = useCompactNumber()
const props = defineProps({
project: {
type: Object,
@@ -64,7 +63,7 @@ const toTransparent = computed(() => {
<div
class="w-full aspect-[2/1] bg-cover bg-center bg-no-repeat"
:style="{
'background-color': (project.featured_gallery ?? project.gallery[0]) ? null : toColor,
'background-color': project.featured_gallery ?? project.gallery[0] ? null : toColor,
'background-image': `url(${
project.featured_gallery ??
project.gallery[0] ??
@@ -97,18 +96,18 @@ const toTransparent = computed(() => {
class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border"
>
<DownloadIcon />
{{ formatCompactNumber(project.downloads) }}
{{ formatNumber(project.downloads) }}
</div>
<div
class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border"
>
<HeartIcon />
{{ formatCompactNumber(project.follows) }}
{{ formatNumber(project.follows) }}
</div>
<div class="flex items-center gap-1 pr-2">
<TagIcon />
<TagItem>
<FormattedTag :tag="featuredCategory" />
{{ formatCategory(featuredCategory) }}
</TagItem>
</div>
</div>
@@ -34,18 +34,18 @@ function updateAdPosition() {
<template>
<div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg">
<a
href="https://modrinth.host/medal?from=app-placeholder"
href="https://modrinth.gg/medal?from=app-placeholder"
target="_blank"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
>
<img
src="https://cdn-raw.modrinth.com/modrinth-hosting-medal-light.webp"
alt="Host your next server with Modrinth Hosting"
src="https://cdn-raw.modrinth.com/medal-modrinth-servers-light.webp"
alt="Host your next server with Modrinth Servers"
class="hidden light-image rounded-[inherit]"
/>
<img
src="https://cdn-raw.modrinth.com/modrinth-hosting-medal-dark.webp"
alt="Host your next server with Modrinth Hosting"
src="https://cdn-raw.modrinth.com/medal-modrinth-servers-dark.webp"
alt="Host your next server with Modrinth Servers"
class="dark-image rounded-[inherit]"
/>
</a>
@@ -49,26 +49,27 @@ onUnmounted(() => {
</script>
<template>
<div v-for="instance in recentInstances" :key="instance.id" v-tooltip.right="instance.name">
<NavButton :to="`/instance/${encodeURIComponent(instance.path)}`" class="relative">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px"
:tint-by="instance.path"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div
v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10 pointer-events-none"
>
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
</div>
<div
v-if="instances && recentInstances.length > 0"
class="h-px w-6 mx-auto my-2 bg-divider"
></div>
<NavButton
v-for="instance in recentInstances"
:key="instance.id"
v-tooltip.right="instance.name"
:to="`/instance/${encodeURIComponent(instance.path)}`"
class="relative"
>
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
size="28px"
:tint-by="instance.path"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div
v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10"
>
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-button-bg"></div>
</template>
<style scoped lang="scss"></style>
@@ -52,12 +52,10 @@
<h3 class="info-title">
{{ loadingBar.title }}
</h3>
<div class="flex flex-col gap-2 w-full">
<ProgressBar :progress="Math.floor((100 * loadingBar.current) / loadingBar.total)" />
<div class="row">
{{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}%
{{ loadingBar.message }}
</div>
<ProgressBar :progress="Math.floor((100 * loadingBar.current) / loadingBar.total)" />
<div class="row">
{{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}%
{{ loadingBar.message }}
</div>
</div>
</Card>
@@ -179,8 +177,8 @@ const currentLoadingBars = ref([])
const refreshInfo = async () => {
const currentLoadingBarCount = currentLoadingBars.value.length
currentLoadingBars.value = Object.values(await progress_bars_list().catch(handleError))
.map((x) => {
currentLoadingBars.value = Object.values(await progress_bars_list().catch(handleError)).map(
(x) => {
if (x.bar_type.type === 'java_download') {
x.title = 'Downloading Java ' + x.bar_type.version
}
@@ -192,8 +190,8 @@ const refreshInfo = async () => {
}
return x
})
.filter((bar) => bar?.bar_type?.type !== 'launcher_update')
},
)
currentLoadingBars.value.sort((a, b) => {
if (a.loading_bar_uuid < b.loading_bar_uuid) {
@@ -295,7 +293,7 @@ onBeforeUnmount(() => {
align-items: center;
gap: 0.5rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-divider);
border: 1px solid var(--color-button-bg);
padding: var(--gap-sm) var(--gap-lg);
}
@@ -348,7 +346,7 @@ onBeforeUnmount(() => {
.info-card {
position: absolute;
top: 3.5rem;
right: 2rem;
right: 0.5rem;
z-index: 9;
width: 20rem;
background-color: var(--color-raised-bg);
@@ -358,7 +356,7 @@ onBeforeUnmount(() => {
gap: 1rem;
overflow: auto;
transition: all 0.2s ease-in-out;
border: 1px solid var(--color-divider);
border: 1px solid var(--color-button-bg);
&.hidden {
transform: translateY(-100%);
@@ -422,7 +420,7 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
gap: 0.5rem;
margin: 0;
padding: 0;
}
@@ -456,7 +454,7 @@ onBeforeUnmount(() => {
flex-direction: column;
overflow: auto;
transition: all 0.2s ease-in-out;
border: 1px solid var(--color-divider);
border: 1px solid var(--color-button-bg);
padding: var(--gap-md);
&.hidden {
@@ -1,7 +1,7 @@
<template>
<ProjectCard
:title="project.name"
:link="
<div
class="card-shadow p-4 bg-bg-raised rounded-xl flex gap-3 group cursor-pointer hover:brightness-90 transition-all"
@click="
() => {
emit('open')
$router.push({
@@ -10,66 +10,122 @@
})
}
"
:author="{ name: project.author, link: `https://modrinth.com/user/${project.author}` }"
:icon-url="project.icon_url"
:summary="project.summary"
:tags="project.display_categories"
:all-tags="project.categories"
:downloads="project.downloads"
:followers="project.follows"
:date-updated="project.date_modified"
:banner="project.featured_gallery ?? undefined"
:color="project.color ?? undefined"
:environment="
['mod', 'modpack'].includes(projectType)
? {
clientSide: project.client_side?.[0],
serverSide: project.server_side?.[0],
}
: undefined
"
layout="list"
>
<template #actions>
<ButtonStyled color="brand" type="outlined">
<button
:disabled="installed || installing"
class="shrink-0 no-wrap"
@click.stop="install()"
<div class="icon w-[96px] h-[96px] relative">
<Avatar :src="project.icon_url" size="96px" class="search-icon origin-top transition-all" />
</div>
<div class="flex flex-col gap-2 overflow-hidden">
<div class="gap-2 overflow-hidden no-wrap text-ellipsis">
<span class="text-lg font-extrabold text-contrast m-0 leading-none">
{{ project.title }}
</span>
<span v-if="project.author" class="text-secondary"> by {{ project.author }}</span>
</div>
<div class="m-0 line-clamp-2">
{{ project.description }}
</div>
<div v-if="categories.length > 0" class="mt-auto flex items-center gap-1 no-wrap">
<TagsIcon class="h-4 w-4 shrink-0" />
<div
v-if="project.project_type === 'mod' || project.project_type === 'modpack'"
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
>
<SpinnerIcon v-if="installing" class="animate-spin" />
<template v-else-if="!installed">
<DownloadIcon v-if="modpack || instance" />
<PlusIcon v-else />
<template v-if="project.client_side === 'optional' && project.server_side === 'optional'">
Client or server
</template>
<CheckIcon v-else />
{{
installing
? 'Installing'
: installed
? 'Installed'
: modpack || instance
? 'Install'
: 'Add to an instance'
}}
</button>
</ButtonStyled>
</template>
</ProjectCard>
<template
v-else-if="
(project.client_side === 'optional' || project.client_side === 'required') &&
(project.server_side === 'optional' || project.server_side === 'unsupported')
"
>
Client
</template>
<template
v-else-if="
(project.server_side === 'optional' || project.server_side === 'required') &&
(project.client_side === 'optional' || project.client_side === 'unsupported')
"
>
Server
</template>
<template
v-else-if="
project.client_side === 'unsupported' && project.server_side === 'unsupported'
"
>
Unsupported
</template>
<template
v-else-if="project.client_side === 'required' && project.server_side === 'required'"
>
Client and server
</template>
</div>
<div
v-for="tag in categories"
:key="tag"
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
>
{{ formatCategory(tag.name) }}
</div>
</div>
</div>
<div class="flex flex-col gap-2 items-end shrink-0 ml-auto">
<div class="flex items-center gap-2">
<DownloadIcon class="shrink-0" />
<span>
{{ formatNumber(project.downloads) }}
<span class="text-secondary">downloads</span>
</span>
</div>
<div class="flex items-center gap-2">
<HeartIcon class="shrink-0" />
<span>
{{ formatNumber(project.follows ?? project.followers) }}
<span class="text-secondary">followers</span>
</span>
</div>
<div class="mt-auto relative">
<div class="absolute bottom-0 right-0 w-fit">
<ButtonStyled color="brand" type="outlined">
<button
:disabled="installed || installing"
class="shrink-0 no-wrap"
@click.stop="install()"
>
<template v-if="!installed">
<DownloadIcon v-if="modpack || instance" />
<PlusIcon v-else />
</template>
<CheckIcon v-else />
{{
installing
? 'Installing'
: installed
? 'Installed'
: modpack || instance
? 'Install'
: 'Add to an instance'
}}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { CheckIcon, DownloadIcon, PlusIcon, SpinnerIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, ProjectCard } from '@modrinth/ui'
import { CheckIcon, DownloadIcon, HeartIcon, PlusIcon, TagsIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { formatCategory, formatNumber } from '@modrinth/utils'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { injectContentInstall } from '@/providers/content-install'
const { install: installVersion } = injectContentInstall()
import { install as installVersion } from '@/store/install.js'
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
@@ -85,6 +141,10 @@ const props = defineProps({
type: Object,
required: true,
},
categories: {
type: Array,
required: true,
},
instance: {
type: Object,
default: null,
@@ -97,18 +157,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
projectType: {
type: String,
default: undefined,
},
activeLoader: {
type: String,
default: null,
},
activeGameVersion: {
type: String,
default: null,
},
})
const emit = defineEmits(['open', 'install'])
@@ -122,21 +170,15 @@ async function install() {
null,
props.instance ? props.instance.path : null,
'SearchCard',
(versionId) => {
() => {
installing.value = false
if (versionId) {
emit('install', props.project.project_id ?? props.project.id)
}
emit('install', props.project.project_id ?? props.project.id)
},
(profile) => {
router.push(`/instance/${profile}`)
},
{
preferredLoader: props.activeLoader ?? undefined,
preferredGameVersion: props.activeGameVersion ?? undefined,
},
).catch(handleError)
}
const modpack = computed(() => props.project.project_types?.includes('modpack'))
const modpack = computed(() => props.project.project_type === 'modpack')
</script>
@@ -114,7 +114,7 @@ watch(loading, (newValue) => {
setTimeout(() => {
hidden.value = true
loading.setEnabled(true)
}, 50)
}, 250)
}
}
})
@@ -135,6 +135,9 @@ loading_listener(async (e) => {
if (e.event.type === 'directory_move') {
loadingProgress.value = 100 * (e.fraction ?? 1)
message.value = 'Updating app directory...'
} else if (e.event.type === 'launcher_update') {
loadingProgress.value = 100 * (e.fraction ?? 1)
message.value = 'Updating Modrinth App...'
} else if (e.event.type === 'checking_for_updates') {
loadingProgress.value = 100 * (e.fraction ?? 1)
message.value = 'Checking for updates...'
@@ -191,8 +194,7 @@ const handleClose = async () => {
position: absolute;
height: 100vh;
width: 100vw;
background:
linear-gradient(180deg, rgba(66, 131, 92, 0.275) 0%, rgba(17, 35, 43, 0.5) 97.29%),
background: linear-gradient(180deg, rgba(66, 131, 92, 0.275) 0%, rgba(17, 35, 43, 0.5) 97.29%),
linear-gradient(0deg, rgba(22, 24, 28, 0.64), rgba(22, 24, 28, 0.64));
z-index: 9997;
}
@@ -6,10 +6,9 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get_project, get_version } from '@/helpers/cache.js'
import { get_categories } from '@/helpers/tags.js'
import { injectContentInstall } from '@/providers/content-install'
import { install as installVersion } from '@/store/install.js'
const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const confirmModal = ref(null)
const project = ref(null)
@@ -1,42 +1,41 @@
<script setup lang="ts">
import { MailIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import {
MailIcon,
MoreVerticalIcon,
SettingsIcon,
TrashIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
defineMessages,
injectNotificationManager,
IntlFormatted,
StyledInput,
OverflowMenu,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { computed, onUnmounted, ref, watch } from 'vue'
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_user_many } from '@/helpers/cache'
import { friend_listener } from '@/helpers/events'
import {
add_friend,
friends,
type FriendWithUserData,
remove_friend,
transformFriends,
} from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
const { formatMessage } = useVIntl()
import { add_friend, friend_statuses, friends, remove_friend } from '@/helpers/friends'
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime()
const props = defineProps<{
credentials: ModrinthCredentials | null
credentials: unknown | null
signIn: () => void
}>()
const userCredentials = computed(() => props.credentials)
const search = ref('')
const manageFriendsModal = ref()
const friendInvitesModal = ref()
const username = ref('')
@@ -48,64 +47,61 @@ async function addFriendFromModal() {
await loadFriends()
}
async function addFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await add_friend(id).catch(handleError)
await loadFriends()
const friendOptions = ref()
async function handleFriendOptions(args) {
switch (args.option) {
case 'remove-friend':
await removeFriend(args.item)
break
}
}
async function removeFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await remove_friend(id).catch(handleError)
await loadFriends()
}
async function addFriend(friend: Friend) {
await add_friend(
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
).catch(handleError)
await loadFriends()
}
const userFriends = ref<FriendWithUserData[]>([])
const sortedFriends = computed<FriendWithUserData[]>(() =>
userFriends.value.slice().sort((a, b) => {
if (a.last_updated === null && b.last_updated === null) {
return 0 // Both are null, equal in sorting
}
if (a.last_updated === null) {
return 1 // `a` is null, move it after `b`
}
if (b.last_updated === null) {
return -1 // `b` is null, move it after `a`
}
// Both are non-null, sort by date
return b.last_updated.diff(a.last_updated)
}),
)
const filteredFriends = computed<FriendWithUserData[]>(() =>
sortedFriends.value.filter((x) =>
x.username.trim().toLowerCase().includes(search.value.trim().toLowerCase()),
),
)
async function removeFriend(friend: Friend) {
await remove_friend(
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
).catch(handleError)
await loadFriends()
}
const activeFriends = computed<FriendWithUserData[]>(() =>
filteredFriends.value.filter((x) => !!x.status && x.online && x.accepted),
)
const onlineFriends = computed<FriendWithUserData[]>(() =>
filteredFriends.value.filter((x) => x.online && !x.status && x.accepted),
)
const offlineFriends = computed<FriendWithUserData[]>(() =>
filteredFriends.value.filter((x) => !x.online && x.accepted),
type Friend = {
id: string
friend_id: string | null
status: string | null
last_updated: Dayjs | null
created: Dayjs
username: string
accepted: boolean
online: boolean
avatar: string
}
const userFriends = ref<Friend[]>([])
const acceptedFriends = computed(() =>
userFriends.value
.filter((x) => x.accepted)
.toSorted((a, b) => {
if (a.last_updated === null && b.last_updated === null) {
return 0 // Both are null, equal in sorting
}
if (a.last_updated === null) {
return 1 // `a` is null, move it after `b`
}
if (b.last_updated === null) {
return -1 // `b` is null, move it after `a`
}
// Both are non-null, sort by date
return b.last_updated.diff(a.last_updated)
}),
)
const pendingFriends = computed(() =>
filteredFriends.value
.filter((x) => !x.accepted && x.id !== userCredentials.value?.user_id)
.slice()
.sort((a, b) => b.created.diff(a.created)),
)
const incomingRequests = computed(() =>
userFriends.value
.filter((x) => !x.accepted && x.id === userCredentials.value?.user_id)
.slice()
.sort((a, b) => b.created.diff(a.created)),
userFriends.value.filter((x) => !x.accepted).toSorted((a, b) => b.created.diff(a.created)),
)
const loading = ref(true)
@@ -114,7 +110,34 @@ async function loadFriends(timeout = false) {
try {
const friendsList = await friends()
userFriends.value = await transformFriends(friendsList, userCredentials.value)
if (friendsList.length === 0) {
userFriends.value = []
} else {
const friendStatuses = await friend_statuses()
const users = await get_user_many(
friendsList.map((x) => (x.id === userCredentials.value.user_id ? x.friend_id : x.id)),
)
userFriends.value = friendsList.map((friend) => {
const user = users.find((x) => x.id === friend.id || x.id === friend.friend_id)
const status = friendStatuses.find(
(x) => x.user_id === friend.id || x.user_id === friend.friend_id,
)
return {
id: friend.id,
friend_id: friend.friend_id,
status: status?.profile_name,
last_updated: status && status.last_update ? dayjs(status.last_update) : null,
created: dayjs(friend.created),
avatar: user?.avatar_url,
username: user?.username,
online: !!status,
accepted: friend.accepted,
}
})
}
loading.value = false
} catch (e) {
console.error('Error loading friends', e)
@@ -129,7 +152,6 @@ watch(
() => {
if (userCredentials.value === undefined) {
userFriends.value = []
loading.value = false
} else if (userCredentials.value === null) {
userFriends.value = []
loading.value = false
@@ -144,87 +166,49 @@ const unlisten = await friend_listener(() => loadFriends())
onUnmounted(() => {
unlisten()
})
const messages = defineMessages({
addFriend: {
id: 'friends.action.add-friend',
defaultMessage: 'Add a friend',
},
addingAFriend: {
id: 'friends.add-friend.title',
defaultMessage: 'Adding a friend',
},
usernameTitle: {
id: 'friends.add-friend.username.title',
defaultMessage: "What's your friend's Modrinth username?",
},
usernameDescription: {
id: 'friends.add-friend.username.description',
defaultMessage: 'It may be different from their Minecraft username!',
},
usernamePlaceholder: {
id: 'friends.add-friend.username.placeholder',
defaultMessage: 'Enter Modrinth username...',
},
sendFriendRequest: {
id: 'friends.add-friend.submit',
defaultMessage: 'Send friend request',
},
viewFriendRequests: {
id: 'friends.action.view-friend-requests',
defaultMessage: '{count} friend {count, plural, one {request} other {requests}}',
},
searchFriends: {
id: 'friends.search-friends-placeholder',
defaultMessage: 'Search friends...',
},
friends: {
id: 'friends.heading',
defaultMessage: 'Friends',
},
pending: {
id: 'friends.heading.pending',
defaultMessage: 'Pending',
},
active: {
id: 'friends.heading.active',
defaultMessage: 'Active',
},
online: {
id: 'friends.heading.online',
defaultMessage: 'Online',
},
offline: {
id: 'friends.heading.offline',
defaultMessage: 'Offline',
},
noFriendsMatch: {
id: 'friends.no-friends-match',
defaultMessage: `No friends matching ''{query}''`,
},
signInToAddFriends: {
id: 'friends.sign-in-to-add-friends',
defaultMessage:
"<link>Sign in to a Modrinth account</link> to add friends and see what they're playing!",
},
addFriendsToShare: {
id: 'friends.add-friends-to-share',
defaultMessage: "<link>Add friends</link> to see what they're playing!",
},
})
</script>
<template>
<ModalWrapper ref="manageFriendsModal" header="Manage friends">
<p v-if="acceptedFriends.length === 0">Add friends to share what you're playing!</p>
<div v-else class="flex flex-col gap-4 min-w-[20rem]">
<input v-model="search" type="text" placeholder="Search friends..." class="w-full" />
<div
v-for="friend in acceptedFriends.filter(
(x) => !search || x.username.toLowerCase().includes(search),
)"
:key="friend.username"
class="flex gap-2 items-center"
>
<div class="relative">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<span
v-if="friend.online"
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div>{{ friend.username }}</div>
<div class="ml-auto">
<ButtonStyled>
<button @click="removeFriend(friend)">
<XIcon />
Remove
</button>
</ButtonStyled>
</div>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="friendInvitesModal" header="View friend requests">
<p v-if="incomingRequests.length === 0">You have no pending friend requests :C</p>
<div v-else class="flex flex-col gap-4 min-w-[40rem]">
<div v-for="friend in incomingRequests" :key="friend.username" class="flex gap-2">
<p v-if="pendingFriends.length === 0">You have no pending friend requests :C</p>
<div v-else class="flex flex-col gap-4">
<div v-for="friend in pendingFriends" :key="friend.username" class="flex gap-2">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<div class="grid grid-cols-[1fr_auto] w-full gap-4">
<div class="flex flex-col gap-2">
<div>
<p class="m-0">
<template v-if="friend.id === userCredentials?.user_id">
<span class="text-contrast">{{ friend.username }}</span> sent you a friend request
<template v-if="friend.id === userCredentials.user_id">
<span class="font-bold">{{ friend.username }}</span> sent you a friend request
</template>
<template v-else>
You sent <span class="font-bold">{{ friend.username }}</span> a friend request
@@ -235,7 +219,7 @@ const messages = defineMessages({
</p>
</div>
<div class="flex gap-2">
<template v-if="friend.id === userCredentials?.user_id">
<template v-if="friend.id === userCredentials.user_id">
<ButtonStyled color="brand">
<button @click="addFriend(friend)">
<UserPlusIcon />
@@ -262,80 +246,78 @@ const messages = defineMessages({
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="addFriendModal" :header="formatMessage(messages.addingAFriend)">
<div class="min-w-[30rem]">
<h2 class="m-0 text-base font-medium text-primary">
{{ formatMessage(messages.usernameTitle) }}
</h2>
<p class="m-0 mt-1 text-sm text-secondary leading-tight">
{{ formatMessage(messages.usernameDescription) }}
</p>
<div class="flex items-center gap-2 mt-4">
<StyledInput
v-model="username"
:icon="UserIcon"
type="text"
:placeholder="formatMessage(messages.usernamePlaceholder)"
wrapper-class="flex-1"
@keyup.enter="addFriendFromModal"
/>
<ButtonStyled color="brand">
<button :disabled="username.length === 0" @click="addFriendFromModal">
<SendIcon />
{{ formatMessage(messages.sendFriendRequest) }}
</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
<div v-if="userCredentials && !loading" class="flex gap-1 items-center mb-3 ml-2 mr-1">
<template v-if="sortedFriends.length > 0">
<ButtonStyled circular type="transparent">
<button
v-tooltip="formatMessage(messages.addFriend)"
:aria-label="formatMessage(messages.addFriend)"
@click="addFriendModal.show"
>
<UserPlusIcon />
</button>
</ButtonStyled>
<StyledInput
v-model="search"
<ModalWrapper ref="addFriendModal" header="Add a friend">
<div class="mb-4">
<h2 class="m-0 text-lg font-extrabold text-contrast">Username</h2>
<p class="m-0 mt-1 leading-tight">You can add friends with their Modrinth username.</p>
<input
v-model="username"
class="mt-2 w-full"
type="text"
:placeholder="formatMessage(messages.searchFriends)"
clearable
variant="outlined"
wrapper-class="flex-1"
@keyup.esc="search = ''"
placeholder="Enter username..."
@keyup.enter="addFriendFromModal"
/>
</template>
<h3 v-else class="ml-2 w-full text-base text-primary font-medium m-0">
{{ formatMessage(messages.friends) }}
</h3>
<ButtonStyled v-if="incomingRequests.length > 0" circular type="transparent">
<button
v-tooltip="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
class="relative"
:aria-label="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
@click="friendInvitesModal.show"
>
<MailIcon />
<span
v-if="incomingRequests.length > 0"
aria-hidden="true"
class="absolute bg-brand text-brand-inverted text-[8px] top-0.5 px-1 right-0.5 min-w-3 h-3 rounded-full flex items-center justify-center font-bold"
>
{{ incomingRequests.length }}
</span>
</div>
<ButtonStyled color="brand">
<button :disabled="username.length === 0" @click="addFriendFromModal">
<UserPlusIcon />
Add friend
</button>
</ButtonStyled>
</ModalWrapper>
<div class="flex justify-between items-center">
<h3 class="text-lg m-0">Friends</h3>
<ButtonStyled v-if="userCredentials" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'add-friend',
action: () => addFriendModal.show(),
},
{
id: 'manage-friends',
action: () => manageFriendsModal.show(),
shown: acceptedFriends.length > 0,
},
{
id: 'view-requests',
action: () => friendInvitesModal.show(),
shown: pendingFriends.length > 0,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #add-friend>
<UserPlusIcon aria-hidden="true" />
Add friend
</template>
<template #manage-friends>
<SettingsIcon aria-hidden="true" />
Manage friends
<div
v-if="acceptedFriends.length > 0"
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
>
{{ acceptedFriends.length }}
</div>
</template>
<template #view-requests>
<MailIcon aria-hidden="true" />
View friend requests
<div
v-if="pendingFriends.length > 0"
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
>
{{ pendingFriends.length }}
</div>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
<div class="flex flex-col gap-3">
<h3 v-if="loading" class="ml-4 mr-1 text-base text-primary font-medium m-0">
{{ formatMessage(messages.friends) }}
</h3>
<div class="flex flex-col gap-2 mt-2">
<template v-if="loading">
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse ml-4 mr-1">
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse">
<div class="min-w-9 min-h-9 bg-button-bg rounded-full"></div>
<div class="flex flex-col w-full">
<div class="h-3 bg-button-bg rounded-full w-1/2 mb-1"></div>
@@ -343,64 +325,50 @@ const messages = defineMessages({
</div>
</div>
</template>
<template v-else-if="sortedFriends.length === 0">
<div class="text-sm ml-4 mr-1">
<template v-else-if="acceptedFriends.length === 0">
<div class="text-sm">
<div v-if="!userCredentials">
<IntlFormatted :message-id="messages.signInToAddFriends">
<template #link="{ children }">
<span class="font-semibold text-brand cursor-pointer" @click="signIn">
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
<span class="text-link cursor-pointer" @click="signIn">Sign in</span> to add friends!
</div>
<div v-else>
<IntlFormatted :message-id="messages.addFriendsToShare">
<template #link="{ children }">
<span class="font-semibold text-brand cursor-pointer" @click="addFriendModal.show">
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
<span class="text-link cursor-pointer" @click="addFriendModal.show()">Add friends</span>
to share what you're playing!
</div>
</div>
</template>
<template v-else>
<FriendsSection
v-if="activeFriends.length > 0"
:is-searching="!!search"
open-by-default
:friends="activeFriends"
:heading="formatMessage(messages.active)"
:remove-friend="removeFriend"
/>
<FriendsSection
v-if="onlineFriends.length > 0"
:is-searching="!!search"
open-by-default
:friends="onlineFriends"
:heading="formatMessage(messages.online)"
:remove-friend="removeFriend"
/>
<FriendsSection
v-if="offlineFriends.length > 0"
:is-searching="!!search"
:open-by-default="activeFriends.length + onlineFriends.length < 3"
:friends="offlineFriends"
:heading="formatMessage(messages.offline)"
:remove-friend="removeFriend"
/>
<FriendsSection
v-if="pendingFriends.length > 0"
:is-searching="!!search"
open-by-default
:friends="pendingFriends"
:heading="formatMessage(messages.pending)"
:remove-friend="removeFriend"
/>
<p v-if="filteredFriends.length === 0 && search" class="text-sm text-secondary my-1 mx-4">
{{ formatMessage(messages.noFriendsMatch, { query: search }) }}
</p>
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
<template #remove-friend> <TrashIcon /> Remove friend </template>
</ContextMenu>
<div
v-for="friend in acceptedFriends.slice(0, 5)"
:key="friend.username"
class="flex gap-2 items-center"
:class="{ grayscale: !friend.online }"
@contextmenu.prevent.stop="
(event) =>
friendOptions.showMenu(event, friend, [
{
name: 'remove-friend',
color: 'danger',
},
])
"
>
<div class="relative">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<span
v-if="friend.online"
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div class="flex flex-col">
<span class="text-md m-0 font-medium" :class="{ 'text-secondary': !friend.online }">
{{ friend.username }}
</span>
<span v-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div>
</div>
</template>
</div>
</template>
@@ -1,191 +0,0 @@
<script setup lang="ts">
import { MoreVerticalIcon, TrashIcon, UserIcon, XIcon } from '@modrinth/assets'
import {
Accordion,
Avatar,
ButtonStyled,
defineMessages,
OverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { useTemplateRef } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import type { FriendWithUserData } from '@/helpers/friends.ts'
const { formatMessage } = useVIntl()
const props = withDefaults(
defineProps<{
friends: FriendWithUserData[]
heading: string
removeFriend: (friend: FriendWithUserData) => Promise<void>
isSearching?: boolean
openByDefault?: boolean
}>(),
{
isSearching: false,
openByDefault: false,
},
)
function createContextMenuOptions(friend: FriendWithUserData) {
if (friend.accepted) {
return [
{
name: 'view-profile',
},
{
name: 'remove-friend',
color: 'danger',
},
]
} else {
return [
{
name: 'view-profile',
},
{
name: 'cancel-request',
},
]
}
}
function openProfile(username: string) {
openUrl('https://modrinth.com/user/' + username)
}
const friendOptions = useTemplateRef('friendOptions')
async function handleFriendOptions(args: { item: FriendWithUserData; option: string }) {
switch (args.option) {
case 'remove-friend':
case 'cancel-request':
await props.removeFriend(args.item)
break
case 'view-profile':
openProfile(args.item.username)
}
}
const messages = defineMessages({
removeFriend: {
id: 'friends.friend.remove-friend',
defaultMessage: 'Remove friend',
},
heading: {
id: 'friends.section.heading',
defaultMessage: '{title} - {count}',
},
friendRequestSent: {
id: 'friends.friend.request-sent',
defaultMessage: 'Friend request sent',
},
cancelRequest: {
id: 'friends.friend.cancel-request',
defaultMessage: 'Cancel request',
},
viewProfile: {
id: 'friends.friend.view-profile',
defaultMessage: 'View profile',
},
})
</script>
<template>
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
<template #view-profile>
<UserIcon />
{{ formatMessage(messages.viewProfile) }}
</template>
<template #remove-friend> <TrashIcon /> {{ formatMessage(messages.removeFriend) }} </template>
<template #cancel-request> <XIcon /> {{ formatMessage(messages.cancelRequest) }} </template>
</ContextMenu>
<Accordion
:open-by-default="openByDefault"
:force-open="isSearching"
:button-class="
'pl-4 pr-3 flex w-full items-center bg-transparent border-0 p-0' +
(isSearching
? ''
: ' cursor-pointer hover:brightness-[--hover-brightness] active:scale-[0.98] transition-all')
"
>
<template #title>
<h3 class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.heading, { title: heading, count: friends.length }) }}
</h3>
</template>
<template #default>
<div class="pt-3 flex flex-col gap-1">
<div
v-for="friend in friends"
:key="friend.username"
class="group grid items-center grid-cols-[auto_1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full ml-4 mr-1"
@contextmenu.prevent.stop="
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
"
>
<div class="relative">
<Avatar
:src="friend.avatar"
:class="{ grayscale: !friend.online && friend.accepted }"
class="w-12 h-12 rounded-full"
size="32px"
circle
/>
<span
v-if="friend.online"
aria-hidden="true"
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div class="flex flex-col">
<span
class="text-sm m-0"
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
>
{{ friend.username }}
</span>
<span v-if="!friend.accepted" class="m-0 text-xs">
{{ formatMessage(messages.friendRequestSent) }}
</span>
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div>
<ButtonStyled v-if="friend.accepted" circular type="transparent">
<OverflowMenu
class="opacity-0 group-hover:opacity-100 transition-opacity"
:options="[
{
id: 'view-profile',
action: () => openProfile(friend.username),
},
{
id: 'remove-friend',
action: () => removeFriend(friend),
color: 'red',
},
]"
>
<MoreVerticalIcon />
<template #view-profile>
<UserIcon />
{{ formatMessage(messages.viewProfile) }}
</template>
<template #remove-friend>
<TrashIcon />
{{ formatMessage(messages.removeFriend) }}
</template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled v-else type="transparent" circular>
<button v-tooltip="formatMessage(messages.cancelRequest)" @click="removeFriend(friend)">
<XIcon />
</button>
</ButtonStyled>
</div>
</div>
</template>
</Accordion>
</template>
@@ -1,125 +0,0 @@
<script setup>
import { CheckIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
injectNotificationManager,
StyledInput,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { list } from '@/helpers/profile'
import { add_server_to_profile, get_profile_worlds } from '@/helpers/worlds.ts'
const { handleError } = injectNotificationManager()
const modal = ref()
const searchFilter = ref('')
const profiles = ref([])
const serverName = ref('')
const serverAddress = ref('')
const shownProfiles = computed(() =>
profiles.value.filter((profile) => {
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
}),
)
defineExpose({
show: async (name, address) => {
serverName.value = name
serverAddress.value = address
searchFilter.value = ''
const profilesVal = await list().catch(handleError)
await Promise.allSettled(
profilesVal.map(async (profile) => {
profile.adding = false
profile.added = false
try {
const worlds = await get_profile_worlds(profile.path)
profile.added = worlds.some(
(w) => w.type === 'server' && w.address === serverAddress.value,
)
} catch {
// Ignore - will show as not added
}
}),
)
profiles.value = profilesVal
modal.value.show()
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
},
})
async function addServer(profile) {
profile.adding = true
try {
await add_server_to_profile(profile.path, serverName.value, serverAddress.value, 'prompt')
profile.added = true
trackEvent('AddServerToInstance', {
server_name: serverName.value,
instance_name: profile.name,
source: 'AddServerToInstanceModal',
})
} catch (err) {
handleError(err)
}
profile.adding = false
}
</script>
<template>
<ModalWrapper ref="modal" header="Add server to instance">
<div class="flex flex-col gap-4 min-w-[350px]">
<Admonition type="warning" body="This server may not be compatible with all instances." />
<StyledInput
v-model="searchFilter"
:icon="SearchIcon"
type="search"
placeholder="Search for an instance"
autocomplete="off"
/>
<div class="max-h-[21rem] overflow-y-auto">
<div
v-for="profile in shownProfiles"
:key="profile.path"
class="flex w-full items-center justify-between gap-2 bg-bg-raised text-icon shadow-none"
>
<router-link
class="btn btn-transparent p-2 text-left"
:to="`/instance/${encodeURIComponent(profile.path)}`"
@click="modal.hide()"
>
<Avatar
:src="profile.icon_path ? convertFileSrc(profile.icon_path) : null"
class="mr-2 [--size:2rem]"
/>
{{ profile.name }}
</router-link>
<ButtonStyled>
<button :disabled="profile.added || profile.adding" @click="addServer(profile)">
<PlusIcon v-if="!profile.added && !profile.adding" />
<CheckIcon v-else-if="profile.added" />
{{ profile.adding ? 'Adding...' : profile.added ? 'Added' : 'Add' }}
</button>
</ButtonStyled>
</div>
</div>
<div class="input-group push-right">
<ButtonStyled>
<button @click="modal.hide()">Cancel</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
</template>
@@ -17,17 +17,29 @@
<tr class="content">
<td class="data">{{ instance?.loader }} {{ instance?.game_version }}</td>
<td>
<Combobox
<multiselect
v-if="versions?.length > 1"
v-model="selectedVersionId"
:options="versionOptions"
v-model="selectedVersion"
:options="versions"
:searchable="true"
placeholder="Select version"
force-direction="up"
open-direction="top"
:show-labels="false"
:custom-label="
(version) =>
`${version?.name} (${version?.loaders
.map((name) => formatCategory(name))
.join(', ')} - ${version?.game_versions.join(', ')})`
"
:max-height="150"
/>
<span v-else>
<span>{{ selectedVersionLabel }}</span>
<span>
{{ selectedVersion?.name }} ({{
selectedVersion?.loaders.map((name) => formatCategory(name)).join(', ')
}}
- {{ selectedVersion?.game_versions.join(', ') }})
</span>
</span>
</td>
</tr>
@@ -45,15 +57,16 @@
<script setup>
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { Button, Combobox, formatLoader, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { ref } from 'vue'
import Multiselect from 'vue-multiselect'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { add_project_from_version as installMod } from '@/helpers/profile'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const instance = ref(null)
const project = ref(null)
@@ -64,35 +77,11 @@ const installing = ref(false)
const onInstall = ref(() => {})
const selectedVersionLabel = computed(() => {
if (!selectedVersion.value) return ''
return `${selectedVersion.value.name} (${selectedVersion.value.loaders
.map((name) => formatLoader(formatMessage, name))
.join(', ')} - ${selectedVersion.value.game_versions.join(', ')})`
})
const versionOptions = computed(() =>
(versions.value ?? []).map((version) => ({
value: version.id,
label: `${version.name} (${version.loaders
.map((name) => formatLoader(formatMessage, name))
.join(', ')} - ${version.game_versions.join(', ')})`,
})),
)
const selectedVersionId = computed({
get: () => selectedVersion.value?.id ?? null,
set: (value) => {
if (!value) return
selectedVersion.value = (versions.value ?? []).find((version) => version.id === value) ?? null
},
})
defineExpose({
show: (instanceVal, projectVal, projectVersions, selected, callback) => {
instance.value = instanceVal
versions.value = projectVersions ?? []
selectedVersion.value = selected ?? projectVersions?.[0] ?? null
versions.value = projectVersions
selectedVersion.value = selected ?? projectVersions[0]
project.value = projectVal
@@ -171,5 +160,9 @@ td:first-child {
display: flex;
flex-direction: column;
gap: 1rem;
:deep(.animated-dropdown .options) {
max-height: 13.375rem;
}
}
</style>
@@ -0,0 +1,77 @@
<script setup>
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { create_profile_and_install as pack_install } from '@/helpers/pack'
const { handleError } = injectNotificationManager()
const versionId = ref()
const project = ref()
const confirmModal = ref(null)
const installing = ref(false)
const onInstall = ref(() => {})
const onCreateInstance = ref(() => {})
defineExpose({
show: (projectVal, versionIdVal, callback, createInstanceCallback) => {
project.value = projectVal
versionId.value = versionIdVal
installing.value = false
confirmModal.value.show()
onInstall.value = callback
onCreateInstance.value = createInstanceCallback
trackEvent('PackInstallStart')
},
})
async function install() {
installing.value = true
confirmModal.value.hide()
await pack_install(
project.value.id,
versionId.value,
project.value.title,
project.value.icon_url,
onCreateInstance.value,
).catch(handleError)
trackEvent('PackInstall', {
id: project.value.id,
version_id: versionId.value,
title: project.value.title,
source: 'ConfirmModal',
})
onInstall.value(versionId.value)
installing.value = false
}
</script>
<template>
<ModalWrapper ref="confirmModal" header="Are you sure?" :on-hide="onInstall">
<div class="modal-body">
<p>You already have this modpack installed. Are you sure you want to install it again?</p>
<div class="input-group push-right">
<Button @click="() => $refs.confirmModal.hide()"><XIcon />Cancel</Button>
<Button color="primary" :disabled="installing" @click="install()"
><DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}</Button
>
</div>
</div>
</ModalWrapper>
</template>
<style lang="scss" scoped>
.modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>
@@ -4,11 +4,10 @@ import {
DownloadIcon,
PlusIcon,
RightArrowIcon,
SearchIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { Avatar, Button, Card, injectNotificationManager, StyledInput } from '@modrinth/ui'
import { Avatar, Button, Card, injectNotificationManager } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { computed, ref } from 'vue'
@@ -16,7 +15,6 @@ import { useRouter } from 'vue-router'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3_many } from '@/helpers/cache.js'
import {
add_project_from_version as installMod,
check_installed,
@@ -50,14 +48,19 @@ const creatingInstance = ref(false)
const profiles = ref([])
const shownProfiles = computed(() =>
profiles.value.filter((profile) => {
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
}),
profiles.value
.filter((profile) => {
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
})
.filter((profile) => {
const version = {
game_versions: versions.value.flatMap((v) => v.game_versions),
loaders: versions.value.flatMap((v) => v.loaders),
}
return isVersionCompatible(version, project.value, profile)
}),
)
const isProfileCompatible = (profile) =>
versions.value?.some((version) => isVersionCompatible(version, project.value, profile))
const onInstall = ref(() => {})
defineExpose({
@@ -82,22 +85,6 @@ defineExpose({
handleError,
)
}
const linkedProjectIds = profilesVal
.filter((p) => p.linked_data?.project_id)
.map((p) => p.linked_data.project_id)
if (linkedProjectIds.length > 0) {
const linkedProjects = await get_project_v3_many(linkedProjectIds, 'must_revalidate').catch(
() => [],
)
const serverProjectIds = new Set(
linkedProjects.filter((p) => p?.minecraft_server != null).map((p) => p.id),
)
for (const profile of profilesVal) {
profile.isServerInstance = serverProjectIds.has(profile.linked_data?.project_id)
}
}
profiles.value = profilesVal
installModal.value.show()
@@ -172,21 +159,20 @@ const reset_icon = () => {
const createInstance = async () => {
creatingInstance.value = true
const gameVersions = versions.value[0].game_versions
const gameVersion = gameVersions[0]
const loader =
versions.value[0].loaders[0] !== 'forge' &&
versions.value[0].loaders[0] !== 'fabric' &&
versions.value[0].loaders[0] !== 'quilt'
? 'vanilla'
: versions.value[0].loaders[0]
const loaders = versions.value[0].loaders
const loader = loaders.includes('fabric')
? 'fabric'
: loaders.includes('neoforge')
? 'neoforge'
: loaders.includes('forge')
? 'forge'
: loaders.includes('quilt')
? 'quilt'
: 'vanilla'
const id = await create(name.value, gameVersion, loader, 'latest', icon.value).catch(handleError)
const id = await create(
name.value,
versions.value[0].game_versions[0],
loader,
'latest',
icon.value,
).catch(handleError)
await installMod(id, versions.value[0].id).catch(handleError)
@@ -224,12 +210,12 @@ const createInstance = async () => {
<template>
<ModalWrapper ref="installModal" header="Install project to instance" :on-hide="onInstall">
<div class="modal-body">
<StyledInput
<input
v-model="searchFilter"
:icon="SearchIcon"
type="search"
placeholder="Search for an instance"
autocomplete="off"
type="text"
class="search"
placeholder="Search for an instance"
/>
<div class="profiles" :class="{ 'hide-creation': !showCreation }">
<div v-for="profile in shownProfiles" :key="profile.name" class="option">
@@ -252,23 +238,17 @@ const createInstance = async () => {
"
>
<Button
:disabled="
!isProfileCompatible(profile) || profile.installedMod || profile.installing
"
:disabled="profile.installedMod || profile.installing"
@click="install(profile)"
>
<DownloadIcon
v-if="isProfileCompatible(profile) && !profile.installedMod && !profile.installing"
/>
<DownloadIcon v-if="!profile.installedMod && !profile.installing" />
<CheckIcon v-else-if="profile.installedMod" />
{{
profile.installing
? 'Installing...'
: profile.installedMod
? 'Installed'
: !isProfileCompatible(profile)
? 'Incompatible'
: 'Install'
: 'Install'
}}
</Button>
</div>
@@ -290,7 +270,7 @@ const createInstance = async () => {
</div>
</div>
<div class="creation-settings">
<StyledInput
<input
v-model="name"
autocomplete="off"
type="text"
@@ -4,23 +4,20 @@ import {
Avatar,
ButtonStyled,
Checkbox,
defineMessages,
injectNotificationManager,
OverflowMenu,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, type Ref, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { duplicate, edit, edit_icon, list, remove } from '@/helpers/profile'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { GameInstance } from '../../../helpers/types'
import type { GameInstance, InstanceSettingsTabProps } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -28,21 +25,21 @@ const router = useRouter()
const deleteConfirmModal = ref()
const { instance } = injectInstanceSettings()
const props = defineProps<InstanceSettingsTabProps>()
const title = ref(instance.name)
const icon: Ref<string | undefined> = ref(instance.icon_path)
const groups = ref(instance.groups)
const title = ref(props.instance.name)
const icon: Ref<string | undefined> = ref(props.instance.icon_path)
const groups = ref(props.instance.groups)
const newCategoryInput = ref('')
const installing = computed(() => instance.install_stage !== 'installed')
const installing = computed(() => props.instance.install_stage !== 'installed')
async function duplicateProfile() {
await duplicate(instance.path).catch(handleError)
await duplicate(props.instance.path).catch(handleError)
trackEvent('InstanceDuplicate', {
loader: instance.loader,
game_version: instance.game_version,
loader: props.instance.loader,
game_version: props.instance.game_version,
})
}
@@ -53,7 +50,7 @@ const availableGroups = computed(() => [
async function resetIcon() {
icon.value = undefined
await edit_icon(instance.path, null).catch(handleError)
await edit_icon(props.instance.path, null).catch(handleError)
trackEvent('InstanceRemoveIcon')
}
@@ -71,7 +68,7 @@ async function setIcon() {
if (!value) return
icon.value = value
await edit_icon(instance.path, icon.value).catch(handleError)
await edit_icon(props.instance.path, icon.value).catch(handleError)
trackEvent('InstanceSetIcon')
}
@@ -101,8 +98,7 @@ const addCategory = () => {
watch(
[title, groups, groups],
async () => {
if (removing.value) return
await edit(instance.path, editProfileObject.value).catch(handleError)
await edit(props.instance.path, editProfileObject.value)
},
{ deep: true },
)
@@ -110,15 +106,15 @@ watch(
const removing = ref(false)
async function removeProfile() {
removing.value = true
const path = instance.path
await remove(props.instance.path).catch(handleError)
removing.value = false
trackEvent('InstanceRemove', {
loader: instance.loader,
game_version: instance.game_version,
loader: props.instance.loader,
game_version: props.instance.game_version,
})
await router.push({ path: '/' })
await remove(path).catch(handleError)
}
const messages = defineMessages({
@@ -196,7 +192,15 @@ const messages = defineMessages({
</script>
<template>
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="removeProfile" />
<ConfirmModalWrapper
ref="deleteConfirmModal"
title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be permanently erased, including your worlds. You will not be able to recover it."
:has-to-type="false"
proceed-label="Delete"
:show-ad-on-close="false"
@proceed="removeProfile"
/>
<div class="block">
<div class="float-end ml-4 relative group">
<OverflowMenu
@@ -219,7 +223,7 @@ const messages = defineMessages({
:src="icon ? convertFileSrc(icon) : icon"
size="108px"
class="!border-4 group-hover:brightness-75"
:tint-by="instance.path"
:tint-by="props.instance.path"
no-shadow
/>
<div class="absolute top-0 right-0 m-2">
@@ -240,12 +244,13 @@ const messages = defineMessages({
{{ formatMessage(messages.name) }}
</label>
<div class="flex">
<StyledInput
<input
id="instance-name"
v-model="title"
autocomplete="off"
:maxlength="80"
wrapper-class="flex-grow"
maxlength="80"
class="flex-grow"
type="text"
/>
</div>
<template v-if="instance.install_stage == 'installed'">
@@ -286,8 +291,9 @@ const messages = defineMessages({
@click="toggleGroup(group)"
/>
<div class="flex gap-2 items-center">
<StyledInput
<input
v-model="newCategoryInput"
type="text"
:placeholder="formatMessage(messages.libraryGroupsEnterName)"
@submit="() => addCategory"
/>
@@ -1,30 +1,26 @@
<script setup lang="ts">
import {
Checkbox,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { Checkbox, injectNotificationManager } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, ref, watch } from 'vue'
import { edit } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { AppSettings, Hooks } from '../../../helpers/types'
import type { AppSettings, Hooks, InstanceSettingsTabProps } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { instance } = injectInstanceSettings()
const props = defineProps<InstanceSettingsTabProps>()
const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideHooks = ref(
!!instance.hooks.pre_launch || !!instance.hooks.wrapper || !!instance.hooks.post_exit,
!!props.instance.hooks.pre_launch ||
!!props.instance.hooks.wrapper ||
!!props.instance.hooks.post_exit,
)
const hooks = ref(instance.hooks ?? globalSettings.hooks)
const hooks = ref(props.instance.hooks ?? globalSettings.hooks)
const editProfileObject = computed(() => {
const editProfile: {
@@ -40,7 +36,7 @@ const editProfileObject = computed(() => {
watch(
[overrideHooks, hooks],
async () => {
await edit(instance.path, editProfileObject.value)
await edit(props.instance.path, editProfileObject.value)
},
{ deep: true },
)
@@ -113,13 +109,14 @@ const messages = defineMessages({
<p class="m-0">
{{ formatMessage(messages.preLaunchDescription) }}
</p>
<StyledInput
<input
id="pre-launch"
v-model="hooks.pre_launch"
autocomplete="off"
:disabled="!overrideHooks"
type="text"
:placeholder="formatMessage(messages.preLaunchEnter)"
wrapper-class="w-full mt-2"
class="w-full mt-2"
/>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast">
@@ -128,13 +125,14 @@ const messages = defineMessages({
<p class="m-0">
{{ formatMessage(messages.wrapperDescription) }}
</p>
<StyledInput
<input
id="wrapper"
v-model="hooks.wrapper"
autocomplete="off"
:disabled="!overrideHooks"
type="text"
:placeholder="formatMessage(messages.wrapperEnter)"
wrapper-class="w-full mt-2"
class="w-full mt-2"
/>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast">
@@ -143,13 +141,14 @@ const messages = defineMessages({
<p class="m-0">
{{ formatMessage(messages.postExitDescription) }}
</p>
<StyledInput
<input
id="post-exit"
v-model="hooks.post_exit"
autocomplete="off"
:disabled="!overrideHooks"
type="text"
:placeholder="formatMessage(messages.postExitEnter)"
wrapper-class="w-full mt-2"
class="w-full mt-2"
/>
</div>
</template>
@@ -1,41 +1,62 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
commonMessages,
defineMessages,
formatLoaderLabel,
DownloadIcon,
HammerIcon,
IssuesIcon,
SpinnerIcon,
TransferIcon,
UndoIcon,
UnlinkIcon,
UnplugIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
Checkbox,
Chips,
injectNotificationManager,
InstallationSettingsLayout,
provideAppBackup,
provideInstallationSettings,
useVIntl,
TeleportDropdownMenu,
} from '@modrinth/ui'
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, shallowRef } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache'
import { get_loader_versions } from '@/helpers/metadata'
import {
duplicate,
edit,
get_linked_modpack_info,
install,
list,
update_managed_modrinth_version,
update_repair_modrinth,
} from '@/helpers/profile'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { injectInstanceSettings } from '@/providers/instance-settings'
formatCategory,
type GameVersionTag,
type PlatformTag,
type Project,
type Version,
} from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { computed, type ComputedRef, type Ref, ref, shallowRef, watch } from 'vue'
import type { Manifest } from '../../../helpers/types'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_version_many } from '@/helpers/cache'
import { get_loader_versions } from '@/helpers/metadata'
import { edit, install, update_repair_modrinth } from '@/helpers/profile'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import type {
InstanceSettingsTabProps,
Manifest,
ManifestLoaderVersion,
} from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const { instance, offline, isMinecraftServer, onUnlinked } = injectInstanceSettings()
const repairConfirmModal = ref()
const modpackVersionModal = ref()
const modalConfirmUnpair = ref()
const modalConfirmReinstall = ref()
const props = defineProps<InstanceSettingsTabProps>()
const loader = ref(props.instance.loader)
const gameVersion = ref(props.instance.game_version)
const showSnapshots = ref(false)
const [
fabric_versions,
@@ -72,208 +93,714 @@ const [
.catch(handleError),
])
const { data: modpackInfo } = useQuery({
queryKey: computed(() => ['linkedModpackInfo', instance.path]),
queryFn: () => get_linked_modpack_info(instance.path, 'must_revalidate'),
enabled: computed(() => !!instance.linked_data?.project_id && !offline),
const modpackProject: Ref<Project | null> = ref(null)
const modpackVersion: Ref<Version | null> = ref(null)
const modpackVersions: Ref<Version[] | null> = ref(null)
const fetching = ref(true)
if (props.instance.linked_data && props.instance.linked_data.project_id && !props.offline) {
get_project(props.instance.linked_data.project_id, 'must_revalidate')
.then((project) => {
modpackProject.value = project
if (project && project.versions) {
get_version_many(project.versions, 'must_revalidate')
.then((versions: Version[]) => {
modpackVersions.value = versions.sort((a, b) =>
dayjs(b.date_published).diff(dayjs(a.date_published)),
)
modpackVersion.value =
versions.find(
(version: Version) => version.id === props.instance.linked_data?.version_id,
) ?? null
})
.catch(handleError)
.finally(() => {
fetching.value = false
})
}
})
.catch((err) => {
handleError(err)
fetching.value = false
})
} else {
fetching.value = false
}
const currentLoaderIcon = computed(
() => loaders?.value.find((x) => x.name === props.instance.loader)?.icon,
)
const gameVersionsForLoader = computed(() => {
return all_game_versions?.value.filter((item) => {
if (loader.value === 'fabric') {
return !!fabric_versions?.value.gameVersions.some((x) => item.version === x.id)
} else if (loader.value === 'forge') {
return !!forge_versions?.value.gameVersions.some((x) => item.version === x.id)
} else if (loader.value === 'quilt') {
return !!quilt_versions?.value.gameVersions.some((x) => item.version === x.id)
} else if (loader.value === 'neoforge') {
return !!neoforge_versions?.value.gameVersions.some((x) => item.version === x.id)
}
return []
})
})
const hasSnapshots = computed(() =>
gameVersionsForLoader.value?.some((x) => x.version_type !== 'release'),
)
const selectableGameVersionNumbers = computed(() => {
return gameVersionsForLoader.value
?.filter((x) => x.version_type === 'release' || showSnapshots.value)
.map((x) => x.version)
})
const selectableLoaderVersions: ComputedRef<ManifestLoaderVersion[] | undefined> = computed(() => {
if (gameVersion.value) {
if (loader.value === 'fabric') {
return fabric_versions?.value.gameVersions[0].loaders
} else if (loader.value === 'forge') {
return forge_versions?.value?.gameVersions?.find((item) => item.id === gameVersion.value)
?.loaders
} else if (loader.value === 'quilt') {
return quilt_versions?.value.gameVersions[0].loaders
} else if (loader.value === 'neoforge') {
return neoforge_versions?.value?.gameVersions?.find((item) => item.id === gameVersion.value)
?.loaders
}
}
return []
})
const loaderVersionIndex: Ref<number> = ref(-1)
resetLoaderVersionIndex()
function resetLoaderVersionIndex() {
loaderVersionIndex.value =
selectableLoaderVersions.value?.findIndex((x) => x.id === props.instance.loader_version) ?? -1
}
const isValid = computed(() => {
return (
selectableGameVersionNumbers.value?.includes(gameVersion.value) &&
((loaderVersionIndex.value !== undefined && loaderVersionIndex.value >= 0) ||
loader.value === 'vanilla')
)
})
const isChanged = computed(() => {
return (
loader.value !== props.instance.loader ||
gameVersion.value !== props.instance.game_version ||
(loader.value !== 'vanilla' &&
loaderVersionIndex.value !== undefined &&
loaderVersionIndex.value >= 0 &&
selectableLoaderVersions.value?.[loaderVersionIndex.value].id !==
props.instance.loader_version)
)
})
watch(loader, () => {
loaderVersionIndex.value = 0
})
const editing = ref(false)
async function saveGvLoaderEdits() {
editing.value = true
const editProfile: { loader?: string; game_version?: string; loader_version?: string } = {}
editProfile.loader = loader.value
editProfile.game_version = gameVersion.value
if (loader.value !== 'vanilla' && loaderVersionIndex.value !== undefined) {
editProfile.loader_version = selectableLoaderVersions.value?.[loaderVersionIndex.value].id
} else {
loaderVersionIndex.value = -1
}
console.log('Editing:')
console.log(loader.value)
await edit(props.instance.path, editProfile).catch(handleError)
await repairProfile(false)
editing.value = false
}
const installing = computed(() => props.instance.install_stage !== 'installed')
const repairing = ref(false)
const reinstalling = ref(false)
const changingVersion = ref(false)
async function repairProfile(force: boolean) {
if (force) {
repairing.value = true
}
await install(props.instance.path, force).catch(handleError)
if (force) {
repairing.value = false
}
trackEvent('InstanceRepair', {
loader: props.instance.loader,
game_version: props.instance.game_version,
})
}
async function unpairProfile() {
await edit(props.instance.path, {
linked_data: null,
})
modpackProject.value = null
modpackVersion.value = null
modpackVersions.value = null
modalConfirmUnpair.value.hide()
}
async function repairModpack() {
reinstalling.value = true
await update_repair_modrinth(props.instance.path).catch(handleError)
reinstalling.value = false
trackEvent('InstanceRepair', {
loader: props.instance.loader,
game_version: props.instance.game_version,
})
}
const messages = defineMessages({
cannotWhileInstalling: {
id: 'instance.settings.tabs.installation.tooltip.cannot-while-installing',
defaultMessage: 'Cannot {action} while installing',
},
cannotWhileOffline: {
id: 'instance.settings.tabs.installation.tooltip.cannot-while-offline',
defaultMessage: 'Cannot {action} while offline',
},
cannotWhileRepairing: {
id: 'instance.settings.tabs.installation.tooltip.cannot-while-repairing',
defaultMessage: 'Cannot {action} while repairing',
},
currentlyInstalled: {
id: 'instance.settings.tabs.installation.currently-installed',
defaultMessage: 'Currently installed',
},
platform: {
id: 'instance.settings.tabs.installation.platform',
defaultMessage: 'Platform',
},
gameVersion: {
id: 'instance.settings.tabs.installation.game-version',
defaultMessage: 'Game version',
},
loaderVersion: {
id: 'instance.settings.tabs.installation.loader-version',
defaultMessage: '{loader} version',
},
})
function getManifest(loader: string) {
const map: Record<string, typeof fabric_versions> = {
fabric: fabric_versions,
forge: forge_versions,
quilt: quilt_versions,
neoforge: neoforge_versions,
}
return map[loader]
}
provideAppBackup({
async createBackup() {
const allProfiles = await list()
const prefix = `${instance.name} - Backup #`
const existingNums = allProfiles
.filter((p) => p.name.startsWith(prefix))
.map((p) => parseInt(p.name.slice(prefix.length), 10))
.filter((n) => !isNaN(n))
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const newPath = await duplicate(instance.path)
await edit(newPath, { name: `${prefix}${nextNum}` })
showAllVersions: {
id: 'instance.settings.tabs.installation.show-all-versions',
defaultMessage: 'Show all versions',
},
})
provideInstallationSettings({
loading: ref(false),
installationInfo: computed(() => {
const rows = [
{
label: formatMessage(commonMessages.platformLabel),
value: formatLoaderLabel(instance.loader),
},
{
label: formatMessage(commonMessages.gameVersionLabel),
value: instance.game_version,
},
]
if (instance.loader !== 'vanilla' && instance.loader_version) {
rows.push({
label: formatMessage(messages.loaderVersion, {
loader: formatLoaderLabel(instance.loader),
}),
value: instance.loader_version,
})
}
return rows
}),
isLinked: computed(() => !!instance.linked_data?.locked),
isBusy: computed(
() =>
instance.install_stage !== 'installed' || repairing.value || reinstalling.value || !!offline,
),
modpack: computed(() => {
if (!modpackInfo.value) return null
return {
iconUrl: modpackInfo.value.project.icon_url,
title: modpackInfo.value.project.title,
link: `/project/${modpackInfo.value.project.slug ?? modpackInfo.value.project.id}`,
versionNumber: modpackInfo.value.version?.version_number,
}
}),
currentPlatform: computed(() => instance.loader),
currentGameVersion: computed(() => instance.game_version),
currentLoaderVersion: computed(() => instance.loader_version ?? ''),
availablePlatforms: loaders?.value?.map((x) => x.name) ?? [],
resolveGameVersions(loader, showSnapshots) {
const versions = all_game_versions?.value ?? []
const filtered = versions.filter((item) => {
if (loader === 'vanilla') return true
const manifest = getManifest(loader)
return !!manifest?.value?.gameVersions?.some((x) => item.version === x.id)
})
return (showSnapshots ? filtered : filtered.filter((x) => x.version_type === 'release')).map(
(x) => ({ value: x.version, label: x.version }),
)
install: {
id: 'instance.settings.tabs.installation.install',
defaultMessage: 'Install',
},
resolveLoaderVersions(loader, gameVersion) {
if (loader === 'vanilla' || !gameVersion) return []
const manifest = getManifest(loader)
if (!manifest?.value) return []
if (loader === 'fabric' || loader === 'quilt') {
return manifest.value.gameVersions[0]?.loaders ?? []
}
return manifest.value.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
resetSelections: {
id: 'instance.settings.tabs.installation.reset-selections',
defaultMessage: 'Reset to current',
},
resolveHasSnapshots(loader) {
const versions = all_game_versions?.value ?? []
if (loader === 'vanilla') return versions.some((x) => x.version_type !== 'release')
const manifest = getManifest(loader)
const supported = versions.filter(
(item) => !!manifest?.value?.gameVersions?.some((x) => item.version === x.id),
)
return supported.some((x) => x.version_type !== 'release')
unknownVersion: {
id: 'instance.settings.tabs.installation.unknown-version',
defaultMessage: '(unknown version)',
},
async save(platform, gameVersion, loaderVersionId) {
const editProfile: Record<string, string | undefined> = {
loader: platform,
game_version: gameVersion,
}
if (platform !== 'vanilla' && loaderVersionId) {
editProfile.loader_version = loaderVersionId
}
await edit(instance.path, editProfile).catch(handleError)
repairConfirmTitle: {
id: 'instance.settings.tabs.installation.repair.confirm.title',
defaultMessage: 'Repair instance?',
},
afterSave: async () => {
await install(instance.path, false).catch(handleError)
trackEvent('InstanceRepair', {
loader: instance.loader,
game_version: instance.game_version,
})
repairConfirmDescription: {
id: 'instance.settings.tabs.installation.repair.confirm.description',
defaultMessage:
'Repairing reinstalls Minecraft dependencies and checks for corruption. This may resolve issues if your game is not launching due to launcher-related errors, but will not resolve issues or crashes related to installed mods.',
},
async repair() {
repairing.value = true
await install(instance.path, true).catch(handleError)
repairing.value = false
trackEvent('InstanceRepair', {
loader: instance.loader,
game_version: instance.game_version,
})
repairButton: {
id: 'instance.settings.tabs.installation.repair.button',
defaultMessage: 'Repair',
},
async reinstallModpack() {
reinstalling.value = true
await update_repair_modrinth(instance.path).catch(handleError)
reinstalling.value = false
trackEvent('InstanceRepair', {
loader: instance.loader,
game_version: instance.game_version,
})
repairingButton: {
id: 'instance.settings.tabs.installation.repair.button.repairing',
defaultMessage: 'Repairing',
},
async unlinkModpack() {
await edit(instance.path, {
linked_data: null as unknown as undefined,
})
await queryClient.invalidateQueries({
queryKey: ['linkedModpackInfo', instance.path],
})
onUnlinked()
repairInProgress: {
id: 'instance.settings.tabs.installation.repair.in-progress',
defaultMessage: 'Repair in progress',
},
getCachedModpackVersions: () => null,
async fetchModpackVersions() {
const versions = await get_project_versions(instance.linked_data!.project_id!).catch(
handleError,
)
return (versions ?? []) as Labrinth.Versions.v2.Version[]
repairAction: {
id: 'instance.settings.tabs.installation.tooltip.action.repair',
defaultMessage: 'repair',
},
async getVersionChangelog(versionId: string) {
return (await get_version(versionId, 'must_revalidate').catch(
() => null,
)) as Labrinth.Versions.v2.Version | null
changeVersionCannotWhileFetching: {
id: 'instance.settings.tabs.installation.change-version.cannot-while-fetching',
defaultMessage: 'Fetching modpack versions',
},
async onModpackVersionConfirm(version) {
await update_managed_modrinth_version(instance.path, version.id)
await queryClient.invalidateQueries({
queryKey: ['linkedModpackInfo', instance.path],
})
changeVersionButton: {
id: 'instance.settings.tabs.installation.change-version.button',
defaultMessage: 'Change version',
},
changeVersionAction: {
id: 'instance.settings.tabs.installation.tooltip.action.change-version',
defaultMessage: 'change version',
},
installingButton: {
id: 'instance.settings.tabs.installation.change-version.button.installing',
defaultMessage: 'Installing',
},
installInProgress: {
id: 'instance.settings.tabs.installation.install.in-progress',
defaultMessage: 'Installation in progress',
},
installButton: {
id: 'instance.settings.tabs.installation.change-version.button.install',
defaultMessage: 'Install',
},
alreadyInstalledVanilla: {
id: 'instance.settings.tabs.installation.change-version.already-installed.vanilla',
defaultMessage: 'Vanilla {game_version} already installed',
},
alreadyInstalledModded: {
id: 'instance.settings.tabs.installation.change-version.already-installed.modded',
defaultMessage: '{platform} {version} for Minecraft {game_version} already installed',
},
installAction: {
id: 'instance.settings.tabs.installation.tooltip.action.install',
defaultMessage: 'install',
},
installingNewVersion: {
id: 'instance.settings.tabs.installation.change-version.in-progress',
defaultMessage: 'Installing new version',
},
minecraftVersion: {
id: 'instance.settings.tabs.installation.minecraft-version',
defaultMessage: 'Minecraft {version}',
},
noLoaderVersions: {
id: 'instance.settings.tabs.installation.no-loader-versions',
defaultMessage: '{loader} is not available for Minecraft {version}. Try another mod loader.',
},
noConnection: {
id: 'instance.settings.tabs.installation.no-connection',
defaultMessage: 'Cannot fetch linked modpack details. Please check your internet connection.',
},
noModpackFound: {
id: 'instance.settings.tabs.installation.no-modpack-found',
defaultMessage:
'This instance is linked to a modpack, but the modpack could not be found on Modrinth.',
},
debugInformation: {
id: 'instance.settings.tabs.installation.debug-information',
defaultMessage: 'Debug information:',
},
fetchingModpackDetails: {
id: 'instance.settings.tabs.installation.fetching-modpack-details',
defaultMessage: 'Fetching modpack details',
},
unlinkInstanceTitle: {
id: 'instance.settings.tabs.installation.unlink.title',
defaultMessage: 'Unlink from modpack',
},
unlinkInstanceDescription: {
id: 'instance.settings.tabs.installation.unlink.description',
defaultMessage: `This instance is linked to a modpack, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the modpack.`,
},
unlinkInstanceButton: {
id: 'instance.settings.tabs.installation.unlink.button',
defaultMessage: 'Unlink instance',
},
unlinkInstanceConfirmTitle: {
id: 'instance.settings.tabs.installation.unlink.confirm.title',
defaultMessage: 'Are you sure you want to unlink this instance?',
},
unlinkInstanceConfirmDescription: {
id: 'instance.settings.tabs.installation.unlink.confirm.description',
defaultMessage:
'If you proceed, you will not be able to re-link it without creating an entirely new instance. You will no longer receive modpack updates and it will become a normal.',
},
reinstallModpackConfirmTitle: {
id: 'instance.settings.tabs.installation.reinstall.confirm.title',
defaultMessage: 'Are you sure you want to reinstall this instance?',
},
reinstallModpackConfirmDescription: {
id: 'instance.settings.tabs.installation.reinstall.confirm.description',
defaultMessage: `Reinstalling will reset all installed or modified content to what is provided by the modpack, removing any mods or content you have added on top of the original installation. This may fix unexpected behavior if changes have been made to the instance, but if your worlds now depend on additional installed content, it may break existing worlds.`,
},
reinstallModpackTitle: {
id: 'instance.settings.tabs.installation.reinstall.title',
defaultMessage: 'Reinstall modpack',
},
reinstallModpackDescription: {
id: 'instance.settings.tabs.installation.reinstall.description',
defaultMessage: `Resets the instance's content to its original state, removing any mods or content you have added on top of the original modpack.`,
},
reinstallModpackButton: {
id: 'instance.settings.tabs.installation.reinstall.button',
defaultMessage: 'Reinstall modpack',
},
reinstallingModpackButton: {
id: 'instance.settings.tabs.installation.reinstall.button.reinstalling',
defaultMessage: 'Reinstalling modpack',
},
reinstallAction: {
id: 'instance.settings.tabs.installation.tooltip.action.reinstall',
defaultMessage: 'reinstall',
},
updaterModalProps: computed(() => ({
isApp: true,
currentVersionId:
modpackInfo.value?.update_version_id ?? instance.linked_data?.version_id ?? '',
projectIconUrl: modpackInfo.value?.project?.icon_url,
projectName: modpackInfo.value?.project?.title ?? 'Modpack',
currentGameVersion: instance.game_version,
currentLoader: instance.loader,
})),
isServer: false,
isApp: true,
showModpackVersionActions: !isMinecraftServer.value,
repairing,
reinstalling,
})
</script>
<template>
<InstallationSettingsLayout />
<ConfirmModalWrapper
ref="repairConfirmModal"
:title="formatMessage(messages.repairConfirmTitle)"
:description="formatMessage(messages.repairConfirmDescription)"
:proceed-icon="HammerIcon"
:proceed-label="formatMessage(messages.repairButton)"
:danger="false"
:show-ad-on-close="false"
@proceed="() => repairProfile(true)"
/>
<ModpackVersionModal
v-if="instance.linked_data && modpackVersions"
ref="modpackVersionModal"
:instance="instance"
:versions="modpackVersions"
@finish-install="
() => {
changingVersion = false
modpackVersion =
modpackVersions?.find(
(version: Version) => version.id === props.instance.linked_data?.version_id,
) ?? null
}
"
/>
<ConfirmModalWrapper
ref="modalConfirmUnpair"
:title="formatMessage(messages.unlinkInstanceConfirmTitle)"
:description="formatMessage(messages.unlinkInstanceConfirmDescription)"
:proceed-icon="UnlinkIcon"
:proceed-label="formatMessage(messages.unlinkInstanceButton)"
:show-ad-on-close="false"
@proceed="() => unpairProfile()"
/>
<ConfirmModalWrapper
ref="modalConfirmReinstall"
:title="formatMessage(messages.reinstallModpackConfirmTitle)"
:description="formatMessage(messages.reinstallModpackConfirmDescription)"
:proceed-icon="DownloadIcon"
:proceed-label="formatMessage(messages.reinstallModpackButton)"
:show-ad-on-close="false"
@proceed="() => repairModpack()"
/>
<div>
<h2 id="project-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.currentlyInstalled) }}
</h2>
<div
v-if="!modpackProject && instance.linked_data && offline && !fetching"
class="text-secondary font-medium mb-2"
>
<UnplugIcon class="top-[3px] relative" /> {{ formatMessage(messages.noConnection) }}
</div>
<div v-else-if="!modpackProject && instance.linked_data && !fetching" class="mb-2">
<p class="text-brand-red font-medium mt-0">
<IssuesIcon class="top-[3px] relative" />
{{ formatMessage(messages.noModpackFound) }}
</p>
<p>{{ formatMessage(messages.debugInformation) }}</p>
<div class="bg-bg p-6 rounded-2xl mt-2 text-sm text-secondary">
{{ instance.linked_data }}
</div>
</div>
<div class="flex gap-4 items-center justify-between p-4 bg-bg rounded-2xl">
<div v-if="fetching" class="flex items-center gap-2 h-10">
<SpinnerIcon class="animate-spin" />
{{ formatMessage(messages.fetchingModpackDetails) }}
</div>
<template v-else>
<div class="flex gap-2 items-center">
<Avatar v-if="modpackProject" :src="modpackProject?.icon_url" size="40px" />
<div
v-else
class="w-10 h-10 flex items-center justify-center rounded-full bg-button-bg border-solid border-[1px] border-button-border p-2 [&_svg]:h-full [&_svg]:w-full"
>
<div v-if="!!currentLoaderIcon" class="contents" v-html="currentLoaderIcon" />
<WrenchIcon v-else />
</div>
<div class="flex flex-col gap-2 justify-center">
<span class="font-semibold leading-none">
{{
modpackProject
? modpackProject.title
: formatMessage(messages.minecraftVersion, {
version: instance.game_version,
})
}}
</span>
<span class="text-sm text-secondary leading-none">
{{
modpackProject
? modpackVersion
? modpackVersion?.version_number
: 'Unknown version'
: formatCategory(instance.loader)
}}
<template v-if="instance.loader !== 'vanilla' && !modpackProject">
{{ instance.loader_version || formatMessage(messages.unknownVersion) }}
</template>
</span>
</div>
</div>
<div class="flex gap-1">
<ButtonStyled color="orange" type="transparent" hover-color-fill="background">
<button
v-tooltip="
repairing
? formatMessage(messages.repairInProgress)
: installing || reinstalling
? formatMessage(messages.cannotWhileInstalling, {
action: formatMessage(messages.repairAction),
})
: offline
? formatMessage(messages.cannotWhileOffline, {
action: formatMessage(messages.repairAction),
})
: null
"
:disabled="installing || repairing || reinstalling || offline"
@click="repairConfirmModal.show()"
>
<SpinnerIcon v-if="repairing" class="animate-spin" />
<HammerIcon v-else />
{{
repairing
? formatMessage(messages.repairingButton)
: formatMessage(messages.repairButton)
}}
</button>
</ButtonStyled>
<ButtonStyled v-if="modpackProject" hover-color-fill="background">
<button
v-tooltip="
changingVersion
? formatMessage(messages.installingNewVersion)
: repairing
? formatMessage(messages.cannotWhileRepairing, {
action: formatMessage(messages.changeVersionAction),
})
: installing || reinstalling
? formatMessage(messages.cannotWhileInstalling, {
action: formatMessage(messages.changeVersionAction),
})
: fetching && !modpackVersions
? formatMessage(messages.changeVersionCannotWhileFetching)
: offline
? formatMessage(messages.cannotWhileOffline, {
action: formatMessage(messages.changeVersionAction),
})
: null
"
:disabled="
changingVersion ||
repairing ||
installing ||
reinstalling ||
offline ||
fetching ||
!modpackVersions
"
@click="
() => {
changingVersion = true
modpackVersionModal.show()
}
"
>
<SpinnerIcon v-if="changingVersion" class="animate-spin" />
<TransferIcon v-else />
{{
changingVersion
? formatMessage(messages.installingButton)
: formatMessage(messages.changeVersionButton)
}}
</button>
</ButtonStyled>
</div>
</template>
</div>
<template v-if="!instance.linked_data || !instance.linked_data.locked">
<h2 class="m-0 mt-4 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.platform) }}
</h2>
<Chips v-if="loaders" v-model="loader" :items="loaders.map((x) => x.name)" class="mt-2" />
<h2 class="m-0 mt-4 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.gameVersion) }}
</h2>
<div class="flex flex-wrap mt-2 gap-2">
<TeleportDropdownMenu
v-if="selectableGameVersionNumbers !== undefined"
v-model="gameVersion"
:options="selectableGameVersionNumbers"
name="Game Version Dropdown"
/>
<Checkbox
v-if="hasSnapshots"
v-model="showSnapshots"
:label="formatMessage(messages.showAllVersions)"
/>
</div>
<template v-if="loader !== 'vanilla'">
<h2 class="m-0 mt-4 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.loaderVersion, { loader: formatCategory(loader) }) }}
</h2>
<TeleportDropdownMenu
v-if="selectableLoaderVersions"
:model-value="selectableLoaderVersions[loaderVersionIndex]"
:options="selectableLoaderVersions"
:display-name="(option: ManifestLoaderVersion) => option?.id"
name="Version selector"
class="mt-2"
@change="(value) => (loaderVersionIndex = value.index)"
/>
<div v-else class="mt-2 text-brand-red flex gap-2 items-center">
<IssuesIcon />
{{
formatMessage(messages.noLoaderVersions, {
loader: loader,
version: gameVersion,
})
}}
</div>
</template>
<div class="mt-4 flex flex-wrap gap-2">
<ButtonStyled color="brand">
<button
v-tooltip="
installing || reinstalling
? formatMessage(messages.installInProgress)
: !isChanged
? formatMessage(
loader === 'vanilla'
? messages.alreadyInstalledVanilla
: messages.alreadyInstalledModded,
{
platform: formatCategory(loader),
version: instance.loader_version,
game_version: gameVersion,
},
)
: repairing
? formatMessage(messages.cannotWhileRepairing, {
action: formatMessage(messages.installAction),
})
: offline
? formatMessage(messages.cannotWhileOffline, {
action: formatMessage(messages.installAction),
})
: null
"
:disabled="!isValid || !isChanged || editing || offline || repairing"
@click="saveGvLoaderEdits()"
>
<SpinnerIcon v-if="editing" class="animate-spin" />
<DownloadIcon v-else />
{{
editing
? formatMessage(messages.installingButton)
: formatMessage(messages.installButton)
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
:disabled="!isChanged"
@click="
() => {
loader = instance.loader
gameVersion = instance.game_version
resetLoaderVersionIndex()
}
"
>
<UndoIcon />
{{ formatMessage(messages.resetSelections) }}
</button>
</ButtonStyled>
</div>
</template>
<template v-else>
<template v-if="instance.linked_data && instance.linked_data.locked">
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.unlinkInstanceTitle) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.unlinkInstanceDescription) }}
</p>
<ButtonStyled>
<button class="mt-2" @click="modalConfirmUnpair.show()">
<UnlinkIcon /> {{ formatMessage(messages.unlinkInstanceButton) }}
</button>
</ButtonStyled>
<template v-if="modpackProject">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast block mt-4">
{{ formatMessage(messages.reinstallModpackTitle) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.reinstallModpackDescription) }}
</p>
</div>
<ButtonStyled color="red" type="outlined">
<button
v-tooltip="
reinstalling
? formatMessage(messages.reinstallingModpackButton)
: repairing
? formatMessage(messages.cannotWhileRepairing, {
action: formatMessage(messages.reinstallAction),
})
: installing
? formatMessage(messages.cannotWhileInstalling, {
action: formatMessage(messages.reinstallAction),
})
: offline
? formatMessage(messages.cannotWhileOffline, {
action: formatMessage(messages.reinstallAction),
})
: null
"
class="mt-2"
:disabled="
changingVersion ||
repairing ||
installing ||
offline ||
fetching ||
!modpackVersions
"
@click="modalConfirmReinstall.show()"
>
<SpinnerIcon v-if="reinstalling" class="animate-spin" />
<DownloadIcon v-else />
{{
reinstalling
? formatMessage(messages.reinstallingModpackButton)
: formatMessage(messages.reinstallModpackButton)
}}
</button>
</ButtonStyled>
</template>
</template>
</template>
</div>
</template>
@@ -1,67 +1,77 @@
<script setup lang="ts">
import { CheckCircleIcon, XCircleIcon } from '@modrinth/assets'
import {
Checkbox,
defineMessages,
injectNotificationManager,
Slider,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { Checkbox, injectNotificationManager, Slider } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, readonly, ref, watch } from 'vue'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { AppSettings } from '../../../helpers/types'
import type { AppSettings, InstanceSettingsTabProps, MemorySettings } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { instance } = injectInstanceSettings()
const props = defineProps<InstanceSettingsTabProps>()
const globalSettings = (await get().catch(handleError)) as unknown as AppSettings
const overrideJavaInstall = ref(!!instance.java_path)
const optimalJava = readonly(await get_optimal_jre_key(instance.path).catch(handleError))
const javaInstall = ref({ path: optimalJava.path ?? instance.java_path })
const overrideJavaInstall = ref(!!props.instance.java_path)
const optimalJava = readonly(await get_optimal_jre_key(props.instance.path).catch(handleError))
const javaInstall = ref({ path: optimalJava.path ?? props.instance.java_path })
const overrideJavaArgs = ref((instance.extra_launch_args?.length ?? 0) > 0)
const javaArgs = ref((instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' '))
const overrideEnvVars = ref((instance.custom_env_vars?.length ?? 0) > 0)
const envVars = ref(
(instance.custom_env_vars ?? globalSettings.custom_env_vars).map((x) => x.join('=')).join(' '),
const overrideJavaArgs = ref(props.instance.extra_launch_args?.length !== undefined)
const javaArgs = ref(
(props.instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' '),
)
const overrideMemorySettings = ref(!!instance.memory)
const memory = ref(instance.memory ?? globalSettings.memory)
const overrideEnvVars = ref(props.instance.custom_env_vars?.length !== undefined)
const envVars = ref(
(props.instance.custom_env_vars ?? globalSettings.custom_env_vars)
.map((x) => x.join('='))
.join(' '),
)
const overrideMemorySettings = ref(!!props.instance.memory)
const memory = ref(props.instance.memory ?? globalSettings.memory)
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
maxMemory: number
snapPoints: number[]
}
const editProfileObject = computed(() => {
return {
java_path:
overrideJavaInstall.value && javaInstall.value.path !== ''
? javaInstall.value.path.replace('java.exe', 'javaw.exe')
: null,
extra_launch_args: overrideJavaArgs.value
? javaArgs.value.trim().split(/\s+/).filter(Boolean)
: null,
custom_env_vars: overrideEnvVars.value
? envVars.value
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
: null,
memory: overrideMemorySettings.value ? memory.value : null,
const editProfile: {
java_path?: string
extra_launch_args?: string[]
custom_env_vars?: string[][]
memory?: MemorySettings
} = {}
if (overrideJavaInstall.value) {
if (javaInstall.value.path !== '') {
editProfile.java_path = javaInstall.value.path.replace('java.exe', 'javaw.exe')
}
}
if (overrideJavaArgs.value) {
editProfile.extra_launch_args = javaArgs.value.trim().split(/\s+/).filter(Boolean)
}
if (overrideEnvVars.value) {
editProfile.custom_env_vars = envVars.value
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
}
if (overrideMemorySettings.value) {
editProfile.memory = memory.value
}
return editProfile
})
watch(
@@ -76,7 +86,7 @@ watch(
memory,
],
async () => {
await edit(instance.path, editProfileObject.value)
await edit(props.instance.path, editProfileObject.value)
},
{ deep: true },
)
@@ -159,25 +169,27 @@ const messages = defineMessages({
{{ formatMessage(messages.javaArguments) }}
</h2>
<Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" />
<StyledInput
<input
id="java-args"
v-model="javaArgs"
autocomplete="off"
:disabled="!overrideJavaArgs"
type="text"
class="w-full"
placeholder="Enter java arguments..."
wrapper-class="w-full"
/>
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaEnvironmentVariables) }}
</h2>
<Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2" />
<StyledInput
<input
id="env-vars"
v-model="envVars"
autocomplete="off"
:disabled="!overrideEnvVars"
type="text"
class="w-full"
placeholder="Enter environmental variables..."
wrapper-class="w-full"
/>
</div>
</template>
@@ -1,52 +1,51 @@
<script setup lang="ts">
import {
Checkbox,
defineMessages,
injectNotificationManager,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { Checkbox, injectNotificationManager, Toggle } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, type Ref, ref, watch } from 'vue'
import { edit } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { AppSettings } from '../../../helpers/types'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { instance } = injectInstanceSettings()
const props = defineProps<InstanceSettingsTabProps>()
const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideWindowSettings = ref(!!instance.game_resolution || !!instance.force_fullscreen)
const overrideWindowSettings = ref(
!!props.instance.game_resolution || !!props.instance.force_fullscreen,
)
const resolution: Ref<[number, number]> = ref(
instance.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]),
props.instance.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]),
)
const fullscreenSetting: Ref<boolean> = ref(
instance.force_fullscreen ?? globalSettings.force_fullscreen,
props.instance.force_fullscreen ?? globalSettings.force_fullscreen,
)
const editProfileObject = computed(() => {
if (!overrideWindowSettings.value) {
return {
force_fullscreen: null,
game_resolution: null,
const editProfile: {
force_fullscreen?: boolean
game_resolution?: [number, number]
} = {}
if (overrideWindowSettings.value) {
editProfile.force_fullscreen = fullscreenSetting.value
if (!fullscreenSetting.value) {
editProfile.game_resolution = resolution.value
}
}
return {
force_fullscreen: fullscreenSetting.value,
game_resolution: fullscreenSetting.value ? null : resolution.value,
}
return editProfile
})
watch(
[overrideWindowSettings, resolution, fullscreenSetting],
async () => {
await edit(instance.path, editProfileObject.value)
await edit(props.instance.path, editProfileObject.value)
},
{ deep: true },
)
@@ -96,6 +95,14 @@ const messages = defineMessages({
<Checkbox
v-model="overrideWindowSettings"
:label="formatMessage(messages.customWindowSettings)"
@update:model-value="
(value) => {
if (!value) {
resolution = globalSettings.game_resolution
fullscreenSetting = globalSettings.force_fullscreen
}
}
"
/>
<div class="mt-2 flex items-center gap-4 justify-between">
<div>
@@ -127,7 +134,7 @@ const messages = defineMessages({
{{ formatMessage(messages.widthDescription) }}
</p>
</div>
<StyledInput
<input
id="width"
v-model="resolution[0]"
autocomplete="off"
@@ -146,7 +153,7 @@ const messages = defineMessages({
{{ formatMessage(messages.heightDescription) }}
</p>
</div>
<StyledInput
<input
id="height"
v-model="resolution[1]"
autocomplete="off"
@@ -1,195 +0,0 @@
<script setup lang="ts">
import {
CheckIcon,
CopyIcon,
DropdownIcon,
LogInIcon,
MessagesSquareIcon,
WrenchIcon,
} from '@modrinth/assets'
import { Admonition, ButtonStyled, Collapsible, NewModal } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleSevereError } from '@/store/error.js'
import { type MinecraftAuthError, minecraftAuthErrors } from './minecraft-auth-errors'
const modal = ref<InstanceType<typeof NewModal>>()
const rawError = ref<string>('')
const matchedError = ref<MinecraftAuthError | null>(null)
const debugCollapsed = ref(true)
const copied = ref(false)
const loadingSignIn = ref(false)
function show(errorVal: { message?: string }) {
rawError.value = errorVal?.message ?? String(errorVal)
matchedError.value = minecraftAuthErrors.find((e) => rawError.value.includes(e.errorCode)) ?? null
debugCollapsed.value = true
hide_ads_window()
modal.value?.show()
}
function hide() {
onModalHide()
modal.value?.hide()
}
function onModalHide() {
show_ads_window()
}
defineExpose({
show,
hide,
})
async function signInAgain() {
try {
loadingSignIn.value = true
const loggedIn = await login_flow()
if (loggedIn) {
await set_default_user(loggedIn.profile.id)
}
loadingSignIn.value = false
modal.value?.hide()
} catch (err) {
loadingSignIn.value = false
handleSevereError(err)
}
}
const debugInfo = computed(() => rawError.value || 'No error message.')
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 3000)
}
</script>
<template>
<NewModal ref="modal" header="Sign in Failed" :max-width="'548px'" @hide="onModalHide">
<div class="flex flex-col gap-6">
<Admonition
type="warning"
body=" We couldn't sign you into your Microsoft account. This may be due to account restrictions or
regional limitations."
>
</Admonition>
<!-- Matched error details -->
<div class="bg-surface-2 rounded-2xl p-4 px-5 flex flex-col gap-3">
<template v-if="matchedError">
<div class="flex flex-col gap-1.5">
<h3 class="text-base font-bold m-0">What we think happened</h3>
<p class="text-sm text-secondary m-0">
{{ matchedError.whatHappened }}
</p>
</div>
<div class="flex flex-col gap-1.5">
<h3 class="text-base font-bold m-0">How to fix it</h3>
<ol class="list-none flex flex-col gap-2 m-0 pl-0">
<li
v-for="(step, index) in matchedError.stepsToFix"
:key="index"
class="flex items-baseline gap-2"
>
<span
class="inline-flex items-center justify-center shrink-0 w-5 h-5 rounded-full bg-surface-4 border border-solid border-surface-5 text-xs font-medium"
>
{{ index + 1 }}
</span>
<!-- eslint-disable-next-line vue/no-v-html -->
<span
class="text-sm [&_a]:text-info [&_a]:font-medium [&_a]:underline"
v-html="step"
/>
</li>
</ol>
</div>
</template>
<template v-else>
<div class="flex flex-col gap-1.5">
<h3 class="text-base font-bold m-0">Unknown error</h3>
<p class="text-sm text-secondary m-0">
We dont recognize this error and cant recommend specific steps to resolve it.
</p>
<p class="text-sm text-secondary m-0">
Try visiting
<a
class="text-info font-medium underline hover:underline"
href="https://www.minecraft.net/en-us/login"
>Minecraft Login</a
>
and signing in, as it may prompt you with the necessary steps. You can also contact
support and we can look into it further.
</p>
</div>
</template>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2">
<ButtonStyled>
<a href="https://support.modrinth.com" class="!w-full" @click="modal?.hide()">
<MessagesSquareIcon /> Contact support
</a>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="loadingSignIn" class="!w-full" @click="signInAgain">
<LogInIcon /> Sign in again
</button>
</ButtonStyled>
</div>
<div class="flex flex-col gap-2">
<div class="w-full h-[1px] bg-surface-5"></div>
<!-- Debug info -->
<div class="overflow-clip">
<button
class="flex items-center justify-between w-full bg-transparent border-0 py-4 cursor-pointer"
@click="debugCollapsed = !debugCollapsed"
>
<span class="flex items-center gap-2 text-contrast font-extrabold m-0">
<WrenchIcon class="h-4 w-4" />
Debug information
</span>
<DropdownIcon
class="h-5 w-5 text-secondary transition-transform"
:class="{ 'rotate-180': !debugCollapsed }"
/>
</button>
<Collapsible :collapsed="debugCollapsed">
<div
class="p-3 bg-surface-2 rounded-2xl text-xs grid grid-cols-[1fr_auto] max-w-full items-start"
>
<div
class="m-0 p-0 rounded-none bg-transparent text-sm font-mono break-words overflow-auto"
>
{{ debugInfo }}
</div>
<ButtonStyled circular>
<button
v-tooltip="'Copy debug info'"
:disabled="copied"
@click="copyToClipboard(debugInfo)"
>
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
<template v-else> <CopyIcon /> </template>
</button>
</ButtonStyled>
</div>
</Collapsible>
</div>
</div>
</div>
</NewModal>
</template>
@@ -1,90 +0,0 @@
export interface MinecraftAuthError {
errorCode: string
whatHappened: string
stepsToFix: string[]
}
export const minecraftAuthErrors: MinecraftAuthError[] = [
{
errorCode: '2148916222',
whatHappened:
'Your Minecraft/Xbox Live account requires age verification to comply with UK regulations. You must complete this before signing in.',
stepsToFix: [
'Go to the <a href="https://www.minecraft.net/en-us/login">Minecraft Login</a> page and sign in',
'Follow the instructions to verify your age',
'Once verified, try signing in again',
'For additional help, visit <a href="https://support.xbox.com/en-GB/help/family-online-safety/online-safety/UK-age-verification">UK age verification on Xbox</a>',
],
},
{
errorCode: '2148916233',
whatHappened: "This account doesn't have an Xbox profile set up or doesn't own Minecraft.",
stepsToFix: [
'Make sure Minecraft is purchased on this account',
'Visit <a href="https://www.minecraft.net/en-us/login">Minecraft Login</a> and sign in',
'Complete Xbox profile setup if prompted',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916235',
whatHappened: "Xbox Live isn't available in your region, so sign-in is blocked.",
stepsToFix: [
'Xbox services must be supported in your country before you can sign in',
'Check <a href="https://www.xbox.com/en-US/regions">Xbox Availability</a> for supported regions',
],
},
{
errorCode: '2148916236',
whatHappened: 'This account requires adult verification under South Korean regulations.',
stepsToFix: [
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in',
'Complete the identity verification process',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916237',
whatHappened: 'This account requires adult verification under South Korean regulations.',
stepsToFix: [
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in',
'Complete the identity verification process',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916238',
whatHappened: 'This account is underage and not linked to a Microsoft family group.',
stepsToFix: [
'Review the <a href="https://help.minecraft.net/hc/en-us/articles/4408968616077">Family Setup Guide</a>',
'Join or create a family group as instructed',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916227',
whatHappened: 'This account was suspended for violating Xbox Community Standards.',
stepsToFix: [
'Visit <a href="https://support.xbox.com">Xbox Support</a> and review the enforcement details',
'Submit an appeal if one is available',
],
},
{
errorCode: '2148916229',
whatHappened: "This account is restricted and doesn't have permission to play online.",
stepsToFix: [
'Have a guardian sign in to <a href="https://account.microsoft.com/family/">Microsoft Family</a>',
'Update online play permissions',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916234',
whatHappened: "This account hasn't accepted Xbox's Terms of Service.",
stepsToFix: [
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in',
'Accept the Terms if prompted',
'Once finished, try signing in again',
],
},
]
@@ -3,34 +3,26 @@ import {
CoffeeIcon,
GameIcon,
GaugeIcon,
LanguagesIcon,
ModrinthIcon,
PaintbrushIcon,
ReportIcon,
SettingsIcon,
ShieldIcon,
} from '@modrinth/assets'
import {
commonMessages,
defineMessage,
defineMessages,
ProgressBar,
TabbedModal,
useVIntl,
} from '@modrinth/ui'
import { TabbedModal } from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { platform as getOsPlatform, version as getOsVersion } from '@tauri-apps/plugin-os'
import { ref, watch } from 'vue'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { computed, ref, watch } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import AppearanceSettings from '@/components/ui/settings/AppearanceSettings.vue'
import DefaultInstanceSettings from '@/components/ui/settings/DefaultInstanceSettings.vue'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
import JavaSettings from '@/components/ui/settings/JavaSettings.vue'
import LanguageSettings from '@/components/ui/settings/LanguageSettings.vue'
import PrivacySettings from '@/components/ui/settings/PrivacySettings.vue'
import ResourceManagementSettings from '@/components/ui/settings/ResourceManagementSettings.vue'
import { get, set } from '@/helpers/settings.ts'
import { injectAppUpdateDownloadProgress } from '@/providers/download-progress.ts'
import { useTheming } from '@/store/state'
const themeStore = useTheming()
@@ -53,15 +45,6 @@ const tabs = [
icon: PaintbrushIcon,
content: AppearanceSettings,
},
{
name: defineMessage({
id: 'app.settings.tabs.language',
defaultMessage: 'Language',
}),
icon: LanguagesIcon,
content: LanguageSettings,
badge: commonMessages.beta,
},
{
name: defineMessage({
id: 'app.settings.tabs.privacy',
@@ -105,15 +88,15 @@ const tabs = [
},
]
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
const modal = ref()
function show() {
modal.value?.show()
modal.value.show()
}
defineExpose({ show })
const isOpen = computed(() => modal.value?.isOpen)
const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress()
defineExpose({ show, isOpen })
const version = await getVersion()
const osPlatform = getOsPlatform()
@@ -135,60 +118,48 @@ function devModeCount() {
settings.value.developer_mode = !!themeStore.devMode
devModeCounter.value = 0
if (!themeStore.devMode && tabs[modal.value!.selectedTab].developerOnly) {
modal.value!.setTab(0)
if (!themeStore.devMode && tabs[modal.value.selectedTab].developerOnly) {
modal.value.setTab(0)
}
}
}
const messages = defineMessages({
downloading: {
id: 'app.settings.downloading',
defaultMessage: 'Downloading v{version}',
},
})
</script>
<template>
<TabbedModal ref="modal" :tabs="tabs.filter((t) => !t.developerOnly || themeStore.devMode)">
<ModalWrapper ref="modal">
<template #title>
<span class="flex items-center gap-2 text-lg font-extrabold text-contrast">
<SettingsIcon /> Settings
</span>
</template>
<template #footer>
<div class="mt-auto text-secondary text-sm">
<div class="mb-3">
<template v-if="progress > 0 && progress < 1">
<p class="m-0 mb-2">
{{ formatMessage(messages.downloading, { version: downloadingVersion }) }}
</p>
<ProgressBar :progress="progress" />
</template>
</div>
<p v-if="themeStore.devMode" class="text-brand font-semibold m-0 mb-2">
{{ formatMessage(developerModeEnabled) }}
</p>
<div class="flex items-center gap-3">
<button
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
:class="{
'text-brand': themeStore.devMode,
'text-secondary': !themeStore.devMode,
}"
@click="devModeCount"
>
<ModrinthIcon class="w-6 h-6" />
</button>
<div>
<p class="m-0">Modrinth App {{ version }}</p>
<p class="m-0">
<span v-if="osPlatform === 'macos'">macOS</span>
<span v-else class="capitalize">{{ osPlatform }}</span>
{{ osVersion }}
</p>
<TabbedModal :tabs="tabs.filter((t) => !t.developerOnly || themeStore.devMode)">
<template #footer>
<div class="mt-auto text-secondary text-sm">
<p v-if="themeStore.devMode" class="text-brand font-semibold m-0 mb-2">
{{ formatMessage(developerModeEnabled) }}
</p>
<div class="flex items-center gap-3">
<button
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
:class="{
'text-brand': themeStore.devMode,
'text-secondary': !themeStore.devMode,
}"
@click="devModeCount"
>
<ModrinthIcon class="w-6 h-6" />
</button>
<div>
<p class="m-0">Modrinth App {{ version }}</p>
<p class="m-0">
<span v-if="osPlatform === 'macos'">MacOS</span>
<span v-else class="capitalize">{{ osPlatform }}</span>
{{ osVersion }}
</p>
</div>
</div>
</div>
</div>
</template>
</TabbedModal>
</template>
</TabbedModal>
</ModalWrapper>
</template>
@@ -1,78 +0,0 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="danger" max-width="500px">
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
{{ formatMessage(messages.admonitionBody) }}
</Admonition>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="confirm">
<TrashIcon />
{{ formatMessage(messages.deleteButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { TrashIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'app.instance.confirm-delete.header',
defaultMessage: 'Delete instance',
},
admonitionHeader: {
id: 'app.instance.confirm-delete.admonition-header',
defaultMessage: 'This action cannot be undone',
},
admonitionBody: {
id: 'app.instance.confirm-delete.admonition-body',
defaultMessage:
'All data for your instance will be permanently deleted, including your worlds, configs, and all installed content.',
},
deleteButton: {
id: 'app.instance.confirm-delete.delete-button',
defaultMessage: 'Delete instance',
},
})
const emit = defineEmits<{
(e: 'delete'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
function show() {
modal.value?.show()
}
function confirm() {
modal.value?.hide()
emit('delete')
}
defineExpose({
show,
})
</script>
@@ -1,9 +1,13 @@
<!-- @deprecated Use ConfirmModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
<script setup lang="ts">
import { ConfirmModal } from '@modrinth/ui'
import { useTemplateRef } from 'vue'
import { ref } from 'vue'
defineProps({
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
const themeStore = useTheming()
const props = defineProps({
confirmationText: {
type: String,
default: '',
@@ -34,7 +38,6 @@ defineProps({
type: Boolean,
default: true,
},
/** @deprecated No longer used — ads are handled by provideModalBehavior */
showAdOnClose: {
type: Boolean,
default: true,
@@ -46,17 +49,25 @@ defineProps({
})
const emit = defineEmits(['proceed'])
const modal = useTemplateRef('modal')
const modal = ref(null)
defineExpose({
show: () => {
modal.value?.show()
hide_ads_window()
modal.value.show()
},
hide: () => {
modal.value?.hide()
onModalHide()
modal.value.hide()
},
})
function onModalHide() {
if (props.showAdOnClose) {
show_ads_window()
}
}
function proceed() {
emit('proceed')
}
@@ -71,6 +82,8 @@ function proceed() {
:description="description"
:proceed-icon="proceedIcon"
:proceed-label="proceedLabel"
:on-hide="onModalHide"
:noblur="!themeStore.advancedRendering"
:danger="danger"
:markdown="markdown"
@proceed="proceed"
@@ -1,274 +0,0 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true">
<div v-if="requiredContentProject" class="flex flex-col gap-6 max-w-[500px]">
<Admonition type="info" :header="formatMessage(messages.contentRequired)">
{{ formatMessage(messages.serverRequiresMods) }}
</Admonition>
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<span class="font-semibold text-contrast">{{
formatMessage(messages.requiredModpack)
}}</span>
<ButtonStyled type="transparent">
<button @click="openViewContents">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
<Avatar
:src="requiredContentProject.icon_url"
:alt="requiredContentProject.title"
size="48px"
/>
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-contrast">
<template v-if="usingCustomModpack && modpackVersion">
{{ modpackVersion.name }}
</template>
<template v-else>
{{ requiredContentProject.title }}
</template>
</span>
<span class="text-sm text-secondary">
{{ loaderDisplay }} {{ requiredContentProject.game_versions?.[0] }}
<template v-if="modCount">
· {{ formatMessage(messages.modCount, { count: modCount }) }}
</template>
</span>
</div>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button @click="handleDecline">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleAccept">
<DownloadIcon />
{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="project?.name ?? ''"
:modpack-icon-url="project?.icon_url ?? undefined"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, EyeIcon, XIcon } from '@modrinth/assets'
import type { ContentItem } from '@modrinth/ui'
import {
Admonition,
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
formatLoader,
ModpackContentModal,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { injectServerInstall } from '@/providers/server-install'
const modal = ref<InstanceType<typeof NewModal>>()
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const project = ref<Labrinth.Projects.v3.Project | null>(null)
const requiredContentProject = ref<Labrinth.Projects.v2.Project | null>(null)
const onInstallComplete = ref<() => void>(() => {})
const { formatMessage } = useVIntl()
const { installServerProject, startInstallingServer, stopInstallingServer } = injectServerInstall()
const usingCustomModpack = computed(() => {
return requiredContentProject.value?.id === project.value?.id
})
const loaderDisplay = computed(() => {
const loader = requiredContentProject.value?.loaders?.[0]
if (!loader) return ''
return formatLoader(formatMessage, loader)
})
const modCount = computed(() => modpackVersion.value?.dependencies?.length)
async function fetchData(versionId: string) {
// cache is making version null for some reason so bypassing for now
modpackVersion.value = await get_version(versionId, 'bypass')
if (modpackVersion.value?.project_id) {
requiredContentProject.value = await get_project(modpackVersion.value.project_id, 'bypass')
}
}
async function handleAccept() {
hide()
const serverProjectId = project.value?.id
startInstallingServer(serverProjectId)
try {
await installServerProject(serverProjectId)
onInstallComplete.value()
} catch (error) {
console.error('Failed to install server project from InstallToPlayModal:', error)
} finally {
stopInstallingServer(serverProjectId)
}
}
function handleDecline() {
hide()
}
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
async function openViewContents() {
modpackContentModal.value?.showLoading()
try {
// Ensure version data is available — the useQuery may not have resolved yet
const versionId = modpackVersionId.value
const version =
modpackVersion.value ?? (versionId ? await get_version(versionId, 'must_revalidate') : null)
const deps = version?.dependencies ?? []
const projectIds = deps
.map((d: { project_id?: string }) => d.project_id)
.filter((id: string | undefined): id is string => !!id)
const versionIds = deps
.map((d: { version_id?: string }) => d.version_id)
.filter((id: string | undefined): id is string => !!id)
const projects: Labrinth.Projects.v2.Project[] =
projectIds.length > 0 ? await get_project_many(projectIds, 'must_revalidate') : []
const versions: Labrinth.Versions.v2.Version[] =
versionIds.length > 0 ? await get_version_many(versionIds, 'must_revalidate') : []
const projectMap = new Map(projects.map((p: Labrinth.Projects.v2.Project) => [p.id, p]))
const contentItems: ContentItem[] = deps.map(
(dep: Labrinth.Versions.v2.Dependency): ContentItem => {
const depProject = dep.project_id ? projectMap.get(dep.project_id) : null
// @ts-expect-error - version_id is missing from the type for some reason
const depVersion = dep.version_id
? // @ts-expect-error - version_id is missing from the type for some reason
versions.find((v: Labrinth.Versions.v2.Version) => v.id === dep.version_id)
: null
return {
file_name: dep.file_name ?? depProject?.title ?? 'Unknown',
project_type: depProject?.project_type ?? 'mod',
has_update: false,
update_version_id: null,
project: {
id: depProject?.id ?? dep.project_id ?? dep.file_name ?? 'unknown',
slug: depProject?.slug ?? dep.project_id ?? 'unknown',
title: depProject?.title ?? dep.file_name ?? 'Unknown',
icon_url: depProject?.icon_url ?? undefined,
},
...(depVersion
? {
version: {
id: depVersion.id,
file_name: depVersion.files?.[0]?.filename ?? dep.file_name,
version_number: depVersion.version_number ?? undefined,
date_published: depVersion.date_published ?? undefined,
},
}
: {}),
}
},
)
modpackContentModal.value?.show(contentItems)
} catch (err) {
console.error('Failed to load modpack contents:', err)
modpackContentModal.value?.show([])
}
}
async function show(
projectVal: Labrinth.Projects.v3.Project,
modpackVersionIdVal: string | null = null,
callback: () => void = () => {},
e?: MouseEvent,
) {
project.value = projectVal
modpackVersionId.value = modpackVersionIdVal
modpackVersion.value = null
requiredContentProject.value = null
onInstallComplete.value = callback
if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
hide_ads_window()
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
show_ads_window()
}
const messages = defineMessages({
installToPlay: {
id: 'app.modal.install-to-play.header',
defaultMessage: 'Install to play',
},
sharedServerInstance: {
id: 'app.modal.install-to-play.shared-server-instance',
defaultMessage: 'Shared server instance',
},
contentRequired: {
id: 'app.modal.install-to-play.content-required',
defaultMessage: 'Content required',
},
serverRequiresMods: {
id: 'app.modal.install-to-play.server-requires-mods',
defaultMessage:
'This server requires mods to play. Click Install to set up the required files from Modrinth, then launch directly into the server.',
},
requiredModpack: {
id: 'app.modal.install-to-play.required-modpack',
defaultMessage: 'Required modpack',
},
sharedInstance: {
id: 'app.modal.install-to-play.shared-instance',
defaultMessage: 'Shared instance',
},
modCount: {
id: 'app.modal.install-to-play.mod-count',
defaultMessage: '{count, plural, one {# mod} other {# mods}}',
},
installButton: {
id: 'app.modal.install-to-play.install-button',
defaultMessage: 'Install',
},
viewContents: {
id: 'app.modal.install-to-play.view-contents',
defaultMessage: 'View contents',
},
})
defineExpose({ show, hide })
</script>
@@ -1,5 +1,4 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
ChevronRightIcon,
CodeIcon,
@@ -8,67 +7,25 @@ import {
MonitorIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
Avatar,
commonMessages,
defineMessage,
TabbedModal,
type TabbedModalTab,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { Avatar, TabbedModal, type TabbedModalTab } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, nextTick, ref, watch } from 'vue'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { ref } from 'vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import { get_project_v3 } from '@/helpers/cache'
import { get_linked_modpack_info } from '@/helpers/profile'
import { provideInstanceSettings } from '@/providers/instance-settings'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '../../../helpers/types'
import type { InstanceSettingsTabProps } from '../../../helpers/types'
const { formatMessage } = useVIntl()
const props = defineProps<{
instance: GameInstance
offline?: boolean
}>()
const emit = defineEmits<{
unlinked: []
}>()
const props = defineProps<InstanceSettingsTabProps>()
const isMinecraftServer = ref(false)
const handleUnlinked = () => emit('unlinked')
provideInstanceSettings({
instance: props.instance,
offline: props.offline,
isMinecraftServer,
onUnlinked: handleUnlinked,
})
watch(
() => props.instance,
(instance) => {
isMinecraftServer.value = false
if (instance.linked_data?.project_id) {
get_project_v3(instance.linked_data.project_id, 'must_revalidate')
.then((project: Labrinth.Projects.v3.Project | undefined) => {
if (project?.minecraft_server != null) {
isMinecraftServer.value = true
}
})
.catch(() => {})
}
},
{ immediate: true },
)
const tabs = computed<TabbedModalTab[]>(() => [
const tabs: TabbedModalTab<InstanceSettingsTabProps>[] = [
{
name: defineMessage({
id: 'instance.settings.tabs.general',
@@ -109,33 +66,23 @@ const tabs = computed<TabbedModalTab[]>(() => [
icon: CodeIcon,
content: HooksSettings,
},
])
]
const queryClient = useQueryClient()
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
const modal = ref()
function show(tabIndex?: number) {
if (props.instance.linked_data?.project_id) {
queryClient.prefetchQuery({
queryKey: ['linkedModpackInfo', props.instance.path],
queryFn: () => get_linked_modpack_info(props.instance.path, 'stale_while_revalidate'),
})
}
tabbedModal.value?.show()
if (tabIndex !== undefined) {
nextTick(() => tabbedModal.value?.setTab(tabIndex))
}
function show() {
modal.value.show()
}
defineExpose({ show })
const titleMessage = defineMessage({
id: 'instance.settings.title',
defaultMessage: 'Settings',
})
</script>
<template>
<TabbedModal
ref="tabbedModal"
:tabs="tabs"
:max-width="'min(928px, calc(95vw - 10rem))'"
:width="'min(928px, calc(95vw - 10rem))'"
>
<ModalWrapper ref="modal">
<template #title>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<Avatar
@@ -144,10 +91,10 @@ defineExpose({ show })
:tint-by="props.instance.path"
/>
{{ instance.name }} <ChevronRightIcon />
<span class="font-extrabold text-contrast">{{
formatMessage(commonMessages.settingsLabel)
}}</span>
<span class="font-extrabold text-contrast">{{ formatMessage(titleMessage) }}</span>
</span>
</template>
</TabbedModal>
<TabbedModal :tabs="tabs.map((tab) => ({ ...tab, props }))" />
</ModalWrapper>
</template>
@@ -1,17 +1,17 @@
<!-- @deprecated Use NewModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
<script setup lang="ts">
import { NewModal as Modal } from '@modrinth/ui'
import { useTemplateRef } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
const themeStore = useTheming()
const props = defineProps({
header: {
type: String,
default: null,
},
hideHeader: {
type: Boolean,
default: false,
},
closable: {
type: Boolean,
default: true,
@@ -22,7 +22,6 @@ const props = defineProps({
return () => {}
},
},
/** @deprecated No longer used — ads are handled by provideModalBehavior */
showAdOnClose: {
type: Boolean,
default: true,
@@ -32,22 +31,25 @@ const modal = useTemplateRef('modal')
defineExpose({
show: (e: MouseEvent) => {
hide_ads_window()
modal.value?.show(e)
},
hide: () => {
onModalHide()
modal.value?.hide()
},
})
function onModalHide() {
if (props.showAdOnClose) {
show_ads_window()
}
props.onHide?.()
}
</script>
<template>
<Modal
ref="modal"
:header="header"
:closable="closable"
:hide-header="hideHeader"
:on-hide="() => props.onHide?.()"
>
<Modal ref="modal" :header="header" :noblur="!themeStore.advancedRendering" @hide="onModalHide">
<template #title>
<slot name="title" />
</template>
@@ -1,84 +0,0 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="warning" max-width="500px">
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
{{ formatMessage(messages.admonitionBody, { instanceName }) }}
</Admonition>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="handleGoToInstance">
<ExternalIcon />
{{ formatMessage(messages.goToInstance) }}
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button @click="handleCreateAnyway">
<PlusIcon />
{{ formatMessage(messages.createAnyway) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { ExternalIcon, PlusIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'app.instance.modpack-already-installed.header',
defaultMessage: 'Modpack already installed',
},
admonitionHeader: {
id: 'app.instance.modpack-already-installed.admonition-header',
defaultMessage: 'Duplicate modpack',
},
admonitionBody: {
id: 'app.instance.modpack-already-installed.admonition-body',
defaultMessage: 'This modpack is already installed in the "{instanceName}" instance.',
},
goToInstance: {
id: 'app.instance.modpack-already-installed.go-to-instance',
defaultMessage: 'Go to instance',
},
createAnyway: {
id: 'app.instance.modpack-already-installed.create-anyway',
defaultMessage: 'Create anyway',
},
})
const emit = defineEmits<{
(e: 'go-to-instance', instancePath: string): void
(e: 'create-anyway'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const instanceName = ref('')
const instancePath = ref('')
function show(name: string, path: string) {
instanceName.value = name
instancePath.value = path
modal.value?.show()
}
function handleGoToInstance() {
modal.value?.hide()
emit('go-to-instance', instancePath.value)
}
function handleCreateAnyway() {
modal.value?.hide()
emit('create-anyway')
}
defineExpose({
show,
})
</script>
@@ -1,8 +1,12 @@
<!-- @deprecated Use ShareModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
<script setup lang="ts">
import { ShareModal } from '@modrinth/ui'
import { ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
const themeStore = useTheming()
defineProps({
header: {
type: String,
@@ -30,12 +34,18 @@ const modal = ref(null)
defineExpose({
show: (passedContent) => {
hide_ads_window()
modal.value.show(passedContent)
},
hide: () => {
onModalHide()
modal.value.hide()
},
})
function onModalHide() {
show_ads_window()
}
</script>
<template>
@@ -46,5 +56,7 @@ defineExpose({
:share-text="shareText"
:link="link"
:open-in-new-tab="openInNewTab"
:on-hide="onModalHide"
:noblur="!themeStore.advancedRendering"
/>
</template>
@@ -1,308 +0,0 @@
<template>
<ContentDiffModal
ref="diffModal"
:header="formatMessage(messages.updateToPlay)"
:admonition-header="formatMessage(messages.updateRequired)"
:description="
instance ? formatMessage(messages.updateRequiredDescription, { name: instance.name }) : ''
"
:diffs="normalizedDiffs"
:confirm-label="formatMessage(commonMessages.updateButton)"
:confirm-icon="DownloadIcon"
:show-report-button="true"
@confirm="handleUpdate"
@cancel="handleDecline"
@report="handleReport"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon } from '@modrinth/assets'
import {
commonMessages,
type ContentDiffItem,
ContentDiffModal,
defineMessages,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { update_managed_modrinth_version } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { injectServerInstall } from '@/providers/server-install'
type Dependency = Labrinth.Versions.v3.Dependency
type Version = Labrinth.Versions.v2.Version
interface BaseDiff {
project_id: string
project?: {
title: string
icon_url?: string
slug: string
}
currentVersionId?: string
newVersionId?: string
currentVersion?: Version
newVersion?: Version
fileName?: string
}
interface AddedDiff extends BaseDiff {
type: 'added'
newVersionId: string
}
interface RemovedDiff extends BaseDiff {
type: 'removed'
}
interface UpdatedDiff extends BaseDiff {
type: 'updated'
currentVersionId: string
newVersionId: string
}
type DependencyDiff = AddedDiff | RemovedDiff | UpdatedDiff
type ProjectInfo = {
id: string
title: string
icon_url?: string
slug: string
}
const { formatMessage } = useVIntl()
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise<void>
const diffModal = ref<InstanceType<typeof ContentDiffModal>>()
const instance = ref<GameInstance | null>(null)
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
const diffs = ref<DependencyDiff[]>([])
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Version | null>(null)
const normalizedDiffs = computed<ContentDiffItem[]>(() =>
diffs.value.map((diff) => ({
type: diff.type,
projectName: diff.project?.title,
fileName: diff.fileName,
currentVersionName: diff.currentVersion?.version_number,
newVersionName: diff.newVersion?.version_number,
})),
)
async function computeDependencyDiffs(
currentDeps: Dependency[],
latestDeps: Dependency[],
): Promise<DependencyDiff[]> {
console.log('Computing dependency diffs', { currentDeps, latestDeps })
// Separate deps with project_id from file_name-only deps
const currentWithProject = currentDeps.filter((d) => d.project_id)
const latestWithProject = latestDeps.filter((d) => d.project_id)
const currentFileOnly = currentDeps.filter((d) => !d.project_id && d.file_name)
const latestFileOnly = latestDeps.filter((d) => !d.project_id && d.file_name)
const currentByProject = new Map<string, Dependency>(
currentWithProject.map((d) => [d.project_id!, d]),
)
const latestByProject = new Map<string, Dependency>(
latestWithProject.map((d) => [d.project_id!, d]),
)
const currentFilenames = new Set(currentFileOnly.map((d) => d.file_name!))
const latestFilenames = new Set(latestFileOnly.map((d) => d.file_name!))
const diffs: DependencyDiff[] = []
// Find added and updated dependencies (by project_id)
latestByProject.forEach((latestDep, projectId) => {
const currentDep = currentByProject.get(projectId)
if (!currentDep && latestDep.version_id) {
diffs.push({ type: 'added', project_id: projectId, newVersionId: latestDep.version_id })
} else if (
currentDep?.version_id &&
latestDep?.version_id &&
currentDep?.version_id !== latestDep.version_id
) {
diffs.push({
type: 'updated',
project_id: projectId,
currentVersionId: currentDep.version_id,
newVersionId: latestDep.version_id,
})
}
})
// Find removed dependencies (by project_id)
currentByProject.forEach((currentDep, projectId) => {
if (!latestByProject.has(projectId)) {
diffs.push({
type: 'removed',
project_id: projectId,
currentVersionId: currentDep.version_id,
})
}
})
// Find added/removed file_name-only dependencies
// ideally in future, this should use the hash of the file instead of filename, but since version dependencies don't include file hashes, we'll use filename as a best effort approach
for (const fileName of latestFilenames) {
if (!currentFilenames.has(fileName)) {
diffs.push({ type: 'added', project_id: '', newVersionId: '' as string, fileName })
}
}
for (const fileName of currentFilenames) {
if (!latestFilenames.has(fileName)) {
diffs.push({ type: 'removed', project_id: '', fileName })
}
}
// Fetch projects and versions of diffs
const allProjectIds = [...new Set(diffs.map((d) => d.project_id).filter(Boolean))]
const allVersionIds = [
...new Set(
[...diffs.map((d) => d.newVersionId), ...diffs.map((d) => d.currentVersionId)].filter(
Boolean,
),
),
] as string[]
const [projects, versions] = await Promise.all([
get_project_many(allProjectIds, 'bypass'),
get_version_many(allVersionIds, 'bypass'),
])
const projectMap = new Map<string, ProjectInfo>(projects.map((p: ProjectInfo) => [p.id, p]))
const versionMap = new Map<string, Version>(versions.map((v: Version) => [v.id, v]))
const mappedDiffs = diffs
.map((diff) => {
const project = projectMap.get(diff.project_id)
return {
...diff,
project: project
? { title: project.title, icon_url: project.icon_url, slug: project.slug }
: undefined,
currentVersion: diff.currentVersionId ? versionMap.get(diff.currentVersionId) : undefined,
newVersion: diff.newVersionId ? versionMap.get(diff.newVersionId) : undefined,
}
})
.sort((a, b) => {
const typeOrder = { added: 0, updated: 1, removed: 2 }
const typeCompare = typeOrder[a.type] - typeOrder[b.type]
if (typeCompare !== 0) return typeCompare
const aDate = a.newVersion?.date_published || a.currentVersion?.date_published || ''
const bDate = b.newVersion?.date_published || b.currentVersion?.date_published || ''
return dayjs(bDate).valueOf() - dayjs(aDate).valueOf()
})
.filter((d) => d.project || d.fileName) // filter out any diffs that couldn't be matched to a project or file
return mappedDiffs
}
async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[] | null> {
if (!inst.linked_data) return null
if (!modpackVersionId.value || !inst.linked_data.version_id) return null
try {
// For server projects, linked_data.project_id is the server project but
// linked_data.version_id references a content modpack version from a different project.
// Detect this by comparing the version's project_id with linked_data.project_id.
modpackVersion.value = await get_version(modpackVersionId.value, 'bypass')
const instanceModpackVersion = await get_version(inst.linked_data.version_id, 'bypass')
// Compute dependency diffs between current and latest version
if (instanceModpackVersion && modpackVersion.value) {
return await computeDependencyDiffs(
instanceModpackVersion.dependencies || [],
modpackVersion.value.dependencies || [],
)
}
} catch (error) {
console.error('Error checking for updates:', error)
return null
}
return null
}
watch(
() => instance.value,
async (newInstance) => {
if (!newInstance) return
const result = await checkUpdateAvailable(newInstance)
diffs.value = result || []
},
{ immediate: true, deep: true },
)
async function handleUpdate() {
hide()
const serverProjectId = instance.value?.linked_data?.project_id
if (serverProjectId) startInstallingServer(serverProjectId)
try {
if (modpackVersionId.value && instance.value) {
await update_managed_modrinth_version(instance.value.path, modpackVersionId.value)
await onUpdateComplete.value()
}
} catch (error) {
console.error('Error updating instance:', error)
} finally {
if (serverProjectId) stopInstallingServer(serverProjectId)
}
}
function handleReport() {
if (instance.value?.linked_data?.project_id) {
openUrl(
`https://modrinth.com/report?item=project&itemID=${instance.value.linked_data.project_id}`,
)
}
}
function handleDecline() {
hide()
}
function show(
instanceVal: GameInstance,
modpackVersionIdVal: string | null = null,
callback: UpdateCompleteCallback = () => {},
e?: MouseEvent,
) {
instance.value = instanceVal
modpackVersionId.value = modpackVersionIdVal
onUpdateComplete.value = callback
diffModal.value?.show(e)
}
function hide() {
diffModal.value?.hide()
}
const messages = defineMessages({
updateToPlay: {
id: 'app.modal.update-to-play.header',
defaultMessage: 'Update to play',
},
updateRequired: {
id: 'app.modal.update-to-play.update-required',
defaultMessage: 'Update required',
},
updateRequiredDescription: {
id: 'app.modal.update-to-play.update-required-description',
defaultMessage:
'An update is required to play {name}. Please update to the latest version to launch the game.',
},
})
const hasUpdate = computed(() => {
if (!instance.value?.linked_data) return false
return (
modpackVersionId.value != null &&
modpackVersionId.value !== instance.value.linked_data.version_id
)
})
defineExpose({ show, hide, hasUpdate })
</script>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Combobox, ThemeSelector, Toggle } from '@modrinth/ui'
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
@@ -50,7 +50,7 @@ watch(
:model-value="themeStore.advancedRendering"
@update:model-value="
(e) => {
themeStore.advancedRendering = !!e
themeStore.advancedRendering = e
settings.advanced_rendering = themeStore.advancedRendering
}
"
@@ -86,13 +86,12 @@ watch(
<h2 class="m-0 text-lg font-extrabold text-contrast">Default landing page</h2>
<p class="m-0 mt-1">Change the page to which the launcher opens on.</p>
</div>
<Combobox
<TeleportDropdownMenu
id="opening-page"
v-model="settings.default_page"
name="Opening page dropdown"
class="w-40"
:options="['Home', 'Library'].map((v) => ({ value: v, label: v }))"
:display-value="settings.default_page ?? 'Select an option'"
:options="['Home', 'Library']"
/>
</div>
@@ -123,7 +122,7 @@ watch(
:model-value="settings.toggle_sidebar"
@update:model-value="
(e) => {
settings.toggle_sidebar = !!e
settings.toggle_sidebar = e
themeStore.toggleSidebar = settings.toggle_sidebar
}
"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { injectNotificationManager, Slider, StyledInput, Toggle } from '@modrinth/ui'
import { injectNotificationManager, Slider, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import useMemorySlider from '@/composables/useMemorySlider'
@@ -73,7 +73,7 @@ watch(
</p>
</div>
<StyledInput
<input
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
@@ -91,12 +91,13 @@ watch(
</p>
</div>
<StyledInput
<input
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
class="input"
placeholder="Enter height..."
/>
</div>
@@ -117,23 +118,23 @@ watch(
/>
<h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Java arguments</h2>
<StyledInput
<input
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
placeholder="Enter java arguments..."
wrapper-class="w-full"
class="w-full"
/>
<h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Environmental variables</h2>
<StyledInput
<input
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
placeholder="Enter environmental variables..."
wrapper-class="w-full"
class="w-full"
/>
<hr class="mt-4 bg-button-border border-none h-[1px]" />
@@ -142,37 +143,37 @@ watch(
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Pre launch</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">Ran before the instance is launched.</p>
<StyledInput
<input
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
placeholder="Enter pre-launch command..."
wrapper-class="w-full"
class="w-full"
/>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Wrapper</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
Wrapper command for launching Minecraft.
</p>
<StyledInput
<input
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
placeholder="Enter wrapper command..."
wrapper-class="w-full"
class="w-full"
/>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Post exit</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">Ran after the game closes.</p>
<StyledInput
<input
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
placeholder="Enter post-exit command..."
wrapper-class="w-full"
class="w-full"
/>
</div>
</template>
@@ -21,7 +21,7 @@ async function updateJavaVersion(version) {
}
</script>
<template>
<div v-for="(javaVersion, index) in [25, 21, 17, 8]" :key="`java-${javaVersion}`">
<div v-for="(javaVersion, index) in [21, 17, 8]" :key="`java-${javaVersion}`">
<h2 class="m-0 text-lg font-extrabold text-contrast" :class="{ 'mt-4': index !== 0 }">
Java {{ javaVersion }} location
</h2>
@@ -1,71 +0,0 @@
<script setup lang="ts">
import {
Admonition,
AutoLink,
IntlFormatted,
LanguageSelector,
languageSelectorMessages,
LOCALES,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import i18n from '@/i18n.config'
const { formatMessage } = useVIntl()
const platform = computed(() => formatMessage(languageSelectorMessages.platformApp))
const settings = ref(await get())
watch(
settings,
async () => {
await set(settings.value)
},
{ deep: true },
)
const $isChanging = ref(false)
async function onLocaleChange(newLocale: string) {
if (settings.value.locale === newLocale) return
$isChanging.value = true
try {
i18n.global.locale.value = newLocale
settings.value.locale = newLocale
} finally {
$isChanging.value = false
}
}
</script>
<template>
<h2 class="m-0 text-lg font-extrabold text-contrast">Language</h2>
<Admonition type="warning" class="mt-2 mb-4">
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
</Admonition>
<p class="m-0 mb-4">
<IntlFormatted
:message-id="languageSelectorMessages.languagesDescription"
:values="{ platform }"
>
<template #~crowdin-link="{ children }">
<AutoLink to="https://translate.modrinth.com">
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</p>
<LanguageSelector
:current-locale="settings.locale"
:locales="LOCALES"
:on-locale-change="onLocaleChange"
:is-changing="$isChanging"
/>
</template>
@@ -1,6 +1,6 @@
<script setup>
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import { Button, injectNotificationManager, Slider, StyledInput } from '@modrinth/ui'
import { Button, injectNotificationManager, Slider } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
@@ -28,12 +28,10 @@ watch(
async function purgeCache() {
await purge_cache_types([
'project',
'project_v3',
'version',
'user',
'team',
'organization',
'file',
'loader_manifest',
'minecraft_manifest',
'categories',
@@ -41,10 +39,8 @@ async function purgeCache() {
'loaders',
'game_versions',
'donation_platforms',
'file_hash',
'file_update',
'search_results',
'search_results_v3',
]).catch(handleError)
}
@@ -69,19 +65,13 @@ async function findLauncherDir() {
</p>
<div class="m-1 my-2">
<StyledInput
id="appDir"
v-model="settings.custom_dir"
:icon="BoxIcon"
type="text"
wrapper-class="w-full"
>
<template #right>
<Button class="r-btn" @click="findLauncherDir">
<FolderSearchIcon />
</Button>
</template>
</StyledInput>
<div class="iconified-input w-full">
<BoxIcon />
<input id="appDir" v-model="settings.custom_dir" type="text" class="input" />
<Button class="r-btn" @click="findLauncherDir">
<FolderSearchIcon />
</Button>
</div>
</div>
<div>
@@ -130,7 +130,6 @@ import {
remove_custom_skin,
type Skin,
type SkinModel,
type SkinTextureUrl,
unequip_skin,
} from '@/helpers/skins.ts'
@@ -143,7 +142,7 @@ const currentSkin = ref<Skin | null>(null)
const shouldRestoreModal = ref(false)
const isSaving = ref(false)
const uploadedTextureUrl = ref<SkinTextureUrl | null>(null)
const uploadedTextureUrl = ref<string | null>(null)
const previewSkin = ref<string>('')
const variant = ref<SkinModel>('CLASSIC')
@@ -189,7 +188,7 @@ function getSortedCapeExcluding(excludeId: string): Cape | undefined {
async function loadPreviewSkin() {
if (uploadedTextureUrl.value) {
previewSkin.value = uploadedTextureUrl.value.normalized
previewSkin.value = uploadedTextureUrl.value
} else if (currentSkin.value) {
try {
previewSkin.value = await get_normalized_skin_texture(currentSkin.value)
@@ -254,11 +253,11 @@ async function show(e: MouseEvent, skin?: Skin) {
modal.value?.show(e)
}
async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
async function showNew(e: MouseEvent, skinTextureUrl: string) {
mode.value = 'new'
currentSkin.value = null
uploadedTextureUrl.value = skinTextureUrl
variant.value = await determineModelType(skinTextureUrl.original)
variant.value = await determineModelType(skinTextureUrl)
selectedCape.value = undefined
visibleCapeList.value = []
initVisibleCapeList()
@@ -268,7 +267,7 @@ async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
modal.value?.show(e)
}
async function restoreWithNewTexture(skinTextureUrl: SkinTextureUrl) {
async function restoreWithNewTexture(skinTextureUrl: string) {
uploadedTextureUrl.value = skinTextureUrl
await loadPreviewSkin()
@@ -362,7 +361,7 @@ async function save() {
let textureUrl: string
if (uploadedTextureUrl.value) {
textureUrl = uploadedTextureUrl.value.original
textureUrl = uploadedTextureUrl.value
} else {
textureUrl = currentSkin.value!.texture
}
@@ -14,13 +14,13 @@ import {
injectNotificationManager,
OverflowMenu,
SmartClickable,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { capitalizeString } from '@modrinth/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import { useVIntl } from '@vintl/vintl'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -36,10 +36,6 @@ import { handleSevereError } from '@/store/error'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const router = useRouter()
@@ -84,7 +80,7 @@ const play = async (event: MouseEvent) => {
await run(props.instance.path)
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
.finally(() => {
trackEvent('InstanceStart', {
trackEvent('InstancePlay', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: 'InstanceItem',
@@ -134,7 +130,7 @@ onUnmounted(() => {
/>
</template>
<div
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised card-shadow rounded-xl smart-clickable:highlight-on-hover"
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised rounded-xl smart-clickable:highlight-on-hover"
>
<Avatar
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
@@ -149,14 +145,18 @@ onUnmounted(() => {
</div>
<div class="flex items-center gap-2 text-sm text-secondary">
<div
v-tooltip="instance.last_played ? formatDateTime(instance.last_played) : null"
v-tooltip="
instance.last_played
? dayjs(instance.last_played).format('MMMM D, YYYY [at] h:mm A')
: null
"
class="w-fit shrink-0"
:class="{ 'cursor-help smart-clickable:allow-pointer-events': last_played }"
>
<template v-if="last_played">
{{
formatMessage(commonMessages.playedLabel, {
ago: formatRelativeTime(last_played.toISOString?.()),
time: formatRelativeTime(last_played.toISOString?.()),
})
}}
</template>
@@ -1,8 +1,6 @@
<script setup lang="ts">
import { LoaderCircleIcon } from '@modrinth/assets'
import type { GameVersion } from '@modrinth/ui'
import { GAME_MODES, HeadingLink, injectNotificationManager } from '@modrinth/ui'
import { platform } from '@tauri-apps/plugin-os'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
@@ -41,7 +39,6 @@ const props = defineProps<{
const theme = useTheming()
const jumpBackInItems = ref<JumpBackInItem[]>([])
const loading = ref(true)
const serverData = ref<Record<string, ServerData>>({})
const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
@@ -49,11 +46,6 @@ const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []
const MIN_JUMP_BACK_IN = 3
const MAX_JUMP_BACK_IN = 6
const TWO_WEEKS_AGO = dayjs().subtract(14, 'day')
const MAX_LINUX_POPULATES = 3
// Track populate calls on Linux to prevent server ping spam
const isLinux = platform() === 'linux'
const linuxPopulateCount = ref(0)
type BaseJumpBackInItem = {
last_played: Dayjs
@@ -79,19 +71,11 @@ watch([() => props.recentInstances, () => showWorlds.value], async () => {
})
})
populateJumpBackIn()
.catch(() => {
console.error('Failed to populate jump back in')
})
.finally(() => {
loading.value = false
})
await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in')
})
async function populateJumpBackIn() {
// On Linux, limit automatic populates to prevent server ping spam
if (isLinux && linuxPopulateCount.value >= MAX_LINUX_POPULATES) return
if (isLinux) linuxPopulateCount.value++
console.info('Repopulating jump back in...')
const worldItems: WorldJumpBackInItem[] = []
@@ -175,17 +159,10 @@ function refreshServer(address: string, instancePath: string) {
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
}
async function joinWorld(world: WorldWithProfile, instance?: GameInstance) {
async function joinWorld(world: WorldWithProfile) {
console.log(`Joining world ${getWorldIdentifier(world)}`)
if (world.type === 'server') {
await start_join_server(world.profile, world.address).catch(handleError)
if (instance) {
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'WorldItem',
})
}
} else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(world.profile, world.path).catch(handleError)
}
@@ -195,7 +172,7 @@ async function playInstance(instance: GameInstance) {
await run(instance.path)
.catch((err) => handleSevereError(err, { profilePath: instance.path }))
.finally(() => {
trackEvent('InstanceStart', {
trackEvent('InstancePlay', {
loader: instance.loader,
game_version: instance.game_version,
source: 'WorldItem',
@@ -247,7 +224,6 @@ const checkProcesses = async () => {
onMounted(() => {
checkProcesses()
linuxPopulateCount.value = 0
})
onUnmounted(() => {
@@ -257,15 +233,7 @@ onUnmounted(() => {
</script>
<template>
<div v-if="loading" class="flex flex-col gap-2">
<span class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold">
Jump back in
</span>
<div class="text-center py-4">
<LoaderCircleIcon class="mx-auto size-8 animate-spin text-contrast" />
</div>
</div>
<div v-else-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1">
Jump back in
</HeadingLink>
@@ -324,7 +292,7 @@ onUnmounted(() => {
() => {
currentProfile = item.instance.path
currentWorld = getWorldIdentifier(item.world)
joinWorld(item.world, item.instance)
joinWorld(item.world)
}
"
@play-instance="
@@ -17,22 +17,18 @@ import {
UserIcon,
XIcon,
} from '@modrinth/assets'
import type { MessageDescriptor } from '@modrinth/ui'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
OverflowMenu,
SmartClickable,
TagItem,
useFormatDateTime,
useFormatNumber,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { getPingLevel } from '@modrinth/utils'
import { formatNumber, getPingLevel } from '@modrinth/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import type { MessageDescriptor } from '@vintl/vintl'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import type { Component } from 'vue'
@@ -49,15 +45,8 @@ import type {
} from '@/helpers/worlds.ts'
import { getWorldIdentifier, set_world_display_status } from '@/helpers/worlds.ts'
import { LockIcon } from '../../../../../../packages/assets/generated-icons'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatNumber = useFormatNumber()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const router = useRouter()
@@ -88,8 +77,6 @@ const props = withDefaults(
message: MessageDescriptor
}
managed?: boolean
// Instance
instancePath?: string
instanceName?: string
@@ -108,7 +95,6 @@ const props = withDefaults(
renderedMotd: undefined,
gameMode: undefined,
managed: false,
instancePath: undefined,
instanceName: undefined,
@@ -130,7 +116,6 @@ const serverIncompatible = computed(
)
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
const managed = computed(() => props.managed)
const messages = defineMessages({
hardcore: {
@@ -185,10 +170,6 @@ const messages = defineMessages({
id: 'instance.worlds.dont_show_on_home',
defaultMessage: `Don't show on Home`,
},
linkedServer: {
id: 'instance.worlds.linked_server',
defaultMessage: 'Managed by server project',
},
})
</script>
<template>
@@ -200,16 +181,14 @@ const messages = defineMessages({
/>
</template>
<div
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised card-shadow smart-clickable:highlight-on-hover rounded-xl"
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised smart-clickable:highlight-on-hover rounded-xl"
:class="{
'world-item-highlighted': highlighted,
}"
>
<Avatar
:src="
world.type === 'server' && serverStatus
? (serverStatus.favicon ?? world.icon)
: world.icon
world.type === 'server' && serverStatus ? serverStatus.favicon ?? world.icon : world.icon
"
size="48px"
/>
@@ -218,14 +197,6 @@ const messages = defineMessages({
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
{{ world.name }}
</div>
<TagItem
v-if="managed"
v-tooltip="formatMessage(messages.linkedServer)"
class="border !border-solid border-blue bg-highlight-blue text-xs"
:style="`--_color: var(--color-blue)`"
>
<LockIcon aria-hidden="true" class="h-5 w-5" />
</TagItem>
<div
v-if="world.type === 'singleplayer'"
class="text-sm text-secondary flex items-center gap-1 font-semibold"
@@ -265,7 +236,7 @@ const messages = defineMessages({
/>
<Tooltip :disabled="!hasPlayersTooltip">
<span :class="{ 'cursor-help': hasPlayersTooltip }">
{{ formatNumber(serverStatus.players?.online) }}
{{ formatNumber(serverStatus.players?.online, false) }}
online
</span>
<template #popper>
@@ -286,7 +257,9 @@ const messages = defineMessages({
</div>
<div class="flex items-center gap-2 text-sm text-secondary">
<div
v-tooltip="world.last_played ? formatDateTime(world.last_played) : null"
v-tooltip="
world.last_played ? dayjs(world.last_played).format('MMMM D, YYYY [at] h:mm A') : null
"
class="w-fit shrink-0"
:class="{
'cursor-help smart-clickable:allow-pointer-events': world.last_played,
@@ -295,7 +268,7 @@ const messages = defineMessages({
<template v-if="world.last_played">
{{
formatMessage(commonMessages.playedLabel, {
ago: formatRelativeTime(dayjs(world.last_played).toISOString()),
time: formatRelativeTime(dayjs(world.last_played).toISOString()),
})
}}
</template>
@@ -420,12 +393,8 @@ const messages = defineMessages({
id: 'edit',
action: () => emit('edit'),
shown: !instancePath,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
: managed
? formatMessage(messages.linkedServer)
: undefined,
disabled: locked,
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
},
{
id: 'open-folder',
@@ -460,12 +429,8 @@ const messages = defineMessages({
hoverFilled: true,
action: () => emit('delete'),
shown: !instancePath,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
: managed
? formatMessage(messages.linkedServer)
: undefined,
disabled: locked,
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
},
]"
>
@@ -1,12 +1,7 @@
<script setup lang="ts">
import { PlayIcon, PlusIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { ButtonStyled, commonMessages, injectNotificationManager } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { ref } from 'vue'
import InstanceModalTitlePrefix from '@/components/ui/modal/InstanceModalTitlePrefix.vue'
@@ -1,12 +1,7 @@
<script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessage,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { ButtonStyled, commonMessages, injectNotificationManager } from '@modrinth/ui'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
@@ -1,14 +1,7 @@
<script setup lang="ts">
import { ChevronRightIcon, SaveIcon, UndoIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { Avatar, ButtonStyled, commonMessages, injectNotificationManager } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
@@ -104,11 +97,12 @@ const messages = defineMessages({
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
{{ formatMessage(messages.name) }}
</h2>
<StyledInput
<input
v-model="name"
type="text"
:placeholder="formatMessage(messages.placeholderName)"
class="w-full"
autocomplete="off"
wrapper-class="w-full"
/>
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
</div>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Checkbox, defineMessage, useVIntl } from '@modrinth/ui'
import { Checkbox } from '@modrinth/ui'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { computed } from 'vue'
const { formatMessage } = useVIntl()
@@ -1,11 +1,6 @@
<script setup lang="ts">
import {
Combobox,
defineMessages,
type MessageDescriptor,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { TeleportDropdownMenu } from '@modrinth/ui'
import { defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import type { ServerPackStatus } from '@/helpers/worlds.ts'
@@ -58,38 +53,33 @@ defineExpose({ resourcePackOptions })
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
{{ formatMessage(messages.name) }}
</h2>
<StyledInput
<input
v-model="name"
type="text"
:placeholder="formatMessage(messages.placeholderName)"
class="w-full"
autocomplete="off"
wrapper-class="w-full"
/>
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
{{ formatMessage(messages.address) }}
</h2>
<StyledInput
<input
v-model="address"
type="text"
placeholder="example.modrinth.gg"
class="w-full"
autocomplete="off"
wrapper-class="w-full"
/>
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
{{ formatMessage(messages.resourcePack) }}
</h2>
<div>
<Combobox
<TeleportDropdownMenu
v-model="resourcePack"
:options="
resourcePackOptions.map((o) => ({
value: o,
label: formatMessage(resourcePackOptionMessages[o]),
}))
"
:options="resourcePackOptions"
name="Server resource pack"
:display-value="
resourcePack
? formatMessage(resourcePackOptionMessages[resourcePack])
: 'Select an option'
:display-name="
(option: ServerPackStatus) => formatMessage(resourcePackOptionMessages[option])
"
/>
</div>
@@ -0,0 +1,24 @@
import { posthog } from 'posthog-js'
export const initAnalytics = () => {
posthog.init('phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ', {
persistence: 'localStorage',
api_host: 'https://posthog.modrinth.com',
})
}
export const debugAnalytics = () => {
posthog.debug()
}
export const optOutAnalytics = () => {
posthog.opt_out_capturing()
}
export const optInAnalytics = () => {
posthog.opt_in_capturing()
}
export const trackEvent = (eventName, properties) => {
posthog.capture(eventName, properties)
}

Some files were not shown because too many files have changed in this diff Show More