mirror of
https://github.com/modrinth/code.git
synced 2026-08-29 02:54:51 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1e6f5c06a | ||
|
|
0d7839404b | ||
|
|
671e3535a9 | ||
|
|
e4f02c399d | ||
|
|
c3968e3d0b | ||
|
|
f140d0c212 | ||
|
|
fa6c80767c | ||
|
|
13a6fafed0 |
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: api-module
|
||||
description: Add an API endpoint module to packages/api-client from an OpenAPI schema. Use for new backend endpoints, API client modules, or tasks that provide an OpenAPI schema.
|
||||
---
|
||||
|
||||
# Add an API Module
|
||||
|
||||
Read the applicable `AGENTS.md` files before you edit code.
|
||||
|
||||
Read [the API module standard](../../../standards/frontend/ADDING_API_MODULES.md) in full.
|
||||
|
||||
1. Identify the OpenAPI schema from the request. If more than one schema is possible, ask the user to select one.
|
||||
2. Read the schema. Identify each endpoint, HTTP method, request type, response type, and path parameter.
|
||||
3. Get the service and version from the URL prefix. For example, map `/v3/projects` to `labrinth/v3/`.
|
||||
4. Define the API types in `types.ts`. Make each type match the schema exactly.
|
||||
5. Do not change, rename, or remove API fields.
|
||||
6. Make a module class that extends `AbstractModule`. Implement each endpoint with `this.client.request()` or `this.client.upload()`.
|
||||
7. Use the request-option pattern from the standard. Do not call `$fetch`, `fetch`, or another HTTP client directly.
|
||||
8. Add the module to `MODULE_REGISTRY` so the client can instantiate it.
|
||||
9. Export new service types from the applicable barrel `index.ts`.
|
||||
10. Check the module paths, registry key, public type exports, and endpoint types.
|
||||
|
||||
Run only the checks that the user or the applicable `AGENTS.md` permits.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Add API Module"
|
||||
short_description: "Add typed API client modules from OpenAPI"
|
||||
default_prompt: "Use $api-module to add an API client module from this OpenAPI schema."
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: cross-platform-pages
|
||||
description: Convert a page to the shared Modrinth page system for the website and desktop app. Use for shared layouts, wrapped layouts, or platform dependency-injection contracts.
|
||||
---
|
||||
|
||||
# Convert a Cross-Platform Page
|
||||
|
||||
Read the applicable `AGENTS.md` files before you edit code.
|
||||
|
||||
Read these standards in full:
|
||||
|
||||
- [Cross-platform pages](../../../standards/frontend/CROSS_PLATFORM_PAGES.md)
|
||||
- [Dependency injection](../../../standards/frontend/DEPENDENCY_INJECTION.md)
|
||||
|
||||
1. Identify the target page from the request.
|
||||
2. Read the page and its route shell. Identify data sources, mutations, navigation, and platform APIs.
|
||||
3. Use a wrapped layout when both platforms use the same API source and page logic.
|
||||
4. Use a shared layout when platform data or operations have different implementations.
|
||||
|
||||
For a shared layout:
|
||||
|
||||
1. Define a provider contract for all platform operations.
|
||||
2. Put common UI and state logic in the shared layout.
|
||||
3. Put reusable search, filter, and selection logic in local composables.
|
||||
4. Implement the contract in `apps/frontend/` and `apps/app-frontend/`.
|
||||
5. Use optional contract fields only for capabilities that are not available on both platforms.
|
||||
|
||||
For a wrapped layout:
|
||||
|
||||
1. Move the page to `packages/ui/src/layouts/wrapped/` and preserve its route structure.
|
||||
2. Replace platform-only imports with common utilities or provider calls.
|
||||
3. Make each frontend route shell render the wrapped component.
|
||||
4. Match primary query options in both route shells when the layout uses `ReadyTransition` and `useReadyState`.
|
||||
5. Prefetch these queries with `ensureQueryData`, as the standard specifies.
|
||||
|
||||
Check that both route shells resolve their imports. Check that all required provider fields have implementations.
|
||||
|
||||
Run only the checks that the user or the applicable `AGENTS.md` permits.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Convert Cross-Platform Page"
|
||||
short_description: "Share pages across the web and desktop app"
|
||||
default_prompt: "Use $cross-platform-pages to convert this page for the website and desktop app."
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: figma-mcp
|
||||
description: Convert a Figma design into a Modrinth Vue page or component. Use when a request provides a Figma URL or asks to implement a Figma layout.
|
||||
---
|
||||
|
||||
# Implement a Figma Design
|
||||
|
||||
Read the applicable `AGENTS.md` files before you edit code.
|
||||
|
||||
Read `packages/ui/AGENTS.md` in full.
|
||||
|
||||
1. Load the available Figma design-to-code instructions and follow the MCP tool guidance.
|
||||
2. Call `get_design_context` first with `clientLanguages: "typescript,html,css"` and `clientFrameworks: "vue"`.
|
||||
3. Treat the result as reference code and adapt it to the Modrinth codebase.
|
||||
4. Map Figma color variables to the applicable `surface-*` and `text-*` tokens. Do not use aliased Figma names directly.
|
||||
5. Reuse applicable components from `packages/ui/src/components/` before creating new ones. Also refer to `standards/frontend/COMPONENT_STRUCTURE.md`
|
||||
6. Read `packages/assets/styles/variables.scss` when Figma does not supply a required token.
|
||||
7. Use exact spacing values from the design.
|
||||
8. Implement the result as a Vue SFC with Tailwind classes and the existing component library.
|
||||
|
||||
Run only the checks that the user or the applicable `AGENTS.md` permits.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Implement Figma Design"
|
||||
short_description: "Build Modrinth Vue UI from Figma designs"
|
||||
default_prompt: "Use $figma-mcp to implement this Figma design as a Modrinth Vue component."
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
name: i18n-pass
|
||||
description: Convert hard-coded English text in changed Vue components to the @modrinth/ui localization system. Use for an i18n pass, untranslated-string review, pull request, or component migration.
|
||||
---
|
||||
|
||||
# Do an Internationalization Pass
|
||||
|
||||
Read the applicable `AGENTS.md` files before you edit code.
|
||||
|
||||
Read [the internationalization standard](../../../standards/frontend/INTERNATIONALIZATION.md) in full.
|
||||
|
||||
1. Identify the scope from the request.
|
||||
2. For a pull request, use `gh pr diff <number>` to identify changed files.
|
||||
3. For a file path, inspect that file.
|
||||
4. When the request gives no scope, inspect the current uncommitted diff.
|
||||
5. Limit the pass to changed `.vue` files.
|
||||
6. Find user-visible text in templates and scripts.
|
||||
|
||||
Check inner text, `alt`, `placeholder`, `aria-label`, buttons, tooltips, notifications, dropdown labels, and error messages.
|
||||
|
||||
Do not change dynamic expressions, HTML tag names, CSS classes, internal identifiers, or log messages.
|
||||
|
||||
1. Define stable message IDs with `defineMessage` or `defineMessages`.
|
||||
2. Replace simple text with `formatMessage()` calls.
|
||||
3. Use `<IntlFormatted>` for text that contains links or markup.
|
||||
4. Use ICU selections and plurals when grammar depends on a value.
|
||||
5. Add a space before `}}` when an ICU placeholder ends at the Vue delimiter.
|
||||
6. Do not change component logic, layout, or reactivity.
|
||||
7. Do not edit localization JSON files. The user maintains those files.
|
||||
8. Check the changed templates again for hard-coded English text.
|
||||
|
||||
Run only the checks that the user or the applicable `AGENTS.md` permits.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Run Internationalization Pass"
|
||||
short_description: "Localize user-visible text in Vue files"
|
||||
default_prompt: "Use $i18n-pass to localize the user-visible text in these changed Vue files."
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: review-changelog
|
||||
description: Review the latest packages/blog/changelog.ts entry against the Modrinth changelog standard. Use before a pull request or when asked to review or lint a changelog entry.
|
||||
---
|
||||
|
||||
# Review a Changelog Entry
|
||||
|
||||
Read [the changelog standard](../../../standards/maintaining/CHANGELOG.md) in full before the review.
|
||||
|
||||
1. Open `packages/blog/changelog.ts`.
|
||||
2. Find the first entry in the `VERSIONS` array.
|
||||
3. If the request names `web`, `hosting`, or `app`, review the latest entry for that product.
|
||||
4. Otherwise, review the latest entry and all adjacent entries with the same date.
|
||||
|
||||
Check the entry structure:
|
||||
|
||||
- `date` contains a valid ISO 8601 timestamp.
|
||||
- `product` is `web`, `hosting`, or `app`.
|
||||
- An `app` entry has a `version` value.
|
||||
- A `web` or `hosting` entry does not have a `version` value.
|
||||
- Standard headings are `## Added`, `## Changed`, `## Fixed`, and `## Security`.
|
||||
- A featured release can use a linked heading.
|
||||
- Flag the legacy `## Improvements` heading.
|
||||
|
||||
Check each bullet:
|
||||
|
||||
- The voice and tense agree with the section.
|
||||
- The first verb agrees with the section.
|
||||
- The bullet describes user-visible behavior, not implementation.
|
||||
- The bullet identifies the applicable page, tab, modal, or feature.
|
||||
- The bullet contains one sentence, uses sentence case, and ends with a period.
|
||||
- Product and UI names use the public labels.
|
||||
- The bullet does not contain filler, vague intensifiers, apologies, or internal references.
|
||||
- The bullet is not a duplicate detail of a larger listed change.
|
||||
|
||||
Group findings by entry. For each finding, show the original bullet and a proposed replacement.
|
||||
|
||||
If the entry has no findings, state this result. Do not edit the changelog unless the user asks you to apply fixes.
|
||||
|
||||
When the user asks for fixes, preserve tab indentation and template-literal formatting.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Review Changelog"
|
||||
short_description: "Review changelog entries for style problems"
|
||||
default_prompt: "Use $review-changelog to review the latest changelog entry."
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
name: tanstack-query
|
||||
description: Convert Vue server-state code to TanStack Query. Use for useQuery, useMutation, cache invalidation, optimistic updates, or replacement of useAsyncData and manual ref patterns.
|
||||
---
|
||||
|
||||
# Convert Data Code to TanStack Query
|
||||
|
||||
Read the applicable `AGENTS.md` files before you edit code.
|
||||
|
||||
Read [the TanStack Query standard](../../../standards/frontend/FETCHING_DATA.md) in full.
|
||||
|
||||
1. Identify the target file from the request.
|
||||
2. Find `useAsyncData`, `useFetch`, manual API refs, and fetch calls in `onMounted`.
|
||||
3. Identify mutations that use manual loading, error, or result refs.
|
||||
|
||||
For queries:
|
||||
|
||||
1. Replace manual fetch logic with `useQuery`.
|
||||
2. Get `api-client` with `injectModrinthClient()`.
|
||||
3. Use a hierarchical query key with the resource, qualifier, and parameters.
|
||||
4. Use a computed query key for reactive parameters.
|
||||
5. Use a computed `enabled` option when the query depends on other data.
|
||||
6. Use a shared query-option factory when multiple components use the query.
|
||||
|
||||
For mutations:
|
||||
|
||||
1. Replace manual mutation state with `useMutation`.
|
||||
2. Invalidate or update related query data after success.
|
||||
3. Use an optimistic update only when the UI needs an immediate response.
|
||||
4. Cancel the applicable query and save its prior data before an optimistic update.
|
||||
5. Restore the prior data after an error. Invalidate the query after settlement.
|
||||
|
||||
Remove manual loading and error refs that TanStack Query replaces. Remove obsolete `onMounted` fetch calls.
|
||||
|
||||
Keep Nuxt SSR behavior. Match route-shell prefetch options when `ReadyTransition` and `useReadyState` depend on the query.
|
||||
|
||||
Check query keys, invalidation prefixes, reactive values, and rollback data.
|
||||
|
||||
Run only the checks that the user or the applicable `AGENTS.md` permits.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Migrate to TanStack Query"
|
||||
short_description: "Migrate Vue server state to TanStack Query"
|
||||
default_prompt: "Use $tanstack-query to migrate this Vue component to TanStack Query."
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
|
||||
[target."cfg(windows)"]
|
||||
[target.'cfg(windows)']
|
||||
rustflags = ["-C", "link-args=/STACK:16777220"]
|
||||
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: api-module
|
||||
description: Add a new API endpoint module to packages/api-client from an OpenAPI schema. Use when adding new backend endpoints, creating API client modules, or when an openapi.yml is provided.
|
||||
argument-hint: <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`.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: cross-platform-pages
|
||||
description: Convert a page to the cross-platform page system so it works in both the website and the desktop app. Use when moving a page into packages/ui/src/layouts/, creating shared or wrapped layouts, or setting up DI contracts for platform abstraction.
|
||||
argument-hint: <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.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: figma-mcp
|
||||
description: Use the Figma MCP server to translate a Figma design into a Vue page or component layout. Use when the user provides a Figma URL, asks to implement a design, or wants to draft a page layout from Figma.
|
||||
argument-hint: <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.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: i18n-pass
|
||||
description: Perform an i18n localization pass on changed files or a pull request, converting hard-coded English strings to the @modrinth/ui i18n system. Use when internationalizing a set of changes, reviewing a PR for untranslated strings, or converting a specific component.
|
||||
argument-hint: [file-path-or-pr-number]
|
||||
---
|
||||
|
||||
Refer to the standard: @standards/frontend/INTERNATIONALIZATION.md
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Identify the scope of changes:**
|
||||
- If `$ARGUMENTS` is a PR number, run `gh pr diff $ARGUMENTS` to get the changed files.
|
||||
- If `$ARGUMENTS` is a file path, use that directly.
|
||||
- If no argument, check `git diff` for uncommitted changes.
|
||||
2. **Read the standard above** for the message definition pattern, ICU format rules, and `IntlFormatted` usage.
|
||||
3. **Filter to Vue SFCs** — only `.vue` files need i18n passes. Skip non-component files.
|
||||
4. **For each file, scan for hard-coded strings:**
|
||||
- `<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.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
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.
|
||||
@@ -81,6 +81,6 @@ Please follow these rules precisely:
|
||||
Use existing patterns from our codebase:
|
||||
|
||||
- Variables/plurals: see `apps/frontend/src/pages/frog.vue`
|
||||
- Rich-text link tags: see `apps/frontend/src/error.vue`
|
||||
- Rich-text link tags: see `apps/frontend/src/pages/auth/welcome.vue` and `apps/frontend/src/error.vue`
|
||||
|
||||
When you finish, there should be no hard-coded English strings left in the template—everything comes from `formatMessage` or `<IntlFormatted>`.
|
||||
|
||||
@@ -34,13 +34,13 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract PR Number and Commit ID
|
||||
id: extract-pr-info
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const githubRef = process.env.GITHUB_REF;
|
||||
@@ -90,26 +90,20 @@ runs:
|
||||
- name: Print PR Branch
|
||||
if: env.CAN_SKIP_CHECKS != 'false'
|
||||
shell: bash
|
||||
env:
|
||||
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
|
||||
run: |
|
||||
echo "PR Branch: $PR_BRANCH"
|
||||
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
|
||||
env:
|
||||
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
|
||||
COMMIT_ID: ${{ steps.extract-pr-info.outputs.commitId }}
|
||||
TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }}
|
||||
run: |
|
||||
# Get the branch name from previous steps
|
||||
branch_name="origin/$PR_BRANCH"
|
||||
commit_id="$COMMIT_ID"
|
||||
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 -qF "$branch_name"; then
|
||||
echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with $TARGET_BRANCH."
|
||||
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"
|
||||
@@ -118,10 +112,8 @@ runs:
|
||||
- name: Compare PR Branch with Current Branch
|
||||
if: env.CAN_SKIP_CHECKS != 'false'
|
||||
shell: bash
|
||||
env:
|
||||
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
|
||||
run: |
|
||||
if git diff --quiet "origin/$PR_BRANCH"; then
|
||||
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."
|
||||
@@ -130,11 +122,9 @@ runs:
|
||||
|
||||
- name: Compute/publish skip result
|
||||
id: passed-checks
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
SECRET: ${{ inputs.secret }}
|
||||
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
|
||||
TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }}
|
||||
with:
|
||||
github-token: ${{ inputs.secret != '' && inputs.secret || github.token }}
|
||||
script: |
|
||||
@@ -154,7 +144,7 @@ runs:
|
||||
const { data: branchProtection } = await github.rest.repos.getBranchProtection({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
branch: process.env.TARGET_BRANCH,
|
||||
branch: "${{ steps.extract-pr-info.outputs.targetBranchName }}",
|
||||
});
|
||||
const requiredCheckNames = branchProtection.required_status_checks.contexts;
|
||||
console.log(`requiredCheckNames = ${requiredCheckNames}`);
|
||||
@@ -162,7 +152,7 @@ runs:
|
||||
const { data: checks } = await github.rest.checks.listForRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: `refs/heads/${process.env.PR_BRANCH}`,
|
||||
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};`)}`);
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
BUMP_TYPE: minor
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
- name: Setup Node
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
registry-url: https://registry.npmjs.org
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
- name: Restore pnpm cache
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.store-path }}
|
||||
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@d07a454dad7609a92316b57b23c9ccfd4f59af66 # 0.13.1
|
||||
uses: styfle/cancel-workflow-action@85880fa0301c86cca9da44039ee3bb12d3bedbfa # 0.12.1
|
||||
with:
|
||||
workflow_id: all
|
||||
access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Post or update changelog comment
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -12,14 +12,14 @@ jobs:
|
||||
typos:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: crate-ci/typos@6ac2ebd1b93eade61faf7e12688ad87a073fea59 # v1.46.0
|
||||
|
||||
# see <https://github.com/influxdata/datafusion-udf-wasm/pull/275>
|
||||
tombi:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
|
||||
with:
|
||||
tool: tombi
|
||||
|
||||
@@ -6,15 +6,14 @@ on:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
SQLX_OFFLINE: true
|
||||
|
||||
jobs:
|
||||
shear:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
|
||||
- uses: cargo-bins/cargo-binstall@ead08b90bd7b2e6d81963fb9cf0b7239f66d5db4 # v1.21.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
- uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
|
||||
- run: cargo binstall --no-confirm cargo-shear
|
||||
- run: cargo shear
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
name: Deploy Command
|
||||
run-name: Deploy PR #${{ github.event.client_payload.github.payload.issue.number }}
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [deploy-command]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: ${{ github.event.client_payload.pull_request.head.repo.full_name == github.repository }}
|
||||
uses: SparkUniverse/workflows/.github/workflows/deploy-command.yaml@main
|
||||
secrets:
|
||||
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
|
||||
CMD_DISPATCH_GH_TOKEN: ${{ secrets.SLASH_CMD_GH_TOKEN }}
|
||||
with:
|
||||
application-set: labrinth
|
||||
build-workflow: labrinth-build.yml
|
||||
branch: ${{ github.event.client_payload.pull_request.head.ref }}
|
||||
issue-number: ${{ github.event.client_payload.github.payload.issue.number }}
|
||||
head-sha: ${{ github.event.client_payload.pull_request.head.sha }}
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check if workflow runs on an internal branch
|
||||
id: check-internal
|
||||
@@ -72,21 +72,49 @@ jobs:
|
||||
fi
|
||||
|
||||
docker:
|
||||
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-medium-amd64' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
env:
|
||||
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
|
||||
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
|
||||
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
|
||||
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
|
||||
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
|
||||
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
|
||||
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
|
||||
needs: [skip-if-clean]
|
||||
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
|
||||
with:
|
||||
rustflags: ''
|
||||
cache: false
|
||||
|
||||
- name: Setup mold
|
||||
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
|
||||
- name: Cache Cargo registry and index
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
~/.cargo/bin
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Mount sccache disk cache
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
|
||||
with:
|
||||
key: ${{ github.repository }}-daedalus-sccache
|
||||
path: /mnt/sccache
|
||||
|
||||
- name: Setup sccache
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
|
||||
- name: Build daedalus_client
|
||||
run: cargo build --release --package daedalus_client
|
||||
@@ -97,11 +125,11 @@ jobs:
|
||||
cp target/release/daedalus_client apps/daedalus_client/docker-stage/daedalus_client
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Generate Docker image metadata
|
||||
id: docker-meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
env:
|
||||
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
|
||||
with:
|
||||
@@ -116,14 +144,14 @@ jobs:
|
||||
org.opencontainers.image.licenses=MIT
|
||||
|
||||
- name: Login to GitHub Packages
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: ./apps/daedalus_client/docker-stage
|
||||
file: ./apps/daedalus_client/Dockerfile
|
||||
|
||||
@@ -12,10 +12,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -16,10 +16,10 @@ on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
preview:
|
||||
required: false
|
||||
type: boolean
|
||||
description: 'Whether this is a preview deployment'
|
||||
environment:
|
||||
required: true
|
||||
type: string
|
||||
description: 'The environment to deploy to (staging-preview or production-preview)'
|
||||
head_sha:
|
||||
required: false
|
||||
type: string
|
||||
@@ -28,56 +28,44 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
description: 'The branch name to deploy (defaults to github.ref_name)'
|
||||
branch_alias:
|
||||
required: false
|
||||
type: string
|
||||
description: 'The persistent preview alias for the branch'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.preview && 'preview' || 'push' }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.environment || 'push' }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: namespace-profile-modrinth-frontend
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure environment
|
||||
id: meta
|
||||
env:
|
||||
PREVIEW_INPUT: ${{ inputs.preview }}
|
||||
ENV_INPUT: ${{ inputs.environment }}
|
||||
REF: ${{ github.ref }}
|
||||
DEPLOY_SHA: ${{ inputs.head_sha || github.sha }}
|
||||
BRANCH_ALIAS: ${{ inputs.branch_alias }}
|
||||
run: |
|
||||
echo "cmd=deploy" >> $GITHUB_OUTPUT
|
||||
|
||||
SHA_SHORT="${DEPLOY_SHA::8}"
|
||||
|
||||
if [ "$PREVIEW_INPUT" = "true" ]; then
|
||||
URL="https://git-$SHA_SHORT-frontend.modrinth.workers.dev"
|
||||
STAGING_URL="https://git-$SHA_SHORT-staging-frontend.modrinth.workers.dev"
|
||||
if [ "$ENV_INPUT" = "staging-preview" ]; then
|
||||
echo "env=staging" >> $GITHUB_OUTPUT
|
||||
echo "url=https://git-$SHA_SHORT-frontend-staging.modrinth.workers.dev" >> $GITHUB_OUTPUT
|
||||
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true" >> $GITHUB_OUTPUT
|
||||
|
||||
elif [ "$ENV_INPUT" = "production-preview" ]; then
|
||||
echo "env=production" >> $GITHUB_OUTPUT
|
||||
echo "url=$URL" >> $GITHUB_OUTPUT
|
||||
echo "staging_url=$STAGING_URL" >> $GITHUB_OUTPUT
|
||||
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true --var CF_PAGES_URL:$URL" >> $GITHUB_OUTPUT
|
||||
|
||||
STAGING_VARS=$(jq -r '.env.staging.vars | to_entries[] | "--var \(.key):\(.value)"' ./apps/frontend/wrangler.jsonc | tr '\n' ' ')
|
||||
echo "staging_cmd=versions upload --preview-alias git-$SHA_SHORT-staging ${STAGING_VARS}--var PREVIEW:true --var CF_PAGES_URL:$STAGING_URL" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ -n "$BRANCH_ALIAS" ]; then
|
||||
echo "branch_alias_url=https://$BRANCH_ALIAS-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
|
||||
echo "staging_branch_alias_url=https://$BRANCH_ALIAS-staging-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
|
||||
echo "staging_branch_alias_cmd=versions upload --preview-alias $BRANCH_ALIAS-staging ${STAGING_VARS}--var PREVIEW:true --var CF_PAGES_URL:https://$BRANCH_ALIAS-staging-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo "url=https://git-$SHA_SHORT-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
|
||||
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true" >> $GITHUB_OUTPUT
|
||||
|
||||
elif [ "$REF" = "refs/heads/main" ]; then
|
||||
echo "env=staging" >> $GITHUB_OUTPUT
|
||||
@@ -90,23 +78,24 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Set up caches
|
||||
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
|
||||
with:
|
||||
cache: |
|
||||
rust
|
||||
pnpm
|
||||
apt
|
||||
- name: Get pnpm store path
|
||||
id: pnpm-store
|
||||
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Turborepo caching
|
||||
uses: namespace-actions/setup-turbocache@f96bd6befa13ae74b2858a5be49573640d4636f5 # v0.0.2
|
||||
- name: Restore pnpm cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.store-path }}
|
||||
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-cache-
|
||||
|
||||
- name: Inject build variables
|
||||
working-directory: ./apps/frontend
|
||||
@@ -131,11 +120,11 @@ jobs:
|
||||
CF_PAGES_COMMIT_SHA: ${{ inputs.head_sha || github.sha }}
|
||||
CF_PAGES_URL: ${{ steps.meta.outputs.url }}
|
||||
BUILD_ENV: ${{ steps.meta.outputs.env }}
|
||||
PREVIEW: ${{ inputs.preview && 'true' || 'false' }}
|
||||
PREVIEW: ${{ inputs.environment != '' && 'true' || 'false' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: Create Sentry release and upload sourcemaps
|
||||
uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3.7.0
|
||||
uses: getsentry/action-release@5657c9e888b4e2cc85f4d29143ea4131fde4a73a # v3.6.0
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: modrinth
|
||||
@@ -145,53 +134,17 @@ jobs:
|
||||
sourcemaps: ./apps/frontend/.output/server
|
||||
url_prefix: '~/'
|
||||
|
||||
- name: Install Wrangler CLI
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
- name: Deploy Cloudflare Worker
|
||||
id: wrangler
|
||||
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
environment: ${{ steps.meta.outputs.env != 'production' && steps.meta.outputs.env || '' }}
|
||||
workingDirectory: ./apps/frontend
|
||||
packageManager: pnpm
|
||||
wranglerVersion: '4.112.0'
|
||||
command: --version
|
||||
|
||||
- parallel:
|
||||
- name: Deploy Cloudflare Worker
|
||||
id: wrangler
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
environment: ${{ steps.meta.outputs.env != 'production' && steps.meta.outputs.env || '' }}
|
||||
workingDirectory: ./apps/frontend
|
||||
command: ${{ steps.meta.outputs.cmd }}
|
||||
|
||||
- name: Deploy staging Cloudflare Worker preview
|
||||
if: ${{ inputs.preview }}
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
workingDirectory: ./apps/frontend
|
||||
command: ${{ steps.meta.outputs.staging_cmd }}
|
||||
|
||||
- name: Deploy persistent Cloudflare Worker preview
|
||||
if: ${{ inputs.preview && inputs.branch_alias != '' }}
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
workingDirectory: ./apps/frontend
|
||||
command: versions upload --preview-alias ${{ inputs.branch_alias }} --var PREVIEW:true --var CF_PAGES_URL:${{ steps.meta.outputs.branch_alias_url }}
|
||||
|
||||
- name: Deploy persistent staging Cloudflare Worker preview
|
||||
if: ${{ inputs.preview && inputs.branch_alias != '' }}
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
workingDirectory: ./apps/frontend
|
||||
command: ${{ steps.meta.outputs.staging_branch_alias_cmd }}
|
||||
wranglerVersion: '4.54.0'
|
||||
command: ${{ steps.meta.outputs.cmd }}
|
||||
|
||||
- name: Purge cache
|
||||
if: github.ref == 'refs/heads/prod'
|
||||
@@ -202,23 +155,14 @@ jobs:
|
||||
--data '{"hosts": ["modrinth.com", "www.modrinth.com", "staging.modrinth.com"]}' \
|
||||
https://api.cloudflare.com/client/v4/zones/e39df17b9c4ef44cbce2646346ee6d33/purge_cache
|
||||
|
||||
- name: Write deployment URLs to files
|
||||
if: ${{ inputs.preview }}
|
||||
- name: Write deployment URL to file
|
||||
if: ${{ inputs.environment != '' }}
|
||||
run: |
|
||||
echo "${{ steps.meta.outputs.url }}" > deployment-url-production.txt
|
||||
echo "${{ steps.meta.outputs.staging_url }}" > deployment-url-staging.txt
|
||||
echo "${{ steps.meta.outputs.url }}" > deployment-url-${{ inputs.environment }}.txt
|
||||
|
||||
- parallel:
|
||||
- name: Upload production deployment URL
|
||||
if: ${{ inputs.preview }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: deployment-url-production
|
||||
path: deployment-url-production.txt
|
||||
|
||||
- name: Upload staging deployment URL
|
||||
if: ${{ inputs.preview }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: deployment-url-staging
|
||||
path: deployment-url-staging.txt
|
||||
- name: Upload deployment URL
|
||||
if: ${{ inputs.environment != '' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: deployment-url-${{ inputs.environment }}
|
||||
path: deployment-url-${{ inputs.environment }}.txt
|
||||
|
||||
@@ -12,34 +12,24 @@ on:
|
||||
- '.github/workflows/frontend-preview.yml'
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
branch-alias: ${{ steps.meta.outputs.branch_alias }}
|
||||
steps:
|
||||
- name: Configure branch alias
|
||||
id: meta
|
||||
run: echo "branch_alias=pr-${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
|
||||
|
||||
deploy:
|
||||
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
needs: metadata
|
||||
uses: ./.github/workflows/frontend-deploy.yml
|
||||
secrets: inherit
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-deploy
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.environment }}
|
||||
cancel-in-progress: true
|
||||
strategy:
|
||||
matrix:
|
||||
environment: [staging-preview, production-preview]
|
||||
with:
|
||||
preview: true
|
||||
environment: ${{ matrix.environment }}
|
||||
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||
head_ref: ${{ github.event.pull_request.head.ref }}
|
||||
branch_alias: ${{ needs.metadata.outputs.branch-alias }}
|
||||
|
||||
deploy-storybook:
|
||||
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
needs: metadata
|
||||
runs-on: namespace-profile-modrinth-frontend
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-storybook
|
||||
cancel-in-progress: true
|
||||
@@ -48,21 +38,27 @@ jobs:
|
||||
deployments: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Set up caches
|
||||
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
|
||||
- name: Get pnpm store path
|
||||
id: pnpm-store
|
||||
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Restore pnpm cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
cache: |
|
||||
pnpm
|
||||
path: ${{ steps.pnpm-store.outputs.store-path }}
|
||||
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-cache-
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./packages/ui
|
||||
@@ -70,8 +66,6 @@ jobs:
|
||||
|
||||
- name: Build Storybook
|
||||
working-directory: ./packages/ui
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
run: pnpm run build-storybook
|
||||
|
||||
- name: Configure short SHA
|
||||
@@ -80,43 +74,20 @@ jobs:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: echo "sha_short=${HEAD_SHA::8}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install Wrangler CLI
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
- name: Deploy Storybook preview
|
||||
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
workingDirectory: ./packages/ui
|
||||
packageManager: pnpm
|
||||
wranglerVersion: '4.112.0'
|
||||
command: --version
|
||||
|
||||
- parallel:
|
||||
- name: Deploy Storybook preview
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
workingDirectory: ./packages/ui
|
||||
command: versions upload --preview-alias git-${{ steps.meta.outputs.sha_short }}
|
||||
|
||||
- name: Deploy persistent Storybook preview
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
workingDirectory: ./packages/ui
|
||||
command: versions upload --preview-alias ${{ needs.metadata.outputs.branch-alias }}
|
||||
wranglerVersion: '4.54.0'
|
||||
command: versions upload --preview-alias git-${{ steps.meta.outputs.sha_short }}
|
||||
|
||||
comment:
|
||||
if: |
|
||||
!cancelled()
|
||||
&& github.repository_owner == 'modrinth'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
&& needs.deploy.result == 'success'
|
||||
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
needs: [metadata, deploy, deploy-storybook]
|
||||
needs: [deploy, deploy-storybook]
|
||||
steps:
|
||||
- name: Download deployment URLs
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -128,29 +99,16 @@ jobs:
|
||||
id: urls
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BRANCH_ALIAS: ${{ needs.metadata.outputs.branch-alias }}
|
||||
STORYBOOK_RESULT: ${{ needs.deploy-storybook.result }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
STAGING_SHA_URL=$(cat deployment-url-staging.txt)
|
||||
PRODUCTION_SHA_URL=$(cat deployment-url-production.txt)
|
||||
STAGING_BRANCH_URL="https://$BRANCH_ALIAS-staging-frontend.modrinth.workers.dev"
|
||||
PRODUCTION_BRANCH_URL="https://$BRANCH_ALIAS-frontend.modrinth.workers.dev"
|
||||
STAGING_PREVIEW_URL=$(cat deployment-url-staging-preview.txt)
|
||||
PRODUCTION_PREVIEW_URL=$(cat deployment-url-production-preview.txt)
|
||||
|
||||
if [ "$STORYBOOK_RESULT" = "success" ]; then
|
||||
STORYBOOK_SHA_URL="https://git-${HEAD_SHA::8}-storybook.modrinth.workers.dev"
|
||||
STORYBOOK_BRANCH_URL="https://$BRANCH_ALIAS-storybook.modrinth.workers.dev"
|
||||
else
|
||||
STORYBOOK_SHA_URL="⚠️ Deployment $STORYBOOK_RESULT ([logs]($RUN_URL))"
|
||||
STORYBOOK_BRANCH_URL="⚠️ Deployment $STORYBOOK_RESULT ([logs]($RUN_URL))"
|
||||
fi
|
||||
echo "Production preview URL: $PRODUCTION_PREVIEW_URL"
|
||||
echo "Staging preview URL: $STAGING_PREVIEW_URL"
|
||||
|
||||
echo "staging-sha-url=$STAGING_SHA_URL" >> $GITHUB_OUTPUT
|
||||
echo "production-sha-url=$PRODUCTION_SHA_URL" >> $GITHUB_OUTPUT
|
||||
echo "storybook-sha-url=$STORYBOOK_SHA_URL" >> $GITHUB_OUTPUT
|
||||
echo "staging-branch-url=$STAGING_BRANCH_URL" >> $GITHUB_OUTPUT
|
||||
echo "production-branch-url=$PRODUCTION_BRANCH_URL" >> $GITHUB_OUTPUT
|
||||
echo "storybook-branch-url=$STORYBOOK_BRANCH_URL" >> $GITHUB_OUTPUT
|
||||
echo "staging-preview-url=$STAGING_PREVIEW_URL" >> $GITHUB_OUTPUT
|
||||
echo "production-preview-url=$PRODUCTION_PREVIEW_URL" >> $GITHUB_OUTPUT
|
||||
echo "storybook-preview-url=https://git-${HEAD_SHA::8}-storybook.modrinth.workers.dev" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Find comment
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -173,19 +131,9 @@ jobs:
|
||||
|
||||
Last deployed commit is [${{ github.event.pull_request.head.sha }}](${{ github.event.pull_request.head.repo.html_url }}/commit/${{ github.event.pull_request.head.sha }})
|
||||
|
||||
### Persistent
|
||||
|
||||
| Environment | URL |
|
||||
|-------------|-----|
|
||||
| staging | ${{ steps.urls.outputs.staging-branch-url }} |
|
||||
| production | ${{ steps.urls.outputs.production-branch-url }} |
|
||||
| storybook | ${{ steps.urls.outputs.storybook-branch-url }} |
|
||||
|
||||
### Latest commit
|
||||
|
||||
| Environment | URL |
|
||||
|-------------|-----|
|
||||
| staging | ${{ steps.urls.outputs.staging-sha-url }} |
|
||||
| production | ${{ steps.urls.outputs.production-sha-url }} |
|
||||
| storybook | ${{ steps.urls.outputs.storybook-sha-url }} |
|
||||
| staging | ${{ steps.urls.outputs.staging-preview-url }} |
|
||||
| production | ${{ steps.urls.outputs.production-preview-url }} |
|
||||
| storybook | ${{ steps.urls.outputs.storybook-preview-url }} |
|
||||
edit-mode: replace
|
||||
|
||||
@@ -51,13 +51,13 @@ jobs:
|
||||
CROWDIN_GH_TOKEN_DEFINED: ${{ secrets.CROWDIN_GH_TOKEN != '' }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
echo "safe_branch_name=$SAFE_BRANCH_NAME" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download translations from Crowdin
|
||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
with:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
|
||||
@@ -56,13 +56,13 @@ jobs:
|
||||
CROWDIN_PERSONAL_TOKEN_DEFINED: ${{ secrets.CROWDIN_PERSONAL_TOKEN != '' }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
echo "safe_branch_name=$SAFE_BRANCH_NAME" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload translations to Crowdin
|
||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
name: Labrinth Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'prod'
|
||||
paths:
|
||||
- .github/workflows/labrinth-build.yml
|
||||
- 'apps/labrinth/**'
|
||||
- 'packages/**'
|
||||
- '!packages/api-client/**'
|
||||
- '!packages/app-lib/**'
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
paths:
|
||||
- .github/workflows/labrinth-build.yml
|
||||
- 'apps/labrinth/**'
|
||||
- 'packages/**'
|
||||
- '!packages/api-client/**'
|
||||
- '!packages/app-lib/**'
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
|
||||
|
||||
jobs:
|
||||
skip-if-clean:
|
||||
name: Skip if merge_queue produces no diff
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
skip: ${{ steps.check.outputs.skip }}
|
||||
internal: ${{ steps.check-internal.outputs.internal }}
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Check if workflow runs on an internal branch
|
||||
id: check-internal
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" != "pull_request" ] || [ "$HEAD_REPO" = "$REPO" ]; then
|
||||
echo "internal=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "internal=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Merge Queue CI Check Skipper
|
||||
id: merge-queue-ci-skipper
|
||||
uses: ./.github/merge-queue-ci-skipper
|
||||
with:
|
||||
secret: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
|
||||
- name: Check merge_group synthetic commit
|
||||
id: check
|
||||
run: |
|
||||
# PR mode: never skip
|
||||
if [ "${{ github.event_name }}" != "merge_group" ]; then
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${{ steps.merge-queue-ci-skipper.outputs.skip-check }}" = "true" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build:
|
||||
name: Build Labrinth
|
||||
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-labrinth' || 'ubuntu-latest' }}
|
||||
needs: [skip-if-clean]
|
||||
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
|
||||
env:
|
||||
SQLX_OFFLINE: 'true'
|
||||
GIT_HASH: ${{ github.sha }}
|
||||
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '20G' || '' }}
|
||||
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
|
||||
RUST_MIN_STACK: '16777216'
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
with:
|
||||
rustflags: ''
|
||||
cache: false
|
||||
|
||||
- name: Setup mold
|
||||
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
|
||||
- name: Set up caches
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
|
||||
with:
|
||||
cache: |
|
||||
rust
|
||||
apt
|
||||
|
||||
- name: Configure sccache
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install build dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake libcurl4-openssl-dev
|
||||
|
||||
- name: Build labrinth
|
||||
run: cargo build --profile release-labrinth --package labrinth
|
||||
|
||||
- name: Stage Docker context
|
||||
run: |
|
||||
mkdir -p apps/labrinth/docker-stage
|
||||
cp target/release-labrinth/labrinth apps/labrinth/docker-stage/labrinth
|
||||
cp -r apps/labrinth/migrations apps/labrinth/docker-stage/migrations
|
||||
cp -r apps/labrinth/assets apps/labrinth/docker-stage/assets
|
||||
|
||||
- name: Upload Docker context
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3
|
||||
with:
|
||||
name: labrinth-docker-context
|
||||
retention-days: 1
|
||||
path: apps/labrinth/docker-stage
|
||||
|
||||
docker-build:
|
||||
needs: [skip-if-clean, build]
|
||||
if: ${{ needs.skip-if-clean.outputs.internal == 'true' }}
|
||||
uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main
|
||||
with:
|
||||
image-name: labrinth
|
||||
dockerfile-path: apps/labrinth/Dockerfile
|
||||
artifacts-name: labrinth-docker-context
|
||||
|
||||
deploy:
|
||||
needs: [skip-if-clean, docker-build]
|
||||
if: ${{ needs.skip-if-clean.outputs.internal == 'true' && github.ref == 'refs/heads/prod' }}
|
||||
uses: SparkUniverse/workflows/.github/workflows/argo-update.yaml@main
|
||||
secrets:
|
||||
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
|
||||
with:
|
||||
application-set: labrinth
|
||||
branch: ${{ github.ref == 'refs/heads/prod' && 'main' || 'develop' }}
|
||||
environment-name: ${{ github.ref == 'refs/heads/prod' && 'production' || 'staging' }}
|
||||
@@ -0,0 +1,176 @@
|
||||
name: docker-build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- .github/workflows/labrinth-docker.yml
|
||||
- 'apps/labrinth/**'
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
paths:
|
||||
- .github/workflows/labrinth-docker.yml
|
||||
- 'apps/labrinth/**'
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
|
||||
|
||||
jobs:
|
||||
skip-if-clean:
|
||||
name: Skip if merge_queue produces no diff
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
skip: ${{ steps.check.outputs.skip }}
|
||||
internal: ${{ steps.check-internal.outputs.internal }}
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check if workflow runs on an internal branch
|
||||
id: check-internal
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" != "pull_request" ] || [ "$HEAD_REPO" = "$REPO" ]; then
|
||||
echo "internal=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "internal=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Merge Queue CI Check Skipper
|
||||
id: merge-queue-ci-skipper
|
||||
uses: ./.github/merge-queue-ci-skipper
|
||||
with:
|
||||
secret: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
|
||||
- name: Check merge_group synthetic commit
|
||||
id: check
|
||||
run: |
|
||||
# PR mode: never skip
|
||||
if [ "${{ github.event_name }}" != "merge_group" ]; then
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${{ steps.merge-queue-ci-skipper.outputs.skip-check }}" = "true" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
docker:
|
||||
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
needs: [skip-if-clean]
|
||||
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
|
||||
env:
|
||||
SQLX_OFFLINE: 'true'
|
||||
GIT_HASH: ${{ github.sha }}
|
||||
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
|
||||
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
|
||||
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
|
||||
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
|
||||
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
|
||||
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
|
||||
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
|
||||
with:
|
||||
rustflags: ''
|
||||
cache: false
|
||||
|
||||
- name: Setup mold
|
||||
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
|
||||
|
||||
- name: Install build dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
|
||||
with:
|
||||
packages: cmake libcurl4-openssl-dev
|
||||
version: v2 # cache key
|
||||
|
||||
- name: Cache Cargo registry and index
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
~/.cargo/bin
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Mount sccache disk cache
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
|
||||
with:
|
||||
key: ${{ github.repository }}-labrinth-sccache
|
||||
path: /mnt/sccache
|
||||
|
||||
- name: Setup sccache
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
|
||||
- name: Build labrinth
|
||||
run: cargo build --profile release-labrinth --package labrinth
|
||||
|
||||
- name: Stage Docker context
|
||||
run: |
|
||||
mkdir -p apps/labrinth/docker-stage
|
||||
cp target/release-labrinth/labrinth apps/labrinth/docker-stage/labrinth
|
||||
cp -r apps/labrinth/migrations apps/labrinth/docker-stage/migrations
|
||||
cp -r apps/labrinth/assets apps/labrinth/docker-stage/assets
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Generate Docker image metadata
|
||||
id: docker-meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
env:
|
||||
# GitHub Packages requires annotations metadata in at least the index descriptor to show them
|
||||
# up properly in its UI it seems, but it's not clear about it, because the docs refer to the
|
||||
# image manifest only. See:
|
||||
# https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images
|
||||
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
|
||||
with:
|
||||
images: ghcr.io/modrinth/labrinth
|
||||
labels: |
|
||||
org.opencontainers.image.title=labrinth
|
||||
org.opencontainers.image.description=Modrinth API
|
||||
org.opencontainers.image.licenses=AGPL-3.0-only
|
||||
annotations: |
|
||||
org.opencontainers.image.title=labrinth
|
||||
org.opencontainers.image.description=Modrinth API
|
||||
org.opencontainers.image.licenses=AGPL-3.0-only
|
||||
|
||||
- name: Login to GitHub Packages
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: ./apps/labrinth/docker-stage
|
||||
file: ./apps/labrinth/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.docker-meta.outputs.tags }}
|
||||
labels: ${{ steps.docker-meta.outputs.labels }}
|
||||
annotations: ${{ steps.docker-meta.outputs.annotations }}
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Prepare pnpm cache
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/prepare-pnpm-cache.yml
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
if: github.repository_owner == 'modrinth'
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Get pnpm store path
|
||||
id: pnpm-store
|
||||
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.store-path }}
|
||||
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm recursive install --frozen-lockfile
|
||||
@@ -1,20 +0,0 @@
|
||||
name: Slash Command Dispatch
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
dispatch-command:
|
||||
if: ${{ github.event.sender.type == 'User' && contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association) }}
|
||||
runs-on: namespace-profile-tiny-arm64
|
||||
steps:
|
||||
- name: Slash Command Dispatch
|
||||
uses: peter-evans/slash-command-dispatch@9bdcd7914ec1b75590b790b844aa3b8eee7c683a # v5.0.2
|
||||
with:
|
||||
token: ${{ secrets.SLASH_CMD_GH_TOKEN }}
|
||||
issue-type: pull-request
|
||||
commands: |
|
||||
deploy
|
||||
@@ -44,62 +44,57 @@ jobs:
|
||||
name: Build
|
||||
env:
|
||||
VITE_STRIPE_PUBLISHABLE_KEY: pk_live_51JbFxJJygY5LJFfKLVVldb10HlLt24p421OWRsTOWc5sXYFOnFUXWieSc6HD3PHo25ktx8db1WcHr36XGFvZFVUz00V9ixrCs5
|
||||
# SCCACHE_DIR: '/mnt/sccache'
|
||||
# SCCACHE_CACHE_SIZE: '10G'
|
||||
# SCCACHE_MULTILEVEL_CHAIN: 'disk,s3'
|
||||
SCCACHE_S3_KEY_PREFIX: '${{ github.repository }}/'
|
||||
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
|
||||
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
|
||||
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
RUSTC_WRAPPER: 'sccache'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
[
|
||||
namespace-profile-medium-amd64-macos,
|
||||
namespace-profile-medium-amd64-windows,
|
||||
namespace-profile-medium-amd64,
|
||||
platform: [
|
||||
blacksmith-6vcpu-macos-26,
|
||||
blacksmith-8vcpu-windows-2025, # At time of writing, Windows 4 vCPU VMs don't seem to actually exist
|
||||
blacksmith-4vcpu-ubuntu-2404,
|
||||
]
|
||||
include:
|
||||
- platform: namespace-profile-medium-amd64-macos
|
||||
- platform: blacksmith-6vcpu-macos-26
|
||||
artifact-target-name: universal-apple-darwin
|
||||
- platform: namespace-profile-medium-amd64-windows
|
||||
- platform: blacksmith-8vcpu-windows-2025
|
||||
artifact-target-name: x86_64-pc-windows-msvc
|
||||
- platform: namespace-profile-medium-amd64
|
||||
- platform: blacksmith-4vcpu-ubuntu-2404
|
||||
artifact-target-name: x86_64-unknown-linux-gnu
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
|
||||
with:
|
||||
rustflags: ''
|
||||
target: ${{ contains(matrix.artifact-target-name, 'darwin') && 'x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Setup mold
|
||||
if: contains(matrix.artifact-target-name, 'linux')
|
||||
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
|
||||
target: ${{ contains(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Set up caches
|
||||
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
cache: |
|
||||
rust
|
||||
pnpm
|
||||
|
||||
- name: Configure sccache
|
||||
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
|
||||
node-version-file: .nvmrc
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Generate tauri-dev.conf.json
|
||||
shell: bash
|
||||
@@ -117,16 +112,11 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
if: contains(matrix.artifact-target-name, 'linux')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
if: contains(matrix.platform, 'ubuntu')
|
||||
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
packages: libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
version: v1 # cache key
|
||||
|
||||
- name: Setup Dasel
|
||||
uses: jaxxstorm/action-install-gh-release@25e24d2d23ae098373794ef1d6faecb48ee52da8 # v3.0.0
|
||||
@@ -134,7 +124,7 @@ jobs:
|
||||
repo: TomWright/dasel
|
||||
tag: v2.8.1
|
||||
extension-matching: disable
|
||||
rename-to: ${{ contains(matrix.artifact-target-name, 'windows') && 'dasel.exe' || 'dasel' }}
|
||||
rename-to: ${{ contains(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
|
||||
chmod: 0755
|
||||
|
||||
- name: Set application version and environment
|
||||
@@ -153,11 +143,14 @@ jobs:
|
||||
|
||||
cp "packages/app-lib/.env.${BUILD_ENVIRONMENT}" packages/app-lib/.env
|
||||
|
||||
- name: Setup Turbo cache
|
||||
uses: rharkor/caching-for-turbo@56219402aacc0d06b650d898c222996dbc1191ec # v2.3.14
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Set up Windows code signing
|
||||
if: contains(matrix.artifact-target-name, 'windows')
|
||||
if: contains(matrix.platform, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
if [ '${{ startsWith(github.ref, 'refs/tags/v') || inputs.sign-windows-binaries }}' = 'true' ]; then
|
||||
@@ -168,7 +161,7 @@ jobs:
|
||||
|
||||
- name: Build macOS app
|
||||
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-dev.conf.json' }}
|
||||
if: contains(matrix.artifact-target-name, 'darwin')
|
||||
if: contains(matrix.platform, 'macos')
|
||||
env:
|
||||
TAURI_BUNDLER_DMG_IGNORE_CI: 'true'
|
||||
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -183,7 +176,7 @@ jobs:
|
||||
|
||||
- name: Build Linux app
|
||||
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json' }}
|
||||
if: contains(matrix.artifact-target-name, 'linux')
|
||||
if: contains(matrix.platform, 'ubuntu')
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
@@ -195,7 +188,7 @@ jobs:
|
||||
$env:JAVA_HOME = "$env:JAVA_HOME_17_X64"
|
||||
${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles "nsis,updater"' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json --verbose --bundles "nsis,updater"' }}
|
||||
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
|
||||
if: contains(matrix.artifact-target-name, 'windows')
|
||||
if: contains(matrix.platform, 'windows')
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
startsWith(github.event.workflow_run.head_branch, 'v')
|
||||
runs-on: namespace-profile-medium-amd64
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
env:
|
||||
VERSION_TAG: ${{ github.event.workflow_run.head_branch }}
|
||||
LINUX_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-unknown-linux-gnu)
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify ref is a tag
|
||||
env:
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check if workflow runs on an internal branch
|
||||
id: check-internal
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Lint and Test
|
||||
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-turbo' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
needs: [skip-if-clean]
|
||||
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
|
||||
|
||||
@@ -76,57 +76,81 @@ jobs:
|
||||
# since we don't want warnings to become errors
|
||||
# while developing)
|
||||
RUSTFLAGS: -Dwarnings
|
||||
# Redis config for tests
|
||||
REDIS_TOPOLOGY: cluster
|
||||
REDIS_CONNECTION_TYPE: multiplexed
|
||||
REDIS_URL: redis://127.0.0.1:7000,redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003,redis://127.0.0.1:7004,redis://127.0.0.1:7005
|
||||
# Avoid stack overflows in tests
|
||||
RUST_MIN_STACK: 134217728
|
||||
# sccache config (only populated for internal branches; secrets aren't
|
||||
# available to forked PRs, and blacksmith stickydisk requires a
|
||||
# blacksmith runner)
|
||||
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
|
||||
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
|
||||
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
|
||||
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
|
||||
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
|
||||
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
|
||||
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Install build dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
|
||||
with:
|
||||
packages: cmake libcurl4-openssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
version: v2 # cache key
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Set up caches
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
|
||||
- name: Get pnpm store path
|
||||
id: pnpm-store
|
||||
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Restore pnpm cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
cache: |
|
||||
rust
|
||||
pnpm
|
||||
apt
|
||||
|
||||
- name: Set up Turborepo caching
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: namespace-actions/setup-turbocache@f96bd6befa13ae74b2858a5be49573640d4636f5 # v0.0.2
|
||||
|
||||
- name: Install apt dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake libcurl4-openssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
path: ${{ steps.pnpm-store.outputs.store-path }}
|
||||
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-cache-
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
|
||||
with:
|
||||
rustflags: ''
|
||||
components: clippy, rustfmt
|
||||
cache: false
|
||||
|
||||
- name: Setup mold
|
||||
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
|
||||
- name: Cache Cargo registry and index
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae #v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
~/.cargo/bin
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Mount sccache disk cache
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
|
||||
with:
|
||||
key: ${{ github.repository }}-turbo-sccache
|
||||
path: /mnt/sccache
|
||||
|
||||
- name: Setup sccache
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
|
||||
- name: Setup binstall
|
||||
uses: cargo-bins/cargo-binstall@ead08b90bd7b2e6d81963fb9cf0b7239f66d5db4 # v1.21.0
|
||||
uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
|
||||
|
||||
- name: Setup nextest
|
||||
run: cargo binstall --no-confirm --secure --locked cargo-nextest@0.9.133
|
||||
@@ -134,13 +158,16 @@ jobs:
|
||||
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
|
||||
# back to a cached cargo install
|
||||
- name: Setup cargo-sqlx
|
||||
uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7
|
||||
uses: taiki-e/cache-cargo-install-action@f9eed3e4680f27610dc6d8c67be1b88593f7dade # v3.0.6
|
||||
with:
|
||||
tool: sqlx-cli@0.8.6
|
||||
locked: false
|
||||
no-default-features: true
|
||||
features: rustls,postgres
|
||||
|
||||
- name: Setup Turbo cache
|
||||
uses: rharkor/caching-for-turbo@56219402aacc0d06b650d898c222996dbc1191ec # v2.3.14
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -162,7 +189,7 @@ jobs:
|
||||
|
||||
- name: Start services
|
||||
if: steps.check-labrinth.outputs.needs_services == 'true'
|
||||
run: docker compose --profile clustered-redis up --wait
|
||||
run: docker compose up --wait
|
||||
|
||||
- name: Setup labrinth environment and database
|
||||
if: steps.check-labrinth.outputs.needs_services == 'true'
|
||||
|
||||
+2
-1
@@ -65,7 +65,8 @@ generated
|
||||
app-playground-data/*
|
||||
|
||||
.astro
|
||||
.claude/
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
.letta
|
||||
|
||||
# labrinth demo fixtures
|
||||
|
||||
Generated
-11
@@ -17,17 +17,6 @@
|
||||
<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" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/async-minecraft-ping/examples" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/async-minecraft-ping/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/async-minecraft-ping/tests" isTestSource="true" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/labrinth-derive/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/modrinth-content-management/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/modrinth-content-management/tests" isTestSource="true" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/neverbounce/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/serde-binhum/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/sqlx-tracing/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/sqlx-tracing/tests" isTestSource="true" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/xredis/src" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
merge_queue:
|
||||
mode: parallel
|
||||
scopes:
|
||||
source:
|
||||
files:
|
||||
frontend:
|
||||
include:
|
||||
- apps/frontend/**/*
|
||||
- packages/ui/**/*
|
||||
- packages/utils/**/*
|
||||
- packages/assets/**/*
|
||||
- "**/wrangler.jsonc"
|
||||
- "**/pnpm-*.yaml"
|
||||
backend:
|
||||
include:
|
||||
- apps/labrinth/**
|
||||
- Cargo.toml
|
||||
app:
|
||||
include:
|
||||
- apps/app/**
|
||||
- apps/app-frontend/**
|
||||
- packages/app-lib/**
|
||||
- packages/app-macros/**
|
||||
@@ -1,93 +0,0 @@
|
||||
# Modrinth Monorepo
|
||||
|
||||
This is the Modrinth monorepo — it contains all Modrinth projects, both frontend and backend. When entering a project, either to edit or analyse, you should read its AGENTS.md.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Monorepo tooling:** [Turborepo](https://turbo.build/) (`turbo.jsonc`) + [pnpm workspaces](https://pnpm.io/workspaces) (`pnpm-workspace.yaml`)
|
||||
- **Frontend:** Vue 3 / Nuxt 3, Tailwind CSS v3
|
||||
- **Backend:** Rust (Labrinth API), Postgres, Clickhouse
|
||||
- **Indentation:** Use TAB everywhere, never spaces
|
||||
|
||||
### Apps (`apps/`)
|
||||
|
||||
| App | Description |
|
||||
| ----------------- | ------------------------------ |
|
||||
| `frontend` | Main Modrinth website (Nuxt 3) |
|
||||
| `app-frontend` | Desktop/app frontend (Vue 3) |
|
||||
| `app` | Desktop/app shell (Tauri) |
|
||||
| `app-playground` | Testing playground for app |
|
||||
| `labrinth` | Backend API service |
|
||||
| `daedalus_client` | Daedalus client implementation |
|
||||
| `docs` | Documentation site (Astro) |
|
||||
|
||||
### Packages (`packages/`)
|
||||
|
||||
| Package | Description |
|
||||
| ------------------ | ----------------------------------------------------- |
|
||||
| `ui` | Shared Vue component library (`@modrinth/ui`) |
|
||||
| `assets` | Styling and auto-generated icons (`@modrinth/assets`) |
|
||||
| `api-client` | API client for Nuxt, Tauri, and Node/browser |
|
||||
| `app-lib` | Shared app library |
|
||||
| `blog` | Blog system and changelog data |
|
||||
| `utils` | Shared utility functions (mostly deprecated) |
|
||||
| `moderation` | Moderation utilities |
|
||||
| `daedalus` | Daedalus protocol |
|
||||
| `tooling-config` | ESLint, Prettier, TypeScript configs |
|
||||
| `ariadne` | Analytics library |
|
||||
| `modrinth-log` | Logging utilities |
|
||||
| `modrinth-maxmind` | MaxMind GeoIP |
|
||||
| `modrinth-util` | General utilities |
|
||||
| `muralpay` | Payment processing |
|
||||
| `path-util` | Path utilities |
|
||||
| `sqlx-tracing` | SQLx query tracing |
|
||||
|
||||
## Pre-PR Commands
|
||||
|
||||
Run these from the **root** folder before opening a pull request - do not run these after each prompt the user gives you, only run when asked, ask the user a question if they want to run it if the user indicates that they are about to create a pull request.
|
||||
|
||||
- **Website:** `pnpm prepr:frontend:web`
|
||||
- **App frontend:** `pnpm prepr:frontend:app`
|
||||
- **Frontend libs:** `pnpm prepr:frontend:lib`
|
||||
- **All frontend (app+web):** `pnpm prepr`
|
||||
- **Labrinth (backend):** See `apps/labrinth/AGENTS.md`
|
||||
|
||||
The website and app `prepr` commands
|
||||
|
||||
## Dev Commands
|
||||
|
||||
- **Website:** `pnpm web:dev` (copy `.env` template in `apps/frontend/` first)
|
||||
- **App:** `pnpm app:dev` (copy `.env` template in `packages/app-lib/` first)
|
||||
- **Storybook (packages/ui):** `pnpm storybook`
|
||||
|
||||
## Project-Specific Instructions
|
||||
|
||||
Each project may have its own file with detailed instructions:
|
||||
|
||||
- [`apps/labrinth/AGENTS.md`](apps/labrinth/AGENTS.md) — Backend API
|
||||
- [`apps/frontend/AGENTS.md`](apps/frontend/AGENTS.md) - Frontend Website
|
||||
|
||||
## Code Guidelines
|
||||
|
||||
### Comments
|
||||
- DO NOT use "heading" comments like: `=== Helper methods ===`.
|
||||
- Use doc comments, but avoid inline comments unless ABSOLUTELY necessary for clarity. Code should aim to be self documenting!
|
||||
|
||||
## Bash Guidelines
|
||||
|
||||
### Output handling
|
||||
- DO NOT pipe output through `head`, `tail`, `less`, or `more`
|
||||
- NEVER use `| head -n X` or `| tail -n X` to truncate output
|
||||
- IMPORTANT: Run commands directly without pipes when possible
|
||||
- IMPORTANT: If you need to limit output, use command-specific flags (e.g. `git log -n 10` instead of `git log | head -10`)
|
||||
- ALWAYS read the full output — never pipe through filters
|
||||
|
||||
### General
|
||||
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to
|
||||
- For Frontend, when doing lint checks, only use the `prepr` commands, do not use `typecheck` or `tsc` etc.
|
||||
- Types in `@modrinth/utils` are considered highly outdated, if a component needs them, check if you can switch said component to use types from `packages/api-client`
|
||||
- When provided problems, do not say "I didn't introduce these problems" (shifting the blame/effort) - just fix them.
|
||||
|
||||
## Standards
|
||||
|
||||
Standards available at the @standards/ folder.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Modrinth Monorepo
|
||||
|
||||
This is the Modrinth monorepo — it contains all Modrinth projects, both frontend and backend. When entering a project, either to edit or analyse, you should read it's CLAUDE.md.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Monorepo tooling:** [Turborepo](https://turbo.build/) (`turbo.jsonc`) + [pnpm workspaces](https://pnpm.io/workspaces) (`pnpm-workspace.yaml`)
|
||||
- **Frontend:** Vue 3 / Nuxt 3, Tailwind CSS v3
|
||||
- **Backend:** Rust (Labrinth API), Postgres, Clickhouse
|
||||
- **Indentation:** Use TAB everywhere, never spaces
|
||||
|
||||
### Apps (`apps/`)
|
||||
|
||||
| App | Description |
|
||||
| ----------------- | ------------------------------ |
|
||||
| `frontend` | Main Modrinth website (Nuxt 3) |
|
||||
| `app-frontend` | Desktop/app frontend (Vue 3) |
|
||||
| `app` | Desktop/app shell (Tauri) |
|
||||
| `app-playground` | Testing playground for app |
|
||||
| `labrinth` | Backend API service |
|
||||
| `daedalus_client` | Daedalus client implementation |
|
||||
| `docs` | Documentation site (Astro) |
|
||||
|
||||
### Packages (`packages/`)
|
||||
|
||||
| Package | Description |
|
||||
| ------------------ | ----------------------------------------------------- |
|
||||
| `ui` | Shared Vue component library (`@modrinth/ui`) |
|
||||
| `assets` | Styling and auto-generated icons (`@modrinth/assets`) |
|
||||
| `api-client` | API client for Nuxt, Tauri, and Node/browser |
|
||||
| `app-lib` | Shared app library |
|
||||
| `blog` | Blog system and changelog data |
|
||||
| `utils` | Shared utility functions (mostly deprecated) |
|
||||
| `moderation` | Moderation utilities |
|
||||
| `daedalus` | Daedalus protocol |
|
||||
| `tooling-config` | ESLint, Prettier, TypeScript configs |
|
||||
| `ariadne` | Analytics library |
|
||||
| `modrinth-log` | Logging utilities |
|
||||
| `modrinth-maxmind` | MaxMind GeoIP |
|
||||
| `modrinth-util` | General utilities |
|
||||
| `muralpay` | Payment processing |
|
||||
| `path-util` | Path utilities |
|
||||
| `sqlx-tracing` | SQLx query tracing |
|
||||
|
||||
## Pre-PR Commands
|
||||
|
||||
Run these from the **root** folder before opening a pull request - do not run these after each prompt the user gives you, only run when asked, ask the user a question if they want to run it if the user indicates that they are about to create a pull request.
|
||||
|
||||
- **Website:** `pnpm prepr:frontend:web`
|
||||
- **App frontend:** `pnpm prepr:frontend:app`
|
||||
- **Frontend libs:** `pnpm prepr:frontend:lib`
|
||||
- **All frontend (app+web):** `pnpm prepr`
|
||||
- **Labrinth (backend):** See `apps/labrinth/AGENTS.md`
|
||||
|
||||
The website and app `prepr` commands
|
||||
|
||||
## Dev Commands
|
||||
|
||||
- **Website:** `pnpm web:dev` (copy `.env` template in `apps/frontend/` first)
|
||||
- **App:** `pnpm app:dev` (copy `.env` template in `packages/app-lib/` first)
|
||||
- **Storybook (packages/ui):** `pnpm storybook`
|
||||
|
||||
## Project-Specific Instructions
|
||||
|
||||
Each project may have its own file with detailed instructions:
|
||||
|
||||
- [`apps/labrinth/AGENTS.md`](apps/labrinth/AGENTS.md) — Backend API
|
||||
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
|
||||
|
||||
## Code Guidelines
|
||||
|
||||
### Comments
|
||||
- DO NOT use "heading" comments like: `=== Helper methods ===`.
|
||||
- Use doc comments, but avoid inline comments unless ABSOLUTELY necessary for clarity. Code should aim to be self documenting!
|
||||
|
||||
## Bash Guidelines
|
||||
|
||||
### Output handling
|
||||
- DO NOT pipe output through `head`, `tail`, `less`, or `more`
|
||||
- NEVER use `| head -n X` or `| tail -n X` to truncate output
|
||||
- IMPORTANT: Run commands directly without pipes when possible
|
||||
- IMPORTANT: If you need to limit output, use command-specific flags (e.g. `git log -n 10` instead of `git log | head -10`)
|
||||
- ALWAYS read the full output — never pipe through filters
|
||||
|
||||
### General
|
||||
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to
|
||||
- For Frontend, when doing lint checks, only use the `prepr` commands, do not use `typecheck` or `tsc` etc.
|
||||
- Types in `@modrinth/utils` are considered highly outdated, if a component needs them, check if you can switch said component to use types from `packages/api-client`
|
||||
- When provided problems, do not say "I didn't introduce these problems" (shifting the blame/effort) - just fix them.
|
||||
|
||||
## Edit Tool - Whitespace Handling (CLAUDE ONLY)
|
||||
|
||||
The Read tool uses `→` to mark where line numbers end and file content begins.
|
||||
|
||||
**Rule:** Copy the EXACT whitespace that appears after the `→` marker.
|
||||
- Whatever appears between `→` and the code text is what's actually in the file
|
||||
- That whitespace must be used EXACTLY in Edit tool's old_string
|
||||
- Don't count arrows, don't interpret - just copy what's after the `→`
|
||||
|
||||
**Example:**
|
||||
14→ private byte tag;
|
||||
For Edit, use: ` private byte tag;` (copy everything after →, including the two tabs)
|
||||
|
||||
**If Edit fails:** Stop and explain the problem. Do not attempt sed/awk/bash workarounds.
|
||||
|
||||
**IMPORTANT**: Trust the Read tool output. Copy what's after `→` into Edit immediately. DO NOT verify with sed/od/grep first - that's wasting time and the instructions already tell you to stop if Edit fails, not to pre-verify.
|
||||
|
||||
## Standards
|
||||
|
||||
Standards available at the @standards/ folder.
|
||||
Generated
+234
-830
File diff suppressed because it is too large
Load Diff
+6
-27
@@ -9,14 +9,10 @@ members = [
|
||||
"packages/ariadne",
|
||||
"packages/daedalus",
|
||||
"packages/labrinth-derive",
|
||||
"packages/modrinth-content-management",
|
||||
"packages/modrinth-log",
|
||||
"packages/modrinth-maxmind",
|
||||
"packages/modrinth-util",
|
||||
"packages/neverbounce",
|
||||
"packages/path-util",
|
||||
"packages/serde-binhum",
|
||||
"packages/xredis",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -36,6 +32,7 @@ actix-ws = "0.3.0"
|
||||
arc-swap = "1.7.1"
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
ariadne = { path = "packages/ariadne" }
|
||||
async-compression = { version = "0.4.32", default-features = false }
|
||||
async-minecraft-ping = { path = "packages/async-minecraft-ping" }
|
||||
async-recursion = "1.1.1"
|
||||
async-stripe = { version = "0.41.0", default-features = false, features = [
|
||||
@@ -54,13 +51,11 @@ aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
|
||||
] }
|
||||
base64 = "0.22.1"
|
||||
bitflags = "2.9.4"
|
||||
bon = "3.9.3"
|
||||
bytemuck = "1.24.0"
|
||||
bytes = "1.10.1"
|
||||
censor = "0.3.0"
|
||||
chardetng = "0.1.17"
|
||||
chrono = "0.4.42"
|
||||
chumsky = "0.9.3"
|
||||
cidre = { version = "0.15.0", default-features = false, features = [
|
||||
"macos_15_0"
|
||||
] }
|
||||
@@ -75,7 +70,7 @@ 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 = "c7209b086829572f368f1194aba54bdea8dc540b", version = "0.23.0" }
|
||||
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "db5fb00b036ecc8fe5f18853c559b745ffe47bde", version = "0.22.1" }
|
||||
derive_more = "2.1.1"
|
||||
directories = "6.0.0"
|
||||
dirs = "6.0.0"
|
||||
@@ -92,12 +87,10 @@ fs4 = { version = "0.13.1", default-features = false }
|
||||
futures = "0.3.31"
|
||||
futures-lite = "2.6.1"
|
||||
futures-util = "0.3.31"
|
||||
governor = "0.10.4"
|
||||
heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
hickory-resolver = "0.25.2"
|
||||
hmac = "0.12.1"
|
||||
httpdate = "1.0.3"
|
||||
hyper = "1.7.0"
|
||||
hyper-rustls = { version = "0.27.7", default-features = false, features = [
|
||||
"aws-lc-rs",
|
||||
@@ -113,7 +106,6 @@ indicatif = "0.18.0"
|
||||
itertools = "0.14.0"
|
||||
jemalloc_pprof = "0.8.1"
|
||||
json-patch = { version = "4.1.0", default-features = false }
|
||||
json5 = "1.3.1"
|
||||
lettre = { version = "0.11.19", default-features = false, features = [
|
||||
"aws-lc-rs",
|
||||
"builder",
|
||||
@@ -125,20 +117,13 @@ lettre = { version = "0.11.19", default-features = false, features = [
|
||||
"tokio1",
|
||||
"tokio1-rustls",
|
||||
] }
|
||||
lz4_flex = { version = "0.11.5", default-features = false, features = [
|
||||
"checked-decode",
|
||||
"safe-decode",
|
||||
"safe-encode",
|
||||
"std",
|
||||
] }
|
||||
maxminddb = "0.26.0"
|
||||
modrinth-content-management = { path = "packages/modrinth-content-management" }
|
||||
meilisearch-sdk = { version = "0.30.0", default-features = false }
|
||||
modrinth-log = { path = "packages/modrinth-log" }
|
||||
modrinth-util = { path = "packages/modrinth-util" }
|
||||
muralpay = { path = "packages/muralpay" }
|
||||
murmur2 = "0.1.0"
|
||||
native-dialog = "0.9.2"
|
||||
neverbounce = { path = "packages/neverbounce" }
|
||||
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 }
|
||||
@@ -148,7 +133,6 @@ paste = "1.0.15"
|
||||
path-util = { path = "packages/path-util" }
|
||||
phf = { version = "0.13.1", features = ["macros"] }
|
||||
png = "0.18.0"
|
||||
postcard = { version = "1.1.3", default-features = false, features = ["alloc"] }
|
||||
proc-macro2 = { version = "1.0" }
|
||||
prometheus = "0.14.0"
|
||||
quartz_nbt = "0.2.9"
|
||||
@@ -157,7 +141,7 @@ 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
|
||||
rdkafka = { version = "0.36.2", features = ["cmake-build"] }
|
||||
redis = "1.4.1"
|
||||
redis = "0.32.7"
|
||||
regex = "1.12.2"
|
||||
reqwest = { version = "0.12.24", default-features = false }
|
||||
rgb = "0.8.52"
|
||||
@@ -173,7 +157,6 @@ rust-s3 = { version = "0.37.0", default-features = false, features = [
|
||||
] }
|
||||
rustls = "0.23.32"
|
||||
rusty-money = "0.4.1"
|
||||
scalar_api_reference = { version = "0.2.2", default-features = false }
|
||||
secrecy = "0.10.3"
|
||||
sentry = { version = "0.45.0", default-features = false, features = [
|
||||
"backtrace",
|
||||
@@ -184,7 +167,6 @@ sentry = { version = "0.45.0", default-features = false, features = [
|
||||
"rustls",
|
||||
] }
|
||||
serde = "1.0.228"
|
||||
serde-binhum = { path = "packages/serde-binhum" }
|
||||
serde_bytes = "0.11.19"
|
||||
serde_cbor = "0.11.2"
|
||||
serde_ini = "0.2.0"
|
||||
@@ -195,7 +177,6 @@ sha1 = "0.10.6"
|
||||
sha1_smol = { version = "1.0.1", features = ["std"] }
|
||||
sha2 = "0.10.9"
|
||||
shlex = "1.3.0"
|
||||
smallvec = "1.15.1"
|
||||
spdx = "0.12.0"
|
||||
sqlx = { version = "0.8.6", default-features = false }
|
||||
sqlx-tracing = { path = "packages/sqlx-tracing" }
|
||||
@@ -225,7 +206,6 @@ tikv-jemallocator = "0.6.0"
|
||||
tokio = "1.47.1"
|
||||
tokio-stream = "0.1.17"
|
||||
tokio-util = "0.7.16"
|
||||
toml = "0.9.8"
|
||||
totp-rs = "5.7.0"
|
||||
tracing = "0.1.41"
|
||||
tracing-actix-web = { version = "0.7.19", default-features = false }
|
||||
@@ -236,10 +216,10 @@ typed-path = "0.12.0"
|
||||
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 }
|
||||
uuid = "1.18.1"
|
||||
validator = "0.20.0"
|
||||
webauthn-rs = "0.5.5"
|
||||
webauthn-rs-proto = "0.5.5"
|
||||
webp = { version = "0.3.1", default-features = false }
|
||||
webview2-com = "0.38.0" # Should be updated in lockstep with wry
|
||||
whoami = "1.6.1"
|
||||
@@ -247,7 +227,6 @@ windows = "=0.61.3" # Locked on 0.61 until we can update windows-core to 0.62
|
||||
windows-core = "=0.61.2" # Locked on 0.61 until webview2-com updates to 0.62
|
||||
winreg = "0.55.0"
|
||||
woothee = "0.13.0"
|
||||
xredis = { path = "packages/xredis" }
|
||||
yaserde = "0.12.0"
|
||||
zbus = "5.11.0"
|
||||
zip = { version = "6.0.0", default-features = false, features = [
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Modrinth Monorepo
|
||||
|
||||
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains  lines of code and has  contributors!
|
||||
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains  lines of code and has  contributors!
|
||||
|
||||
If you're not a developer and you've stumbled upon this repository, you can access the web interface on the [Modrinth website](https://modrinth.com) and download the latest release of the app [here](https://modrinth.com/app).
|
||||
|
||||
|
||||
@@ -8,11 +8,8 @@ extend-exclude = [
|
||||
"packages/blog/",
|
||||
# contains licenses like `CC-BY-ND-4.0`
|
||||
"packages/moderation/src/data/stages/license.ts",
|
||||
"packages/moderation/src/utils.ts",
|
||||
# contains payment card IDs like `IY1VMST1MOXS` which are flagged
|
||||
"apps/labrinth/src/queue/payouts/mod.rs",
|
||||
# contains domain names with deliberate typos
|
||||
"apps/labrinth/assets/disposable_email_blocklist.txt"
|
||||
]
|
||||
|
||||
[default.extend-words]
|
||||
|
||||
+307
-490
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 50 KiB |
@@ -24,8 +24,7 @@ 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 { install_duplicate_instance } from '@/helpers/install'
|
||||
import { remove } from '@/helpers/instance'
|
||||
import { duplicate, remove } from '@/helpers/profile.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
@@ -49,23 +48,24 @@ const instanceComponents = ref(null)
|
||||
const currentDeleteInstance = ref(null)
|
||||
const confirmModal = ref(null)
|
||||
|
||||
async function deleteInstance() {
|
||||
async function deleteProfile() {
|
||||
if (currentDeleteInstance.value) {
|
||||
instanceComponents.value = instanceComponents.value.filter(
|
||||
(x) => x.instance.id !== currentDeleteInstance.value,
|
||||
(x) => x.instance.path !== currentDeleteInstance.value,
|
||||
)
|
||||
await remove(currentDeleteInstance.value).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateInstance(p) {
|
||||
await install_duplicate_instance(p).catch(handleError)
|
||||
async function duplicateProfile(p) {
|
||||
await duplicate(p).catch(handleError)
|
||||
}
|
||||
|
||||
const handleRightClick = (event, instanceId) => {
|
||||
const item = instanceComponents.value.find((x) => x.instance.id === instanceId)
|
||||
const handleRightClick = (event, profilePathId) => {
|
||||
const item = instanceComponents.value.find((x) => x.instance.path === profilePathId)
|
||||
const baseOptions = [
|
||||
...(item.instance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
|
||||
{ name: 'add_content' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'edit' },
|
||||
{ name: 'duplicate' },
|
||||
{ name: 'open' },
|
||||
@@ -89,14 +89,10 @@ const handleRightClick = (event, instanceId) => {
|
||||
...baseOptions,
|
||||
]
|
||||
: [
|
||||
...(item.instance.quarantined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: 'play',
|
||||
color: 'primary',
|
||||
},
|
||||
]),
|
||||
{
|
||||
name: 'play',
|
||||
color: 'primary',
|
||||
},
|
||||
...baseOptions,
|
||||
],
|
||||
)
|
||||
@@ -118,16 +114,16 @@ const handleOptionsClick = async (args) => {
|
||||
break
|
||||
case 'duplicate':
|
||||
if (args.item.instance.install_stage == 'installed')
|
||||
await duplicateInstance(args.item.instance.id)
|
||||
await duplicateProfile(args.item.instance.path)
|
||||
break
|
||||
case 'open':
|
||||
await args.item.openFolder()
|
||||
break
|
||||
case 'copy':
|
||||
await navigator.clipboard.writeText(args.item.instance.id)
|
||||
await navigator.clipboard.writeText(args.item.instance.path)
|
||||
break
|
||||
case 'delete':
|
||||
currentDeleteInstance.value = args.item.instance.id
|
||||
currentDeleteInstance.value = args.item.instance.path
|
||||
confirmModal.value.show()
|
||||
break
|
||||
}
|
||||
@@ -325,13 +321,13 @@ const filteredResults = computed(() => {
|
||||
<Instance
|
||||
v-for="instance in instanceSection.value"
|
||||
ref="instanceComponents"
|
||||
:key="instance.id + instance.install_stage"
|
||||
:key="instance.path + instance.install_stage"
|
||||
:instance="instance"
|
||||
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.id)"
|
||||
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
|
||||
/>
|
||||
</section>
|
||||
</Accordion>
|
||||
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteInstance" />
|
||||
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteProfile" />
|
||||
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
|
||||
<template #play> <PlayIcon /> Play </template>
|
||||
<template #stop> <StopCircleIcon /> Stop </template>
|
||||
|
||||
@@ -21,10 +21,9 @@ import Instance from '@/components/ui/Instance.vue'
|
||||
import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { kill, remove, run } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process.js'
|
||||
import { showInstanceInFolder } from '@/helpers/utils.js'
|
||||
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'
|
||||
|
||||
@@ -61,19 +60,20 @@ const deleteConfirmModal = ref(null)
|
||||
|
||||
const currentDeleteInstance = ref(null)
|
||||
|
||||
async function deleteInstance() {
|
||||
async function deleteProfile() {
|
||||
if (currentDeleteInstance.value) {
|
||||
await remove(currentDeleteInstance.value).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateInstance(p) {
|
||||
await install_duplicate_instance(p).catch(handleError)
|
||||
async function duplicateProfile(p) {
|
||||
await duplicate(p).catch(handleError)
|
||||
}
|
||||
|
||||
const handleInstanceRightClick = async (event, passedInstance) => {
|
||||
const baseOptions = [
|
||||
...(passedInstance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
|
||||
{ name: 'add_content' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'edit' },
|
||||
{ name: 'duplicate' },
|
||||
{ name: 'open_folder' },
|
||||
@@ -85,7 +85,7 @@ const handleInstanceRightClick = async (event, passedInstance) => {
|
||||
},
|
||||
]
|
||||
|
||||
const runningProcesses = await get_by_instance_id(passedInstance.id).catch(handleError)
|
||||
const runningProcesses = await get_by_profile_path(passedInstance.path).catch(handleError)
|
||||
|
||||
const options =
|
||||
runningProcesses.length > 0
|
||||
@@ -97,14 +97,10 @@ const handleInstanceRightClick = async (event, passedInstance) => {
|
||||
...baseOptions,
|
||||
]
|
||||
: [
|
||||
...(passedInstance.quarantined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: 'play',
|
||||
color: 'primary',
|
||||
},
|
||||
]),
|
||||
{
|
||||
name: 'play',
|
||||
color: 'primary',
|
||||
},
|
||||
...baseOptions,
|
||||
]
|
||||
|
||||
@@ -130,14 +126,16 @@ const handleProjectClick = (event, passedInstance) => {
|
||||
const handleOptionsClick = async (args) => {
|
||||
switch (args.option) {
|
||||
case 'play':
|
||||
await run(args.item.id).catch((err) => handleSevereError(err, { instanceId: args.item.id }))
|
||||
await run(args.item.path).catch((err) =>
|
||||
handleSevereError(err, { profilePath: args.item.path }),
|
||||
)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: args.item.loader,
|
||||
game_version: args.item.game_version,
|
||||
})
|
||||
break
|
||||
case 'stop':
|
||||
await kill(args.item.id).catch(handleError)
|
||||
await kill(args.item.path).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
loader: args.item.loader,
|
||||
game_version: args.item.game_version,
|
||||
@@ -146,26 +144,26 @@ const handleOptionsClick = async (args) => {
|
||||
case 'add_content':
|
||||
await router.push({
|
||||
path: `/browse/${args.item.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
||||
query: { i: args.item.id },
|
||||
query: { i: args.item.path },
|
||||
})
|
||||
break
|
||||
case 'edit':
|
||||
await router.push({
|
||||
path: `/instance/${encodeURIComponent(args.item.id)}`,
|
||||
path: `/instance/${encodeURIComponent(args.item.path)}`,
|
||||
})
|
||||
break
|
||||
case 'duplicate':
|
||||
if (args.item.install_stage == 'installed') await duplicateInstance(args.item.id)
|
||||
if (args.item.install_stage == 'installed') await duplicateProfile(args.item.path)
|
||||
break
|
||||
case 'delete':
|
||||
currentDeleteInstance.value = args.item.id
|
||||
currentDeleteInstance.value = args.item.path
|
||||
deleteConfirmModal.value.show()
|
||||
break
|
||||
case 'open_folder':
|
||||
await showInstanceInFolder(args.item.id)
|
||||
await showProfileInFolder(args.item.path)
|
||||
break
|
||||
case 'copy_path':
|
||||
await navigator.clipboard.writeText(args.item.id)
|
||||
await navigator.clipboard.writeText(args.item.path)
|
||||
break
|
||||
case 'install': {
|
||||
await installVersion(
|
||||
@@ -241,7 +239,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="deleteInstance" />
|
||||
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="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">
|
||||
@@ -258,7 +256,7 @@ onUnmounted(() => {
|
||||
0,
|
||||
row.compact ? maxInstancesPerCompactRow : maxInstancesPerRow,
|
||||
)"
|
||||
:key="row.label + instance.id"
|
||||
:key="row.label + instance.path"
|
||||
:instance="instance"
|
||||
:compact="row.compact"
|
||||
:first="instanceIndex === 0"
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2"
|
||||
>
|
||||
<span>{{ formatMessage(messages.notSignedIn) }}</span>
|
||||
<Button type="colored" color="brand" :disabled="loginDisabled" @click="login()">
|
||||
<LogInIcon v-if="!loginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
{{ formatMessage(messages.signInToMinecraft) }}
|
||||
</Button>
|
||||
<ButtonStyled color="brand">
|
||||
<button color="primary" :disabled="loginDisabled" @click="login()">
|
||||
<LogInIcon v-if="!loginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
{{ formatMessage(messages.signInToMinecraft) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<Accordion
|
||||
v-else
|
||||
@@ -58,28 +60,24 @@
|
||||
{{ account.profile.name }}
|
||||
</p>
|
||||
</button>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.removeAccount)"
|
||||
type="quiet"
|
||||
color="red"
|
||||
:label="formatMessage(messages.removeAccount)"
|
||||
class="mr-2 !bg-button-bg !text-primary ![box-shadow:var(--shadow-button)] hover:!bg-red focus-visible:!bg-red hover:!text-[var(--color-accent-contrast)] focus-visible:!text-[var(--color-accent-contrast)]"
|
||||
@click="logout(account.profile.id)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.removeAccount)"
|
||||
class="mr-2"
|
||||
@click="logout(account.profile.id)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-2 px-2 pt-2">
|
||||
<Button
|
||||
v-if="accounts.length > 0"
|
||||
class="w-full !bg-button-bg !text-primary ![box-shadow:var(--shadow-button)]"
|
||||
:disabled="loginDisabled"
|
||||
@click="login()"
|
||||
>
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.addAccount) }}
|
||||
</Button>
|
||||
<ButtonStyled v-if="accounts.length > 0" class="w-full">
|
||||
<button :disabled="loginDisabled" @click="login()">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.addAccount) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
@@ -97,9 +95,8 @@ import {
|
||||
import {
|
||||
Accordion,
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
IconButton,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon, FolderOpenIcon, PlusIcon } from '@modrinth/assets'
|
||||
import { Button, injectNotificationManager, TeleportOverflowMenu } from '@modrinth/ui'
|
||||
import { ButtonStyled, injectNotificationManager, OverflowMenu } from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { add_project_from_path } from '@/helpers/instance'
|
||||
import { add_project_from_path } from '@/helpers/profile.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
@@ -22,40 +22,41 @@ const handleAddContentFromFile = async () => {
|
||||
if (!newProject) return
|
||||
|
||||
for (const project of newProject) {
|
||||
await add_project_from_path(props.instance.id, project.path ?? project).catch(handleError)
|
||||
await add_project_from_path(props.instance.path, project.path ?? project).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchContent = async () => {
|
||||
await router.push({
|
||||
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
|
||||
query: { i: props.instance.id },
|
||||
query: { i: props.instance.path },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="joined-buttons">
|
||||
<Button @click="handleSearchContent">
|
||||
<PlusIcon />
|
||||
Install content
|
||||
</Button>
|
||||
<TeleportOverflowMenu
|
||||
label="More options"
|
||||
:options="[
|
||||
{
|
||||
id: 'from_file',
|
||||
label: 'Add from file',
|
||||
action: handleAddContentFromFile,
|
||||
},
|
||||
]"
|
||||
class="!w-auto !px-2.5 !rounded-xl"
|
||||
>
|
||||
<DropdownIcon />
|
||||
<template #from_file>
|
||||
<FolderOpenIcon />
|
||||
<span class="no-wrap"> Add from file </span>
|
||||
</template>
|
||||
</TeleportOverflowMenu>
|
||||
<ButtonStyled>
|
||||
<button @click="handleSearchContent">
|
||||
<PlusIcon />
|
||||
Install content
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'from_file',
|
||||
action: handleAddContentFromFile,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<DropdownIcon />
|
||||
<template #from_file>
|
||||
<FolderOpenIcon />
|
||||
<span class="no-wrap"> Add from file </span>
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
<template>
|
||||
<div class="flex gap-2 items-center">
|
||||
<div v-if="downloadState.total > 0 || hasActiveLoadingBars" class="relative">
|
||||
<IconButton
|
||||
v-tooltip="downloadToggleLabel"
|
||||
:color="downloadState.hidden > 0 ? 'brand' : undefined"
|
||||
type="quiet"
|
||||
:label="downloadToggleLabel"
|
||||
@click="toggleDownloadNotifications"
|
||||
>
|
||||
<ButtonStyled
|
||||
v-if="hasActiveLoadingBars && !hasVisibleActiveDownloadToasts"
|
||||
color="brand"
|
||||
type="transparent"
|
||||
circular
|
||||
>
|
||||
<button v-tooltip="formatMessage(messages.viewActiveDownloads)" @click="openDownloadToast()">
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</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>
|
||||
<AppUpdateButton />
|
||||
<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"
|
||||
>
|
||||
@@ -24,36 +47,30 @@
|
||||
<div class="text-contrast flex items-center gap-2">
|
||||
<router-link
|
||||
v-tooltip="formatMessage(messages.viewInstance)"
|
||||
:to="`/instance/${encodeURIComponent(selectedProcess.instance.id)}`"
|
||||
:to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`"
|
||||
class="hover:underline"
|
||||
>
|
||||
{{ selectedProcess.instance.name }}
|
||||
{{ selectedProcess.profile.name }}
|
||||
</router-link>
|
||||
<Dropdown
|
||||
v-if="currentProcesses.length > 1"
|
||||
placement="bottom"
|
||||
:triggers="['click']"
|
||||
:hide-triggers="['click']"
|
||||
@show="showInstances = true"
|
||||
@hide="showInstances = false"
|
||||
@show="showProfiles = true"
|
||||
@hide="showProfiles = false"
|
||||
>
|
||||
<IconButton
|
||||
v-tooltip="
|
||||
showInstances
|
||||
? formatMessage(messages.hideMoreRunningInstances)
|
||||
: formatMessage(messages.showMoreRunningInstances)
|
||||
"
|
||||
class="!size-6"
|
||||
type="quiet"
|
||||
size="xs"
|
||||
:label="
|
||||
showInstances
|
||||
? formatMessage(messages.hideMoreRunningInstances)
|
||||
: formatMessage(messages.showMoreRunningInstances)
|
||||
"
|
||||
>
|
||||
<DropdownIcon :class="{ 'rotate-180': !!showInstances }" />
|
||||
</IconButton>
|
||||
<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
|
||||
@@ -76,7 +93,7 @@
|
||||
>
|
||||
<OnlineIndicatorIcon />
|
||||
<span class="mr-auto text-contrast flex items-center gap-2">
|
||||
{{ process.instance.name }}
|
||||
{{ process.profile.name }}
|
||||
<StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
|
||||
</span>
|
||||
</button>
|
||||
@@ -90,7 +107,7 @@
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.viewLogs)"
|
||||
class="active:scale-95 flex"
|
||||
@click.stop="goToTerminal(process.instance.id)"
|
||||
@click.stop="goToTerminal(process.profile.path)"
|
||||
>
|
||||
<TerminalSquareIcon class="text-secondary size-5" />
|
||||
</button>
|
||||
@@ -127,13 +144,14 @@ import {
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
OnlineIndicatorIcon,
|
||||
RefreshCwIcon,
|
||||
StarIcon,
|
||||
StopCircleIcon,
|
||||
TerminalSquareIcon,
|
||||
UnplugIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { IconButton } from '@modrinth/ui'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
injectPopupNotificationManager,
|
||||
@@ -143,18 +161,21 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
|
||||
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { loading_listener, process_listener } from '@/helpers/events'
|
||||
import { get_many as getInstances } from '@/helpers/instance'
|
||||
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()
|
||||
@@ -162,12 +183,12 @@ const { formatMessage } = useVIntl()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const showInstances = ref(false)
|
||||
const showProfiles = ref(false)
|
||||
|
||||
interface RunningProcess {
|
||||
uuid: string
|
||||
instance_id: string
|
||||
instance: GameInstance
|
||||
profile_path: string
|
||||
profile: GameInstance
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -219,32 +240,93 @@ const messages = defineMessages({
|
||||
id: 'app.action-bar.view-active-downloads',
|
||||
defaultMessage: 'View active downloads',
|
||||
},
|
||||
hideDownloads: {
|
||||
id: 'app.action-bar.hide-downloads',
|
||||
defaultMessage: 'Hide active downloads',
|
||||
update: {
|
||||
id: 'app.action-bar.update',
|
||||
defaultMessage: 'Update',
|
||||
},
|
||||
showDownloads: {
|
||||
id: 'app.action-bar.show-downloads',
|
||||
defaultMessage: 'Show active downloads',
|
||||
downloadingUpdate: {
|
||||
id: 'app.action-bar.downloading-update',
|
||||
defaultMessage: 'Downloading update',
|
||||
},
|
||||
reloadToUpdate: {
|
||||
id: 'app.action-bar.reload-to-update',
|
||||
defaultMessage: 'Reload to update',
|
||||
},
|
||||
})
|
||||
|
||||
const downloadState = computed(() => popupNotificationManager.getDownloadState())
|
||||
const downloadToggleLabel = computed(() =>
|
||||
formatMessage(
|
||||
downloadState.value.hidden > 0
|
||||
? messages.showDownloads
|
||||
: downloadState.value.total > 0
|
||||
? messages.hideDownloads
|
||||
: messages.viewActiveDownloads,
|
||||
),
|
||||
)
|
||||
const {
|
||||
downloading,
|
||||
downloadPercent,
|
||||
downloadProgress,
|
||||
finishedDownloading,
|
||||
isVisible: isUpdateVisible,
|
||||
metered,
|
||||
restarting,
|
||||
} = appUpdateState
|
||||
|
||||
function toggleDownloadNotifications(): void {
|
||||
if (downloadState.value.total > 0) {
|
||||
popupNotificationManager.toggleDownloadNotifications()
|
||||
} else if (hasActiveLoadingBars.value) {
|
||||
openDownloadToast()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,22 +337,22 @@ const refresh = async () => {
|
||||
const processes = ((await getRunningProcesses().catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})) ?? []) as Array<{ uuid: string; instance_id: string }>
|
||||
const instanceIds = processes.map((process) => process.instance_id)
|
||||
const instances: GameInstance[] = await getInstances(instanceIds).catch((error) => {
|
||||
})) ?? []) 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 instance = instances.find((item) => process.instance_id === item.id)
|
||||
if (!instance) {
|
||||
const profile = profiles.find((item) => process.profile_path === item.path)
|
||||
if (!profile) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...process,
|
||||
instance,
|
||||
profile,
|
||||
}
|
||||
})
|
||||
.filter((process): process is RunningProcess => process !== null)
|
||||
@@ -303,8 +385,8 @@ const stop = async (process: RunningProcess) => {
|
||||
await killProcess(process.uuid).catch(handleError)
|
||||
|
||||
trackEvent('InstanceStop', {
|
||||
loader: process.instance.loader,
|
||||
game_version: process.instance.game_version,
|
||||
loader: process.profile.loader,
|
||||
game_version: process.profile.game_version,
|
||||
source: 'AppBar',
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -313,18 +395,17 @@ const stop = async (process: RunningProcess) => {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
function goToTerminal(instanceId?: string) {
|
||||
const selectedInstanceId = instanceId ?? selectedProcess.value?.instance.id
|
||||
if (!selectedInstanceId) {
|
||||
function goToTerminal(path?: string) {
|
||||
const selectedPath = path ?? selectedProcess.value?.profile.path
|
||||
if (!selectedPath) {
|
||||
return
|
||||
}
|
||||
router.push(`/instance/${encodeURIComponent(selectedInstanceId)}/logs`)
|
||||
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 terminalNotificationIds = new Map<string, string | number>()
|
||||
const dismissed = ref(false)
|
||||
|
||||
function getLoadingBarKey(loadingBar: LoadingBar): string {
|
||||
@@ -339,7 +420,8 @@ function getLoadingProgress(loadingBar: LoadingBar): number {
|
||||
}
|
||||
|
||||
function getLoadingText(loadingBar: LoadingBar): string {
|
||||
return loadingBar.message ?? ''
|
||||
const percent = Math.floor(getLoadingProgress(loadingBar) * 100)
|
||||
return loadingBar.message ? `${percent}% ${loadingBar.message}` : `${percent}%`
|
||||
}
|
||||
|
||||
function getDisplayIconUrl(icon: string | null | undefined): string | null {
|
||||
@@ -370,73 +452,26 @@ function removeNotification(): void {
|
||||
notificationId.value = null
|
||||
}
|
||||
|
||||
function syncTerminalNotifications(): void {
|
||||
const terminalNotifications = installJobNotifications.terminalNotifications.value
|
||||
const currentJobIds = new Set(terminalNotifications.map((notification) => notification.id))
|
||||
|
||||
for (const terminal of terminalNotifications) {
|
||||
const popupId = terminalNotificationIds.get(terminal.id)
|
||||
let notification = popupId
|
||||
? popupNotificationManager.getNotifications().find((candidate) => candidate.id === popupId)
|
||||
: undefined
|
||||
|
||||
if (!notification) {
|
||||
notification = popupNotificationManager.addPopupNotification({
|
||||
title: terminal.title,
|
||||
text: terminal.text,
|
||||
type: terminal.type,
|
||||
buttons: terminal.buttons,
|
||||
onDismiss: terminal.onDismiss,
|
||||
autoCloseMs: null,
|
||||
})
|
||||
terminalNotificationIds.set(terminal.id, notification.id)
|
||||
continue
|
||||
}
|
||||
|
||||
notification.title = terminal.title
|
||||
notification.text = terminal.text
|
||||
notification.type = terminal.type
|
||||
notification.buttons = terminal.buttons
|
||||
notification.onDismiss = terminal.onDismiss
|
||||
}
|
||||
|
||||
for (const [jobId, popupId] of terminalNotificationIds) {
|
||||
if (!currentJobIds.has(jobId)) {
|
||||
popupNotificationManager.removeNotification(popupId)
|
||||
terminalNotificationIds.delete(jobId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildDownloadItems(): PopupNotificationProgressItem[] {
|
||||
return [
|
||||
...installJobNotifications.progressItems.value,
|
||||
...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,
|
||||
progressType: bar.bar_type?.type === 'pack_import' ? 'bytes' : 'percentage',
|
||||
progressCurrent: bar.current,
|
||||
progressTotal: bar.total,
|
||||
})),
|
||||
]
|
||||
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 hasActiveLoadingBars = computed(
|
||||
() => currentLoadingBars.value.length > 0 || installJobNotifications.active.value,
|
||||
)
|
||||
const hasVisibleActiveDownloadToasts = computed(() => !!getNotification())
|
||||
const hasActiveLoadingBars = computed(() => currentLoadingBars.value.length > 0)
|
||||
|
||||
function updateNotification(resummon = false): void {
|
||||
syncTerminalNotifications()
|
||||
|
||||
if (resummon) {
|
||||
dismissed.value = false
|
||||
}
|
||||
|
||||
if (currentLoadingBars.value.length === 0 && !installJobNotifications.active.value) {
|
||||
if (currentLoadingBars.value.length === 0) {
|
||||
removeNotification()
|
||||
dismissed.value = false
|
||||
return
|
||||
@@ -455,18 +490,14 @@ function updateNotification(resummon = false): void {
|
||||
const progressItems = buildDownloadItems()
|
||||
|
||||
if (notif) {
|
||||
notif.title = installJobNotifications.active.value
|
||||
? installJobNotifications.title.value
|
||||
: formatMessage(messages.downloads)
|
||||
notif.title = formatMessage(messages.downloads)
|
||||
notif.text = undefined
|
||||
notif.progressItems = progressItems
|
||||
notif.progress = undefined
|
||||
notif.waiting = undefined
|
||||
} else {
|
||||
notif = popupNotificationManager.addPopupNotification({
|
||||
title: installJobNotifications.active.value
|
||||
? installJobNotifications.title.value
|
||||
: formatMessage(messages.downloads),
|
||||
title: formatMessage(messages.downloads),
|
||||
type: 'download',
|
||||
autoCloseMs: null,
|
||||
progressItems,
|
||||
@@ -482,8 +513,8 @@ function formatLoadingBars(loadingBar: LoadingBar): LoadingBar {
|
||||
version: formatted.bar_type.version,
|
||||
})
|
||||
}
|
||||
if (formatted.bar_type?.instance_id) {
|
||||
formatted.title = formatted.bar_type.instance_name ?? formatted.bar_type.instance_id
|
||||
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
|
||||
@@ -499,41 +530,31 @@ async function refreshLoadingBars() {
|
||||
|
||||
currentLoadingBars.value = Object.values(bars)
|
||||
.map(formatLoadingBars)
|
||||
.filter(
|
||||
(bar) =>
|
||||
bar?.bar_type?.type !== 'launcher_update' &&
|
||||
![
|
||||
'java_download',
|
||||
'pack_file_download',
|
||||
'pack_download',
|
||||
'minecraft_download',
|
||||
'copy_instance',
|
||||
].includes(bar?.bar_type?.type ?? ''),
|
||||
)
|
||||
.filter((bar) => bar?.bar_type?.type !== 'launcher_update')
|
||||
|
||||
const instanceIds = Array.from(
|
||||
const profilePaths = Array.from(
|
||||
new Set(
|
||||
currentLoadingBars.value
|
||||
.map((bar) => bar.bar_type?.instance_id)
|
||||
.filter((instanceId): instanceId is string => !!instanceId),
|
||||
.map((bar) => bar.bar_type?.profile_path)
|
||||
.filter((path): path is string => !!path),
|
||||
),
|
||||
)
|
||||
const instances = instanceIds.length
|
||||
? await getInstances(instanceIds).catch((error) => {
|
||||
const profiles = profilePaths.length
|
||||
? await getInstances(profilePaths).catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})
|
||||
: []
|
||||
const instanceIconUrls = new Map(
|
||||
instances.map((instance) => [instance.id, getDisplayIconUrl(instance.icon_path)]),
|
||||
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 instanceIconUrl = bar.bar_type?.instance_id
|
||||
? instanceIconUrls.get(bar.bar_type.instance_id)
|
||||
const profileIconUrl = bar.bar_type?.profile_path
|
||||
? profileIconUrls.get(bar.bar_type.profile_path)
|
||||
: null
|
||||
return [getLoadingBarKey(bar), barIconUrl ?? instanceIconUrl ?? null]
|
||||
return [getLoadingBarKey(bar), barIconUrl ?? profileIconUrl ?? null]
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -546,12 +567,6 @@ async function refreshLoadingBars() {
|
||||
updateNotification()
|
||||
}
|
||||
|
||||
const installJobNotifications = await useInstallJobNotifications({
|
||||
router,
|
||||
handleError,
|
||||
onChange: updateNotification,
|
||||
})
|
||||
|
||||
await refreshLoadingBars()
|
||||
|
||||
const unlistenLoading = await loading_listener(async () => {
|
||||
@@ -568,13 +583,25 @@ function selectProcess(process: RunningProcess) {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
removeNotification()
|
||||
terminalNotificationIds.forEach((id) => popupNotificationManager.removeNotification(id))
|
||||
terminalNotificationIds.clear()
|
||||
dismissed.value = false
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
window.removeEventListener('online', handleOnline)
|
||||
unlistenProcess()
|
||||
unlistenLoading()
|
||||
installJobNotifications.dispose()
|
||||
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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
ref="outerRef"
|
||||
data-tauri-drag-region
|
||||
class="min-w-0 overflow-hidden pl-4"
|
||||
class="min-w-0 overflow-hidden pl-3"
|
||||
:class="{ 'breadcrumb-fade-mask': isOverflowing }"
|
||||
:style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined"
|
||||
@mouseenter="onMouseEnter"
|
||||
@@ -11,48 +11,30 @@
|
||||
<div
|
||||
ref="innerRef"
|
||||
data-tauri-drag-region
|
||||
class="flex w-fit items-center gap-2 pr-4"
|
||||
class="flex w-fit items-center gap-1"
|
||||
:class="{ 'breadcrumbs-scroll': isAnimating }"
|
||||
@animationiteration="onAnimationIteration"
|
||||
>
|
||||
<template v-for="(breadcrumb, index) in breadcrumbs" :key="breadcrumb.slot">
|
||||
<component
|
||||
:is="index < breadcrumbs.length - 1 && breadcrumb.to ? RouterLink : 'span'"
|
||||
v-bind="index < breadcrumbs.length - 1 && breadcrumb.to ? { to: breadcrumb.to } : {}"
|
||||
:data-tauri-drag-region="index === breadcrumbs.length - 1 ? '' : undefined"
|
||||
class="flex shrink-0 items-center gap-1.5 whitespace-nowrap text-base font-medium leading-6"
|
||||
:class="
|
||||
index === breadcrumbs.length - 1
|
||||
? 'cursor-default select-none text-contrast'
|
||||
: 'text-primary hover:text-contrast'
|
||||
"
|
||||
:aria-current="index === breadcrumbs.length - 1 ? 'page' : undefined"
|
||||
{{ 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"
|
||||
>
|
||||
<Avatar
|
||||
v-if="breadcrumb.visual?.type === 'image'"
|
||||
:src="breadcrumb.visual.src"
|
||||
:alt="breadcrumb.visual.alt ?? breadcrumb.label"
|
||||
:circle="breadcrumb.visual.circle"
|
||||
:tint-by="breadcrumb.visual.tintBy ?? breadcrumb.id"
|
||||
size="20px"
|
||||
no-shadow
|
||||
raised
|
||||
class="inline-block shrink-0 align-middle"
|
||||
:class="{ '!rounded-md': !breadcrumb.visual.circle }"
|
||||
/>
|
||||
<component
|
||||
:is="breadcrumb.visual.component"
|
||||
v-else-if="breadcrumb.visual?.type === 'icon'"
|
||||
class="size-5 shrink-0 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ breadcrumb.label }}</span>
|
||||
</component>
|
||||
<ChevronRightIcon
|
||||
v-if="index < breadcrumbs.length - 1"
|
||||
{{ resolveLabel(breadcrumb.name) }}
|
||||
</router-link>
|
||||
<span
|
||||
v-else
|
||||
data-tauri-drag-region
|
||||
class="size-5 shrink-0 text-primary"
|
||||
/>
|
||||
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>
|
||||
</div>
|
||||
@@ -60,13 +42,34 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
import { Avatar } from '@modrinth/ui'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { injectBreadcrumbManager } from '@/providers/breadcrumbs'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
|
||||
const { entries: breadcrumbs } = injectBreadcrumbManager()
|
||||
interface Breadcrumb {
|
||||
name: string
|
||||
link?: string
|
||||
query?: Record<string, string>
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const breadcrumbData = useBreadcrumbs()
|
||||
|
||||
const breadcrumbs = computed<Breadcrumb[]>(() => {
|
||||
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)
|
||||
@@ -80,13 +83,9 @@ let stopping = false
|
||||
|
||||
function checkOverflow() {
|
||||
if (!outerRef.value || !innerRef.value) return
|
||||
const outerStyles = window.getComputedStyle(outerRef.value)
|
||||
const horizontalPadding =
|
||||
Number.parseFloat(outerStyles.paddingLeft) + Number.parseFloat(outerStyles.paddingRight)
|
||||
const availableWidth = outerRef.value.clientWidth - horizontalPadding
|
||||
const overflow = innerRef.value.scrollWidth - availableWidth
|
||||
const overflow = innerRef.value.scrollWidth - outerRef.value.clientWidth
|
||||
isOverflowing.value = overflow > 0
|
||||
overflowAmount.value = overflow
|
||||
overflowAmount.value = overflow + 12
|
||||
}
|
||||
|
||||
function onMouseEnter() {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)">
|
||||
<hr v-if="option.type === 'divider'" class="divider" />
|
||||
<div
|
||||
v-else-if="!(isInstanceLink(item) && option.name === `add_content`)"
|
||||
v-else-if="!(isLinkedData(item) && option.name === `add_content`)"
|
||||
class="item clickable"
|
||||
:class="[option.color ?? 'base']"
|
||||
>
|
||||
@@ -64,10 +64,10 @@ defineExpose({
|
||||
},
|
||||
})
|
||||
|
||||
const isInstanceLink = (item) => {
|
||||
if (item.instance != undefined && item.instance.link) {
|
||||
const isLinkedData = (item) => {
|
||||
if (item.instance != undefined && item.instance.linked_data) {
|
||||
return true
|
||||
} else if (item != undefined && item.link) {
|
||||
} else if (item != undefined && item.linked_data) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -9,20 +9,14 @@ import {
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
ButtonLink,
|
||||
Collapsible,
|
||||
IconButton,
|
||||
injectNotificationManager,
|
||||
} from '@modrinth/ui'
|
||||
import { ButtonStyled, Collapsible, injectNotificationManager } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { ChatIcon } from '@/assets/icons'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { install_existing_instance } from '@/helpers/install'
|
||||
import { install } from '@/helpers/profile.js'
|
||||
import { cancel_directory_change } from '@/helpers/settings.ts'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
@@ -58,6 +52,10 @@ defineExpose({
|
||||
if (errorVal.message.includes('because the target machine actively refused it')) {
|
||||
metadata.value.hostsFile = true
|
||||
}
|
||||
} else if (errorVal.message && errorVal.message.includes('User is not logged in')) {
|
||||
title.value = 'Sign in to Minecraft'
|
||||
errorType.value = 'minecraft_sign_in'
|
||||
supportLink.value = 'https://support.modrinth.com'
|
||||
} else if (errorVal.message && errorVal.message.includes('Move directory error:')) {
|
||||
title.value = 'Could not change app directory'
|
||||
errorType.value = 'directory_move'
|
||||
@@ -74,7 +72,7 @@ defineExpose({
|
||||
title.value = 'No loader selected'
|
||||
errorType.value = 'no_loader_version'
|
||||
supportLink.value = 'https://support.modrinth.com'
|
||||
metadata.value.instanceId = context.instanceId
|
||||
metadata.value.profilePath = context.profilePath
|
||||
} else if (source === 'state_init') {
|
||||
title.value = 'Error initializing Modrinth App'
|
||||
errorType.value = 'state_init'
|
||||
@@ -127,7 +125,7 @@ const loadingRepair = ref(false)
|
||||
async function repairInstance() {
|
||||
loadingRepair.value = true
|
||||
try {
|
||||
await install_existing_instance(metadata.value.instanceId, false)
|
||||
await install(metadata.value.profilePath, false)
|
||||
errorModal.value.hide()
|
||||
} catch (err) {
|
||||
handleSevereError(err)
|
||||
@@ -245,6 +243,20 @@ async function copyToClipboard(text) {
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="errorType === 'minecraft_sign_in'">
|
||||
<p>
|
||||
To play this instance, you must sign in through Microsoft below. If you don't have a
|
||||
Minecraft account, you can purchase the game on the
|
||||
<a href="https://www.minecraft.net/en-us/store/minecraft-java-bedrock-edition-pc"
|
||||
>Minecraft website</a
|
||||
>.
|
||||
</p>
|
||||
<div class="cta-button">
|
||||
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
|
||||
<LogInIcon /> Sign in to Minecraft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else-if="errorType === 'state_init'">
|
||||
<p>
|
||||
Modrinth App failed to load correctly. This may be because of a corrupted file, or
|
||||
@@ -279,10 +291,12 @@ async function copyToClipboard(text) {
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonLink :href="supportLink" @click="errorModal.hide()"
|
||||
><ChatIcon /> Get support</ButtonLink
|
||||
>
|
||||
<Button v-if="closable" @click="errorModal.hide()"><XIcon /> Close</Button>
|
||||
<ButtonStyled>
|
||||
<a :href="supportLink" @click="errorModal.hide()"><ChatIcon /> Get support</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="closable">
|
||||
<button @click="errorModal.hide()"><XIcon /> Close</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<template v-if="hasDebugInfo">
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -311,15 +325,16 @@ async function copyToClipboard(text) {
|
||||
>
|
||||
{{ debugInfo }}
|
||||
</div>
|
||||
<IconButton
|
||||
v-tooltip="'Copy debug info'"
|
||||
:label="'Copy debug info'"
|
||||
:disabled="copied"
|
||||
@click="copyToClipboard(debugInfo)"
|
||||
>
|
||||
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
|
||||
<template v-else> <CopyIcon /> </template>
|
||||
</IconButton>
|
||||
<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>
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
<script setup>
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { WrenchIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
Accordion,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
FileTreeSelect,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
import { ref, shallowRef } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { PackageIcon } from '@/assets/icons'
|
||||
import { export_instance_mrpack, get_pack_export_candidates } from '@/helpers/instance'
|
||||
import { PackageIcon, VersionIcon } from '@/assets/icons'
|
||||
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' },
|
||||
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack Name' },
|
||||
modpackNamePlaceholder: {
|
||||
id: 'app.export-modal.modpack-name-placeholder',
|
||||
defaultMessage: 'Modpack name',
|
||||
@@ -38,7 +39,15 @@ const messages = defineMessages({
|
||||
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({
|
||||
@@ -50,9 +59,8 @@ const props = defineProps({
|
||||
|
||||
defineExpose({
|
||||
show: () => {
|
||||
resetExportState()
|
||||
exportModal.value.show()
|
||||
void initFiles().catch(handleError)
|
||||
initFiles()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -60,28 +68,63 @@ const exportModal = ref(null)
|
||||
const nameInput = ref(props.instance.name)
|
||||
const exportDescription = ref('')
|
||||
const versionInput = ref('1.0.0')
|
||||
const files = shallowRef([])
|
||||
const includedFilePaths = ref([])
|
||||
const excludedFilePaths = ref([])
|
||||
const fileTreeKey = ref(0)
|
||||
const filesLoadId = ref(0)
|
||||
const directoryEntries = new Map()
|
||||
const currentDirectory = ref('')
|
||||
const files = ref([])
|
||||
const folders = ref([])
|
||||
|
||||
async function initFiles() {
|
||||
const loadId = ++filesLoadId.value
|
||||
const exportCandidates = await get_pack_export_candidates(props.instance.id)
|
||||
if (loadId !== filesLoadId.value) return
|
||||
|
||||
files.value = exportCandidates
|
||||
directoryEntries.set('', exportCandidates)
|
||||
currentDirectory.value = ''
|
||||
includedFilePaths.value = files.value
|
||||
.filter((file) => !file.disabled && file.defaultSelected)
|
||||
.map((file) => file.path)
|
||||
const initFiles = async () => {
|
||||
const newFolders = new Map()
|
||||
const sep = '/'
|
||||
files.value = []
|
||||
await get_pack_export_candidates(props.instance.path).then((filePaths) =>
|
||||
filePaths
|
||||
.map((folder) => ({
|
||||
path: folder,
|
||||
name: folder.split(sep).pop(),
|
||||
selected:
|
||||
folder.startsWith('mods') ||
|
||||
folder.startsWith('datapacks') ||
|
||||
folder.startsWith('resourcepacks') ||
|
||||
folder.startsWith('shaderpacks') ||
|
||||
folder.startsWith('config'),
|
||||
disabled:
|
||||
folder === 'profile.json' ||
|
||||
folder.startsWith('modrinth_logs') ||
|
||||
folder.startsWith('.fabric') ||
|
||||
folder.startsWith('__MACOSX'),
|
||||
}))
|
||||
.forEach((pathData) => {
|
||||
const parent = pathData.path.split(sep).slice(0, -1).join(sep)
|
||||
if (parent !== '') {
|
||||
if (newFolders.has(parent)) {
|
||||
newFolders.get(parent).push(pathData)
|
||||
} else {
|
||||
newFolders.set(parent, [pathData])
|
||||
}
|
||||
} else {
|
||||
files.value.push(pathData)
|
||||
}
|
||||
}),
|
||||
)
|
||||
folders.value = [...newFolders.entries()].map(([name, value]) => [
|
||||
{
|
||||
name,
|
||||
showingMore: false,
|
||||
},
|
||||
value,
|
||||
])
|
||||
}
|
||||
|
||||
await initFiles()
|
||||
|
||||
const exportPack = async () => {
|
||||
const filesToExport = files.value.filter((file) => file.selected).map((file) => file.path)
|
||||
folders.value.forEach((args) => {
|
||||
args[1].forEach((child) => {
|
||||
if (child.selected) {
|
||||
filesToExport.push(child.path)
|
||||
}
|
||||
})
|
||||
})
|
||||
const outputPath = await save({
|
||||
defaultPath: `${nameInput.value} ${versionInput.value}.mrpack`,
|
||||
filters: [
|
||||
@@ -93,11 +136,10 @@ const exportPack = async () => {
|
||||
})
|
||||
|
||||
if (outputPath) {
|
||||
export_instance_mrpack(
|
||||
props.instance.id,
|
||||
export_profile_mrpack(
|
||||
props.instance.path,
|
||||
outputPath,
|
||||
includedFilePaths.value,
|
||||
excludedFilePaths.value,
|
||||
filesToExport,
|
||||
versionInput.value,
|
||||
exportDescription.value,
|
||||
nameInput.value,
|
||||
@@ -105,118 +147,108 @@ const exportPack = async () => {
|
||||
exportModal.value.hide()
|
||||
}
|
||||
}
|
||||
|
||||
function resetExportState() {
|
||||
nameInput.value = props.instance.name
|
||||
exportDescription.value = ''
|
||||
versionInput.value = '1.0.0'
|
||||
files.value = []
|
||||
includedFilePaths.value = []
|
||||
excludedFilePaths.value = []
|
||||
fileTreeKey.value += 1
|
||||
directoryEntries.clear()
|
||||
currentDirectory.value = ''
|
||||
}
|
||||
|
||||
async function loadExportDirectory(path) {
|
||||
const normalizedPath = normalizeExportPath(path)
|
||||
currentDirectory.value = normalizedPath
|
||||
|
||||
const cachedEntries = directoryEntries.get(normalizedPath)
|
||||
if (cachedEntries) {
|
||||
files.value = cachedEntries
|
||||
return
|
||||
}
|
||||
|
||||
const loadId = filesLoadId.value
|
||||
files.value = []
|
||||
|
||||
try {
|
||||
const childItems = await get_pack_export_candidates(
|
||||
props.instance.id,
|
||||
normalizedPath || undefined,
|
||||
)
|
||||
if (loadId !== filesLoadId.value) return
|
||||
|
||||
directoryEntries.set(normalizedPath, childItems)
|
||||
if (currentDirectory.value === normalizedPath) {
|
||||
files.value = childItems
|
||||
}
|
||||
} catch {
|
||||
if (currentDirectory.value === normalizedPath) files.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExportPath(path) {
|
||||
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="exportModal"
|
||||
:header="formatMessage(messages.header)"
|
||||
scrollable
|
||||
width="46rem"
|
||||
max-width="calc(100vw - 2rem)"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<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 w-full">
|
||||
<p class="text-contrast font-semibold">{{ formatMessage(messages.modpackNameLabel) }}</p>
|
||||
<div class="labeled_input">
|
||||
<p>{{ formatMessage(messages.modpackNameLabel) }}</p>
|
||||
<StyledInput
|
||||
v-model="nameInput"
|
||||
:icon="PackageIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="labeled_input w-full">
|
||||
<p class="text-contrast font-semibold">
|
||||
{{ formatMessage(messages.versionNumberLabel) }}
|
||||
</p>
|
||||
<div class="labeled_input">
|
||||
<p>{{ formatMessage(messages.versionNumberLabel) }}</p>
|
||||
<StyledInput
|
||||
v-model="versionInput"
|
||||
:icon="VersionIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 min-w-0">
|
||||
<p class="m-0 text-contrast font-semibold">
|
||||
{{ formatMessage(commonMessages.descriptionLabel) }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="m-0">{{ formatMessage(commonMessages.descriptionLabel) }}</p>
|
||||
<StyledInput
|
||||
v-model="exportDescription"
|
||||
multiline
|
||||
:placeholder="formatMessage(messages.descriptionPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<FileTreeSelect
|
||||
:key="fileTreeKey"
|
||||
v-model="includedFilePaths"
|
||||
v-model:excluded-paths="excludedFilePaths"
|
||||
class="min-w-0"
|
||||
:items="files"
|
||||
lazy
|
||||
@navigate="loadExportDirectory"
|
||||
/>
|
||||
</div>
|
||||
<template #actions>
|
||||
<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"
|
||||
>
|
||||
<template #title>
|
||||
<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"
|
||||
: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"
|
||||
/>
|
||||
</div>
|
||||
</Accordion>
|
||||
</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">
|
||||
<Button type="outlined" @click="exportModal.hide">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button type="colored" color="brand" @click="exportPack">
|
||||
<PackageIcon />
|
||||
{{ formatMessage(messages.exportButton) }}
|
||||
</Button>
|
||||
<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>
|
||||
</template>
|
||||
</NewModal>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, ExcitedRinthbot, RefreshCwIcon, ServerStackIcon } from '@modrinth/assets'
|
||||
import { Button, commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
appUpdateState,
|
||||
downloadAvailableAppUpdate,
|
||||
installAvailableAppUpdate,
|
||||
} from '@/providers/app-update'
|
||||
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.hosting.update-required.title',
|
||||
defaultMessage: 'Modrinth App update required',
|
||||
},
|
||||
description: {
|
||||
id: 'app.hosting.update-required.description',
|
||||
defaultMessage: 'You need to update to use Modrinth Hosting through the Modrinth App',
|
||||
},
|
||||
downloadToUpdate: {
|
||||
id: 'app.hosting.update-required.download',
|
||||
defaultMessage: 'Download to update',
|
||||
},
|
||||
downloadingUpdate: {
|
||||
id: 'app.action-bar.downloading-update',
|
||||
defaultMessage: 'Downloading update',
|
||||
},
|
||||
reloadToUpdate: {
|
||||
id: 'app.action-bar.reload-to-update',
|
||||
defaultMessage: 'Reload to update',
|
||||
},
|
||||
excitedRinthbotAlt: {
|
||||
id: 'app.hosting.update-required.rinthbot-alt',
|
||||
defaultMessage: 'Excited Modrinth Bot',
|
||||
},
|
||||
})
|
||||
|
||||
useRootBreadcrumb({
|
||||
slot: 'root',
|
||||
id: 'servers',
|
||||
label: () => formatMessage(commonMessages.serversLabel),
|
||||
to: '/hosting/manage/',
|
||||
visual: { type: 'icon', component: ServerStackIcon },
|
||||
})
|
||||
|
||||
const { downloading, downloadPercent, downloadProgress, finishedDownloading } = appUpdateState
|
||||
|
||||
const isUpdateDownloading = computed(
|
||||
() =>
|
||||
downloading.value ||
|
||||
(downloadProgress.value > 0 && downloadProgress.value < 1 && !finishedDownloading.value),
|
||||
)
|
||||
|
||||
async function handleUpdateClick() {
|
||||
if (isUpdateDownloading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
await installAvailableAppUpdate()
|
||||
} else {
|
||||
await downloadAvailableAppUpdate()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="box-border flex min-h-full items-center justify-center p-4">
|
||||
<div class="relative mx-auto w-full max-w-xl pt-28">
|
||||
<img
|
||||
:src="ExcitedRinthbot"
|
||||
:alt="formatMessage(messages.excitedRinthbotAlt)"
|
||||
class="absolute right-8 top-0 h-28 w-auto md:right-20"
|
||||
/>
|
||||
<div class="relative flex flex-col gap-5 rounded-lg bg-bg-raised p-7 shadow-lg">
|
||||
<div
|
||||
class="absolute left-0 top-0 h-px w-full bg-gradient-to-r from-transparent via-green-500 to-transparent opacity-40"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 2rem,
|
||||
var(--color-green) calc(100% - 13rem),
|
||||
var(--color-green) calc(100% - 5rem),
|
||||
transparent calc(100% - 2rem)
|
||||
);
|
||||
"
|
||||
></div>
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
<h1 class="m-0 text-3xl font-extrabold">
|
||||
{{ formatMessage(messages.title) }}
|
||||
</h1>
|
||||
<p class="m-0 text-lg">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</p>
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="isUpdateDownloading"
|
||||
:aria-busy="isUpdateDownloading"
|
||||
@click="handleUpdateClick"
|
||||
>
|
||||
<RefreshCwIcon v-if="finishedDownloading" />
|
||||
<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-if="finishedDownloading">
|
||||
{{ formatMessage(messages.reloadToUpdate) }}
|
||||
</span>
|
||||
<span v-else>{{ formatMessage(messages.downloadToUpdate) }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
StopCircleIcon,
|
||||
TimerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, IconButton, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
|
||||
import { Avatar, ButtonStyled, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
@@ -15,10 +15,9 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import { showInstanceInFolder } from '@/helpers/utils.js'
|
||||
import { get_by_profile_path } from '@/helpers/process'
|
||||
import { finish_install, kill, run } from '@/helpers/profile'
|
||||
import { showProfileInFolder } from '@/helpers/utils.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
@@ -55,21 +54,20 @@ const installed = computed(() => props.instance.install_stage === 'installed')
|
||||
const router = useRouter()
|
||||
|
||||
const seeInstance = async () => {
|
||||
await router.push(`/instance/${encodeURIComponent(props.instance.id)}`)
|
||||
await router.push(`/instance/${encodeURIComponent(props.instance.path)}`)
|
||||
}
|
||||
|
||||
const checkProcess = async () => {
|
||||
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError)
|
||||
const runningProcesses = await get_by_profile_path(props.instance.path).catch(handleError)
|
||||
|
||||
playing.value = runningProcesses.length > 0
|
||||
}
|
||||
|
||||
const play = async (e, context) => {
|
||||
e?.stopPropagation()
|
||||
if (props.instance.quarantined) return
|
||||
loading.value = true
|
||||
await run(props.instance.id)
|
||||
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
|
||||
await run(props.instance.path)
|
||||
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
|
||||
.finally(() => {
|
||||
trackEvent('InstanceStart', {
|
||||
loader: props.instance.loader,
|
||||
@@ -84,7 +82,7 @@ const stop = async (e, context) => {
|
||||
e?.stopPropagation()
|
||||
playing.value = false
|
||||
|
||||
await kill(props.instance.id).catch(handleError)
|
||||
await kill(props.instance.path).catch(handleError)
|
||||
|
||||
trackEvent('InstanceStop', {
|
||||
loader: props.instance.loader,
|
||||
@@ -95,33 +93,18 @@ const stop = async (e, context) => {
|
||||
|
||||
const repair = async (e) => {
|
||||
e?.stopPropagation()
|
||||
if (props.instance.quarantined) return
|
||||
|
||||
if (
|
||||
props.instance.install_stage !== 'pack_installed' &&
|
||||
(props.instance.link?.type === 'modrinth_modpack' ||
|
||||
props.instance.link?.type === 'server_project_modpack')
|
||||
) {
|
||||
await install_pack_to_existing_instance(props.instance.id, {
|
||||
type: 'fromVersionId',
|
||||
project_id: props.instance.link.project_id ?? props.instance.link.server_project_id ?? '',
|
||||
version_id: props.instance.link.version_id ?? props.instance.link.content_version_id ?? '',
|
||||
title: props.instance.name,
|
||||
}).catch(handleError)
|
||||
} else {
|
||||
await install_existing_instance(props.instance.id, false).catch(handleError)
|
||||
}
|
||||
await finish_install(props.instance).catch(handleError)
|
||||
}
|
||||
|
||||
const openFolder = async () => {
|
||||
await showInstanceInFolder(props.instance.id)
|
||||
await showProfileInFolder(props.instance.path)
|
||||
}
|
||||
|
||||
const addContent = async () => {
|
||||
if (props.instance.quarantined) return
|
||||
await router.push({
|
||||
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
||||
query: { i: props.instance.id },
|
||||
query: { i: props.instance.path },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,7 +120,7 @@ defineExpose({
|
||||
const currentEvent = ref(null)
|
||||
|
||||
const unlisten = await process_listener((e) => {
|
||||
if (e.instance_id === props.instance.id) {
|
||||
if (e.profile_path_id === props.instance.path) {
|
||||
currentEvent.value = e.event
|
||||
if (e.event === 'finished') {
|
||||
playing.value = false
|
||||
@@ -145,9 +128,7 @@ const unlisten = await process_listener((e) => {
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
checkProcess()
|
||||
})
|
||||
onMounted(() => checkProcess())
|
||||
onUnmounted(() => unlisten())
|
||||
</script>
|
||||
|
||||
@@ -161,44 +142,33 @@ onUnmounted(() => unlisten())
|
||||
<Avatar
|
||||
size="48px"
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
:tint-by="instance.id"
|
||||
:tint-by="instance.path"
|
||||
alt="Mod card"
|
||||
/>
|
||||
<div class="h-full flex items-center font-bold text-contrast leading-normal">
|
||||
<span class="line-clamp-2">{{ instance.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<IconButton
|
||||
v-if="playing"
|
||||
v-tooltip="'Stop'"
|
||||
type="colored"
|
||||
color="red"
|
||||
:label="'Stop'"
|
||||
@mouseenter="checkProcess"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="modLoading"
|
||||
v-tooltip="'Instance is loading...'"
|
||||
:label="'Instance is loading...'"
|
||||
disabled
|
||||
>
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="!instance.quarantined"
|
||||
v-tooltip="'Play'"
|
||||
:type="first ? 'colored' : 'base'"
|
||||
:color="first ? 'brand' : undefined"
|
||||
label="Play"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
<!-- Translate for optical centering -->
|
||||
<PlayIcon class="translate-x-[1px]" />
|
||||
</IconButton>
|
||||
<ButtonStyled v-if="playing" color="red" circular @mousehover="checkProcess">
|
||||
<button v-tooltip="'Stop'" @click="(e) => stop(e, 'InstanceCard')">
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="modLoading" color="standard" circular>
|
||||
<button v-tooltip="'Instance is loading...'" disabled>
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else :color="first ? 'brand' : 'standard'" circular>
|
||||
<button
|
||||
v-tooltip="'Play'"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<!-- Translate for optical centering -->
|
||||
<PlayIcon class="translate-x-[1px]" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
|
||||
<TimerIcon />
|
||||
@@ -221,56 +191,47 @@ onUnmounted(() => unlisten())
|
||||
<Avatar
|
||||
size="48px"
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
:tint-by="instance.id"
|
||||
:tint-by="instance.path"
|
||||
alt="Mod card"
|
||||
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
|
||||
/>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<IconButton
|
||||
v-if="playing"
|
||||
v-tooltip="'Stop'"
|
||||
type="colored"
|
||||
color="red"
|
||||
size="xl"
|
||||
:label="'Stop'"
|
||||
:class="{ 'scale-100 opacity-100': playing }"
|
||||
class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</IconButton>
|
||||
<ButtonStyled v-if="playing" size="large" color="red" circular>
|
||||
<button
|
||||
v-tooltip="'Stop'"
|
||||
:class="{ 'scale-100 opacity-100': playing }"
|
||||
class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<SpinnerIcon
|
||||
v-else-if="modLoading || installing"
|
||||
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
|
||||
class="animate-spin w-8 h-8"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<IconButton
|
||||
v-else-if="!installed && !instance.quarantined"
|
||||
v-tooltip="'Repair'"
|
||||
type="colored"
|
||||
color="brand"
|
||||
size="xl"
|
||||
:label="'Repair'"
|
||||
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
|
||||
@click="(e) => repair(e)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="!instance.quarantined"
|
||||
v-tooltip="'Play'"
|
||||
type="colored"
|
||||
color="brand"
|
||||
size="xl"
|
||||
:label="'Play'"
|
||||
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
<PlayIcon class="translate-x-[2px]" />
|
||||
</IconButton>
|
||||
<ButtonStyled v-else-if="!installed" size="large" color="brand" circular>
|
||||
<button
|
||||
v-tooltip="'Repair'"
|
||||
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
|
||||
@click="(e) => repair(e)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else size="large" color="brand" circular>
|
||||
<button
|
||||
v-tooltip="'Play'"
|
||||
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<PlayIcon class="translate-x-[2px]" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
|
||||
import { Avatar, ButtonLink, FormattedTag } from '@modrinth/ui'
|
||||
import { Avatar, ButtonStyled, FormattedTag } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -22,7 +22,7 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const instanceLink = computed(() => {
|
||||
const base = `/instance/${encodeURIComponent(props.instance.id)}`
|
||||
const base = `/instance/${encodeURIComponent(props.instance.path)}`
|
||||
return props.backTab ? `${base}/${props.backTab}` : base
|
||||
})
|
||||
</script>
|
||||
@@ -48,7 +48,9 @@ const instanceLink = computed(() => {
|
||||
</span>
|
||||
</span>
|
||||
</router-link>
|
||||
<ButtonLink :to="instanceLink"> <LeftArrowIcon /> Back to instance </ButtonLink>
|
||||
<ButtonStyled>
|
||||
<router-link :to="instanceLink"> <LeftArrowIcon /> Back to instance </router-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<ModalWrapper
|
||||
ref="detectJavaModal"
|
||||
:header="formatMessage(messages.title)"
|
||||
:show-ad-on-close="false"
|
||||
>
|
||||
<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 }">
|
||||
@@ -14,95 +10,51 @@
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center justify-end">
|
||||
<Button v-if="currentSelected.path === row.path" disabled>
|
||||
<CheckIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.selected) }}
|
||||
</Button>
|
||||
<Button v-else @click="setJavaInstall(row)">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.select) }}
|
||||
</Button>
|
||||
<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>
|
||||
</template>
|
||||
<template #empty-state>
|
||||
<div class="p-4 text-secondary">
|
||||
{{ formatMessage(messages.noInstallationsFound) }}
|
||||
</div>
|
||||
<div class="p-4 text-secondary">No java installations found!</div>
|
||||
</template>
|
||||
</Table>
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
type="outlined"
|
||||
class="!border-surface-4 !border"
|
||||
@click="$refs.detectJavaModal.hide()"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</Button>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="!shadow-none !border-surface-4 !border"
|
||||
@click="$refs.detectJavaModal.hide()"
|
||||
>
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
<script setup>
|
||||
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { Button, defineMessages, injectNotificationManager, Table, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ButtonStyled, injectNotificationManager, Table } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { find_filtered_jres } from '@/helpers/jre.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.java-detection.title',
|
||||
defaultMessage: 'Select Java installation',
|
||||
},
|
||||
versionColumn: {
|
||||
id: 'app.java-detection.columns.version',
|
||||
defaultMessage: 'Version',
|
||||
},
|
||||
pathColumn: {
|
||||
id: 'app.java-detection.columns.path',
|
||||
defaultMessage: 'Path',
|
||||
},
|
||||
actionsColumn: {
|
||||
id: 'app.java-detection.columns.actions',
|
||||
defaultMessage: 'Actions',
|
||||
},
|
||||
selected: {
|
||||
id: 'app.java-detection.selected',
|
||||
defaultMessage: 'Selected',
|
||||
},
|
||||
select: {
|
||||
id: 'app.java-detection.select',
|
||||
defaultMessage: 'Select',
|
||||
},
|
||||
noInstallationsFound: {
|
||||
id: 'app.java-detection.no-installations-found',
|
||||
defaultMessage: 'No Java installations found.',
|
||||
},
|
||||
cancel: {
|
||||
id: 'app.java-detection.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
})
|
||||
|
||||
const chosenInstallOptions = ref([])
|
||||
const detectJavaModal = ref(null)
|
||||
const currentSelected = ref({})
|
||||
const javaInstallColumns = computed(() => [
|
||||
{ key: 'version', label: formatMessage(messages.versionColumn), width: '9rem' },
|
||||
{ key: 'path', label: formatMessage(messages.pathColumn) },
|
||||
{
|
||||
key: 'actions',
|
||||
label: formatMessage(messages.actionsColumn),
|
||||
align: 'right',
|
||||
width: '10rem',
|
||||
},
|
||||
])
|
||||
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) => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
autocomplete="off"
|
||||
:disabled="props.disabled"
|
||||
:model-value="props.modelValue ? props.modelValue.path : ''"
|
||||
:placeholder="placeholder ?? formatMessage(messages.pathPlaceholder)"
|
||||
:placeholder="placeholder ?? '/path/to/java'"
|
||||
wrapper-class="installation-input"
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
@@ -17,73 +17,57 @@
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
type="quiet"
|
||||
<ButtonStyled
|
||||
:color="
|
||||
!hoveringTest && !testingJava
|
||||
? testingJavaSuccess === true
|
||||
? 'green'
|
||||
: 'red'
|
||||
: undefined
|
||||
: 'standard'
|
||||
"
|
||||
:aria-label="formatMessage(messages.testJavaInstallation)"
|
||||
class="!text-[var(--legacy-button-color,var(--color-base))] [&>svg]:!text-[var(--legacy-button-color,var(--color-primary))]"
|
||||
:disabled="testingJava || props.disabled"
|
||||
:style="{
|
||||
'--legacy-button-color':
|
||||
(!hoveringTest && !testingJava
|
||||
? testingJavaSuccess === true
|
||||
? 'green'
|
||||
: 'red'
|
||||
: 'standard') &&
|
||||
(!hoveringTest && !testingJava
|
||||
? testingJavaSuccess === true
|
||||
? 'green'
|
||||
: 'red'
|
||||
: 'standard') !== 'standard'
|
||||
? `var(--color-${
|
||||
!hoveringTest && !testingJava
|
||||
? testingJavaSuccess === true
|
||||
? 'green'
|
||||
: 'red'
|
||||
: 'standard'
|
||||
})`
|
||||
: undefined,
|
||||
}"
|
||||
@click="runTest(props.modelValue?.path)"
|
||||
@mouseenter="!props.disabled && (hoveringTest = true)"
|
||||
@mouseleave="hoveringTest = false"
|
||||
color-fill="text"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<span class="installation-buttons">
|
||||
<Button
|
||||
v-if="props.version"
|
||||
v-tooltip="
|
||||
testingJavaSuccess === true ? formatMessage(messages.alreadyInstalled) : undefined
|
||||
"
|
||||
:disabled="props.disabled || installingJava || testingJavaSuccess === true"
|
||||
@click="reinstallJava"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{
|
||||
installingJava
|
||||
? formatMessage(messages.installing)
|
||||
: formatMessage(messages.installRecommended)
|
||||
}}
|
||||
</Button>
|
||||
<Button :disabled="props.disabled" @click="autoDetect">
|
||||
<SearchIcon />
|
||||
{{ formatMessage(messages.detect) }}
|
||||
</Button>
|
||||
<Button :disabled="props.disabled" @click="handleJavaFileInput()">
|
||||
<FolderSearchIcon />
|
||||
{{ formatMessage(messages.browse) }}
|
||||
</Button>
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -98,13 +82,7 @@ import {
|
||||
SpinnerIcon,
|
||||
XCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ButtonStyled, injectNotificationManager, StyledInput } from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
@@ -114,38 +92,6 @@ import { trackEvent } from '@/helpers/analytics'
|
||||
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
pathPlaceholder: {
|
||||
id: 'app.java-selector.path.placeholder',
|
||||
defaultMessage: '/path/to/java',
|
||||
},
|
||||
testJavaInstallation: {
|
||||
id: 'app.java-selector.test-installation',
|
||||
defaultMessage: 'Test Java installation',
|
||||
},
|
||||
alreadyInstalled: {
|
||||
id: 'app.java-selector.already-installed',
|
||||
defaultMessage: 'Already installed',
|
||||
},
|
||||
installing: {
|
||||
id: 'app.java-selector.installing',
|
||||
defaultMessage: 'Installing...',
|
||||
},
|
||||
installRecommended: {
|
||||
id: 'app.java-selector.install-recommended',
|
||||
defaultMessage: 'Install recommended',
|
||||
},
|
||||
detect: {
|
||||
id: 'app.java-selector.detect',
|
||||
defaultMessage: 'Detect',
|
||||
},
|
||||
browse: {
|
||||
id: 'app.java-selector.browse',
|
||||
defaultMessage: 'Browse',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup>
|
||||
import { CheckIcon } from '@modrinth/assets'
|
||||
import { Badge, IconButton } from '@modrinth/ui'
|
||||
import { Badge, ButtonStyled } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { SwapIcon } from '@/assets/icons/index.js'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { update_managed_modrinth_version } from '@/helpers/instance'
|
||||
import { update_managed_modrinth_version } from '@/helpers/profile'
|
||||
import { releaseColor } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -32,14 +32,14 @@ const filteredVersions = computed(() => {
|
||||
})
|
||||
|
||||
const modpackVersionModal = ref(null)
|
||||
const installedVersion = computed(() => props.instance?.link?.version_id)
|
||||
const installedVersion = computed(() => props.instance?.linked_data?.version_id)
|
||||
const installing = computed(() => props.instance.install_stage !== 'installed')
|
||||
const inProgress = ref(false)
|
||||
|
||||
const switchVersion = async (versionId) => {
|
||||
modpackVersionModal.value.hide()
|
||||
inProgress.value = true
|
||||
await update_managed_modrinth_version(props.instance.id, versionId)
|
||||
await update_managed_modrinth_version(props.instance.path, versionId)
|
||||
inProgress.value = false
|
||||
emit('finish-install')
|
||||
}
|
||||
@@ -59,7 +59,7 @@ const onHide = () => {
|
||||
:on-hide="onHide"
|
||||
>
|
||||
<div class="modal-body">
|
||||
<div v-if="instance.link" class="mod-card">
|
||||
<div v-if="instance.linked_data" class="mod-card">
|
||||
<div class="table">
|
||||
<div class="table-row with-columns table-head">
|
||||
<div class="table-cell table-text download-cell" />
|
||||
@@ -74,16 +74,18 @@ const onHide = () => {
|
||||
@click="$router.push(`/project/${version.project_id}/version/${version.id}`)"
|
||||
>
|
||||
<div class="table-cell table-text">
|
||||
<IconButton
|
||||
:type="version.id === installedVersion ? 'base' : 'colored'"
|
||||
:color="version.id === installedVersion ? undefined : 'brand'"
|
||||
label="Switch version"
|
||||
:disabled="inProgress || installing || version.id === installedVersion"
|
||||
@click.stop="() => switchVersion(version.id)"
|
||||
<ButtonStyled
|
||||
circular
|
||||
:color="version.id === installedVersion ? 'standard' : 'brand'"
|
||||
>
|
||||
<SwapIcon v-if="version.id !== installedVersion" />
|
||||
<CheckIcon v-else />
|
||||
</IconButton>
|
||||
<button
|
||||
:disabled="inProgress || installing || version.id === installedVersion"
|
||||
@click.stop="() => switchVersion(version.id)"
|
||||
>
|
||||
<SwapIcon v-if="version.id !== installedVersion" />
|
||||
<CheckIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="name-cell table-cell table-text">
|
||||
<div class="version-link">
|
||||
|
||||
@@ -1,310 +1,74 @@
|
||||
<script setup>
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { Avatar, injectNotificationManager } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import { instance_listener } from '@/helpers/events.js'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { instanceKeys } from '@/pages/instance/query-options'
|
||||
|
||||
const ITEM_SIZE = 52
|
||||
const APPROX_USED_VERTICAL_SPACE = 513 // doesn't need to be exact lol just close enough so there's a little gap and no overflow
|
||||
const STORAGE_KEY = 'modrinth-quick-instance-count'
|
||||
import { profile_listener } from '@/helpers/events.js'
|
||||
import { list } from '@/helpers/profile'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const maxAuto = ref(0)
|
||||
const allInstances = ref([])
|
||||
const dragging = ref(false)
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
const userLimit = ref(stored === null ? null : Number(stored))
|
||||
|
||||
const maxVisible = computed(() => Math.min(maxAuto.value, allInstances.value.length))
|
||||
const visibleCount = computed(() => Math.min(userLimit.value ?? maxVisible.value, maxVisible.value))
|
||||
const recentInstances = computed(() => allInstances.value.slice(0, visibleCount.value))
|
||||
const canDrag = computed(() => maxVisible.value > 0)
|
||||
const showOverdrag = ref(false)
|
||||
|
||||
const updateMaxAuto = () => {
|
||||
maxAuto.value = Math.max(
|
||||
0,
|
||||
Math.floor((window.innerHeight - APPROX_USED_VERTICAL_SPACE) / ITEM_SIZE),
|
||||
)
|
||||
}
|
||||
|
||||
const setLimit = (count) => {
|
||||
const clamped = Math.max(0, Math.min(count, maxVisible.value))
|
||||
if (clamped >= maxVisible.value) {
|
||||
userLimit.value = null
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
} else {
|
||||
userLimit.value = clamped
|
||||
localStorage.setItem(STORAGE_KEY, String(clamped))
|
||||
}
|
||||
}
|
||||
|
||||
let dragStartY = 0
|
||||
let dragStartCount = 0
|
||||
let wasOverdragging = false
|
||||
let overdragTimeout = null
|
||||
|
||||
const clearOverdragFlash = () => {
|
||||
showOverdrag.value = false
|
||||
if (overdragTimeout !== null) {
|
||||
clearTimeout(overdragTimeout)
|
||||
overdragTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
const flashOverdrag = () => {
|
||||
showOverdrag.value = true
|
||||
if (overdragTimeout !== null) {
|
||||
clearTimeout(overdragTimeout)
|
||||
}
|
||||
overdragTimeout = setTimeout(() => {
|
||||
showOverdrag.value = false
|
||||
overdragTimeout = null
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const onDividerPointerDown = (event) => {
|
||||
if (!canDrag.value) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
dragging.value = true
|
||||
wasOverdragging = false
|
||||
clearOverdragFlash()
|
||||
dragStartY = event.clientY
|
||||
dragStartCount = visibleCount.value
|
||||
document.body.classList.add('quick-instance-dragging')
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
const onDividerPointerMove = (event) => {
|
||||
if (!dragging.value) {
|
||||
return
|
||||
}
|
||||
const delta = event.clientY - dragStartY
|
||||
const target = dragStartCount + Math.round(delta / ITEM_SIZE)
|
||||
const isOverdragging = target < 0 || target > maxAuto.value
|
||||
if (isOverdragging && !wasOverdragging) {
|
||||
flashOverdrag()
|
||||
}
|
||||
wasOverdragging = isOverdragging
|
||||
setLimit(target)
|
||||
}
|
||||
|
||||
const endDrag = (event) => {
|
||||
if (!dragging.value) {
|
||||
return
|
||||
}
|
||||
dragging.value = false
|
||||
wasOverdragging = false
|
||||
clearOverdragFlash()
|
||||
document.body.classList.remove('quick-instance-dragging')
|
||||
if (event?.currentTarget?.hasPointerCapture?.(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
const onDividerPointerUp = (event) => {
|
||||
endDrag(event)
|
||||
}
|
||||
|
||||
const recentInstances = ref([])
|
||||
const getInstances = async () => {
|
||||
const instances = await list().catch(handleError)
|
||||
const profiles = await list().catch(handleError)
|
||||
|
||||
for (const instance of instances) {
|
||||
queryClient.setQueryData(instanceKeys.detail(instance.id), instance)
|
||||
}
|
||||
recentInstances.value = profiles
|
||||
.sort((a, b) => {
|
||||
const dateACreated = dayjs(a.created)
|
||||
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
|
||||
|
||||
allInstances.value = instances.sort((a, b) => {
|
||||
const dateACreated = dayjs(a.created)
|
||||
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
|
||||
const dateBCreated = dayjs(b.created)
|
||||
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)
|
||||
|
||||
const dateBCreated = dayjs(b.created)
|
||||
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)
|
||||
const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
|
||||
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed
|
||||
|
||||
const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
|
||||
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed
|
||||
if (dateA.isSame(dateB)) {
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
if (dateA.isSame(dateB)) {
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
return dateB - dateA
|
||||
})
|
||||
return dateB - dateA
|
||||
})
|
||||
.slice(0, 3)
|
||||
}
|
||||
|
||||
await getInstances()
|
||||
updateMaxAuto()
|
||||
|
||||
const unlistenInstance = await instance_listener(async (event) => {
|
||||
const unlistenProfile = await profile_listener(async (event) => {
|
||||
if (event.event !== 'synced') {
|
||||
await getInstances()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', updateMaxAuto)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateMaxAuto)
|
||||
document.body.classList.remove('quick-instance-dragging')
|
||||
clearOverdragFlash()
|
||||
unlistenInstance()
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
dragTooltip: {
|
||||
id: 'app.quick-instance-switcher.drag-tooltip',
|
||||
defaultMessage: 'Drag to resize',
|
||||
},
|
||||
dragShowTooltip: {
|
||||
id: 'app.quick-instance-switcher.drag-show-tooltip',
|
||||
defaultMessage: 'Drag to show recent instances',
|
||||
},
|
||||
})
|
||||
|
||||
const dividerTooltip = computed(() => {
|
||||
if (!canDrag.value || dragging.value) {
|
||||
return null
|
||||
}
|
||||
return formatMessage(visibleCount.value === 0 ? messages.dragShowTooltip : messages.dragTooltip)
|
||||
unlistenProfile()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="top-divider">
|
||||
<div
|
||||
v-if="recentInstances.length > 0"
|
||||
class="top-divider flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
<div class="h-px w-8 bg-surface-5 shrink-0"></div>
|
||||
</div>
|
||||
</Transition>
|
||||
<TransitionGroup name="quick-instance" tag="div" class="flex flex-col items-center">
|
||||
<div
|
||||
v-for="instance in recentInstances"
|
||||
:key="instance.id"
|
||||
v-tooltip.right="instance.name"
|
||||
class="quick-instance-item"
|
||||
>
|
||||
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
|
||||
<Avatar
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
size="28px"
|
||||
:tint-by="instance.id"
|
||||
: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>
|
||||
</TransitionGroup>
|
||||
<div
|
||||
v-tooltip.right="dividerTooltip"
|
||||
class="flex items-center justify-center py-2 select-none"
|
||||
:class="canDrag ? 'cursor-ns-resize touch-none group' : ''"
|
||||
@pointerdown="onDividerPointerDown"
|
||||
@pointermove="onDividerPointerMove"
|
||||
@pointerup="onDividerPointerUp"
|
||||
@pointercancel="onDividerPointerUp"
|
||||
>
|
||||
<div
|
||||
class="h-px w-8 transition-colors duration-200"
|
||||
:class="
|
||||
showOverdrag ? 'bg-red' : canDrag ? 'bg-surface-5 group-hover:bg-secondary' : 'bg-surface-5'
|
||||
"
|
||||
></div>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.top-divider {
|
||||
height: calc(1rem + 1px);
|
||||
}
|
||||
|
||||
.top-divider-enter-active,
|
||||
.top-divider-leave-active {
|
||||
transition:
|
||||
opacity 0.25s ease,
|
||||
height 0.25s ease;
|
||||
}
|
||||
|
||||
.top-divider-enter-from,
|
||||
.top-divider-leave-to {
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.quick-instance-item {
|
||||
height: 3rem;
|
||||
overflow: hidden;
|
||||
|
||||
& + & {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-instance-enter-active,
|
||||
.quick-instance-leave-active {
|
||||
transition:
|
||||
opacity 0.25s ease,
|
||||
transform 0.25s ease,
|
||||
height 0.25s ease,
|
||||
margin-top 0.25s ease;
|
||||
}
|
||||
|
||||
.quick-instance-enter-from,
|
||||
.quick-instance-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
height: 0;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.top-divider-enter-active,
|
||||
.top-divider-leave-active,
|
||||
.quick-instance-enter-active,
|
||||
.quick-instance-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.top-divider-enter-from,
|
||||
.top-divider-leave-to {
|
||||
opacity: 1;
|
||||
height: calc(1rem + 1px);
|
||||
}
|
||||
|
||||
.quick-instance-enter-from,
|
||||
.quick-instance-leave-to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
height: 3rem;
|
||||
margin-top: unset !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
body.quick-instance-dragging,
|
||||
body.quick-instance-dragging * {
|
||||
cursor: ns-resize !important;
|
||||
}
|
||||
</style>
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { NotepadTextIcon, XIcon } from '@modrinth/assets'
|
||||
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { type } from '@tauri-apps/plugin-os'
|
||||
import { $fetch } from 'ofetch'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { release_ads_window_hold, take_ads_window_hold } from '@/helpers/ads.js'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { get as getCreds } from '@/helpers/mr_auth.ts'
|
||||
|
||||
let adsWindowHold = false
|
||||
|
||||
type Survey = {
|
||||
id: string
|
||||
tally_id: string
|
||||
type: string
|
||||
condition?: string
|
||||
assigned_users?: string[]
|
||||
dismissed_users?: string[]
|
||||
}
|
||||
|
||||
type TallyApi = {
|
||||
openPopup: (formId: string, options: object) => void
|
||||
}
|
||||
|
||||
const tallyWindow = window as Window & { Tally?: TallyApi }
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const availableSurvey = ref<Survey | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
surveyTitle: {
|
||||
id: 'app.survey.title',
|
||||
defaultMessage: 'Hey there Modrinth user!',
|
||||
},
|
||||
surveyBody: {
|
||||
id: 'app.survey.body',
|
||||
defaultMessage:
|
||||
'Would you mind answering a few questions about your experience with Modrinth App?',
|
||||
},
|
||||
surveyFooter: {
|
||||
id: 'app.survey.footer',
|
||||
defaultMessage:
|
||||
'This feedback will go directly to the Modrinth team and help guide future updates!',
|
||||
},
|
||||
takeSurvey: {
|
||||
id: 'app.survey.take-survey',
|
||||
defaultMessage: 'Take survey',
|
||||
},
|
||||
surveyNoThanks: {
|
||||
id: 'app.survey.no-thanks',
|
||||
defaultMessage: 'No thanks',
|
||||
},
|
||||
})
|
||||
|
||||
function cleanupOldSurveyDisplayData() {
|
||||
const threeWeeksAgo = new Date()
|
||||
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21)
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
|
||||
if (key?.startsWith('survey-') && key.endsWith('-display')) {
|
||||
const dateValue = new Date(localStorage.getItem(key) ?? '')
|
||||
if (dateValue < threeWeeksAgo) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openSurvey() {
|
||||
if (!availableSurvey.value) {
|
||||
console.error('No survey to open')
|
||||
return
|
||||
}
|
||||
|
||||
const creds = await getCreds().catch(handleError)
|
||||
const userId = creds?.user_id
|
||||
|
||||
const formId = availableSurvey.value.tally_id
|
||||
|
||||
const popupOptions = {
|
||||
layout: 'modal',
|
||||
width: 700,
|
||||
autoClose: 2000,
|
||||
hideTitle: true,
|
||||
hiddenFields: {
|
||||
user_id: userId,
|
||||
},
|
||||
onOpen: () => console.info('Opened user survey'),
|
||||
onClose: () => {
|
||||
console.info('Closed user survey')
|
||||
if (adsWindowHold) {
|
||||
adsWindowHold = false
|
||||
release_ads_window_hold()
|
||||
}
|
||||
},
|
||||
onSubmit: () => console.info('Active user survey submitted'),
|
||||
}
|
||||
|
||||
try {
|
||||
await take_ads_window_hold()
|
||||
adsWindowHold = true
|
||||
if (tallyWindow.Tally?.openPopup) {
|
||||
console.info(`Opening Tally popup for user survey (form ID: ${formId})`)
|
||||
dismissSurvey()
|
||||
tallyWindow.Tally.openPopup(formId, popupOptions)
|
||||
} else {
|
||||
console.warn('Tally script not yet loaded')
|
||||
adsWindowHold = false
|
||||
await release_ads_window_hold()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error opening Tally popup:', e)
|
||||
if (adsWindowHold) {
|
||||
adsWindowHold = false
|
||||
await release_ads_window_hold()
|
||||
}
|
||||
}
|
||||
|
||||
console.info(`Found user survey to show with tally_id: ${formId}`)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (adsWindowHold) {
|
||||
adsWindowHold = false
|
||||
release_ads_window_hold()
|
||||
}
|
||||
})
|
||||
|
||||
function dismissSurvey() {
|
||||
if (!availableSurvey.value) return
|
||||
localStorage.setItem(`survey-${availableSurvey.value.id}-display`, String(new Date()))
|
||||
availableSurvey.value = null
|
||||
}
|
||||
|
||||
async function processPendingSurveys() {
|
||||
function isWithinLastTwoWeeks(date: string | Date | null | undefined) {
|
||||
if (!date) return false
|
||||
const twoWeeksAgo = new Date()
|
||||
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14)
|
||||
return new Date(date) >= twoWeeksAgo
|
||||
}
|
||||
|
||||
cleanupOldSurveyDisplayData()
|
||||
|
||||
const creds = await getCreds().catch(handleError)
|
||||
const userId = creds?.user_id
|
||||
|
||||
const instances = (await list().catch(handleError)) ?? []
|
||||
const isActivePlayer = instances.some(
|
||||
(instance) =>
|
||||
isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created),
|
||||
)
|
||||
|
||||
let surveys: Survey[] = []
|
||||
try {
|
||||
surveys = await $fetch('https://api.modrinth.com/v2/surveys')
|
||||
} catch (e) {
|
||||
console.error('Error fetching surveys:', e)
|
||||
}
|
||||
|
||||
const surveyToShow = surveys.find(
|
||||
(survey) =>
|
||||
!!(
|
||||
localStorage.getItem(`survey-${survey.id}-display`) === null &&
|
||||
survey.type === 'tally_app' &&
|
||||
((survey.condition === 'active_player' && isActivePlayer) ||
|
||||
(!!userId &&
|
||||
survey.assigned_users?.includes(userId) &&
|
||||
!survey.dismissed_users?.includes(userId)))
|
||||
),
|
||||
)
|
||||
|
||||
if (surveyToShow) {
|
||||
availableSurvey.value = surveyToShow
|
||||
} else {
|
||||
console.info('No user survey to show')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const osType = await type()
|
||||
if (osType === 'windows') {
|
||||
await processPendingSurveys()
|
||||
} else {
|
||||
console.info('Skipping user surveys on non-Windows platforms')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="popup-survey">
|
||||
<div
|
||||
v-if="availableSurvey"
|
||||
class="w-[400px] z-20 fixed -bottom-12 pb-16 right-[--right-bar-width] mr-4 rounded-t-2xl card-shadow bg-bg-raised border-surface-5 border-[1px] border-solid border-b-0 p-4"
|
||||
>
|
||||
<h2 class="text-lg font-extrabold mt-0 mb-2">
|
||||
{{ formatMessage(messages.surveyTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 leading-tight">
|
||||
{{ formatMessage(messages.surveyBody) }}
|
||||
</p>
|
||||
<p class="mt-3 mb-4 leading-tight">
|
||||
{{ formatMessage(messages.surveyFooter) }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<Button type="colored" color="brand" @click="openSurvey">
|
||||
<NotepadTextIcon />
|
||||
{{ formatMessage(messages.takeSurvey) }}
|
||||
</Button>
|
||||
<Button @click="dismissSurvey">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.surveyNoThanks) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.popup-survey-enter-active {
|
||||
transition:
|
||||
opacity 0.25s ease,
|
||||
transform 0.25s cubic-bezier(0.51, 1.08, 0.35, 1.15);
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.popup-survey-leave-active {
|
||||
transition:
|
||||
opacity 0.25s ease,
|
||||
transform 0.25s cubic-bezier(0.68, -0.17, 0.23, 0.11);
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.popup-survey-enter-from,
|
||||
.popup-survey-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10rem) scale(0.8) scaleY(1.6);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { Button, injectNotificationManager, ProjectCard } from '@modrinth/ui'
|
||||
import { ButtonStyled, injectNotificationManager, ProjectCard } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
@@ -69,7 +69,9 @@ async function install() {
|
||||
</p>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<Button type="colored" color="brand" @click="install">Install</Button>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="install">Install</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,37 +4,34 @@
|
||||
class="flex items-center gap-2 mr-1.5"
|
||||
data-tauri-drag-region-exclude
|
||||
>
|
||||
<IconButton
|
||||
type="quiet"
|
||||
label="Minimize window"
|
||||
class="relative expanded-button"
|
||||
@click="() => getCurrentWindow().minimize()"
|
||||
<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
|
||||
>
|
||||
<MinimizeIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="quiet"
|
||||
label="Toggle maximize window"
|
||||
class="relative expanded-button"
|
||||
@click="() => getCurrentWindow().toggleMaximize()"
|
||||
>
|
||||
<RestoreIcon v-if="isMaximized" />
|
||||
<MaximizeIcon v-else />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="quiet"
|
||||
label="Close window"
|
||||
class="relative expanded-button close-button"
|
||||
@click="handleClose"
|
||||
>
|
||||
<XIcon />
|
||||
</IconButton>
|
||||
<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 { IconButton } from '@modrinth/ui'
|
||||
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'
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
v-if="showUpdatePill"
|
||||
type="outlined"
|
||||
native-type="button"
|
||||
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out !text-brand [&>svg]:!text-brand !shadow-[inset_0_0_0_1px_var(--color-brand)] hover:!bg-brand focus-visible:!bg-brand hover:!text-[var(--color-accent-contrast)] focus-visible:!text-[var(--color-accent-contrast)]"
|
||||
:class="{
|
||||
'opacity-0 scale-[0.96]': finishedDownloading && !animateReadyPill,
|
||||
'opacity-100 scale-100': 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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, RefreshCwIcon } from '@modrinth/assets'
|
||||
import { Button, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
appUpdateState,
|
||||
downloadAvailableAppUpdate,
|
||||
installAvailableAppUpdate,
|
||||
} from '@/providers/app-update'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
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)
|
||||
})
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (readyPillAnimationFrame !== null) {
|
||||
cancelAnimationFrame(readyPillAnimationFrame)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,75 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { MailIcon, SearchIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { MailIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
IconButton,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
StyledInput,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { useFriends } from '@/composables/use-friends'
|
||||
import type { FriendWithUserData } from '@/helpers/friends.ts'
|
||||
import { friend_listener } from '@/helpers/events'
|
||||
import {
|
||||
add_friend,
|
||||
friends,
|
||||
type FriendWithUserData,
|
||||
remove_friend,
|
||||
transformFriends,
|
||||
} from '@/helpers/friends.ts'
|
||||
import type { ModrinthCredentials } from '@/helpers/mr_auth'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const themeStore = useTheming()
|
||||
|
||||
const props = defineProps<{
|
||||
credentials: ModrinthCredentials | null
|
||||
signIn: () => void
|
||||
}>()
|
||||
|
||||
type FriendsSectionCollapsedFlag =
|
||||
| 'friends_active_collapsed'
|
||||
| 'friends_online_collapsed'
|
||||
| 'friends_offline_collapsed'
|
||||
| 'friends_pending_collapsed'
|
||||
|
||||
function isFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag) {
|
||||
return themeStore.getFeatureFlag(flag)
|
||||
}
|
||||
|
||||
function setFriendsSectionCollapsed(flag: FriendsSectionCollapsedFlag, collapsed: boolean) {
|
||||
themeStore.featureFlags[flag] = collapsed
|
||||
getSettings()
|
||||
.then((settings) => {
|
||||
settings.feature_flags[flag] = collapsed
|
||||
return setSettings(settings)
|
||||
})
|
||||
.catch(handleError)
|
||||
}
|
||||
|
||||
const userCredentials = computed(() => props.credentials)
|
||||
const {
|
||||
friends: userFriends,
|
||||
loading,
|
||||
requestFriend,
|
||||
acceptFriend,
|
||||
removeFriend: removeFriendRecord,
|
||||
} = useFriends({
|
||||
currentUserId: () => userCredentials.value?.user_id,
|
||||
getCredentials: () => userCredentials.value,
|
||||
onError: handleError,
|
||||
})
|
||||
|
||||
const search = ref('')
|
||||
const friendInvitesModal = ref()
|
||||
|
||||
const username = ref('')
|
||||
const addFriendModal = ref()
|
||||
async function addFriendFromModal() {
|
||||
addFriendModal.value.hide()
|
||||
await add_friend(username.value).catch(handleError)
|
||||
username.value = ''
|
||||
await loadFriends()
|
||||
}
|
||||
|
||||
async function addFriend(friend: FriendWithUserData) {
|
||||
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
|
||||
if (id) {
|
||||
await add_friend(id).catch(handleError)
|
||||
await loadFriends()
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFriend(friend: FriendWithUserData) {
|
||||
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
|
||||
if (id) {
|
||||
await remove_friend(id).catch(handleError)
|
||||
await loadFriends()
|
||||
}
|
||||
}
|
||||
|
||||
const userFriends = ref<FriendWithUserData[]>([])
|
||||
const sortedFriends = computed<FriendWithUserData[]>(() =>
|
||||
userFriends.value.slice().sort((a, b) => {
|
||||
if (a.last_updated === null && b.last_updated === null) {
|
||||
@@ -113,22 +108,42 @@ const incomingRequests = computed(() =>
|
||||
.sort((a, b) => b.created.diff(a.created)),
|
||||
)
|
||||
|
||||
function addFriendFromModal() {
|
||||
const target = username.value.trim()
|
||||
if (!target) return
|
||||
const loading = ref(true)
|
||||
async function loadFriends(timeout = false) {
|
||||
loading.value = timeout
|
||||
|
||||
addFriendModal.value.hide()
|
||||
requestFriend({ id: target, username: target })
|
||||
username.value = ''
|
||||
try {
|
||||
const friendsList = await friends()
|
||||
userFriends.value = await transformFriends(friendsList, userCredentials.value)
|
||||
loading.value = false
|
||||
} catch (e) {
|
||||
console.error('Error loading friends', e)
|
||||
if (timeout) {
|
||||
setTimeout(() => loadFriends(), 15 * 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addFriend(friend: FriendWithUserData) {
|
||||
acceptFriend(friend)
|
||||
}
|
||||
watch(
|
||||
userCredentials,
|
||||
() => {
|
||||
if (userCredentials.value === undefined) {
|
||||
userFriends.value = []
|
||||
loading.value = false
|
||||
} else if (userCredentials.value === null) {
|
||||
userFriends.value = []
|
||||
loading.value = false
|
||||
} else {
|
||||
loadFriends(true)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function removeFriend(friend: FriendWithUserData) {
|
||||
removeFriendRecord(friend)
|
||||
}
|
||||
const unlisten = await friend_listener(() => loadFriends())
|
||||
onUnmounted(() => {
|
||||
unlisten()
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
addFriend: {
|
||||
@@ -221,20 +236,26 @@ const messages = defineMessages({
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<template v-if="friend.id === userCredentials?.user_id">
|
||||
<Button type="colored" color="brand" @click="addFriend(friend)">
|
||||
<UserPlusIcon />
|
||||
Accept
|
||||
</Button>
|
||||
<Button @click="removeFriend(friend)">
|
||||
<XIcon />
|
||||
Ignore
|
||||
</Button>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="addFriend(friend)">
|
||||
<UserPlusIcon />
|
||||
Accept
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="removeFriend(friend)">
|
||||
<XIcon />
|
||||
Ignore
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button @click="removeFriend(friend)">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonStyled>
|
||||
<button @click="removeFriend(friend)">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,59 +279,56 @@ const messages = defineMessages({
|
||||
wrapper-class="flex-1"
|
||||
@keyup.enter="addFriendFromModal"
|
||||
/>
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="username.length === 0"
|
||||
@click="addFriendFromModal"
|
||||
>
|
||||
<SendIcon />
|
||||
{{ formatMessage(messages.sendFriendRequest) }}
|
||||
</Button>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="username.length === 0" @click="addFriendFromModal">
|
||||
<SendIcon />
|
||||
{{ formatMessage(messages.sendFriendRequest) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<div v-if="userCredentials && !loading" class="flex gap-1 items-center mb-3 -ml-1">
|
||||
<template v-if="sortedFriends.length > 0">
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.addFriend)"
|
||||
type="quiet"
|
||||
:label="formatMessage(messages.addFriend)"
|
||||
@click="addFriendModal.show"
|
||||
>
|
||||
<UserPlusIcon />
|
||||
</IconButton>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.addFriend)"
|
||||
:aria-label="formatMessage(messages.addFriend)"
|
||||
@click="addFriendModal.show"
|
||||
>
|
||||
<UserPlusIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.searchFriends)"
|
||||
clearable
|
||||
input-class="!bg-transparent !border !border-solid !border-button-bg !text-primary !placeholder:text-primary"
|
||||
wrapper-class="flex-1 [&>svg]:!text-primary [&>svg]:!opacity-100"
|
||||
variant="outlined"
|
||||
wrapper-class="flex-1"
|
||||
@keyup.esc="search = ''"
|
||||
/>
|
||||
</template>
|
||||
<h3 v-else class="w-full text-base text-primary font-medium m-0">
|
||||
{{ formatMessage(messages.friends) }}
|
||||
</h3>
|
||||
<IconButton
|
||||
v-if="incomingRequests.length > 0"
|
||||
v-tooltip="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
|
||||
type="quiet"
|
||||
:label="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
|
||||
class="relative"
|
||||
@click="friendInvitesModal.show"
|
||||
>
|
||||
<MailIcon />
|
||||
<span
|
||||
v-if="incomingRequests.length > 0"
|
||||
aria-hidden="true"
|
||||
class="absolute bg-brand text-brand-inverted text-[8px] top-0.5 px-1 right-0.5 min-w-3 h-3 rounded-full flex items-center justify-center font-bold"
|
||||
<ButtonStyled v-if="incomingRequests.length > 0" circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
|
||||
class="relative"
|
||||
:aria-label="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
|
||||
@click="friendInvitesModal.show"
|
||||
>
|
||||
{{ incomingRequests.length }}
|
||||
</span>
|
||||
</IconButton>
|
||||
<MailIcon />
|
||||
<span
|
||||
v-if="incomingRequests.length > 0"
|
||||
aria-hidden="true"
|
||||
class="absolute bg-brand text-brand-inverted text-[8px] top-0.5 px-1 right-0.5 min-w-3 h-3 rounded-full flex items-center justify-center font-bold"
|
||||
>
|
||||
{{ incomingRequests.length }}
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3 v-if="loading" class="text-base text-primary font-medium m-0">
|
||||
@@ -351,42 +369,33 @@ const messages = defineMessages({
|
||||
<FriendsSection
|
||||
v-if="activeFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
:open-by-default="!isFriendsSectionCollapsed('friends_active_collapsed')"
|
||||
open-by-default
|
||||
:friends="activeFriends"
|
||||
:heading="formatMessage(messages.active)"
|
||||
:remove-friend="removeFriend"
|
||||
@on-open="setFriendsSectionCollapsed('friends_active_collapsed', false)"
|
||||
@on-close="setFriendsSectionCollapsed('friends_active_collapsed', true)"
|
||||
/>
|
||||
<FriendsSection
|
||||
v-if="onlineFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
:open-by-default="!isFriendsSectionCollapsed('friends_online_collapsed')"
|
||||
open-by-default
|
||||
:friends="onlineFriends"
|
||||
:heading="formatMessage(messages.online)"
|
||||
:remove-friend="removeFriend"
|
||||
@on-open="setFriendsSectionCollapsed('friends_online_collapsed', false)"
|
||||
@on-close="setFriendsSectionCollapsed('friends_online_collapsed', true)"
|
||||
/>
|
||||
<FriendsSection
|
||||
v-if="offlineFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
:open-by-default="!isFriendsSectionCollapsed('friends_offline_collapsed')"
|
||||
:open-by-default="activeFriends.length + onlineFriends.length < 3"
|
||||
:friends="offlineFriends"
|
||||
:heading="formatMessage(messages.offline)"
|
||||
:remove-friend="removeFriend"
|
||||
@on-open="setFriendsSectionCollapsed('friends_offline_collapsed', false)"
|
||||
@on-close="setFriendsSectionCollapsed('friends_offline_collapsed', true)"
|
||||
/>
|
||||
<FriendsSection
|
||||
v-if="pendingFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
:open-by-default="!isFriendsSectionCollapsed('friends_pending_collapsed')"
|
||||
:friends="pendingFriends"
|
||||
:heading="formatMessage(messages.pending)"
|
||||
:remove-friend="removeFriend"
|
||||
@on-open="setFriendsSectionCollapsed('friends_pending_collapsed', false)"
|
||||
@on-close="setFriendsSectionCollapsed('friends_pending_collapsed', true)"
|
||||
/>
|
||||
<p v-if="filteredFriends.length === 0 && search" class="text-sm text-secondary my-1 mx-4">
|
||||
{{ formatMessage(messages.noFriendsMatch, { query: search }) }}
|
||||
|
||||
@@ -3,19 +3,18 @@ import { MoreVerticalIcon, TrashIcon, UserIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
IconButton,
|
||||
TeleportOverflowMenu,
|
||||
OverflowMenu,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import type { FriendWithUserData } from '@/helpers/friends.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -31,11 +30,6 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
onOpen: []
|
||||
onClose: []
|
||||
}>()
|
||||
|
||||
function createContextMenuOptions(friend: FriendWithUserData) {
|
||||
if (friend.accepted) {
|
||||
return [
|
||||
@@ -60,7 +54,7 @@ function createContextMenuOptions(friend: FriendWithUserData) {
|
||||
}
|
||||
|
||||
function openProfile(username: string) {
|
||||
void router.push(`/user/${encodeURIComponent(username)}`)
|
||||
openUrl('https://modrinth.com/user/' + username)
|
||||
}
|
||||
|
||||
const friendOptions = useTemplateRef('friendOptions')
|
||||
@@ -117,8 +111,6 @@ const messages = defineMessages({
|
||||
? ''
|
||||
: ' cursor-pointer hover:brightness-[--hover-brightness] active:scale-[0.98] transition-all')
|
||||
"
|
||||
@on-open="emit('onOpen')"
|
||||
@on-close="emit('onClose')"
|
||||
>
|
||||
<template #title>
|
||||
<h3 class="text-base text-primary font-medium m-0">
|
||||
@@ -130,71 +122,68 @@ const messages = defineMessages({
|
||||
<div
|
||||
v-for="friend in friends"
|
||||
:key="friend.username"
|
||||
class="group grid items-center grid-cols-[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 mr-1"
|
||||
@contextmenu.prevent.stop="
|
||||
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
|
||||
"
|
||||
>
|
||||
<RouterLink
|
||||
:to="`/user/${encodeURIComponent(friend.username)}`"
|
||||
class="grid min-w-0 grid-cols-[auto_1fr] items-center gap-2 text-inherit no-underline"
|
||||
>
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:src="friend.avatar"
|
||||
:class="{ grayscale: !friend.online && friend.accepted }"
|
||||
class="w-12 h-12 rounded-full"
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<span
|
||||
v-if="friend.online"
|
||||
aria-hidden="true"
|
||||
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-sm m-0"
|
||||
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ friend.username }}
|
||||
</span>
|
||||
<span v-if="!friend.accepted" class="m-0 text-xs">
|
||||
{{ formatMessage(messages.friendRequestSent) }}
|
||||
</span>
|
||||
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
<TeleportOverflowMenu
|
||||
v-if="friend.accepted"
|
||||
type="quiet"
|
||||
label="More options"
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:options="[
|
||||
{
|
||||
id: 'remove-friend',
|
||||
label: formatMessage(messages.removeFriend),
|
||||
action: () => removeFriend(friend),
|
||||
tone: 'red',
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #remove-friend>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.removeFriend) }}
|
||||
</template>
|
||||
</TeleportOverflowMenu>
|
||||
<IconButton
|
||||
v-else
|
||||
v-tooltip="formatMessage(messages.cancelRequest)"
|
||||
type="quiet"
|
||||
:label="formatMessage(messages.cancelRequest)"
|
||||
@click="removeFriend(friend)"
|
||||
>
|
||||
<XIcon />
|
||||
</IconButton>
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:src="friend.avatar"
|
||||
:class="{ grayscale: !friend.online && friend.accepted }"
|
||||
class="w-12 h-12 rounded-full"
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<span
|
||||
v-if="friend.online"
|
||||
aria-hidden="true"
|
||||
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-sm m-0"
|
||||
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ friend.username }}
|
||||
</span>
|
||||
<span v-if="!friend.accepted" class="m-0 text-xs">
|
||||
{{ formatMessage(messages.friendRequestSent) }}
|
||||
</span>
|
||||
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
|
||||
</div>
|
||||
<ButtonStyled v-if="friend.accepted" circular type="transparent">
|
||||
<OverflowMenu
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:options="[
|
||||
{
|
||||
id: 'view-profile',
|
||||
action: () => openProfile(friend.username),
|
||||
},
|
||||
{
|
||||
id: 'remove-friend',
|
||||
action: () => removeFriend(friend),
|
||||
color: 'red',
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #view-profile>
|
||||
<UserIcon />
|
||||
{{ formatMessage(messages.viewProfile) }}
|
||||
</template>
|
||||
<template #remove-friend>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.removeFriend) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else type="transparent" circular>
|
||||
<button v-tooltip="formatMessage(messages.cancelRequest)" @click="removeFriend(friend)">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
<script setup>
|
||||
import { CheckIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { Admonition, Avatar, Button, injectNotificationManager, StyledInput } from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
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/instance'
|
||||
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
|
||||
import { instanceKeys } from '@/pages/instance/query-options'
|
||||
import { list } from '@/helpers/profile'
|
||||
import { add_server_to_profile, get_profile_worlds } from '@/helpers/worlds.ts'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const modal = ref()
|
||||
const searchFilter = ref('')
|
||||
const instances = ref([])
|
||||
const profiles = ref([])
|
||||
|
||||
const serverName = ref('')
|
||||
const serverAddress = ref('')
|
||||
|
||||
const shownInstances = computed(() =>
|
||||
instances.value.filter((instance) => {
|
||||
return instance.name.toLowerCase().includes(searchFilter.value.toLowerCase())
|
||||
const shownProfiles = computed(() =>
|
||||
profiles.value.filter((profile) => {
|
||||
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -33,15 +36,15 @@ defineExpose({
|
||||
serverAddress.value = address
|
||||
searchFilter.value = ''
|
||||
|
||||
const instanceValues = await list().catch(handleError)
|
||||
const profilesVal = await list().catch(handleError)
|
||||
await Promise.allSettled(
|
||||
instanceValues.map(async (instance) => {
|
||||
instance.adding = false
|
||||
instance.added = false
|
||||
profilesVal.map(async (profile) => {
|
||||
profile.adding = false
|
||||
profile.added = false
|
||||
|
||||
try {
|
||||
const worlds = await get_instance_worlds(instance.id)
|
||||
instance.added = worlds.some(
|
||||
const worlds = await get_profile_worlds(profile.path)
|
||||
profile.added = worlds.some(
|
||||
(w) => w.type === 'server' && w.address === serverAddress.value,
|
||||
)
|
||||
} catch {
|
||||
@@ -50,29 +53,28 @@ defineExpose({
|
||||
}),
|
||||
)
|
||||
|
||||
instances.value = instanceValues
|
||||
profiles.value = profilesVal
|
||||
modal.value.show()
|
||||
|
||||
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
|
||||
},
|
||||
})
|
||||
|
||||
async function addServer(instance) {
|
||||
instance.adding = true
|
||||
async function addServer(profile) {
|
||||
profile.adding = true
|
||||
try {
|
||||
await add_server_to_instance(instance.id, serverName.value, serverAddress.value, 'prompt')
|
||||
instance.added = true
|
||||
await queryClient.invalidateQueries({ queryKey: instanceKeys.worlds(instance.id) })
|
||||
await add_server_to_profile(profile.path, serverName.value, serverAddress.value, 'prompt')
|
||||
profile.added = true
|
||||
|
||||
trackEvent('AddServerToInstance', {
|
||||
server_name: serverName.value,
|
||||
instance_name: instance.name,
|
||||
instance_name: profile.name,
|
||||
source: 'AddServerToInstanceModal',
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
}
|
||||
instance.adding = false
|
||||
profile.adding = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -89,30 +91,34 @@ async function addServer(instance) {
|
||||
/>
|
||||
<div class="max-h-[21rem] overflow-y-auto">
|
||||
<div
|
||||
v-for="instance in shownInstances"
|
||||
:key="instance.id"
|
||||
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(instance.id)}`"
|
||||
:to="`/instance/${encodeURIComponent(profile.path)}`"
|
||||
@click="modal.hide()"
|
||||
>
|
||||
<Avatar
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
:src="profile.icon_path ? convertFileSrc(profile.icon_path) : null"
|
||||
class="mr-2 [--size:2rem]"
|
||||
/>
|
||||
{{ instance.name }}
|
||||
{{ profile.name }}
|
||||
</router-link>
|
||||
<Button :disabled="instance.added || instance.adding" @click="addServer(instance)">
|
||||
<PlusIcon v-if="!instance.added && !instance.adding" />
|
||||
<CheckIcon v-else-if="instance.added" />
|
||||
{{ instance.adding ? 'Adding...' : instance.added ? 'Added' : 'Add' }}
|
||||
</Button>
|
||||
<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">
|
||||
<Button @click="modal.hide()">Cancel</Button>
|
||||
<ButtonStyled>
|
||||
<button @click="modal.hide()">Cancel</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
|
||||
@@ -1,41 +1,108 @@
|
||||
<template>
|
||||
<UnknownFileWarningModal
|
||||
ref="modal"
|
||||
:mode="externalFilesInModpack.length > 0 ? 'modpack' : 'mod'"
|
||||
:file-name="fileName"
|
||||
:external-files-in-modpack="externalFilesInModpack"
|
||||
@cancel="reset"
|
||||
@continue="proceed"
|
||||
/>
|
||||
<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 { UnknownFileWarningModal } from '@modrinth/ui'
|
||||
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 externalFilesInModpack = ref<string[]>([])
|
||||
|
||||
function show(
|
||||
createInstance: () => Promise<void>,
|
||||
selectedFileName = '',
|
||||
selectedExternalFiles: string[] = [],
|
||||
) {
|
||||
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 it’s 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
|
||||
externalFilesInModpack.value = selectedExternalFiles
|
||||
dontShowAgain.value = false
|
||||
|
||||
if (themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
|
||||
void createInstance()
|
||||
// noinspection ES6MissingAwait
|
||||
createInstance()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -45,11 +112,18 @@ function show(
|
||||
function reset() {
|
||||
onProceed.value = undefined
|
||||
fileName.value = ''
|
||||
externalFilesInModpack.value = []
|
||||
}
|
||||
|
||||
async function proceed(dontShowAgain: boolean) {
|
||||
if (dontShowAgain) {
|
||||
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
|
||||
@@ -57,8 +131,9 @@ async function proceed(dontShowAgain: boolean) {
|
||||
}
|
||||
|
||||
const createInstance = onProceed.value
|
||||
reset()
|
||||
if (createInstance) void createInstance()
|
||||
modal.value?.hide()
|
||||
// noinspection ES6MissingAwait
|
||||
createInstance()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
|
||||
+66
-77
@@ -2,13 +2,13 @@
|
||||
import { CopyIcon, EditIcon, PlusIcon, SpinnerIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
Chips,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
StyledInput,
|
||||
TeleportOverflowMenu,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
@@ -19,11 +19,10 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { edit, edit_icon, list, remove } from '@/helpers/instance'
|
||||
import { duplicate, edit, edit_icon, list, remove } from '@/helpers/profile'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { GameInstance } from '../../../../helpers/types'
|
||||
import { injectInstanceSettings } from './instance-settings-context'
|
||||
import type { GameInstance } from '../../../helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -33,14 +32,14 @@ const queryClient = useQueryClient()
|
||||
const deleteConfirmModal = ref()
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
type ReleaseChannel = GameInstance['update_channel']
|
||||
type ReleaseChannel = GameInstance['preferred_update_channel']
|
||||
const releaseChannelOptions: ReleaseChannel[] = ['release', 'beta', 'alpha']
|
||||
|
||||
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.update_channel)
|
||||
const selectedReleaseChannel = ref<ReleaseChannel>(instance.value.preferred_update_channel)
|
||||
const releaseChannelDisabledItems = computed<ReleaseChannel[]>(() =>
|
||||
savingReleaseChannel.value ? [...releaseChannelOptions] : [],
|
||||
)
|
||||
@@ -49,8 +48,8 @@ const newCategoryInput = ref('')
|
||||
|
||||
const installing = computed(() => instance.value.install_stage !== 'installed')
|
||||
|
||||
async function duplicateInstance() {
|
||||
await install_duplicate_instance(instance.value.id).catch(handleError)
|
||||
async function duplicateProfile() {
|
||||
await duplicate(instance.value.path).catch(handleError)
|
||||
trackEvent('InstanceDuplicate', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
@@ -85,22 +84,22 @@ function formatReleaseChannelDescription(channel: ReleaseChannel) {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [instance.value.id, instance.value.update_channel] as const,
|
||||
() => [instance.value.path, instance.value.preferred_update_channel] as const,
|
||||
() => {
|
||||
if (!savingReleaseChannel.value) {
|
||||
selectedReleaseChannel.value = instance.value.update_channel
|
||||
selectedReleaseChannel.value = instance.value.preferred_update_channel
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(selectedReleaseChannel, async (channel, previousChannel) => {
|
||||
const previousReleaseChannel = previousChannel ?? instance.value.update_channel
|
||||
if (channel === instance.value.update_channel) return
|
||||
const previousReleaseChannel = previousChannel ?? instance.value.preferred_update_channel
|
||||
if (channel === instance.value.preferred_update_channel) return
|
||||
|
||||
savingReleaseChannel.value = true
|
||||
const instanceId = instance.value.id
|
||||
await edit(instanceId, { update_channel: channel })
|
||||
.then(() => queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instanceId] }))
|
||||
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)
|
||||
@@ -109,13 +108,9 @@ watch(selectedReleaseChannel, async (channel, previousChannel) => {
|
||||
})
|
||||
|
||||
async function resetIcon() {
|
||||
try {
|
||||
await edit_icon(instance.value.id, null)
|
||||
icon.value = undefined
|
||||
trackEvent('InstanceRemoveIcon')
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
icon.value = undefined
|
||||
await edit_icon(instance.value.path, null).catch(handleError)
|
||||
trackEvent('InstanceRemoveIcon')
|
||||
}
|
||||
|
||||
async function setIcon() {
|
||||
@@ -131,16 +126,13 @@ async function setIcon() {
|
||||
|
||||
if (!value) return
|
||||
|
||||
try {
|
||||
await edit_icon(instance.value.id, value)
|
||||
icon.value = value
|
||||
trackEvent('InstanceSetIcon')
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
icon.value = value
|
||||
await edit_icon(instance.value.path, icon.value).catch(handleError)
|
||||
|
||||
trackEvent('InstanceSetIcon')
|
||||
}
|
||||
|
||||
const editInstanceObject = computed(() => ({
|
||||
const editProfileObject = computed(() => ({
|
||||
name: title.value.trim().substring(0, 32) ?? 'Instance',
|
||||
groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0),
|
||||
}))
|
||||
@@ -166,15 +158,15 @@ watch(
|
||||
[title, groups, groups],
|
||||
async () => {
|
||||
if (removing.value) return
|
||||
await edit(instance.value.id, editInstanceObject.value).catch(handleError)
|
||||
await edit(instance.value.path, editProfileObject.value).catch(handleError)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const removing = ref(false)
|
||||
async function removeInstance() {
|
||||
async function removeProfile() {
|
||||
removing.value = true
|
||||
const path = instance.value.id
|
||||
const path = instance.value.path
|
||||
|
||||
trackEvent('InstanceRemove', {
|
||||
loader: instance.value.loader,
|
||||
@@ -292,30 +284,23 @@ const messages = defineMessages({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="removeInstance" />
|
||||
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="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">
|
||||
<TeleportOverflowMenu
|
||||
:label="formatMessage(messages.editIcon)"
|
||||
:tooltip="formatMessage(messages.editIcon)"
|
||||
:icon-only="false"
|
||||
type="quiet"
|
||||
interaction="none"
|
||||
class="m-0 !h-auto cursor-pointer appearance-none border-none bg-transparent !p-0 transition-transform group-active:scale-95"
|
||||
<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',
|
||||
label: icon
|
||||
? formatMessage(messages.replaceIcon)
|
||||
: formatMessage(messages.selectIcon),
|
||||
action: () => setIcon(),
|
||||
},
|
||||
{
|
||||
id: 'remove',
|
||||
label: formatMessage(messages.removeIcon),
|
||||
color: 'danger',
|
||||
action: () => resetIcon(),
|
||||
shown: !!icon,
|
||||
},
|
||||
@@ -325,7 +310,7 @@ const messages = defineMessages({
|
||||
:src="icon ? convertFileSrc(icon) : icon"
|
||||
size="108px"
|
||||
class="transition-[filter] group-hover:brightness-75"
|
||||
:tint-by="instance.id"
|
||||
:tint-by="instance.path"
|
||||
no-shadow
|
||||
/>
|
||||
<div
|
||||
@@ -338,7 +323,7 @@ const messages = defineMessages({
|
||||
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
|
||||
</template>
|
||||
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
|
||||
</TeleportOverflowMenu>
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,15 +344,17 @@ const messages = defineMessages({
|
||||
<h2 id="duplicate-instance-label" class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.duplicateInstance) }}
|
||||
</h2>
|
||||
<Button
|
||||
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
|
||||
aria-labelledby="duplicate-instance-label"
|
||||
:disabled="installing"
|
||||
class="w-max"
|
||||
@click="duplicateInstance"
|
||||
>
|
||||
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
|
||||
</Button>
|
||||
<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">
|
||||
{{ formatMessage(messages.duplicateInstanceDescription) }}
|
||||
</p>
|
||||
@@ -393,9 +380,11 @@ const messages = defineMessages({
|
||||
class="w-full max-w-[300px]"
|
||||
@submit="() => addCategory"
|
||||
/>
|
||||
<Button class="w-fit" @click="() => addCategory()">
|
||||
<PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }}
|
||||
</Button>
|
||||
<ButtonStyled>
|
||||
<button class="w-fit !shadow-none" @click="() => addCategory()">
|
||||
<PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0">
|
||||
@@ -424,22 +413,22 @@ const messages = defineMessages({
|
||||
<h2 id="delete-instance-label" class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.deleteInstance) }}
|
||||
</h2>
|
||||
<Button
|
||||
type="colored"
|
||||
color="red"
|
||||
aria-labelledby="delete-instance-label"
|
||||
:disabled="removing"
|
||||
class="w-fit"
|
||||
@click="deleteConfirmModal.show()"
|
||||
>
|
||||
<SpinnerIcon v-if="removing" class="animate-spin" />
|
||||
<TrashIcon v-else />
|
||||
{{
|
||||
removing
|
||||
? formatMessage(messages.deletingInstanceButton)
|
||||
: formatMessage(messages.deleteInstanceButton)
|
||||
}}
|
||||
</Button>
|
||||
<ButtonStyled color="red">
|
||||
<button
|
||||
aria-labelledby="delete-instance-label"
|
||||
:disabled="removing"
|
||||
class="w-fit !shadow-none"
|
||||
@click="deleteConfirmModal.show()"
|
||||
>
|
||||
<SpinnerIcon v-if="removing" class="animate-spin" />
|
||||
<TrashIcon v-else />
|
||||
{{
|
||||
removing
|
||||
? formatMessage(messages.deletingInstanceButton)
|
||||
: formatMessage(messages.deleteInstanceButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.deleteInstanceDescription) }}
|
||||
</p>
|
||||
+8
-49
@@ -8,11 +8,11 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { edit } from '@/helpers/instance'
|
||||
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 { injectInstanceSettings } from './instance-settings-context'
|
||||
import type { AppSettings, Hooks } from '../../../helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -28,21 +28,21 @@ const overrideHooks = ref(
|
||||
)
|
||||
const hooks = ref(instance.value.hooks ?? globalSettings.hooks)
|
||||
|
||||
const editInstanceObject = computed(() => {
|
||||
const editInstancePatch: {
|
||||
const editProfileObject = computed(() => {
|
||||
const editProfile: {
|
||||
hooks?: Hooks
|
||||
} = {}
|
||||
|
||||
// When hooks are not overridden per-instance, we want to clear them
|
||||
editInstancePatch.hooks = overrideHooks.value ? hooks.value : {}
|
||||
editProfile.hooks = overrideHooks.value ? hooks.value : {}
|
||||
|
||||
return editInstancePatch
|
||||
return editProfile
|
||||
})
|
||||
|
||||
watch(
|
||||
[overrideHooks, hooks],
|
||||
async () => {
|
||||
await edit(instance.value.id, editInstanceObject.value)
|
||||
await edit(instance.value.path, editProfileObject.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
@@ -56,35 +56,6 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
'Hooks allow advanced users to run certain system commands before and after launching the game.',
|
||||
},
|
||||
hookVariablesDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.description',
|
||||
defaultMessage:
|
||||
'Hooks run in the working directory of the instance, with the following variables:',
|
||||
},
|
||||
instanceNameDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-name.description',
|
||||
defaultMessage: '$INST_NAME: The name of the instance',
|
||||
},
|
||||
instanceIdDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-id.description',
|
||||
defaultMessage: "$INST_ID: The name of the instance's folder",
|
||||
},
|
||||
instanceDirDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-dir.description',
|
||||
defaultMessage: "$INST_DIR: The absolute path to the instance's folder",
|
||||
},
|
||||
instanceMcDirDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-mc-dir.description',
|
||||
defaultMessage: '$INST_MC_DIR: An alias for $INST_DIR',
|
||||
},
|
||||
instanceJavaDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-java.description',
|
||||
defaultMessage: '$INST_JAVA: The absolute path to the java binary',
|
||||
},
|
||||
instanceJavaArgsDescription: {
|
||||
id: 'instance.settings.tabs.hooks.variables.inst-java-args.description',
|
||||
defaultMessage: '$INST_JAVA_ARGS: The JVM Arguments provided to the game',
|
||||
},
|
||||
customHooks: {
|
||||
id: 'instance.settings.tabs.hooks.custom-hooks',
|
||||
defaultMessage: 'Custom launch hooks',
|
||||
@@ -182,17 +153,5 @@ const messages = defineMessages({
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.postExitDescription) }}
|
||||
</p>
|
||||
|
||||
<div class="m-0 mt-6">
|
||||
{{ formatMessage(messages.hookVariablesDescription) }}
|
||||
</div>
|
||||
<ul class="m-0 mt-2">
|
||||
<li>{{ formatMessage(messages.instanceNameDescription) }}</li>
|
||||
<li>{{ formatMessage(messages.instanceIdDescription) }}</li>
|
||||
<li>{{ formatMessage(messages.instanceDirDescription) }}</li>
|
||||
<li>{{ formatMessage(messages.instanceMcDirDescription) }}</li>
|
||||
<li>{{ formatMessage(messages.instanceJavaDescription) }}</li>
|
||||
<li>{{ formatMessage(messages.instanceJavaArgsDescription) }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
+72
-195
@@ -4,9 +4,9 @@ import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
formatLoaderLabel,
|
||||
injectFilePicker,
|
||||
injectNotificationManager,
|
||||
InstallationSettingsLayout,
|
||||
provideAppBackup,
|
||||
provideInstallationSettings,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
@@ -15,46 +15,32 @@ import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_versions, get_version } from '@/helpers/cache'
|
||||
import { get_loader_versions } from '@/helpers/metadata'
|
||||
import {
|
||||
install_existing_instance,
|
||||
install_pack_to_existing_instance,
|
||||
wait_for_install_job,
|
||||
} from '@/helpers/install'
|
||||
import {
|
||||
duplicate,
|
||||
edit,
|
||||
get_linked_modpack_info,
|
||||
unlink_shared_instance,
|
||||
install,
|
||||
list,
|
||||
update_managed_modrinth_version,
|
||||
update_repair_modrinth,
|
||||
} from '@/helpers/instance'
|
||||
import { get_loader_versions } from '@/helpers/metadata'
|
||||
} from '@/helpers/profile'
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { provideInstanceBackup } from '@/providers/instance-backup'
|
||||
import { useTheming } from '@/store/state'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { Manifest } from '../../../../helpers/types'
|
||||
import { instanceKeys } from '../../query-options.ts'
|
||||
import { injectInstanceSettings } from './instance-settings-context.ts'
|
||||
import SharedInstanceInstallationSettingsControls from './shared-instance-installation-settings-controls.vue'
|
||||
import type { Manifest } from '../../../helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const filePicker = injectFilePicker()
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
const debug = useDebugLogger('AppInstallationSettings')
|
||||
const themeStore = useTheming()
|
||||
|
||||
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
|
||||
const managedContentPolicy = useManagedContentPolicy(instance)
|
||||
const skipNonEssentialWarnings = computed(() =>
|
||||
themeStore.getFeatureFlag('skip_non_essential_warnings'),
|
||||
)
|
||||
|
||||
debug('metadata load: start', {
|
||||
instanceId: instance.value.id,
|
||||
instancePath: instance.value.path,
|
||||
loader: instance.value.loader,
|
||||
gameVersion: instance.value.game_version,
|
||||
installStage: instance.value.install_stage,
|
||||
@@ -105,71 +91,26 @@ const metadataLoading = computed(() =>
|
||||
)
|
||||
|
||||
debug('metadata queries configured', {
|
||||
instanceId: instance.value.id,
|
||||
instancePath: instance.value.path,
|
||||
loader: instance.value.loader,
|
||||
gameVersion: instance.value.game_version,
|
||||
})
|
||||
|
||||
const isModrinthLinkedModpack = computed(
|
||||
() =>
|
||||
instance.value.link?.type === 'modrinth_modpack' ||
|
||||
instance.value.link?.type === 'server_project_modpack' ||
|
||||
(instance.value.link?.type === 'shared_instance' &&
|
||||
!!instance.value.link.modpack_project_id &&
|
||||
!!instance.value.link.modpack_version_id),
|
||||
)
|
||||
const isImportedModpack = computed(() => instance.value.link?.type === 'imported_modpack')
|
||||
const isSharedInstanceManagedModpack = managedContentPolicy.isManagedModpack
|
||||
const canUnlinkSharedInstance = managedContentPolicy.canUnlink
|
||||
|
||||
const modpackInfoQuery = useQuery({
|
||||
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
|
||||
queryFn: () => get_linked_modpack_info(instance.value.id, 'must_revalidate'),
|
||||
enabled: computed(() => isModrinthLinkedModpack.value && !offline),
|
||||
queryKey: computed(() => ['linkedModpackInfo', instance.value.path]),
|
||||
queryFn: () => get_linked_modpack_info(instance.value.path, 'must_revalidate'),
|
||||
enabled: computed(() => !!instance.value.linked_data?.project_id && !offline),
|
||||
})
|
||||
const modpackInfo = modpackInfoQuery.data
|
||||
|
||||
const repairing = ref(false)
|
||||
const reinstalling = ref(false)
|
||||
const unlinkingSharedInstance = ref(false)
|
||||
const installationSettingsBusy = computed(
|
||||
() =>
|
||||
instance.value.quarantined ||
|
||||
instance.value.install_stage !== 'installed' ||
|
||||
repairing.value ||
|
||||
reinstalling.value ||
|
||||
unlinkingSharedInstance.value ||
|
||||
!!offline,
|
||||
)
|
||||
const installationSettingsBusyMessage = computed(() =>
|
||||
instance.value.quarantined ? formatMessage(messages.locked) : null,
|
||||
)
|
||||
|
||||
async function unlinkSharedInstance() {
|
||||
unlinkingSharedInstance.value = true
|
||||
try {
|
||||
await unlink_shared_instance(instance.value.id)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: instanceKeys.sharedMembers(instance.value.id),
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
|
||||
onUnlinked()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
unlinkingSharedInstance.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
loaderVersion: {
|
||||
id: 'instance.settings.tabs.installation.loader-version',
|
||||
defaultMessage: '{loader} version',
|
||||
},
|
||||
locked: {
|
||||
id: 'instance.settings.tabs.installation.locked',
|
||||
defaultMessage: 'Installation settings are unavailable while this instance is locked.',
|
||||
},
|
||||
})
|
||||
|
||||
function getManifest(loader: string) {
|
||||
@@ -188,21 +129,24 @@ function getManifest(loader: string) {
|
||||
return manifest
|
||||
}
|
||||
|
||||
async function installLocalModpackFromPicker() {
|
||||
const picked = await filePicker.pickModpackFile({ readFile: false })
|
||||
if (!picked?.path) return false
|
||||
|
||||
const job = await install_pack_to_existing_instance(instance.value.id, {
|
||||
type: 'fromFile',
|
||||
path: picked.path,
|
||||
}).catch(handleError)
|
||||
if (!job) return false
|
||||
|
||||
const completed = await wait_for_install_job(job.job_id).catch(handleError)
|
||||
return !!completed
|
||||
}
|
||||
|
||||
provideInstanceBackup(instance)
|
||||
provideAppBackup({
|
||||
async createBackup() {
|
||||
debug('createBackup: start', {
|
||||
instancePath: instance.value.path,
|
||||
instanceName: instance.value.name,
|
||||
})
|
||||
const allProfiles = await list()
|
||||
const prefix = `${instance.value.name} - Backup #`
|
||||
const existingNums = allProfiles
|
||||
.filter((p) => p.name.startsWith(prefix))
|
||||
.map((p) => parseInt(p.name.slice(prefix.length), 10))
|
||||
.filter((n) => !isNaN(n))
|
||||
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
|
||||
const newPath = await duplicate(instance.value.path)
|
||||
await edit(newPath, { name: `${prefix}${nextNum}` })
|
||||
debug('createBackup: done', { newPath, backupName: `${prefix}${nextNum}` })
|
||||
},
|
||||
})
|
||||
|
||||
provideInstallationSettings({
|
||||
closeSettings: closeModal,
|
||||
@@ -228,25 +172,15 @@ provideInstallationSettings({
|
||||
}
|
||||
return rows
|
||||
}),
|
||||
isLinked: computed(
|
||||
isLinked: computed(() => !!instance.value.linked_data?.locked),
|
||||
isBusy: computed(
|
||||
() =>
|
||||
isModrinthLinkedModpack.value ||
|
||||
isImportedModpack.value ||
|
||||
instance.value.link?.type === 'server_project' ||
|
||||
isSharedInstanceManagedModpack.value,
|
||||
instance.value.install_stage !== 'installed' ||
|
||||
repairing.value ||
|
||||
reinstalling.value ||
|
||||
!!offline,
|
||||
),
|
||||
isBusy: installationSettingsBusy,
|
||||
busyMessage: installationSettingsBusyMessage,
|
||||
skipNonEssentialWarnings,
|
||||
modpack: computed(() => {
|
||||
if (isImportedModpack.value && instance.value.link?.type === 'imported_modpack') {
|
||||
return {
|
||||
iconUrl: instance.value.icon_path,
|
||||
title: instance.value.link.name ?? instance.value.name,
|
||||
versionNumber: instance.value.link.version_number ?? undefined,
|
||||
filename: instance.value.link.filename ?? undefined,
|
||||
}
|
||||
}
|
||||
if (!modpackInfo.value) return null
|
||||
return {
|
||||
iconUrl: modpackInfo.value.project.icon_url,
|
||||
@@ -290,31 +224,16 @@ provideInstallationSettings({
|
||||
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
|
||||
return []
|
||||
}
|
||||
const entry = manifest.gameVersions?.find((item) => item.id === gameVersion)
|
||||
if (entry?.versionGroup) {
|
||||
const result =
|
||||
manifest.versionGroups?.find((group) => group.id === entry.versionGroup)?.loaders ?? []
|
||||
debug('resolveLoaderVersions: version group result', {
|
||||
loader,
|
||||
gameVersion,
|
||||
versionGroup: entry.versionGroup,
|
||||
count: result.length,
|
||||
})
|
||||
return result
|
||||
}
|
||||
const placeholder = manifest.gameVersions?.find((item) => item.id === '${modrinth.gameVersion}')
|
||||
if (placeholder) {
|
||||
const result = manifest.gameVersions?.some((item) => item.id === gameVersion)
|
||||
? placeholder.loaders
|
||||
: []
|
||||
debug('resolveLoaderVersions: placeholder result', {
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
const result = manifest.gameVersions[0]?.loaders ?? []
|
||||
debug('resolveLoaderVersions: fabric/quilt result', {
|
||||
loader,
|
||||
gameVersion,
|
||||
count: result.length,
|
||||
})
|
||||
return result
|
||||
}
|
||||
const result = entry?.loaders ?? []
|
||||
const result = manifest.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
|
||||
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
|
||||
return result
|
||||
},
|
||||
@@ -342,25 +261,25 @@ provideInstallationSettings({
|
||||
|
||||
async save(platform, gameVersion, loaderVersionId) {
|
||||
debug('save: called', {
|
||||
instanceId: instance.value.id,
|
||||
instancePath: instance.value.path,
|
||||
platform,
|
||||
gameVersion,
|
||||
loaderVersionId,
|
||||
})
|
||||
const editInstancePatch: Record<string, string | undefined> = {
|
||||
const editProfile: Record<string, string | undefined> = {
|
||||
loader: platform,
|
||||
game_version: gameVersion,
|
||||
}
|
||||
if (platform !== 'vanilla' && loaderVersionId) {
|
||||
editInstancePatch.loader_version = loaderVersionId
|
||||
editProfile.loader_version = loaderVersionId
|
||||
}
|
||||
await edit(instance.value.id, editInstancePatch).catch(handleError)
|
||||
debug('save: edit complete', { editInstancePatch })
|
||||
await edit(instance.value.path, editProfile).catch(handleError)
|
||||
debug('save: edit complete', { editProfile })
|
||||
},
|
||||
|
||||
afterSave: async () => {
|
||||
debug('afterSave: installing', { instanceId: instance.value.id })
|
||||
await install_existing_instance(instance.value.id, false).catch(handleError)
|
||||
debug('afterSave: installing', { instancePath: instance.value.path })
|
||||
await install(instance.value.path, false).catch(handleError)
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
@@ -369,9 +288,9 @@ provideInstallationSettings({
|
||||
},
|
||||
|
||||
async repair() {
|
||||
debug('repair: called', { instanceId: instance.value.id })
|
||||
debug('repair: called', { instancePath: instance.value.path })
|
||||
repairing.value = true
|
||||
await install_existing_instance(instance.value.id, true).catch(handleError)
|
||||
await install(instance.value.path, true).catch(handleError)
|
||||
repairing.value = false
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
@@ -381,52 +300,24 @@ provideInstallationSettings({
|
||||
},
|
||||
|
||||
async reinstallModpack() {
|
||||
debug('reinstallModpack: called', { instanceId: instance.value.id })
|
||||
debug('reinstallModpack: called', { instancePath: instance.value.path })
|
||||
reinstalling.value = true
|
||||
let shouldTrack = false
|
||||
try {
|
||||
if (isImportedModpack.value) {
|
||||
shouldTrack = await installLocalModpackFromPicker()
|
||||
} else {
|
||||
await update_repair_modrinth(instance.value.id).catch(handleError)
|
||||
shouldTrack = true
|
||||
}
|
||||
} finally {
|
||||
reinstalling.value = false
|
||||
}
|
||||
if (shouldTrack) {
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
await update_repair_modrinth(instance.value.path).catch(handleError)
|
||||
reinstalling.value = false
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('reinstallModpack: done')
|
||||
},
|
||||
|
||||
async swapModpack() {
|
||||
debug('swapModpack: called', { instanceId: instance.value.id })
|
||||
reinstalling.value = true
|
||||
try {
|
||||
const installed = await installLocalModpackFromPicker()
|
||||
if (installed) {
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
reinstalling.value = false
|
||||
}
|
||||
debug('swapModpack: done')
|
||||
},
|
||||
|
||||
async unlinkModpack() {
|
||||
debug('unlinkModpack: called', { instanceId: instance.value.id })
|
||||
await edit(instance.value.id, {
|
||||
link: null as unknown as undefined,
|
||||
debug('unlinkModpack: called', { instancePath: instance.value.path })
|
||||
await edit(instance.value.path, {
|
||||
linked_data: null as unknown as undefined,
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.value.id],
|
||||
queryKey: ['linkedModpackInfo', instance.value.path],
|
||||
})
|
||||
onUnlinked()
|
||||
debug('unlinkModpack: done')
|
||||
@@ -435,9 +326,11 @@ provideInstallationSettings({
|
||||
getCachedModpackVersions: () => null,
|
||||
async fetchModpackVersions() {
|
||||
debug('fetchModpackVersions: called', {
|
||||
projectId: instance.value.link?.project_id,
|
||||
projectId: instance.value.linked_data?.project_id,
|
||||
})
|
||||
const versions = await get_project_versions(instance.value.link!.project_id!).catch(handleError)
|
||||
const versions = await get_project_versions(instance.value.linked_data!.project_id!).catch(
|
||||
handleError,
|
||||
)
|
||||
debug('fetchModpackVersions: done', { count: versions?.length ?? 0 })
|
||||
return (versions ?? []) as Labrinth.Versions.v2.Version[]
|
||||
},
|
||||
@@ -452,18 +345,19 @@ provideInstallationSettings({
|
||||
async onModpackVersionConfirm(version) {
|
||||
debug('onModpackVersionConfirm: called', {
|
||||
versionId: version.id,
|
||||
instanceId: instance.value.id,
|
||||
instancePath: instance.value.path,
|
||||
})
|
||||
await update_managed_modrinth_version(instance.value.id, version.id)
|
||||
await update_managed_modrinth_version(instance.value.path, version.id)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.value.id],
|
||||
queryKey: ['linkedModpackInfo', instance.value.path],
|
||||
})
|
||||
debug('onModpackVersionConfirm: done')
|
||||
},
|
||||
|
||||
updaterModalProps: computed(() => ({
|
||||
isApp: true,
|
||||
currentVersionId: modpackInfo.value?.update_version_id ?? instance.value.link?.version_id ?? '',
|
||||
currentVersionId:
|
||||
modpackInfo.value?.update_version_id ?? instance.value.linked_data?.version_id ?? '',
|
||||
projectIconUrl: modpackInfo.value?.project?.icon_url,
|
||||
projectName: modpackInfo.value?.project?.title ?? 'Modpack',
|
||||
currentGameVersion: instance.value.game_version,
|
||||
@@ -472,29 +366,12 @@ provideInstallationSettings({
|
||||
|
||||
isServer: false,
|
||||
isApp: true,
|
||||
showModpackVersionActions: computed(
|
||||
() =>
|
||||
isModrinthLinkedModpack.value &&
|
||||
!isMinecraftServer.value &&
|
||||
!isSharedInstanceManagedModpack.value,
|
||||
),
|
||||
isLocalFile: isImportedModpack,
|
||||
isManagedModpack: isSharedInstanceManagedModpack,
|
||||
managedModpackWarning: managedContentPolicy.managedModpackWarning,
|
||||
showModpackVersionActions: !isMinecraftServer.value,
|
||||
repairing,
|
||||
reinstalling,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InstallationSettingsLayout>
|
||||
<template #extra>
|
||||
<SharedInstanceInstallationSettingsControls
|
||||
:can-unlink="canUnlinkSharedInstance"
|
||||
:busy="installationSettingsBusy"
|
||||
:unlinking="unlinkingSharedInstance"
|
||||
:unlink="unlinkSharedInstance"
|
||||
/>
|
||||
</template>
|
||||
</InstallationSettingsLayout>
|
||||
<InstallationSettingsLayout />
|
||||
</template>
|
||||
+37
-52
@@ -9,7 +9,7 @@ import {
|
||||
XCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
@@ -23,11 +23,11 @@ import { computed, readonly, ref, watch } from 'vue'
|
||||
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
|
||||
import useJavaTest from '@/composables/useJavaTest'
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
import { edit, get_optimal_jre_key } from '@/helpers/instance'
|
||||
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 { injectInstanceSettings } from './instance-settings-context'
|
||||
import type { AppSettings } from '../../../helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -36,7 +36,7 @@ const { instance } = injectInstanceSettings()
|
||||
|
||||
const globalSettings = (await get().catch(handleError)) as unknown as AppSettings
|
||||
|
||||
const optimalJava = readonly(await get_optimal_jre_key(instance.value.id).catch(handleError))
|
||||
const optimalJava = readonly(await get_optimal_jre_key(instance.value.path).catch(handleError))
|
||||
|
||||
const overrideJavaInstall = ref(!!instance.value.java_path)
|
||||
const javaPath = ref(instance.value.java_path ?? optimalJava?.path ?? '')
|
||||
@@ -104,7 +104,7 @@ const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) a
|
||||
snapPoints: number[]
|
||||
}
|
||||
|
||||
const editInstanceObject = computed(() => {
|
||||
const editProfileObject = computed(() => {
|
||||
return {
|
||||
java_path:
|
||||
overrideJavaInstall.value && javaPath.value
|
||||
@@ -136,7 +136,7 @@ watch(
|
||||
memory,
|
||||
],
|
||||
async () => {
|
||||
await edit(instance.value.id, editInstanceObject.value)
|
||||
await edit(instance.value.path, editProfileObject.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
@@ -224,60 +224,45 @@ const messages = defineMessages({
|
||||
wrapper-class="flex-1 min-w-0"
|
||||
@update:model-value="(val) => (javaPath = String(val))"
|
||||
/>
|
||||
<Button
|
||||
type="quiet"
|
||||
<ButtonStyled
|
||||
:color="
|
||||
!hoveringTest && !testingJava
|
||||
? javaTestResult === true
|
||||
? 'green'
|
||||
: 'red'
|
||||
: undefined
|
||||
: 'standard'
|
||||
"
|
||||
:disabled="!overrideJavaInstall || testingJava"
|
||||
:style="{
|
||||
'--legacy-button-color':
|
||||
(!hoveringTest && !testingJava
|
||||
? javaTestResult === true
|
||||
? 'green'
|
||||
: 'red'
|
||||
: 'standard') &&
|
||||
(!hoveringTest && !testingJava
|
||||
? javaTestResult === true
|
||||
? 'green'
|
||||
: 'red'
|
||||
: 'standard') !== 'standard'
|
||||
? `var(--color-${
|
||||
!hoveringTest && !testingJava
|
||||
? javaTestResult === true
|
||||
? 'green'
|
||||
: 'red'
|
||||
: 'standard'
|
||||
})`
|
||||
: undefined,
|
||||
}"
|
||||
class="!text-[var(--legacy-button-color,var(--color-base))] [&>svg]:!text-[var(--legacy-button-color,var(--color-primary))]"
|
||||
@click="testJavaInstallation(activePath, optimalJava?.parsed_version, true)"
|
||||
@mouseenter="overrideJavaInstall && (hoveringTest = true)"
|
||||
@mouseleave="hoveringTest = false"
|
||||
color-fill="text"
|
||||
>
|
||||
<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>
|
||||
<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">
|
||||
<Button @click="handleDetectJava">
|
||||
<SearchIcon />
|
||||
Detect
|
||||
</Button>
|
||||
<Button @click="handleBrowseJava">
|
||||
<FolderSearchIcon />
|
||||
Browse
|
||||
</Button>
|
||||
<ButtonStyled>
|
||||
<button @click="handleDetectJava">
|
||||
<SearchIcon />
|
||||
Detect
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="handleBrowseJava">
|
||||
<FolderSearchIcon />
|
||||
Browse
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+5
-5
@@ -9,11 +9,11 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { computed, type Ref, ref, watch } from 'vue'
|
||||
|
||||
import { edit } from '@/helpers/instance'
|
||||
import { edit } from '@/helpers/profile'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { AppSettings } from '../../../../helpers/types'
|
||||
import { injectInstanceSettings } from './instance-settings-context'
|
||||
import type { AppSettings } from '../../../helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -32,7 +32,7 @@ const fullscreenSetting: Ref<boolean> = ref(
|
||||
instance.value.force_fullscreen ?? globalSettings.force_fullscreen,
|
||||
)
|
||||
|
||||
const editInstanceObject = computed(() => {
|
||||
const editProfileObject = computed(() => {
|
||||
if (!overrideWindowSettings.value) {
|
||||
return {
|
||||
force_fullscreen: null,
|
||||
@@ -48,7 +48,7 @@ const editInstanceObject = computed(() => {
|
||||
watch(
|
||||
[overrideWindowSettings, resolution, fullscreenSetting],
|
||||
async () => {
|
||||
await edit(instance.value.id, editInstanceObject.value)
|
||||
await edit(instance.value.path, editProfileObject.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
+29
-23
@@ -7,9 +7,10 @@ import {
|
||||
MessagesSquareIcon,
|
||||
WrenchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Admonition, Button, ButtonLink, Collapsible, IconButton, NewModal } from '@modrinth/ui'
|
||||
import { Admonition, ButtonStyled, Collapsible, NewModal } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
@@ -28,13 +29,19 @@ function show(errorVal: { message?: string }) {
|
||||
matchedError.value = findMinecraftAuthError(rawError.value)
|
||||
|
||||
debugCollapsed.value = true
|
||||
hide_ads_window()
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
onModalHide()
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function onModalHide() {
|
||||
show_ads_window()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
@@ -67,7 +74,7 @@ async function copyToClipboard(text: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" header="Sign in Failed" :max-width="'548px'">
|
||||
<NewModal ref="modal" header="Sign in Failed" :max-width="'548px'" @hide="onModalHide">
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition
|
||||
type="warning"
|
||||
@@ -130,18 +137,16 @@ async function copyToClipboard(text: string) {
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonLink href="https://support.modrinth.com" class="!w-full" @click="modal?.hide()">
|
||||
<MessagesSquareIcon /> Contact support
|
||||
</ButtonLink>
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="loadingSignIn"
|
||||
class="!w-full"
|
||||
@click="signInAgain"
|
||||
>
|
||||
<LogInIcon /> Sign in again
|
||||
</Button>
|
||||
<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">
|
||||
@@ -171,15 +176,16 @@ async function copyToClipboard(text: string) {
|
||||
>
|
||||
{{ debugInfo }}
|
||||
</div>
|
||||
<IconButton
|
||||
v-tooltip="'Copy debug info'"
|
||||
:label="'Copy debug info'"
|
||||
:disabled="copied"
|
||||
@click="copyToClipboard(debugInfo)"
|
||||
>
|
||||
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
|
||||
<template v-else> <CopyIcon /> </template>
|
||||
</IconButton>
|
||||
<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>
|
||||
|
||||
-133
@@ -1,133 +0,0 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" max-width="544px" no-padding>
|
||||
<div class="grid grid-cols-[1fr_auto] gap-2.5 h-[154px] px-7 pt-4 pb-1 pr-9">
|
||||
<div class="flex flex-col gap-2.5 items-start justify-center h-min mt-5">
|
||||
<div class="font-semibold text-xl text-contrast">
|
||||
{{ formatMessage(messages.descriptionHeader) }}
|
||||
</div>
|
||||
<div class="text-secondary leading-6">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-full w-[96px] overflow-hidden mx-3">
|
||||
<div class="absolute top-0 left-0 z-0 w-full flex grow-0 flex-col items-end p-0">
|
||||
<img :src="steveImage" alt="" class="self-stretch" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute left-0 bottom-0 z-10 order-1 h-6 w-[120px] shrink-0 grow-0 bg-[linear-gradient(180deg,rgba(39,41,46,0)_0%,#27292E_80%,#27292E_100%)]"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6 px-6 pb-6">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<ButtonLink href="https://support.modrinth.com" @click="modal?.hide()">
|
||||
<MessagesSquareIcon />
|
||||
{{ formatMessage(messages.getSupport) }}
|
||||
</ButtonLink>
|
||||
<Button type="colored" color="brand" :disabled="loadingSignIn" @click="signIn">
|
||||
<SpinnerIcon v-if="loadingSignIn" class="animate-spin" />
|
||||
<svg
|
||||
v-else
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
|
||||
<rect x="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
|
||||
<rect y="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
|
||||
<rect x="10.75" y="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
|
||||
</svg>
|
||||
{{ formatMessage(messages.signIn) }}
|
||||
</Button>
|
||||
</div>
|
||||
<p class="m-0 text-center text-sm text-secondary">
|
||||
{{ formatMessage(messages.dontHaveAccount) }}
|
||||
<a
|
||||
class="text-blue font-medium hover:underline"
|
||||
href="https://www.minecraft.net/en-us/store/minecraft-java-bedrock-edition-pc"
|
||||
>
|
||||
{{ formatMessage(messages.getMinecraft) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MessagesSquareIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { Button, ButtonLink, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { inject, type Ref, ref } from 'vue'
|
||||
|
||||
import steveImage from '@/assets/steve-look-up-left.webp'
|
||||
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { login as loginFlow, set_default_user } from '@/helpers/auth.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const accountsCard = inject('accountsCard') as Ref<InstanceType<typeof AccountsCard> | null>
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'minecraft-required.header',
|
||||
defaultMessage: 'Minecraft required',
|
||||
},
|
||||
descriptionHeader: {
|
||||
id: 'minecraft-required.description-header',
|
||||
defaultMessage: 'Sign in to a Microsoft account',
|
||||
},
|
||||
description: {
|
||||
id: 'minecraft-required.description',
|
||||
defaultMessage:
|
||||
'You need a Microsoft account that owns Minecraft before you can launch and play.',
|
||||
},
|
||||
getSupport: {
|
||||
id: 'minecraft-required.get-support',
|
||||
defaultMessage: 'Get support',
|
||||
},
|
||||
signIn: {
|
||||
id: 'minecraft-required.sign-in',
|
||||
defaultMessage: 'Sign in to Microsoft',
|
||||
},
|
||||
dontHaveAccount: {
|
||||
id: 'minecraft-required.dont-have-account',
|
||||
defaultMessage: 'Don’t have an account?',
|
||||
},
|
||||
getMinecraft: {
|
||||
id: 'minecraft-required.get-minecraft',
|
||||
defaultMessage: 'Get Minecraft',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const loadingSignIn = ref(false)
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
loadingSignIn.value = true
|
||||
|
||||
try {
|
||||
const loggedIn = await loginFlow()
|
||||
if (!loggedIn) return
|
||||
|
||||
await set_default_user(loggedIn.profile.id)
|
||||
await accountsCard.value?.refreshValues()
|
||||
await trackEvent('AccountLogIn', { source: 'MinecraftRequiredModal' })
|
||||
modal.value?.hide()
|
||||
} catch (error) {
|
||||
handleSevereError(error)
|
||||
} finally {
|
||||
loadingSignIn.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@@ -3,14 +3,12 @@ import {
|
||||
CoffeeIcon,
|
||||
GameIcon,
|
||||
GaugeIcon,
|
||||
HeartHandshakeIcon,
|
||||
LanguagesIcon,
|
||||
ModrinthIcon,
|
||||
PaintbrushIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
ShieldIcon,
|
||||
ToggleRightIcon,
|
||||
UserIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
commonMessages,
|
||||
@@ -19,32 +17,23 @@ import {
|
||||
defineMessages,
|
||||
ProgressBar,
|
||||
TabbedModal,
|
||||
UnsavedChangesPopup,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { platform as getOsPlatform, version as getOsVersion } from '@tauri-apps/plugin-os'
|
||||
import { computed, provide, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import PrivacySettings from '@/components/ui/settings/account/PrivacySettings.vue'
|
||||
import ProfileSettings from '@/components/ui/settings/account/ProfileSettings.vue'
|
||||
import SocialSettings from '@/components/ui/settings/account/SocialSettings.vue'
|
||||
import AppearanceSettings from '@/components/ui/settings/display/AppearanceSettings.vue'
|
||||
import BehaviorSettings from '@/components/ui/settings/display/BehaviorSettings.vue'
|
||||
import FeatureFlagSettings from '@/components/ui/settings/display/FeatureFlagSettings.vue'
|
||||
import LanguageSettings from '@/components/ui/settings/display/LanguageSettings.vue'
|
||||
import DefaultInstanceSettings from '@/components/ui/settings/instances/DefaultInstanceSettings.vue'
|
||||
import JavaSettings from '@/components/ui/settings/instances/JavaSettings.vue'
|
||||
import ResourceManagementSettings from '@/components/ui/settings/instances/ResourceManagementSettings.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 {
|
||||
appSettingsModalContextKey,
|
||||
type UnsavedChangesController,
|
||||
} from '@/providers/app-settings-modal'
|
||||
import { injectAppUpdateDownloadProgress } from '@/providers/download-progress.ts'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
// TODO: Apply COMPONENT_STRUCTURE.md here and extract out common setting option components
|
||||
const themeStore = useTheming()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -56,162 +45,71 @@ const developerModeEnabled = defineMessage({
|
||||
defaultMessage: 'Developer mode enabled.',
|
||||
})
|
||||
|
||||
const tabCategories = defineMessages({
|
||||
display: {
|
||||
id: 'settings.sidebar.label.display',
|
||||
defaultMessage: 'Display',
|
||||
},
|
||||
account: {
|
||||
id: 'settings.sidebar.label.account',
|
||||
defaultMessage: 'Account',
|
||||
},
|
||||
instances: {
|
||||
id: 'app.settings.sidebar.label.instances',
|
||||
defaultMessage: 'Instances',
|
||||
},
|
||||
})
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'app.settings.tabs.appearance',
|
||||
defaultMessage: 'Appearance',
|
||||
}),
|
||||
category: tabCategories.display,
|
||||
icon: PaintbrushIcon,
|
||||
content: AppearanceSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'app.settings.tabs.behavior',
|
||||
defaultMessage: 'Behavior',
|
||||
}),
|
||||
category: tabCategories.display,
|
||||
icon: Settings2Icon,
|
||||
content: BehaviorSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'app.settings.tabs.language',
|
||||
defaultMessage: 'Language',
|
||||
}),
|
||||
category: tabCategories.display,
|
||||
icon: LanguagesIcon,
|
||||
content: LanguageSettings,
|
||||
badge: commonMessages.beta,
|
||||
},
|
||||
{
|
||||
name: commonSettingsMessages.featureFlags,
|
||||
category: tabCategories.display,
|
||||
icon: ToggleRightIcon,
|
||||
content: FeatureFlagSettings,
|
||||
developerOnly: true,
|
||||
},
|
||||
{
|
||||
name: commonSettingsMessages.profile,
|
||||
category: tabCategories.account,
|
||||
icon: UserIcon,
|
||||
content: ProfileSettings,
|
||||
},
|
||||
{
|
||||
name: commonSettingsMessages.social,
|
||||
category: tabCategories.account,
|
||||
icon: HeartHandshakeIcon,
|
||||
content: SocialSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'app.settings.tabs.privacy',
|
||||
defaultMessage: 'Privacy',
|
||||
}),
|
||||
category: tabCategories.account,
|
||||
icon: ShieldIcon,
|
||||
content: PrivacySettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'app.settings.tabs.default-instance-options',
|
||||
defaultMessage: 'Default game options',
|
||||
}),
|
||||
category: tabCategories.instances,
|
||||
icon: GameIcon,
|
||||
content: DefaultInstanceSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'app.settings.tabs.java-installations',
|
||||
defaultMessage: 'Java installations',
|
||||
}),
|
||||
category: tabCategories.instances,
|
||||
icon: CoffeeIcon,
|
||||
content: JavaSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'app.settings.tabs.default-instance-options',
|
||||
defaultMessage: 'Default instance options',
|
||||
}),
|
||||
icon: GameIcon,
|
||||
content: DefaultInstanceSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'app.settings.tabs.resource-management',
|
||||
defaultMessage: 'Resource management',
|
||||
}),
|
||||
category: tabCategories.instances,
|
||||
icon: GaugeIcon,
|
||||
content: ResourceManagementSettings,
|
||||
},
|
||||
{
|
||||
name: commonSettingsMessages.featureFlags,
|
||||
icon: ToggleRightIcon,
|
||||
content: FeatureFlagSettings,
|
||||
developerOnly: true,
|
||||
},
|
||||
]
|
||||
|
||||
const availableTabs = computed(() => tabs.filter((tab) => !tab.developerOnly || themeStore.devMode))
|
||||
|
||||
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||
const unsavedChangesPopup = ref<{ nudge: () => void } | null>(null)
|
||||
const unsavedChangesController = ref<UnsavedChangesController | null>(null)
|
||||
const emptyUnsavedChangesState: Record<string, unknown> = {}
|
||||
const originalUnsavedChangesState = computed(
|
||||
() => unsavedChangesController.value?.getOriginal() ?? emptyUnsavedChangesState,
|
||||
)
|
||||
const modifiedUnsavedChangesState = computed(
|
||||
() => unsavedChangesController.value?.getModified() ?? emptyUnsavedChangesState,
|
||||
)
|
||||
const savingUnsavedChanges = computed(() => unsavedChangesController.value?.isSaving() ?? false)
|
||||
const hasUnsavedChanges = computed(() => unsavedChangesController.value?.hasChanges() ?? false)
|
||||
|
||||
function canLeaveCurrentTab(): boolean {
|
||||
if (!unsavedChangesController.value?.hasChanges()) return true
|
||||
unsavedChangesPopup.value?.nudge()
|
||||
return false
|
||||
}
|
||||
|
||||
function close(): boolean {
|
||||
return modal.value?.hide() ?? false
|
||||
}
|
||||
|
||||
function registerUnsavedChangesController(controller: UnsavedChangesController | null): void {
|
||||
unsavedChangesController.value = controller
|
||||
}
|
||||
|
||||
provide(appSettingsModalContextKey, {
|
||||
close,
|
||||
registerUnsavedChangesController,
|
||||
})
|
||||
|
||||
function resetUnsavedChanges(): void {
|
||||
unsavedChangesController.value?.reset()
|
||||
}
|
||||
|
||||
function saveUnsavedChanges(): void {
|
||||
void unsavedChangesController.value?.save()
|
||||
}
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function showProfile(): void {
|
||||
const profileTabIndex = availableTabs.value.findIndex((tab) => tab.content === ProfileSettings)
|
||||
if (profileTabIndex >= 0) {
|
||||
modal.value?.setTab(profileTabIndex)
|
||||
}
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
defineExpose({ show, showProfile })
|
||||
defineExpose({ show })
|
||||
|
||||
const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress()
|
||||
|
||||
@@ -231,15 +129,12 @@ watch(
|
||||
function devModeCount() {
|
||||
devModeCounter.value++
|
||||
if (devModeCounter.value > 5) {
|
||||
const selectedTab = modal.value ? availableTabs.value[modal.value.selectedTab] : undefined
|
||||
|
||||
themeStore.devMode = !themeStore.devMode
|
||||
settings.value.developer_mode = !!themeStore.devMode
|
||||
devModeCounter.value = 0
|
||||
|
||||
if (modal.value) {
|
||||
const selectedTabIndex = selectedTab ? availableTabs.value.indexOf(selectedTab) : -1
|
||||
modal.value.setTab(selectedTabIndex >= 0 ? selectedTabIndex : 0)
|
||||
if (!themeStore.devMode && tabs[modal.value!.selectedTab].developerOnly) {
|
||||
modal.value!.setTab(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,45 +144,15 @@ const messages = defineMessages({
|
||||
id: 'app.settings.downloading',
|
||||
defaultMessage: 'Downloading v{version}',
|
||||
},
|
||||
appVersion: {
|
||||
id: 'app.settings.app-version',
|
||||
defaultMessage: 'Modrinth App {version}',
|
||||
},
|
||||
macos: {
|
||||
id: 'app.settings.operating-system.macos',
|
||||
defaultMessage: 'macOS',
|
||||
},
|
||||
developerModeButtonLabel: {
|
||||
id: 'app.settings.developer-mode-button.label',
|
||||
defaultMessage: 'Toggle developer mode',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<TabbedModal
|
||||
ref="modal"
|
||||
:tabs="availableTabs"
|
||||
:width="'min(928px, calc(95vw - 10rem))'"
|
||||
:before-hide="canLeaveCurrentTab"
|
||||
:before-tab-change="canLeaveCurrentTab"
|
||||
:floating-action-bar-shown="hasUnsavedChanges"
|
||||
>
|
||||
<TabbedModal ref="modal" :tabs="tabs.filter((t) => !t.developerOnly || themeStore.devMode)">
|
||||
<template #title>
|
||||
<span class="text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(commonMessages.settingsLabel) }}
|
||||
<span class="flex items-center gap-2 text-lg font-extrabold text-contrast">
|
||||
<SettingsIcon /> Settings
|
||||
</span>
|
||||
</template>
|
||||
<template #floating-action-bar>
|
||||
<UnsavedChangesPopup
|
||||
ref="unsavedChangesPopup"
|
||||
:original="originalUnsavedChangesState"
|
||||
:modified="modifiedUnsavedChangesState"
|
||||
:saving="savingUnsavedChanges"
|
||||
inline
|
||||
@reset="resetUnsavedChanges"
|
||||
@save="saveUnsavedChanges"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="mt-auto text-secondary text-sm">
|
||||
<div class="mb-3">
|
||||
@@ -303,7 +168,6 @@ const messages = defineMessages({
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
:aria-label="formatMessage(messages.developerModeButtonLabel)"
|
||||
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
|
||||
:class="{
|
||||
'text-brand': themeStore.devMode,
|
||||
@@ -311,14 +175,12 @@ const messages = defineMessages({
|
||||
}"
|
||||
@click="devModeCount"
|
||||
>
|
||||
<ModrinthIcon aria-hidden="true" class="w-6 h-6" />
|
||||
<ModrinthIcon class="w-6 h-6" />
|
||||
</button>
|
||||
<div class="max-w-[200px]">
|
||||
<p class="m-0">Modrinth App {{ version }}</p>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.appVersion, { version }) }}
|
||||
</p>
|
||||
<p class="m-0">
|
||||
<span v-if="osPlatform === 'macos'">{{ formatMessage(messages.macos) }}</span>
|
||||
<span v-if="osPlatform === 'macos'">macOS</span>
|
||||
<span v-else class="capitalize">{{ osPlatform }}</span>
|
||||
{{ osVersion }}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
|
||||
defineProps({
|
||||
onFlowCancel: {
|
||||
type: Function,
|
||||
default() {
|
||||
return async () => {}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref()
|
||||
|
||||
function show() {
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @hide="onFlowCancel">
|
||||
<template #title>
|
||||
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
|
||||
<LogInIcon /> Sign in
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-center gap-2">
|
||||
<SpinnerIcon class="w-12 h-12 animate-spin" />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">
|
||||
Please sign in at the browser window that just opened to continue.
|
||||
</p>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@@ -6,14 +6,18 @@
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button type="outlined" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button type="colored" color="red" @click="confirm">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.deleteButton) }}
|
||||
</Button>
|
||||
<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>
|
||||
@@ -23,7 +27,7 @@
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Button,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
|
||||
@@ -1,38 +1,32 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.installToPlay)"
|
||||
:closable="true"
|
||||
max-width="544px"
|
||||
width="544px"
|
||||
>
|
||||
<div v-if="requiredContentProject" class="flex w-full flex-col gap-6">
|
||||
<p class="m-0 text-primary">
|
||||
{{ formatMessage(messages.inviteWarning) }}
|
||||
</p>
|
||||
<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-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.sharedInstance) }}
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.requiredModpack)
|
||||
}}</span>
|
||||
|
||||
<Button type="quiet" @click="openViewContents">
|
||||
<EyeIcon />
|
||||
{{ formatMessage(messages.viewContents) }}
|
||||
</Button>
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="openViewContents">
|
||||
<EyeIcon />
|
||||
{{ formatMessage(messages.viewContents) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 rounded-2xl bg-surface-2 p-3">
|
||||
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
|
||||
<Avatar
|
||||
:src="requiredContentProject.icon_url"
|
||||
:alt="requiredContentProject.title"
|
||||
size="56px"
|
||||
no-shadow
|
||||
class="!rounded-2xl"
|
||||
size="48px"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="truncate font-semibold text-contrast">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
<template v-if="usingCustomModpack && modpackVersion">
|
||||
{{ modpackVersion.name }}
|
||||
</template>
|
||||
@@ -40,7 +34,7 @@
|
||||
{{ requiredContentProject.title }}
|
||||
</template>
|
||||
</span>
|
||||
<span class="truncate text-sm font-medium text-secondary">
|
||||
<span class="text-sm text-secondary">
|
||||
{{ loaderDisplay }} {{ requiredContentProject.game_versions?.[0] }}
|
||||
<template v-if="modCount">
|
||||
· {{ formatMessage(messages.modCount, { count: modCount }) }}
|
||||
@@ -49,156 +43,62 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Admonition
|
||||
v-if="hasExternalFiles"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.unknownFilesWarning)"
|
||||
>
|
||||
{{ formatMessage(messages.unknownFilesDescription) }}
|
||||
</Admonition>
|
||||
|
||||
<div v-if="hasExternalFiles" class="relative w-full">
|
||||
<div
|
||||
ref="externalFileTable"
|
||||
class="max-h-[242px] overflow-y-auto rounded-2xl"
|
||||
@scroll="checkTableScrollState"
|
||||
>
|
||||
<Table
|
||||
:columns="externalFileColumns"
|
||||
:data="externalFileRows"
|
||||
row-key="id"
|
||||
virtualized
|
||||
:virtual-row-height="48"
|
||||
class="shadow-sm"
|
||||
>
|
||||
<template #cell-name="{ value }">
|
||||
<span class="block truncate" :title="String(value)">{{ value }}</span>
|
||||
</template>
|
||||
</Table>
|
||||
</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-2"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-2"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showTableTopFade"
|
||||
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
<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-2"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-2"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showTableBottomFade"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<p v-if="hasExternalFiles" class="m-0 text-primary">
|
||||
{{ formatMessage(messages.reviewedFiles) }}
|
||||
</p>
|
||||
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<Button type="quiet" color="red" @click="handleReport">
|
||||
<ReportIcon />
|
||||
{{ formatMessage(commonMessages.reportButton) }}
|
||||
</Button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="hasExternalFiles">
|
||||
<Button type="quiet" color="orange" @click="handleAccept">
|
||||
{{ formatMessage(messages.installAnyway) }}
|
||||
</Button>
|
||||
<Button type="colored" color="brand" @click="handleDecline">
|
||||
<BanIcon />
|
||||
{{ formatMessage(messages.dontInstall) }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button @click="handleDecline">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button type="colored" color="brand" @click="handleAccept">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.installButton) }}
|
||||
</Button>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
<ManagedContentModal
|
||||
ref="managedContentModal"
|
||||
:header="formatMessage(messages.modpackContent)"
|
||||
:source-name="project?.name ?? ''"
|
||||
:source-icon-url="project?.icon_url ?? undefined"
|
||||
<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 { BanIcon, DownloadIcon, EyeIcon, ReportIcon, XIcon } from '@modrinth/assets'
|
||||
import { Button } from '@modrinth/ui'
|
||||
import { DownloadIcon, EyeIcon, XIcon } from '@modrinth/assets'
|
||||
import type { ContentItem } from '@modrinth/ui'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
type ContentItem,
|
||||
defineMessages,
|
||||
formatLoader,
|
||||
ManagedContentModal,
|
||||
ModpackContentModal,
|
||||
NewModal,
|
||||
Table,
|
||||
type TableColumn,
|
||||
useScrollIndicator,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
|
||||
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
|
||||
type ExternalFileColumn = 'name'
|
||||
type ExternalFileRow = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
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 externalFiles = ref<string[]>([])
|
||||
const externalFileTable = ref<HTMLElement | null>(null)
|
||||
const onInstallComplete = ref<() => void>(() => {})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
showExternalWarnings?: boolean
|
||||
}>()
|
||||
|
||||
const { installServerProject, startInstallingServer, stopInstallingServer } = injectServerInstall()
|
||||
const {
|
||||
showTopFade: showTableTopFade,
|
||||
showBottomFade: showTableBottomFade,
|
||||
checkScrollState: checkTableScrollState,
|
||||
forceCheck: forceCheckTableScroll,
|
||||
} = useScrollIndicator(externalFileTable)
|
||||
|
||||
const usingCustomModpack = computed(() => {
|
||||
return requiredContentProject.value?.id === project.value?.id
|
||||
@@ -211,36 +111,13 @@ const loaderDisplay = computed(() => {
|
||||
})
|
||||
|
||||
const modCount = computed(() => modpackVersion.value?.dependencies?.length)
|
||||
const hasExternalFiles = computed(
|
||||
() => Boolean(props.showExternalWarnings) && externalFiles.value.length > 0,
|
||||
)
|
||||
const externalFileRows = computed<ExternalFileRow[]>(() =>
|
||||
externalFiles.value.map((name, index) => ({
|
||||
id: `${index}-${name}`,
|
||||
name,
|
||||
})),
|
||||
)
|
||||
|
||||
async function fetchData(versionId: string) {
|
||||
// cache is making version null for some reason so bypassing for now
|
||||
const version = await get_version(versionId, 'bypass')
|
||||
modpackVersion.value = version
|
||||
modpackVersion.value = await get_version(versionId, 'bypass')
|
||||
|
||||
if (version?.project_id) {
|
||||
requiredContentProject.value = await get_project(version.project_id, 'bypass')
|
||||
externalFiles.value = [
|
||||
...new Set(
|
||||
(version.dependencies ?? [])
|
||||
.filter(
|
||||
(dependency) =>
|
||||
dependency.dependency_type === 'embedded' &&
|
||||
!dependency.project_id &&
|
||||
!dependency.version_id &&
|
||||
dependency.file_name,
|
||||
)
|
||||
.flatMap((dependency) => (dependency.file_name ? [dependency.file_name] : [])),
|
||||
),
|
||||
].sort((left, right) => left.localeCompare(right))
|
||||
if (modpackVersion.value?.project_id) {
|
||||
requiredContentProject.value = await get_project(modpackVersion.value.project_id, 'bypass')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,16 +139,10 @@ function handleDecline() {
|
||||
hide()
|
||||
}
|
||||
|
||||
function handleReport() {
|
||||
if (project.value?.id) {
|
||||
openUrl(`https://modrinth.com/report?item=project&itemID=${project.value.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const managedContentModal = ref<InstanceType<typeof ManagedContentModal>>()
|
||||
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
|
||||
|
||||
async function openViewContents() {
|
||||
managedContentModal.value?.showLoading()
|
||||
modpackContentModal.value?.showLoading()
|
||||
try {
|
||||
// Ensure version data is available — the useQuery may not have resolved yet
|
||||
const versionId = modpackVersionId.value
|
||||
@@ -329,10 +200,10 @@ async function openViewContents() {
|
||||
}
|
||||
},
|
||||
)
|
||||
managedContentModal.value?.show(contentItems)
|
||||
modpackContentModal.value?.show(contentItems)
|
||||
} catch (err) {
|
||||
console.error('Failed to load modpack contents:', err)
|
||||
managedContentModal.value?.show([])
|
||||
modpackContentModal.value?.show([])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,33 +217,40 @@ async function show(
|
||||
modpackVersionId.value = modpackVersionIdVal
|
||||
modpackVersion.value = null
|
||||
requiredContentProject.value = null
|
||||
externalFiles.value = []
|
||||
onInstallComplete.value = callback
|
||||
|
||||
if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
|
||||
|
||||
hide_ads_window()
|
||||
modal.value?.show(e)
|
||||
await nextTick()
|
||||
forceCheckTableScroll()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
show_ads_window()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
modpackContent: {
|
||||
id: 'app.modal.install-to-play.managed-content.modpack-header',
|
||||
defaultMessage: 'Modpack content',
|
||||
},
|
||||
installToPlay: {
|
||||
id: 'app.modal.install-to-play.header',
|
||||
defaultMessage: 'Install to play',
|
||||
},
|
||||
inviteWarning: {
|
||||
id: 'app.modal.install-to-play.invite-warning',
|
||||
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 invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.',
|
||||
'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',
|
||||
@@ -390,40 +268,7 @@ const messages = defineMessages({
|
||||
id: 'app.modal.install-to-play.view-contents',
|
||||
defaultMessage: 'View contents',
|
||||
},
|
||||
unknownFilesWarning: {
|
||||
id: 'app.modal.install-to-play.unknown-files-warning',
|
||||
defaultMessage: 'Unknown files warning',
|
||||
},
|
||||
unknownFilesDescription: {
|
||||
id: 'app.modal.install-to-play.unknown-files-description',
|
||||
defaultMessage:
|
||||
'This server modpack contains files that aren’t published on Modrinth. We strongly recommend only installing files from sources you trust.',
|
||||
},
|
||||
unrecognizedFiles: {
|
||||
id: 'app.modal.install-to-play.unrecognized-files',
|
||||
defaultMessage: 'Unrecognized files',
|
||||
},
|
||||
reviewedFiles: {
|
||||
id: 'app.modal.install-to-play.reviewed-files',
|
||||
defaultMessage: "Files that aren't published to Modrinth aren't reviewed.",
|
||||
},
|
||||
installAnyway: {
|
||||
id: 'app.modal.install-to-play.install-anyway',
|
||||
defaultMessage: 'Install anyway',
|
||||
},
|
||||
dontInstall: {
|
||||
id: 'app.modal.install-to-play.external-files-dont-install',
|
||||
defaultMessage: "Don't install",
|
||||
},
|
||||
})
|
||||
|
||||
const externalFileColumns = computed<TableColumn<ExternalFileColumn>[]>(() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: formatMessage(messages.unrecognizedFiles),
|
||||
cellClass: '!h-12',
|
||||
},
|
||||
])
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
@@ -14,7 +14,7 @@ defineProps<{
|
||||
<Avatar
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
|
||||
size="24px"
|
||||
:tint-by="instance.id"
|
||||
:tint-by="instance.path"
|
||||
/>
|
||||
{{ instance.name }} <ChevronRightIcon />
|
||||
</span>
|
||||
|
||||
+15
-33
@@ -6,7 +6,6 @@ import {
|
||||
CoffeeIcon,
|
||||
InfoIcon,
|
||||
MonitorIcon,
|
||||
UsersIcon,
|
||||
WrenchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
@@ -18,26 +17,24 @@ import {
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { PlatformTag } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { computed, nextTick, ref, watch } 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/instance'
|
||||
import { get_loader_versions } from '@/helpers/metadata'
|
||||
import { get_linked_modpack_info } from '@/helpers/profile'
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { provideInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import GeneralSettings from './general-settings.vue'
|
||||
import HooksSettings from './hooks-settings.vue'
|
||||
import InstallationSettings from './installation-settings.vue'
|
||||
import { provideInstanceSettings } from './instance-settings-context.ts'
|
||||
import JavaSettings from './java-settings.vue'
|
||||
import SharingSettings from './sharing-settings.vue'
|
||||
import WindowSettings from './window-settings.vue'
|
||||
import type { GameInstance } from '../../../helpers/types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
@@ -69,8 +66,8 @@ watch(
|
||||
() => props.instance,
|
||||
(instance) => {
|
||||
isMinecraftServer.value = false
|
||||
if (instance.link?.project_id) {
|
||||
get_project_v3(instance.link.project_id, 'must_revalidate')
|
||||
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
|
||||
@@ -99,15 +96,6 @@ const tabs = computed<TabbedModalTab[]>(() => [
|
||||
icon: WrenchIcon,
|
||||
content: InstallationSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.sharing',
|
||||
defaultMessage: 'Sharing',
|
||||
}),
|
||||
icon: UsersIcon,
|
||||
content: SharingSettings,
|
||||
shown: props.instance.shared_instance?.role === 'owner' && !props.instance.quarantined,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.window',
|
||||
@@ -168,18 +156,12 @@ useQuery({
|
||||
queryFn: getSupportedModpackLoaders,
|
||||
})
|
||||
useQuery({
|
||||
queryKey: computed(() => ['linkedModpackInfo', props.instance.id]),
|
||||
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
|
||||
enabled: computed(() => !!props.instance.link?.project_id && !props.offline),
|
||||
queryKey: computed(() => ['linkedModpackInfo', props.instance.path]),
|
||||
queryFn: () => get_linked_modpack_info(props.instance.path, 'stale_while_revalidate'),
|
||||
enabled: computed(() => !!props.instance.linked_data?.project_id && !props.offline),
|
||||
})
|
||||
|
||||
function show(tabIndex?: number) {
|
||||
if (props.instance.link?.project_id) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['linkedModpackInfo', props.instance.id],
|
||||
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
|
||||
})
|
||||
}
|
||||
tabbedModal.value?.show()
|
||||
if (tabIndex !== undefined) {
|
||||
nextTick(() => tabbedModal.value?.setTab(tabIndex))
|
||||
@@ -200,7 +182,7 @@ defineExpose({ show, hide })
|
||||
<Avatar
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
|
||||
size="24px"
|
||||
:tint-by="props.instance.id"
|
||||
:tint-by="props.instance.path"
|
||||
/>
|
||||
{{ instance.name }} <ChevronRightIcon />
|
||||
<span class="font-extrabold text-contrast">{{
|
||||
@@ -10,18 +10,24 @@
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button type="outlined" @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button @click="handleGoToInstance">
|
||||
{{ formatMessage(messages.instance) }}
|
||||
<RightArrowIcon />
|
||||
</Button>
|
||||
<Button type="colored" color="orange" @click="handleCreateAnyway">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.create) }}
|
||||
</Button>
|
||||
<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>
|
||||
@@ -30,7 +36,7 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
@@ -62,17 +68,17 @@ const messages = defineMessages({
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'go-to-instance', instanceId: string): void
|
||||
(e: 'go-to-instance', instancePath: string): void
|
||||
(e: 'create-anyway'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const instanceName = ref('')
|
||||
const instanceId = ref('')
|
||||
const instancePath = ref('')
|
||||
|
||||
function show(name: string, id: string) {
|
||||
function show(name: string, path: string) {
|
||||
instanceName.value = name
|
||||
instanceId.value = id
|
||||
instancePath.value = path
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
@@ -82,7 +88,7 @@ function handleCancel() {
|
||||
|
||||
function handleGoToInstance() {
|
||||
modal.value?.hide()
|
||||
emit('go-to-instance', instanceId.value)
|
||||
emit('go-to-instance', instancePath.value)
|
||||
}
|
||||
|
||||
function handleCreateAnyway() {
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(authenticating ? messages.signingInHeader : messages.header)"
|
||||
:on-hide="handleHide"
|
||||
no-padding
|
||||
max-width="548px"
|
||||
width="100%"
|
||||
>
|
||||
<div v-if="authenticating === null" class="flex w-full flex-col gap-6 p-6">
|
||||
<div class="flex flex-col gap-2 px-3">
|
||||
<h2 class="m-0 text-xl font-semibold leading-7 text-contrast">
|
||||
{{ formatMessage(messages.signInHeading) }}
|
||||
</h2>
|
||||
<p class="m-0 text-base leading-6 text-primary">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Button class="w-full" native-type="button" @click="authenticate('sign-up')">
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.createAccountButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
class="w-full"
|
||||
native-type="button"
|
||||
@click="authenticate('sign-in')"
|
||||
>
|
||||
<LogInIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.signInButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
|
||||
<IntlFormatted :message-id="messages.supportPrompt">
|
||||
<template #support="{ children }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline cursor-pointer border-0 bg-transparent p-0 text-base font-medium leading-6 text-blue hover:underline"
|
||||
@click="openSupport"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</button>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex w-full flex-col gap-6 p-6">
|
||||
<div class="flex flex-col gap-2.5 px-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="m-0 text-xl font-semibold leading-7 text-contrast">
|
||||
{{ formatMessage(messages.continueInBrowserHeading) }}
|
||||
</h2>
|
||||
<p class="m-0 text-base leading-6 text-primary">
|
||||
{{ formatMessage(messages.browserDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-primary">
|
||||
<SpinnerIcon aria-hidden="true" class="h-5 w-5 shrink-0 animate-spin" />
|
||||
<span class="text-base leading-6">
|
||||
{{ formatMessage(messages.waitingForBrowser) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Button type="outlined" class="w-full" native-type="button" @click="modal?.hide()">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
class="w-full"
|
||||
native-type="button"
|
||||
:disabled="reopeningBrowser"
|
||||
@click="reopenBrowser"
|
||||
>
|
||||
<RefreshCwIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.openBrowserAgainButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
|
||||
<IntlFormatted :message-id="messages.supportPrompt">
|
||||
<template #support="{ children }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline cursor-pointer border-0 bg-transparent p-0 text-base font-medium leading-6 text-blue hover:underline"
|
||||
@click="openSupport"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</button>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LogInIcon, RefreshCwIcon, SpinnerIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { cancelLogin, type ModrinthAuthFlow } from '@/helpers/mr_auth'
|
||||
|
||||
const props = defineProps<{
|
||||
requestAuth: (flow: ModrinthAuthFlow) => Promise<boolean>
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const authenticating = ref<ModrinthAuthFlow | null>(null)
|
||||
const reopeningBrowser = ref(false)
|
||||
let resolveShow: ((signedIn: boolean) => void) | undefined
|
||||
let authenticationId = 0
|
||||
let activeAuthentication: Promise<void> | undefined
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
resetAuthentication(true)
|
||||
resolveShow?.(false)
|
||||
const modalInstance = modal.value
|
||||
if (!modalInstance) return Promise.resolve(false)
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveShow = resolve
|
||||
modalInstance.show(event)
|
||||
})
|
||||
}
|
||||
|
||||
function showSigningIn(flow: ModrinthAuthFlow = 'sign-in', event?: MouseEvent) {
|
||||
const result = show(event)
|
||||
authenticate(flow)
|
||||
return result
|
||||
}
|
||||
|
||||
function finish(signedIn: boolean) {
|
||||
resolveShow?.(signedIn)
|
||||
resolveShow = undefined
|
||||
}
|
||||
|
||||
function authenticate(flow: ModrinthAuthFlow) {
|
||||
const id = ++authenticationId
|
||||
authenticating.value = flow
|
||||
|
||||
const authentication = (async () => {
|
||||
try {
|
||||
if ((await props.requestAuth(flow)) && authenticationId === id) {
|
||||
authenticating.value = null
|
||||
activeAuthentication = undefined
|
||||
finish(true)
|
||||
modal.value?.hide()
|
||||
}
|
||||
} finally {
|
||||
if (authenticationId === id) {
|
||||
authenticating.value = null
|
||||
activeAuthentication = undefined
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
activeAuthentication = authentication
|
||||
}
|
||||
|
||||
async function reopenBrowser() {
|
||||
const flow = authenticating.value
|
||||
if (!flow || reopeningBrowser.value) return
|
||||
|
||||
reopeningBrowser.value = true
|
||||
const previousAuthentication = activeAuthentication
|
||||
++authenticationId
|
||||
|
||||
try {
|
||||
await cancelLogin()
|
||||
await previousAuthentication?.catch(() => undefined)
|
||||
if (authenticating.value === flow) authenticate(flow)
|
||||
} finally {
|
||||
reopeningBrowser.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetAuthentication(cancelActive: boolean) {
|
||||
const wasAuthenticating = authenticating.value !== null
|
||||
++authenticationId
|
||||
activeAuthentication = undefined
|
||||
authenticating.value = null
|
||||
reopeningBrowser.value = false
|
||||
|
||||
if (cancelActive && wasAuthenticating) void cancelLogin()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
resetAuthentication(true)
|
||||
finish(false)
|
||||
}
|
||||
|
||||
function openSupport() {
|
||||
openUrl('https://support.modrinth.com')
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'modal.modrinth-account-required.header',
|
||||
defaultMessage: 'Account required',
|
||||
},
|
||||
signingInHeader: {
|
||||
id: 'modal.modrinth-account-required.signing-in-header',
|
||||
defaultMessage: 'Signing in',
|
||||
},
|
||||
signInHeading: {
|
||||
id: 'modal.modrinth-account-required.sign-in-heading',
|
||||
defaultMessage: 'Sign in to a Modrinth account',
|
||||
},
|
||||
description: {
|
||||
id: 'modal.modrinth-account-required.description',
|
||||
defaultMessage:
|
||||
"You'll need to sign into your Modrinth account before you can use this feature.",
|
||||
},
|
||||
createAccountButton: {
|
||||
id: 'modal.modrinth-account-required.create-account-button',
|
||||
defaultMessage: 'Create an account',
|
||||
},
|
||||
signInButton: {
|
||||
id: 'modal.modrinth-account-required.sign-in-button',
|
||||
defaultMessage: 'Sign in to Modrinth',
|
||||
},
|
||||
continueInBrowserHeading: {
|
||||
id: 'modal.modrinth-account-required.continue-in-browser-heading',
|
||||
defaultMessage: 'Continue in your browser',
|
||||
},
|
||||
browserDescription: {
|
||||
id: 'modal.modrinth-account-required.browser-description',
|
||||
defaultMessage:
|
||||
'A new tab opened to sign in. Complete the sign in there, then return to the app.',
|
||||
},
|
||||
waitingForBrowser: {
|
||||
id: 'modal.modrinth-account-required.waiting-for-browser',
|
||||
defaultMessage: 'Waiting for browser confirmation...',
|
||||
},
|
||||
openBrowserAgainButton: {
|
||||
id: 'modal.modrinth-account-required.open-browser-again-button',
|
||||
defaultMessage: 'Open browser again',
|
||||
},
|
||||
supportPrompt: {
|
||||
id: 'modal.modrinth-account-required.support-prompt',
|
||||
defaultMessage: 'Having trouble signing in? <support>Get support</support>',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ show, showSigningIn })
|
||||
</script>
|
||||
@@ -2,18 +2,17 @@
|
||||
<ContentDiffModal
|
||||
ref="diffModal"
|
||||
:header="formatMessage(messages.updateToPlay)"
|
||||
:admonition-header="formatMessage(messages.updateRequired)"
|
||||
:description="
|
||||
instance ? formatMessage(messages.updateRequiredDescription, { name: instance.name }) : ''
|
||||
"
|
||||
:diffs="normalizedDiffs"
|
||||
:version-date="versionDate"
|
||||
:show-external-warnings="showExternalWarnings"
|
||||
:external-warning-description="formatMessage(messages.externalWarningDescription)"
|
||||
:confirm-label="formatMessage(commonMessages.updateButton)"
|
||||
:confirm-icon="DownloadIcon"
|
||||
:removed-label="formatMessage(messages.removed)"
|
||||
:show-report-button="true"
|
||||
@confirm="handleUpdate"
|
||||
@cancel="handleDecline"
|
||||
@report="handleReport"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -27,12 +26,12 @@ import {
|
||||
defineMessages,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import { wait_for_install_job } from '@/helpers/install'
|
||||
import { update_managed_modrinth_version } from '@/helpers/instance'
|
||||
import { update_managed_modrinth_version } from '@/helpers/profile'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
|
||||
@@ -77,15 +76,6 @@ const { formatMessage } = useVIntl()
|
||||
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
|
||||
type UpdateCompleteCallback = () => void | Promise<void>
|
||||
|
||||
const { showExternalWarnings } = defineProps<{
|
||||
showExternalWarnings?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
complete: []
|
||||
}>()
|
||||
|
||||
const diffModal = ref<InstanceType<typeof ContentDiffModal>>()
|
||||
const instance = ref<GameInstance | null>(null)
|
||||
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
|
||||
@@ -93,21 +83,14 @@ const diffs = ref<DependencyDiff[]>([])
|
||||
const modpackVersionId = ref<string | null>(null)
|
||||
const modpackVersion = ref<Version | null>(null)
|
||||
|
||||
const normalizedDiffs = computed<ContentDiffItem[]>(() => {
|
||||
return diffs.value.map((diff) => ({
|
||||
const normalizedDiffs = computed<ContentDiffItem[]>(() =>
|
||||
diffs.value.map((diff) => ({
|
||||
type: diff.type,
|
||||
external: Boolean(diff.fileName && !diff.project),
|
||||
projectName: diff.project?.title,
|
||||
fileName: diff.fileName,
|
||||
currentVersionName: diff.currentVersion?.version_number,
|
||||
newVersionName: diff.newVersion?.version_number,
|
||||
}))
|
||||
})
|
||||
|
||||
const versionDate = computed(() =>
|
||||
modpackVersion.value?.date_published
|
||||
? dayjs(modpackVersion.value.date_published).format('MMMM D, YYYY')
|
||||
: undefined,
|
||||
})),
|
||||
)
|
||||
|
||||
async function computeDependencyDiffs(
|
||||
@@ -206,10 +189,6 @@ async function computeDependencyDiffs(
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aExternal = Boolean(a.fileName && !a.project)
|
||||
const bExternal = Boolean(b.fileName && !b.project)
|
||||
if (aExternal !== bExternal) return aExternal ? -1 : 1
|
||||
|
||||
const typeOrder = { added: 0, updated: 1, removed: 2 }
|
||||
const typeCompare = typeOrder[a.type] - typeOrder[b.type]
|
||||
if (typeCompare !== 0) return typeCompare
|
||||
@@ -223,15 +202,15 @@ async function computeDependencyDiffs(
|
||||
}
|
||||
|
||||
async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[] | null> {
|
||||
if (!inst.link) return null
|
||||
if (!modpackVersionId.value || !inst.link.version_id) return null
|
||||
if (!inst.linked_data) return null
|
||||
if (!modpackVersionId.value || !inst.linked_data.version_id) return null
|
||||
|
||||
try {
|
||||
// For server projects, link.project_id is the server project but
|
||||
// link.version_id references a content modpack version from a different project.
|
||||
// Detect this by comparing the version's project_id with link.project_id.
|
||||
// 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.link.version_id, 'bypass')
|
||||
const instanceModpackVersion = await get_version(inst.linked_data.version_id, 'bypass')
|
||||
|
||||
// Compute dependency diffs between current and latest version
|
||||
if (instanceModpackVersion && modpackVersion.value) {
|
||||
@@ -247,25 +226,42 @@ async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[
|
||||
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() {
|
||||
const serverProjectId = instance.value?.link?.project_id
|
||||
hide()
|
||||
const serverProjectId = instance.value?.linked_data?.project_id
|
||||
if (serverProjectId) startInstallingServer(serverProjectId)
|
||||
try {
|
||||
if (modpackVersionId.value && instance.value) {
|
||||
const job = await update_managed_modrinth_version(instance.value.id, modpackVersionId.value)
|
||||
await wait_for_install_job(job.job_id)
|
||||
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)
|
||||
emit('complete')
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
emit('cancel')
|
||||
hide()
|
||||
}
|
||||
|
||||
function show(
|
||||
@@ -276,15 +272,8 @@ function show(
|
||||
) {
|
||||
instance.value = instanceVal
|
||||
modpackVersionId.value = modpackVersionIdVal
|
||||
modpackVersion.value = null
|
||||
diffs.value = []
|
||||
onUpdateComplete.value = callback
|
||||
diffModal.value?.show(e)
|
||||
void checkUpdateAvailable(instanceVal).then((result) => {
|
||||
if (instance.value?.id === instanceVal.id && modpackVersionId.value === modpackVersionIdVal) {
|
||||
diffs.value = result || []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function hide() {
|
||||
@@ -296,25 +285,23 @@ const messages = defineMessages({
|
||||
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 latest version to launch the game.',
|
||||
},
|
||||
removed: {
|
||||
id: 'app.modal.update-to-play.removed',
|
||||
defaultMessage: 'Removed',
|
||||
},
|
||||
externalWarningDescription: {
|
||||
id: 'app.modal.update-to-play.server-modpack-unknown-files-description',
|
||||
defaultMessage:
|
||||
'This server modpack update contains files that aren’t published on Modrinth. We strongly recommend only installing files from sources you trust.',
|
||||
'An update is required to play {name}. Please update to the latest version to launch the game.',
|
||||
},
|
||||
})
|
||||
|
||||
const hasUpdate = computed(() => {
|
||||
if (!instance.value?.link) return false
|
||||
return modpackVersionId.value != null && modpackVersionId.value !== instance.value.link.version_id
|
||||
if (!instance.value?.linked_data) return false
|
||||
return (
|
||||
modpackVersionId.value != null &&
|
||||
modpackVersionId.value !== instance.value.linked_data.version_id
|
||||
)
|
||||
})
|
||||
|
||||
defineExpose({ show, hide, hasUpdate })
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
<script setup lang="ts">
|
||||
import { Combobox, defineMessages, ThemeSelector, Toggle, useVIntl } 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'
|
||||
|
||||
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())
|
||||
|
||||
watch(
|
||||
settings,
|
||||
async () => {
|
||||
await set(settings.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</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>
|
||||
|
||||
<ThemeSelector
|
||||
:update-color-theme="
|
||||
(theme: ColorTheme) => {
|
||||
themeStore.setThemeState(theme)
|
||||
settings.theme = theme
|
||||
}
|
||||
"
|
||||
:current-theme="settings.theme"
|
||||
:theme-options="themeStore.getThemeOptions()"
|
||||
system-theme-color="system"
|
||||
/>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.advancedRenderingTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">
|
||||
{{ formatMessage(messages.advancedRenderingDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
id="advanced-rendering"
|
||||
:model-value="themeStore.advancedRendering"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
themeStore.advancedRendering = !!e
|
||||
settings.advanced_rendering = themeStore.advancedRendering
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
|
||||
<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>
|
||||
</div>
|
||||
<Toggle id="native-decorations" v-model="settings.native_decorations" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 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>
|
||||
</div>
|
||||
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 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>
|
||||
</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),
|
||||
},
|
||||
]"
|
||||
:display-value="settings.default_page ?? 'Select an option'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 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>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="themeStore.getFeatureFlag(worldsInHomeFlag)"
|
||||
@update:model-value="
|
||||
() => {
|
||||
const newValue = !themeStore.getFeatureFlag(worldsInHomeFlag)
|
||||
themeStore.featureFlags[worldsInHomeFlag] = newValue
|
||||
settings.feature_flags[worldsInHomeFlag] = newValue
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between gap-4">
|
||||
<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>
|
||||
</div>
|
||||
<Toggle
|
||||
id="toggle-sidebar"
|
||||
:model-value="settings.toggle_sidebar"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
settings.toggle_sidebar = !!e
|
||||
themeStore.toggleSidebar = settings.toggle_sidebar
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { injectNotificationManager, Slider, StyledInput, Toggle } from '@modrinth/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const fetchSettings = await get()
|
||||
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
|
||||
fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).join(' ')
|
||||
|
||||
const settings = ref(fetchSettings)
|
||||
|
||||
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
|
||||
maxMemory: number
|
||||
snapPoints: number[]
|
||||
}
|
||||
|
||||
watch(
|
||||
settings,
|
||||
async () => {
|
||||
const setSettings = JSON.parse(JSON.stringify(settings.value))
|
||||
|
||||
setSettings.extra_launch_args = setSettings.launchArgs.trim().split(/\s+/).filter(Boolean)
|
||||
setSettings.custom_env_vars = setSettings.envVars
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((x) => x.split('=').filter(Boolean))
|
||||
|
||||
if (!setSettings.hooks.pre_launch) {
|
||||
setSettings.hooks.pre_launch = null
|
||||
}
|
||||
if (!setSettings.hooks.wrapper) {
|
||||
setSettings.hooks.wrapper = null
|
||||
}
|
||||
if (!setSettings.hooks.post_exit) {
|
||||
setSettings.hooks.post_exit = null
|
||||
}
|
||||
|
||||
if (!setSettings.custom_dir) {
|
||||
setSettings.custom_dir = null
|
||||
}
|
||||
|
||||
await set(setSettings)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+10
-9
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Toggle } from '@modrinth/ui'
|
||||
import { ButtonStyled, Toggle } from '@modrinth/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
@@ -25,7 +25,7 @@ watch(
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<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">
|
||||
@@ -33,13 +33,14 @@ watch(
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
type="quiet"
|
||||
:disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
|
||||
@click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])"
|
||||
>
|
||||
Reset to default
|
||||
</Button>
|
||||
<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)"
|
||||
+2
-10
@@ -1,19 +1,11 @@
|
||||
<script setup>
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { injectNotificationManager } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||
import { get_java_versions, set_java_version } from '@/helpers/jre'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
javaLocation: {
|
||||
id: 'app.settings.java-installations.location.title',
|
||||
defaultMessage: 'Java {version, number} location',
|
||||
},
|
||||
})
|
||||
|
||||
const javaVersions = ref(await get_java_versions().catch(handleError))
|
||||
async function updateJavaVersion(version) {
|
||||
@@ -36,7 +28,7 @@ async function updateJavaVersion(version) {
|
||||
class="flex flex-col gap-2.5"
|
||||
>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast" :class="{ 'mt-4': index !== 0 }">
|
||||
{{ formatMessage(messages.javaLocation, { version: javaVersion }) }}
|
||||
Java {{ javaVersion }} location
|
||||
</h2>
|
||||
<JavaSelector
|
||||
:id="'java-selector-' + javaVersion"
|
||||
+1
-4
@@ -2,7 +2,6 @@
|
||||
import {
|
||||
Admonition,
|
||||
AutoLink,
|
||||
commonSettingsMessages,
|
||||
IntlFormatted,
|
||||
LanguageSelector,
|
||||
languageSelectorMessages,
|
||||
@@ -44,9 +43,7 @@ async function onLocaleChange(newLocale: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(commonSettingsMessages.language) }}
|
||||
</h2>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">Language</h2>
|
||||
|
||||
<Admonition type="warning" class="mt-2 mb-4">
|
||||
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { Toggle } from '@modrinth/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
|
||||
const settings = ref(await get())
|
||||
|
||||
watch(
|
||||
settings,
|
||||
async () => {
|
||||
if (settings.value.telemetry) {
|
||||
optInAnalytics()
|
||||
} else {
|
||||
optOutAnalytics()
|
||||
}
|
||||
|
||||
await set(settings.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
<Toggle id="personalized-ads" v-model="settings.personalized_ads" />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle id="opt-out-analytics" v-model="settings.telemetry" />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
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>
|
||||
<p class="m-0 mt-2 text-sm">
|
||||
Note: This will not prevent any instance-specific Discord Rich Presence integrations, such
|
||||
as those added by mods. (app restart required to take effect)
|
||||
</p>
|
||||
</div>
|
||||
<Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script setup>
|
||||
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectNotificationManager, Slider, StyledInput } from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
||||
import { purge_cache_types } from '@/helpers/cache.js'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const settings = ref(await get())
|
||||
|
||||
watch(
|
||||
settings,
|
||||
async () => {
|
||||
const setSettings = JSON.parse(JSON.stringify(settings.value))
|
||||
|
||||
if (!setSettings.custom_dir) {
|
||||
setSettings.custom_dir = null
|
||||
}
|
||||
|
||||
await set(setSettings)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
async function purgeCache() {
|
||||
await purge_cache_types([
|
||||
'project',
|
||||
'project_v3',
|
||||
'version',
|
||||
'user',
|
||||
'team',
|
||||
'organization',
|
||||
'file',
|
||||
'loader_manifest',
|
||||
'minecraft_manifest',
|
||||
'categories',
|
||||
'report_types',
|
||||
'loaders',
|
||||
'game_versions',
|
||||
'donation_platforms',
|
||||
'file_hash',
|
||||
'file_update',
|
||||
'search_results',
|
||||
'search_results_v3',
|
||||
]).catch(handleError)
|
||||
}
|
||||
|
||||
async function findLauncherDir() {
|
||||
const newDir = await open({
|
||||
multiple: false,
|
||||
directory: true,
|
||||
title: 'Select a new app directory',
|
||||
})
|
||||
|
||||
if (newDir) {
|
||||
settings.value.custom_dir = newDir
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user