Compare commits

..

1 Commits

Author SHA1 Message Date
Pernoise c60cf79a72 Update README.md 2025-12-28 17:46:36 +00:00
2820 changed files with 153874 additions and 429158 deletions
-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,26 +0,0 @@
---
name: cross-platform-pages
description: Convert a page to the cross-platform page system so it works in both the website and the desktop app. Use when moving a page into packages/ui/src/layouts/, creating shared or wrapped layouts, or setting up DI contracts for platform abstraction.
argument-hint: <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.
- If the layout uses TanStack Query for initial route paint with `ReadyTransition` / `useReadyState`, each platform route shell must call `ensureQueryData` for those queries with matching keys and fetchers — see **Platform route shells: prefetch with `ensureQueryData`** in `standards/frontend/CROSS_PLATFORM_PAGES.md`.
6. **Verify** the page renders correctly by checking for missing imports and that all DI contracts are satisfied.
-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.
-36
View File
@@ -1,36 +0,0 @@
---
name: review-changelog
description: Review the latest changelog entry in packages/blog/changelog.ts against the project's changelog style guide and flag bullets that need rewriting. Use when checking a freshly added changelog entry before opening a PR, or when the user asks to review/lint the latest changelog.
argument-hint: [product?]
---
Refer to the standard: @standards/maintaining/CHANGELOG.md
## Steps
1. **Locate the latest entry:**
- Open `packages/blog/changelog.ts`.
- The latest entries are at the top of the `VERSIONS` array.
- If `$ARGUMENTS` specifies a product (`web`, `hosting`, `app`), review the most recent entry for that product. Otherwise, review the most recent entry overall, plus any sibling entries sharing the same `date` (coordinated releases ship together).
2. **Read the standard above** in full before reviewing. The bullet rules, section/verb agreement, and "Don't" list are the source of truth.
3. **Check the entry shell:**
- `date` is a valid ISO 8601 timestamp.
- `product` is one of `web`, `hosting`, `app`.
- `version` is present for `app` entries and omitted for `web`/`hosting`.
- Section headings use `## Added`, `## Changed`, `## Fixed`, `## Security` (or a featured-release linked heading). Flag legacy `## Improvements`.
4. **Review each bullet** against the standard. For each bullet, check:
- Voice/tense matches the section heading.
- Opening verb agrees with its section.
- Describes observable behavior, not implementation.
- Specific enough to identify the surface (names the tab/page/modal).
- One sentence, ends with a period, sentence case.
- Uses branded names (Modrinth App, Modrinth Hosting) correctly.
- No filler ("issue with", "issue where", "various", "some"), no vague intensifiers, no apologies, no PR/commit references (unless crediting a third-party contributor with a linked GitHub profile).
- Not a duplicate sub-fix of a bigger change already listed.
5. **Report findings** as a short list grouped by entry. For each problem bullet, show the original line and a suggested rewrite. If the entry is clean, say so explicitly. Do not edit the file unless the user asks - this skill is a review pass, not a rewrite pass.
6. **If the user then asks to apply fixes**, edit `packages/blog/changelog.ts` directly using the suggested rewrites. Preserve tab indentation and template literal formatting.
-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.
@@ -1,6 +1,6 @@
name: 👥 Bug with Modrinth Hosting
description: For issues with a Modrinth Hosting product.
labels: [hosting]
name: 👥 Bug with Modrinth Servers
description: For issues with a Modrinth Servers product.
labels: [servers]
type: 'bug'
body:
- type: checkboxes
@@ -2,7 +2,7 @@
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`.
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 @vintl/vintl-nuxt (which wraps FormatJS).
Please follow these rules precisely:
@@ -13,53 +13,40 @@ Please follow these rules precisely:
2. Create message definitions
- In the <script setup> block, import `defineMessage` or `defineMessages` from `@modrinth/ui`.
- In the <script setup> block, import `defineMessage` or `defineMessages` from `@vintl/vintl`.
- 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…' },
welcomeDescription: { id: 'auth.welcome.description', defaultMessage: 'Youre 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 numbers/dates/times, use ICU/FormatJS 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>
- Render rich-text messages with `<IntlFormatted>` from `@vintl/vintl/components` and map tags via `values`:
<IntlFormatted
:message="messages.tosLabel"
:values="{
'terms-link': (chunks) => <NuxtLink to='/terms'>{chunks}</NuxtLink>,
'privacy-link': (chunks) => <NuxtLink to='/privacy'>{chunks}</NuxtLink>,
}"
/>
- For simple emphasis: `'Welcome to <strong>Modrinth</strong>!'` and map `'strong': (c) => <strong>{c}</strong>`
5. Formatting in templates
- Import and use `useVIntl()` from `@modrinth/ui`; prefer `formatMessage` for simple strings:
- Import and use `useVIntl()`; 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 }) }}`
- Vue methods like `$formatMessage`, `$formatNumber`, `$formatDate` are also available if needed.
6. Naming conventions and id stability
@@ -71,8 +58,7 @@ Please follow these rules precisely:
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.
- Ensure imports for `defineMessage`/`defineMessages`, `useVIntl`, and `<IntlFormatted>` are present. Replace all hard-coded strings with `formatMessage(...)` or `<IntlFormatted>` and remove the literals.
9. Preserve functionality
-171
View File
@@ -1,171 +0,0 @@
# MIT License
#
# Copyright (c) 2024 CARIAD SE
#
# 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.
name: 'Merge Queue CI Check Skipper'
description: 'Outputs `skip-check` as `true` if this is running as part of merge queue checks and the same checks have already been executed in the PR itself.'
inputs:
secret:
description: 'Optional GitHub Secret that can access branch protection rules using the administration:read permission'
required: false
outputs:
skip-check:
description: 'Skip Check (boolean)'
value: ${{ steps.passed-checks.outputs.can-skip-checks }}
runs:
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract PR Number and Commit ID
id: extract-pr-info
uses: actions/github-script@v7
with:
script: |
const githubRef = process.env.GITHUB_REF;
const regex = /^refs\/heads\/gh-readonly-queue\/([a-zA-Z0-9.\-_\/]+)\/pr-(\d+)-([a-f0-9]+)$/;
if (regex.test(githubRef)) {
const [, targetBranchName, prNumber, commitId] = githubRef.match(regex);
core.setOutput('targetBranchName', targetBranchName);
core.setOutput('prNumber', prNumber);
core.setOutput('commitId', commitId);
} else {
console.log(`GITHUB_REF is not a merge queue ref, setting CAN_SKIP_CHECKS to false: ${githubRef}`);
core.exportVariable('CAN_SKIP_CHECKS', 'false');
}
- name: Print PR Number and Target Commit ID
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
run: |
echo "Target Branch Name: ${{ steps.extract-pr-info.outputs.targetBranchName }}"
echo "PR Number: ${{ steps.extract-pr-info.outputs.prNumber }}"
echo "Target Commit ID: ${{ steps.extract-pr-info.outputs.commitId }}"
- name: Check if merge queue entry was enqueued as head of the queue
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
run: |
targetBranchHead=$(git rev-parse origin/${{ steps.extract-pr-info.outputs.targetBranchName }})
if [[ "$targetBranchHead" != "${{ steps.extract-pr-info.outputs.commitId }}" ]]; then
echo "'${{ steps.extract-pr-info.outputs.targetBranchName }}' branch commit ID does not match PR commit ID. This Merge Queue run was not head of the queue when it was enqueued. Setting CAN_SKIP_CHECKS to false."
echo "CAN_SKIP_CHECKS=false" >> "$GITHUB_ENV"
else
echo "This merge queue entry is targeting '${{ steps.extract-pr-info.outputs.targetBranchName }}' directly."
fi
- name: Get PR Branch
id: get-pr-branch
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
prNumber=${{ steps.extract-pr-info.outputs.prNumber }}
branchName=$(gh pr view ${prNumber} --json headRefName -q '.headRefName')
echo "prBranch=$branchName" >> "$GITHUB_OUTPUT"
- name: Print PR Branch
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
run: |
echo "PR Branch: ${{ steps.get-pr-branch.outputs.prBranch }}"
- name: Check if PR branch contains the Merge Queue target commit ID
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
run: |
# Get the branch name from previous steps
branch_name="origin/${{ steps.get-pr-branch.outputs.prBranch }}"
commit_id="${{ steps.extract-pr-info.outputs.commitId }}"
# Check if the branch history contains the commit
if git branch -r --contains "$commit_id" | grep -q "$branch_name"; then
echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with ${{ steps.extract-pr-info.outputs.targetBranchName }}."
else
echo "Branch '$branch_name' does not contain commit '$commit_id'. It is outdated. Setting CAN_SKIP_CHECKS to false."
echo "CAN_SKIP_CHECKS=false" >> "$GITHUB_ENV"
fi
- name: Compare PR Branch with Current Branch
if: env.CAN_SKIP_CHECKS != 'false'
shell: bash
run: |
if git diff --quiet "origin/${{ steps.get-pr-branch.outputs.prBranch }}"; then
echo "No differences found. PR branch is identical with this merge queue branch."
else
echo "Differences detected. PR branch has been updated after PR was added to merge queue. Setting CAN_SKIP_CHECKS to false."
echo "CAN_SKIP_CHECKS=false" >> "$GITHUB_ENV"
fi
- name: Compute/publish skip result
id: passed-checks
uses: actions/github-script@v7
env:
SECRET: ${{ inputs.secret }}
with:
github-token: ${{ inputs.secret != '' && inputs.secret || github.token }}
script: |
if (process.env.CAN_SKIP_CHECKS == "false") {
console.log("Setting CAN_SKIP_CHECKS to false");
core.setOutput("can-skip-checks", false);
return;
}
const secretProvided = !!process.env.SECRET;
if (!secretProvided) {
console.log("secret input not set, assuming all checks have passed. Ensure 'Require status checks to pass before merging' is enabled, or provide a secret with administration:read.");
core.setOutput("can-skip-checks", true);
return;
}
const { data: branchProtection } = await github.rest.repos.getBranchProtection({
owner: context.repo.owner,
repo: context.repo.repo,
branch: "${{ steps.extract-pr-info.outputs.targetBranchName }}",
});
const requiredCheckNames = branchProtection.required_status_checks.contexts;
console.log(`requiredCheckNames = ${requiredCheckNames}`);
const { data: checks } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: "refs/heads/${{ steps.get-pr-branch.outputs.prBranch }}",
});
console.log(`checks.check_runs = ${checks.check_runs.map(check => `${check.status},${check.conclusion},${check.name};`)}`);
const nonSuccessfulChecks = checks.check_runs.filter(check => check.status !== "completed" || check.conclusion !== "success");
const nonSuccessfulCheckNames = nonSuccessfulChecks.map(check => check.name);
console.log(`nonSuccessfulCheckNames = ${nonSuccessfulCheckNames}`);
const missingChecks = requiredCheckNames.filter(checkName => nonSuccessfulCheckNames.includes(checkName));
if (missingChecks.length > 0) {
console.log(`Required checks not passed, cannot skip merge queue checks. Setting CAN_SKIP_CHECKS to false. Missing checks: ${missingChecks.join(', ')}`);
core.setOutput("can-skip-checks", false);
} else {
console.log("No missing checks. Setting CAN_SKIP_CHECKS to true.");
core.setOutput("can-skip-checks", true);
}
-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.
+65 -94
View File
@@ -1,4 +1,4 @@
name: AstralRinth App Build
name: AstralRinth App build
on:
push:
@@ -21,54 +21,25 @@ on:
- 'packages/assets/**'
- 'packages/ui/**'
- 'packages/utils/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.jsonc'
- 'mise.toml'
- 'rust-toolchain.toml'
workflow_dispatch:
concurrency:
group: astralrinth-build-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: ${{ matrix.label }}
runs-on: ${{ matrix.runner }}
name: Build
strategy:
fail-fast: false
matrix:
# platform: [macos-latest, windows-latest, ubuntu-latest]
platform: [windows-latest, ubuntu-latest]
include:
- runner: ubuntu-latest
label: 🐧 Linux x86_64 Build
target: x86_64-unknown-linux-gnu
rust_targets: x86_64-unknown-linux-gnu
artifact_name: astralrinth-bundle-linux-x86_64
- runner: windows-latest
label: 🪟 Windows x86_64 Build
target: x86_64-pc-windows-msvc
rust_targets: x86_64-pc-windows-msvc
artifact_name: astralrinth-bundle-windows-x86_64
- runner: windows-latest
label: 🪟 Windows aarch64 Build
target: aarch64-pc-windows-msvc
rust_targets: aarch64-pc-windows-msvc
artifact_name: astralrinth-bundle-windows-aarch64
- runner: macos-latest
label: 🍎 macOS x86_64 Build
target: x86_64-apple-darwin
rust_targets: x86_64-apple-darwin
artifact_name: astralrinth-bundle-macos-x86_64
- runner: macos-latest
label: 🍎 macOS aarch64 Build
target: aarch64-apple-darwin
rust_targets: aarch64-apple-darwin
artifact_name: astralrinth-bundle-macos-aarch64
# - platform: macos-latest
# artifact-target-name: universal-apple-darwin
- platform: windows-latest
artifact-target-name: x86_64-pc-windows-msvc
- platform: ubuntu-latest
artifact-target-name: x86_64-unknown-linux-gnu
env:
CI: true
runs-on: ${{ matrix.platform }}
steps:
- name: 📥 Check out code
@@ -92,33 +63,25 @@ jobs:
fi
if [ "$eol_setting" = "crlf" ]; then
echo "⚠️ WARNING: core.eol is set to 'crlf'. Consider unsetting it or setting it to 'lf'."
echo "⚠️ WARNING: core.eol is set to 'crlf'. Consider unsetting it or setting to 'lf'."
fi
- name: 🔍 Check migration files line endings (LF only)
shell: bash
run: |
echo "🔍 Scanning migration SQL files for CR characters (\\r)..."
if grep -Iq $'\r' packages/app-lib/migrations/*.sql; then
echo "❌ ERROR: Some migration files contain CR characters; expected LF-only files."
echo "❌ ERROR: Some migration files contain CR (\\r) characters expected only LF line endings."
exit 1
fi
echo "✅ All migration files use LF line endings."
echo "✅ All migration files use LF line endings"
- name: 🧰 Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
target: ${{ matrix.rust_targets }}
target: ${{ matrix.artifact-target-name }}
- name: ☕ Setup Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: 🧰 Setup pnpm
- name: 🧰 Install pnpm
uses: pnpm/action-setup@v4
- name: 🧰 Setup Node.js
@@ -127,61 +90,69 @@ jobs:
node-version-file: .nvmrc
cache: pnpm
- name: 🧰 Setup mise
uses: jdx/mise-action@v2
with:
install: true
cache: true
- name: 🧰 Install Linux build dependencies
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -yq \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
xdg-utils \
openjdk-17-jdk
- name: 🦀 Cache Rust build artifacts
uses: Swatinem/rust-cache@v2
with:
workspaces: |
. -> target
cache-on-failure: true
- name: ⚙️ Set application environment
shell: bash
run: |
cp packages/app-lib/.env.prod packages/app-lib/.env
- name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8
- name: 🐧 Install Linux build dependencies
if: matrix.runner == 'ubuntu-latest'
- name: 🧰 Install dependencies
run: pnpm install
- name: ✍️ Set up Windows code signing (jsign)
if: matrix.platform == 'windows-latest' && env.SIGN_WINDOWS_BINARIES == 'true'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -yq \
build-essential \
curl \
file \
libayatana-appindicator3-dev \
libgtk-3-dev \
librsvg2-dev \
libssl-dev \
libwebkit2gtk-4.1-dev \
patchelf \
xdg-utils
choco install jsign --ignore-dependencies
- name: Set application environment
shell: bash
run: cp packages/app-lib/.env.prod packages/app-lib/.env
- name: 📦 Install dependencies
shell: bash
run: pnpm install --frozen-lockfile
- name: 🧹 Clean previous bundles
- name: 🗑 Clean up cached bundles
shell: bash
run: |
rm -rf target/release/bundle
rm -rf target/*/release/bundle || true
- name: 🔨 Build app
shell: bash
run: mise exec -- pnpm --filter @modrinth/app exec tauri build --target ${{ matrix.target }}
# - name: 🔨 Build macOS app
# if: matrix.platform == 'macos-latest'
# run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
# env:
# TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
# TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: 🔨 Build Linux app
if: matrix.platform == 'ubuntu-latest'
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: 🔨 Build Windows app
if: matrix.platform == 'windows-latest'
shell: pwsh
run: |
$env:JAVA_HOME = "$env:JAVA_HOME_17_X64"
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: 📤 Upload app bundles
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: ${{ matrix.artifact_name }}
name: App bundle (${{ matrix.artifact-target-name }})
path: |
target/${{ matrix.target }}/release/bundle/**
if-no-files-found: error
target/release/bundle/**
target/*/release/bundle/**
+1 -20
View File
@@ -22,7 +22,6 @@ node_modules
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/i18n-ally-custom-framework.yml
# IDE - IntelliJ
.idea/*
@@ -65,25 +64,7 @@ generated
app-playground-data/*
.astro
.claude/*
!.claude/skills/
.letta
.claude
# labrinth demo fixtures
apps/labrinth/fixtures/demo
*storybook.log
storybook-static
.wrangler/
# frontend robots.txt
apps/frontend/src/public/robots.txt
# Oh My Code
.omc/
# Local dry-run output for scripts/build-theseus-release-notes.mjs
/test_result.md
packages/tooling-config/script-utils/import-sort.js
-5
View File
@@ -12,11 +12,6 @@
<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" />
-9
View File
@@ -1,11 +1,2 @@
strict-peer-dependencies=false
auto-install-peers=true
public-hoist-pattern[]=prettier-plugin-*
public-hoist-pattern[]=@prettier/plugin-*
public-hoist-pattern[]=eslint
public-hoist-pattern[]=@eslint/*
public-hoist-pattern[]=eslint-plugin-*
public-hoist-pattern[]=@nuxt/eslint-config
public-hoist-pattern[]=typescript-eslint
public-hoist-pattern[]=vue-eslint-parser
public-hoist-pattern[]=globals
+1 -1
View File
@@ -1 +1 @@
24.15.0
20.19.2
+1 -1
View File
@@ -1,3 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode", "Vue.volar", "rust-lang.rust-analyzer", "lokalise.i18n-ally"]
"recommendations": ["esbenp.prettier-vscode", "Vue.volar", "rust-lang.rust-analyzer"]
}
-10
View File
@@ -1,10 +0,0 @@
languageIds:
- vue
- typescript
- javascript
- typescriptreact
usageMatchRegex:
- id:\s*['"]({key})['"]
monopoly: true
+3 -15
View File
@@ -9,11 +9,11 @@
"files.insertFinalNewline": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
"source.organizeImports": "always"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
@@ -29,17 +29,5 @@
},
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
},
"css.lint.unknownAtRules": "ignore",
"scss.lint.unknownAtRules": "ignore",
"i18n-ally.localesPaths": [
"packages/ui/src/locales",
"apps/frontend/src/locales",
"packages/moderation/src/locales"
],
"i18n-ally.pathMatcher": "{locale}/index.{ext}",
"i18n-ally.keystyle": "flat",
"i18n-ally.sourceLanguage": "en-US",
"i18n-ally.namespace": false,
"i18n-ally.includeSubfolders": true
}
}
-1
View File
@@ -1 +0,0 @@
CLAUDE.md
+34 -81
View File
@@ -1,110 +1,63 @@
# Modrinth Monorepo
# Architecture
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.
Use TAB instead of spaces.
## Architecture
## Frontend
- **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
There are two similar frontends in the Modrinth monorepo, the website (apps/frontend) and the app frontend (apps/app-frontend).
### Apps (`apps/`)
Both use Tailwind v3, and their respective configs can be seen at `tailwind.config.ts` and `tailwind.config.js` respectively.
| 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) |
Both utilize shared and common components from `@modrinth/ui` which can be found at `packages/ui`, and stylings from `@modrinth/assets` which can be found at `packages/assets`.
### Packages (`packages/`)
Both can utilize icons from `@modrinth/assets`, which are automatically generated based on what's available within the `icons` folder of the `packages/assets` directory. You can see the generated icons list in `generated-icons.ts`.
| 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 |
Both have access to our dependency injection framework, examples as seen in `packages/ui/src/providers/`. Ideally any state which is shared between a page and it's subpages should be shared using this dependency injection framework.
## Pre-PR Commands
### Website (apps/frontend)
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.
Before a pull request can be opened for the website, `pnpm web:fix` and `pnpm web:intl:extract` must be run, otherwise CI will fail.
- **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`
To run a development version of the frontend, you must first copy over the relevant `.env` template file (prod, staging or local, usually prod) within the `apps/frontend` folder into `apps/frontend/.env`. Then you can run the frontend by running `pnpm web:dev` in the root folder.
The website and app `prepr` commands
### App Frontend (apps/app-frontend)
## Dev Commands
Before a pull request can be opened for the website, you must CD into the `app-frontend` folder; `pnpm fix` and `pnpm intl:extract` must be run, otherwise CI will fail.
- **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`
To run a development version of the app frontend, you must first copy over the relevant `.env` template file (prod, staging or local, usually prod) within `packages/app-lib` into `packages/app-lib/.env`. Then you must run the app itself by running `pnpm app:dev` in the root folder.
## Project-Specific Instructions
### Localization
Each project may have its own `CLAUDE.md` with detailed instructions:
Refer to `.github/instructions/i18n-convert.instructions.md` if the user asks you to perform any i18n conversion work on a component, set of components, pages or sets of pages.
- [`apps/labrinth/AGENTS.md`](apps/labrinth/AGENTS.md) — Backend API
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
## Labrinth
## Code Guidelines
Labrinth is the backend API service for Modrinth.
### 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!
### Testing
## Bash Guidelines
Before a pull request can be opened, run `cargo clippy -p labrinth --all-targets` and make sure there are ZERO warnings, otherwise CI will fail.
### 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
Use `cargo test -p labrinth --all-targets` to test your changes. All tests must pass, otherwise CI will fail.
### 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`
- When provided problems, do not say "I didn't introduce these problems" (shifting the blame/effort) - just fix them.
To prepare the sqlx cache, cd into `apps/labrinth` and run `cargo sqlx prepare`. Make sure to NEVER run `cargo sqlx prepare --workspace`.
## Edit Tool - Whitespace Handling (CLAUDE ONLY)
Read the root `docker-compose.yml` to see what running services are available while developing. Use `docker exec` to access these services.
The Read tool uses `→` to mark where line numbers end and file content begins.
When the user refers to "performing pre-PR checks", do the following:
**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 `→`
- Run clippy as described above
- DO NOT run tests unless explicitly requested (they take a long time)
- Prepare the sqlx cache
**Example:**
14→ private byte tag;
For Edit, use: ` private byte tag;` (copy everything after →, including the two tabs)
### Clickhouse
**If Edit fails:** Stop and explain the problem. Do not attempt sed/awk/bash workarounds.
Use `docker exec labrinth-clickhouse clickhouse-client` to access the Clickhouse instance. We use the `staging_ariadne` database to store data in testing.
**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.
### Postgres
## Standards
Use `docker exec labrinth-postgres psql -U postgres` to access the PostgreSQL instance.
Standards available at the @standards/ folder.
# Guidelines
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to.
+1 -5
View File
@@ -8,14 +8,10 @@ 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.
> All rights reserved. © 2020-2024 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
If you fork this repository, you must remove all Modrinth branding assets from your fork.
Generated
+1867 -3125
View File
File diff suppressed because it is too large Load Diff
+10 -31
View File
@@ -8,8 +8,6 @@ members = [
"packages/app-lib",
"packages/ariadne",
"packages/daedalus",
"packages/labrinth-derive",
"packages/modrinth-log",
"packages/modrinth-maxmind",
"packages/modrinth-util",
"packages/path-util",
@@ -33,7 +31,6 @@ 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-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
@@ -44,11 +41,6 @@ async-tungstenite = { version = "0.31.0", default-features = false, features = [
] }
async-walkdir = "2.1.0"
async_zip = "0.0.18"
aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
"default-https-client",
"rt-tokio",
"rustls",
] }
base64 = "0.22.1"
bitflags = "2.9.4"
bytemuck = "1.24.0"
@@ -56,7 +48,7 @@ bytes = "1.10.1"
censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.42"
cidre = { version = "0.15.0", default-features = false, features = [
cidre = { version = "0.11.3", default-features = false, features = [
"macos_15_0"
] }
clap = "4.5.48"
@@ -64,14 +56,11 @@ clickhouse = "0.14.0"
color-eyre = "0.6.5"
color-thief = "0.2.2"
const_format = "0.2.34"
core-foundation = "0.10.1"
core-graphics = "0.24.0"
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" }
derive_more = "2.1.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"
@@ -85,7 +74,6 @@ eyre = "0.6.12"
flate2 = "1.1.4"
fs4 = { version = "0.13.1", default-features = false }
futures = "0.3.31"
futures-lite = "2.6.1"
futures-util = "0.3.31"
heck = "0.5.0"
hex = "0.4.3"
@@ -119,25 +107,21 @@ lettre = { version = "0.11.19", default-features = false, features = [
] }
maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.30.0", default-features = false }
modrinth-log = { path = "packages/modrinth-log" }
modrinth-maxmind = { path = "packages/modrinth-maxmind" }
modrinth-util = { path = "packages/modrinth-util" }
muralpay = { path = "packages/muralpay" }
murmur2 = "0.1.0"
native-dialog = "0.9.2"
notify = { version = "8.2.0", default-features = false }
notify-debouncer-mini = { version = "0.7.0", default-features = false }
objc2-app-kit = { version = "0.3.2", 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" }
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"
@@ -165,6 +149,7 @@ sentry = { version = "0.45.0", default-features = false, features = [
"reqwest",
"rustls",
] }
sentry-actix = "0.45.0"
serde = "1.0.228"
serde_bytes = "0.11.19"
serde_cbor = "0.11.2"
@@ -178,21 +163,18 @@ sha2 = "0.10.9"
shlex = "1.3.0"
spdx = "0.12.0"
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 }
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-fs = "2.4.5"
tauri-plugin-http = "2.5.7"
tauri-plugin-http = "2.5.2"
tauri-plugin-opener = "2.5.0"
tauri-plugin-os = "2.3.1"
tauri-plugin-single-instance = "2.3.4"
tauri-plugin-updater = { git = "https://github.com/modrinth/plugins-workspace", rev = "0d30f2aa28ec668ce187d527da1c475da3c01cbc", default-features = false, features = [
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
"rustls-tls",
"zip",
] }
@@ -216,7 +198,7 @@ url = "2.5.7"
urlencoding = "2.1.3"
utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "decimal"] }
utoipa-actix-web = { version = "0.1.2" }
utoipa-scalar = { version = "0.3.0", default-features = false }
utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web", "vendored"] }
uuid = "1.18.1"
validator = "0.20.0"
webp = { version = "0.3.1", default-features = false }
@@ -256,7 +238,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"
@@ -266,7 +248,6 @@ read_zero_byte_vec = "warn"
redundant_clone = "warn"
redundant_feature_names = "warn"
redundant_type_annotations = "warn"
result_large_err = "allow"
todo = "warn"
too_many_arguments = "allow"
uninlined_format_args = "warn"
@@ -282,11 +263,9 @@ 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"
opt-level = 2
strip = false # Keep debug symbols for Sentry
lto = "thin" # Enable LTO but keep compile times reasonable
panic = "unwind" # Don't exit the whole app on panic in production
+7 -115
View File
@@ -1,119 +1,11 @@
# 📘 Navigation
RocketMC
- [🔧 Install Instructions](#install-instructions)
- [✨ Features](#features)
- [🚀 Getting Started](#getting-started)
- [⚠️ Disclaimer](#disclaimer)
- [💰 Donate](#support-our-project-crypto-wallets)
Based on AstralRinth
https://github.com/AstralRinth
## Other languages
> [Русский](readme/ru_ru/README.md)
AstralRinth is based on MultiMC
Copyright © MultiMC Contributors
## Support channel
> [Telegram](https://astralium.su/product/astralrinth/support)
RocketMC modifications © 2025 Pernoise
---
# About Project
## **AstralRinth • Empowering Your Minecraft Experience**
**AstralRinth** — a powerful fork of Modrinth, reimagined to enhance your Minecraft journey. Whether you're a GUI enthusiast or a developer building with Modrinths API, **Theseus Core** is your launchpad into a new era of Minecraft gameplay.
## **About the Software**
**AstralRinth** is a dedicated branch of the Modrinth (a.k.a Theseus) project, focused on **offline authentication**, offering you more flexibility and control. Play Minecraft without the need for constant online verification — a user-first approach to modern modded gaming.
---
# Install Instructions
To install the launcher:
1. Visit the [releases page](https://astralium.su/product/astralrinth/source) to download the correct version for your system.
2. Run the downloaded file or extract and launch it, depending on the format.
### Downloadable File Extensions
| Extension | OS | Notes |
| --------- | ------- | --------------------------------------------------------------------- |
| `.msi` | Windows | Supported on all recent Windows versions (10/11) |
| `.dmg` | macOS | Works on Ventura, Sonoma, Sequoia, Tahoe _(may also support older versions)_ |
| `.deb` | Linux | Basic support; compatibility may vary by distribution |
### Installation Warnings
Avoid using builds with these prefixes — they may be unstable or experimental:
- `dev`
- `nightly`
- `dirty`
- `dirty-dev`
- `dirty-nightly`
- `dirty_dev`
- `dirty_nightly`
---
# Features
> _The launcher provides an opportunity to use the well-known Modrinth, but with an improved user experience._
## Included exclusive features
- No ads in the entire launcher.
- Custom `.svg` vector icons for a distinct UI.
- Improved compatibility with both licensed and offline accounts.
- Use **official microsoft accounts** or **offline accounts**.
- Supports license-free access for testing or personal use.
- No dependence on official authentication services.
- Discord Rich Presence integration:
- Dynamic status messages.
- In-game timer and AFK counter.
- Strict disabling of statistics and other Modrinth metrics.
- Optimized archive/package size.
- Integrated update fetcher for seamless version management.
- Built-in update alerts for new versions posted on Git Astralium.
- Automatic download and installation capabilities.
- Database migration fixes, when error occurred (Interactive Mode) (Modrinth issue)
- Ely.by full integration
- The official account skin system is managed by ely.by
- Offline accounts must install AuthLib through the instance settings
---
# Getting Started
To begin using AstralRinth:
1. **Download Latest Release**
- Go to the [releases page](https://astralium.su/product/astralrinth)
- [How to choose a file](#downloadable-file-extensions)
- [How to choose a release](#installation-warnings)
2. **Log in or create new offline account**
- Use your official Microsoft account (MSA), or test using a non-licensed account (Offline).
3. **Launch Minecraft**
- Start Minecraft from the launcher.
- The launcher will auto-detect the recommended JVM version.
- You can also configure Java manually in the settings.
---
# Disclaimer
- **AstralRinth** is intended **solely for educational and experimental use**.
- We **do not condone piracy** — users are encouraged to purchase a legitimate Minecraft license.
- Respect all relevant licensing agreements and support Minecraft developers.
---
# Support Our Project (Crypto Wallets)
If you'd like to support development, you can donate via the following crypto wallets:
- Toncoin (TON): UQA5pGOJhIz9UAVEOh5t2ur1QVbNr_FC1eq9bOb3GwTgaiqk
- USDT (TON): UQA5pGOJhIz9UAVEOh5t2ur1QVbNr_FC1eq9bOb3GwTgaiqk
Licensed under the GNU General Public License v3.0
-4
View File
@@ -17,7 +17,3 @@ extend-exclude = [
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 -15
View File
@@ -10,7 +10,6 @@
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/app-frontend",
"test": "vue-tsc --noEmit"
},
"dependencies": {
@@ -20,32 +19,28 @@
"@modrinth/utils": "workspace:*",
"@sentry/vue": "^8.27.0",
"@sfirew/minecraft-motd-parser": "^1.1.6",
"@tanstack/vue-query": "5.90.7",
"@tanstack/vue-query": "^5.90.7",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-fs": "^2.4.5",
"@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",
"overlayscrollbars": "^2.15.1",
"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-virtual-scroller": "v2.0.0-beta.8",
"vuedraggable": "^4.1.0"
"vue-multiselect": "3.0.0",
"vue-router": "4.3.0",
"vue-virtual-scroller": "v2.0.0-beta.8"
},
"devDependencies": {
"@eslint/compat": "^1.1.1",
@@ -53,7 +48,7 @@
"@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",
@@ -62,8 +57,7 @@
"sass": "^1.74.1",
"tailwindcss": "^3.4.4",
"typescript": "^5.5.4",
"vite": "^8.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
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

@@ -2,6 +2,9 @@ export { default as AddProjectImage } from './add-project.svg'
export { default as SwapIcon } from './arrow-left-right.svg'
export { default as MenuIcon } from './menu.svg'
export { default as ChatIcon } from './messages-square.svg'
export { default as Pirate } from './pirate.svg'
export { default as Microsoft } from './microsoft.svg'
export { default as PirateShip } from './pirate-ship.svg'
export { default as VersionIcon } from './milestone.svg'
export { default as NewInstanceImage } from './new-instance.svg'
export { default as PackageIcon } from './package.svg'

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<svg height="800px" width="800px" version="1.1" id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 511.672 511.672" xml:space="preserve">
<path style="fill:#ED5564;" d="M227.674,44.901c0,0,0.047-0.031,0.141-0.109c-0.031,0.031-3.342,2.437-9.088,3.514
c-7.745,1.437-16.208-0.109-25.14-4.591c-31.386-15.771-68.175-0.968-69.721-0.344l0.016,0.062c-3.88,1.593-6.621,5.403-6.621,9.869
c0,5.887,4.771,10.649,10.657,10.649c1.694,0,3.295-0.406,4.716-1.109c4.466-1.624,30.816-10.416,51.381-0.078
c10.954,5.497,20.706,7.371,28.919,7.371c17.028,0,27.42-8.073,28.044-8.573L227.674,44.901z"/>
<g>
<path style="fill:#7F4545;" d="M234.514,31.973c-5.887,0-10.657,4.778-10.657,10.665v351.704h21.322V42.638
C245.179,36.751,240.401,31.973,234.514,31.973z"/>
<path style="fill:#7F4545;" d="M511.672,319.655c0-5.887-4.777-10.665-10.648-10.665c-1.031,0-2.016,0.156-2.951,0.422l0,0
l-0.234,0.062l0,0l-108.213,31.073l5.871,20.487l108.463-31.137l0,0C508.408,328.618,511.672,324.512,511.672,319.655z"/>
</g>
<path style="fill:#A85D5D;" d="M10.689,308.99l99.725,33.385c0,0,39.116,41.239,124.1,41.239c85,0,106.611-29.138,106.611-29.138
l85.258-24.484c9.588,160.21-122.656,149.561-122.656,149.561H115.294c-86.171-5.996-73.633-88.568-73.633-88.568l-13.187-9.728
l-4.208-18.021L0,351.635L10.689,308.99z"/>
<path style="fill:#965353;" d="M426.664,335.317c-5.871,132.337-122.938,122.905-122.938,122.905H115.294
c-57.409-3.981-71.001-41.973-73.68-66.879c-0.765,5.84-9.205,82.432,73.68,88.209h188.433
C303.727,479.553,433.004,489.968,426.664,335.317z"/>
<g>
<path style="fill:#434A54;" d="M277.166,415.594c0,5.887,4.763,10.649,10.649,10.649c5.888,0,10.649-4.763,10.649-10.649
s-4.762-10.665-10.649-10.665C281.929,404.929,277.166,409.707,277.166,415.594z"/>
<path style="fill:#434A54;" d="M234.514,415.594c0,5.887,4.778,10.649,10.665,10.649s10.657-4.763,10.657-10.649
s-4.77-10.665-10.657-10.665S234.514,409.707,234.514,415.594z"/>
<path style="fill:#434A54;" d="M191.877,415.594c0,5.887,4.771,10.649,10.657,10.649s10.665-4.763,10.665-10.649
s-4.778-10.665-10.665-10.665S191.877,409.707,191.877,415.594z"/>
<path style="fill:#434A54;" d="M149.24,415.594c0,5.887,4.771,10.649,10.657,10.649s10.657-4.763,10.657-10.649
s-4.771-10.665-10.657-10.665S149.24,409.707,149.24,415.594z"/>
<path style="fill:#434A54;" d="M99.569,330.305C114.942,207.719,74.616,95.869,74.616,95.869h260.169
c80.105,93.783,24.953,234.561,24.953,234.561C262.832,285.849,99.569,330.305,99.569,330.305z"/>
</g>
<g style="opacity:0.1;">
<path style="fill:#FFFFFF;" d="M334.785,95.869h-21.314c69.206,81.026,37.445,197.147,27.529,227.222
c6.434,2.123,12.695,4.56,18.738,7.339C359.738,330.43,414.891,189.652,334.785,95.869z"/>
</g>
<path style="fill:#E6E9ED;" d="M170.555,196.477c0-35.321,28.638-63.959,63.959-63.959c35.329,0,63.951,28.638,63.951,63.959
c0,18.941-8.213,35.961-21.299,47.672v26.952h-85.289v-26.952C178.792,232.438,170.555,215.418,170.555,196.477z"/>
<g>
<path style="fill:#434A54;" d="M250.503,196.477c0,5.887,4.778,10.665,10.665,10.665c5.888,0,10.658-4.778,10.658-10.665
s-4.771-10.665-10.658-10.665C255.282,185.812,250.503,190.59,250.503,196.477z"/>
<path style="fill:#434A54;" d="M197.21,196.477c0,5.887,4.771,10.665,10.657,10.665s10.657-4.778,10.657-10.665
s-4.771-10.665-10.657-10.665S197.21,190.59,197.21,196.477z"/>
</g>
<g>
<path style="fill:#CCD1D9;" d="M277.166,271.085c-0.016-5.871-4.777-10.649-10.673-10.649c-5.887,0-10.657,4.778-10.657,10.665
h21.33V271.085z"/>
<path style="fill:#CCD1D9;" d="M255.836,271.085c0-5.871-4.77-10.649-10.657-10.649s-10.665,4.778-10.665,10.665h21.322V271.085z" />
<path style="fill:#CCD1D9;" d="M234.514,271.085c0-5.871-4.771-10.649-10.657-10.649s-10.657,4.778-10.657,10.665h21.314V271.085z" />
<path style="fill:#CCD1D9;" d="M213.199,271.085c-0.008-5.871-4.778-10.649-10.665-10.649s-10.657,4.778-10.657,10.665h21.322
L213.199,271.085L213.199,271.085z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="10mm" height="10mm" viewBox="0 0 10 10" version="1.1" id="svg26662" inkscape:version="1.2.2 (732a01da63, 2022-12-09)" sodipodi:docname="pir.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview id="namedview26664" pagecolor="#505050" bordercolor="#eeeeee" borderopacity="1" inkscape:showpageshadow="0" inkscape:pageopacity="0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#505050" inkscape:document-units="mm" showgrid="false" inkscape:zoom="10.35098" inkscape:cx="16.375261" inkscape:cy="42.073312" inkscape:window-width="1488" inkscape:window-height="1230" inkscape:window-x="2794" inkscape:window-y="123" inkscape:window-maximized="0" inkscape:current-layer="layer1" />
<defs id="defs26659" />
<g inkscape:label="Слой 1" inkscape:groupmode="layer" id="layer1">
<path id="path26647" style="fill:#e7f9fb;fill-opacity:1;stroke:none;stroke-width:0.734686;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:5.8;stroke-dasharray:none;stroke-opacity:1;paint-order:normal" d="M 0.953646,0 C 0.4245958,0 0,0.42377 0,0.950056 V 9.051658 C 0,9.577943 0.4245958,9.9999995 0.953646,9.9999995 H 9.044545 C 9.573595,9.9999995 10,9.577943 10,9.051658 V 0.950056 C 10,0.42377 9.573595,0 9.044545,0 Z m 4.0319653,0.680202 c 0.7122257,0 1.1971907,0.171336 1.6235677,0.587681 C 7.149602,1.795597 7.303455,2.529484 7.076165,3.503527 6.954393,4.024672 6.743646,4.411034 6.384675,4.767983 6.046182,5.105514 5.7597,5.274565 5.369942,5.364516 4.7470347,5.510152 4.177518,5.313116 3.618915,4.763985 3.260174,4.411034 3.0504322,4.028385 2.9274247,3.503527 2.6985267,2.5272 2.8504547,1.797596 3.392573,1.267883 3.8051351,0.866387 4.2970505,0.680202 4.9856113,0.680202 Z M 4.1253339,2.785916 C 3.7727965,2.786202 3.5578276,2.980097 3.5578276,3.29821 c 0,0.197035 0.074385,0.320683 0.2711164,0.455467 C 4.0618628,3.91359 4.4444125,3.833637 4.5886441,3.596619 4.6788251,3.450984 4.6839941,3.154002 4.6001321,3.01265 4.5082281,2.855593 4.3583101,2.784203 4.1277751,2.784203 Z m 1.7295441,0 c -0.20506,0 -0.404923,0.09423 -0.493868,0.26557 C 5.226026,3.311345 5.34091,3.641452 5.60714,3.779948 5.859588,3.90845 6.185962,3.822778 6.372268,3.574345 6.463308,3.45441 6.469338,3.164568 6.383758,3.033211 6.273759,2.864731 6.062581,2.784774 5.85752,2.784774 Z M 1.8659927,5.307119 c 0.00862,-5.71e-4 0.020104,0 0.03073,0 0.2179844,2.86e-4 0.5876389,0.16848 1.6846262,0.695051 C 4.3488321,6.369399 4.9972712,6.669808 5.018122,6.669808 5.038222,6.669237 5.68049,6.371113 6.440564,6.008738 7.200522,5.64465 7.88377,5.323396 7.958155,5.323396 8.14742,5.283416 8.403142,5.409066 8.509492,5.577544 8.663431,5.82598 8.604552,6.167795 8.374795,6.361404 8.316205,6.409954 6.898963,7.091579 5.226428,7.875439 2.6791977,9.069933 2.1554033,9.312944 1.9972712,9.312944 c -0.2153996,0 -0.2679571,0 -0.3915391,-0.114223 C 1.3150575,8.936006 1.3506989,8.509952 1.6827021,8.264656 1.7519171,8.213256 2.1996325,7.990519 2.6777048,7.764356 3.1557197,7.54162 3.5582872,7.334304 3.5756915,7.334304 c 0.01436,0 -0.3826933,-0.217025 -0.8872167,-0.448042 C 2.1839513,6.654959 1.7142365,6.421657 1.646802,6.369971 1.4819495,6.24718 1.4026824,6.080128 1.4026824,5.863103 c 0,-0.219881 0.09535,-0.394643 0.2731268,-0.491448 0.063758,-0.03141 0.1203366,-0.05711 0.1901261,-0.06282 z M 7.094115,7.62329 7.661622,7.905994 c 0.784456,0.383506 0.930354,0.522573 0.930354,0.881807 0,0.219881 -0.105403,0.397498 -0.280307,0.480311 -0.151641,0.06568 -0.331859,0.06853 -0.513628,0 C 7.59815,9.193862 5.819639,8.388875 5.788334,8.354036 c -0.01436,0 0.273414,-0.182758 0.639363,-0.378651 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -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;
@@ -45,14 +43,6 @@
color-scheme: dark;
--view-width: calc(100% - 5rem);
--expanded-view-width: calc(100% - 13rem);
--medal-promotion-bg: #000;
--medal-promotion-bg-orange: rgba(208, 246, 255, 0.25);
--medal-promotion-text-orange: #42abff;
--medal-promotion-bg-gradient: linear-gradient(
90deg,
rgba(66, 170, 255, 0.15),
rgba(0, 0, 0, 0) 100%
);
}
body {
@@ -85,10 +75,16 @@ body {
}
a {
color: inherit;
color: var(--color-link);
text-decoration: none;
-webkit-font-smoothing: antialiased;
will-change: filter;
&:hover {
text-decoration: none;
}
}
input {
border: none !important;
}
.badge {
@@ -161,75 +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';
input {
border-radius: var(--size-rounded-sm);
box-sizing: border-box;
border: 2px solid transparent;
// safari iOS rounds inputs by default
// set the appearance to none to prevent this
appearance: none !important;
}
pre {
font-weight: var(--font-weight-regular);
}
input,
textarea {
background: var(--color-button-bg);
color: var(--color-text);
padding: 0.5rem 1rem;
font-weight: var(--font-weight-medium);
border: none;
outline: 2px solid transparent;
box-shadow:
var(--shadow-inset-sm),
0 0 0 0 transparent;
transition: box-shadow 0.1s ease-in-out;
min-height: 36px;
&:focus,
&:focus-visible {
box-shadow:
inset 0 0 0 transparent,
0 0 0 0.25rem var(--color-brand-shadow);
color: var(--color-button-text-active);
}
&:disabled,
&[disabled='true'] {
opacity: 0.6;
pointer-events: none;
cursor: not-allowed;
}
&:focus::placeholder {
opacity: 0.8;
}
&::placeholder {
color: var(--color-button-text);
opacity: 0.6;
}
}
@@ -8,28 +8,21 @@ import {
SearchIcon,
StopCircleIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import {
Accordion,
DropdownSelect,
formatLoader,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { Button, DropdownSelect, injectNotificationManager } from '@modrinth/ui'
import { formatCategoryHeader } from '@modrinth/utils'
import { useStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import 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,
@@ -134,33 +127,12 @@ const state = useStorage(
{
group: 'Group',
sortBy: 'Name',
collapsedGroups: [],
},
localStorage,
{ mergeDefaults: true },
)
const search = ref('')
const collapsedSectionKeys = computed(() => new Set(state.value.collapsedGroups ?? []))
const getSectionKey = (sectionName) => `${state.value.group}:${sectionName}`
const isSectionCollapsed = (sectionName) => {
return collapsedSectionKeys.value.has(getSectionKey(sectionName))
}
const setSectionCollapsed = (sectionName, collapsed) => {
const sectionKey = getSectionKey(sectionName)
const collapsedSections = new Set(state.value.collapsedGroups ?? [])
if (collapsed) {
collapsedSections.add(sectionKey)
} else {
collapsedSections.delete(sectionKey)
}
state.value.collapsedGroups = [...collapsedSections]
}
const filteredResults = computed(() => {
const { group = 'Group', sortBy = 'Name' } = state.value
@@ -203,7 +175,7 @@ const filteredResults = computed(() => {
if (group === 'Loader') {
instances.forEach((instance) => {
const loader = formatLoader(formatMessage, instance.loader)
const loader = formatCategoryHeader(instance.loader)
if (!instanceMap.has(loader)) {
instanceMap.set(loader, [])
}
@@ -271,14 +243,13 @@ 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"
@@ -302,21 +273,18 @@ const filteredResults = computed(() => {
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
</div>
<Accordion
<div
v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({
key,
value,
}))"
:key="instanceSection.key"
:divider="instanceSection.key !== 'None'"
:open-by-default="!isSectionCollapsed(instanceSection.key)"
class="row"
@on-open="setSectionCollapsed(instanceSection.key, false)"
@on-close="setSectionCollapsed(instanceSection.key, true)"
>
<template v-if="instanceSection.key !== 'None'" #title>
<span class="text-base">{{ instanceSection.key }}</span>
</template>
<div v-if="instanceSection.key !== 'None'" class="divider">
<p>{{ instanceSection.key }}</p>
<hr aria-hidden="true" />
</div>
<section class="instances">
<Instance
v-for="instance in instanceSection.value"
@@ -326,8 +294,15 @@ const filteredResults = computed(() => {
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
/>
</section>
</Accordion>
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteProfile" />
</div>
<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>
@@ -341,7 +316,73 @@ const filteredResults = computed(() => {
</template>
<style lang="scss" scoped>
.row {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
.divider {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: 1rem;
margin-bottom: 1rem;
p {
margin: 0;
font-size: 1rem;
white-space: nowrap;
color: var(--color-contrast);
}
hr {
background-color: var(--color-gray);
height: 1px;
width: 100%;
border: none;
}
}
}
.header {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
align-items: inherit;
margin: 1rem 1rem 0 !important;
padding: 1rem;
width: calc(100% - 2rem);
.iconified-input {
flex-grow: 1;
input {
min-width: 100%;
}
}
.sort-dropdown {
width: 10rem;
}
.filter-dropdown {
width: 15rem;
}
.group-dropdown {
width: 10rem;
}
.labeled_button {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
white-space: nowrap;
}
}
.instances {
@@ -0,0 +1,142 @@
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useLoading } from '@/store/state.js'
const props = defineProps({
throttle: {
type: Number,
default: 0,
},
duration: {
type: Number,
default: 1000,
},
height: {
type: Number,
default: 2,
},
color: {
type: String,
default: 'var(--loading-bar-gradient)',
},
})
const indicator = useLoadingIndicator({
duration: props.duration,
throttle: props.throttle,
})
onBeforeUnmount(() => indicator.clear)
const loading = useLoading()
watch(loading, (newValue) => {
if (newValue.barEnabled) {
if (newValue.loading) {
indicator.start()
} else {
indicator.finish()
}
}
})
function useLoadingIndicator(opts) {
const progress = ref(0)
const isLoading = ref(false)
const step = computed(() => 10000 / opts.duration)
let _timer = null
let _throttle = null
function start() {
clear()
progress.value = 0
if (opts.throttle) {
_throttle = setTimeout(() => {
isLoading.value = true
_startTimer()
}, opts.throttle)
} else {
isLoading.value = true
_startTimer()
}
}
function finish() {
progress.value = 100
_hide()
}
function clear() {
clearInterval(_timer)
clearTimeout(_throttle)
_timer = null
_throttle = null
}
function _increase(num) {
progress.value = Math.min(100, progress.value + num)
}
function _hide() {
clear()
setTimeout(() => {
isLoading.value = false
setTimeout(() => {
progress.value = 0
}, 400)
}, 500)
}
function _startTimer() {
_timer = setInterval(() => {
_increase(step.value)
}, 100)
}
return { progress, isLoading, start, finish, clear }
}
</script>
<template>
<div
class="loading-indicator-bar"
:style="{
'--_width': `${indicator.progress.value}%`,
'--_height': `${indicator.isLoading.value ? props.height : 0}px`,
'--_opacity': `${indicator.isLoading.value ? 1 : 0}`,
top: `0`,
right: `0`,
left: `${props.offsetWidth}`,
pointerEvents: 'none',
width: `var(--_width)`,
height: `var(--_height)`,
borderRadius: `var(--_height)`,
// opacity: `var(--_opacity)`,
background: `${props.color}`,
backgroundSize: `${(100 / indicator.progress.value) * 100}% auto`,
transition: 'width 0.1s ease-in-out, height 0.1s ease-out',
zIndex: 6,
}"
>
<slot />
</div>
</template>
<style lang="scss" scoped>
.loading-indicator-bar::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
width: var(--_width);
bottom: 0;
background-image: radial-gradient(80% 100% at 20% 0%, var(--color-brand) 0%, transparent 80%);
opacity: calc(var(--_opacity) * 0.1);
z-index: 5;
transition:
width 0.1s ease-in-out,
opacity 0.1s ease-out;
}
</style>
@@ -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()
@@ -149,7 +148,7 @@ const handleOptionsClick = async (args) => {
break
case 'edit':
await router.push({
path: `/instance/${encodeURIComponent(args.item.path)}`,
path: `/instance/${encodeURIComponent(args.item.path)}/`,
})
break
case 'duplicate':
@@ -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"
File diff suppressed because it is too large Load Diff
@@ -1,607 +0,0 @@
<template>
<div class="flex gap-2 items-center">
<ButtonStyled
v-if="hasActiveLoadingBars && !hasVisibleActiveDownloadToasts"
color="brand"
type="transparent"
circular
>
<button v-tooltip="formatMessage(messages.viewActiveDownloads)" @click="openDownloadToast()">
<DownloadIcon />
</button>
</ButtonStyled>
<div v-if="offline" class="flex items-center gap-1">
<UnplugIcon class="text-secondary" />
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
</div>
<ButtonStyled color="brand" type="outlined" hover-color-fill="background">
<button
v-if="showUpdatePill"
type="button"
class="!h-[34px] overflow-hidden text-sm !transition-[width,opacity,transform,background-color,color,filter] !duration-200 ease-out"
:class="[
updatePillWidthClass,
{
'update-pill-ready-hidden': finishedDownloading && !animateReadyPill,
'update-pill-ready-visible': finishedDownloading && animateReadyPill,
},
]"
:disabled="isUpdateDownloading"
:aria-busy="isUpdateDownloading"
@click="handleUpdateClick"
>
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
<DownloadIcon v-else />
<span v-if="isUpdateDownloading">
{{ formatMessage(messages.downloadingUpdate) }}
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
</span>
<span v-else>{{ updateLabel }}</span>
</button>
</ButtonStyled>
<div
class="flex border-solid border-surface-5 text-sm items-center gap-2 py-1.5 px-3 rounded-xl border"
>
<template v-if="selectedProcess">
<OnlineIndicatorIcon />
<div class="text-contrast flex items-center gap-2">
<router-link
v-tooltip="formatMessage(messages.viewInstance)"
:to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`"
class="hover:underline"
>
{{ selectedProcess.profile.name }}
</router-link>
<Dropdown
v-if="currentProcesses.length > 1"
placement="bottom"
:triggers="['click']"
:hide-triggers="['click']"
@show="showProfiles = true"
@hide="showProfiles = false"
>
<ButtonStyled type="transparent" circular size="small">
<button
v-tooltip="
showProfiles
? formatMessage(messages.hideMoreRunningInstances)
: formatMessage(messages.showMoreRunningInstances)
"
>
<DropdownIcon :class="{ 'rotate-180': !!showProfiles }" />
</button>
</ButtonStyled>
<template #popper>
<div class="flex w-[20rem] max-h-[24rem] flex-col gap-2 overflow-auto">
<div
v-for="process in currentProcesses"
:key="process.uuid"
class="flex w-full items-center gap-2 rounded-xl bg-surface-4 p-2 text-sm"
>
<button
v-tooltip.left="
process.uuid === selectedProcess.uuid
? formatMessage(messages.primaryInstance)
: formatMessage(messages.makePrimaryInstance)
"
class="flex flex-grow items-center gap-2"
:class="{
'active:scale-95 transition-transform': process.uuid !== selectedProcess.uuid,
}"
:disabled="process.uuid === selectedProcess.uuid"
@click="selectProcess(process)"
>
<OnlineIndicatorIcon />
<span class="mr-auto text-contrast flex items-center gap-2">
{{ process.profile.name }}
<StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
</span>
</button>
<button
v-tooltip="formatMessage(messages.stopInstance)"
class="active:scale-95 flex"
@click.stop="stop(process)"
>
<StopCircleIcon class="text-red size-5" />
</button>
<button
v-tooltip="formatMessage(messages.viewLogs)"
class="active:scale-95 flex"
@click.stop="goToTerminal(process.profile.path)"
>
<TerminalSquareIcon class="text-secondary size-5" />
</button>
</div>
</div>
</template>
</Dropdown>
</div>
<button
v-tooltip="formatMessage(messages.stopInstance)"
class="active:scale-95 flex"
@click="stop(selectedProcess)"
>
<StopCircleIcon class="text-red size-5" />
</button>
<button
v-tooltip="formatMessage(messages.viewLogs)"
class="active:scale-95 flex"
@click="goToTerminal()"
>
<TerminalSquareIcon class="text-secondary size-5" />
</button>
</template>
<template v-else>
<span class="size-2 rounded-full bg-secondary" />
<span class="text-secondary"> {{ formatMessage(messages.noInstancesRunning) }} </span>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import {
DownloadIcon,
DropdownIcon,
OnlineIndicatorIcon,
RefreshCwIcon,
StarIcon,
StopCircleIcon,
TerminalSquareIcon,
UnplugIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
injectPopupNotificationManager,
type PopupNotification,
type PopupNotificationProgressItem,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { Dropdown } from 'floating-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener, process_listener } from '@/helpers/events'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import { get_many as getInstances } from '@/helpers/profile.js'
import type { LoadingBar } from '@/helpers/state'
import { progress_bars_list } from '@/helpers/state'
import type { GameInstance } from '@/helpers/types'
import {
appUpdateState,
downloadAvailableAppUpdate,
installAvailableAppUpdate,
} from '@/providers/app-update'
const { handleError } = injectNotificationManager()
const popupNotificationManager = injectPopupNotificationManager()
const { formatMessage } = useVIntl()
const router = useRouter()
const showProfiles = ref(false)
interface RunningProcess {
uuid: string
profile_path: string
profile: GameInstance
}
const messages = defineMessages({
offline: {
id: 'app.action-bar.offline',
defaultMessage: 'Offline',
},
viewInstance: {
id: 'app.action-bar.view-instance',
defaultMessage: 'View instance',
},
showMoreRunningInstances: {
id: 'app.action-bar.show-more-running-instances',
defaultMessage: 'Show more running instances',
},
hideMoreRunningInstances: {
id: 'app.action-bar.hide-more-running-instances',
defaultMessage: 'Hide more running instances',
},
primaryInstance: {
id: 'app.action-bar.primary-instance',
defaultMessage: 'Primary instance',
},
makePrimaryInstance: {
id: 'app.action-bar.make-primary-instance',
defaultMessage: 'Make primary instance',
},
stopInstance: {
id: 'app.action-bar.stop-instance',
defaultMessage: 'Stop instance',
},
viewLogs: {
id: 'app.action-bar.view-logs',
defaultMessage: 'View logs',
},
noInstancesRunning: {
id: 'app.action-bar.no-instances-running',
defaultMessage: 'No instances running',
},
downloadingJava: {
id: 'app.action-bar.downloading-java',
defaultMessage: 'Downloading Java {version}',
},
downloads: {
id: 'app.action-bar.downloads',
defaultMessage: 'Downloads',
},
viewActiveDownloads: {
id: 'app.action-bar.view-active-downloads',
defaultMessage: 'View active downloads',
},
update: {
id: 'app.action-bar.update',
defaultMessage: 'Update',
},
downloadingUpdate: {
id: 'app.action-bar.downloading-update',
defaultMessage: 'Downloading update',
},
reloadToUpdate: {
id: 'app.action-bar.reload-to-update',
defaultMessage: 'Reload to update',
},
})
const {
downloading,
downloadPercent,
downloadProgress,
finishedDownloading,
isVisible: isUpdateVisible,
metered,
restarting,
} = appUpdateState
const isUpdateDownloading = computed(
() =>
downloading.value ||
(downloadProgress.value > 0 && downloadProgress.value < 1 && !finishedDownloading.value),
)
const showUpdatePill = computed(
() => isUpdateVisible.value && (finishedDownloading.value || metered.value),
)
const animateReadyPill = ref(false)
const updateLabel = computed(() => {
if (isUpdateDownloading.value) {
return formatMessage(messages.downloadingUpdate)
}
if (finishedDownloading.value) {
return formatMessage(messages.reloadToUpdate)
}
return formatMessage(messages.update)
})
const updatePillWidthClass = computed(() => {
if (isUpdateDownloading.value) {
return 'w-[219px]'
}
if (finishedDownloading.value) {
return 'w-[166px]'
}
return '!w-[96px]'
})
let readyPillAnimationFrame: number | null = null
watch([showUpdatePill, finishedDownloading], async ([show, ready], [wasShown, wasReady]) => {
if (readyPillAnimationFrame !== null) {
cancelAnimationFrame(readyPillAnimationFrame)
readyPillAnimationFrame = null
}
if (!show || !ready) {
animateReadyPill.value = false
return
}
if (wasShown && wasReady) {
return
}
animateReadyPill.value = false
await nextTick()
readyPillAnimationFrame = requestAnimationFrame(() => {
animateReadyPill.value = true
readyPillAnimationFrame = null
})
})
async function handleUpdateClick() {
if (isUpdateDownloading.value) {
return
}
if (finishedDownloading.value) {
await installAvailableAppUpdate()
} else {
await downloadAvailableAppUpdate()
}
}
const currentProcesses = ref<RunningProcess[]>([])
const selectedProcess = ref<RunningProcess | undefined>()
const refresh = async () => {
const processes = ((await getRunningProcesses().catch((error) => {
handleError(error)
return []
})) ?? []) as Array<{ uuid: string; profile_path: string }>
const paths = processes.map((process) => process.profile_path)
const profiles: GameInstance[] = await getInstances(paths).catch((error) => {
handleError(error)
return []
})
currentProcesses.value = processes
.map((process) => {
const profile = profiles.find((item) => process.profile_path === item.path)
if (!profile) {
return null
}
return {
...process,
profile,
}
})
.filter((process): process is RunningProcess => process !== null)
if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) {
selectedProcess.value = currentProcesses.value[0]
}
}
await refresh()
const offline = ref(!navigator.onLine)
function handleOffline() {
offline.value = true
}
function handleOnline() {
offline.value = false
}
onMounted(() => {
window.addEventListener('offline', handleOffline)
window.addEventListener('online', handleOnline)
})
const unlistenProcess = await process_listener(async () => {
await refresh()
})
const stop = async (process: RunningProcess) => {
try {
await killProcess(process.uuid).catch(handleError)
trackEvent('InstanceStop', {
loader: process.profile.loader,
game_version: process.profile.game_version,
source: 'AppBar',
})
} catch (e) {
console.error(e)
}
await refresh()
}
function goToTerminal(path?: string) {
const selectedPath = path ?? selectedProcess.value?.profile.path
if (!selectedPath) {
return
}
router.push(`/instance/${encodeURIComponent(selectedPath)}/logs`)
}
const currentLoadingBars = ref<LoadingBar[]>([])
const currentLoadingBarIconUrls = ref<Record<string, string | null>>({})
const notificationId = ref<string | number | null>(null)
const dismissed = ref(false)
function getLoadingBarKey(loadingBar: LoadingBar): string {
return `${loadingBar.loading_bar_uuid ?? loadingBar.id}`
}
function getLoadingProgress(loadingBar: LoadingBar): number {
if (!loadingBar.total || loadingBar.total <= 0) {
return 0
}
return Math.max(0, Math.min(1, (loadingBar.current ?? 0) / (loadingBar.total ?? 0)))
}
function getLoadingText(loadingBar: LoadingBar): string {
const percent = Math.floor(getLoadingProgress(loadingBar) * 100)
return loadingBar.message ? `${percent}% ${loadingBar.message}` : `${percent}%`
}
function getDisplayIconUrl(icon: string | null | undefined): string | null {
if (!icon) {
return null
}
if (/^(https?:|data:|blob:|asset:|tauri:)/.test(icon)) {
return icon
}
return convertFileSrc(icon)
}
function getNotification(): PopupNotification | null {
if (!notificationId.value) {
return null
}
const notification = popupNotificationManager
.getNotifications()
.find((notification) => notification.id === notificationId.value)
return notification ?? null
}
function removeNotification(): void {
if (!notificationId.value) {
return
}
popupNotificationManager.removeNotification(notificationId.value)
notificationId.value = null
}
function buildDownloadItems(): PopupNotificationProgressItem[] {
return currentLoadingBars.value.map((bar) => ({
id: getLoadingBarKey(bar),
title: bar.title ?? '',
text: getLoadingText(bar),
iconUrl: currentLoadingBarIconUrls.value[getLoadingBarKey(bar)] ?? null,
progress: getLoadingProgress(bar),
waiting: !bar.total || bar.total <= 0,
}))
}
const hasVisibleActiveDownloadToasts = computed(() => !!getNotification())
const hasActiveLoadingBars = computed(() => currentLoadingBars.value.length > 0)
function updateNotification(resummon = false): void {
if (resummon) {
dismissed.value = false
}
if (currentLoadingBars.value.length === 0) {
removeNotification()
dismissed.value = false
return
}
if (notificationId.value && !getNotification()) {
notificationId.value = null
dismissed.value = true
}
if (dismissed.value && !resummon) {
return
}
let notif = getNotification()
const progressItems = buildDownloadItems()
if (notif) {
notif.title = formatMessage(messages.downloads)
notif.text = undefined
notif.progressItems = progressItems
notif.progress = undefined
notif.waiting = undefined
} else {
notif = popupNotificationManager.addPopupNotification({
title: formatMessage(messages.downloads),
type: 'download',
autoCloseMs: null,
progressItems,
})
notificationId.value = notif.id
}
}
function formatLoadingBars(loadingBar: LoadingBar): LoadingBar {
const formatted = { ...loadingBar }
if (formatted.bar_type?.type === 'java_download') {
formatted.title = formatMessage(messages.downloadingJava, {
version: formatted.bar_type.version,
})
}
if (formatted.bar_type?.profile_path) {
formatted.title = formatted.bar_type.profile_path
}
if (formatted.bar_type?.pack_name) {
formatted.title = formatted.bar_type.pack_name
}
return formatted
}
async function refreshLoadingBars() {
const bars: Record<string, LoadingBar> = await progress_bars_list().catch((error) => {
handleError(error)
return {}
})
currentLoadingBars.value = Object.values(bars)
.map(formatLoadingBars)
.filter((bar) => bar?.bar_type?.type !== 'launcher_update')
const profilePaths = Array.from(
new Set(
currentLoadingBars.value
.map((bar) => bar.bar_type?.profile_path)
.filter((path): path is string => !!path),
),
)
const profiles = profilePaths.length
? await getInstances(profilePaths).catch((error) => {
handleError(error)
return []
})
: []
const profileIconUrls = new Map(
profiles.map((profile) => [profile.path, getDisplayIconUrl(profile.icon_path)]),
)
currentLoadingBarIconUrls.value = Object.fromEntries(
currentLoadingBars.value.map((bar) => {
const barIconUrl = getDisplayIconUrl(bar.bar_type?.icon)
const profileIconUrl = bar.bar_type?.profile_path
? profileIconUrls.get(bar.bar_type.profile_path)
: null
return [getLoadingBarKey(bar), barIconUrl ?? profileIconUrl ?? null]
}),
)
currentLoadingBars.value.sort((a, b) => {
const aKey = `${a.loading_bar_uuid ?? a.id ?? ''}`
const bKey = `${b.loading_bar_uuid ?? b.id ?? ''}`
return aKey.localeCompare(bKey)
})
updateNotification()
}
await refreshLoadingBars()
const unlistenLoading = await loading_listener(async () => {
await refreshLoadingBars()
})
function openDownloadToast() {
updateNotification(true)
}
function selectProcess(process: RunningProcess) {
selectedProcess.value = process
}
onBeforeUnmount(() => {
removeNotification()
dismissed.value = false
window.removeEventListener('offline', handleOffline)
window.removeEventListener('online', handleOnline)
unlistenProcess()
unlistenLoading()
if (readyPillAnimationFrame !== null) {
cancelAnimationFrame(readyPillAnimationFrame)
}
})
</script>
<style scoped>
.update-pill-ready-hidden {
opacity: 0;
transform: scale(0.96);
}
.update-pill-ready-visible {
opacity: 1;
transform: scale(1);
}
</style>
@@ -1,158 +1,64 @@
<template>
<div
ref="outerRef"
data-tauri-drag-region
class="min-w-0 overflow-hidden pl-3"
:class="{ 'breadcrumb-fade-mask': isOverflowing }"
: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>
.breadcrumb-fade-mask {
mask-image: linear-gradient(
to right,
transparent,
black 12px,
black calc(100% - 12px),
transparent
);
}
.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>
@@ -0,0 +1,380 @@
<template>
<ModalWrapper ref="modal" :header="'Import from CurseForge Profile Code'">
<div class="modal-body">
<div class="input-row">
<p class="input-label">Profile Code</p>
<div class="iconified-input">
<SearchIcon aria-hidden="true" class="text-lg" />
<input ref="codeInput" v-model="profileCode" autocomplete="off" class="h-12 card-shadow"
spellcheck="false" type="text" placeholder="Enter CurseForge profile code" maxlength="20"
@keyup.enter="importProfile" />
<Button v-if="profileCode" class="r-btn" @click="() => (profileCode = '')">
<XIcon />
</Button>
</div>
</div>
<div v-if="metadata && !importing" class="profile-info">
<h3>Profile Information</h3>
<p><strong>Name:</strong> {{ metadata.name }}</p>
</div>
<div v-if="error" class="error-message">
<p>{{ error }}</p>
</div>
<div v-if="importing && importProgress.visible" class="progress-section">
<div class="progress-info">
<span class="progress-text">{{ importProgress.message }}</span>
<span class="progress-percentage">{{ Math.floor(importProgress.percentage) }}%</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" :style="{ width: `${importProgress.percentage}%` }"></div>
</div>
</div>
<div class="button-row">
<Button @click="hide" :disabled="importing">
<XIcon />
Cancel
</Button>
<Button v-if="!metadata" @click="fetchMetadata" :disabled="!profileCode.trim() || fetching"
color="secondary">
<SearchIcon v-if="!fetching" />
{{ fetching ? 'Checking...' : 'Check Profile' }}
</Button>
<Button v-if="metadata" @click="importProfile" :disabled="importing" color="primary">
<DownloadIcon v-if="!importing" />
{{ importing ? 'Importing...' : 'Import Profile' }}
</Button>
</div>
</div>
</ModalWrapper>
</template>
<script setup>
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { Button } from '@modrinth/ui'
import {
XIcon,
SearchIcon,
DownloadIcon
} from '@modrinth/assets'
import {
fetch_curseforge_profile_metadata,
import_curseforge_profile
} from '@/helpers/import.js'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener } from '@/helpers/events.js'
const props = defineProps({
closeParent: {
type: Function,
default: null
}
})
const router = useRouter()
const modal = ref(null)
const codeInput = ref(null)
const profileCode = ref('')
const metadata = ref(null)
const fetching = ref(false)
const importing = ref(false)
const error = ref('')
const importProgress = ref({
visible: false,
percentage: 0,
message: 'Starting import...',
totalMods: 0,
downloadedMods: 0
})
let unlistenLoading = null
let activeLoadingBarId = null
let progressFallbackTimer = null
defineExpose({
show: () => {
profileCode.value = ''
metadata.value = null
fetching.value = false
importing.value = false
error.value = ''
importProgress.value = {
visible: false,
percentage: 0,
message: 'Starting import...',
totalMods: 0,
downloadedMods: 0
}
modal.value?.show()
nextTick(() => {
setTimeout(() => {
codeInput.value?.focus()
}, 100)
})
trackEvent('CurseForgeProfileImportStart', { source: 'ImportModal' })
},
})
const hide = () => {
modal.value?.hide()
}
const fetchMetadata = async () => {
if (!profileCode.value.trim()) return
fetching.value = true
error.value = ''
try {
const result = await fetch_curseforge_profile_metadata(profileCode.value.trim())
metadata.value = result
trackEvent('CurseForgeProfileMetadataFetched', {
profileCode: profileCode.value.trim()
})
} catch (err) {
console.error('Failed to fetch CurseForge profile metadata:', err)
error.value = 'Failed to fetch profile information. Please check the code and try again.'
handleError(err)
} finally {
fetching.value = false
}
}
const importProfile = async () => {
if (!profileCode.value.trim()) return
importing.value = true
error.value = ''
activeLoadingBarId = null // Reset for new import session
importProgress.value = {
visible: true,
percentage: 0,
message: 'Starting import...',
totalMods: 0,
downloadedMods: 0
}
// Fallback progress timer in case loading events don't work
progressFallbackTimer = setInterval(() => {
if (importing.value && importProgress.value.percentage < 90) {
// Slowly increment progress as a fallback
importProgress.value.percentage = Math.min(90, importProgress.value.percentage + 1)
}
}, 1000)
try {
const { result, profilePath } = await import_curseforge_profile(profileCode.value.trim())
trackEvent('CurseForgeProfileImported', {
profileCode: profileCode.value.trim()
})
hide()
// Close the parent modal if provided
if (props.closeParent) {
props.closeParent()
}
// Navigate to the imported profile
await router.push(`/instance/${encodeURIComponent(profilePath)}`)
} catch (err) {
console.error('Failed to import CurseForge profile:', err)
error.value = 'Failed to import profile. Please try again.'
handleError(err)
} finally {
importing.value = false
importProgress.value.visible = false
if (progressFallbackTimer) {
clearInterval(progressFallbackTimer)
progressFallbackTimer = null
}
activeLoadingBarId = null
}
}
onMounted(async () => {
// Listen for loading events to update progress
unlistenLoading = await loading_listener((event) => {
console.log('Loading event received:', event) // Debug log
// Handle all loading events that could be related to CurseForge profile import
const isCurseForgeEvent = event.event?.type === 'curseforge_profile_download'
const hasProfileName = event.event?.profile_name && importing.value
if ((isCurseForgeEvent || hasProfileName) && importing.value) {
// Store the loading bar ID for this import session
if (!activeLoadingBarId) {
activeLoadingBarId = event.loader_uuid
}
// Only process events for our current import session
if (event.loader_uuid === activeLoadingBarId) {
if (event.fraction !== null && event.fraction !== undefined) {
const baseProgress = (event.fraction || 0) * 100
// Calculate custom progress based on the message
let finalProgress = baseProgress
const message = event.message || 'Importing profile...'
// Custom progress calculation for different stages
if (message.includes('Fetching') || message.includes('metadata')) {
finalProgress = Math.min(10, baseProgress)
} else if (message.includes('Downloading profile ZIP') || message.includes('profile ZIP')) {
finalProgress = Math.min(15, 10 + (baseProgress - 10) * 0.5)
} else if (message.includes('Extracting') || message.includes('ZIP')) {
finalProgress = Math.min(20, 15 + (baseProgress - 15) * 0.5)
} else if (message.includes('Configuring') || message.includes('profile')) {
finalProgress = Math.min(30, 20 + (baseProgress - 20) * 0.5)
} else if (message.includes('Copying') || message.includes('files')) {
finalProgress = Math.min(40, 30 + (baseProgress - 30) * 0.5)
} else if (message.includes('Downloaded mod') && message.includes(' of ')) {
// Parse "Downloaded mod X of Y" message
const match = message.match(/Downloaded mod (\d+) of (\d+)/)
if (match) {
const current = parseInt(match[1])
const total = parseInt(match[2])
// Mods take 40% of progress (from 40% to 80%)
const modProgress = (current / total) * 40
finalProgress = 40 + modProgress
} else {
finalProgress = Math.min(80, 40 + (baseProgress - 40) * 0.5)
}
} else if (message.includes('Downloading mod') || message.includes('mods')) {
// General mod downloading stage (40% to 80%)
finalProgress = Math.min(80, 40 + (baseProgress - 40) * 0.4)
} else if (message.includes('Installing Minecraft') || message.includes('Minecraft')) {
finalProgress = Math.min(95, 80 + (baseProgress - 80) * 0.75)
} else if (message.includes('Finalizing') || message.includes('completed')) {
finalProgress = Math.min(100, 95 + (baseProgress - 95))
} else {
// Default: use the base progress but ensure minimum progression
finalProgress = Math.max(importProgress.value.percentage, baseProgress)
}
importProgress.value.percentage = Math.min(100, Math.max(0, finalProgress))
importProgress.value.message = message
} else {
// Loading complete
importProgress.value.percentage = 100
importProgress.value.message = 'Import completed!'
activeLoadingBarId = null
}
}
}
})
})
onUnmounted(() => {
if (unlistenLoading) {
unlistenLoading()
}
if (progressFallbackTimer) {
clearInterval(progressFallbackTimer)
}
})
</script>
<style lang="scss" scoped>
.modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.input-row {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-label {
font-weight: 600;
color: var(--color-contrast);
margin: 0;
}
.profile-info {
background: var(--color-bg);
border: 1px solid var(--color-button);
border-radius: var(--radius-md);
padding: 1rem;
h3 {
margin: 0 0 0.5rem 0;
color: var(--color-contrast);
}
p {
margin: 0.25rem 0;
color: var(--color-base);
}
}
.error-message {
background: var(--color-red);
border: 1px solid var(--color-red);
border-radius: var(--radius-md);
padding: 0.75rem;
p {
margin: 0;
color: var(--color-contrast);
font-weight: 500;
}
}
.progress-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
.progress-text {
color: var(--color-base);
font-size: 0.875rem;
}
.progress-percentage {
color: var(--color-contrast);
font-size: 0.875rem;
font-weight: 600;
}
}
.progress-bar-container {
width: 100%;
height: 4px;
background: var(--color-button);
border-radius: 2px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--color-brand);
border-radius: 2px;
transition: width 0.3s ease;
min-width: 0;
}
.button-row {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: auto;
}
</style>
@@ -6,7 +6,6 @@ import {
HammerIcon,
LogInIcon,
UpdatedIcon,
WrenchIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, Collapsible, injectNotificationManager } from '@modrinth/ui'
@@ -20,21 +19,26 @@ import { install } from '@/helpers/profile.js'
import { cancel_directory_change } from '@/helpers/settings.ts'
import { handleSevereError } from '@/store/error.js'
// [AR] Imports
import { applyMigrationFix } from '@/helpers/utils.js'
import { restartApp } from '@/helpers/utils.js'
const { handleError } = injectNotificationManager()
const errorModal = ref()
const error = ref()
const closable = ref(true)
const errorCollapsed = ref(false)
const migrationFixSuccess = ref(null) // null | true | false
const migrationFixCallbackModel = ref()
const title = ref('An error occurred')
const errorType = ref('unknown')
const supportLink = ref('https://astralium.su/product/astralrinth/support')
const supportLink = ref('https://support.modrinth.com')
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:')) {
@@ -61,7 +65,7 @@ defineExpose({
errorType.value = 'directory_move'
supportLink.value = 'https://support.modrinth.com'
if (errorVal.message.includes('directory is not writable')) {
if (errorVal.message.includes('directory is not writeable')) {
metadata.value.readOnly = true
}
@@ -74,7 +78,7 @@ defineExpose({
supportLink.value = 'https://support.modrinth.com'
metadata.value.profilePath = context.profilePath
} else if (source === 'state_init') {
title.value = 'Error initializing Modrinth App'
title.value = 'Error initializing AstralRinth App'
errorType.value = 'state_init'
supportLink.value = 'https://support.modrinth.com'
} else {
@@ -152,17 +156,37 @@ async function copyToClipboard(text) {
copied.value = false
}, 3000)
}
async function onApplyMigrationFix(eol) {
console.log(`[AR] • Attempting to apply migration ${eol.toUpperCase()} fix`)
try {
const result = await applyMigrationFix(eol)
migrationFixSuccess.value = result === true
console.log(`[AR] • Successfully applied migration ${eol.toUpperCase()} fix`, result)
} catch (err) {
console.error(`[AR] • Failed to apply migration fix:`, err)
migrationFixSuccess.value = false
} finally {
migrationFixCallbackModel.value?.show?.()
if (migrationFixSuccess.value === true) {
setTimeout(async () => {
await restartApp()
}, 3000)
}
}
}
</script>
<template>
<ModalWrapper ref="errorModal" :header="title" :closable="closable">
<div class="modal-body max-w-[550px]">
<div class="modal-body">
<div class="markdown-body">
<template v-if="errorType === 'minecraft_auth'">
<template v-if="metadata.network">
<h3>Network issues</h3>
<p>
It looks like there were issues with the Modrinth App connecting to Microsoft's
It looks like there were issues with the AstralRinth App connecting to Microsoft's
servers. This is often the result of a poor connection, so we recommend trying again
to see if it works. If issues continue to persist, follow the steps in
<a
@@ -176,7 +200,7 @@ async function copyToClipboard(text) {
<template v-else-if="metadata.hostsFile">
<h3>Network issues</h3>
<p>
The Modrinth App tried to connect to Microsoft / Xbox / Minecraft services, but the
The AstralRinth App tried to connect to Microsoft / Xbox / Minecraft services, but the
remote server rejected the connection. This may indicate that these services are
blocked by the hosts file. Please visit
<a
@@ -215,7 +239,7 @@ async function copyToClipboard(text) {
<template v-if="metadata.readOnly">
<h3>Change directory permissions</h3>
<p>
It looks like the Modrinth App is unable to write to the directory you selected.
It looks like the AstralRinth App is unable to write to the directory you selected.
Please adjust the permissions of the directory and try again or cancel the directory
change.
</p>
@@ -229,7 +253,7 @@ async function copyToClipboard(text) {
</template>
<template v-else>
<p>
The Modrinth App is unable to migrate to the new directory you selected. Please
The AstralRinth App is unable to migrate to the new directory you selected. Please
contact support for help or cancel the directory change.
</p>
</template>
@@ -259,7 +283,7 @@ async function copyToClipboard(text) {
</div>
<template v-else-if="errorType === 'state_init'">
<p>
Modrinth App failed to load correctly. This may be because of a corrupted file, or
AstralRinth App failed to load correctly. This may be because of a corrupted file, or
because the app is missing crucial files.
</p>
<p>You may be able to fix it through one of the following ways:</p>
@@ -269,7 +293,7 @@ async function copyToClipboard(text) {
</ul>
</template>
<template v-else-if="errorType === 'no_loader_version'">
<p>The Modrinth App failed to find the loader version for this instance.</p>
<p>The AstralRinth App failed to find the loader version for this instance.</p>
<p>To resolve this, you need to repair the instance. Click the button below to do so.</p>
<div class="cta-button">
<button class="btn btn-primary" :disabled="loadingRepair" @click="repairInstance">
@@ -281,7 +305,7 @@ async function copyToClipboard(text) {
{{ debugInfo }}
</template>
<template v-if="hasDebugInfo">
<div class="w-full h-[1px] bg-surface-5 mb-3"></div>
<hr />
<p>
If nothing is working and you need help, visit
<a :href="supportLink">our support page</a>
@@ -297,51 +321,100 @@ async function copyToClipboard(text) {
<ButtonStyled v-if="closable">
<button @click="errorModal.hide()"><XIcon /> Close</button>
</ButtonStyled>
<ButtonStyled v-if="hasDebugInfo">
<button :disabled="copied" @click="copyToClipboard(debugInfo)">
<template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template>
<template v-else> <CopyIcon /> Copy debug info </template>
</button>
</ButtonStyled>
</div>
<template v-if="hasDebugInfo">
<div class="flex flex-col gap-2">
<div class="w-full h-[1px] bg-surface-5"></div>
<div class="overflow-clip">
<button
class="flex items-center justify-between w-full bg-transparent border-0 py-4 cursor-pointer"
@click="errorCollapsed = !errorCollapsed"
>
<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': !errorCollapsed }"
/>
</button>
<Collapsible :collapsed="errorCollapsed">
<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 class="bg-button-bg rounded-xl mt-2 overflow-clip">
<button
class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer"
@click="errorCollapsed = !errorCollapsed"
>
<span class="text-contrast font-extrabold m-0">Debug information:</span>
<DropdownIcon
class="h-5 w-5 text-secondary transition-transform"
:class="{ 'rotate-180': !errorCollapsed }"
/>
</button>
<Collapsible :collapsed="errorCollapsed">
<pre class="m-0 px-4 py-3 bg-bg rounded-none whitespace-pre-wrap break-words overflow-x-auto max-w-full"
>{{ debugInfo }}</pre>
</Collapsible>
</div>
<template v-if="errorType === 'state_init'">
<h2> Migration Issue Important Notice</h2>
<p>We've detected a problem with our database migration system caused by inconsistent line endings between operating systems (Windows vs. macOS/Linux). This may affect app stability.</p>
<p><strong>Whats happening?</strong> Our migration validator misreads modified migrations when line endings differ (CRLF ↔ LF), which can make the app unusable.</p>
<p><strong>Why?</strong> Gits automatic line-ending conversions and OS differences can cause these inconsistencies during builds.</p>
<p><strong>Whats next?</strong> Were working on a permanent fix. In the meantime, you can apply one of the quick fixes below depending on your system.</p>
<h3>Do I need to apply a fix now?</h3>
<div>
<p class="notice__text">
If you're encountering an error while applying migrations, such as "Error while applying migrations: migration XXXXXXXXXX was previously applied but has been modified", or a similar issue with migration, the following actions might help:
</p>
<p>If none of the above steps help, you can try saving a copy of the file <code>app.db</code> to a safe location, such as <code>%appdata%\Roaming\AstralRinthApp</code>
on Windows or <code>~/Library/Application Support/AstralRinthApp</code> on macOS, then deleting the original file and letting the app re-create the database file.
Note that this may cause data loss inside the app, so make sure to back up your launcher data before applying this fixes.
</p>
</div>
<div class="flex justify-between">
<ol class="flex flex-col gap-3">
<li>
<ButtonStyled class="neon-button neon">
<button
title="Convert all line endings in migration files to LF (Unix-style: \\n)"
@click="onApplyMigrationFix('lf')"
>
Apply fix for Unix like systems (Debian, Ubuntu, macOS and others)
</button>
</ButtonStyled>
</li>
<li>
<ButtonStyled class="neon-button neon">
<button
title="Convert all line endings in migration files to CRLF (Windows-style: \\r\\n)"
@click="onApplyMigrationFix('crlf')"
>
Apply fix for Windows
</button>
</ButtonStyled>
</li>
</ol>
</div>
</template>
</template>
</div>
</ModalWrapper>
<ModalWrapper
ref="migrationFixCallbackModel"
header="💡 Migration fix report"
:closable="closable">
<div class="modal-body">
<h2 class="text-lg font-bold text-contrast space-y-2">
<template v-if="migrationFixSuccess === true">
<p class="flex items-center gap-2 neon-text">
The migration fix has been applied successfully. Please restart the launcher and try to log in to the game :)
</p>
<p class="mt-2 text-sm neon-text">
If the problem persists, please try the other fix.
</p>
</template>
<template v-else-if="migrationFixSuccess === false">
<p class="flex items-center gap-2 neon-text">
The migration fix failed or had no effect.
</p>
<p class="mt-2 text-sm neon-text">
If the problem persists, please try the other fix.
</p>
</template>
</h2>
</div>
</ModalWrapper>
</template>
<style>
@@ -356,6 +429,16 @@ async function copyToClipboard(text) {
</style>
<style scoped lang="scss">
@import '../../../../../packages/assets/styles/neon-button.scss';
@import '../../../../../packages/assets/styles/neon-text.scss';
code {
background: linear-gradient(90deg, #005eff, #00cfff);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.cta-button {
display: flex;
align-items: center;
@@ -1,16 +1,7 @@
<script setup>
import { WrenchIcon, XIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog'
import { PlusIcon, XIcon } from '@modrinth/assets'
import { Button, Checkbox, injectNotificationManager } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue'
import { PackageIcon, VersionIcon } from '@/assets/icons'
@@ -18,37 +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: 'Configure which files are included in this export',
},
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
includeFile: {
id: 'app.export-modal.include-file-accessibility-label',
defaultMessage: 'Include "{file}"?',
},
})
const props = defineProps({
instance: {
@@ -70,6 +30,7 @@ const exportDescription = ref('')
const versionInput = ref('1.0.0')
const files = ref([])
const folders = ref([])
const showingFiles = ref(false)
const initFiles = async () => {
const newFolders = new Map()
@@ -89,9 +50,9 @@ const initFiles = async () => {
disabled:
folder === 'profile.json' ||
folder.startsWith('modrinth_logs') ||
folder.startsWith('.fabric') ||
folder.startsWith('__MACOSX'),
folder.startsWith('.fabric'),
}))
.filter((pathData) => !pathData.path.includes('.DS_Store'))
.forEach((pathData) => {
const parent = pathData.path.split(sep).slice(0, -1).join(sep)
if (parent !== '') {
@@ -125,20 +86,15 @@ const exportPack = async () => {
}
})
})
const outputPath = await save({
defaultPath: `${nameInput.value} ${versionInput.value}.mrpack`,
filters: [
{
name: 'Modrinth Modpack',
extensions: ['mrpack'],
},
],
const outputPath = await open({
directory: true,
multiple: false,
})
if (outputPath) {
export_profile_mrpack(
props.instance.path,
outputPath,
outputPath + `/${nameInput.value} ${versionInput.value}.mrpack`,
filesToExport,
versionInput.value,
exportDescription.value,
@@ -150,105 +106,200 @@ const exportPack = async () => {
</script>
<template>
<ModalWrapper ref="exportModal" :header="formatMessage(messages.header)">
<div class="flex flex-col gap-4 w-[40rem]">
<div class="grid grid-cols-2 gap-4">
<div class="labeled_input">
<p>{{ formatMessage(messages.modpackNameLabel) }}</p>
<StyledInput
v-model="nameInput"
:icon="PackageIcon"
type="text"
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
clearable
/>
</div>
<div class="labeled_input">
<p>{{ formatMessage(messages.versionNumberLabel) }}</p>
<StyledInput
v-model="versionInput"
:icon="VersionIcon"
type="text"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
clearable
/>
<ModalWrapper ref="exportModal" header="Export modpack">
<div class="modal-body">
<div class="labeled_input">
<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="flex flex-col gap-2">
<p class="m-0">{{ formatMessage(commonMessages.descriptionLabel) }}</p>
<StyledInput
v-model="exportDescription"
multiline
:placeholder="formatMessage(messages.descriptionPlaceholder)"
/>
<div class="labeled_input">
<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>
<Accordion
class="w-full bg-surface-4 border border-solid border-surface-5 rounded-2xl overflow-clip"
button-class="p-4 w-full border-b border-solid border-b-surface-5 bg-surface-2 -mb-px hover:brightness-[--hover-brightness] group"
>
<template #title>
<span class="flex items-center gap-3 text-contrast group-active:scale-[0.98]">
<WrenchIcon aria-hidden="true" class="size-5 text-secondary" />
Configure which files are included in this export
</span>
</template>
<div class="flex flex-col [&>*:nth-child(even)]:bg-surface-3">
<div v-for="[path, children] in folders" :key="path.name" class="flex flex-col">
<Accordion
class="flex flex-col"
button-class="flex gap-3 pr-4 hover:bg-surface-5 group"
<div class="adjacent-input">
<div class="labeled_input">
<p>Description</p>
<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">
Select files and folders to include in pack
<Button
class="sleek-primary collapsed-button"
icon-only
@click="() => (showingFiles = !showingFiles)"
>
<template #title>
<PlusIcon v-if="!showingFiles" />
<XIcon v-else />
</Button>
</div>
</div>
<div v-if="showingFiles" class="table-content">
<div v-for="[path, children] in folders" :key="path.name" class="table-row">
<div class="table-cell file-entry">
<div class="file-primary">
<Checkbox
:model-value="children.every((child) => child.selected)"
:indeterminate="
!children.every((child) => child.selected) &&
children.some((child) => child.selected)
"
:description="formatMessage(messages.includeFile, { file: path.name })"
class="pl-4 py-2"
:label="path.name"
class="select-checkbox"
:disabled="children.every((x) => x.disabled)"
@update:model-value="
(newValue) => children.forEach((child) => (child.selected = newValue))
"
@click.stop
/>
<span class="ml-2 group-active:scale-95">{{ path.name }}/</span>
</template>
<div v-for="child in children" :key="child.path">
<Checkbox
v-model="child.selected"
:label="child.name"
class="w-full px-8 py-2 hover:bg-surface-4 text-primary"
:disabled="child.disabled"
v-model="path.showingMore"
class="select-checkbox dropdown"
collapsing-toggle-style
/>
</div>
</Accordion>
<div v-if="path.showingMore" class="file-secondary">
<div v-for="child in children" :key="child.path" class="file-secondary-row">
<Checkbox
v-model="child.selected"
:label="child.name"
class="select-checkbox"
:disabled="child.disabled"
/>
</div>
</div>
</div>
</div>
<div v-for="file in files" :key="file.path" class="table-row">
<div class="table-cell file-entry">
<div class="file-primary">
<Checkbox
v-model="file.selected"
:label="file.name"
:disabled="file.disabled"
class="select-checkbox"
/>
</div>
</div>
</div>
<Checkbox
v-for="file in files"
:key="file.path"
v-model="file.selected"
:label="file.name"
:disabled="file.disabled"
class="w-full px-4 py-2 hover:bg-surface-4 text-primary"
/>
</div>
</Accordion>
<div class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="exportModal.hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="exportPack">
<PackageIcon />
{{ formatMessage(messages.exportButton) }}
</button>
</ButtonStyled>
</div>
<div class="button-row push-right">
<Button @click="exportModal.hide">
<XIcon />
Cancel
</Button>
<Button color="primary" @click="exportPack">
<PackageIcon />
Export
</Button>
</div>
</div>
</ModalWrapper>
</template>
<style scoped lang="scss">
.modal-body {
display: flex;
flex-direction: column;
gap: var(--gap-md);
}
.labeled_input {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
p {
margin: 0;
}
}
.select-checkbox {
gap: var(--gap-sm);
button.checkbox {
border: none;
}
&.dropdown {
margin-left: auto;
}
}
.table-content {
max-height: 18rem;
overflow-y: auto;
}
.table {
border: 1px solid var(--color-bg);
}
.file-entry {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
}
.file-primary {
display: flex;
align-items: center;
gap: var(--gap-sm);
}
.file-secondary {
margin-left: var(--gap-xl);
display: flex;
flex-direction: column;
gap: var(--gap-sm);
height: 100%;
vertical-align: center;
}
.file-secondary-row {
display: flex;
align-items: center;
gap: var(--gap-sm);
}
.button-row {
display: flex;
gap: var(--gap-sm);
align-items: center;
}
.row-wise {
display: flex;
flex-direction: row;
justify-content: space-between;
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,675 @@
<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="flex gap-4 items-center">
<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="shrink-0" 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
v-if="selectedProfileType.name === 'Curseforge'"
@click="showCurseForgeProfileModal"
:disabled="loading"
>
<CodeIcon />
Import from Profile Code
</Button>
<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>
<CurseForgeProfileImportModal ref="curseforgeProfileModal" :close-parent="hide" />
</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'
import CurseForgeProfileImportModal from '@/components/ui/CurseForgeProfileImportModal.vue'
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
}
}
const showCurseForgeProfileModal = () => {
curseforgeProfileModal.value?.show()
}
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 curseforgeProfileModal = 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;
}
: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,8 +1,8 @@
<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'
import { computed } from 'vue'
type Instance = {
game_version: string
@@ -13,23 +13,18 @@ type Instance = {
name: string
}
const props = withDefaults(
defineProps<{
instance: Instance
backTab?: string
}>(),
{ backTab: undefined },
)
const instanceLink = computed(() => {
const base = `/instance/${encodeURIComponent(props.instance.path)}`
return props.backTab ? `${base}/${props.backTab}` : base
})
defineProps<{
instance: Instance
}>()
</script>
<template>
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
<router-link :to="instanceLink" tabindex="-1" class="flex flex-col gap-4 text-primary">
<router-link
:to="`/instance/${encodeURIComponent(instance.path)}`"
tabindex="-1"
class="flex flex-col gap-4 text-primary"
>
<span class="flex items-center gap-2">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
@@ -42,14 +37,15 @@ const instanceLink = computed(() => {
</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>
</router-link>
<ButtonStyled>
<router-link :to="instanceLink"> <LeftArrowIcon /> Back to instance </router-link>
<router-link :to="`/instance/${encodeURIComponent(instance.path)}`">
<LeftArrowIcon /> Back to instance
</router-link>
</ButtonStyled>
</div>
</template>
@@ -1,44 +1,42 @@
<template>
<ModalWrapper ref="detectJavaModal" header="Select java version" :show-ad-on-close="false">
<div class="flex flex-col gap-4">
<Table :columns="javaInstallColumns" :data="chosenInstallOptions" row-key="path">
<template #cell-version="{ value }">
<span class="font-semibold text-primary">{{ value }}</span>
</template>
<template #cell-path="{ value }">
<span v-tooltip="value" class="block truncate font-mono text-xs">{{ value }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end">
<ButtonStyled v-if="currentSelected.path === row.path">
<button class="!shadow-none" disabled><CheckIcon /> Selected</button>
</ButtonStyled>
<ButtonStyled v-else>
<button class="!shadow-none" @click="setJavaInstall(row)"><PlusIcon /> Select</button>
</ButtonStyled>
<div class="auto-detect-modal">
<div class="table">
<div class="table-row table-head">
<div class="table-cell table-text">Version</div>
<div class="table-cell table-text">Path</div>
<div class="table-cell table-text">Actions</div>
</div>
<div v-for="javaInstall in chosenInstallOptions" :key="javaInstall.path" class="table-row">
<div class="table-cell table-text">
<span>{{ javaInstall.version }}</span>
</div>
</template>
<template #empty-state>
<div class="p-4 text-secondary">No java installations found!</div>
</template>
</Table>
<div class="flex justify-end">
<ButtonStyled type="outlined">
<button
class="!shadow-none !border-surface-4 !border"
@click="$refs.detectJavaModal.hide()"
>
<XIcon />
Cancel
</button>
</ButtonStyled>
<div v-tooltip="javaInstall.path" class="table-cell table-text">
<span>{{ javaInstall.path }}</span>
</div>
<div class="table-cell table-text manage">
<Button v-if="currentSelected.path === javaInstall.path" disabled
><CheckIcon /> Selected</Button
>
<Button v-else @click="setJavaInstall(javaInstall)"><PlusIcon /> Select</Button>
</div>
</div>
<div v-if="chosenInstallOptions.length === 0" class="table-row entire-row">
<div class="table-cell table-text">No java installations found!</div>
</div>
</div>
<div class="input-group push-right">
<Button @click="$refs.detectJavaModal.hide()">
<XIcon />
Cancel
</Button>
</div>
</div>
</ModalWrapper>
</template>
<script setup>
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, Table } from '@modrinth/ui'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
@@ -50,11 +48,6 @@ const { handleError } = injectNotificationManager()
const chosenInstallOptions = ref([])
const detectJavaModal = ref(null)
const currentSelected = ref({})
const javaInstallColumns = [
{ key: 'version', label: 'Version', width: '9rem' },
{ key: 'path', label: 'Path' },
{ key: 'actions', label: 'Actions', align: 'right', width: '10rem' },
]
defineExpose({
show: async (version, currentSelectedJava) => {
@@ -80,3 +73,25 @@ function setJavaInstall(javaInstall) {
})
}
</script>
<style lang="scss" scoped>
.auto-detect-modal {
.table {
.table-row {
grid-template-columns: 1fr 4fr min-content;
}
span {
display: inherit;
align-items: center;
justify-content: center;
}
padding: 0.5rem;
}
}
.manage {
display: flex;
gap: 0.5rem;
}
</style>
@@ -1,104 +1,76 @@
<template>
<JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', val)" />
<div :id="props.id" class="toggle-setting" :class="{ compact }">
<div class="input-with-status">
<StyledInput
autocomplete="off"
:disabled="props.disabled"
:model-value="props.modelValue ? props.modelValue.path : ''"
:placeholder="placeholder ?? '/path/to/java'"
wrapper-class="installation-input"
@update:model-value="
(val) => {
emit('update:modelValue', {
...props.modelValue,
path: val,
})
}
"
/>
<ButtonStyled
:color="
!hoveringTest && !testingJava
? testingJavaSuccess === true
? 'green'
: 'red'
: 'standard'
"
color-fill="text"
>
<button
class="!shadow-none"
:disabled="testingJava || props.disabled"
@click="runTest(props.modelValue?.path)"
@mouseenter="!props.disabled && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="testingJavaSuccess === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon v-else-if="testingJavaSuccess !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="!props.disabled" class="h-4 w-4" />
</button>
</ButtonStyled>
</div>
<div class="toggle-setting" :class="{ compact }">
<input
autocomplete="off"
:disabled="props.disabled"
:value="props.modelValue ? props.modelValue.path : ''"
type="text"
class="installation-input"
:placeholder="placeholder ?? '/path/to/java'"
@input="
(val) => {
emit('update:modelValue', {
...props.modelValue,
path: val.target.value,
})
}
"
/>
<span class="installation-buttons">
<ButtonStyled v-if="props.version">
<button
v-tooltip="testingJavaSuccess === true ? 'Already installed' : undefined"
class="!shadow-none"
:disabled="props.disabled || installingJava || testingJavaSuccess === true"
@click="reinstallJava"
>
<DownloadIcon />
{{ installingJava ? 'Installing...' : 'Install recommended' }}
</button>
</ButtonStyled>
<ButtonStyled>
<button class="!shadow-none" :disabled="props.disabled" @click="autoDetect">
<SearchIcon />
Detect
</button>
</ButtonStyled>
<ButtonStyled>
<button class="!shadow-none" :disabled="props.disabled" @click="handleJavaFileInput()">
<FolderSearchIcon />
Browse
</button>
</ButtonStyled>
<Button
v-if="props.version"
:disabled="props.disabled || installingJava"
@click="reinstallJava"
>
<DownloadIcon />
{{ installingJava ? 'Installing...' : 'Install recommended' }}
</Button>
<Button :disabled="props.disabled" @click="autoDetect">
<SearchIcon />
Detect
</Button>
<Button :disabled="props.disabled" @click="handleJavaFileInput()">
<FolderSearchIcon />
Browse
</Button>
<Button v-if="testingJava" disabled> Testing... </Button>
<Button v-else-if="testingJavaSuccess === true">
<CheckIcon class="test-success" />
Success
</Button>
<Button v-else-if="testingJavaSuccess === false">
<XIcon class="test-fail" />
Failed
</Button>
<Button v-else :disabled="props.disabled" @click="testJava">
<PlayIcon />
Test
</Button>
</span>
</div>
</template>
<script setup>
import {
CheckCircleIcon,
CheckIcon,
DownloadIcon,
FolderSearchIcon,
RefreshCwIcon,
PlayIcon,
SearchIcon,
SpinnerIcon,
XCircleIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, StyledInput } from '@modrinth/ui'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
import { ref } from 'vue'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import useJavaTest from '@/composables/useJavaTest'
import { trackEvent } from '@/helpers/analytics'
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager()
const props = defineProps({
id: {
type: String,
required: false,
default: null,
},
version: {
type: Number,
required: false,
@@ -129,36 +101,29 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const {
testingJava,
javaTestResult: testingJavaSuccess,
testJavaInstallationDebounced,
testJavaInstallation,
} = useJavaTest()
const testingJava = ref(false)
const testingJavaSuccess = ref(null)
const installingJava = ref(false)
const hoveringTest = ref(false)
let hasInitialized = false
async function runTest(path) {
await testJavaInstallation(path, props.version, true)
async function testJava() {
testingJava.value = true
testingJavaSuccess.value = await test_jre(
props.modelValue ? props.modelValue.path : '',
props.version,
)
testingJava.value = false
trackEvent('JavaTest', {
path: props.modelValue ? props.modelValue.path : '',
success: testingJavaSuccess.value,
})
setTimeout(() => {
testingJavaSuccess.value = null
}, 2000)
}
watch(
() => props.modelValue?.path,
(newPath) => {
if (newPath) {
if (!hasInitialized) {
testJavaInstallation(newPath, props.version, false)
hasInitialized = true
} else {
testJavaInstallationDebounced(newPath, props.version)
}
}
},
{ immediate: true },
)
async function handleJavaFileInput() {
const filePath = await open()
@@ -168,7 +133,6 @@ async function handleJavaFileInput() {
result = {
path: filePath.path ?? filePath,
version: props.version.toString(),
parsed_version: props.version,
architecture: 'x86',
}
}
@@ -202,7 +166,6 @@ async function reinstallJava() {
result = {
path: path,
version: props.version.toString(),
parsed_version: props.version,
architecture: 'x86',
}
}
@@ -214,23 +177,13 @@ async function reinstallJava() {
emit('update:modelValue', result)
installingJava.value = false
runTest(result.path)
}
</script>
<style lang="scss" scoped>
.input-with-status {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
width: 100%;
min-width: 0;
}
.installation-input {
flex: 1 1 0;
min-width: 0;
width: 100% !important;
flex-grow: 1;
}
.toggle-setting {
@@ -252,5 +205,17 @@ async function reinstallJava() {
align-items: center;
gap: 0.5rem;
margin: 0;
.btn {
width: max-content;
}
}
.test-success {
color: var(--color-green);
}
.test-fail {
color: var(--color-red);
}
</style>
@@ -1,6 +1,6 @@
<script setup>
import { CheckIcon } from '@modrinth/assets'
import { Badge, ButtonStyled } from '@modrinth/ui'
import { Badge, Button } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { SwapIcon } from '@/assets/icons/index.js'
@@ -74,18 +74,15 @@ const onHide = () => {
@click="$router.push(`/project/${version.project_id}/version/${version.id}`)"
>
<div class="table-cell table-text">
<ButtonStyled
circular
:color="version.id === installedVersion ? 'standard' : 'brand'"
<Button
:color="version.id === installedVersion ? '' : 'primary'"
icon-only
:disabled="inProgress || installing || version.id === installedVersion"
@click.stop="() => switchVersion(version.id)"
>
<button
:disabled="inProgress || installing || version.id === installedVersion"
@click.stop="() => switchVersion(version.id)"
>
<SwapIcon v-if="version.id !== installedVersion" />
<CheckIcon v-else />
</button>
</ButtonStyled>
<SwapIcon v-if="version.id !== installedVersion" />
<CheckIcon v-else />
</Button>
</div>
<div class="name-cell table-cell table-text">
<div class="version-link">
@@ -3,7 +3,6 @@
v-if="typeof to === 'string'"
:to="to"
v-bind="$attrs"
:active-class="isSubpage ? '' : undefined"
:class="{
'router-link-active': isPrimary && isPrimary(route),
'subpage-active': isSubpage && isSubpage(route),
@@ -0,0 +1,160 @@
<template>
<nav
class="card-shadow experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<RouterLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="tabLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
:class="`button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full ${activeIndex === index && !subpageSelected ? 'text-button-textSelected' : activeIndex === index && subpageSelected ? 'text-contrast' : 'text-primary'}`"
>
<component :is="link.icon" v-if="link.icon" class="size-5" />
<span class="text-nowrap">{{ link.label }}</span>
</RouterLink>
<div
: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: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
></div>
</nav>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { RouterLink, useRoute } from 'vue-router'
const route = useRoute()
interface Tab {
label: string
href: string | RouteLocationRaw
shown?: boolean
icon?: unknown
subpages?: string[]
}
const props = defineProps<{
links: Tab[]
query?: string
}>()
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 filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
)
const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
const sliderTopPx = computed(() => `${sliderTop.value}px`)
const sliderRightPx = computed(() => `${sliderRight.value}px`)
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
function pickLink() {
let index = -1
subpageSelected.value = false
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]
if (route.path === (typeof link.href === 'string' ? link.href : link.href.path)) {
index = i
break
} else if (link.subpages && link.subpages.some((subpage) => route.path.includes(subpage))) {
index = i
subpageSelected.value = true
break
}
}
activeIndex.value = index
if (activeIndex.value !== -1) {
startAnimation()
} else {
oldIndex.value = -1
sliderLeft.value = 0
sliderRight.value = 0
}
}
const tabLinkElements = ref()
function startAnimation() {
const el = tabLinkElements.value[activeIndex.value].$el
if (!el || !el.offsetParent) return
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
}
if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
} else {
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)
}
}
}
onMounted(() => {
window.addEventListener('resize', pickLink)
pickLink()
})
onUnmounted(() => {
window.removeEventListener('resize', pickLink)
})
watch(route, () => {
pickLink()
})
</script>
<style scoped>
.navtabs-transition {
/* Delay on opacity is to hide any jankiness as the page loads */
transition:
all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s,
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
</style>
@@ -37,6 +37,6 @@ defineProps({
.progress-bar__fill {
height: 100%;
transition: width 0.3s ease-out;
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>
@@ -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-divider"></div>
</template>
<style scoped lang="scss"></style>
@@ -0,0 +1,474 @@
<template>
<div class="action-groups">
<ButtonStyled v-if="currentLoadingBars.length > 0" color="brand" type="transparent" circular>
<button ref="infoButton" @click="toggleCard()">
<DownloadIcon />
</button>
</ButtonStyled>
<div v-if="offline" class="status">
<UnplugIcon />
<div class="running-text">
<span> Offline </span>
</div>
</div>
<div v-if="selectedProcess" class="status">
<span class="circle running" />
<div ref="profileButton" class="running-text">
<router-link
class="text-primary"
:to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`"
>
{{ selectedProcess.profile.name }}
</router-link>
<div
v-if="currentProcesses.length > 1"
class="arrow button-base"
:class="{ rotate: showProfiles }"
@click="toggleProfiles()"
>
<DropdownIcon />
</div>
</div>
<Button
v-tooltip="'Stop instance'"
icon-only
class="icon-button stop"
@click="stop(selectedProcess)"
>
<StopCircleIcon />
</Button>
<Button v-tooltip="'View logs'" icon-only class="icon-button" @click="goToTerminal()">
<TerminalSquareIcon />
</Button>
</div>
<div v-else class="status">
<span class="circle stopped" />
<span class="running-text"> No instances running </span>
</div>
</div>
<transition name="download">
<Card v-if="showCard === true && currentLoadingBars.length > 0" ref="card" class="info-card">
<div v-for="loadingBar in currentLoadingBars" :key="loadingBar.id" class="info-text">
<h3 class="info-title">
{{ loadingBar.title }}
</h3>
<ProgressBar :progress="Math.floor((100 * loadingBar.current) / loadingBar.total)" />
<div class="row">
{{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}%
{{ loadingBar.message }}
</div>
</div>
</Card>
</transition>
<transition name="download">
<Card
v-if="showProfiles === true && currentProcesses.length > 0"
ref="profiles"
class="profile-card"
>
<Button
v-for="process in currentProcesses"
:key="process.uuid"
class="profile-button"
@click="selectProcess(process)"
>
<div class="text"><span class="circle running" /> {{ process.profile.name }}</div>
<Button
v-tooltip="'Stop instance'"
icon-only
class="icon-button stop"
@click.stop="stop(process)"
>
<StopCircleIcon />
</Button>
<Button
v-tooltip="'View logs'"
icon-only
class="icon-button"
@click.stop="goToTerminal(process.profile.path)"
>
<TerminalSquareIcon />
</Button>
</Button>
</Card>
</transition>
</template>
<script setup>
import {
DownloadIcon,
DropdownIcon,
StopCircleIcon,
TerminalSquareIcon,
UnplugIcon,
} from '@modrinth/assets'
import { Button, ButtonStyled, Card, injectNotificationManager } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener, process_listener } from '@/helpers/events'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import { get_many } from '@/helpers/profile.js'
import { progress_bars_list } from '@/helpers/state.js'
const { handleError } = injectNotificationManager()
const router = useRouter()
const card = ref(null)
const profiles = ref(null)
const infoButton = ref(null)
const profileButton = ref(null)
const showCard = ref(false)
const showProfiles = ref(false)
const currentProcesses = ref([])
const selectedProcess = ref()
const refresh = async () => {
const processes = await getRunningProcesses().catch(handleError)
const profiles = await get_many(processes.map((x) => x.profile_path)).catch(handleError)
currentProcesses.value = processes.map((x) => ({
profile: profiles.find((prof) => x.profile_path === prof.path),
...x,
}))
if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) {
selectedProcess.value = currentProcesses.value[0]
}
}
await refresh()
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
offline.value = true
})
window.addEventListener('online', () => {
offline.value = false
})
const unlistenProcess = await process_listener(async () => {
await refresh()
})
const stop = async (process) => {
try {
await killProcess(process.uuid).catch(handleError)
trackEvent('InstanceStop', {
loader: process.profile.loader,
game_version: process.profile.game_version,
source: 'AppBar',
})
} catch (e) {
console.error(e)
}
await refresh()
}
const goToTerminal = (path) => {
router.push(`/instance/${encodeURIComponent(path ?? selectedProcess.value.profile.path)}/logs`)
}
const currentLoadingBars = ref([])
const refreshInfo = async () => {
const currentLoadingBarCount = currentLoadingBars.value.length
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
}
if (x.bar_type.profile_path) {
x.title = x.bar_type.profile_path
}
if (x.bar_type.pack_name) {
x.title = x.bar_type.pack_name
}
return x
})
.filter((bar) => bar?.bar_type?.type !== 'launcher_update')
currentLoadingBars.value.sort((a, b) => {
if (a.loading_bar_uuid < b.loading_bar_uuid) {
return -1
}
if (a.loading_bar_uuid > b.loading_bar_uuid) {
return 1
}
return 0
})
if (currentLoadingBars.value.length === 0) {
showCard.value = false
} else if (currentLoadingBarCount < currentLoadingBars.value.length) {
showCard.value = true
}
}
await refreshInfo()
const unlistenLoading = await loading_listener(async () => {
await refreshInfo()
})
const selectProcess = (process) => {
selectedProcess.value = process
showProfiles.value = false
}
const handleClickOutsideCard = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY)
if (
card.value &&
card.value.$el !== event.target &&
!elements.includes(card.value.$el) &&
infoButton.value &&
!infoButton.value.contains(event.target)
) {
showCard.value = false
}
}
const handleClickOutsideProfile = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY)
if (
profiles.value &&
profiles.value.$el !== event.target &&
!elements.includes(profiles.value.$el) &&
!profileButton.value.contains(event.target)
) {
showProfiles.value = false
}
}
const toggleCard = async () => {
showCard.value = !showCard.value
showProfiles.value = false
await refreshInfo()
}
const toggleProfiles = async () => {
if (currentProcesses.value.length === 1) return
showProfiles.value = !showProfiles.value
showCard.value = false
}
onMounted(() => {
window.addEventListener('click', handleClickOutsideCard)
window.addEventListener('click', handleClickOutsideProfile)
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutsideCard)
window.removeEventListener('click', handleClickOutsideProfile)
unlistenProcess()
unlistenLoading()
})
</script>
<style scoped lang="scss">
.action-groups {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-md);
}
.arrow {
transition: transform 0.2s ease-in-out;
display: flex;
align-items: center;
&.rotate {
transform: rotate(180deg);
}
}
.status {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-divider);
padding: var(--gap-sm) var(--gap-lg);
}
.running-text {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
white-space: nowrap;
overflow: hidden;
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10 and IE 11 */
user-select: none;
&.clickable:hover {
cursor: pointer;
}
}
.circle {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
display: inline-block;
margin-right: 0.25rem;
&.running {
background-color: var(--color-brand);
}
&.stopped {
background-color: var(--color-base);
}
}
.icon-button {
background-color: rgba(0, 0, 0, 0);
box-shadow: none;
width: 1.25rem !important;
height: 1.25rem !important;
svg {
min-width: 1.25rem;
}
&.stop {
color: var(--color-red);
}
}
.info-card {
position: absolute;
top: 3.5rem;
right: 0.5rem;
z-index: 9;
width: 20rem;
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-raised);
display: flex;
flex-direction: column;
gap: 1rem;
overflow: auto;
transition: all 0.2s ease-in-out;
border: 1px solid var(--color-divider);
&.hidden {
transform: translateY(-100%);
}
}
.loading-option {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
margin: 0;
padding: 0;
:hover {
background-color: var(--color-raised-bg-hover);
}
}
.loading-text {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
.row {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
}
.loading-icon {
width: 2.25rem;
height: 2.25rem;
display: block;
:deep(svg) {
left: 1rem;
width: 2.25rem;
height: 2.25rem;
}
}
.download-enter-active,
.download-leave-active {
transition: opacity 0.3s ease;
}
.download-enter-from,
.download-leave-to {
opacity: 0;
}
.progress-bar {
width: 100%;
}
.info-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
margin: 0;
padding: 0;
}
.info-title {
margin: 0;
}
.profile-button {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
width: 100%;
background-color: var(--color-raised-bg);
box-shadow: none;
.text {
margin-right: auto;
}
}
.profile-card {
position: absolute;
top: 3.5rem;
right: 0.5rem;
z-index: 9;
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-raised);
display: flex;
flex-direction: column;
overflow: auto;
transition: all 0.2s ease-in-out;
border: 1px solid var(--color-divider);
padding: var(--gap-md);
&.hidden {
transform: translateY(-100%);
}
}
.link {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
margin: 0;
color: var(--color-text);
text-decoration: none;
}
</style>
@@ -0,0 +1,184 @@
<template>
<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({
path: `/project/${project.project_id ?? project.id}`,
query: { i: props.instance ? props.instance.path : undefined },
})
}
"
>
<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"
>
<template v-if="project.client_side === 'optional' && project.server_side === 'optional'">
Client or server
</template>
<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, 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 { install as installVersion } from '@/store/install.js'
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
const router = useRouter()
const props = defineProps({
backgroundImage: {
type: String,
default: null,
},
project: {
type: Object,
required: true,
},
categories: {
type: Array,
required: true,
},
instance: {
type: Object,
default: null,
},
featured: {
type: Boolean,
default: false,
},
installed: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['open', 'install'])
const installing = ref(false)
async function install() {
installing.value = true
await installVersion(
props.project.project_id ?? props.project.id,
null,
props.instance ? props.instance.path : null,
'SearchCard',
() => {
installing.value = false
emit('install', props.project.project_id ?? props.project.id)
},
(profile) => {
router.push(`/instance/${profile}`)
},
).catch(handleError)
}
const modpack = computed(() => props.project.project_type === 'modpack')
</script>
File diff suppressed because one or more lines are too long
@@ -1,32 +1,38 @@
<script setup>
import { ButtonStyled, injectNotificationManager, ProjectCard } from '@modrinth/ui'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_project_v3, get_version } from '@/helpers/cache.js'
import { injectContentInstall } from '@/providers/content-install'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get_project, get_version } from '@/helpers/cache.js'
import { get_categories } from '@/helpers/tags.js'
import { install as installVersion } from '@/store/install.js'
const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const confirmModal = ref(null)
const project = ref(null)
const version = ref(null)
const categories = ref(null)
const installing = ref(false)
defineExpose({
async show(event) {
if (event.event === 'InstallVersion') {
version.value = await get_version(event.id, 'must_revalidate').catch(handleError)
project.value = await get_project_v3(version.value.project_id, 'must_revalidate').catch(
project.value = await get_project(version.value.project_id, 'must_revalidate').catch(
handleError,
)
} else {
project.value = await get_project_v3(event.id, 'must_revalidate').catch(handleError)
project.value = await get_project(event.id, 'must_revalidate').catch(handleError)
version.value = await get_version(
project.value.versions[project.value.versions.length - 1],
'must_revalidate',
).catch(handleError)
}
categories.value = (await get_categories().catch(handleError)).filter(
(cat) => project.value.categories.includes(cat.name) && cat.project_type === 'mod',
)
confirmModal.value.show()
},
})
@@ -45,22 +51,13 @@ async function install() {
</script>
<template>
<ModalWrapper ref="confirmModal" :header="`Install ${project?.name}`">
<ModalWrapper ref="confirmModal" :header="`Install ${project?.title}`">
<div class="modal-body">
<ProjectCard
:title="project.name"
:link="() => confirmModal.hide()"
: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"
layout="list"
<SearchCard
:project="project"
class="project-card"
:categories="categories"
@open="confirmModal.hide()"
/>
<div class="button-row">
<div class="markdown-body">
@@ -69,9 +66,7 @@ async function install() {
</p>
</div>
<div class="button-group">
<ButtonStyled color="brand">
<button @click="install">Install</button>
</ButtonStyled>
<Button :loading="installing" color="primary" @click="install">Install</Button>
</div>
</div>
</div>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { DownloadIcon, ExternalIcon, RefreshCwIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages, ProgressBar } from '@modrinth/ui'
import { formatBytes } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { ref } from 'vue'
import { injectAppUpdateDownloadProgress } from '@/providers/download-progress.ts'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'close' | 'restart' | 'download'): void
}>()
defineProps<{
version: string
size: number | null
metered: boolean
}>()
const downloading = ref(false)
const { progress } = injectAppUpdateDownloadProgress()
function download() {
emit('download')
downloading.value = true
}
const messages = defineMessages({
title: {
id: 'app.update-toast.title',
defaultMessage: 'Update available',
},
body: {
id: 'app.update-toast.body',
defaultMessage:
'Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App.',
},
reload: {
id: 'app.update-toast.reload',
defaultMessage: 'Reload',
},
download: {
id: 'app.update-toast.download',
defaultMessage: 'Download ({size})',
},
downloading: {
id: 'app.update-toast.downloading',
defaultMessage: 'Downloading...',
},
changelog: {
id: 'app.update-toast.changelog',
defaultMessage: 'Changelog',
},
meteredBody: {
id: 'app.update-toast.body.metered',
defaultMessage: `Modrinth App v{version} is available now! Since you're on a metered network, we didn't automatically download it.`,
},
downloadCompleteTitle: {
id: 'app.update-toast.title.download-complete',
defaultMessage: 'Download complete',
},
downloadedBody: {
id: 'app.update-toast.body.download-complete',
defaultMessage: `Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App.`,
},
})
</script>
<template>
<div
class="grid grid-cols-[min-content] fixed card-shadow rounded-2xl top-[--top-bar-height] mt-6 right-6 p-4 z-10 bg-bg-raised border-divider border-solid border-[2px]"
:class="{
'download-complete': progress === 1,
}"
>
<div class="flex min-w-[25rem] gap-4">
<h2 class="whitespace-nowrap text-base text-contrast font-semibold m-0 grow">
{{
formatMessage(metered && progress === 1 ? messages.downloadCompleteTitle : messages.title)
}}
</h2>
<ButtonStyled size="small" circular>
<button v-tooltip="formatMessage(commonMessages.closeButton)" @click="emit('close')">
<XIcon />
</button>
</ButtonStyled>
</div>
<p class="text-sm mt-2 mb-0">
{{
formatMessage(
metered
? progress === 1
? messages.downloadedBody
: messages.meteredBody
: messages.body,
{ version },
)
}}
</p>
<p
v-if="metered && progress < 1"
class="text-sm text-secondary mt-2 mb-0 flex items-center gap-1"
>
<template v-if="progress > 0">
<ProgressBar :progress="progress" class="max-w-[unset]" />
</template>
</p>
<div class="flex gap-2 mt-4">
<ButtonStyled color="brand">
<button v-if="metered && progress < 1" :disabled="downloading" @click="download">
<SpinnerIcon v-if="downloading" class="animate-spin" />
<DownloadIcon v-else />
{{
formatMessage(downloading ? messages.downloading : messages.download, {
size: formatBytes(size ?? 0),
})
}}
</button>
<button v-else @click="emit('restart')">
<RefreshCwIcon /> {{ formatMessage(messages.reload) }}
</button>
</ButtonStyled>
<ButtonStyled>
<a href="https://modrinth.com/news/changelog?filter=app">
{{ formatMessage(messages.changelog) }} <ExternalIcon />
</a>
</ButtonStyled>
</div>
</div>
</template>
@@ -1,93 +0,0 @@
<template>
<section
v-if="showControls"
class="flex items-center gap-2 mr-1.5"
data-tauri-drag-region-exclude
>
<ButtonStyled type="transparent" circular>
<button class="relative expanded-button" @click="() => getCurrentWindow().minimize()">
<MinimizeIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" circular>
<button class="relative expanded-button" @click="() => getCurrentWindow().toggleMaximize()">
<RestoreIcon v-if="isMaximized" />
<MaximizeIcon v-else />
</button>
</ButtonStyled>
<ButtonStyled
type="transparent"
color="red"
color-fill="none"
hover-color-fill="background"
circular
>
<button class="relative expanded-button close-button" @click="handleClose">
<XIcon />
</button>
</ButtonStyled>
</section>
</template>
<script setup>
import { MaximizeIcon, MinimizeIcon, RestoreIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { get as getSettings } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils.js'
import { useTheming } from '@/store/state'
const themeStore = useTheming()
const nativeDecorations = ref(true)
const isMaximized = ref(false)
const os = ref('')
const alwaysShowAppControls = computed(() => themeStore.getFeatureFlag('always_show_app_controls'))
const showControls = computed(
() =>
alwaysShowAppControls.value ||
(!nativeDecorations.value && (os.value === 'Windows' || os.value === 'Linux')),
)
onMounted(async () => {
os.value = await getOS()
const settings = await getSettings()
nativeDecorations.value = settings.native_decorations
if (os.value !== 'MacOS') {
await getCurrentWindow().setDecorations(nativeDecorations.value)
}
isMaximized.value = await getCurrentWindow().isMaximized()
const unlisten = await getCurrentWindow().onResized(async () => {
isMaximized.value = await getCurrentWindow().isMaximized()
})
onUnmounted(() => {
unlisten()
})
})
const handleClose = async () => {
await saveWindowState(StateFlags.ALL)
await getCurrentWindow().close()
}
</script>
<style scoped>
.expanded-button::before {
inset: -9px -6px;
content: '';
position: absolute;
}
.expanded-button.close-button::before {
inset: -9px -9px -9px -6px;
}
</style>
@@ -1,253 +0,0 @@
<script setup lang="ts">
import { Button, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import {
downloadLatestRelease,
isUpdateInstalling,
LAUNCHER_RELEASES_URL,
LAUNCHER_REPOSITORY_URL,
latestLauncherRelease,
} from '@/helpers/astralrinth/update'
type ModalHandle = {
hide: () => void
show: () => void
}
const props = defineProps<{
version: string
}>()
const { formatMessage } = useVIntl()
const updateModalView = ref<ModalHandle | null>(null)
const updateRequestFailView = ref<ModalHandle | null>(null)
const releaseTag = computed(() => latestLauncherRelease.value?.tag_name ?? '')
const releaseTitle = computed(() => latestLauncherRelease.value?.name ?? '')
const messages = defineMessages({
updateHeader: {
id: 'astralrinth.app.launcher-update-modal.update.header',
defaultMessage: 'AstralRinth launcher update',
},
updateTitle: {
id: 'astralrinth.app.launcher-update-modal.update.title',
defaultMessage: 'A new version of the AstralRinth launcher is available.',
},
updateDescription: {
id: 'astralrinth.app.launcher-update-modal.update.description',
defaultMessage:
'You are using an older version. We recommend updating now for the latest fixes and improvements.',
},
updateNoticeTitle: {
id: 'astralrinth.app.launcher-update-modal.update.notice-title',
defaultMessage: '⚠️ Before you continue',
},
updateNoticeLead: {
id: 'astralrinth.app.launcher-update-modal.update.notice-lead',
defaultMessage:
'Save your work, close all running launcher instances, and back up your launcher data before installing the update.',
},
updateNoticeWindows: {
id: 'astralrinth.app.launcher-update-modal.update.notice-windows',
defaultMessage: 'On Windows, important data may be stored in',
},
updateNoticeMacos: {
id: 'astralrinth.app.launcher-update-modal.update.notice-macos',
defaultMessage: 'On macOS, important data may be stored in',
},
updateNoticeOutro: {
id: 'astralrinth.app.launcher-update-modal.update.notice-outro',
defaultMessage: 'To avoid data loss, keep a backup copy in a safe place before continuing.',
},
latestReleaseTag: {
id: 'astralrinth.app.launcher-update-modal.update.latest-release-tag',
defaultMessage: '☁️ Latest release tag:',
},
latestReleaseTitle: {
id: 'astralrinth.app.launcher-update-modal.update.latest-release-title',
defaultMessage: '☁️ Latest release title:',
},
installedVersion: {
id: 'astralrinth.app.launcher-update-modal.update.installed-version',
defaultMessage: '💾 Installed & Running version:',
},
repositoryLink: {
id: 'astralrinth.app.launcher-update-modal.update.repository-link',
defaultMessage: 'Open the project repository',
},
cancelAction: {
id: 'astralrinth.app.launcher-update-modal.update.cancel-action',
defaultMessage: 'Cancel',
},
downloadAction: {
id: 'astralrinth.app.launcher-update-modal.update.download-action',
defaultMessage: 'Download update and close',
},
errorHeader: {
id: 'astralrinth.app.launcher-update-modal.error.header',
defaultMessage: 'Could not download the update',
},
errorTitle: {
id: 'astralrinth.app.launcher-update-modal.error.title',
defaultMessage: 'Download failed',
},
errorDescription: {
id: 'astralrinth.app.launcher-update-modal.error.description',
defaultMessage: 'AstralRinth could not download the update file from the server.',
},
errorHelpText: {
id: 'astralrinth.app.launcher-update-modal.error.help-text',
defaultMessage: 'You can try downloading it manually from',
},
errorHelpLink: {
id: 'astralrinth.app.launcher-update-modal.error.help-link',
defaultMessage: 'AstralRinth repository releases',
},
errorHelpSuffix: {
id: 'astralrinth.app.launcher-update-modal.error.help-suffix',
defaultMessage: 'if a newer release is available there.',
},
localVersion: {
id: 'astralrinth.app.launcher-update-modal.error.local-version',
defaultMessage: 'Local AstralRinth:',
},
closeAction: {
id: 'astralrinth.app.launcher-update-modal.error.close-action',
defaultMessage: 'Close',
},
})
async function show() {
updateModalView.value?.show()
}
async function initDownload() {
updateModalView.value?.hide()
const result = await downloadLatestRelease()
if (!result) {
updateRequestFailView.value?.show()
}
}
defineExpose({
show,
hide: () => updateModalView.value?.hide(),
})
</script>
<template>
<ModalWrapper
ref="updateModalView"
:has-to-type="false"
:header="formatMessage(messages.updateHeader)"
>
<div class="space-y-3 pb-16">
<div class="space-y-1 rounded-2xl border border-solid border-[rgba(255,255,255,0.12)] p-3">
<p class="m-0 text-base">
<strong>{{ formatMessage(messages.updateTitle) }}</strong>
</p>
<p class="m-0 text-secondary">{{ formatMessage(messages.updateDescription) }}</p>
</div>
<div
class="space-y-2 rounded-2xl border border-solid border-[rgba(255,255,255,0.12)] bg-[rgba(255,255,255,0.03)] p-3"
>
<div class="space-y-2">
<p class="m-0">
<strong class="neon-text">{{ formatMessage(messages.updateNoticeTitle) }}</strong>
</p>
<p class="m-0 text-secondary text-sm">{{ formatMessage(messages.updateNoticeLead) }}</p>
<p class="m-0 text-sm">
{{ formatMessage(messages.updateNoticeWindows) }}
<code class="neon-text">%appdata%\Roaming\AstralRinthApp</code>
</p>
<p class="m-0 text-sm">
{{ formatMessage(messages.updateNoticeMacos) }}
<code class="neon-text">~/Library/Application Support/AstralRinthApp</code>
</p>
<p class="m-0 text-sm">{{ formatMessage(messages.updateNoticeOutro) }}</p>
</div>
</div>
<div
class="space-y-2 rounded-2xl border border-solid border-[rgba(255,255,255,0.12)] p-3 text-sm text-secondary"
>
<p class="m-0">
<strong>{{ formatMessage(messages.latestReleaseTag) }}</strong>
<span class="neon-text">{{ releaseTag }}</span>
<br />
<strong>{{ formatMessage(messages.latestReleaseTitle) }}</strong>
<span class="neon-text">{{ releaseTitle }}</span>
<br />
<strong>{{ formatMessage(messages.installedVersion) }}</strong>
<span class="neon-text">v{{ props.version }}</span>
</p>
<a
class="inline-flex neon-text"
:href="LAUNCHER_REPOSITORY_URL"
target="_blank"
rel="noopener noreferrer"
>
{{ formatMessage(messages.repositoryLink) }}
</a>
</div>
<div class="absolute bottom-4 right-4 flex items-center gap-4 neon-button neon">
<Button class="bordered" @click="updateModalView?.hide()">
{{ formatMessage(messages.cancelAction) }}
</Button>
<Button class="bordered" :disabled="isUpdateInstalling" @click="initDownload()">
{{ formatMessage(messages.downloadAction) }}
</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper
ref="updateRequestFailView"
:has-to-type="false"
:header="formatMessage(messages.errorHeader)"
>
<div class="space-y-3 pb-16">
<div class="space-y-2 rounded-2xl border border-solid border-[rgba(255,255,255,0.12)] p-3">
<p><strong>{{ formatMessage(messages.errorTitle) }}</strong></p>
<p class="m-0 text-secondary">{{ formatMessage(messages.errorDescription) }}</p>
<p class="m-0 text-sm">
{{ formatMessage(messages.errorHelpText) }}
<a
class="neon-text"
:href="LAUNCHER_RELEASES_URL"
target="_blank"
rel="noopener noreferrer"
>
{{ formatMessage(messages.errorHelpLink) }}
</a>
{{ formatMessage(messages.errorHelpSuffix) }}
</p>
</div>
<div class="rounded-2xl border border-solid border-[rgba(255,255,255,0.12)] p-3 text-sm text-secondary">
<p class="m-0">
<strong>{{ formatMessage(messages.localVersion) }}</strong>
<span class="neon-text">v{{ props.version }}</span>
</p>
</div>
<div class="absolute bottom-4 right-4 flex items-center gap-4 neon-button neon">
<Button class="bordered" @click="updateRequestFailView?.hide()">
{{ formatMessage(messages.closeAction) }}
</Button>
</div>
</div>
</ModalWrapper>
</template>
<style lang="scss" scoped>
@import '../../../../../../packages/assets/styles/astralrinth/neon-button.scss';
@import '../../../../../../packages/assets/styles/astralrinth/neon-text.scss';
</style>
@@ -1,193 +0,0 @@
<script setup lang="ts">
import { Button, defineMessages, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
type ModalHandle = {
hide: () => void
show: () => void
}
defineProps<{
maxOfflinePlayerNameLength: number
minOfflinePlayerNameLength: number
nameExp: string
}>()
const emit = defineEmits<{
(event: 'retry-elyby'): void
(event: 'retry-offline'): void
}>()
const { formatMessage } = useVIntl()
const authenticationElyByErrorModal = ref<ModalHandle | null>(null)
const inputElyByErrorModal = ref<ModalHandle | null>(null)
const inputOfflineErrorModal = ref<ModalHandle | null>(null)
const unexpectedErrorModal = ref<ModalHandle | null>(null)
const messages = defineMessages({
authenticationElyByHeader: {
id: 'astralrinth.app.minecraft-account.error.authentication-elyby.header',
defaultMessage: 'Error while proceeding authentication event with Ely.by',
},
authenticationElyByDescription: {
id: 'astralrinth.app.minecraft-account.error.authentication-elyby.description',
defaultMessage: 'An error occurred while logging in.',
},
inputElyByHeader: {
id: 'astralrinth.app.minecraft-account.error.input-elyby.header',
defaultMessage: 'Error while proceeding input event with Ely.by',
},
inputElyByDescription: {
id: 'astralrinth.app.minecraft-account.error.input-elyby.description',
defaultMessage:
'An error occurred while adding the Ely.by account. Please follow the instructions below.',
},
inputElyByNameOrEmailHint: {
id: 'astralrinth.app.minecraft-account.error.input-elyby.name-or-email-hint',
defaultMessage: 'Check that you have entered the correct player name or email.',
},
inputElyByPasswordHint: {
id: 'astralrinth.app.minecraft-account.error.input-elyby.password-hint',
defaultMessage: 'Check that you have entered the correct password.',
},
inputOfflineHeader: {
id: 'astralrinth.app.minecraft-account.error.input-offline.header',
defaultMessage: 'Error while proceeding input event with offline account',
},
inputOfflineDescription: {
id: 'astralrinth.app.minecraft-account.error.input-offline.description',
defaultMessage:
'An error occurred while adding the offline account. Please follow the instructions below.',
},
inputOfflineNameHint: {
id: 'astralrinth.app.minecraft-account.error.input-offline.name-hint',
defaultMessage: 'Check that you have entered the correct player name.',
},
inputOfflineLengthHint: {
id: 'astralrinth.app.minecraft-account.error.input-offline.length-hint',
defaultMessage:
'Player name must be at least {min} characters long and no more than {max} characters.',
},
inputOfflineFormatHint: {
id: 'astralrinth.app.minecraft-account.error.input-offline.format-hint',
defaultMessage: 'Make sure your name meets the format requirement `{nameExp}`',
},
unexpectedHeader: {
id: 'astralrinth.app.minecraft-account.error.unexpected.header',
defaultMessage: 'Unexpected error occurred',
},
unexpectedDescription: {
id: 'astralrinth.app.minecraft-account.error.unexpected.description',
defaultMessage: 'An unexpected error has occurred. Please try again later.',
},
retryAction: {
id: 'astralrinth.app.minecraft-account.error.retry-action',
defaultMessage: 'Try again',
},
})
defineExpose({
hideAuthenticationElyByError: () => authenticationElyByErrorModal.value?.hide(),
hideInputElyByError: () => inputElyByErrorModal.value?.hide(),
hideInputOfflineError: () => inputOfflineErrorModal.value?.hide(),
showAuthenticationElyByError: () => authenticationElyByErrorModal.value?.show(),
showInputElyByError: () => inputElyByErrorModal.value?.show(),
showInputOfflineError: () => inputOfflineErrorModal.value?.show(),
showUnexpectedError: () => unexpectedErrorModal.value?.show(),
})
</script>
<template>
<ModalWrapper
ref="authenticationElyByErrorModal"
class="modal"
:header="formatMessage(messages.authenticationElyByHeader)"
>
<div class="flex flex-col gap-4 px-6 py-5">
<label class="text-base font-medium text-red-700">
{{ formatMessage(messages.authenticationElyByDescription) }}
</label>
<div class="mt-6 ml-auto">
<Button color="primary" @click="emit('retry-elyby')">
{{ formatMessage(messages.retryAction) }}
</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper
ref="inputElyByErrorModal"
class="modal"
:header="formatMessage(messages.inputElyByHeader)"
>
<div class="flex flex-col gap-4 px-6 py-5">
<label class="text-base font-medium text-red-700">
{{ formatMessage(messages.inputElyByDescription) }}
</label>
<ul class="list-disc list-inside text-sm space-y-1">
<li>{{ formatMessage(messages.inputElyByNameOrEmailHint) }}</li>
<li>{{ formatMessage(messages.inputElyByPasswordHint) }}</li>
</ul>
<div class="mt-6 ml-auto">
<Button color="primary" @click="emit('retry-elyby')">
{{ formatMessage(messages.retryAction) }}
</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper
ref="inputOfflineErrorModal"
class="modal"
:header="formatMessage(messages.inputOfflineHeader)"
>
<div class="flex flex-col gap-4 px-6 py-5">
<label class="text-base font-medium text-red-700">
{{ formatMessage(messages.inputOfflineDescription) }}
</label>
<ul class="list-disc list-inside text-sm space-y-1">
<li>{{ formatMessage(messages.inputOfflineNameHint) }}</li>
<li>
{{
formatMessage(messages.inputOfflineLengthHint, {
min: minOfflinePlayerNameLength,
max: maxOfflinePlayerNameLength,
})
}}
</li>
<li>
{{ formatMessage(messages.inputOfflineFormatHint, { nameExp }) }}
</li>
</ul>
<div class="mt-6 ml-auto">
<Button color="primary" @click="emit('retry-offline')">
{{ formatMessage(messages.retryAction) }}
</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper
ref="unexpectedErrorModal"
class="modal"
:header="formatMessage(messages.unexpectedHeader)"
>
<div class="modal-body">
<label class="label">{{ formatMessage(messages.unexpectedDescription) }}</label>
</div>
</ModalWrapper>
</template>
<style scoped lang="scss">
.modal {
position: absolute;
}
.modal-body {
display: flex;
flex-direction: row;
gap: var(--gap-lg);
align-items: center;
padding: var(--gap-xl);
}
</style>
@@ -1,179 +0,0 @@
<script setup lang="ts">
import { Button, defineMessages, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
type ModalHandle = {
hide: () => void
show: () => void
}
const props = defineProps<{
elyByLoginDisabled: boolean
elyByLoginValue: string
elyByPassword: string
elyByTwoFactorCode: string
offlineLoginDisabled: boolean
offlinePlayerName: string
}>()
const emit = defineEmits<{
(event: 'submit-elyby'): void
(event: 'submit-offline'): void
(event: 'update:elyByLoginValue', value: string): void
(event: 'update:elyByPassword', value: string): void
(event: 'update:elyByTwoFactorCode', value: string): void
(event: 'update:offlinePlayerName', value: string): void
}>()
const { formatMessage } = useVIntl()
const addOfflineModal = ref<ModalHandle | null>(null)
const addElyByModal = ref<ModalHandle | null>(null)
const requestElyByTwoFactorCodeModal = ref<ModalHandle | null>(null)
const messages = defineMessages({
addElyByHeader: {
id: 'astralrinth.app.minecraft-account.input.elyby.header',
defaultMessage: 'Authenticate with Ely.by',
},
requestTwoFactorHeader: {
id: 'astralrinth.app.minecraft-account.input.elyby.two-factor.header',
defaultMessage: 'Ely.by requested 2FA code for authentication',
},
requestTwoFactorLabel: {
id: 'astralrinth.app.minecraft-account.input.elyby.two-factor.label',
defaultMessage: 'Enter your 2FA code',
},
requestTwoFactorPlaceholder: {
id: 'astralrinth.app.minecraft-account.input.elyby.two-factor.placeholder',
defaultMessage: 'Your 2FA code here...',
},
continueAction: {
id: 'astralrinth.app.minecraft-account.input.elyby.two-factor.continue-action',
defaultMessage: 'Continue',
},
elyByLoginLabel: {
id: 'astralrinth.app.minecraft-account.input.elyby.login.label',
defaultMessage: 'Enter your player name or email (preferred)',
},
elyByLoginPlaceholder: {
id: 'astralrinth.app.minecraft-account.input.elyby.login.placeholder',
defaultMessage: 'Your player name or email here...',
},
elyByPasswordLabel: {
id: 'astralrinth.app.minecraft-account.input.elyby.password.label',
defaultMessage: 'Enter your password',
},
elyByPasswordPlaceholder: {
id: 'astralrinth.app.minecraft-account.input.elyby.password.placeholder',
defaultMessage: 'Your password here...',
},
loginAction: {
id: 'astralrinth.app.minecraft-account.input.login-action',
defaultMessage: 'Login',
},
addOfflineHeader: {
id: 'astralrinth.app.minecraft-account.input.offline.header',
defaultMessage: 'Add new offline account',
},
offlineNameLabel: {
id: 'astralrinth.app.minecraft-account.input.offline.name.label',
defaultMessage: 'Enter your player name',
},
offlineNamePlaceholder: {
id: 'astralrinth.app.minecraft-account.input.offline.name.placeholder',
defaultMessage: 'Your player name here...',
},
})
defineExpose({
hideElyBy: () => addElyByModal.value?.hide(),
hideElyByTwoFactor: () => requestElyByTwoFactorCodeModal.value?.hide(),
hideOffline: () => addOfflineModal.value?.hide(),
showElyBy: () => addElyByModal.value?.show(),
showElyByTwoFactor: () => requestElyByTwoFactorCodeModal.value?.show(),
showOffline: () => addOfflineModal.value?.show(),
})
</script>
<template>
<ModalWrapper ref="addElyByModal" class="modal" :header="formatMessage(messages.addElyByHeader)">
<ModalWrapper
ref="requestElyByTwoFactorCodeModal"
class="modal"
:header="formatMessage(messages.requestTwoFactorHeader)"
>
<div class="flex flex-col gap-4 px-6 py-5">
<label class="label form-label">{{ formatMessage(messages.requestTwoFactorLabel) }}</label>
<input
:value="props.elyByTwoFactorCode"
type="text"
:placeholder="formatMessage(messages.requestTwoFactorPlaceholder)"
class="input soft-input"
@input="
emit('update:elyByTwoFactorCode', ($event.target as HTMLInputElement).value)
"
/>
<div class="mt-6 ml-auto">
<Button color="primary" :disabled="props.elyByLoginDisabled" @click="emit('submit-elyby')">
{{ formatMessage(messages.continueAction) }}
</Button>
</div>
</div>
</ModalWrapper>
<div class="flex flex-col gap-4 px-6 py-5">
<label class="label form-label">{{ formatMessage(messages.elyByLoginLabel) }}</label>
<input
:value="props.elyByLoginValue"
type="text"
:placeholder="formatMessage(messages.elyByLoginPlaceholder)"
class="input soft-input"
@input="emit('update:elyByLoginValue', ($event.target as HTMLInputElement).value)"
/>
<label class="label form-label">{{ formatMessage(messages.elyByPasswordLabel) }}</label>
<input
:value="props.elyByPassword"
type="password"
:placeholder="formatMessage(messages.elyByPasswordPlaceholder)"
class="input soft-input"
@input="emit('update:elyByPassword', ($event.target as HTMLInputElement).value)"
/>
<div class="mt-6 ml-auto">
<Button color="primary" :disabled="props.elyByLoginDisabled" @click="emit('submit-elyby')">
{{ formatMessage(messages.loginAction) }}
</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper
ref="addOfflineModal"
class="modal"
:header="formatMessage(messages.addOfflineHeader)"
>
<div class="flex flex-col gap-4 px-6 py-5">
<label class="label form-label">{{ formatMessage(messages.offlineNameLabel) }}</label>
<input
:value="props.offlinePlayerName"
type="text"
:placeholder="formatMessage(messages.offlineNamePlaceholder)"
class="input soft-input"
@input="emit('update:offlinePlayerName', ($event.target as HTMLInputElement).value)"
/>
<div class="mt-6 ml-auto">
<Button color="primary" :disabled="props.offlineLoginDisabled" @click="emit('submit-offline')">
{{ formatMessage(messages.loginAction) }}
</Button>
</div>
</div>
</ModalWrapper>
</template>
<style scoped lang="scss">
@import '../../../../../../../../packages/assets/styles/astralrinth/soft-inputs.scss';
.modal {
position: absolute;
}
</style>
@@ -3,13 +3,12 @@ import { MailIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/ass
import {
Avatar,
ButtonStyled,
defineMessages,
commonMessages,
injectNotificationManager,
IntlFormatted,
StyledInput,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { computed, onUnmounted, ref, watch } from 'vue'
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
@@ -271,14 +270,15 @@ const messages = defineMessages({
{{ 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"
/>
<div class="iconified-input flex-1">
<UserIcon aria-hidden="true" />
<input
v-model="username"
type="text"
:placeholder="formatMessage(messages.usernamePlaceholder)"
@keyup.enter="addFriendFromModal"
/>
</div>
<ButtonStyled color="brand">
<button :disabled="username.length === 0" @click="addFriendFromModal">
<SendIcon />
@@ -288,7 +288,7 @@ const messages = defineMessages({
</div>
</div>
</ModalWrapper>
<div v-if="userCredentials && !loading" class="flex gap-1 items-center mb-3 -ml-1">
<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
@@ -299,17 +299,25 @@ const messages = defineMessages({
<UserPlusIcon />
</button>
</ButtonStyled>
<StyledInput
v-model="search"
type="text"
:placeholder="formatMessage(messages.searchFriends)"
clearable
variant="outlined"
wrapper-class="flex-1"
@keyup.esc="search = ''"
/>
<div class="iconified-input flex-1">
<input
v-model="search"
type="text"
class="friends-search-bar flex w-full"
:placeholder="formatMessage(messages.searchFriends)"
@keyup.esc="search = ''"
/>
<button
v-if="search"
v-tooltip="formatMessage(commonMessages.clearButton)"
class="r-btn flex items-center justify-center bg-transparent button-animation p-2 cursor-pointer appearance-none border-none"
@click="search = ''"
>
<XIcon />
</button>
</div>
</template>
<h3 v-else class="w-full text-base text-primary font-medium m-0">
<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">
@@ -331,11 +339,11 @@ const messages = defineMessages({
</ButtonStyled>
</div>
<div class="flex flex-col gap-3">
<h3 v-if="loading" class="text-base text-primary font-medium m-0">
<h3 v-if="loading" class="ml-4 mr-1 text-base text-primary font-medium m-0">
{{ formatMessage(messages.friends) }}
</h3>
<template v-if="loading">
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse">
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse ml-4 mr-1">
<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>
@@ -344,7 +352,7 @@ const messages = defineMessages({
</div>
</template>
<template v-else-if="sortedFriends.length === 0">
<div class="text-sm">
<div class="text-sm ml-4 mr-1">
<div v-if="!userCredentials">
<IntlFormatted :message-id="messages.signInToAddFriends">
<template #link="{ children }">
@@ -393,6 +401,7 @@ const messages = defineMessages({
<FriendsSection
v-if="pendingFriends.length > 0"
:is-searching="!!search"
open-by-default
:friends="pendingFriends"
:heading="formatMessage(messages.pending)"
:remove-friend="removeFriend"
@@ -403,3 +412,16 @@ const messages = defineMessages({
</template>
</div>
</template>
<style scoped>
.friends-search-bar {
background: none;
border: 2px solid var(--color-button-bg) !important;
padding: 8px;
border-radius: 12px;
height: 36px;
}
.friends-search-bar::placeholder {
@apply text-sm font-normal;
}
</style>
@@ -1,14 +1,8 @@
<script setup lang="ts">
import { MoreVerticalIcon, TrashIcon, UserIcon, XIcon } from '@modrinth/assets'
import {
Accordion,
Avatar,
ButtonStyled,
defineMessages,
OverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { Accordion, Avatar, ButtonStyled, OverflowMenu } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { useTemplateRef } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
@@ -106,7 +100,7 @@ const messages = defineMessages({
:open-by-default="openByDefault"
:force-open="isSearching"
:button-class="
'flex w-full items-center bg-transparent border-0 p-0' +
'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')
@@ -122,7 +116,7 @@ const messages = defineMessages({
<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 mr-1"
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))
"
@@ -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>
@@ -0,0 +1,168 @@
<template>
<ModalWrapper ref="incompatibleModal" header="Incompatibility warning" :on-hide="onInstall">
<div class="modal-body">
<p>
This {{ versions?.length > 0 ? 'project' : 'version' }} is not compatible with the instance
you're trying to install it on. Are you sure you want to continue? Dependencies will not be
installed.
</p>
<table>
<thead>
<tr class="header">
<th>{{ instance?.name }}</th>
<th>{{ project.title }}</th>
</tr>
</thead>
<tbody>
<tr class="content">
<td class="data">{{ instance?.loader }} {{ instance?.game_version }}</td>
<td>
<multiselect
v-if="versions?.length > 1"
v-model="selectedVersion"
:options="versions"
:searchable="true"
placeholder="Select version"
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>
{{ selectedVersion?.name }} ({{
selectedVersion?.loaders.map((name) => formatCategory(name)).join(', ')
}}
- {{ selectedVersion?.game_versions.join(', ') }})
</span>
</span>
</td>
</tr>
</tbody>
</table>
<div class="button-group">
<Button @click="() => incompatibleModal.hide()"><XIcon />Cancel</Button>
<Button color="primary" :disabled="installing" @click="install()">
<DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}
</Button>
</div>
</div>
</ModalWrapper>
</template>
<script setup>
import { DownloadIcon, XIcon } from '@modrinth/assets'
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 instance = ref(null)
const project = ref(null)
const versions = ref(null)
const selectedVersion = ref(null)
const incompatibleModal = ref(null)
const installing = ref(false)
const onInstall = ref(() => {})
defineExpose({
show: (instanceVal, projectVal, projectVersions, selected, callback) => {
instance.value = instanceVal
versions.value = projectVersions
selectedVersion.value = selected ?? projectVersions[0]
project.value = projectVal
onInstall.value = callback
installing.value = false
incompatibleModal.value.show()
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
},
})
const install = async () => {
installing.value = true
await installMod(instance.value.path, selectedVersion.value.id).catch(handleError)
installing.value = false
onInstall.value(selectedVersion.value.id)
incompatibleModal.value.hide()
trackEvent('ProjectInstall', {
loader: instance.value.loader,
game_version: instance.value.game_version,
id: project.value,
version_id: selectedVersion.value.id,
project_type: project.value.project_type,
title: project.value.title,
source: 'ProjectIncompatibilityWarningModal',
})
}
</script>
<style lang="scss" scoped>
.data {
text-transform: capitalize;
}
table {
width: 100%;
border-radius: var(--radius-lg);
border-collapse: collapse;
box-shadow: 0 0 0 1px var(--color-button-bg);
}
th {
text-align: left;
padding: 1rem;
background-color: var(--color-bg);
overflow: hidden;
border-bottom: 1px solid var(--color-button-bg);
}
th:first-child {
border-top-left-radius: var(--radius-lg);
border-right: 1px solid var(--color-button-bg);
}
th:last-child {
border-top-right-radius: var(--radius-lg);
}
td {
padding: 1rem;
}
td:first-child {
border-right: 1px solid var(--color-button-bg);
}
.button-group {
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.modal-body {
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>
@@ -0,0 +1,398 @@
<script setup>
import {
CheckIcon,
DownloadIcon,
PlusIcon,
RightArrowIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
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'
import { useRouter } from 'vue-router'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import {
add_project_from_version as installMod,
check_installed,
create,
get,
list,
} from '@/helpers/profile'
import {
findPreferredVersion,
installVersionDependencies,
isVersionCompatible,
} from '@/store/install.js'
const { handleError } = injectNotificationManager()
const router = useRouter()
const versions = ref()
const project = ref()
const installModal = ref()
const searchFilter = ref('')
const showCreation = ref(false)
const icon = ref(null)
const name = ref(null)
const display_icon = ref(null)
const loader = ref(null)
const gameVersion = ref(null)
const creatingInstance = ref(false)
const profiles = ref([])
const shownProfiles = computed(() =>
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 onInstall = ref(() => {})
defineExpose({
show: async (projectVal, versionsVal, callback) => {
project.value = projectVal
versions.value = versionsVal
searchFilter.value = ''
showCreation.value = false
name.value = null
icon.value = null
display_icon.value = null
gameVersion.value = null
loader.value = null
onInstall.value = callback
const profilesVal = await list().catch(handleError)
for (const profile of profilesVal) {
profile.installing = false
profile.installedMod = await check_installed(profile.path, project.value.id).catch(
handleError,
)
}
profiles.value = profilesVal
installModal.value.show()
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
},
})
async function install(instance) {
instance.installing = true
const version = findPreferredVersion(versions.value, project.value, instance)
if (!version) {
instance.installing = false
handleError('No compatible version found')
return
}
await installMod(instance.path, version.id).catch(handleError)
await installVersionDependencies(instance, version).catch(handleError)
instance.installedMod = true
instance.installing = false
trackEvent('ProjectInstall', {
loader: instance.loader,
game_version: instance.game_version,
id: project.value.id,
version_id: version.id,
project_type: project.value.project_type,
title: project.value.title,
source: 'ProjectInstallModal',
})
onInstall.value(version.id)
}
const toggleCreation = () => {
showCreation.value = !showCreation.value
name.value = null
icon.value = null
display_icon.value = null
gameVersion.value = null
loader.value = null
if (showCreation.value) {
trackEvent('InstanceCreateStart', { source: 'ProjectInstallModal' })
}
}
const upload_icon = async () => {
const res = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg'],
},
],
})
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 createInstance = async () => {
creatingInstance.value = true
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 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)
await router.push(`/instance/${encodeURIComponent(id)}/`)
const instance = await get(id, true)
await installVersionDependencies(instance, versions.value[0]).catch(handleError)
trackEvent('InstanceCreate', {
profile_name: name.value,
game_version: versions.value[0].game_versions[0],
loader: loader,
loader_version: 'latest',
has_icon: !!icon.value,
source: 'ProjectInstallModal',
})
trackEvent('ProjectInstall', {
loader: loader,
game_version: versions.value[0].game_versions[0],
id: project.value,
version_id: versions.value[0].id,
project_type: project.value.project_type,
title: project.value.title,
source: 'ProjectInstallModal',
})
onInstall.value(versions.value[0].id)
if (installModal.value) installModal.value.hide()
creatingInstance.value = false
}
</script>
<template>
<ModalWrapper ref="installModal" header="Install project to instance" :on-hide="onInstall">
<div class="modal-body">
<input
v-model="searchFilter"
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">
<router-link
class="btn btn-transparent profile-button"
:to="`/instance/${encodeURIComponent(profile.path)}`"
@click="installModal.hide()"
>
<Avatar
:src="profile.icon_path ? convertFileSrc(profile.icon_path) : null"
class="profile-image"
/>
{{ profile.name }}
</router-link>
<div
v-tooltip="
profile.linked_data?.locked && !profile.installedMod
? 'Unpair or unlock an instance to add mods.'
: ''
"
>
<Button
:disabled="profile.installedMod || profile.installing"
@click="install(profile)"
>
<DownloadIcon v-if="!profile.installedMod && !profile.installing" />
<CheckIcon v-else-if="profile.installedMod" />
{{
profile.installing
? 'Installing...'
: profile.installedMod
? 'Installed'
: 'Install'
}}
</Button>
</div>
</div>
</div>
<Card v-if="showCreation" class="creation-card">
<div class="creation-container">
<div class="creation-icon">
<Avatar size="md" class="icon" :src="display_icon" />
<div class="creation-icon__description">
<Button @click="upload_icon()">
<UploadIcon />
<span class="no-wrap"> Select icon </span>
</Button>
<Button :disabled="!display_icon" @click="reset_icon()">
<XIcon />
<span class="no-wrap"> Remove icon </span>
</Button>
</div>
</div>
<div class="creation-settings">
<input
v-model="name"
autocomplete="off"
type="text"
placeholder="Name"
class="creation-input"
/>
<Button :disabled="creatingInstance === true || !name" @click="createInstance()">
<RightArrowIcon />
{{ creatingInstance ? 'Creating...' : 'Create' }}
</Button>
</div>
</div>
</Card>
<div class="input-group push-right">
<Button :color="showCreation ? '' : 'primary'" @click="toggleCreation()">
<PlusIcon />
{{ showCreation ? 'Hide New Instance' : 'Create new instance' }}
</Button>
<Button @click="installModal.hide()">Cancel</Button>
</div>
</div>
</ModalWrapper>
</template>
<style scoped lang="scss">
.creation-card {
display: flex;
flex-direction: column;
gap: 1rem;
margin: 0;
background-color: var(--color-bg);
}
.creation-container {
display: flex;
flex-direction: row;
gap: 1rem;
}
.creation-icon {
display: flex;
flex-direction: row;
gap: 1rem;
align-items: center;
flex-grow: 1;
.creation-icon__description {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
}
.creation-input {
width: 100%;
}
.no-wrap {
white-space: nowrap;
}
.creation-dropdown {
width: min-content !important;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.creation-settings {
width: 100%;
margin-left: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: center;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 350px;
}
.profiles {
max-height: 12rem;
overflow-y: auto;
&.hide-creation {
max-height: 21rem;
}
}
.option {
width: calc(100%);
background: var(--color-raised-bg);
color: var(--color-base);
box-shadow: none;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
img {
margin-right: 0.5rem;
}
.name {
display: flex;
flex-direction: column;
justify-content: center;
}
.profile-button {
align-content: start;
padding: 0.5rem;
text-align: left;
}
}
.profile-image {
--size: 2rem !important;
}
</style>
@@ -1,140 +0,0 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" :on-hide="reset">
<div class="max-w-[31rem] flex flex-col gap-6">
<Admonition
type="warning"
:header="formatMessage(messages.warningTitle)"
:body="formatMessage(messages.warningBody)"
/>
<div v-if="fileName" class="overflow-x-auto whitespace-nowrap text-sm text-secondary">
{{ fileName }}
</div>
<div>
<p class="mt-0 leading-tight">
{{ formatMessage(messages.body) }}
</p>
<p class="text-orange font-semibold mb-0 leading-tight">
{{ formatMessage(messages.malwareStatement) }}
</p>
</div>
<Checkbox v-model="dontShowAgain" :label="formatMessage(messages.dontShowAgain)" />
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="cancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button :disabled="isProceeding" @click="proceed">
<SpinnerIcon v-if="isProceeding" class="animate-spin" />
<CircleArrowRightIcon v-else />
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { CircleArrowRightIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme.ts'
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
const dontShowAgain = ref(false)
const modal = useTemplateRef('modal')
const onProceed = ref<() => Promise<void>>()
const isProceeding = ref(false)
const fileName = ref('')
const messages = defineMessages({
header: {
id: 'unknown-pack-warning-modal.header',
defaultMessage: 'Confirm installation',
},
warningTitle: {
id: 'unknown-pack-warning-modal.warning.title',
defaultMessage: 'Unknown file warning',
},
warningBody: {
id: 'unknown-pack-warning-modal.warning.body',
defaultMessage: `We couldn't find this file on Modrinth. We strongly recommend only installing files from sources you trust.`,
},
body: {
id: 'unknown-pack-warning-modal.body',
defaultMessage: `A file is only reviewed if its uploaded to Modrinth, regardless of its file format (including .mrpack).`,
},
malwareStatement: {
id: 'unknown-pack-warning-modal.malware-statement',
defaultMessage: `Malware is often distributed through modpack files by sharing them on platforms like Discord.`,
},
dontShowAgain: {
id: 'unknown-pack-warning-modal.dont-show-again',
defaultMessage: `Don't show this warning again`,
},
installAnyway: {
id: 'unknown-pack-warning-modal.install-anyway',
defaultMessage: `Install anyway`,
},
})
function show(createInstance: () => Promise<void>, selectedFileName = '') {
onProceed.value = createInstance
fileName.value = selectedFileName
dontShowAgain.value = false
if (themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
// noinspection ES6MissingAwait
createInstance()
return
}
modal.value?.show()
}
function reset() {
onProceed.value = undefined
fileName.value = ''
}
function cancel() {
modal.value?.hide()
}
async function proceed() {
if (!onProceed.value) {
return
}
if (dontShowAgain.value) {
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = true
const settings = await getSettings()
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = true
await setSettings(settings)
}
const createInstance = onProceed.value
modal.value?.hide()
// noinspection ES6MissingAwait
createInstance()
}
defineExpose({ show })
</script>
@@ -4,55 +4,42 @@ import {
Avatar,
ButtonStyled,
Checkbox,
Chips,
defineMessages,
injectNotificationManager,
OverflowMenu,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
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()
const router = useRouter()
const queryClient = useQueryClient()
const deleteConfirmModal = ref()
const { instance } = injectInstanceSettings()
type ReleaseChannel = GameInstance['preferred_update_channel']
const releaseChannelOptions: ReleaseChannel[] = ['release', 'beta', 'alpha']
const props = defineProps<InstanceSettingsTabProps>()
const title = ref(instance.value.name)
const icon: Ref<string | undefined> = ref(instance.value.icon_path)
const groups = ref([...instance.value.groups])
const savingReleaseChannel = ref(false)
const selectedReleaseChannel = ref<ReleaseChannel>(instance.value.preferred_update_channel)
const releaseChannelDisabledItems = computed<ReleaseChannel[]>(() =>
savingReleaseChannel.value ? [...releaseChannelOptions] : [],
)
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.value.install_stage !== 'installed')
const installing = computed(() => props.instance.install_stage !== 'installed')
async function duplicateProfile() {
await duplicate(instance.value.path).catch(handleError)
await duplicate(props.instance.path).catch(handleError)
trackEvent('InstanceDuplicate', {
loader: instance.value.loader,
game_version: instance.value.game_version,
loader: props.instance.loader,
game_version: props.instance.game_version,
})
}
@@ -61,55 +48,9 @@ const availableGroups = computed(() => [
...new Set([...allInstances.value.flatMap((instance) => instance.groups), ...groups.value]),
])
function formatReleaseChannelLabel(channel: ReleaseChannel) {
switch (channel) {
case 'release':
return formatMessage(messages.updateChannelRelease)
case 'beta':
return formatMessage(messages.updateChannelBeta)
case 'alpha':
return formatMessage(messages.updateChannelAlpha)
}
}
function formatReleaseChannelDescription(channel: ReleaseChannel) {
switch (channel) {
case 'release':
return formatMessage(messages.updateChannelReleaseDescription)
case 'beta':
return formatMessage(messages.updateChannelBetaDescription)
case 'alpha':
return formatMessage(messages.updateChannelAlphaDescription)
}
}
watch(
() => [instance.value.path, instance.value.preferred_update_channel] as const,
() => {
if (!savingReleaseChannel.value) {
selectedReleaseChannel.value = instance.value.preferred_update_channel
}
},
)
watch(selectedReleaseChannel, async (channel, previousChannel) => {
const previousReleaseChannel = previousChannel ?? instance.value.preferred_update_channel
if (channel === instance.value.preferred_update_channel) return
savingReleaseChannel.value = true
const profilePath = instance.value.path
await edit(profilePath, { preferred_update_channel: channel })
.then(() => queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', profilePath] }))
.catch((error) => {
selectedReleaseChannel.value = previousReleaseChannel
handleError(error)
})
savingReleaseChannel.value = false
})
async function resetIcon() {
icon.value = undefined
await edit_icon(instance.value.path, null).catch(handleError)
await edit_icon(props.instance.path, null).catch(handleError)
trackEvent('InstanceRemoveIcon')
}
@@ -127,7 +68,7 @@ async function setIcon() {
if (!value) return
icon.value = value
await edit_icon(instance.value.path, icon.value).catch(handleError)
await edit_icon(props.instance.path, icon.value).catch(handleError)
trackEvent('InstanceSetIcon')
}
@@ -157,8 +98,7 @@ const addCategory = () => {
watch(
[title, groups, groups],
async () => {
if (removing.value) return
await edit(instance.value.path, editProfileObject.value).catch(handleError)
await edit(props.instance.path, editProfileObject.value)
},
{ deep: true },
)
@@ -166,15 +106,15 @@ watch(
const removing = ref(false)
async function removeProfile() {
removing.value = true
const path = instance.value.path
await remove(props.instance.path).catch(handleError)
removing.value = false
trackEvent('InstanceRemove', {
loader: instance.value.loader,
game_version: instance.value.game_version,
loader: props.instance.loader,
game_version: props.instance.game_version,
})
await router.push({ path: '/' })
await remove(path).catch(handleError)
}
const messages = defineMessages({
@@ -231,38 +171,6 @@ const messages = defineMessages({
id: 'instance.settings.tabs.general.duplicate-button',
defaultMessage: 'Duplicate',
},
updateChannel: {
id: 'instance.settings.tabs.general.update-channel',
defaultMessage: 'Update channel',
},
updateChannelReleaseDescription: {
id: 'instance.settings.tabs.general.update-channel.release.description',
defaultMessage: 'Only release versions will be shown as available updates.',
},
updateChannelBetaDescription: {
id: 'instance.settings.tabs.general.update-channel.beta.description',
defaultMessage: 'Release and beta versions will be shown as available updates.',
},
updateChannelAlphaDescription: {
id: 'instance.settings.tabs.general.update-channel.alpha.description',
defaultMessage: 'Release, beta, and alpha versions will be shown as available updates.',
},
updateChannelRelease: {
id: 'instance.settings.tabs.general.update-channel.release',
defaultMessage: 'Release',
},
updateChannelBeta: {
id: 'instance.settings.tabs.general.update-channel.beta',
defaultMessage: 'Beta',
},
updateChannelAlpha: {
id: 'instance.settings.tabs.general.update-channel.alpha',
defaultMessage: 'Alpha',
},
selectUpdateChannelAriaLabel: {
id: 'instance.settings.tabs.general.update-channel.select',
defaultMessage: 'Select update channel',
},
deleteInstance: {
id: 'instance.settings.tabs.general.delete',
defaultMessage: 'Delete instance',
@@ -284,155 +192,139 @@ 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-10 relative group w-fit">
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">Icon</span>
<div class="group relative w-fit">
<OverflowMenu
v-tooltip="formatMessage(messages.editIcon)"
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
:options="[
{
id: 'select',
action: () => setIcon(),
},
{
id: 'remove',
color: 'danger',
action: () => resetIcon(),
shown: !!icon,
},
]"
<div class="float-end ml-4 relative group">
<OverflowMenu
v-tooltip="formatMessage(messages.editIcon)"
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
:options="[
{
id: 'select',
action: () => setIcon(),
},
{
id: 'remove',
color: 'danger',
action: () => resetIcon(),
shown: !!icon,
},
]"
>
<Avatar
:src="icon ? convertFileSrc(icon) : icon"
size="108px"
class="!border-4 group-hover:brightness-75"
:tint-by="props.instance.path"
no-shadow
/>
<div class="absolute top-0 right-0 m-2">
<div
class="p-2 m-0 text-primary flex items-center justify-center aspect-square bg-button-bg rounded-full border-button-border border-solid border-[1px] hovering-icon-shadow"
>
<Avatar
:src="icon ? convertFileSrc(icon) : icon"
size="108px"
class="transition-[filter] group-hover:brightness-75"
:tint-by="instance.path"
no-shadow
/>
<div
class="absolute top-0 h-full w-full flex items-center justify-center opacity-0 transition-all group-hover:opacity-100"
>
<EditIcon aria-hidden="true" class="h-10 w-10 text-primary" />
</div>
<template #select>
<UploadIcon />
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
</template>
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
</OverflowMenu>
<EditIcon aria-hidden="true" class="h-4 w-4 text-primary" />
</div>
</div>
</div>
<template #select>
<UploadIcon />
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
</template>
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
</OverflowMenu>
</div>
<label for="instance-name" class="m-0 text-lg font-semibold text-contrast block">
<label for="instance-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block">
{{ 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'">
<div class="flex flex-col gap-2.5 mt-6">
<h2 id="duplicate-instance-label" class="m-0 text-lg font-semibold text-contrast block">
<div>
<h2
id="duplicate-instance-label"
class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block"
>
{{ formatMessage(messages.duplicateInstance) }}
</h2>
<ButtonStyled>
<button
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
aria-labelledby="duplicate-instance-label"
:disabled="installing"
class="w-max !shadow-none"
@click="duplicateProfile"
>
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
</button>
</ButtonStyled>
<p class="m-0">
<p class="m-0 mb-2">
{{ formatMessage(messages.duplicateInstanceDescription) }}
</p>
</div>
</template>
<div class="flex flex-col gap-2.5 mt-6">
<h2 class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.libraryGroups) }}
</h2>
<div class="flex flex-col gap-1">
<Checkbox
v-for="group in availableGroups"
:key="group"
:model-value="groups.includes(group)"
:label="group"
@click="toggleGroup(group)"
/>
<div class="flex gap-2 items-center">
<StyledInput
v-model="newCategoryInput"
:placeholder="formatMessage(messages.libraryGroupsEnterName)"
class="w-full max-w-[300px]"
@submit="() => addCategory"
/>
<ButtonStyled>
<button class="w-fit !shadow-none" @click="() => addCategory()">
<PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }}
</button>
</ButtonStyled>
</div>
</div>
<p class="m-0">
{{ formatMessage(messages.libraryGroupsDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5 mt-6">
<h2 class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.updateChannel) }}
</h2>
<Chips
v-model="selectedReleaseChannel"
:items="releaseChannelOptions"
:format-label="formatReleaseChannelLabel"
:capitalize="false"
:disabled-items="releaseChannelDisabledItems"
:aria-label="formatMessage(messages.selectUpdateChannelAriaLabel)"
/>
<p class="m-0">
{{ formatReleaseChannelDescription(selectedReleaseChannel) }}
</p>
</div>
<div class="flex flex-col gap-2.5 mt-6">
<h2 id="delete-instance-label" class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.deleteInstance) }}
</h2>
<ButtonStyled color="red">
<ButtonStyled>
<button
aria-labelledby="delete-instance-label"
:disabled="removing"
class="w-fit !shadow-none"
@click="deleteConfirmModal.show()"
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
aria-labelledby="duplicate-instance-label"
:disabled="installing"
@click="duplicateProfile"
>
<SpinnerIcon v-if="removing" class="animate-spin" />
<TrashIcon v-else />
{{
removing
? formatMessage(messages.deletingInstanceButton)
: formatMessage(messages.deleteInstanceButton)
}}
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
</button>
</ButtonStyled>
<p class="m-0">
{{ formatMessage(messages.deleteInstanceDescription) }}
</p>
</template>
<h2 class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.libraryGroups) }}
</h2>
<p class="m-0 mb-2">
{{ formatMessage(messages.libraryGroupsDescription) }}
</p>
<div class="flex flex-col gap-1">
<Checkbox
v-for="group in availableGroups"
:key="group"
:model-value="groups.includes(group)"
:label="group"
@click="toggleGroup(group)"
/>
<div class="flex gap-2 items-center">
<input
v-model="newCategoryInput"
type="text"
:placeholder="formatMessage(messages.libraryGroupsEnterName)"
@submit="() => addCategory"
/>
<ButtonStyled>
<button class="w-fit" @click="() => addCategory()">
<PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }}
</button>
</ButtonStyled>
</div>
</div>
<h2 id="delete-instance-label" class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.deleteInstance) }}
</h2>
<p class="m-0 mb-2">
{{ formatMessage(messages.deleteInstanceDescription) }}
</p>
<ButtonStyled color="red">
<button
aria-labelledby="delete-instance-label"
:disabled="removing"
@click="deleteConfirmModal.show()"
>
<SpinnerIcon v-if="removing" class="animate-spin" />
<TrashIcon v-else />
{{
removing
? formatMessage(messages.deletingInstanceButton)
: formatMessage(messages.deleteInstanceButton)
}}
</button>
</ButtonStyled>
</div>
</template>
<style scoped lang="scss">
@@ -1,32 +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.value.hooks.pre_launch ||
!!instance.value.hooks.wrapper ||
!!instance.value.hooks.post_exit,
!!props.instance.hooks.pre_launch ||
!!props.instance.hooks.wrapper ||
!!props.instance.hooks.post_exit,
)
const hooks = ref(instance.value.hooks ?? globalSettings.hooks)
const hooks = ref(props.instance.hooks ?? globalSettings.hooks)
const editProfileObject = computed(() => {
const editProfile: {
@@ -42,7 +36,7 @@ const editProfileObject = computed(() => {
watch(
[overrideHooks, hooks],
async () => {
await edit(instance.value.path, editProfileObject.value)
await edit(props.instance.path, editProfileObject.value)
},
{ deep: true },
)
@@ -101,57 +95,60 @@ const messages = defineMessages({
<template>
<div>
<h2 class="m-0 m-0 text-lg font-semibold text-contrast">
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.hooks) }}
</h2>
<Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="my-2.5" />
<p class="m-0">
{{ formatMessage(messages.hooksDescription) }}
</p>
<Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="mt-2" />
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
<h2 class="mt-2 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.preLaunch) }}
</h2>
<StyledInput
<p class="m-0">
{{ formatMessage(messages.preLaunchDescription) }}
</p>
<input
id="pre-launch"
v-model="hooks.pre_launch"
autocomplete="off"
:disabled="!overrideHooks"
type="text"
:placeholder="formatMessage(messages.preLaunchEnter)"
wrapper-class="w-full my-2.5"
class="w-full mt-2"
/>
<p class="m-0">
{{ formatMessage(messages.preLaunchDescription) }}
</p>
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.wrapper) }}
</h2>
<StyledInput
<p class="m-0">
{{ formatMessage(messages.wrapperDescription) }}
</p>
<input
id="wrapper"
v-model="hooks.wrapper"
autocomplete="off"
:disabled="!overrideHooks"
type="text"
:placeholder="formatMessage(messages.wrapperEnter)"
wrapper-class="w-full my-2.5"
class="w-full mt-2"
/>
<p class="m-0">
{{ formatMessage(messages.wrapperDescription) }}
</p>
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.postExit) }}
</h2>
<StyledInput
<p class="m-0">
{{ formatMessage(messages.postExitDescription) }}
</p>
<input
id="post-exit"
v-model="hooks.post_exit"
autocomplete="off"
:disabled="!overrideHooks"
type="text"
:placeholder="formatMessage(messages.postExitEnter)"
wrapper-class="w-full my-2.5"
class="w-full mt-2"
/>
<p class="m-0">
{{ formatMessage(messages.postExitDescription) }}
</p>
</div>
</template>
File diff suppressed because it is too large Load Diff
@@ -1,133 +1,83 @@
<script setup lang="ts">
import {
CheckCircleIcon,
CoffeeIcon,
FolderSearchIcon,
RefreshCwIcon,
SearchIcon,
SpinnerIcon,
XCircleIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
defineMessages,
injectNotificationManager,
Slider,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { CheckCircleIcon, XCircleIcon } from '@modrinth/assets'
import { Checkbox, injectNotificationManager, Slider } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, readonly, ref, watch } from 'vue'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import useJavaTest from '@/composables/useJavaTest'
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 optimalJava = readonly(await get_optimal_jre_key(instance.value.path).catch(handleError))
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 overrideJavaInstall = ref(!!instance.value.java_path)
const javaPath = ref(instance.value.java_path ?? optimalJava?.path ?? '')
const activePath = computed(() =>
overrideJavaInstall.value ? javaPath.value : (optimalJava?.path ?? ''),
)
watch(overrideJavaInstall, (enabled) => {
if (enabled && !javaPath.value) {
javaPath.value = optimalJava?.path ?? ''
}
})
const { testingJava, javaTestResult, testJavaInstallationDebounced, testJavaInstallation } =
useJavaTest()
const hoveringTest = ref(false)
let hasInitialized = false
watch(
activePath,
(newPath) => {
if (newPath && optimalJava?.parsed_version) {
if (!hasInitialized) {
testJavaInstallation(newPath, optimalJava?.parsed_version, false)
hasInitialized = true
} else {
testJavaInstallationDebounced(newPath, optimalJava?.parsed_version)
}
}
},
{ immediate: true },
)
const javaDetectionModal = ref<{ show: (version: number, current: object) => void } | null>(null)
async function handleBrowseJava() {
const result = await open({ multiple: false })
if (result) {
javaPath.value = result
}
}
function handleDetectJava() {
javaDetectionModal.value?.show(optimalJava?.parsed_version, { path: javaPath.value })
}
const overrideJavaArgs = ref((instance.value.extra_launch_args?.length ?? 0) > 0)
const overrideJavaArgs = ref(props.instance.extra_launch_args?.length !== undefined)
const javaArgs = ref(
(instance.value.extra_launch_args ?? globalSettings.extra_launch_args).join(' '),
(props.instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' '),
)
const overrideEnvVars = ref((instance.value.custom_env_vars?.length ?? 0) > 0)
const overrideEnvVars = ref(props.instance.custom_env_vars?.length !== undefined)
const envVars = ref(
(instance.value.custom_env_vars ?? globalSettings.custom_env_vars)
(props.instance.custom_env_vars ?? globalSettings.custom_env_vars)
.map((x) => x.join('='))
.join(' '),
)
const overrideMemorySettings = ref(!!instance.value.memory)
const memory = ref(instance.value.memory ?? globalSettings.memory)
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 && javaPath.value
? javaPath.value.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(
[
overrideJavaInstall,
javaPath,
javaInstall,
overrideJavaArgs,
javaArgs,
overrideEnvVars,
@@ -136,7 +86,7 @@ watch(
memory,
],
async () => {
await edit(instance.value.path, editProfileObject.value)
await edit(props.instance.path, editProfileObject.value)
},
{ deep: true },
)
@@ -146,45 +96,17 @@ const messages = defineMessages({
id: 'instance.settings.tabs.java.java-installation',
defaultMessage: 'Java installation',
},
customJavaInstallation: {
id: 'instance.settings.tabs.java.custom-java-installation',
defaultMessage: 'Custom Java installation',
},
javaPathPlaceholder: {
id: 'instance.settings.tabs.java.java-path-placeholder',
defaultMessage: '/path/to/java',
},
javaMemory: {
id: 'instance.settings.tabs.java.java-memory',
defaultMessage: 'Memory allocated',
},
customMemoryAllocation: {
id: 'instance.settings.tabs.java.custom-memory-allocation',
defaultMessage: 'Custom memory allocation',
},
javaArguments: {
id: 'instance.settings.tabs.java.java-arguments',
defaultMessage: 'Java arguments',
},
customJavaArguments: {
id: 'instance.settings.tabs.java.custom-java-arguments',
defaultMessage: 'Custom Java arguments',
},
enterJavaArguments: {
id: 'instance.settings.tabs.java.enter-java-arguments',
defaultMessage: 'Enter Java arguments...',
},
javaEnvironmentVariables: {
id: 'instance.settings.tabs.java.environment-variables',
defaultMessage: 'Environment variables',
},
customEnvironmentVariables: {
id: 'instance.settings.tabs.java.custom-environment-variables',
defaultMessage: 'Custom environment variables',
},
enterEnvironmentVariables: {
id: 'instance.settings.tabs.java.enter-environment-variables',
defaultMessage: 'Enter environmental variables...',
javaMemory: {
id: 'instance.settings.tabs.java.java-memory',
defaultMessage: 'Memory allocated',
},
hooks: {
id: 'instance.settings.tabs.java.hooks',
@@ -195,86 +117,43 @@ const messages = defineMessages({
<template>
<div>
<JavaDetectionModal ref="javaDetectionModal" @submit="(val) => (javaPath = val.path)" />
<h2 class="m-0 mb-2 text-lg font-extrabold text-contrast block">
<h2 id="project-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaInstallation) }}
</h2>
<Checkbox
v-model="overrideJavaInstall"
:label="formatMessage(messages.customJavaInstallation)"
class="mb-2"
/>
<div class="flex gap-4 p-4 bg-bg rounded-2xl">
<div class="flex gap-3 items-start flex-1 min-w-0">
<div
class="w-10 h-10 flex items-center justify-center rounded-full bg-button-bg border-solid border-[1px] border-button-border p-2 mt-1 shrink-0 [&_svg]:h-full [&_svg]:w-full"
>
<CoffeeIcon />
</div>
<div class="flex flex-col gap-2 flex-1 min-w-0">
<span class="font-semibold leading-none mt-2"
>Java {{ optimalJava?.parsed_version }}</span
<Checkbox v-model="overrideJavaInstall" label="Custom Java installation" class="mb-2" />
<template v-if="!overrideJavaInstall">
<div class="flex my-2 items-center gap-2 font-semibold">
<template v-if="javaInstall">
<CheckCircleIcon class="text-brand-green h-4 w-4" />
<span>Using default Java {{ optimalJava.major_version }} installation:</span>
</template>
<template v-else-if="optimalJava">
<XCircleIcon class="text-brand-red h-5 w-5" />
<span
>Could not find a default Java {{ optimalJava.major_version }} installation. Please set
one below:</span
>
<div class="flex gap-2 items-center">
<StyledInput
:model-value="activePath"
:disabled="!overrideJavaInstall"
autocomplete="off"
:placeholder="formatMessage(messages.javaPathPlaceholder)"
wrapper-class="flex-1 min-w-0"
@update:model-value="(val) => (javaPath = String(val))"
/>
<ButtonStyled
:color="
!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard'
"
color-fill="text"
>
<button
:disabled="!overrideJavaInstall || testingJava"
@click="testJavaInstallation(activePath, optimalJava?.parsed_version, true)"
@mouseenter="overrideJavaInstall && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="javaTestResult === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon v-else-if="javaTestResult !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="overrideJavaInstall" class="h-4 w-4" />
</button>
</ButtonStyled>
</div>
<div v-if="overrideJavaInstall" class="flex gap-2">
<ButtonStyled>
<button @click="handleDetectJava">
<SearchIcon />
Detect
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleBrowseJava">
<FolderSearchIcon />
Browse
</button>
</ButtonStyled>
</div>
</div>
</template>
<template v-else>
<XCircleIcon class="text-brand-red h-5 w-5" />
<span
>Could not automatically determine a Java installation to use. Please set one
below:</span
>
</template>
</div>
</div>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
<div
v-if="javaInstall && !overrideJavaInstall"
class="p-4 bg-bg rounded-xl text-xs text-secondary leading-none font-mono"
>
{{ javaInstall.path }}
</div>
</template>
<JavaSelector v-if="overrideJavaInstall || !javaInstall" v-model="javaInstall" />
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaMemory) }}
</h2>
<Checkbox
v-model="overrideMemorySettings"
:label="formatMessage(messages.customMemoryAllocation)"
class="mb-2"
/>
<Checkbox v-model="overrideMemorySettings" label="Custom memory allocation" class="mb-2" />
<Slider
id="max-memory"
v-model="memory.maximum"
@@ -286,37 +165,31 @@ const messages = defineMessages({
:snap-range="512"
unit="MB"
/>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaArguments) }}
</h2>
<Checkbox
v-model="overrideJavaArgs"
:label="formatMessage(messages.customJavaArguments)"
class="my-2"
/>
<StyledInput
<Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" />
<input
id="java-args"
v-model="javaArgs"
autocomplete="off"
:disabled="!overrideJavaArgs"
:placeholder="formatMessage(messages.enterJavaArguments)"
wrapper-class="w-full"
type="text"
class="w-full"
placeholder="Enter java arguments..."
/>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
<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="formatMessage(messages.customEnvironmentVariables)"
class="mb-2"
/>
<StyledInput
<Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2" />
<input
id="env-vars"
v-model="envVars"
autocomplete="off"
:disabled="!overrideEnvVars"
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
wrapper-class="w-full"
type="text"
class="w-full"
placeholder="Enter environmental variables..."
/>
</div>
</template>
@@ -1,54 +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.value.game_resolution || !!instance.value.force_fullscreen,
!!props.instance.game_resolution || !!props.instance.force_fullscreen,
)
const resolution: Ref<[number, number]> = ref(
instance.value.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.value.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.value.path, editProfileObject.value)
await edit(props.instance.path, editProfileObject.value)
},
{ deep: true },
)
@@ -94,14 +91,22 @@ const messages = defineMessages({
</script>
<template>
<div class="flex flex-col gap-6">
<div>
<Checkbox
v-model="overrideWindowSettings"
:label="formatMessage(messages.customWindowSettings)"
@update:model-value="
(value) => {
if (!value) {
resolution = globalSettings.game_resolution
fullscreenSetting = globalSettings.force_fullscreen
}
}
"
/>
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
<div class="mt-2 flex items-center gap-4 justify-between">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.fullscreen) }}
</h2>
<p class="m-0">
@@ -120,16 +125,16 @@ const messages = defineMessages({
/>
</div>
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
<div class="mt-4 flex items-center gap-4 justify-between">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.width) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.widthDescription) }}
</p>
</div>
<StyledInput
<input
id="width"
v-model="resolution[0]"
autocomplete="off"
@@ -139,16 +144,16 @@ const messages = defineMessages({
/>
</div>
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
<div class="mt-4 flex items-center gap-4 justify-between">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.height) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.heightDescription) }}
</p>
</div>
<StyledInput
<input
id="height"
v-model="resolution[1]"
autocomplete="off"
@@ -1,188 +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 { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleSevereError } from '@/store/error.js'
import { findMinecraftAuthError, type MinecraftAuthError } 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 = findMinecraftAuthError(rawError.value)
debugCollapsed.value = true
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
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'">
<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,200 +0,0 @@
export interface MinecraftAuthError {
errorCode?: string
errorMatchers?: string[]
matches?: (message: string) => boolean
whatHappened: string
stepsToFix: string[]
}
export const minecraftAuthErrors: MinecraftAuthError[] = [
{
errorMatchers: ['Failed to deserialize response to JSON during step RefreshOAuthToken:'],
whatHappened:
'Your saved Microsoft sign-in token has expired or was revoked, so Modrinth App cannot refresh your Minecraft session.',
stepsToFix: [
'Sign out of the affected Minecraft account in Modrinth App',
'Sign in to the account again',
'Once the new sign-in finishes, try launching Minecraft again',
],
},
{
errorMatchers: ['Failed to deserialize response to JSON during step SisuAuthenticate:'],
whatHappened:
'Xbox services rejected the first sign-in response. This is most often caused by your system clock or time zone being out of sync.',
stepsToFix: [
'Open your system date and time settings',
'Turn on automatic time zone and automatic time, if available',
'Use the sync option in your system settings to synchronize the clock',
'Restart Modrinth App',
'Try signing in again',
],
},
{
matches: (message) =>
message.includes('Failed to deserialize response to JSON during step MinecraftToken:') &&
message.includes('429 Too Many Requests'),
whatHappened:
'Microsoft or Minecraft temporarily blocked the sign-in request because there were too many recent attempts.',
stepsToFix: [
'Wait about an hour before trying again',
'Restart Modrinth App after waiting',
'Try signing in once more',
'If the same message appears, wait longer before retrying so the temporary limit can clear',
],
},
{
matches: (message) =>
message.includes('Failed to deserialize response to JSON during step MinecraftToken:') &&
/Status Code: 5\d\d/.test(message),
whatHappened:
"Minecraft's authentication service is returning a server error, so Modrinth App cannot finish signing you in right now.",
stepsToFix: [
'Wait a few minutes and try signing in again',
'Check <a href="https://support.xbox.com/xbox-live-status">Xbox Status</a> for current service issues',
'Try signing in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a> to confirm whether Minecraft sign-in is also affected there',
'If the service is healthy and this keeps happening, contact support with the debug information below',
],
},
{
errorMatchers: ['Failed to fetch player profile'],
whatHappened:
'Minecraft services could not return a Java Edition profile for this account. This most often happens when the game was purchased recently, the Java profile has not finished being created, or the wrong Microsoft account is being used.',
stepsToFix: [
'Sign in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>',
'Launch Minecraft: Java Edition once from the official launcher',
'Wait up to an hour if the purchase or profile setup was recent',
'Make sure you are using the Microsoft account that owns Minecraft. See <a href="https://support.modrinth.com/en/articles/9409136-finding-the-right-xbox-account">Finding the right Xbox account</a> for help',
'Try signing in to Modrinth App again',
],
},
{
matches: (message) =>
message.includes('error sending request for url (') &&
[
'minecraft.net',
'minecraftservices.com',
'mojang.com',
'xbox.com',
'xboxlive.com',
'live.com',
].some((domain) => message.includes(domain)),
whatHappened:
'Modrinth App could not connect to a Microsoft, Xbox, or Minecraft service needed for sign-in. This is usually caused by a local network, DNS, proxy, firewall, hosts file, VPN, or antivirus issue.',
stepsToFix: [
'Restart Modrinth App and try signing in again',
'Check that your internet connection is working',
'Allow Modrinth App through your firewall, antivirus, proxy, VPN, and hosts file rules',
'Try a different network or temporarily disable VPN/proxy software if you use one',
'If routing or DNS is the issue, a service like Cloudflare WARP can sometimes help',
],
},
{
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',
],
},
{
errorMatchers: ['Failed to deserialize response to JSON during step XstsAuthorize:'],
whatHappened:
'Xbox services rejected the request to authorize this account for Minecraft services, but did not return a specific account restriction that Modrinth App recognizes.',
stepsToFix: [
'Sign in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>',
'Complete any prompts shown by Microsoft, Xbox, or Minecraft',
'Try signing in to Modrinth App again',
'If the official launcher also fails, follow the error shown there or contact Xbox Support',
],
},
]
export function findMinecraftAuthError(message: string): MinecraftAuthError | null {
return (
minecraftAuthErrors.find((error) => {
if (error.errorCode && message.includes(error.errorCode)) {
return true
}
if (error.errorMatchers?.some((matcher) => message.includes(matcher))) {
return true
}
return error.matches?.(message) ?? false
}) ?? null
)
}
@@ -1,49 +1,56 @@
<script setup lang="ts">
import {
AstralRinthLogo,
CoffeeIcon,
DownloadIcon,
GameIcon,
GaugeIcon,
LanguagesIcon,
AstralRinthLogo,
DownloadIcon,
SpinnerIcon,
PaintbrushIcon,
ReportIcon,
SettingsIcon,
ShieldIcon,
SpinnerIcon,
ToggleRightIcon,
} from '@modrinth/assets'
import {
commonMessages,
commonSettingsMessages,
defineMessage,
defineMessages,
ProgressBar,
TabbedModal,
useVIntl,
} from '@modrinth/ui'
import { ProgressBar, 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, defineMessages, useVIntl } from '@vintl/vintl'
import { computed, ref, watch } from 'vue'
import LauncherUpdateModal from '@/components/ui/astralrinth/LauncherUpdateModal.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 { isUpdateInstalling, isUpdateAvailable } from '@/helpers/astralrinth/update'
// [AR] Imports
import { installState, getRemote, updateState } from '@/helpers/update.js'
const updateModalView = ref(null)
const updateRequestFailView = ref(null)
const initUpdateModal = async () => {
updateModalView.value.show()
}
const initDownload = async () => {
updateModalView.value.hide()
const result = await getRemote(true);
if (!result) {
updateRequestFailView.value.show()
}
}
import { injectAppUpdateDownloadProgress } from '@/providers/download-progress.ts'
import { useTheming } from '@/store/state'
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const devModeCounter = ref(0)
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
const launcherUpdateModal = ref<InstanceType<typeof LauncherUpdateModal> | null>(null)
const developerModeEnabled = defineMessage({
id: 'app.settings.developer-mode-enabled',
@@ -59,15 +66,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',
@@ -101,23 +99,25 @@ const tabs = [
content: ResourceManagementSettings,
},
{
name: commonSettingsMessages.featureFlags,
icon: ToggleRightIcon,
name: defineMessage({
id: 'app.settings.tabs.feature-flags',
defaultMessage: 'Feature flags',
}),
icon: ReportIcon,
content: FeatureFlagSettings,
developerOnly: true,
},
]
const modal = ref()
function show() {
modal.value?.show()
modal.value.show()
}
function showUpdateModal() {
modal.value?.show()
void launcherUpdateModal.value?.show()
}
const isOpen = computed(() => modal.value?.isOpen)
defineExpose({ show, showUpdateModal })
defineExpose({ show, isOpen })
const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress()
@@ -141,8 +141,8 @@ 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)
}
}
}
@@ -152,82 +152,136 @@ const messages = defineMessages({
id: 'app.settings.downloading',
defaultMessage: 'Downloading v{version}',
},
updateInstalling: {
id: 'astralrinth.app.settings.update-installing',
defaultMessage: 'Installing update...',
},
viewUpdateInfo: {
id: 'astralrinth.app.settings.view-update-info',
defaultMessage: 'View update info',
},
})
</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"
>
<AstralRinthLogo class="w-6 h-6" />
</button>
<div class="max-w-[200px]">
<p class="m-0">AstralRinth 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
v-if="isUpdateAvailable"
class="w-8 h-8 cursor-pointer hover:brightness-75 neon-icon pulse shrink-0"
>
<template v-if="isUpdateInstalling">
<SpinnerIcon
class="size-6 animate-spin"
v-tooltip.bottom="formatMessage(messages.updateInstalling)"
/>
</template>
<template v-else>
<DownloadIcon
class="size-6"
v-tooltip.bottom="formatMessage(messages.viewUpdateInfo)"
@click="showUpdateModal()"
/>
</template>
</div>
</div>
</div>
</template>
</TabbedModal>
<LauncherUpdateModal ref="launcherUpdateModal" :version="version" />
<TabbedModal :tabs="tabs.filter((t) => !t.developerOnly || themeStore.devMode)">
<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"
>
<AstralRinthLogo class="w-6 h-6" />
</button>
<div>
<p class="m-0">AstralRinth 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 v-if="updateState" class="w-8 h-8 cursor-pointer hover:brightness-75 neon-icon pulse">
<template v-if="installState">
<SpinnerIcon class="size-6 animate-spin" v-tooltip.bottom="'Installing in process...'" />
</template>
<template v-else>
<DownloadIcon class="size-6" v-tooltip.bottom="'View update info'" @click="!installState && (initUpdateModal(), getRemote(false))" />
</template>
</div>
</div>
</div>
</template>
</TabbedModal>
<!-- [AR] Feature -->
<ModalWrapper ref="updateModalView" :has-to-type="false" header="Request to update the AstralRinth launcher">
<div class="space-y-4">
<div class="space-y-2">
<strong>The new version of the AstralRinth launcher is available!</strong>
<p>Your version is outdated. We recommend that you update to the latest version.</p>
<br/>
<br/>
<p><strong> Please, read this notice before initialize update process</strong></p>
<p>
Before updating, make sure that you have saved and closed all running instances and made a backup copy of the launcher data such as
<code>%appdata%\Roaming\AstralRinthApp</code> on Windows or <code>~/Library/Application Support/AstralRinthApp</code> on macOS.
Remember that the authors of the product are not responsible for the breakdown of
your files, so you should always make back up copies of them and keep them in a safe place.
</p>
</div>
<div class="text-sm text-secondary space-y-1">
<p>
<strong> Latest release tag:</strong>
<span id="releaseTag" class="neon-text"></span>
<br/>
<strong> Latest release title:</strong>
<span id="releaseTitle" class="neon-text"></span>
<br/>
<strong>💾 Installed & Running version:</strong>
<span class="neon-text">v{{ version }}</span>
</p>
</div>
<a class="neon-text" href="https://me.astralium.su/get/ar" target="_blank"
rel="noopener noreferrer">
Checkout our git repository
</a>
<div class="absolute bottom-4 right-4 flex items-center gap-4 neon-button neon">
<Button class="bordered" @click="updateModalView.hide()">Cancel</Button>
<Button class="bordered" @click="initDownload()">Download file</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="updateRequestFailView" :has-to-type="false" header="Failed to request a file from the server :(">
<div class="space-y-4">
<div class="space-y-2">
<p><strong>Error occurred</strong></p>
<p>Unfortunately, the program was unable to download the file from our servers.</p>
<p>
Please try downloading it yourself from
<a class="neon-text" href="https://me.astralium.su/get/ar" target="_blank" rel="noopener noreferrer">Git
Astralium</a>
if there are any updates available.
</p>
</div>
<div class="text-sm text-secondary">
<p>
<strong>Local AstralRinth:</strong>
<span class="neon-text">v{{ version }}</span>
</p>
</div>
<div class="absolute bottom-4 right-4 flex items-center gap-4 neon-button neon">
<Button class="bordered" @click="updateRequestFailView.hide()">Close</Button>
</div>
</div>
</ModalWrapper>
</ModalWrapper>
</template>
<style lang="scss" scoped>
@import '../../../../../../packages/assets/styles/astralrinth/neon-icon.scss';
@import '../../../../../../packages/assets/styles/neon-icon.scss';
@import '../../../../../../packages/assets/styles/neon-button.scss';
@import '../../../../../../packages/assets/styles/neon-text.scss';
code {
background: linear-gradient(90deg, #005eff, #00cfff);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
</style>
@@ -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 @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,11 +38,10 @@ defineProps({
type: Boolean,
default: true,
},
/** @deprecated No longer used — ads are handled by provideModalBehavior */
showAdOnClose: {
type: Boolean,
default: true,
},
// showAdOnClose: {
// type: Boolean,
// default: true,
// },
markdown: {
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,271 +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 { 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)
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
}
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,76 +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')
const instanceRef = computed(() => props.instance)
const queryClient = useQueryClient()
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
function hide() {
tabbedModal.value?.hide()
}
provideInstanceSettings({
instance: instanceRef,
offline: props.offline,
isMinecraftServer,
onUnlinked: handleUnlinked,
closeModal: hide,
})
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',
@@ -118,30 +66,23 @@ const tabs = computed<TabbedModalTab[]>(() => [
icon: CodeIcon,
content: HooksSettings,
},
])
]
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))
}
const modal = ref()
function show() {
modal.value.show()
}
defineExpose({ show, hide })
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
@@ -150,10 +91,10 @@ defineExpose({ show, hide })
: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,8 +1,12 @@
<!-- @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,
@@ -22,31 +26,40 @@ const props = defineProps({
return () => {}
},
},
/** @deprecated No longer used — ads are handled by provideModalBehavior */
showAdOnClose: {
type: Boolean,
default: true,
},
// showAdOnClose: {
// type: Boolean,
// default: true,
// },
})
const modal = useTemplateRef('modal')
defineExpose({
show: (e?: MouseEvent) => {
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"
:noblur="!themeStore.advancedRendering"
:closable="closable"
:hide-header="hideHeader"
:on-hide="() => props.onHide?.()"
@hide="onModalHide"
>
<template #title>
<slot name="title" />
@@ -1,102 +0,0 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="warning" max-width="500px">
<p class="m-0 text-secondary">
<IntlFormatted :message-id="messages.body" :values="{ instanceName }">
<template #bold="{ children }">
<span class="font-medium text-contrast"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</p>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleGoToInstance">
{{ formatMessage(messages.instance) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button @click="handleCreateAnyway">
<PlusIcon />
{{ formatMessage(messages.create) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { PlusIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
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',
},
body: {
id: 'app.instance.modpack-already-installed.body',
defaultMessage:
'This modpack is already installed in the <bold>{instanceName}</bold> instance. Are you sure you want to duplicate it?',
},
instance: {
id: 'app.instance.modpack-already-installed.instance',
defaultMessage: 'Instance',
},
create: {
id: 'app.instance.modpack-already-installed.create',
defaultMessage: 'Create',
},
})
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 handleCancel() {
modal.value?.hide()
}
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,115 +1,13 @@
<script setup lang="ts">
import { Combobox, defineMessages, ThemeSelector, Toggle, useVIntl } from '@modrinth/ui'
import { Combobox, ThemeSelector, Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils'
import { useTheming } from '@/store/state'
import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
import type { ColorTheme } from '@/store/theme.ts'
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
const messages = defineMessages({
colorThemeTitle: {
id: 'app.appearance-settings.color-theme.title',
defaultMessage: 'Color theme',
},
colorThemeDescription: {
id: 'app.appearance-settings.color-theme.description',
defaultMessage: 'Select your preferred color theme for Modrinth App.',
},
advancedRenderingTitle: {
id: 'app.appearance-settings.advanced-rendering.title',
defaultMessage: 'Advanced rendering',
},
advancedRenderingDescription: {
id: 'app.appearance-settings.advanced-rendering.description',
defaultMessage:
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
},
hideNametagTitle: {
id: 'app.appearance-settings.hide-nametag.title',
defaultMessage: 'Hide nametag',
},
hideNametagDescription: {
id: 'app.appearance-settings.hide-nametag.description',
defaultMessage: 'Disables the nametag above your player on the skins page.',
},
nativeDecorationsTitle: {
id: 'app.appearance-settings.native-decorations.title',
defaultMessage: 'Native decorations',
},
nativeDecorationsDescription: {
id: 'app.appearance-settings.native-decorations.description',
defaultMessage: 'Use system window frame (app restart required).',
},
minimizeLauncherTitle: {
id: 'app.appearance-settings.minimize-launcher.title',
defaultMessage: 'Minimize launcher',
},
minimizeLauncherDescription: {
id: 'app.appearance-settings.minimize-launcher.description',
defaultMessage: 'Minimize the launcher when a Minecraft process starts.',
},
defaultLandingPageTitle: {
id: 'app.appearance-settings.default-landing-page.title',
defaultMessage: 'Default landing page',
},
defaultLandingPageDescription: {
id: 'app.appearance-settings.default-landing-page.description',
defaultMessage: 'Change the page to which the launcher opens on.',
},
defaultLandingPageHome: {
id: 'app.appearance-settings.default-landing-page.home',
defaultMessage: 'Home',
},
defaultLandingPageLibrary: {
id: 'app.appearance-settings.default-landing-page.library',
defaultMessage: 'Library',
},
selectOption: {
id: 'app.appearance-settings.select-option',
defaultMessage: 'Select an option',
},
jumpBackIntoWorldsTitle: {
id: 'app.appearance-settings.jump-back-into-worlds.title',
defaultMessage: 'Jump back into worlds',
},
jumpBackIntoWorldsDescription: {
id: 'app.appearance-settings.jump-back-into-worlds.description',
defaultMessage: 'Includes recent worlds in the "Jump back in" section on the Home page.',
},
toggleSidebarTitle: {
id: 'app.appearance-settings.toggle-sidebar.title',
defaultMessage: 'Toggle sidebar',
},
toggleSidebarDescription: {
id: 'app.appearance-settings.toggle-sidebar.description',
defaultMessage: 'Enables the ability to toggle the sidebar.',
},
unknownPackWarningTitle: {
id: 'app.appearance-settings.unknown-pack-warning.title',
defaultMessage: 'Warn me before installing unknown modpacks',
},
unknownPackWarningDescription: {
id: 'app.appearance-settings.unknown-pack-warning.description',
defaultMessage:
"If you attempt to install a Modrinth Pack file (.mrpack) that isn't hosted on Modrinth, we'll make sure you understand the risks before installing it.",
},
showPlayTimeTitle: {
id: 'app.appearance-settings.show-play-time.title',
defaultMessage: 'Show play time',
},
showPlayTimeDescription: {
id: 'app.appearance-settings.show-play-time.description',
defaultMessage: `Displays how much time you've spent playing an instance.`,
},
})
const os = ref(await getOS())
const settings = ref(await get())
@@ -123,10 +21,8 @@ watch(
)
</script>
<template>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.colorThemeTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.colorThemeDescription) }}</p>
<h2 class="m-0 text-lg font-extrabold text-contrast">Color theme</h2>
<p class="m-0 mt-1">Select your preferred color theme for Modrinth App.</p>
<ThemeSelector
:update-color-theme="
@@ -140,13 +36,12 @@ watch(
system-theme-color="system"
/>
<div class="mt-6 flex items-center justify-between">
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.advancedRenderingTitle) }}
</h2>
<h2 class="m-0 text-lg font-extrabold text-contrast">Advanced rendering</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.advancedRenderingDescription) }}
Enables advanced rendering such as blur effects that may cause performance issues without
hardware-accelerated rendering.
</p>
</div>
@@ -162,135 +57,66 @@ watch(
/>
</div>
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.nativeDecorationsTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.nativeDecorationsDescription) }}</p>
<h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2>
<p class="m-0 mt-1">Disables the nametag above your player on the skins page.</p>
</div>
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
</div>
<div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Native decorations</h2>
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
</div>
<Toggle id="native-decorations" v-model="settings.native_decorations" />
</div>
<div class="mt-6 flex items-center justify-between">
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.minimizeLauncherTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.minimizeLauncherDescription) }}</p>
<h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2>
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
</div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
</div>
<div class="mt-6 flex items-center justify-between">
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.showPlayTimeTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.showPlayTimeDescription) }}</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag(showPlayTimeFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(showPlayTimeFlag)
themeStore.featureFlags[showPlayTimeFlag] = newValue
settings.feature_flags[showPlayTimeFlag] = newValue
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.hideNametagTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.hideNametagDescription) }}</p>
</div>
<Toggle
id="hide-nametag-skins-page"
:model-value="themeStore.hideNametagSkinsPage"
@update:model-value="
(e) => {
themeStore.hideNametagSkinsPage = !!e
settings.hide_nametag_skins_page = themeStore.hideNametagSkinsPage
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.defaultLandingPageTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.defaultLandingPageDescription) }}</p>
<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
id="opening-page"
v-model="settings.default_page"
name="Opening page dropdown"
class="max-w-40"
:options="[
{
value: 'Home',
label: formatMessage(messages.defaultLandingPageHome),
},
{
value: 'Library',
label: formatMessage(messages.defaultLandingPageLibrary),
},
]"
class="w-40"
:options="['Home', 'Library'].map((v) => ({ value: v, label: v }))"
:display-value="settings.default_page ?? 'Select an option'"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.jumpBackIntoWorldsTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.jumpBackIntoWorldsDescription) }}</p>
<h2 class="m-0 text-lg font-extrabold text-contrast">Jump back into worlds</h2>
<p class="m-0 mt-1">Includes recent worlds in the "Jump back in" section on the Home page.</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag(worldsInHomeFlag)"
:model-value="themeStore.getFeatureFlag('worlds_in_home')"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(worldsInHomeFlag)
themeStore.featureFlags[worldsInHomeFlag] = newValue
settings.feature_flags[worldsInHomeFlag] = newValue
const newValue = !themeStore.getFeatureFlag('worlds_in_home')
themeStore.featureFlags['worlds_in_home'] = newValue
settings.feature_flags['worlds_in_home'] = newValue
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between gap-4">
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.unknownPackWarningTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.unknownPackWarningDescription) }}</p>
</div>
<Toggle
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFlag)"
@update:model-value="
(e) => {
const warnBeforeUnknownPackInstall = !!e
const skipUnknownPackWarning = !warnBeforeUnknownPackInstall
themeStore.featureFlags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
settings.feature_flags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.toggleSidebarTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.toggleSidebarDescription) }}</p>
<h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2>
<p class="m-0 mt-1">Enables the ability to toggle the sidebar.</p>
</div>
<Toggle
id="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'
@@ -52,135 +52,128 @@ watch(
<template>
<div>
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Fullscreen</h3>
<p class="m-0 leading-tight">
Overwrites the options.txt file to start in full screen when launched.
</p>
</div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Window size</h2>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Fullscreen</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
Overwrites the options.txt file to start in full screen when launched.
</p>
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Width</h3>
<p class="m-0 leading-tight">The width of the game window when launched.</p>
</div>
<StyledInput
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter width..."
/>
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Height</h3>
<p class="m-0 leading-tight">The height of the game window when launched.</p>
</div>
<StyledInput
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter height..."
/>
</div>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Memory allocated</h2>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<p class="m-0 mt-1 leading-tight">The memory allocated to each instance when it is ran.</p>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Width</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The width of the game window when launched.
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Java arguments</h2>
<StyledInput
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
placeholder="Enter java arguments..."
wrapper-class="w-full"
/>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Environmental variables</h2>
<StyledInput
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
placeholder="Enter environmental variables..."
wrapper-class="w-full"
/>
</div>
<input
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter width..."
/>
</div>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Pre launch hook</h3>
<StyledInput
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
placeholder="Enter pre-launch command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Ran before the instance is launched.</p>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Height</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The height of the game window when launched.
</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Wrapper hook</h3>
<StyledInput
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
placeholder="Enter wrapper command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Wrapper command for launching Minecraft.</p>
</div>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Post exit hook</h3>
<StyledInput
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
placeholder="Enter post-exit command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Ran after the game closes.</p>
</div>
<input
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
class="input"
placeholder="Enter height..."
/>
</div>
<hr class="mt-4 bg-button-border border-none h-[1px]" />
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Memory allocated</h2>
<p class="m-0 mt-1 leading-tight">The memory allocated to each instance when it is ran.</p>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Java arguments</h2>
<input
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
placeholder="Enter java arguments..."
class="w-full"
/>
<h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Environmental variables</h2>
<input
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
placeholder="Enter environmental variables..."
class="w-full"
/>
<hr class="mt-4 bg-button-border border-none h-[1px]" />
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Hooks</h2>
<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>
<input
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
placeholder="Enter pre-launch command..."
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>
<input
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
placeholder="Enter wrapper command..."
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>
<input
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
placeholder="Enter post-exit command..."
class="w-full"
/>
</div>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ButtonStyled, Toggle } from '@modrinth/ui'
import { Toggle } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
@@ -25,28 +25,17 @@ watch(
)
</script>
<template>
<div class="flex flex-col gap-2.5 min-w-[600px]">
<div v-for="option in options" :key="option" class="flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast capitalize">
{{ option.replaceAll('_', ' ') }}
</h2>
</div>
<div class="flex items-center gap-2">
<ButtonStyled type="transparent">
<button
:disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
@click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])"
>
Reset to default
</button>
</ButtonStyled>
<Toggle
id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
/>
</div>
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast capitalize">
{{ option.replaceAll('_', ' ') }}
</h2>
</div>
<Toggle
id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
/>
</div>
</template>
@@ -21,21 +21,15 @@ async function updateJavaVersion(version) {
}
</script>
<template>
<div class="flex flex-col gap-6">
<div
v-for="(javaVersion, index) in [25, 21, 17, 8]"
:key="`java-${javaVersion}`"
class="flex flex-col gap-2.5"
>
<h2 class="m-0 text-lg font-semibold text-contrast" :class="{ 'mt-4': index !== 0 }">
Java {{ javaVersion }} location
</h2>
<JavaSelector
:id="'java-selector-' + javaVersion"
v-model="javaVersions[javaVersion]"
:version="javaVersion"
@update:model-value="updateJavaVersion"
/>
</div>
<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>
<JavaSelector
:id="'java-selector-' + javaVersion"
v-model="javaVersions[javaVersion]"
:version="javaVersion"
@update:model-value="updateJavaVersion"
/>
</div>
</template>
@@ -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-semibold 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>
@@ -25,8 +25,8 @@ watch(
<template>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">Personalized ads</h2>
<p class="m-0 mt-1 text-sm">
<h2 class="m-0 text-lg font-extrabold text-contrast">Personalized ads</h2>
<p class="m-0 text-sm">
Modrinth's ad provider, Aditude, shows ads based on your preferences. By disabling this
option, you opt out and ads will no longer be shown based on your interests.
</p>
@@ -37,8 +37,8 @@ watch(
<div class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">Telemetry</h2>
<p class="m-0 mt-1 text-sm">
<h2 class="m-0 text-lg font-extrabold text-contrast">Telemetry</h2>
<p class="m-0 text-sm">
Modrinth collects anonymized analytics and usage data to improve our user experience and
customize your experience. By disabling this option, you opt out and your data will no
longer be collected.
@@ -50,8 +50,8 @@ watch(
<div class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">Discord RPC</h2>
<p class="m-0 mt-1 text-sm">
<h2 class="m-0 text-lg font-extrabold text-contrast">Discord RPC</h2>
<p class="m-0 text-sm">
Manages the Discord Rich Presence integration. Disabling this will cause 'Modrinth' to no
longer show up as a game or app you are using on your Discord profile.
</p>
@@ -1,6 +1,6 @@
<script setup>
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import { ButtonStyled, 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)
}
@@ -62,79 +58,61 @@ async function findLauncherDir() {
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">App directory</h2>
<StyledInput
id="appDir"
v-model="settings.custom_dir"
:icon="BoxIcon"
type="text"
wrapper-class="w-full"
>
<template #right>
<ButtonStyled circular>
<button class="ml-1.5" @click="findLauncherDir">
<FolderSearchIcon />
</button>
</ButtonStyled>
</template>
</StyledInput>
<p class="m-0 leading-tight text-secondary">
The directory where the launcher stores all of its files. Changes will be applied after
restarting the launcher.
</p>
</div>
<h2 class="m-0 text-lg font-extrabold text-contrast">App directory</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The directory where the launcher stores all of its files. Changes will be applied after
restarting the launcher.
</p>
<div class="flex flex-col gap-2.5">
<ConfirmModalWrapper
ref="purgeCacheConfirmModal"
title="Are you sure you want to purge the cache?"
description="If you proceed, your entire cache will be purged. This may slow down the app temporarily."
:has-to-type="false"
proceed-label="Purge cache"
:show-ad-on-close="false"
@proceed="purgeCache"
/>
<h2 class="m-0 text-lg font-semibold text-contrast">App cache</h2>
<button id="purge-cache" class="btn min-w-max" @click="$refs.purgeCacheConfirmModal.show()">
<TrashIcon />
Purge cache
</button>
<p class="m-0 leading-tight text-secondary">
The Modrinth app stores a cache of data to speed up loading. This can be purged to force the
app to reload data. This may slow down the app temporarily.
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast mt-4">Maximum concurrent downloads</h2>
<Slider
id="max-downloads"
v-model="settings.max_concurrent_downloads"
:min="1"
:max="10"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
The maximum amount of files the launcher can download at the same time. Set this to a lower
value if you have a poor internet connection. (app restart required to take effect)
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">Maximum concurrent writes</h2>
<Slider
id="max-writes"
v-model="settings.max_concurrent_writes"
:min="1"
:max="50"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
The maximum amount of files the launcher can write to the disk at once. Set this to a lower
value if you are frequently getting I/O errors. (app restart required to take effect)
</p>
<div class="m-1 my-2">
<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>
<ConfirmModalWrapper
ref="purgeCacheConfirmModal"
title="Are you sure you want to purge the cache?"
description="If you proceed, your entire cache will be purged. This may slow down the app temporarily."
:has-to-type="false"
proceed-label="Purge cache"
:show-ad-on-close="false"
@proceed="purgeCache"
/>
<h2 class="m-0 text-lg font-extrabold text-contrast">App cache</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The Modrinth app stores a cache of data to speed up loading. This can be purged to force the
app to reload data. This may slow down the app temporarily.
</p>
</div>
<button id="purge-cache" class="btn min-w-max" @click="$refs.purgeCacheConfirmModal.show()">
<TrashIcon />
Purge cache
</button>
<h2 class="m-0 text-lg font-extrabold text-contrast mt-4">Maximum concurrent downloads</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The maximum amount of files the launcher can download at the same time. Set this to a lower
value if you have a poor internet connection. (app restart required to take effect)
</p>
<Slider
id="max-downloads"
v-model="settings.max_concurrent_downloads"
:min="1"
:max="10"
:step="1"
/>
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Maximum concurrent writes</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The maximum amount of files the launcher can write to the disk at once. Set this to a lower
value if you are frequently getting I/O errors. (app restart required to take effect)
</p>
<Slider id="max-writes" v-model="settings.max_concurrent_writes" :min="1" :max="50" :step="1" />
</template>
@@ -1,241 +1,146 @@
<template>
<NewModal ref="modal" :on-hide="handleModalHide">
<UploadSkinModal ref="uploadModal" />
<ModalWrapper ref="modal" @on-hide="resetState">
<template #title>
<span class="text-lg font-extrabold text-contrast">
{{ formatMessage(mode === 'edit' ? messages.editSkinTitle : messages.addSkinTitle) }}
{{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }}
</span>
</template>
<div class="flex flex-col md:flex-row gap-6">
<div class="h-[25rem] w-[16rem] min-w-[16rem] flex-shrink-0 md:self-center">
<SkinPreviewRenderer
:variant="variant"
:texture-src="previewSkin || ''"
:cape-src="selectedCapeTexture"
framing="modal"
:initial-rotation="Math.PI / 8"
class="h-full w-full"
/>
<div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer
:variant="variant"
:texture-src="previewSkin || ''"
:cape-src="selectedCapeTexture"
:scale="1.4"
:fov="50"
:initial-rotation="Math.PI / 8"
class="h-full w-full"
/>
</div>
</div>
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
<section v-if="mode === 'edit' && canEditTextureAndModel">
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.textureSection) }}</h2>
<ButtonStyled>
<button class="!shadow-none" @click="openTextureFileBrowser">
<UploadIcon /> {{ formatMessage(messages.replaceTextureButton) }}
</button>
</ButtonStyled>
<input
ref="textureFileInput"
type="file"
accept="image/png"
class="hidden"
@change="onTextureFileInputChange"
/>
<section>
<h2 class="text-base font-semibold mb-2">Texture</h2>
<Button @click="openUploadSkinModal"> <UploadIcon /> Replace texture </Button>
</section>
<section v-if="canEditTextureAndModel">
<h2 class="text-base font-semibold mb-2">
{{ formatMessage(messages.armStyleSection) }}
</h2>
<section>
<h2 class="text-base font-semibold mb-2">Arm style</h2>
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
<template #default="{ item }">
{{
formatMessage(item === 'CLASSIC' ? messages.wideArmStyle : messages.slimArmStyle)
}}
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
</template>
</RadioButtons>
</section>
<section>
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.capeSection) }}</h2>
<div class="relative w-fit max-w-full">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-6"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-6"
leave-to-class="opacity-0 max-h-0"
<h2 class="text-base font-semibold mb-2">Cape</h2>
<div class="flex gap-2">
<CapeButton
v-if="defaultCape"
:id="defaultCape.id"
:texture="defaultCape.texture"
:name="undefined"
:selected="!selectedCape"
faded
@select="selectCape(undefined)"
>
<div
v-if="showCapeTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-6 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<span>Use default cape</span>
</CapeButton>
<CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)">
<span>Use default cape</span>
</CapeLikeTextButton>
<div
ref="capeListRef"
class="grid grid-cols-[repeat(4,max-content)] auto-rows-max gap-2 overflow-y-auto pr-1"
:style="{ maxHeight: capeListMaxHeight }"
@scroll="checkCapeScrollState"
<CapeButton
v-for="cape in visibleCapeList"
:id="cape.id"
:key="cape.id"
:texture="cape.texture"
:name="cape.name || 'Cape'"
:selected="selectedCape?.id === cape.id"
@select="selectCape(cape)"
/>
<CapeLikeTextButton
v-if="(capes?.length ?? 0) > 2"
tooltip="View more capes"
@mouseup="openSelectCapeModal"
>
<CapeLikeTextButton
:tooltip="formatMessage(messages.noCapeTooltip)"
:highlighted="!selectedCape"
@click="selectCape(undefined)"
>
<template #icon><XIcon /></template>
<span>{{ formatMessage(messages.noneCapeOption) }}</span>
</CapeLikeTextButton>
<CapeButton
v-for="cape in sortedCapes"
:id="cape.id"
:key="cape.id"
:texture="cape.texture"
:name="cape.name || formatMessage(messages.capeFallbackName)"
:selected="selectedCape?.id === cape.id"
@select="selectCape(cape)"
/>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-6"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-6"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showCapeBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-6 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
<template #icon><ChevronRightIcon /></template>
<span>More</span>
</CapeLikeTextButton>
</div>
</section>
</div>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button :disabled="isSaving" @click="hide">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
<SpinnerIcon v-if="isSaving" class="animate-spin" />
<CheckIcon v-else-if="mode === 'new'" />
<SaveIcon v-else />
{{ formatMessage(mode === 'new' ? messages.addSkinButton : messages.saveSkinButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<div class="flex gap-2 mt-12">
<ButtonStyled color="brand" :disabled="disableSave || isSaving">
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
<SpinnerIcon v-if="isSaving" class="animate-spin" />
<CheckIcon v-else-if="mode === 'new'" />
<SaveIcon v-else />
{{ mode === 'new' ? 'Add skin' : 'Save skin' }}
</button>
</ButtonStyled>
<Button :disabled="isSaving" @click="hide"><XIcon />Cancel</Button>
</div>
</ModalWrapper>
<SelectCapeModal
ref="selectCapeModal"
:capes="capes || []"
@select="handleCapeSelected"
@cancel="handleCapeCancel"
/>
</template>
<script setup lang="ts">
import { CheckIcon, SaveIcon, SpinnerIcon, UploadIcon, XIcon } from '@modrinth/assets'
import {
CheckIcon,
ChevronRightIcon,
SaveIcon,
SpinnerIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import {
Button,
ButtonStyled,
CapeButton,
CapeLikeTextButton,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
RadioButtons,
SkinPreviewRenderer,
useScrollIndicator,
useVIntl,
} from '@modrinth/ui'
import { arrayBufferToBase64 } from '@modrinth/utils'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { computed, ref, useTemplateRef, watch } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
import {
add_and_equip_custom_skin,
type Cape,
determineModelType,
equip_skin,
get_normalized_skin_texture,
normalize_skin_texture,
save_custom_skin,
remove_custom_skin,
type Skin,
type SkinModel,
type SkinTextureUrl,
unequip_skin,
} from '@/helpers/skins.ts'
const CAPE_LIST_MAX_HEIGHT = 334
const messages = defineMessages({
editSkinTitle: {
id: 'app.skins.modal.edit-title',
defaultMessage: 'Editing skin',
},
addSkinTitle: {
id: 'app.skins.modal.add-title',
defaultMessage: 'Adding a skin',
},
textureSection: {
id: 'app.skins.modal.texture-section',
defaultMessage: 'Texture',
},
replaceTextureButton: {
id: 'app.skins.modal.replace-texture-button',
defaultMessage: 'Replace texture',
},
armStyleSection: {
id: 'app.skins.modal.arm-style-section',
defaultMessage: 'Arm style',
},
wideArmStyle: {
id: 'app.skins.modal.arm-style-wide',
defaultMessage: 'Wide',
},
slimArmStyle: {
id: 'app.skins.modal.arm-style-slim',
defaultMessage: 'Slim',
},
capeSection: {
id: 'app.skins.modal.cape-section',
defaultMessage: 'Cape',
},
noCapeTooltip: {
id: 'app.skins.modal.no-cape-tooltip',
defaultMessage: 'No cape',
},
noneCapeOption: {
id: 'app.skins.modal.none-cape-option',
defaultMessage: 'None',
},
capeFallbackName: {
id: 'app.skins.modal.cape-fallback-name',
defaultMessage: 'Cape',
},
savingTooltip: {
id: 'app.skins.modal.saving-tooltip',
defaultMessage: 'Saving...',
},
uploadSkinFirstTooltip: {
id: 'app.skins.modal.upload-skin-first-tooltip',
defaultMessage: 'Upload a skin first!',
},
makeEditFirstTooltip: {
id: 'app.skins.modal.make-edit-first-tooltip',
defaultMessage: 'Make an edit to the skin first!',
},
addSkinButton: {
id: 'app.skins.modal.add-skin-button',
defaultMessage: 'Add skin',
},
saveSkinButton: {
id: 'app.skins.modal.save-skin-button',
defaultMessage: 'Save skin',
},
})
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const modal = useTemplateRef('modal')
const textureFileInput = useTemplateRef<HTMLInputElement>('textureFileInput')
const capeListRef = ref<HTMLElement | null>(null)
const capeListMaxHeight = ref(`${CAPE_LIST_MAX_HEIGHT}px`)
const selectCapeModal = useTemplateRef('selectCapeModal')
const mode = ref<'new' | 'edit'>('new')
const currentSkin = ref<Skin | null>(null)
const shouldRestoreModal = ref(false)
const isSaving = ref(false)
const uploadedTextureUrl = ref<SkinTextureUrl | null>(null)
@@ -243,49 +148,10 @@ const previewSkin = ref<string>('')
const variant = ref<SkinModel>('CLASSIC')
const selectedCape = ref<Cape | undefined>(undefined)
const props = defineProps<{ capes?: Cape[] }>()
const props = defineProps<{ capes?: Cape[]; defaultCape?: Cape }>()
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
const canEditTextureAndModel = computed(() => currentSkin.value?.source !== 'default')
const {
showTopFade: showCapeTopFade,
showBottomFade: showCapeBottomFade,
checkScrollState: checkCapeScrollState,
forceCheck: forceCapeScrollCheck,
} = useScrollIndicator(capeListRef)
let capeListLayoutFrame: number | null = null
function updateCapeListLayout() {
const capeList = capeListRef.value
const modalContent = capeList?.closest('[data-modal-content]') as HTMLElement | null
if (!capeList || !modalContent) {
capeListMaxHeight.value = `${CAPE_LIST_MAX_HEIGHT}px`
forceCapeScrollCheck()
return
}
const availableHeight =
modalContent.getBoundingClientRect().bottom - capeList.getBoundingClientRect().top
capeListMaxHeight.value = `${Math.min(
CAPE_LIST_MAX_HEIGHT,
Math.max(0, Math.floor(availableHeight)),
)}px`
nextTick(() => forceCapeScrollCheck())
}
function refreshCapeListLayout() {
if (capeListLayoutFrame !== null) {
cancelAnimationFrame(capeListLayoutFrame)
}
capeListLayoutFrame = requestAnimationFrame(() => {
capeListLayoutFrame = null
updateCapeListLayout()
})
}
const visibleCapeList = ref<Cape[]>([])
const sortedCapes = computed(() => {
return [...(props.capes || [])].sort((a, b) => {
@@ -295,6 +161,32 @@ const sortedCapes = computed(() => {
})
})
function initVisibleCapeList() {
if (!props.capes || props.capes.length === 0) {
visibleCapeList.value = []
return
}
if (visibleCapeList.value.length === 0) {
if (selectedCape.value) {
const otherCape = getSortedCapeExcluding(selectedCape.value.id)
visibleCapeList.value = otherCape ? [selectedCape.value, otherCape] : [selectedCape.value]
} else {
visibleCapeList.value = getSortedCapes(2)
}
}
}
function getSortedCapes(count: number): Cape[] {
if (!sortedCapes.value || sortedCapes.value.length === 0) return []
return sortedCapes.value.slice(0, count)
}
function getSortedCapeExcluding(excludeId: string): Cape | undefined {
if (!sortedCapes.value || sortedCapes.value.length <= 1) return undefined
return sortedCapes.value.find((cape) => cape.id !== excludeId)
}
async function loadPreviewSkin() {
if (uploadedTextureUrl.value) {
previewSkin.value = uploadedTextureUrl.value.normalized
@@ -326,13 +218,9 @@ const disableSave = computed(
)
const saveTooltip = computed(() => {
if (isSaving.value) return formatMessage(messages.savingTooltip)
if (mode.value === 'new' && !uploadedTextureUrl.value) {
return formatMessage(messages.uploadSkinFirstTooltip)
}
if (mode.value === 'edit' && !hasEdits.value) {
return formatMessage(messages.makeEditFirstTooltip)
}
if (isSaving.value) return 'Saving...'
if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!'
if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!'
return undefined
})
@@ -343,13 +231,11 @@ function resetState() {
previewSkin.value = ''
variant.value = 'CLASSIC'
selectedCape.value = undefined
visibleCapeList.value = []
shouldRestoreModal.value = false
isSaving.value = false
}
function handleModalHide() {
setTimeout(() => resetState(), 250)
}
async function show(e: MouseEvent, skin?: Skin) {
mode.value = skin ? 'edit' : 'new'
currentSkin.value = skin ?? null
@@ -360,11 +246,12 @@ async function show(e: MouseEvent, skin?: Skin) {
variant.value = 'CLASSIC'
selectedCape.value = undefined
}
visibleCapeList.value = []
initVisibleCapeList()
await loadPreviewSkin()
modal.value?.show(e)
nextTick(() => refreshCapeListLayout())
}
async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
@@ -373,54 +260,98 @@ async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
uploadedTextureUrl.value = skinTextureUrl
variant.value = await determineModelType(skinTextureUrl.original)
selectedCape.value = undefined
visibleCapeList.value = []
initVisibleCapeList()
await loadPreviewSkin()
modal.value?.show(e)
nextTick(() => refreshCapeListLayout())
}
async function setUploadedTexture(skinTextureUrl: SkinTextureUrl) {
async function restoreWithNewTexture(skinTextureUrl: SkinTextureUrl) {
uploadedTextureUrl.value = skinTextureUrl
await loadPreviewSkin()
nextTick(() => refreshCapeListLayout())
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
}
function hide() {
modal.value?.hide()
setTimeout(() => resetState(), 250)
}
function selectCape(cape: Cape | undefined) {
if (cape && selectedCape.value?.id !== cape.id) {
const isInVisibleList = visibleCapeList.value.some((c) => c.id === cape.id)
if (!isInVisibleList && visibleCapeList.value.length > 0) {
visibleCapeList.value.splice(0, 1, cape)
if (visibleCapeList.value.length > 1 && visibleCapeList.value[1].id === cape.id) {
const otherCape = getSortedCapeExcluding(cape.id)
if (otherCape) {
visibleCapeList.value.splice(1, 1, otherCape)
}
}
}
}
selectedCape.value = cape
}
function openTextureFileBrowser() {
textureFileInput.value?.click()
function handleCapeSelected(cape: Cape | undefined) {
selectCape(cape)
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
}
async function onTextureFileInputChange(e: Event) {
const files = (e.target as HTMLInputElement).files
const file = files?.[0]
if (!file) {
return
function handleCapeCancel() {
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
}
try {
const originalSkinTexUrl = `data:image/png;base64,${arrayBufferToBase64(
await file.arrayBuffer(),
)}`
const skinTextureNormalized = await normalize_skin_texture(originalSkinTexUrl)
await setUploadedTexture({
original: originalSkinTexUrl,
normalized: `data:image/png;base64,${arrayBufferToBase64(skinTextureNormalized)}`,
})
} catch (error) {
handleError(error)
} finally {
if (textureFileInput.value) {
textureFileInput.value.value = ''
}
function openSelectCapeModal(e: MouseEvent) {
if (!selectCapeModal.value) return
shouldRestoreModal.value = true
modal.value?.hide()
setTimeout(() => {
selectCapeModal.value?.show(
e,
currentSkin.value?.texture_key,
selectedCape.value,
previewSkin.value,
variant.value,
)
}, 0)
}
function openUploadSkinModal(e: MouseEvent) {
shouldRestoreModal.value = true
modal.value?.hide()
emit('open-upload-modal', e)
}
function restoreModal() {
if (shouldRestoreModal.value) {
setTimeout(() => {
const fakeEvent = new MouseEvent('click')
modal.value?.show(fakeEvent)
shouldRestoreModal.value = false
}, 500)
}
}
@@ -436,45 +367,17 @@ async function save() {
textureUrl = currentSkin.value!.texture
}
await unequip_skin()
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
if (mode.value === 'new') {
const addedSkin = await save_custom_skin(
{
texture_key: '',
variant: variant.value,
cape_id: selectedCape.value?.id,
texture: textureUrl,
source: 'custom',
is_equipped: false,
},
bytes,
variant.value,
selectedCape.value,
true,
)
emit('saved', {
applied: false,
skin: addedSkin,
})
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
emit('saved')
} else {
const updatedSkin = await save_custom_skin(
currentSkin.value!,
bytes,
variant.value,
selectedCape.value,
!!uploadedTextureUrl.value && textureUrl !== currentSkin.value?.texture,
)
if (currentSkin.value?.is_equipped) {
await equip_skin(updatedSkin)
}
emit('saved', {
applied: !!currentSkin.value?.is_equipped,
skin: updatedSkin,
previousSkin: currentSkin.value!,
})
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
await remove_custom_skin(currentSkin.value!)
emit('saved')
}
hide()
@@ -487,53 +390,28 @@ async function save() {
watch([uploadedTextureUrl, currentSkin], async () => {
await loadPreviewSkin()
refreshCapeListLayout()
})
watch(
() => props.capes,
() => {
nextTick(() => refreshCapeListLayout())
initVisibleCapeList()
},
{ immediate: true },
)
watch(
capeListRef,
(capeList, _, onCleanup) => {
if (!capeList) return
const modalContent = capeList.closest('[data-modal-content]')
const resizeObserver = new ResizeObserver(() => refreshCapeListLayout())
if (modalContent instanceof HTMLElement) {
resizeObserver.observe(modalContent)
}
window.addEventListener('resize', refreshCapeListLayout, { passive: true })
refreshCapeListLayout()
onCleanup(() => {
resizeObserver.disconnect()
window.removeEventListener('resize', refreshCapeListLayout)
if (capeListLayoutFrame !== null) {
cancelAnimationFrame(capeListLayoutFrame)
capeListLayoutFrame = null
}
})
},
{ flush: 'post' },
)
const emit = defineEmits<{
(event: 'saved', options: { applied: boolean; skin?: Skin; previousSkin?: Skin }): void
(event: 'saved'): void
(event: 'deleted', skin: Skin): void
(event: 'open-upload-modal', mouseEvent: MouseEvent): void
}>()
defineExpose({
show,
showNew,
restoreWithNewTexture,
hide,
shouldRestoreModal,
restoreModal,
})
</script>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { CheckIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
CapeButton,
CapeLikeTextButton,
ScrollablePanel,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { Cape, SkinModel } from '@/helpers/skins.ts'
const modal = useTemplateRef('modal')
const emit = defineEmits<{
(e: 'select', cape: Cape | undefined): void
(e: 'cancel'): void
}>()
const props = defineProps<{
capes: Cape[]
}>()
const sortedCapes = computed(() => {
return [...props.capes].sort((a, b) => {
const nameA = (a.name || '').toLowerCase()
const nameB = (b.name || '').toLowerCase()
return nameA.localeCompare(nameB)
})
})
const currentSkinId = ref<string | undefined>()
const currentSkinTexture = ref<string | undefined>()
const currentSkinVariant = ref<SkinModel>('CLASSIC')
const currentCapeTexture = computed<string | undefined>(() => currentCape.value?.texture)
const currentCape = ref<Cape | undefined>()
function show(
e: MouseEvent,
skinId?: string,
selected?: Cape,
skinTexture?: string,
variant?: SkinModel,
) {
currentSkinId.value = skinId
currentSkinTexture.value = skinTexture
currentSkinVariant.value = variant || 'CLASSIC'
currentCape.value = selected
modal.value?.show(e)
}
function select() {
emit('select', currentCape.value)
hide()
}
function hide() {
modal.value?.hide()
emit('cancel')
}
function updateSelectedCape(cape: Cape | undefined) {
currentCape.value = cape
}
function onModalHide() {
emit('cancel')
}
defineExpose({
show,
hide,
})
</script>
<template>
<ModalWrapper ref="modal" @on-hide="onModalHide">
<template #title>
<div class="flex flex-col">
<span class="text-lg font-extrabold text-heading">Change cape</span>
</div>
</template>
<div class="flex flex-col md:flex-row gap-6">
<div class="max-h-[25rem] h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer
v-if="currentSkinTexture"
:cape-src="currentCapeTexture"
:texture-src="currentSkinTexture"
:variant="currentSkinVariant"
:scale="1.4"
:fov="50"
:initial-rotation="Math.PI + Math.PI / 8"
class="h-full w-full"
/>
</div>
</div>
<div class="flex flex-col gap-4 w-full my-auto">
<ScrollablePanel class="max-h-[20rem] max-w-[30rem] mb-5 h-full">
<div class="flex flex-wrap gap-2 justify-center content-start overflow-y-auto h-full">
<CapeLikeTextButton
tooltip="No Cape"
:highlighted="!currentCape"
@click="updateSelectedCape(undefined)"
>
<template #icon>
<XIcon />
</template>
<span>None</span>
</CapeLikeTextButton>
<CapeButton
v-for="cape in sortedCapes"
:id="cape.id"
:key="cape.id"
:name="cape.name"
:texture="cape.texture"
:selected="currentCape?.id === cape.id"
@select="updateSelectedCape(cape)"
/>
</div>
</ScrollablePanel>
</div>
</div>
<div class="flex gap-2 items-center">
<ButtonStyled color="brand">
<button @click="select">
<CheckIcon />
Select
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>
@@ -0,0 +1,141 @@
<template>
<ModalWrapper ref="modal" @on-hide="hide(true)">
<template #title>
<span class="text-lg font-extrabold text-contrast"> Upload skin texture </span>
</template>
<div class="relative">
<div
class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative"
@click="triggerFileInput"
>
<p class="mx-auto mb-0 text-primary font-bold text-lg text-center flex items-center gap-2">
<UploadIcon /> Select skin texture file
</p>
<p class="mx-auto mt-0 text-secondary text-sm text-center">
Drag and drop or click here to browse
</p>
<input
ref="fileInput"
type="file"
accept="image/png"
class="hidden"
@change="handleInputFileChange"
/>
</div>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
import { UploadIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { onBeforeUnmount, ref, watch } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_dragged_skin_data } from '@/helpers/skins'
const { addNotification } = injectNotificationManager()
const modal = ref()
const fileInput = ref<HTMLInputElement>()
const unlisten = ref<() => void>()
const modalVisible = ref(false)
const emit = defineEmits<{
(e: 'uploaded', data: ArrayBuffer): void
(e: 'canceled'): void
}>()
function show(e?: MouseEvent) {
modal.value?.show(e)
modalVisible.value = true
setupDragDropListener()
}
function hide(emitCanceled = false) {
modal.value?.hide()
modalVisible.value = false
cleanupDragDropListener()
resetState()
if (emitCanceled) {
emit('canceled')
}
}
function resetState() {
if (fileInput.value) fileInput.value.value = ''
}
function triggerFileInput() {
fileInput.value?.click()
}
async function handleInputFileChange(e: Event) {
const files = (e.target as HTMLInputElement).files
if (!files || files.length === 0) {
return
}
const file = files[0]
const buffer = await file.arrayBuffer()
await processData(buffer)
}
async function setupDragDropListener() {
try {
if (modalVisible.value) {
await cleanupDragDropListener()
unlisten.value = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop') {
return
}
if (!event.payload.paths || event.payload.paths.length === 0) {
return
}
const filePath = event.payload.paths[0]
try {
const data = await get_dragged_skin_data(filePath)
await processData(data.buffer)
} catch (error) {
addNotification({
title: 'Error processing file',
text: error instanceof Error ? error.message : 'Failed to read the dropped file.',
type: 'error',
})
}
})
}
} catch (error) {
console.error('Failed to set up drag and drop listener:', error)
}
}
async function cleanupDragDropListener() {
if (unlisten.value) {
unlisten.value()
unlisten.value = undefined
}
}
async function processData(buffer: ArrayBuffer) {
emit('uploaded', buffer)
hide()
}
watch(modalVisible, (isVisible) => {
if (isVisible) {
setupDragDropListener()
} else {
cleanupDragDropListener()
}
})
onBeforeUnmount(() => {
cleanupDragDropListener()
})
defineExpose({ show, hide })
</script>
@@ -1,578 +0,0 @@
<script setup lang="ts">
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon, UnknownIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
commonMessages,
defineMessages,
SkinButton,
SkinLikeTextButton,
useScrollViewport,
useVIntl,
} from '@modrinth/ui'
import { useElementSize, useWindowSize } from '@vueuse/core'
import { Tooltip } from 'floating-vue'
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import Draggable from 'vuedraggable'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins.ts'
type SkinSectionKind = 'saved' | 'default'
type SkinLikeTextButtonExpose = {
getRootElement: () => HTMLElement | null | undefined
}
type AddSkinButtonRef = SkinLikeTextButtonExpose | SkinLikeTextButtonExpose[]
interface DefaultSkinSection {
title: string
infoTooltip?: string
skins: Skin[]
}
interface SkinSection {
key: string
title: string
kind: SkinSectionKind
infoTooltip?: string
skins: Skin[]
}
interface VirtualSkinSection {
section: SkinSection
top: number
index: number
}
const SKIN_CARD_ASPECT_WIDTH = 31
const SKIN_CARD_ASPECT_HEIGHT = 40
const SKIN_GRID_GAP = 12
const SKIN_SECTION_FIRST_SPACING = 4
const SKIN_SECTION_SPACING = 24
const SKIN_SECTION_HEADER_HEIGHT = 28
const SKIN_SECTION_CONTENT_SPACING = 8
const SKIN_SECTION_OVERSCAN = 900
const FALLBACK_CARD_WIDTH = 220
const messages = defineMessages({
savedSkinsSection: {
id: 'app.skins.section.saved-skins',
defaultMessage: 'Saved skins',
},
addSkinButton: {
id: 'app.skins.add-button',
defaultMessage: 'Add skin',
},
dragAndDropSubtitle: {
id: 'app.skins.add-button.drag-and-drop',
defaultMessage: 'Drag and drop',
},
editSkinButton: {
id: 'app.skins.edit-button',
defaultMessage: 'Edit skin',
},
deleteSkinButton: {
id: 'app.skins.delete-button',
defaultMessage: 'Delete skin',
},
})
const props = defineProps<{
savedSkins: Skin[]
defaultSkinSections: DefaultSkinSection[]
getBakedSkinTextures: (skin: Skin) => RenderResult | undefined
isSkinSelected: (skin: Skin) => boolean
isSkinActive: (skin: Skin) => boolean
isAddSkinButtonDragActive: boolean
readOnly?: boolean
}>()
const emit = defineEmits<{
select: [skin: Skin]
edit: [skin: Skin, event: MouseEvent]
delete: [skin: Skin]
'reorder-saved-skins': [skins: Skin[]]
'add-skin': []
'add-skin-dragenter': [event: DragEvent]
'add-skin-dragover': [event: DragEvent]
'add-skin-dragleave': [event: DragEvent]
'add-skin-drop': [event: DragEvent]
}>()
const addSkinButton = useTemplateRef<AddSkinButtonRef>('addSkinButton')
const { formatMessage } = useVIntl()
const { listContainer, relativeScrollTop, scrollContainer, viewportHeight } = useScrollViewport()
const openSectionKeys = ref<Set<string>>(new Set())
const hasSettledInitialLayout = ref(false)
const knownSectionKeys = new Set<string>()
let enableLayoutTransitionsFrame: number | null = null
let isEnableLayoutTransitionsScheduled = false
let isUnmounted = false
const { width: listWidth } = useElementSize(listContainer)
const { width: windowWidth } = useWindowSize()
const columnCount = computed(() => {
if (windowWidth.value >= 2050) {
return 6
}
if (windowWidth.value >= 1750) {
return 5
}
if (windowWidth.value >= 1300) {
return 4
}
return 3
})
const cardWidth = computed(() => {
if (listWidth.value <= 0) {
return FALLBACK_CARD_WIDTH
}
const gapsWidth = (columnCount.value - 1) * SKIN_GRID_GAP
return Math.max(0, (listWidth.value - gapsWidth) / columnCount.value)
})
const cardHeight = computed(
() => (cardWidth.value * SKIN_CARD_ASPECT_HEIGHT) / SKIN_CARD_ASPECT_WIDTH,
)
const sections = computed<SkinSection[]>(() => [
{
key: 'saved-skins',
title: formatMessage(messages.savedSkinsSection),
kind: 'saved',
skins: props.savedSkins,
},
...props.defaultSkinSections.map((section) => ({
key: defaultSkinSectionKey(section.title),
title: section.title,
kind: 'default' as const,
infoTooltip: section.infoTooltip,
skins: section.skins,
})),
])
const draggableSavedSkins = ref<Skin[]>([])
const isDraggingSavedSkin = ref(false)
const canReorderSavedSkins = computed(() => draggableSavedSkins.value.length > 1)
const fixedSavedSkins = computed(() => props.savedSkins.filter((skin) => !canDragSavedSkin(skin)))
const sectionLayouts = computed(() => {
const layouts: Array<{ section: SkinSection; top: number; height: number; index: number }> = []
let top = 0
sections.value.forEach((section, index) => {
const height = getSectionHeightEstimate(section, index)
layouts.push({ section, top, height, index })
top += height
})
return layouts
})
const totalHeight = computed(() => {
const lastSection = sectionLayouts.value[sectionLayouts.value.length - 1]
return lastSection ? lastSection.top + lastSection.height : 0
})
const visibleSections = computed<VirtualSkinSection[]>(() => {
if (!listContainer.value || !scrollContainer.value) {
return sectionLayouts.value.slice(0, 4)
}
const viewportStart = Math.max(0, relativeScrollTop.value - SKIN_SECTION_OVERSCAN)
const viewportEnd = relativeScrollTop.value + viewportHeight.value + SKIN_SECTION_OVERSCAN
return sectionLayouts.value
.filter((layout) => layout.top + layout.height >= viewportStart && layout.top <= viewportEnd)
.map(({ section, top, index }) => ({ section, top, index }))
})
watch(
sections,
(nextSections) => {
const sectionKeys = new Set(nextSections.map((section) => section.key))
const openKeys = new Set(openSectionKeys.value)
for (const section of nextSections) {
if (!knownSectionKeys.has(section.key)) {
knownSectionKeys.add(section.key)
openKeys.add(section.key)
}
}
for (const key of knownSectionKeys) {
if (!sectionKeys.has(key)) {
knownSectionKeys.delete(key)
openKeys.delete(key)
}
}
openSectionKeys.value = openKeys
},
{ immediate: true },
)
watch(
() => props.savedSkins,
(nextSkins) => {
if (isDraggingSavedSkin.value) {
return
}
draggableSavedSkins.value = nextSkins.filter(canDragSavedSkin)
},
{ immediate: true },
)
watch(
listWidth,
(width) => {
if (
typeof window === 'undefined' ||
width <= 0 ||
hasSettledInitialLayout.value ||
isEnableLayoutTransitionsScheduled
) {
return
}
isEnableLayoutTransitionsScheduled = true
void nextTick(() => {
if (isUnmounted) return
enableLayoutTransitionsFrame = window.requestAnimationFrame(() => {
if (isUnmounted) return
enableLayoutTransitionsFrame = window.requestAnimationFrame(() => {
if (isUnmounted) return
hasSettledInitialLayout.value = true
enableLayoutTransitionsFrame = null
isEnableLayoutTransitionsScheduled = false
})
})
})
},
{ immediate: true },
)
onUnmounted(() => {
isUnmounted = true
if (enableLayoutTransitionsFrame !== null) {
window.cancelAnimationFrame(enableLayoutTransitionsFrame)
}
})
function defaultSkinSectionKey(title: string) {
return `default-skins-${title}`
}
function skinKey(skin: Skin, prefix: string) {
return `${prefix}-${skin.source}-${skin.texture_key}-${skin.variant}-${skin.cape_id ?? 'no-cape'}`
}
function savedSkinKey(skin: Skin) {
return skinKey(skin, 'saved-skin')
}
function canDragSavedSkin(skin: Skin) {
return skin.source === 'custom' || skin.source === 'custom_external'
}
function doSkinOrdersMatch(firstSkins: Skin[], secondSkins: Skin[]) {
const draggableSecondSkins = secondSkins.filter(canDragSavedSkin)
return (
firstSkins.length === draggableSecondSkins.length &&
firstSkins.every(
(skin, index) => savedSkinKey(skin) === savedSkinKey(draggableSecondSkins[index]),
)
)
}
function onSavedSkinDragStart() {
isDraggingSavedSkin.value = true
}
function onSavedSkinDragEnd() {
isDraggingSavedSkin.value = false
if (doSkinOrdersMatch(draggableSavedSkins.value, props.savedSkins)) {
draggableSavedSkins.value = props.savedSkins.filter(canDragSavedSkin)
return
}
emit('reorder-saved-skins', [...draggableSavedSkins.value])
}
function isSectionOpen(key: string) {
return openSectionKeys.value.has(key)
}
function setSectionOpen(key: string, open: boolean) {
const openKeys = new Set(openSectionKeys.value)
if (open) {
openKeys.add(key)
} else {
openKeys.delete(key)
}
openSectionKeys.value = openKeys
}
function getSectionHeightEstimate(section: SkinSection, index: number) {
const spacing = index === 0 ? SKIN_SECTION_FIRST_SPACING : SKIN_SECTION_SPACING
if (!isSectionOpen(section.key)) {
return spacing + SKIN_SECTION_HEADER_HEIGHT
}
const cardCount = section.kind === 'saved' ? section.skins.length + 1 : section.skins.length
const rowCount = Math.ceil(cardCount / columnCount.value)
const gridHeight = rowCount * cardHeight.value + Math.max(0, rowCount - 1) * SKIN_GRID_GAP
return spacing + SKIN_SECTION_HEADER_HEIGHT + SKIN_SECTION_CONTENT_SPACING + gridHeight
}
function getAddSkinButtonElement() {
const button = Array.isArray(addSkinButton.value)
? addSkinButton.value.find((candidate) => candidate.getRootElement())
: addSkinButton.value
return button?.getRootElement()
}
defineExpose({ getAddSkinButtonElement })
</script>
<template>
<div
ref="listContainer"
class="relative w-full"
:style="{ height: `${totalHeight}px`, overflowAnchor: 'none' }"
>
<div
v-for="{ section, top, index } in visibleSections"
:key="section.key"
class="absolute inset-x-0"
:class="[
index === 0 ? 'pt-1' : 'pt-6',
hasSettledInitialLayout
? 'transition-transform duration-300 ease-in-out will-change-transform motion-reduce:transition-none'
: '',
]"
:style="{ transform: `translateY(${top}px)` }"
>
<Accordion
button-class="group flex w-full items-center gap-[6px] bg-transparent m-0 p-0 border-none cursor-pointer text-left"
content-class="pt-2"
:open-by-default="isSectionOpen(section.key)"
@on-open="setSectionOpen(section.key, true)"
@on-close="setSectionOpen(section.key, false)"
>
<template #title>
{{ section.title }}
</template>
<template #button="{ open }">
<DropdownIcon
class="size-6 shrink-0 text-primary transition-transform duration-300"
:class="{ 'rotate-180': open }"
/>
<span class="min-w-0 text-xl font-semibold leading-7 text-primary">
{{ section.title }}
</span>
<Tooltip
v-if="section.infoTooltip"
theme="dismissable-prompt"
placement="top"
:triggers="['hover', 'focus']"
>
<span
class="inline-flex size-6 shrink-0 items-center justify-center text-secondary transition-colors group-hover:text-primary"
@click.stop
>
<UnknownIcon class="size-5" />
</span>
<template #popper>
<p class="m-0 max-w-96 text-wrap text-sm font-medium leading-tight">
{{ section.infoTooltip }}
</p>
</template>
</Tooltip>
</template>
<Draggable
v-if="section.kind === 'saved'"
:list="draggableSavedSkins"
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
:item-key="savedSkinKey"
:disabled="readOnly || !canReorderSavedSkins"
:animation="250"
:swap-threshold="1"
:invert-swap="false"
:force-fallback="true"
:fallback-on-body="true"
:fallback-tolerance="4"
ghost-class="skin-reorder-ghost"
chosen-class="skin-reorder-chosen"
drag-class="skin-reorder-drag"
fallback-class="skin-reorder-fallback"
@start="onSavedSkinDragStart"
@end="onSavedSkinDragEnd"
>
<template #header>
<SkinLikeTextButton
ref="addSkinButton"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
dropzone
:disabled="readOnly"
:drag-active="!readOnly && isAddSkinButtonDragActive"
@click="emit('add-skin')"
@dragenter="emit('add-skin-dragenter', $event)"
@dragover="emit('add-skin-dragover', $event)"
@dragleave="emit('add-skin-dragleave', $event)"
@drop="emit('add-skin-drop', $event)"
>
<template #icon>
<PlusIcon class="size-8" />
</template>
{{ formatMessage(messages.addSkinButton) }}
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
</SkinLikeTextButton>
</template>
<template #item="{ element: skin }">
<div
:key="savedSkinKey(skin)"
class="relative aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
>
<SkinButton
class="h-full w-full min-w-0 box-border rounded-[20px]"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
:disabled="readOnly"
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)"
>
<template v-if="!readOnly" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
<button
v-tooltip="formatMessage(messages.deleteSkinButton)"
:aria-label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</button>
</ButtonStyled>
</template>
</SkinButton>
</div>
</template>
<template #footer>
<div
v-for="skin in fixedSavedSkins"
:key="savedSkinKey(skin)"
class="relative aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
>
<SkinButton
class="h-full w-full min-w-0 box-border rounded-[20px]"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
:disabled="readOnly"
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)"
>
<template v-if="!readOnly" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
<button
v-tooltip="formatMessage(messages.deleteSkinButton)"
:aria-label="formatMessage(messages.deleteSkinButton)"
class="!rounded-[100%] pointer-events-auto"
@click.stop="emit('delete', skin)"
>
<TrashIcon />
</button>
</ButtonStyled>
</template>
</SkinButton>
</div>
</template>
</Draggable>
<div
v-else
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
>
<SkinButton
v-for="skin in section.skins"
:key="skinKey(skin, section.key)"
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:selected="isSkinSelected(skin)"
:active="isSkinActive(skin)"
:tooltip="skin.name"
:disabled="readOnly"
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)"
>
<template #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
class="pointer-events-auto"
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
>
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
</template>
</SkinButton>
</div>
</Accordion>
</div>
</div>
</template>
<style scoped>
:global(.skin-reorder-ghost) {
opacity: 0.35;
}
:global(.skin-reorder-drag) {
cursor: grabbing;
}
:global(.skin-reorder-fallback) {
opacity: 0.9;
pointer-events: none;
}
</style>
@@ -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',
@@ -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>

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