Compare commits

..
Author SHA1 Message Date
Calum H. (IMB11) 7deb7e9ef1 fix: prepr 2026-08-10 12:58:02 +01:00
Calum H. (IMB11) eb5897b553 refactor: clean up checklist structure + node renderer 2026-08-10 11:58:16 +01:00
930 changed files with 17330 additions and 38594 deletions
-23
View File
@@ -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."
-21
View File
@@ -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."
-32
View File
@@ -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."
-40
View File
@@ -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."
-39
View File
@@ -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."
+18
View File
@@ -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.
+22
View File
@@ -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.
+24
View File
@@ -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.
+36
View File
@@ -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.
+27
View File
@@ -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.
+1 -3
View File
@@ -14,14 +14,12 @@ jobs:
comment:
name: Post changelog comment
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Post or update changelog comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ github.token }}
github-token: ${{ secrets.CROWDIN_GH_TOKEN }}
script: |
const marker = '<!-- changelog -->';
const mergedMarker = '<!-- changelog-merged -->';
-1
View File
@@ -10,7 +10,6 @@ on:
- 'packages/ui/**/*'
- 'packages/utils/**/*'
- 'packages/assets/**/*'
- 'packages/moderation/**/*'
- '**/wrangler.jsonc'
- '**/pnpm-*.yaml'
- '.github/workflows/frontend-deploy.yml'
+2 -5
View File
@@ -7,7 +7,6 @@ on:
- 'packages/ui/**/*'
- 'packages/utils/**/*'
- 'packages/assets/**/*'
- 'packages/moderation/**/*'
- '**/wrangler.jsonc'
- '**/pnpm-*.yaml'
- '.github/workflows/frontend-preview.yml'
@@ -118,8 +117,6 @@ jobs:
&& needs.deploy.result == 'success'
runs-on: ubuntu-latest
needs: [metadata, deploy, deploy-storybook]
permissions:
pull-requests: write
steps:
- name: Download deployment URLs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -160,7 +157,7 @@ jobs:
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
id: fc
with:
token: ${{ github.token }}
token: ${{ secrets.CROWDIN_GH_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
body-includes: Frontend previews
@@ -168,7 +165,7 @@ jobs:
if: github.event_name == 'pull_request'
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
token: ${{ github.token }}
token: ${{ secrets.CROWDIN_GH_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.fc.outputs.comment-id }}
body: |
+1 -1
View File
@@ -147,7 +147,7 @@ jobs:
deploy:
needs: [skip-if-clean, docker-build]
if: ${{ needs.skip-if-clean.outputs.internal == 'true' && github.ref == 'refs/heads/prod' }}
if: ${{ needs.skip-if-clean.outputs.internal == 'true' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/prod') }}
uses: SparkUniverse/workflows/.github/workflows/argo-update.yaml@main
secrets:
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
+2 -9
View File
@@ -59,21 +59,14 @@ apps/frontend/src/generated
.turbo
target
generated
!apps/app-frontend/src/generated/
!apps/app-frontend/src/generated/app-events/
!apps/app-frontend/src/generated/app-events/*.ts
!apps/app-frontend/src/generated/app-events/README.md
!apps/app-frontend/src/generated/app-events/postcard/
!apps/app-frontend/src/generated/app-events/postcard/index.d.ts
!apps/app-frontend/src/generated/app-events/postcard/index.js
!apps/app-frontend/src/generated/app-events/postcard/package.json
.env
# app testing dir
app-playground-data/*
.astro
.claude/
.claude/*
!.claude/skills/
.letta
# labrinth demo fixtures
-12
View File
@@ -17,18 +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" />
<sourceFolder url="file://$MODULE_DIR$/packages/component-derive/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
-93
View File
@@ -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.
Symlink
+1
View File
@@ -0,0 +1 @@
CLAUDE.md
+110
View File
@@ -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
+12 -201
View File
@@ -2116,7 +2116,7 @@ checksum = "ff6669899e23cb87b43daf7996f0ea3b9c07d0fb933d745bb7b815b052515ae3"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals 0.29.1",
"serde_derive_internals",
"syn 2.0.106",
]
@@ -2223,16 +2223,6 @@ dependencies = [
"static_assertions",
]
[[package]]
name = "component-derive"
version = "0.0.0"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "compression-codecs"
version = "0.4.31"
@@ -2350,15 +2340,6 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "convert_case"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cookie"
version = "0.16.2"
@@ -4010,28 +3991,6 @@ dependencies = [
"x11",
]
[[package]]
name = "genco"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ab846431e5d637791b3279e7242fe2b21e11c3d8b4cf6a99f645c5f16ba7c0"
dependencies = [
"genco-macros",
"relative-path",
"smallvec",
]
[[package]]
name = "genco-macros"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c42a1fe5a699c7f1d36ea6e04ed680a5c787cabff4b610ae3b8954ea3bcefec1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "generic-array"
version = "0.14.9"
@@ -5388,16 +5347,6 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "json5"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c"
dependencies = [
"serde",
"ucd-trie",
]
[[package]]
name = "jsonptr"
version = "0.6.3"
@@ -5490,7 +5439,6 @@ dependencies = [
"clickhouse",
"color-eyre",
"color-thief",
"component-derive",
"const_format",
"dashmap",
"derive_more 2.1.1",
@@ -5568,6 +5516,16 @@ dependencies = [
"zxcvbn",
]
[[package]]
name = "labrinth-derive"
version = "0.0.0"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "language-tags"
version = "0.3.2"
@@ -7541,45 +7499,6 @@ dependencies = [
"serde",
]
[[package]]
name = "postcard-bindgen"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e709af61573ae2563a00e760ff7db47f5c70b9d96c6dcb28993526fb8479f68"
dependencies = [
"postcard-bindgen-core",
"postcard-bindgen-derive",
]
[[package]]
name = "postcard-bindgen-core"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f19cebcb145fdccb24b63ad410785aa7c2ff9364551646ca7a927422d2d0386"
dependencies = [
"convert_case 0.11.0",
"genco",
"tree-ds",
]
[[package]]
name = "postcard-bindgen-derive"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0372f55dd7c15d73f7b0f7ca507ac3db5653f346b2b44a36537e2ad39f55441b"
dependencies = [
"convert_case 0.11.0",
"genco",
"postcard-bindgen-core",
"proc-macro2",
"quote",
"regex",
"regex-macro",
"serde",
"serde_derive_internals 0.30.0",
"syn 3.0.3",
]
[[package]]
name = "potential_utf"
version = "0.1.3"
@@ -8462,27 +8381,12 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da"
[[package]]
name = "regex-macro"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d306632607af6ec61c0b117971d57a96381b6317cf18ae419b5558048fe016e"
dependencies = [
"regex",
]
[[package]]
name = "regex-syntax"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "relative-path"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
[[package]]
name = "rend"
version = "0.4.2"
@@ -9148,7 +9052,7 @@ checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals 0.29.1",
"serde_derive_internals",
"syn 2.0.106",
]
@@ -9391,16 +9295,6 @@ dependencies = [
"uuid 1.23.3",
]
[[package]]
name = "sequential_gen"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d630b1418311e028ceb45e1e567845501a9d9c770a258b80d2edc025ffad17c"
dependencies = [
"lazy_static",
"uuid 1.23.3",
]
[[package]]
name = "serde"
version = "1.0.228"
@@ -9506,17 +9400,6 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "serde_derive_internals"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "serde_ini"
version = "0.2.0"
@@ -9957,9 +9840,6 @@ name = "spin"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
dependencies = [
"lock_api",
]
[[package]]
name = "spinning_top"
@@ -10387,17 +10267,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -11011,15 +10880,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "testcontainers"
version = "0.25.2"
@@ -11099,7 +10959,6 @@ dependencies = [
"image",
"indicatif",
"itertools 0.14.0",
"json5",
"modrinth-content-management",
"notify",
"notify-debouncer-mini",
@@ -11109,8 +10968,6 @@ dependencies = [
"path-util",
"phf 0.13.1",
"png 0.18.0",
"postcard",
"postcard-bindgen",
"quartz_nbt",
"quick-xml 0.38.3",
"rand 0.8.5",
@@ -11118,7 +10975,6 @@ dependencies = [
"reqwest 0.12.24",
"rgb",
"serde",
"serde-binhum",
"serde_ini",
"serde_json",
"serde_with",
@@ -11132,11 +10988,9 @@ dependencies = [
"thiserror 2.0.17",
"tokio",
"tokio-util",
"toml 0.9.8",
"tracing",
"tracing-error",
"tracing-subscriber",
"ts-rs",
"url",
"urlencoding",
"uuid 1.23.3",
@@ -11813,49 +11667,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "tree-ds"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710c388a2bb52d1a7186ae4aa2e92c6fae0606426bf4bdfb4106fa1e8e5c602e"
dependencies = [
"lazy_static",
"sequential_gen",
"serde",
"spin 0.10.0",
"thiserror 2.0.17",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ts-rs"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
dependencies = [
"chrono",
"thiserror 2.0.17",
"ts-rs-macros",
"uuid 1.23.3",
]
[[package]]
name = "ts-rs-macros"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"termcolor",
]
[[package]]
name = "tungstenite"
version = "0.27.0"
@@ -11893,12 +11710,6 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uds_windows"
version = "1.1.0"
+1 -6
View File
@@ -7,8 +7,8 @@ members = [
"apps/labrinth",
"packages/app-lib",
"packages/ariadne",
"packages/component-derive",
"packages/daedalus",
"packages/labrinth-derive",
"packages/modrinth-content-management",
"packages/modrinth-log",
"packages/modrinth-maxmind",
@@ -68,7 +68,6 @@ clap = "4.5.48"
clickhouse = "0.14.0"
color-eyre = "0.6.5"
color-thief = "0.2.2"
component-derive = { path = "packages/component-derive" }
const_format = "0.2.34"
core-foundation = "0.10.1"
core-graphics = "0.24.0"
@@ -114,7 +113,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",
@@ -150,7 +148,6 @@ 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"] }
postcard-bindgen = "0.8.0"
proc-macro2 = { version = "1.0" }
prometheus = "0.14.0"
quartz_nbt = "0.2.9"
@@ -227,14 +224,12 @@ 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 }
tracing-ecs = "0.5.0"
tracing-error = "0.2.1"
tracing-subscriber = "0.3.20"
ts-rs = "12.0.1"
typed-path = "0.12.0"
url = "2.5.7"
urlencoding = "2.1.3"
-4
View File
@@ -24,7 +24,3 @@ gam = "gam"
consts = "consts"
# short for "Copy"
Cpy = "Cpy"
[default.extend-identifiers]
# Constant from the `zip` crate
ZIP64_BYTES_THR = "ZIP64_BYTES_THR"
-4
View File
@@ -2,7 +2,3 @@
*.gltf
src/locales/
src/assets/**/*.svg
# Generated app-event bindings
src/generated/app-events/*.ts
src/generated/app-events/postcard/**
+1 -6
View File
@@ -1,7 +1,2 @@
import config from '@modrinth/tooling-config/eslint/nuxt.mjs'
export default config.append([
{
ignores: ['src/generated/app-events/*.ts', 'src/generated/app-events/postcard/**'],
},
])
export default config
-1
View File
@@ -14,7 +14,6 @@
"test": "vue-tsc --noEmit"
},
"dependencies": {
"@dnd-kit/vue": "^0.5.0",
"@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*",
+82 -240
View File
@@ -13,10 +13,11 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
HomeIcon,
LibraryIcon,
LogInIcon,
LogOutIcon,
NewspaperIcon,
PlayIcon,
PlusIcon,
RefreshCwIcon,
RightArrowIcon,
@@ -56,7 +57,7 @@ import {
import { renderString } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
@@ -70,10 +71,8 @@ import AppActionBar from '@/components/ui/AppActionBar.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import ErrorModal from '@/components/ui/ErrorModal.vue'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
import HostingUpdateRequired from '@/components/ui/HostingUpdateRequired.vue'
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
import IconEditorModal from '@/components/ui/instance_settings/icon-editor-modal/index.vue'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import MinecraftRequiredModal from '@/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
@@ -82,9 +81,6 @@ import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyIn
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
import NewIconEditorNotification from '@/components/ui/new-icon-editor-notification/index.vue'
import { shouldShowNewIconEditorNotification } from '@/components/ui/new-icon-editor-notification/show-notification'
import OnboardingChecklist from '@/components/ui/onboarding-checklist/index.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
@@ -93,19 +89,19 @@ import SplashScreen from '@/components/ui/SplashScreen.vue'
import SurveyPopup from '@/components/ui/SurveyPopup.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { useAppEvent } from '@/composables/use-app-event'
import { config } from '@/config'
import {
ads_consent_listener,
hide_ads_window,
init_ads_window,
perform_ads_consent_action,
release_ads_window_hold,
should_show_ads_consent_popup,
take_ads_window_hold,
show_ads_window,
} from '@/helpers/ads.js'
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js'
import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
import { can_current_user_use_shared_instances, get as getInstance, run } from '@/helpers/instance'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
@@ -144,7 +140,6 @@ import {
} from '@/providers/download-progress.ts'
import { createServerInstall, provideServerInstall } from '@/providers/server-install'
import { setupProviders } from '@/providers/setup'
import { setupAppEventsProvider } from '@/providers/setup/app-events'
import { setupAuthProvider } from '@/providers/setup/auth'
import { setupLoadingStateProvider } from '@/providers/setup/loading-state'
import { useError } from '@/store/error.js'
@@ -160,7 +155,6 @@ import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
const themeStore = useTheming()
const router = useRouter()
const route = useRoute()
const { channel: appEventChannel, events: appEvents } = setupAppEventsProvider()
const breadcrumbManager = createBreadcrumbManager()
provideBreadcrumbManager(breadcrumbManager)
const canNavigateBack = ref(false)
@@ -172,25 +166,6 @@ function updateHistoryNavigationState() {
canNavigateForward.value = historyState?.forward != null
}
let fullscreenAdsWindowHold = false
async function handleFullscreenChange() {
const fullscreen = document.fullscreenElement !== null
if (fullscreen === fullscreenAdsWindowHold) return
fullscreenAdsWindowHold = fullscreen
try {
if (fullscreen) {
await take_ads_window_hold()
} else {
await release_ads_window_hold()
}
} catch (error) {
fullscreenAdsWindowHold = !fullscreen
handleError(error)
}
}
updateHistoryNavigationState()
const APP_LEFT_NAV_WIDTH = '4rem'
@@ -200,26 +175,14 @@ const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
const credentials = ref()
let credentialsRefreshId = 0
const sidebarToggled = ref(true)
watch(
() => themeStore.toggleSidebar,
(toggleSidebar) => {
sidebarToggled.value = !toggleSidebar
},
)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
})
const forceSidebar = computed(
() =>
route.path.startsWith('/browse') ||
route.path.startsWith('/project') ||
route.path.startsWith('/user'),
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
)
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
const hostingUpdateRequired = computed(
() =>
hostingRouteActive.value &&
!!appUpdateState.availableUpdate.value &&
appUpdateState.updatesEnabled.value,
)
const prideFundraiserEnabled = computed(
() => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
)
@@ -230,9 +193,7 @@ const hostingIntercomIdentityKey = computed(() => {
return `${userId}:${serverId ?? 'hosting'}`
})
const hostingIntercom = useHostingIntercom({
enabled: computed(
() => hostingRouteActive.value && !hostingUpdateRequired.value && !!credentials.value?.session,
),
enabled: computed(() => hostingRouteActive.value && !!credentials.value?.session),
appId: 'ykeritl9',
fetchToken: fetchIntercomToken,
identityKey: hostingIntercomIdentityKey,
@@ -247,22 +208,11 @@ const notificationManager = new AppNotificationManager()
provideNotificationManager(notificationManager)
const { handleError, addNotification } = notificationManager
useAppEvent(
'warning',
(event) =>
addNotification({
title: formatMessage(messages.warning),
text: event.message,
type: 'warning',
}),
appEvents,
)
const popupNotificationManager = new AppPopupNotificationManager()
providePopupNotificationManager(popupNotificationManager)
const { addPopupNotification } = popupNotificationManager
let adsConsentPopupId = null
useAppEvent('ads_consent_required', handleAdsConsentRequired, appEvents)
let unlistenAdsConsent
const appVersion = getVersion()
const tauriApiClient = new TauriModrinthClient({
@@ -331,72 +281,23 @@ providePageContext({
})
provideModalBehavior({
noblur: computed(() => !themeStore.advancedRendering),
onShow: () => take_ads_window_hold(),
onHide: () => release_ads_window_hold(),
onShow: () => hide_ads_window(),
onHide: () => show_ads_window(),
})
const creationIconEditorModal = ref(null)
const creationGeneratedIcon = ref(null)
const creationIconTarget = ref('creation-flow')
const {
installationModal,
unknownPackWarningModal,
fetchExistingInstanceNames,
handleCreate,
handleBrowseModpacks,
searchProjects,
searchModpacks,
getProjectVersions,
getLoaderManifest,
setModpackAlreadyInstalledModal,
handleModpackDuplicateCreateAnyway,
handleModpackDuplicateGoToInstance,
onboardingChecklist,
} = setupProviders(
tauriApiClient,
notificationManager,
popupNotificationManager,
appEvents,
(iconPath) =>
creationGeneratedIcon.value?.path === iconPath ? creationGeneratedIcon.value.config : null,
)
const { hasLoggedIntoMinecraft, hasLoggedIntoModrinth, showChecklist } = onboardingChecklist
const showFriendsList = computed(() => !showChecklist.value || hasLoggedIntoModrinth.value)
async function randomizeCreationIcon() {
const generated = await creationIconEditorModal.value?.randomizeAndSave()
if (!generated) return null
creationGeneratedIcon.value = { path: generated.iconPath, config: generated.config }
return {
path: generated.iconPath,
previewUrl: convertFileSrc(generated.iconPath),
}
}
function customizeCreationIcon() {
creationIconTarget.value = 'creation-flow'
creationIconEditorModal.value?.show()
}
function customizeContentInstallIcon() {
creationIconTarget.value = 'content-install'
creationIconEditorModal.value?.show()
}
function onCreationIconSaved(iconPath, config) {
creationGeneratedIcon.value = { path: iconPath, config }
if (creationIconTarget.value === 'content-install') {
modInstallModal.value?.setIcon(iconPath, convertFileSrc(iconPath))
return
}
const context = installationModal.value?.ctx
if (!context) return
context.instanceIcon.value = null
context.instanceIconUrl.value = convertFileSrc(iconPath)
context.instanceIconPath.value = iconPath
}
} = setupProviders(notificationManager, popupNotificationManager)
const news = ref([])
const displayedServerInviteNotifications = new Set()
@@ -412,6 +313,7 @@ window.addEventListener('online', () => {
offline.value = false
})
const showOnboarding = ref(false)
const nativeDecorations = ref(false)
const os = ref('')
@@ -447,6 +349,7 @@ const authUnreachable = computed(() => {
onMounted(async () => {
await useCheckDisableMouseover()
try {
unlistenAdsConsent = await ads_consent_listener(handleAdsConsentRequired)
handleAdsConsentRequired(await should_show_ads_consent_popup())
} catch (error) {
handleError(error)
@@ -454,7 +357,6 @@ onMounted(async () => {
document.querySelector('body').addEventListener('click', handleClick)
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
document.addEventListener('fullscreenchange', handleFullscreenChange)
checkUpdates()
})
@@ -462,13 +364,10 @@ onMounted(async () => {
onUnmounted(async () => {
document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
document.removeEventListener('fullscreenchange', handleFullscreenChange)
unsubscribeSidebarToggle()
clearDelayedUpdatePopup()
if (fullscreenAdsWindowHold) {
fullscreenAdsWindowHold = false
await release_ads_window_hold().catch(handleError)
}
await unlistenAdsConsent?.()
await unlistenUpdateDownload?.()
})
@@ -476,15 +375,6 @@ const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const messages = defineMessages({
warning: { id: 'app.notification.warning', defaultMessage: 'Warning' },
moreOptions: { id: 'app.navigation.more-options', defaultMessage: 'More options' },
goBack: { id: 'app.navigation.go-back', defaultMessage: 'Go back' },
goForward: { id: 'app.navigation.go-forward', defaultMessage: 'Go forward' },
nextImage: { id: 'app.navigation.next-image', defaultMessage: 'Next image' },
updateDownloadMissingVersion: {
id: 'app.update.download-error.missing-version',
defaultMessage: 'Failed to download update: no version available',
},
updateInstalledToastTitle: {
id: 'app.update.complete-toast.title',
defaultMessage: 'Version {version} was successfully installed!',
@@ -527,6 +417,10 @@ const messages = defineMessages({
id: 'app.nav.home',
defaultMessage: 'Home',
},
library: {
id: 'app.nav.library',
defaultMessage: 'Library',
},
modrinthHosting: {
id: 'app.nav.modrinth-hosting',
defaultMessage: 'Modrinth Hosting',
@@ -586,7 +480,6 @@ function handleAdsConsentRequired(required) {
}
const notification = addPopupNotification({
contentType: 'standard',
title: formatMessage(messages.adsConsentTitle),
text: formatMessage(messages.adsConsentBody),
type: 'info',
@@ -619,16 +512,6 @@ function handleAdsConsentRequired(required) {
}
async function setupApp() {
await onboardingChecklist.initialize()
if (shouldShowNewIconEditorNotification(showChecklist.value)) {
addPopupNotification({
contentType: 'custom',
component: NewIconEditorNotification,
autoCloseMs: null,
})
}
const {
native_decorations,
theme,
@@ -637,6 +520,8 @@ async function setupApp() {
collapsed_navigation,
hide_nametag_skins_page,
advanced_rendering,
onboarded,
default_page,
toggle_sidebar,
developer_mode,
feature_flags,
@@ -648,10 +533,16 @@ async function setupApp() {
i18n.global.locale.value = locale
}
if (default_page === 'Library') {
await router.push('/library')
}
os.value = await getOS()
const dev = await isDev()
isDevEnvironment.value = dev
const version = await getVersion()
showOnboarding.value = !onboarded
nativeDecorations.value = native_decorations
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
@@ -673,7 +564,7 @@ async function setupApp() {
if (telemetry) {
initAnalytics()
if (dev) debugAnalytics()
trackEvent('Launched', { version, dev })
trackEvent('Launched', { version, dev, onboarded })
}
if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault())
@@ -685,6 +576,14 @@ async function setupApp() {
document.getElementsByTagName('html')[0].classList.add('windows')
}
await warning_listener((e) =>
addNotification({
title: 'Warning',
text: e.message,
type: 'warning',
}),
)
fetch(`https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`)
.then((response) => response.json())
.then((res) => {
@@ -733,7 +632,7 @@ async function setupApp() {
}
const stateFailed = ref(false)
initialize_state(appEventChannel)
initialize_state()
.then(() => {
setupApp().catch((err) => {
stateFailed.value = true
@@ -863,7 +762,7 @@ const errorModal = ref()
const minecraftAuthErrorModal = ref()
const minecraftRequiredModal = ref()
const contentInstall = createContentInstall({ router, handleError, appEvents })
const contentInstall = createContentInstall({ router, handleError })
provideContentInstall(contentInstall)
const {
instances: contentInstallInstances,
@@ -877,7 +776,6 @@ const {
projectInfo: contentInstallProjectInfo,
handleInstallToInstance,
handleCreateAndInstall,
prepareNewInstance,
handleNavigate: handleContentInstallNavigate,
handleCancel: handleContentInstallCancel,
setContentInstallModal,
@@ -897,41 +795,7 @@ const {
handleIncompatibilityWarningCancel: handleContentInstallIncompatibilityWarningCancel,
} = contentInstall
async function prepareCreationProjectInstall(projectId, projectType) {
if (projectType === 'modpack') {
await contentInstall.install(
projectId,
null,
null,
'CreationModalProject',
undefined,
(instanceId) => void router.push(`/instance/${encodeURIComponent(instanceId)}`),
)
return null
}
await prepareNewInstance(projectId)
const info = contentInstallProjectInfo.value
if (!info) throw new Error(`Project information is unavailable: '${projectId}'`)
return {
projectId,
title: info.title,
iconUrl: info.iconUrl,
link: info.link,
owner: info.owner,
compatibleLoaders: [...contentInstallLoaders.value],
gameVersions: [...contentInstallGameVersions.value],
releaseGameVersions: new Set(contentInstallReleaseGameVersions.value),
}
}
const serverInstall = createServerInstall({
router,
handleError,
popupNotificationManager,
appEvents,
})
const serverInstall = createServerInstall({ router, handleError, popupNotificationManager })
provideServerInstall(serverInstall)
const {
setInstallToPlayModal: setServerInstallToPlayModal,
@@ -1107,8 +971,8 @@ onMounted(() => {
const accounts = ref(null)
provide('accountsCard', accounts)
useAppEvent('command', handleCommand, appEvents)
useAppEvent('notification', handleLiveNotification, appEvents)
command_listener(handleCommand)
notification_listener(handleLiveNotification)
async function markLiveNotificationRead(notification) {
try {
@@ -1179,16 +1043,17 @@ async function handleLiveNotification(notification) {
if (generation !== liveNotificationGeneration) return
const popupNotification = addPopupNotification({
contentType: 'toast',
title: serverName,
type: 'server-invite',
actorName: invitedBy?.username ?? null,
actorAvatarUrl: invitedBy?.avatar_url ?? null,
entityName: serverName,
autoCloseMs: null,
onAccept: () => acceptServerInviteNotification(notification),
onDecline: () => declineServerInviteNotification(notification),
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
toast: {
type: 'server-invite',
actorName: invitedBy?.username ?? null,
actorAvatarUrl: invitedBy?.avatar_url ?? null,
entityName: serverName,
onAccept: () => acceptServerInviteNotification(notification),
onDecline: () => declineServerInviteNotification(notification),
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
},
})
serverInvitePopupNotificationIds.add(popupNotification.id)
}
@@ -1365,7 +1230,6 @@ function showDelayedUpdatePopup() {
if (metered.value && !finishedDownloading.value) {
addPopupNotification({
contentType: 'standard',
title: formatMessage(updatePopupMessages.updateAvailable),
text: formatMessage(updatePopupMessages.meteredBody, { version: update.version }),
type: 'info',
@@ -1387,7 +1251,6 @@ function showDelayedUpdatePopup() {
})
} else if (finishedDownloading.value) {
addPopupNotification({
contentType: 'standard',
title: formatMessage(updatePopupMessages.downloadComplete),
text: formatMessage(updatePopupMessages.downloadedBody, {
version: update.version,
@@ -1486,7 +1349,6 @@ async function checkLinuxUpdates() {
const nextPopupTime = getNextAppUpdatePopupTime(latestVersion)
if (nextPopupTime !== null && Date.now() >= nextPopupTime) {
addPopupNotification({
contentType: 'standard',
title: formatMessage(updatePopupMessages.updateAvailable),
text: formatMessage(updatePopupMessages.linuxBody, { version: latestVersion }),
type: 'info',
@@ -1506,7 +1368,7 @@ async function downloadAvailableUpdate() {
async function downloadUpdate(versionToDownload) {
if (!versionToDownload) {
handleError(formatMessage(messages.updateDownloadMissingVersion))
handleError(`Failed to download update: no version available`)
return
}
@@ -1536,7 +1398,6 @@ async function downloadUpdate(versionToDownload) {
handleError(e)
})
unlistenUpdateDownload = await subscribeToDownloadProgress(
appEvents,
appUpdateDownload,
versionToDownload.version,
)
@@ -1670,34 +1531,18 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
type="instance"
show-snapshot-toggle
:fetch-existing-instance-names="fetchExistingInstanceNames"
:search-projects="searchProjects"
:prepare-project-install="prepareCreationProjectInstall"
:create-project-install="handleCreateAndInstall"
:search-modpacks="searchModpacks"
:get-project-versions="getProjectVersions"
:get-loader-manifest="getLoaderManifest"
:randomize-instance-icon="randomizeCreationIcon"
:customize-instance-icon="customizeCreationIcon"
@create="handleCreate"
@browse-modpacks="handleBrowseModpacks"
/>
<IconEditorModal
ref="creationIconEditorModal"
:config="creationGeneratedIcon?.config"
@saved="onCreationIconSaved"
/>
<UnknownPackWarningModal ref="unknownPackWarningModal" />
<div
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.25rem] w-[--left-bar-width]"
>
<NavButton
v-tooltip.right="formatMessage(messages.home)"
to="/"
:is-primary="(route) => route.path === '/'"
:is-subpage="
() =>
(route.path.startsWith('/browse') || route.path.startsWith('/project')) && route.query.i
"
>
<PlayIcon />
<NavButton v-tooltip.right="formatMessage(messages.home)" to="/">
<HomeIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(commonMessages.discoverContentLabel)"
@@ -1712,6 +1557,19 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<NavButton v-tooltip.right="formatMessage(appMessages.skinSelectorLabel)" to="/skins">
<ShirtIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.library)"
to="/library"
:is-primary="(r) => r.path === '/library' || r.path === '/library'"
:is-subpage="
() =>
route.path.startsWith('/instance') ||
((route.path.startsWith('/browse') || route.path.startsWith('/project')) &&
route.query.i)
"
>
<LibraryIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.modrinthHosting)"
to="/hosting/manage"
@@ -1746,7 +1604,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
v-tooltip.right="formatMessage(messages.modrinthAccount)"
type="quiet"
size="xl"
:label="formatMessage(messages.moreOptions)"
label="More options"
:options="[
{
id: 'view-profile',
@@ -1801,7 +1659,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div data-tauri-drag-region class="ml-2 flex shrink-0 items-center gap-2">
<IconButton
type="outlined"
:label="formatMessage(messages.goBack)"
label="Go back"
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateBack"
@click="router.back()"
@@ -1813,7 +1671,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</IconButton>
<IconButton
type="outlined"
:label="formatMessage(messages.goForward)"
label="Go forward"
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateForward"
@click="router.forward()"
@@ -1830,7 +1688,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<IconButton
v-if="!forceSidebar && themeStore.toggleSidebar"
:type="sidebarToggled ? 'base' : 'quiet'"
:label="formatMessage(messages.nextImage)"
label="Next image"
class="mr-3 transition-transform"
:class="{ 'rotate-180': !sidebarToggled }"
@click="sidebarToggled = !sidebarToggled"
@@ -1898,13 +1756,10 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
{{ formatMessage(messages.authUnreachableBody) }}
</Admonition>
<HostingUpdateRequired v-if="hostingUpdateRequired" />
<RouterView v-else v-slot="{ Component }">
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
<KeepAlive include="LibraryPage">
<component :is="Component"></component>
</KeepAlive>
<component :is="Component"></component>
</Suspense>
</template>
</RouterView>
@@ -1919,17 +1774,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
:class="{ 'pb-12': !hasPlus }"
data-overlayscrollbars-initialize
>
<OnboardingChecklist
@create-instance="installationModal?.show()"
@login-minecraft="accounts?.login()"
@login-modrinth="signIn"
/>
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
<div
v-show="hasLoggedIntoMinecraft"
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
>
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<h3 class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.playingAs) }}
</h3>
@@ -1937,10 +1784,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<AccountsCard ref="accounts" />
</suspense>
</div>
<div
v-show="showFriendsList"
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
>
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<suspense>
<FriendsList :credentials="credentials" :sign-in="() => requestSignIn()" />
</suspense>
@@ -2004,8 +1848,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
:preferred-game-version="contentInstallPreferredGameVersion"
:release-game-versions="contentInstallReleaseGameVersions"
:project-info="contentInstallProjectInfo"
:randomize-icon="randomizeCreationIcon"
:customize-icon="customizeContentInstallIcon"
@install="handleInstallToInstance"
@create-and-install="handleCreateAndInstall"
@navigate="handleContentInstallNavigate"
@@ -1,3 +0,0 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="30.8333" height="20.8333" viewBox="0 0 30.8333 20.8333" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M1.6228 20.8333C2.51907 20.8333 3.24562 20.1158 3.24562 19.2308L3.24563 17.5253C3.24562 17.0803 3.2459 16.016 3.7136 15.2292C4.00182 14.7442 4.37085 14.339 4.74993 14.1326C5.36078 13.8001 6.0611 13.6111 6.80555 13.6111C7.05077 13.6111 7.3144 13.6219 7.6104 13.6356L7.7201 13.6408C7.97828 13.6529 8.25927 13.6661 8.5395 13.6719C9.17298 13.6851 9.91222 13.6651 10.6258 13.4739C12.0158 13.1014 13.1015 12.0158 13.4738 10.6258C13.665 9.91222 13.685 9.17298 13.672 8.5395C13.6662 8.25937 13.6528 7.9782 13.6408 7.7201L13.6357 7.6104C13.6218 7.3144 13.6112 7.05077 13.6112 6.80555C13.6112 6.02607 13.8182 5.29497 14.1805 4.66428C14.3917 4.29663 14.7595 3.96015 15.2675 3.66463C16.0377 3.21645 16.96 3.2123 17.5237 3.20975C18.0872 3.20722 18.6818 3.20527 19.1787 3.20527C22.2357 3.20527 23.347 3.2282 24.1857 3.5193C25.6395 4.02402 26.7495 5.07765 27.2667 6.36838C27.3963 6.69185 27.4878 7.1167 27.5368 7.85255C27.5868 8.60265 27.5877 9.56012 27.5877 10.9399V19.2308C27.5877 20.1158 28.3143 20.8333 29.2105 20.8333C30.1068 20.8333 30.8333 20.1158 30.8333 19.2308V10.8854C30.8333 9.5729 30.8333 8.50977 30.7755 7.6419C30.7158 6.74778 30.5898 5.95072 30.2847 5.18902C29.4037 2.99072 27.5568 1.29222 25.262 0.495583C23.8303 -0.0014166 22.1092 -0.000816752 19.5178 8.32478e-05L17.939 0.000200073L17.9167 0L17.8582 0.000249942C13.6775 0.00308328 11.0862 0.0528001 8.93633 0.79915C5.23583 2.08378 2.2801 4.81432 0.87485 8.32102C0.40655 9.48963 0.199033 10.7449 0.0986334 12.2503C-1.66334e-05 13.7293 0 15.5563 0 17.8857V19.2308C0 20.1158 0.72655 20.8333 1.6228 20.8333Z" fill="#B0BAC5"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

@@ -0,0 +1,360 @@
<script setup>
import {
ClipboardCopyIcon,
EyeIcon,
FolderOpenIcon,
PlayIcon,
PlusIcon,
SearchIcon,
StopCircleIcon,
TrashIcon,
} from '@modrinth/assets'
import {
Accordion,
DropdownSelect,
formatLoader,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { install_duplicate_instance } from '@/helpers/install'
import { remove } from '@/helpers/instance'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const props = defineProps({
instances: {
type: Array,
default() {
return []
},
},
label: {
type: String,
default: '',
},
})
const instanceOptions = ref(null)
const instanceComponents = ref(null)
const currentDeleteInstance = ref(null)
const confirmModal = ref(null)
async function deleteInstance() {
if (currentDeleteInstance.value) {
instanceComponents.value = instanceComponents.value.filter(
(x) => x.instance.id !== currentDeleteInstance.value,
)
await remove(currentDeleteInstance.value).catch(handleError)
}
}
async function duplicateInstance(p) {
await install_duplicate_instance(p).catch(handleError)
}
const handleRightClick = (event, instanceId) => {
const item = instanceComponents.value.find((x) => x.instance.id === instanceId)
const baseOptions = [
...(item.instance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open' },
{ name: 'copy' },
{ type: 'divider' },
{
name: 'delete',
color: 'danger',
},
]
instanceOptions.value.showMenu(
event,
item,
item.playing
? [
{
name: 'stop',
color: 'danger',
},
...baseOptions,
]
: [
...(item.instance.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
...baseOptions,
],
)
}
const handleOptionsClick = async (args) => {
switch (args.option) {
case 'play':
args.item.play(null, 'InstanceGridContextMenu')
break
case 'stop':
args.item.stop(null, 'InstanceGridContextMenu')
break
case 'add_content':
await args.item.addContent()
break
case 'edit':
await args.item.seeInstance()
break
case 'duplicate':
if (args.item.instance.install_stage == 'installed')
await duplicateInstance(args.item.instance.id)
break
case 'open':
await args.item.openFolder()
break
case 'copy':
await navigator.clipboard.writeText(args.item.instance.id)
break
case 'delete':
currentDeleteInstance.value = args.item.instance.id
confirmModal.value.show()
break
}
}
const state = useStorage(
`${props.label}-grid-display-state`,
{
group: 'Group',
sortBy: 'Name',
collapsedGroups: [],
},
localStorage,
{ mergeDefaults: true },
)
const search = ref('')
const collapsedSectionKeys = computed(() => new Set(state.value.collapsedGroups ?? []))
const getSectionKey = (sectionName) => `${state.value.group}:${sectionName}`
const isSectionCollapsed = (sectionName) => {
return collapsedSectionKeys.value.has(getSectionKey(sectionName))
}
const setSectionCollapsed = (sectionName, collapsed) => {
const sectionKey = getSectionKey(sectionName)
const collapsedSections = new Set(state.value.collapsedGroups ?? [])
if (collapsed) {
collapsedSections.add(sectionKey)
} else {
collapsedSections.delete(sectionKey)
}
state.value.collapsedGroups = [...collapsedSections]
}
const filteredResults = computed(() => {
const { group = 'Group', sortBy = 'Name' } = state.value
const instances = props.instances.filter((instance) => {
return instance.name.toLowerCase().includes(search.value.toLowerCase())
})
if (sortBy === 'Name') {
instances.sort((a, b) => {
return a.name.localeCompare(b.name)
})
}
if (sortBy === 'Game version') {
instances.sort((a, b) => {
return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
})
}
if (sortBy === 'Last played') {
instances.sort((a, b) => {
return dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0))
})
}
if (sortBy === 'Date created') {
instances.sort((a, b) => {
return dayjs(b.date_created).diff(dayjs(a.date_created))
})
}
if (sortBy === 'Date modified') {
instances.sort((a, b) => {
return dayjs(b.date_modified).diff(dayjs(a.date_modified))
})
}
const instanceMap = new Map()
if (group === 'Loader') {
instances.forEach((instance) => {
const loader = formatLoader(formatMessage, instance.loader)
if (!instanceMap.has(loader)) {
instanceMap.set(loader, [])
}
instanceMap.get(loader).push(instance)
})
} else if (group === 'Game version') {
instances.forEach((instance) => {
if (!instanceMap.has(instance.game_version)) {
instanceMap.set(instance.game_version, [])
}
instanceMap.get(instance.game_version).push(instance)
})
} else if (group === 'Group') {
instances.forEach((instance) => {
if (instance.groups.length === 0) {
instance.groups.push('None')
}
for (const category of instance.groups) {
if (!instanceMap.has(category)) {
instanceMap.set(category, [])
}
instanceMap.get(category).push(instance)
}
})
} else {
return instanceMap.set('None', instances)
}
// For 'name', we intuitively expect the sorting to apply to the name of the group first, not just the name of the instance
// ie: Category A should come before B, even if the first instance in B comes before the first instance in A
if (sortBy === 'Name') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
// None should always be first
if (a[0] === 'None' && b[0] !== 'None') {
return -1
}
if (a[0] !== 'None' && b[0] === 'None') {
return 1
}
return a[0].localeCompare(b[0])
})
instanceMap.clear()
sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1])
})
}
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
if (group === 'Game version') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
return a[0].localeCompare(b[0], undefined, { numeric: true })
})
instanceMap.clear()
sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1])
})
}
return instanceMap
})
</script>
<template>
<div class="flex gap-2">
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
placeholder="Search"
clearable
wrapper-class="flex-1"
/>
<DropdownSelect
v-slot="{ selected }"
v-model="state.sortBy"
name="Sort Dropdown"
class="max-w-[16rem]"
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
placeholder="Select..."
>
<span class="font-semibold text-primary">Sort by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
<DropdownSelect
v-slot="{ selected }"
v-model="state.group"
class="max-w-[16rem]"
name="Group Dropdown"
:options="['Group', 'Loader', 'Game version', 'None']"
placeholder="Select..."
>
<span class="font-semibold text-primary">Group by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
</DropdownSelect>
</div>
<Accordion
v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({
key,
value,
}))"
:key="instanceSection.key"
:divider="instanceSection.key !== 'None'"
:open-by-default="!isSectionCollapsed(instanceSection.key)"
class="row"
@on-open="setSectionCollapsed(instanceSection.key, false)"
@on-close="setSectionCollapsed(instanceSection.key, true)"
>
<template v-if="instanceSection.key !== 'None'" #title>
<span class="text-base">{{ instanceSection.key }}</span>
</template>
<section class="instances">
<Instance
v-for="instance in instanceSection.value"
ref="instanceComponents"
:key="instance.id + instance.install_stage"
:instance="instance"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.id)"
/>
</section>
</Accordion>
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteInstance" />
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EyeIcon /> View instance </template>
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
<template #delete> <TrashIcon /> Delete </template>
<template #open> <FolderOpenIcon /> Open folder </template>
<template #copy> <ClipboardCopyIcon /> Copy path </template>
</ContextMenu>
</template>
<style lang="scss" scoped>
.row {
width: 100%;
}
.instances {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
width: 100%;
gap: 0.75rem;
margin-right: auto;
scroll-behavior: smooth;
overflow-y: auto;
}
</style>
@@ -0,0 +1,371 @@
<script setup>
import {
ClipboardCopyIcon,
DownloadIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GlobeIcon,
PlayIcon,
PlusIcon,
StopCircleIcon,
TrashIcon,
} from '@modrinth/assets'
import { HeadingLink, injectNotificationManager } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { 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 { injectContentInstall } from '@/providers/content-install'
import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const router = useRouter()
const props = defineProps({
instances: {
type: Array,
default() {
return []
},
},
label: {
type: String,
default: '',
},
canPaginate: Boolean,
})
const actualInstances = computed(() =>
props.instances.filter(
(x) => (x && x.instances && x.instances[0] && x.show === undefined) || x.show,
),
)
const modsRow = ref(null)
const instanceOptions = ref(null)
const instanceComponents = ref(null)
const rows = ref(null)
const deleteConfirmModal = ref(null)
const currentDeleteInstance = ref(null)
async function deleteInstance() {
if (currentDeleteInstance.value) {
await remove(currentDeleteInstance.value).catch(handleError)
}
}
async function duplicateInstance(p) {
await install_duplicate_instance(p).catch(handleError)
}
const handleInstanceRightClick = async (event, passedInstance) => {
const baseOptions = [
...(passedInstance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'edit' },
{ name: 'duplicate' },
{ name: 'open_folder' },
{ name: 'copy_path' },
{ type: 'divider' },
{
name: 'delete',
color: 'danger',
},
]
const runningProcesses = await get_by_instance_id(passedInstance.id).catch(handleError)
const options =
runningProcesses.length > 0
? [
{
name: 'stop',
color: 'danger',
},
...baseOptions,
]
: [
...(passedInstance.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
...baseOptions,
]
instanceOptions.value.showMenu(event, passedInstance, options)
}
const handleProjectClick = (event, passedInstance) => {
instanceOptions.value.showMenu(event, passedInstance, [
{
name: 'install',
color: 'primary',
},
{ type: 'divider' },
{
name: 'open_link',
},
{
name: 'copy_link',
},
])
}
const handleOptionsClick = async (args) => {
switch (args.option) {
case 'play':
await run(args.item.id).catch((err) => handleSevereError(err, { instanceId: args.item.id }))
trackEvent('InstanceStart', {
loader: args.item.loader,
game_version: args.item.game_version,
})
break
case 'stop':
await kill(args.item.id).catch(handleError)
trackEvent('InstanceStop', {
loader: args.item.loader,
game_version: args.item.game_version,
})
break
case 'add_content':
await router.push({
path: `/browse/${args.item.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: args.item.id },
})
break
case 'edit':
await router.push({
path: `/instance/${encodeURIComponent(args.item.id)}`,
})
break
case 'duplicate':
if (args.item.install_stage == 'installed') await duplicateInstance(args.item.id)
break
case 'delete':
currentDeleteInstance.value = args.item.id
deleteConfirmModal.value.show()
break
case 'open_folder':
await showInstanceInFolder(args.item.id)
break
case 'copy_path':
await navigator.clipboard.writeText(args.item.id)
break
case 'install': {
await installVersion(
args.item.project_id,
null,
null,
'ProjectCardContextMenu',
() => {},
() => {},
).catch(handleError)
break
}
case 'open_link':
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
break
case 'copy_link':
await navigator.clipboard.writeText(
`https://modrinth.com/${args.item.project_type}/${args.item.slug}`,
)
break
}
}
const maxInstancesPerCompactRow = ref(1)
const maxInstancesPerRow = ref(1)
const maxProjectsPerRow = ref(1)
const calculateCardsPerRow = () => {
if (rows.value.length === 0) {
return
}
// Calculate how many cards fit in one row
const containerWidth = rows.value[0].clientWidth
// Convert container width from pixels to rem
const containerWidthInRem =
containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize)
maxInstancesPerCompactRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 0.75) / 20.75)
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
if (maxInstancesPerRow.value < 5) {
maxInstancesPerRow.value *= 2
}
if (maxInstancesPerCompactRow.value < 5) {
maxInstancesPerCompactRow.value *= 2
}
if (maxProjectsPerRow.value < 3) {
maxProjectsPerRow.value *= 2
}
}
const rowContainer = ref(null)
const resizeObserver = ref(null)
onMounted(() => {
calculateCardsPerRow()
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
if (rowContainer.value) {
resizeObserver.value.observe(rowContainer.value)
}
window.addEventListener('resize', calculateCardsPerRow)
})
onUnmounted(() => {
window.removeEventListener('resize', calculateCardsPerRow)
if (rowContainer.value) {
resizeObserver.value.unobserve(rowContainer.value)
}
})
</script>
<template>
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="deleteInstance" />
<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">
{{ row.label }}
</HeadingLink>
<section
v-if="row.instance"
ref="modsRow"
class="instances"
:class="{ compact: row.compact }"
>
<Instance
v-for="(instance, instanceIndex) in row.instances.slice(
0,
row.compact ? maxInstancesPerCompactRow : maxInstancesPerRow,
)"
:key="row.label + instance.id"
:instance="instance"
:compact="row.compact"
:first="instanceIndex === 0"
@contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)"
/>
</section>
<section v-else ref="modsRow" class="projects">
<LegacyProjectCard
v-for="project in row.instances.slice(0, maxProjectsPerRow)"
:key="project?.project_id"
ref="instanceComponents"
class="item"
:project="project"
@contextmenu.prevent.stop="(event) => handleProjectClick(event, project)"
/>
</section>
</div>
</div>
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
<template #play> <PlayIcon /> Play </template>
<template #stop> <StopCircleIcon /> Stop </template>
<template #add_content> <PlusIcon /> Add content </template>
<template #edit> <EyeIcon /> View instance </template>
<template #delete> <TrashIcon /> Delete </template>
<template #open_folder> <FolderOpenIcon /> Open folder </template>
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
<template #install> <DownloadIcon /> Install </template>
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
</ContextMenu>
</template>
<style lang="scss" scoped>
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
gap: 1rem;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
background: transparent;
}
}
.row {
display: flex;
flex-direction: column;
align-items: flex-start;
overflow: hidden;
width: 100%;
min-width: 100%;
&:nth-child(even) {
background: var(--color-bg);
}
.header {
width: 100%;
margin-bottom: 1rem;
gap: var(--gap-xs);
display: flex;
flex-direction: row;
align-items: center;
a {
margin: 0;
font-size: var(--font-size-md);
font-weight: bolder;
white-space: nowrap;
color: var(--color-base);
}
svg {
height: 1.25rem;
width: 1.25rem;
color: var(--color-base);
}
}
.instances {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
grid-gap: 0.75rem;
width: 100%;
&.compact {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: 0.75rem;
}
}
.projects {
display: grid;
width: 100%;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
grid-gap: 0.75rem;
.item {
width: 100%;
max-width: 100%;
}
}
}
</style>
@@ -104,9 +104,8 @@ import {
useVIntl,
} from '@modrinth/ui'
import type { Ref } from 'vue'
import { computed, ref } from 'vue'
import { computed, onUnmounted, ref } from 'vue'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics'
import {
get_default_user,
@@ -115,6 +114,7 @@ import {
set_default_user,
users,
} from '@/helpers/auth'
import { process_listener } from '@/helpers/events'
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins'
import { get_available_skins } from '@/helpers/skins'
@@ -185,7 +185,6 @@ defineExpose({
refreshValues,
setEquippedSkin,
setLoginDisabled,
login,
loginDisabled,
})
@@ -252,12 +251,16 @@ async function logout(id: string) {
trackEvent('AccountLogOut')
}
useAppEvent('process', async (e) => {
const unlisten = await process_listener(async (e) => {
if (e.event === 'launched') {
await refreshValues()
}
})
onUnmounted(() => {
unlisten()
})
const messages = defineMessages({
notSignedIn: {
id: 'minecraft-account.not-signed-in',
@@ -1,16 +1,15 @@
<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"
>
<DownloadIcon />
</IconButton>
</div>
<IconButton
v-if="hasActiveLoadingBars && !hasVisibleActiveDownloadToasts"
v-tooltip="formatMessage(messages.viewActiveDownloads)"
type="quiet"
color="brand"
:label="formatMessage(messages.viewActiveDownloads)"
@click="openDownloadToast()"
>
<DownloadIcon />
</IconButton>
<div v-if="offline" class="flex items-center gap-1">
<UnplugIcon class="text-secondary" />
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
@@ -137,8 +136,8 @@ import {
defineMessages,
injectNotificationManager,
injectPopupNotificationManager,
type PopupNotification,
type PopupNotificationProgressItem,
type PopupNotificationStandard,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
@@ -148,9 +147,8 @@ import { useRouter } from 'vue-router'
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics'
import { toError } from '@/helpers/errors'
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 type { LoadingBar } from '@/helpers/state'
@@ -220,35 +218,8 @@ 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',
},
showDownloads: {
id: 'app.action-bar.show-downloads',
defaultMessage: 'Show active downloads',
},
})
const downloadState = computed(() => popupNotificationManager.getDownloadState())
const downloadToggleLabel = computed(() =>
formatMessage(
downloadState.value.hidden > 0
? messages.showDownloads
: downloadState.value.total > 0
? messages.hideDownloads
: messages.viewActiveDownloads,
),
)
function toggleDownloadNotifications(): void {
if (downloadState.value.total > 0) {
popupNotificationManager.toggleDownloadNotifications()
} else if (hasActiveLoadingBars.value) {
openDownloadToast()
}
}
const currentProcesses = ref<RunningProcess[]>([])
const selectedProcess = ref<RunningProcess | undefined>()
@@ -295,7 +266,7 @@ onMounted(() => {
window.addEventListener('online', handleOnline)
})
useAppEvent('process', async () => {
const unlistenProcess = await process_listener(async () => {
await refresh()
})
@@ -325,7 +296,6 @@ function goToTerminal(instanceId?: string) {
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 {
@@ -353,16 +323,14 @@ function getDisplayIconUrl(icon: string | null | undefined): string | null {
return convertFileSrc(icon)
}
function getNotification(): PopupNotificationStandard | null {
function getNotification(): PopupNotification | null {
if (!notificationId.value) {
return null
}
const notification = popupNotificationManager
.getNotifications()
.find((notification) => notification.id === notificationId.value)
return notification?.contentType === 'standard' && notification.type === 'download'
? notification
: null
return notification ?? null
}
function removeNotification(): void {
@@ -373,54 +341,10 @@ 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 is PopupNotificationStandard =>
candidate.id === popupId && candidate.contentType === 'standard',
)
: undefined
if (!notification) {
notification = popupNotificationManager.addPopupNotification({
contentType: 'standard',
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<PopupNotificationProgressItem>((bar) => ({
...currentLoadingBars.value.map((bar) => ({
id: getLoadingBarKey(bar),
title: bar.title ?? '',
text: getLoadingText(bar),
@@ -434,13 +358,12 @@ function buildDownloadItems(): PopupNotificationProgressItem[] {
]
}
const hasVisibleActiveDownloadToasts = computed(() => !!getNotification())
const hasActiveLoadingBars = computed(
() => currentLoadingBars.value.length > 0 || installJobNotifications.active.value,
)
function updateNotification(resummon = false): void {
syncTerminalNotifications()
if (resummon) {
dismissed.value = false
}
@@ -460,7 +383,7 @@ function updateNotification(resummon = false): void {
return
}
const notif = getNotification()
let notif = getNotification()
const progressItems = buildDownloadItems()
if (notif) {
@@ -469,19 +392,20 @@ function updateNotification(resummon = false): void {
: formatMessage(messages.downloads)
notif.text = undefined
notif.progressItems = progressItems
notif.buttons = installJobNotifications.buttons.value
notif.progress = undefined
notif.waiting = undefined
} else {
const notification = popupNotificationManager.addPopupNotification({
contentType: 'standard',
notif = popupNotificationManager.addPopupNotification({
title: installJobNotifications.active.value
? installJobNotifications.title.value
: formatMessage(messages.downloads),
type: 'download',
autoCloseMs: null,
progressItems,
buttons: installJobNotifications.buttons.value,
})
notificationId.value = notification.id
notificationId.value = notif.id
}
}
@@ -558,13 +482,13 @@ async function refreshLoadingBars() {
const installJobNotifications = await useInstallJobNotifications({
router,
handleError: (error) => handleError(toError(error)),
handleError,
onChange: updateNotification,
})
await refreshLoadingBars()
useAppEvent('loading', async () => {
const unlistenLoading = await loading_listener(async () => {
await refreshLoadingBars()
})
@@ -578,11 +502,11 @@ 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()
})
</script>
@@ -34,18 +34,9 @@ const options = ref([])
const left = ref('0px')
const top = ref('0px')
const shown = ref(false)
const contextMenuId = Symbol()
const contextMenuOpenEvent = 'modrinth-context-menu-open'
const hideContextMenu = () => {
shown.value = false
emit('menu-closed')
}
defineExpose({
showMenu: (event, passedItem, passedOptions) => {
window.dispatchEvent(new CustomEvent(contextMenuOpenEvent, { detail: contextMenuId }))
item.value = passedItem
options.value = passedOptions
@@ -71,7 +62,6 @@ defineExpose({
}
})
},
hideMenu: hideContextMenu,
})
const isInstanceLink = (item) => {
@@ -83,6 +73,11 @@ const isInstanceLink = (item) => {
return false
}
const hideContextMenu = () => {
shown.value = false
emit('menu-closed')
}
const optionClicked = (option) => {
emit('option-clicked', {
item: item.value,
@@ -97,12 +92,6 @@ const onEscKeyRelease = (event) => {
}
}
const handleContextMenuOpen = (event) => {
if (shown.value && event.detail !== contextMenuId) {
hideContextMenu()
}
}
const handleClickOutside = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY)
if (
@@ -116,13 +105,11 @@ const handleClickOutside = (event) => {
onMounted(() => {
window.addEventListener('click', handleClickOutside)
window.addEventListener(contextMenuOpenEvent, handleContextMenuOpen)
document.body.addEventListener('keyup', onEscKeyRelease)
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside)
window.removeEventListener(contextMenuOpenEvent, handleContextMenuOpen)
document.removeEventListener('keyup', onEscKeyRelease)
})
</script>
@@ -141,24 +128,15 @@ onBeforeUnmount(() => {
.item {
align-items: center;
color: var(--color-text-primary);
font-weight: 500;
color: var(--color-base);
cursor: pointer;
display: flex;
gap: var(--gap-sm);
padding: var(--gap-sm);
border-radius: var(--radius-sm);
:deep(svg) {
color: var(--color-base);
}
&:hover,
&:active {
:deep(svg) {
color: inherit;
}
&.base {
background-color: var(--color-button-bg);
color: var(--color-contrast);
@@ -167,19 +145,19 @@ onBeforeUnmount(() => {
&.primary {
background-color: var(--color-brand);
color: var(--color-accent-contrast);
font-weight: 500;
font-weight: bold;
}
&.danger {
background-color: var(--color-red);
color: var(--color-accent-contrast);
font-weight: 500;
font-weight: bold;
}
&.contrast {
background-color: var(--color-orange);
color: var(--color-accent-contrast);
font-weight: 500;
font-weight: bold;
}
}
}
@@ -1,12 +1,11 @@
<script setup>
import { FolderOpenIcon, XIcon } from '@modrinth/assets'
import { XIcon } from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
FileTreeSelect,
injectNotificationManager,
injectPopupNotificationManager,
NewModal,
StyledInput,
useVIntl,
@@ -16,10 +15,8 @@ import { ref, shallowRef } from 'vue'
import { PackageIcon } from '@/assets/icons'
import { export_instance_mrpack, get_pack_export_candidates } from '@/helpers/instance'
import { highlightInFolder } from '@/helpers/utils'
const { handleError } = injectNotificationManager()
const popupNotificationManager = injectPopupNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
@@ -42,14 +39,6 @@ const messages = defineMessages({
defaultMessage: 'Enter modpack description...',
},
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
exportComplete: {
id: 'app.export-modal.export-complete',
defaultMessage: 'Export complete',
},
exportCompleteDescription: {
id: 'app.export-modal.export-complete-description',
defaultMessage: '{name} was exported successfully.',
},
})
const props = defineProps({
@@ -104,35 +93,16 @@ const exportPack = async () => {
})
if (outputPath) {
export_instance_mrpack(
props.instance.id,
outputPath,
includedFilePaths.value,
excludedFilePaths.value,
versionInput.value,
exportDescription.value,
nameInput.value,
).catch((err) => handleError(err))
exportModal.value.hide()
try {
await export_instance_mrpack(
props.instance.id,
outputPath,
includedFilePaths.value,
excludedFilePaths.value,
versionInput.value,
exportDescription.value,
nameInput.value,
)
const fileName = outputPath.split(/[\\/]/).pop() ?? outputPath
popupNotificationManager.addPopupNotification({
title: formatMessage(messages.exportComplete),
text: formatMessage(messages.exportCompleteDescription, { name: fileName }),
type: 'success',
buttons: [
{
label: formatMessage(commonMessages.openInFolderButton),
icon: FolderOpenIcon,
action: () => highlightInFolder(outputPath).catch(handleError),
},
],
})
} catch (error) {
handleError(error)
}
}
}
@@ -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,39 +7,22 @@ import {
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import {
Avatar,
defineMessages,
IconButton,
injectNotificationManager,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { Avatar, IconButton, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppEvent } from '@/composables/use-app-event'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { getInstanceIconUrl, kill, run } from '@/helpers/instance'
import { kill, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import { showInstanceInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const messages = defineMessages({
instanceIcon: { id: 'app.instance.card.icon-alt', defaultMessage: 'Instance icon' },
stop: { id: 'app.instance.card.stop', defaultMessage: 'Stop' },
loading: { id: 'app.instance.card.loading', defaultMessage: 'Instance is loading...' },
installing: { id: 'app.instance.card.installing', defaultMessage: 'Installing...' },
play: { id: 'app.instance.card.play', defaultMessage: 'Play' },
repair: { id: 'app.instance.card.repair', defaultMessage: 'Repair' },
played: { id: 'app.instance.card.played', defaultMessage: 'Played {relativeTime}' },
neverPlayed: { id: 'app.instance.card.never-played', defaultMessage: 'Never played' },
})
const props = defineProps({
instance: {
@@ -153,7 +136,7 @@ defineExpose({
const currentEvent = ref(null)
useAppEvent('process', (e) => {
const unlisten = await process_listener((e) => {
if (e.instance_id === props.instance.id) {
currentEvent.value = e.event
if (e.event === 'finished') {
@@ -165,6 +148,7 @@ useAppEvent('process', (e) => {
onMounted(() => {
checkProcess()
})
onUnmounted(() => unlisten())
</script>
<template>
@@ -176,9 +160,9 @@ onMounted(() => {
>
<Avatar
size="48px"
:src="getInstanceIconUrl(instance.icon_path)"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.id"
:alt="formatMessage(messages.instanceIcon)"
alt="Mod card"
/>
<div class="h-full flex items-center font-bold text-contrast leading-normal">
<span class="line-clamp-2">{{ instance.name }}</span>
@@ -186,10 +170,10 @@ onMounted(() => {
<div class="flex items-center">
<IconButton
v-if="playing"
v-tooltip="formatMessage(messages.stop)"
v-tooltip="'Stop'"
type="colored"
color="red"
:label="formatMessage(messages.stop)"
:label="'Stop'"
@mouseenter="checkProcess"
@click="(e) => stop(e, 'InstanceCard')"
>
@@ -197,18 +181,18 @@ onMounted(() => {
</IconButton>
<IconButton
v-else-if="modLoading"
v-tooltip="formatMessage(messages.loading)"
:label="formatMessage(messages.loading)"
v-tooltip="'Instance is loading...'"
:label="'Instance is loading...'"
disabled
>
<SpinnerIcon class="animate-spin" />
</IconButton>
<IconButton
v-else-if="!instance.quarantined"
v-tooltip="formatMessage(messages.play)"
v-tooltip="'Play'"
:type="first ? 'colored' : 'base'"
:color="first ? 'brand' : undefined"
:label="formatMessage(messages.play)"
label="Play"
@click="(e) => play(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
@@ -220,13 +204,9 @@ onMounted(() => {
<TimerIcon />
<span class="text-sm">
<template v-if="instance.last_played">
{{
formatMessage(messages.played, {
relativeTime: formatRelativeTime(dayjs(instance.last_played).toISOString()),
})
}}
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
</template>
<template v-else>{{ formatMessage(messages.neverPlayed) }}</template>
<template v-else> Never played </template>
</span>
</div>
</div>
@@ -240,19 +220,19 @@ onMounted(() => {
<div class="relative flex items-center justify-center">
<Avatar
size="48px"
:src="getInstanceIconUrl(instance.icon_path)"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.id"
:alt="formatMessage(messages.instanceIcon)"
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="formatMessage(messages.stop)"
v-tooltip="'Stop'"
type="colored"
color="red"
size="xl"
:label="formatMessage(messages.stop)"
:label="'Stop'"
:class="{ 'scale-100 opacity-100': playing }"
class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
@click="(e) => stop(e, 'InstanceCard')"
@@ -262,17 +242,17 @@ onMounted(() => {
</IconButton>
<SpinnerIcon
v-else-if="modLoading || installing"
v-tooltip="formatMessage(modLoading ? messages.loading : messages.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="formatMessage(messages.repair)"
v-tooltip="'Repair'"
type="colored"
color="brand"
size="xl"
:label="formatMessage(messages.repair)"
: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)"
>
@@ -280,11 +260,11 @@ onMounted(() => {
</IconButton>
<IconButton
v-else-if="!instance.quarantined"
v-tooltip="formatMessage(messages.play)"
v-tooltip="'Play'"
type="colored"
color="brand"
size="xl"
:label="formatMessage(messages.play)"
: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"
@@ -1,18 +1,9 @@
<script setup lang="ts">
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
import { Avatar, ButtonLink, defineMessages, FormattedTag, useVIntl } from '@modrinth/ui'
import { Avatar, ButtonLink, FormattedTag } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed } from 'vue'
import { getInstanceIconUrl } from '@/helpers/instance'
const { formatMessage } = useVIntl()
const messages = defineMessages({
backToInstance: {
id: 'app.instance.navigation.back-to-instance',
defaultMessage: 'Back to instance',
},
})
type Instance = {
game_version: string
loader: string
@@ -40,7 +31,11 @@ const instanceLink = computed(() => {
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
<router-link :to="instanceLink" tabindex="-1" class="flex flex-col gap-4 text-primary">
<span class="flex items-center gap-2">
<Avatar :src="getInstanceIconUrl(instance.icon_path)" :alt="instance.name" size="48px" />
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
:alt="instance.name"
size="48px"
/>
<span class="flex flex-col gap-2">
<span class="font-extrabold bold text-contrast">
{{ instance.name }}
@@ -53,9 +48,7 @@ const instanceLink = computed(() => {
</span>
</span>
</router-link>
<ButtonLink :to="instanceLink">
<LeftArrowIcon /> {{ formatMessage(messages.backToInstance) }}
</ButtonLink>
<ButtonLink :to="instanceLink"> <LeftArrowIcon /> Back to instance </ButtonLink>
</div>
</template>
@@ -2,12 +2,13 @@
import { SpinnerIcon } from '@modrinth/assets'
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import NavButton from '@/components/ui/NavButton.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { getInstanceIconUrl, list } from '@/helpers/instance'
import { instance_listener } from '@/helpers/events.js'
import { list } from '@/helpers/instance'
import { instanceKeys } from '@/pages/instance/query-options'
const ITEM_SIZE = 52
@@ -147,7 +148,7 @@ const getInstances = async () => {
await getInstances()
updateMaxAuto()
useAppEvent('instance', async (event) => {
const unlistenInstance = await instance_listener(async (event) => {
if (event.event !== 'synced') {
await getInstances()
}
@@ -161,6 +162,7 @@ onUnmounted(() => {
window.removeEventListener('resize', updateMaxAuto)
document.body.classList.remove('quick-instance-dragging')
clearOverdragFlash()
unlistenInstance()
})
const messages = defineMessages({
@@ -200,7 +202,7 @@ const dividerTooltip = computed(() => {
>
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
<Avatar
:src="getInstanceIconUrl(instance.icon_path)"
: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`}`"
@@ -82,7 +82,7 @@ import { injectLoadingState } from '@modrinth/ui'
import { ref, watch } from 'vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { useAppEvent } from '@/composables/use-app-event'
import { loading_listener } from '@/helpers/events.js'
const doneLoading = ref(false)
const loadingProgress = ref(0)
@@ -132,10 +132,13 @@ function fakeLoadingIncrease() {
}
}
useAppEvent('loading', (e) => {
loading_listener(async (e) => {
if (e.event.type === 'directory_move') {
loadingProgress.value = 100 * (e.fraction ?? 1)
message.value = 'Updating app directory...'
} else if (e.event.type === 'checking_for_updates') {
loadingProgress.value = 100 * (e.fraction ?? 1)
message.value = 'Checking for updates...'
}
})
</script>
@@ -3,14 +3,12 @@ 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 { onMounted, ref } from 'vue'
import { release_ads_window_hold, take_ads_window_hold } from '@/helpers/ads.js'
import { hide_ads_window, show_ads_window } 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
@@ -94,44 +92,30 @@ async function openSurvey() {
onOpen: () => console.info('Opened user survey'),
onClose: () => {
console.info('Closed user survey')
if (adsWindowHold) {
adsWindowHold = false
release_ads_window_hold()
}
show_ads_window()
},
onSubmit: () => console.info('Active user survey submitted'),
}
try {
await take_ads_window_hold()
adsWindowHold = true
hide_ads_window()
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()
show_ads_window()
}
} catch (e) {
console.error('Error opening Tally popup:', e)
if (adsWindowHold) {
adsWindowHold = false
await release_ads_window_hold()
}
show_ads_window()
}
console.info(`Found user survey to show with tally_id: ${formId}`)
tallyWindow.Tally?.openPopup(formId, popupOptions)
}
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()))
@@ -1,154 +0,0 @@
<script setup lang="ts">
import { ImportIcon, PlusIcon } from '@modrinth/assets'
import { Button, defineMessages, IntlFormatted, useVIntl } from '@modrinth/ui'
import { inject, onMounted, onUnmounted, ref } from 'vue'
import modrinthSocialIcon from '../../assets/welcome/modrinth-social-icon.png'
const showCreationModal = inject<() => void>('showCreationModal')
const showImportModal = inject<() => void>('showImportModal')
const { formatMessage } = useVIntl()
const messages = defineMessages({
welcomeTitle: {
id: 'app.welcome-screen.title',
defaultMessage: 'Welcome to Modrinth',
},
welcomeDescription: {
id: 'app.welcome-screen.description',
defaultMessage: 'Ready to start playing?',
},
createInstance: {
id: 'app.welcome-screen.create-instance',
defaultMessage: 'Create an instance',
},
quickCreateHint: {
id: 'app.welcome-screen.quick-create-hint',
defaultMessage: 'Press <shortcut>N</shortcut> to quick create an instance',
},
importPrompt: {
id: 'app.welcome-screen.import-prompt',
defaultMessage: 'Escaping another launcher?',
},
importFromLauncher: {
id: 'app.welcome-screen.import-from-launcher',
defaultMessage: 'Import from launcher',
},
})
const offline = ref(!navigator.onLine)
function handleOffline() {
offline.value = true
}
function handleOnline() {
offline.value = false
}
function handleQuickCreate(event: KeyboardEvent) {
const target = event.target as HTMLElement | null
if (
event.key.toLowerCase() !== 'n' ||
event.repeat ||
event.metaKey ||
event.ctrlKey ||
event.altKey ||
target?.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? '')
) {
return
}
if (!offline.value) {
event.preventDefault()
showCreationModal?.()
}
}
onMounted(() => {
window.addEventListener('offline', handleOffline)
window.addEventListener('online', handleOnline)
window.addEventListener('keydown', handleQuickCreate)
})
onUnmounted(() => {
window.removeEventListener('offline', handleOffline)
window.removeEventListener('online', handleOnline)
window.removeEventListener('keydown', handleQuickCreate)
})
</script>
<template>
<div class="flex flex-col min-h-full px-6 pb-6 pt-16">
<div class="relative flex grow items-center justify-center">
<div class="relative isolate flex flex-col items-center gap-6">
<div
class="dot-pattern pointer-events-none absolute left-1/2 -top-52 -z-10 h-[29.875rem] w-[min(25.9375rem,80vw)] -translate-x-1/2 rounded-2xl [@media(max-height:700px)]:h-[23rem]"
aria-hidden="true"
/>
<div class="size-[6.25rem]">
<img :src="modrinthSocialIcon" alt="" class="pointer-events-none size-full" />
</div>
<div class="flex flex-col items-center gap-2">
<h1 class="m-0 flex items-center gap-2 text-2xl font-semibold leading-8 text-contrast">
{{ formatMessage(messages.welcomeTitle) }}
</h1>
<p class="m-0 text-center text-base leading-6 text-primary">
{{ formatMessage(messages.welcomeDescription) }}
</p>
</div>
<div class="flex w-72 flex-col items-center gap-4">
<Button
type="colored"
color="brand"
size="lg"
class="!shadow-none"
:disabled="offline"
@click="showCreationModal?.()"
>
<PlusIcon />
{{ formatMessage(messages.createInstance) }}
</Button>
<span class="flex items-center gap-1 text-sm leading-5 text-secondary">
<IntlFormatted :message-id="messages.quickCreateHint">
<template #shortcut="{ children }">
<kbd
class="inline-flex h-5 min-w-5 items-center justify-center rounded-md border border-solid border-surface-5 bg-button-bg px-1 text-xs font-normal leading-4 text-primary"
>
<component :is="() => children" />
</kbd>
</template>
</IntlFormatted>
</span>
</div>
</div>
</div>
<div
class="flex flex-col h-max items-center justify-end gap-4 text-sm leading-5 text-secondary"
>
<span class="whitespace-nowrap">{{ formatMessage(messages.importPrompt) }}</span>
<Button size="lg" class="!font-medium" :disabled="offline" @click="showImportModal?.()">
<ImportIcon />
{{ formatMessage(messages.importFromLauncher) }}
</Button>
</div>
</div>
</template>
<style scoped>
.dot-pattern {
background-image: radial-gradient(
circle,
color-mix(in srgb, var(--color-text-primary) 25%, transparent) 0.5px,
transparent 0.75px
);
background-size: 0.5625rem 0.5625rem;
opacity: 0.8;
-webkit-mask-image: radial-gradient(ellipse at center, black 10%, transparent 68%);
mask-image: radial-gradient(ellipse at center, black 10%, transparent 68%);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
}
</style>
@@ -23,8 +23,9 @@
</IconButton>
<IconButton
type="quiet"
color="red"
label="Close window"
class="relative expanded-button close-button"
class="relative expanded-button close-button hover:!bg-red focus-visible:!bg-red"
@click="handleClose"
>
<XIcon />
@@ -3,7 +3,7 @@
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-inherit !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="!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,
@@ -1,40 +1,17 @@
<script setup>
import { CheckIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
Button,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { Admonition, Avatar, Button, injectNotificationManager, StyledInput } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
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 { getInstanceIconUrl, list } from '@/helpers/instance'
import { list } from '@/helpers/instance'
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
import { instanceKeys } from '@/pages/instance/query-options'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.instance.add-server.title', defaultMessage: 'Add server to instance' },
compatibilityWarning: {
id: 'app.instance.add-server.compatibility-warning',
defaultMessage: 'This server may not be compatible with all instances.',
},
searchPlaceholder: {
id: 'app.instance.add-server.search-placeholder',
defaultMessage: 'Search for an instance',
},
adding: { id: 'app.instance.add-server.adding', defaultMessage: 'Adding...' },
added: { id: 'app.instance.add-server.added', defaultMessage: 'Added' },
add: { id: 'app.instance.add-server.add', defaultMessage: 'Add' },
cancel: { id: 'app.instance.add-server.cancel', defaultMessage: 'Cancel' },
})
const queryClient = useQueryClient()
const modal = ref()
@@ -100,14 +77,14 @@ async function addServer(instance) {
</script>
<template>
<ModalWrapper ref="modal" :header="formatMessage(messages.title)">
<ModalWrapper ref="modal" header="Add server to instance">
<div class="flex flex-col gap-4 min-w-[350px]">
<Admonition type="warning" :body="formatMessage(messages.compatibilityWarning)" />
<Admonition type="warning" body="This server may not be compatible with all instances." />
<StyledInput
v-model="searchFilter"
:icon="SearchIcon"
type="search"
:placeholder="formatMessage(messages.searchPlaceholder)"
placeholder="Search for an instance"
autocomplete="off"
/>
<div class="max-h-[21rem] overflow-y-auto">
@@ -121,22 +98,21 @@ async function addServer(instance) {
:to="`/instance/${encodeURIComponent(instance.id)}`"
@click="modal.hide()"
>
<Avatar :src="getInstanceIconUrl(instance.icon_path)" class="mr-2 [--size:2rem]" />
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
class="mr-2 [--size:2rem]"
/>
{{ instance.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" />
{{
formatMessage(
instance.adding ? messages.adding : instance.added ? messages.added : messages.add,
)
}}
{{ instance.adding ? 'Adding...' : instance.added ? 'Added' : 'Add' }}
</Button>
</div>
</div>
<div class="input-group push-right">
<Button @click="modal.hide()">{{ formatMessage(messages.cancel) }}</Button>
<Button @click="modal.hide()">Cancel</Button>
</div>
</div>
</ModalWrapper>
@@ -1,466 +0,0 @@
import { defineMessages } from '@modrinth/ui'
import backpack from '@/assets/instance-icons/backpack.png'
import beacon from '@/assets/instance-icons/beacon.png'
import blueShark from '@/assets/instance-icons/blue-shark.png'
import bookshelf from '@/assets/instance-icons/bookshelf.png'
import brownBear from '@/assets/instance-icons/brown-bear.png'
import cake from '@/assets/instance-icons/cake.png'
import campfire from '@/assets/instance-icons/campfire.png'
import chest from '@/assets/instance-icons/chest.png'
import cogwheel from '@/assets/instance-icons/cogwheel.png'
import commandBlock from '@/assets/instance-icons/command-block.png'
import cookingPot from '@/assets/instance-icons/cooking-pot.png'
import couch from '@/assets/instance-icons/couch.png'
import craftingTable from '@/assets/instance-icons/crafting-table.png'
import creeper from '@/assets/instance-icons/creeper.png'
import enchantingTable from '@/assets/instance-icons/enchanting-table.png'
import enderChest from '@/assets/instance-icons/ender-chest.png'
import enderDragon from '@/assets/instance-icons/ender-dragon.png'
import engine from '@/assets/instance-icons/engine.png'
import furnace from '@/assets/instance-icons/furnace.png'
import gizmo from '@/assets/instance-icons/gizmo.png'
import globe from '@/assets/instance-icons/globe.png'
import grassBlock from '@/assets/instance-icons/grass-block.png'
import lantern from '@/assets/instance-icons/lantern.png'
import moobloom from '@/assets/instance-icons/moobloom.png'
import mrPack from '@/assets/instance-icons/mr-pack.png'
import orb from '@/assets/instance-icons/orb.png'
import oxygenDistributor from '@/assets/instance-icons/oxygen-distributor.png'
import pancakes from '@/assets/instance-icons/pancakes.png'
import pickaxe from '@/assets/instance-icons/pickaxe.png'
import pokeBall from '@/assets/instance-icons/poke-ball.png'
import redstoneBlock from '@/assets/instance-icons/redstone-block.png'
import sculkSensor from '@/assets/instance-icons/sculk-sensor.png'
import skeleton from '@/assets/instance-icons/skeleton.png'
import skillet from '@/assets/instance-icons/skillet.png'
import slimeBlock from '@/assets/instance-icons/slime-block.png'
import spaceHelmet from '@/assets/instance-icons/space-helmet.png'
import stickyPiston from '@/assets/instance-icons/sticky-piston.png'
import sword from '@/assets/instance-icons/sword.png'
import terminal from '@/assets/instance-icons/terminal.png'
import tinyPotato from '@/assets/instance-icons/tiny-potato.png'
import tire from '@/assets/instance-icons/tire.png'
import tnt from '@/assets/instance-icons/tnt.png'
import wrench from '@/assets/instance-icons/wrench.png'
import wrenchRinth from '@/assets/instance-icons/wrench-rinth.png'
import zombie from '@/assets/instance-icons/zombie.png'
const names = defineMessages({
yellow: {
id: 'instance.icon-editor.background.yellow',
defaultMessage: 'Yellow',
},
green: {
id: 'instance.icon-editor.background.green',
defaultMessage: 'Green',
},
lime: {
id: 'instance.icon-editor.background.lime',
defaultMessage: 'Lime',
},
darkGreen: {
id: 'instance.icon-editor.background.dark-green',
defaultMessage: 'Dark green',
},
purple: {
id: 'instance.icon-editor.background.purple',
defaultMessage: 'Purple',
},
blue: {
id: 'instance.icon-editor.background.blue',
defaultMessage: 'Blue',
},
orange: {
id: 'instance.icon-editor.background.orange',
defaultMessage: 'Orange',
},
red: {
id: 'instance.icon-editor.background.red',
defaultMessage: 'Red',
},
rose: {
id: 'instance.icon-editor.background.rose',
defaultMessage: 'Rose',
},
pink: {
id: 'instance.icon-editor.background.pink',
defaultMessage: 'Pink',
},
indigo: {
id: 'instance.icon-editor.background.indigo',
defaultMessage: 'Indigo',
},
lavender: {
id: 'instance.icon-editor.background.lavender',
defaultMessage: 'Lavender',
},
lightGray: {
id: 'instance.icon-editor.background.light-gray',
defaultMessage: 'Light gray',
},
gray: {
id: 'instance.icon-editor.background.gray',
defaultMessage: 'Gray',
},
darkGray: {
id: 'instance.icon-editor.background.dark-gray',
defaultMessage: 'Dark gray',
},
backpack: { id: 'instance.icon-editor.symbol.backpack', defaultMessage: 'Backpack' },
beacon: { id: 'instance.icon-editor.symbol.beacon', defaultMessage: 'Beacon' },
blueShark: { id: 'instance.icon-editor.symbol.blue-shark', defaultMessage: 'Blue Shark' },
bookshelf: { id: 'instance.icon-editor.symbol.bookshelf', defaultMessage: 'Bookshelf' },
brownBear: { id: 'instance.icon-editor.symbol.brown-bear', defaultMessage: 'Brown Bear' },
cake: { id: 'instance.icon-editor.symbol.cake', defaultMessage: 'Cake' },
campfire: { id: 'instance.icon-editor.symbol.campfire', defaultMessage: 'Campfire' },
chest: { id: 'instance.icon-editor.symbol.chest', defaultMessage: 'Chest' },
cogwheel: { id: 'instance.icon-editor.symbol.cogwheel', defaultMessage: 'Cogwheel' },
commandBlock: {
id: 'instance.icon-editor.symbol.command-block',
defaultMessage: 'Command Block',
},
cookingPot: {
id: 'instance.icon-editor.symbol.cooking-pot',
defaultMessage: 'Cooking Pot',
},
couch: { id: 'instance.icon-editor.symbol.couch', defaultMessage: 'Couch' },
craftingTable: {
id: 'instance.icon-editor.symbol.crafting-table',
defaultMessage: 'Crafting Table',
},
creeper: { id: 'instance.icon-editor.symbol.creeper', defaultMessage: 'Creeper' },
enchantingTable: {
id: 'instance.icon-editor.symbol.enchanting-table',
defaultMessage: 'Enchanting Table',
},
enderChest: {
id: 'instance.icon-editor.symbol.ender-chest',
defaultMessage: 'Ender Chest',
},
enderDragon: {
id: 'instance.icon-editor.symbol.ender-dragon',
defaultMessage: 'Ender Dragon',
},
engine: { id: 'instance.icon-editor.symbol.engine', defaultMessage: 'Engine' },
furnace: { id: 'instance.icon-editor.symbol.furnace', defaultMessage: 'Furnace' },
gizmo: { id: 'instance.icon-editor.symbol.gizmo', defaultMessage: 'Gizmo' },
globe: { id: 'instance.icon-editor.symbol.globe', defaultMessage: 'Globe' },
grassBlock: {
id: 'instance.icon-editor.symbol.grass-block',
defaultMessage: 'Grass Block',
},
lantern: { id: 'instance.icon-editor.symbol.lantern', defaultMessage: 'Lantern' },
moobloom: { id: 'instance.icon-editor.symbol.moobloom', defaultMessage: 'Moobloom' },
mrPack: { id: 'instance.icon-editor.symbol.mr-pack', defaultMessage: 'Mr Pack' },
orb: { id: 'instance.icon-editor.symbol.orb', defaultMessage: 'Orb' },
oxygenDistributor: {
id: 'instance.icon-editor.symbol.oxygen-distributor',
defaultMessage: 'Oxygen Distributor',
},
pancakes: { id: 'instance.icon-editor.symbol.pancakes', defaultMessage: 'Pancakes' },
pickaxe: { id: 'instance.icon-editor.symbol.pickaxe', defaultMessage: 'Pickaxe' },
pokeBall: { id: 'instance.icon-editor.symbol.poke-ball', defaultMessage: 'Poke Ball' },
redstoneBlock: {
id: 'instance.icon-editor.symbol.redstone-block',
defaultMessage: 'Redstone Block',
},
sculkSensor: {
id: 'instance.icon-editor.symbol.sculk-sensor',
defaultMessage: 'Sculk Sensor',
},
skeleton: { id: 'instance.icon-editor.symbol.skeleton', defaultMessage: 'Skeleton' },
skillet: { id: 'instance.icon-editor.symbol.skillet', defaultMessage: 'Skillet' },
slimeBlock: {
id: 'instance.icon-editor.symbol.slime-block',
defaultMessage: 'Slime Block',
},
spaceHelmet: {
id: 'instance.icon-editor.symbol.space-helmet',
defaultMessage: 'Space Helmet',
},
stickyPiston: {
id: 'instance.icon-editor.symbol.sticky-piston',
defaultMessage: 'Sticky Piston',
},
sword: { id: 'instance.icon-editor.symbol.sword', defaultMessage: 'Sword' },
tnt: { id: 'instance.icon-editor.symbol.tnt', defaultMessage: 'TNT' },
terminal: { id: 'instance.icon-editor.symbol.terminal', defaultMessage: 'Terminal' },
tinyPotato: {
id: 'instance.icon-editor.symbol.tiny-potato',
defaultMessage: 'Tiny Potato',
},
tire: { id: 'instance.icon-editor.symbol.tire', defaultMessage: 'Tire' },
wrench: { id: 'instance.icon-editor.symbol.create-wrench', defaultMessage: 'Wrench' },
wrenchRinth: {
id: 'instance.icon-editor.symbol.wrenth-rinth',
defaultMessage: 'Modrinth Wrench',
},
zombie: { id: 'instance.icon-editor.symbol.zombie', defaultMessage: 'Zombie' },
})
export const backgroundOptions = [
{
id: 'rose',
background: {
type: 'linear-top-down-gradient',
top_color: '#D62E63',
bottom_color: '#F95C62',
},
name: names.rose,
},
{
id: 'orange',
background: {
type: 'linear-top-down-gradient',
top_color: '#FF8D29',
bottom_color: '#FFB452',
},
name: names.orange,
},
{
id: 'yellow',
background: {
type: 'linear-top-down-gradient',
top_color: '#FFC629',
bottom_color: '#FFEE53',
},
name: names.yellow,
},
{
id: 'lime',
background: {
type: 'linear-top-down-gradient',
top_color: '#6FDA1D',
bottom_color: '#CBFF50',
},
name: names.lime,
},
{
id: 'green',
background: {
type: 'linear-top-down-gradient',
top_color: '#0B9F21',
bottom_color: '#4FD24B',
},
name: names.green,
},
// {
// id: 'dark_green',
// background: {
// type: 'linear-top-down-gradient',
// top_color: '#084C13',
// bottom_color: '#327735',
// },
// name: names.darkGreen,
// },
// {
// id: 'indigo',
// background: {
// type: 'linear-top-down-gradient',
// top_color: '#3F00D3',
// bottom_color: '#2659FE',
// },
// name: names.indigo,
// },
{
id: 'purple',
background: {
type: 'linear-top-down-gradient',
top_color: '#4739FF',
bottom_color: '#6670FF',
},
name: names.purple,
},
{
id: 'blue',
background: {
type: 'linear-top-down-gradient',
top_color: '#227EFF',
bottom_color: '#5EC1FF',
},
name: names.blue,
},
{
id: 'lavender',
background: {
type: 'linear-top-down-gradient',
top_color: '#C056FD',
bottom_color: '#B889FF',
},
name: names.lavender,
},
{
id: 'pink',
background: {
type: 'linear-top-down-gradient',
top_color: '#F640C0',
bottom_color: '#FF7BF1',
},
name: names.pink,
},
// {
// id: 'red',
// background: {
// type: 'linear-top-down-gradient',
// top_color: '#F6111C',
// bottom_color: '#F94548',
// },
// name: names.red,
// },
{
id: 'light_gray',
background: {
type: 'linear-top-down-gradient',
top_color: '#AEAEAE',
bottom_color: '#D9D9D9',
},
name: names.lightGray,
},
{
id: 'gray',
background: {
type: 'linear-top-down-gradient',
top_color: '#373C4C',
bottom_color: '#4C4F58',
},
name: names.gray,
},
{
id: 'dark_gray',
background: {
type: 'linear-top-down-gradient',
top_color: '#1B1D29',
bottom_color: '#252731',
},
name: names.darkGray,
},
] as const
export const symbolOptions = [
// Cobblemon: Poké Ball
{ id: 'poke_ball', name: names.pokeBall, asset: pokeBall, category: 'modded' },
// Origins: Orb of Origins
{ id: 'orb', name: names.orb, asset: orb, category: 'modded' },
// Farmer's Delight: Cooking Pot, Skillet
{ id: 'cooking_pot', name: names.cookingPot, asset: cookingPot, category: 'modded' },
{ id: 'skillet', name: names.skillet, asset: skillet, category: 'modded' },
// Supplementaries: Globe, Pancakes
{ id: 'globe', name: names.globe, asset: globe, category: 'modded' },
{ id: 'pancakes', name: names.pancakes, asset: pancakes, category: 'modded' },
// Sophisticated Backpacks: Backpack
{ id: 'backpack', name: names.backpack, asset: backpack, category: 'modded' },
// Chipped: Chair
{ id: 'couch', name: names.couch, asset: couch, category: 'modded' },
// Botania: Tiny Potato
{ id: 'tiny_potato', name: names.tinyPotato, asset: tinyPotato, category: 'modded' },
// Blåhaj: Blue Shark
{ id: 'blue_shark', name: names.blueShark, asset: blueShark, category: 'modded' },
// Other modded symbols: Brown Bear, Moobloom
{ id: 'brown_bear', name: names.brownBear, asset: brownBear, category: 'modded' },
{ id: 'moobloom', name: names.moobloom, asset: moobloom, category: 'modded' },
// Create: Wrench, Cogwheel
{ id: 'create_wrench', name: names.wrench, asset: wrench, category: 'modded' },
{ id: 'cogwheel', name: names.cogwheel, asset: cogwheel, category: 'modded' },
// Create Aeronautics: Engine, Tire
{ id: 'engine', name: names.engine, asset: engine, category: 'modded' },
{ id: 'tire', name: names.tire, asset: tire, category: 'modded' },
// Ad Astra: Oxygen Distributor, Space Helmet
{
id: 'oxygen_distributor',
name: names.oxygenDistributor,
asset: oxygenDistributor,
category: 'modded',
},
{ id: 'space_helmet', name: names.spaceHelmet, asset: spaceHelmet, category: 'modded' },
// Miscellaneous: Gizmo, Terminal
{ id: 'gizmo', name: names.gizmo, asset: gizmo, category: 'modded' },
{ id: 'terminal', name: names.terminal, asset: terminal, category: 'modded' },
// Miscellaneous: Modrinth Wrench, Mr Pack
{ id: 'wrenth_rinth', name: names.wrenchRinth, asset: wrenchRinth, category: 'modded' },
{ id: 'mr_pack', name: names.mrPack, asset: mrPack, category: 'modded' },
/////////////////////////
// vanilla ones
/////////////////////////
{ id: 'grass_block', name: names.grassBlock, asset: grassBlock, category: 'vanilla' },
{ id: 'crafting_table', name: names.craftingTable, asset: craftingTable, category: 'vanilla' },
{ id: 'furnace', name: names.furnace, asset: furnace, category: 'vanilla' },
{ id: 'chest', name: names.chest, asset: chest, category: 'vanilla' },
{ id: 'bookshelf', name: names.bookshelf, asset: bookshelf, category: 'vanilla' },
{
id: 'redstone_block',
name: names.redstoneBlock,
asset: redstoneBlock,
category: 'vanilla',
},
{
id: 'sticky_piston',
name: names.stickyPiston,
asset: stickyPiston,
category: 'vanilla',
},
{ id: 'slime_block', name: names.slimeBlock, asset: slimeBlock, category: 'vanilla' },
{ id: 'cake', name: names.cake, asset: cake, category: 'vanilla' },
{ id: 'campfire', name: names.campfire, asset: campfire, category: 'vanilla' },
{ id: 'pickaxe', name: names.pickaxe, asset: pickaxe, category: 'vanilla' },
{ id: 'sword', name: names.sword, asset: sword, category: 'vanilla' },
{ id: 'zombie', name: names.zombie, asset: zombie, category: 'vanilla' },
{ id: 'creeper', name: names.creeper, asset: creeper, category: 'vanilla' },
{ id: 'skeleton', name: names.skeleton, asset: skeleton, category: 'vanilla' },
{ id: 'ender_dragon', name: names.enderDragon, asset: enderDragon, category: 'vanilla' },
{ id: 'ender_chest', name: names.enderChest, asset: enderChest, category: 'vanilla' },
{ id: 'sculk_sensor', name: names.sculkSensor, asset: sculkSensor, category: 'vanilla' },
{ id: 'beacon', name: names.beacon, asset: beacon, category: 'vanilla' },
{
id: 'enchanting_table',
name: names.enchantingTable,
asset: enchantingTable,
category: 'vanilla',
},
{ id: 'lantern', name: names.lantern, asset: lantern, category: 'vanilla' },
{ id: 'tnt', name: names.tnt, asset: tnt, category: 'vanilla' },
{ id: 'command_block', name: names.commandBlock, asset: commandBlock, category: 'vanilla' },
] as const
export type BackgroundId = (typeof backgroundOptions)[number]['id']
export type SymbolId = (typeof symbolOptions)[number]['id']
export const RANDOM_CONFIG_BLACKLIST = [
{ background: 'purple', symbol: 'globe' },
{ background: 'blue', symbol: 'globe' },
{ background: 'gray', symbol: 'cogwheel' },
{ background: 'dark_gray', symbol: 'cogwheel' },
{ background: 'rose', symbol: 'poke_ball' },
{ background: 'lime', symbol: 'slime_block' },
{ background: 'green', symbol: 'slime_block' },
{ background: 'rose', symbol: 'redstone_block' },
{ background: 'rose', symbol: 'couch' },
{ background: 'orange', symbol: 'space_helmet' },
{ background: 'rose', symbol: 'tnt' },
{ background: 'yellow', symbol: 'moobloom' },
{ background: 'green', symbol: 'wrenth_rinth' },
{ background: 'lime', symbol: 'mr_pack' },
{ background: 'light_gray', symbol: 'skillet' },
{ background: 'light_gray', symbol: 'cooking_pot' },
] satisfies readonly { background: BackgroundId; symbol: SymbolId }[]
export const DEFAULT_BACKGROUND_ID = 'purple' satisfies BackgroundId
export const DEFAULT_SYMBOL_ID = 'grass_block' satisfies SymbolId
@@ -1,537 +0,0 @@
<script setup lang="ts">
import { CheckIcon, InfoIcon, RefreshCwIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { toError } from '@/helpers/errors'
import {
cache_generated_icon,
edit_generated_icon,
edit_generated_icon_if_empty,
get_recent_icon_configs,
} from '@/helpers/instance'
import type { IconBackground, InstanceIconConfig } from '@/helpers/types'
import {
type BackgroundId,
backgroundOptions,
DEFAULT_BACKGROUND_ID,
DEFAULT_SYMBOL_ID,
RANDOM_CONFIG_BLACKLIST,
type SymbolId,
symbolOptions,
} from './editor-catalog'
const props = defineProps<{
instanceId?: string
config?: InstanceIconConfig | null
}>()
const emit = defineEmits<{
saved: [iconPath: string, config: InstanceIconConfig]
}>()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const recentConfigs = ref<InstanceIconConfig[]>([])
const saving = ref(false)
const backgroundScroller = ref<HTMLElement | null>(null)
const showLeftBackgroundShadow = ref(false)
const showRightBackgroundShadow = ref(false)
const draggingBackgrounds = ref(false)
let backgroundScrollerResizeObserver: ResizeObserver | null = null
let backgroundDragPointerId: number | null = null
let backgroundDragCaptureTarget: Element | null = null
let backgroundDragStartX = 0
let backgroundDragStartScrollLeft = 0
let suppressBackgroundClick = false
let suppressBackgroundClickTimeout: ReturnType<typeof setTimeout> | null = null
const selectedBackground = ref<BackgroundId>(DEFAULT_BACKGROUND_ID)
const selectedSymbol = ref<SymbolId>(DEFAULT_SYMBOL_ID)
const selectedBackgroundOption = computed(
() => backgroundOptions.find((option) => option.id === selectedBackground.value)!,
)
const selectedSymbolOption = computed(
() => symbolOptions.find((option) => option.id === selectedSymbol.value)!,
)
const vanillaSymbolStartIndex = symbolOptions.findIndex((option) => option.category === 'vanilla')
const selectedConfig = computed<InstanceIconConfig>(() => ({
background: { ...selectedBackgroundOption.value.background },
symbol: selectedSymbol.value,
}))
const visibleRecentConfigs = computed(() =>
recentConfigs.value.filter(
(config) => backgroundOption(config.background) && symbolOption(config.symbol),
),
)
function updateBackgroundScrollShadows() {
const el = backgroundScroller.value
if (!el) {
showLeftBackgroundShadow.value = false
showRightBackgroundShadow.value = false
return
}
showLeftBackgroundShadow.value = el.scrollLeft > 0
showRightBackgroundShadow.value = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
}
function onBackgroundWheel(event: WheelEvent) {
const el = backgroundScroller.value
if (!el || el.scrollWidth <= el.clientWidth) return
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
el.scrollLeft += delta
}
function onBackgroundPointerDown(event: PointerEvent) {
const el = backgroundScroller.value
if (!el || event.pointerType === 'touch' || event.button !== 0) return
backgroundDragPointerId = event.pointerId
backgroundDragStartX = event.clientX
backgroundDragStartScrollLeft = el.scrollLeft
suppressBackgroundClick = false
backgroundDragCaptureTarget =
event.target instanceof Element ? (event.target.closest('button') ?? el) : el
backgroundDragCaptureTarget.setPointerCapture(event.pointerId)
}
function onBackgroundPointerMove(event: PointerEvent) {
const el = backgroundScroller.value
if (!el || event.pointerId !== backgroundDragPointerId) return
const distance = event.clientX - backgroundDragStartX
if (!draggingBackgrounds.value && Math.abs(distance) < 4) return
draggingBackgrounds.value = true
suppressBackgroundClick = true
event.preventDefault()
el.scrollLeft = backgroundDragStartScrollLeft - distance
}
function finishBackgroundDrag(event: PointerEvent) {
const el = backgroundScroller.value
if (!el || event.pointerId !== backgroundDragPointerId) return
if (backgroundDragCaptureTarget?.hasPointerCapture(event.pointerId)) {
backgroundDragCaptureTarget.releasePointerCapture(event.pointerId)
}
backgroundDragPointerId = null
backgroundDragCaptureTarget = null
draggingBackgrounds.value = false
if (suppressBackgroundClick) {
if (suppressBackgroundClickTimeout) clearTimeout(suppressBackgroundClickTimeout)
suppressBackgroundClickTimeout = setTimeout(() => {
suppressBackgroundClick = false
suppressBackgroundClickTimeout = null
}, 0)
}
}
function onBackgroundClick(event: MouseEvent) {
if (!suppressBackgroundClick) return
event.preventDefault()
event.stopPropagation()
suppressBackgroundClick = false
if (suppressBackgroundClickTimeout) clearTimeout(suppressBackgroundClickTimeout)
suppressBackgroundClickTimeout = null
}
onMounted(() => {
backgroundScrollerResizeObserver = new ResizeObserver(updateBackgroundScrollShadows)
if (backgroundScroller.value) backgroundScrollerResizeObserver.observe(backgroundScroller.value)
nextTick(updateBackgroundScrollShadows)
})
onBeforeUnmount(() => {
backgroundScrollerResizeObserver?.disconnect()
if (suppressBackgroundClickTimeout) clearTimeout(suppressBackgroundClickTimeout)
})
function backgroundOption(background?: IconBackground) {
if (background?.type !== 'linear-top-down-gradient') return undefined
return backgroundOptions.find(
(option) =>
option.background.top_color === background.top_color &&
option.background.bottom_color === background.bottom_color,
)
}
function backgroundStyle(background: IconBackground) {
if (background.type === 'color') return { backgroundColor: background.value }
return {
backgroundImage: `linear-gradient(to bottom, ${background.top_color}, ${background.bottom_color})`,
}
}
function backgroundKey(background: IconBackground) {
if (background.type === 'color') return `${background.type}-${background.value}`
return `${background.type}-${background.top_color}-${background.bottom_color}`
}
function symbolOption(symbol: string) {
return symbolOptions.find((option) => option.id === symbol)
}
function selectRecent(config: InstanceIconConfig) {
const background = backgroundOption(config.background)
const symbol = symbolOption(config.symbol)
if (!background || !symbol) return
selectedBackground.value = background.id
selectedSymbol.value = symbol.id
}
function surpriseMe() {
const configurations = backgroundOptions.flatMap((background) =>
symbolOptions
.filter(
(symbol) =>
background.id !== selectedBackground.value &&
symbol.id !== selectedSymbol.value &&
!RANDOM_CONFIG_BLACKLIST.some(
(config) => config.background === background.id && config.symbol === symbol.id,
),
)
.map((symbol) => ({ background: background.id, symbol: symbol.id })),
)
const configuration = configurations[Math.floor(Math.random() * configurations.length)]
if (!configuration) return
selectedBackground.value = configuration.background
selectedSymbol.value = configuration.symbol
}
async function loadRecents() {
try {
recentConfigs.value = await get_recent_icon_configs()
} catch (error) {
handleError(toError(error))
}
}
function show() {
selectedBackground.value = backgroundOption(props.config?.background)?.id ?? DEFAULT_BACKGROUND_ID
selectedSymbol.value = symbolOption(props.config?.symbol ?? '')?.id ?? DEFAULT_SYMBOL_ID
modal.value?.show()
void loadRecents()
nextTick(updateBackgroundScrollShadows)
}
function hide() {
modal.value?.hide()
}
async function loadSymbolBytes(asset: string): Promise<number[]> {
const response = await fetch(asset)
if (!response.ok) throw new Error('Failed to load the icon symbol.')
return Array.from(new Uint8Array(await response.arrayBuffer()))
}
async function saveIcon() {
if (saving.value) return
saving.value = true
try {
const config = selectedConfig.value
const symbolBytes = await loadSymbolBytes(selectedSymbolOption.value.asset)
const iconPath = props.instanceId
? await edit_generated_icon(props.instanceId, config, symbolBytes)
: await cache_generated_icon(config, symbolBytes, true)
emit('saved', iconPath, config)
saving.value = false
await nextTick()
hide()
} catch (error) {
handleError(toError(error))
} finally {
saving.value = false
}
}
async function randomizeAndSave() {
try {
surpriseMe()
const config = selectedConfig.value
const iconPath = await cache_generated_icon(
config,
await loadSymbolBytes(selectedSymbolOption.value.asset),
)
return { iconPath, config }
} catch (error) {
handleError(toError(error))
return null
}
}
async function applyGeneratedIcon(instanceId: string, config: InstanceIconConfig) {
try {
const symbol = symbolOption(config.symbol)
if (!backgroundOption(config.background) || !symbol) return false
await edit_generated_icon_if_empty(instanceId, config, await loadSymbolBytes(symbol.asset))
return true
} catch (error) {
handleError(toError(error))
return false
}
}
defineExpose({ show, hide, randomize: randomizeAndSave, randomizeAndSave, applyGeneratedIcon })
const messages = defineMessages({
title: {
id: 'instance.icon-editor.title',
defaultMessage: 'Icon editor',
},
background: {
id: 'instance.icon-editor.background',
defaultMessage: 'Background',
},
symbol: {
id: 'instance.icon-editor.symbol',
defaultMessage: 'Symbol',
},
surpriseMe: {
id: 'instance.icon-editor.surprise-me',
defaultMessage: 'Randomize',
},
recents: {
id: 'instance.icon-editor.recents',
defaultMessage: 'Recents',
},
description: {
id: 'instance.icon-editor.description',
defaultMessage: 'Mix and match elements to create a custom icon.',
},
saveIcon: {
id: 'instance.icon-editor.save',
defaultMessage: 'Save icon',
},
})
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
width="928px"
max-width="calc(100vw - 2rem)"
no-padding
actions-divider
:disable-close="saving"
>
<div class="flex h-[552px] max-h-[calc(100vh-168px)] min-h-0">
<aside
class="flex w-[244px] shrink-0 flex-col gap-4 overflow-y-auto border-0 border-r border-solid border-surface-5 p-6"
>
<div
class="flex w-full flex-col items-center gap-3 rounded-[20px] border border-solid border-surface-4 bg-surface-2 p-4"
>
<div
class="icon-outline relative size-[132px] overflow-hidden rounded-[20px]"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
<div class="flex items-center gap-2.5">
<div
class="icon-outline relative size-12 overflow-hidden rounded-2xl"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
<div
class="icon-outline relative size-8 overflow-hidden rounded-[10px]"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
<div
class="icon-outline relative size-4 overflow-hidden rounded-[5px]"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
</div>
</div>
<Button class="w-full !shadow-none" @click="surpriseMe">
<RefreshCwIcon />
{{ formatMessage(messages.surpriseMe) }}
</Button>
<div v-if="visibleRecentConfigs.length" class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">{{ formatMessage(messages.recents) }}</span>
<div class="grid grid-cols-4 gap-3">
<button
v-for="(recentConfig, index) in visibleRecentConfigs"
:key="`${backgroundKey(recentConfig.background)}-${recentConfig.symbol}`"
class="icon-outline relative size-10 cursor-pointer overflow-hidden rounded-xl border-0 p-0 transition-transform hover:scale-105"
:style="backgroundStyle(recentConfig.background)"
:aria-label="`${formatMessage(messages.recents)} ${index + 1}`"
@click="selectRecent(recentConfig)"
>
<img
:src="symbolOption(recentConfig.symbol)?.asset"
alt=""
class="size-full object-cover"
/>
</button>
</div>
</div>
</aside>
<div class="min-w-0 flex-1 overflow-y-auto bg-surface-2">
<section class="border-0 border-b border-solid border-surface-5 p-4">
<h3 class="m-0 mb-3 text-lg font-semibold text-contrast">
{{ formatMessage(messages.background) }}
</h3>
<div class="relative">
<div
class="background-scroll-shadow-left pointer-events-none absolute bottom-0 -left-0.5 top-0 z-10 w-8 bg-surface-2 transition-opacity duration-200"
:class="showLeftBackgroundShadow ? 'opacity-100' : 'opacity-0'"
/>
<div
ref="backgroundScroller"
class="flex w-full select-none gap-2.5 overflow-x-auto overflow-y-hidden pb-2 pr-6"
:class="{ 'cursor-grabbing': draggingBackgrounds }"
@pointerdown="onBackgroundPointerDown"
@pointermove="onBackgroundPointerMove"
@pointerup="finishBackgroundDrag"
@pointercancel="finishBackgroundDrag"
@click.capture="onBackgroundClick"
@wheel.prevent="onBackgroundWheel"
@scroll="updateBackgroundScrollShadows"
>
<button
v-for="option in backgroundOptions"
:key="option.id"
class="icon-option icon-outline relative aspect-square w-[calc((100%_-_3.125rem)/6)] shrink-0 cursor-pointer rounded-[20px] border-0 p-0"
:class="{ 'icon-outline-selected': selectedBackground === option.id }"
:style="backgroundStyle(option.background)"
:aria-label="formatMessage(option.name)"
:aria-pressed="selectedBackground === option.id"
@click="selectedBackground = option.id"
>
<span
v-if="selectedBackground === option.id"
class="absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-full bg-white/80 text-black"
>
<CheckIcon class="size-4" />
</span>
</button>
</div>
<div
class="background-scroll-shadow-right pointer-events-none absolute bottom-0 right-0 top-0 z-10 w-8 bg-surface-2 transition-opacity duration-200"
:class="showRightBackgroundShadow ? 'opacity-100' : 'opacity-0'"
/>
</div>
</section>
<section class="p-4">
<h3 class="m-0 mb-3 text-lg font-semibold text-contrast">
{{ formatMessage(messages.symbol) }}
</h3>
<div class="grid grid-cols-6 gap-2.5">
<template v-for="(option, index) in symbolOptions" :key="option.id">
<hr
v-if="index === vanillaSymbolStartIndex"
class="col-span-6 my-2.5 mx-1 w-full border-0 border-t border-solid border-surface-5"
/>
<button
v-tooltip="{
content: formatMessage(option.name),
delay: { show: 500, hide: 0 },
}"
class="icon-option icon-outline relative aspect-square cursor-pointer overflow-hidden rounded-[20px] border-0 bg-transparent p-0"
:class="{ 'icon-outline-selected': selectedSymbol === option.id }"
:aria-label="formatMessage(option.name)"
:aria-pressed="selectedSymbol === option.id"
@click="selectedSymbol = option.id"
>
<img :src="option.asset" alt="" class="size-full object-cover" />
<span
v-if="selectedSymbol === option.id"
class="absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-full bg-white/80 text-black"
>
<CheckIcon class="size-4" />
</span>
</button>
</template>
</div>
</section>
</div>
</div>
<template #actions>
<div class="flex items-center justify-between gap-4 px-2">
<div class="flex min-w-0 items-center gap-2 text-primary">
<InfoIcon class="size-6 shrink-0 text-blue" />
<span>{{ formatMessage(messages.description) }}</span>
</div>
<div class="flex shrink-0 items-center gap-2">
<Button :disabled="saving" type="outlined" @click="hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="brand" :disabled="saving" @click="saveIcon">
<SpinnerIcon v-if="saving" class="animate-spin" />
<SaveIcon v-else />
{{ formatMessage(messages.saveIcon) }}
</Button>
</div>
</div>
</template>
</NewModal>
</template>
<style scoped>
.icon-outline {
outline: 1px solid color-mix(in srgb, var(--color-text-primary) 15%, transparent);
outline-offset: -1px;
}
.icon-option:not(.icon-outline-selected):hover {
outline-color: color-mix(in srgb, var(--color-text-primary) 30%, transparent);
}
.icon-option {
transition: outline-color 150ms ease;
}
.cursor-grabbing,
.cursor-grabbing * {
cursor: grabbing !important;
}
.icon-outline-selected {
outline-color: color-mix(in srgb, var(--color-text-primary) 60%, transparent);
}
.background-scroll-shadow-left {
-webkit-mask-image: linear-gradient(to right, black, transparent);
mask-image: linear-gradient(to right, black, transparent);
}
.background-scroll-shadow-right {
-webkit-mask-image: linear-gradient(to left, black, transparent);
mask-image: linear-gradient(to left, black, transparent);
}
</style>
@@ -1,206 +0,0 @@
<template>
<FloatingActionBar
:shown="selectedInstanceCount > 0"
:aria-label="formatMessage(messages.ariaLabel)"
hide-when-modal-open
>
<div class="flex items-center gap-0.5">
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
{{ formatMessage(messages.selectedCount, { count: selectedLibraryInstances.size }) }}
</span>
<div class="mx-1 h-6 w-px bg-surface-5" />
<Button
type="quiet"
class="!text-primary"
:disabled="busy"
@click="clearLibraryInstanceSelection"
>
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
</Button>
</div>
<div class="ml-auto flex items-center gap-0.5">
<Button
v-if="displayState.group === 'Group'"
type="quiet"
:disabled="busy"
@click="createGroupFromSelection"
>
<SquarePlusIcon />
<span class="bar-label">{{ formatMessage(messages.newGroup) }}</span>
</Button>
<Button
v-if="selectedGroupedInstances.length > 0"
type="quiet"
:disabled="busy"
@click="removeSelectedInstancesFromGroups"
>
<MinusIcon />
<span class="bar-label">{{ formatMessage(messages.removeFromGroup) }}</span>
</Button>
<div class="mx-1 h-6 w-px bg-surface-5" />
<Button
v-tooltip="deleting ? formatMessage(messages.deleting) : undefined"
type="quiet"
color="red"
interaction="filled"
:disabled="busy"
@click="confirmDeleteModal?.show()"
>
<TrashIcon />
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
</Button>
</div>
</FloatingActionBar>
<ConfirmDeleteInstanceModal
ref="confirmDeleteModal"
:instances="selectedInstances"
@delete="deleteSelectedInstances"
/>
</template>
<script setup lang="ts">
import { MinusIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
FloatingActionBar,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { getLibraryInstanceSelectionKey, useLibrary } from '@/components/ui/library/use-library'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { toError } from '@/helpers/errors'
import { remove } from '@/helpers/instance'
import { set_group_memberships as setInstanceGroupMemberships } from '@/helpers/instance-groups'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const {
instances,
selectedLibraryInstances,
clearLibraryInstanceSelection,
setSelectedLibraryInstances,
creatingGroup,
createDefaultGroup,
displayState,
} = useLibrary()
const confirmDeleteModal = ref<InstanceType<typeof ConfirmDeleteInstanceModal>>()
const deleting = ref(false)
const removingFromGroup = ref(false)
const selectedInstanceCount = computed(() => selectedLibraryInstances.value.size)
const selectedInstanceIds = computed(
() =>
new Set([...selectedLibraryInstances.value.values()].map((selection) => selection.instanceId)),
)
const selectedInstances = computed(() =>
instances.value.filter((instance) => selectedInstanceIds.value.has(instance.id)),
)
const selectedGroupedInstances = computed(() =>
[...selectedLibraryInstances.value.values()].flatMap((selection) => {
const instance = instances.value.find((candidate) => candidate.id === selection.instanceId)
return instance?.group_ids.includes(selection.groupId) ? [{ instance, selection }] : []
}),
)
const busy = computed(() => deleting.value || removingFromGroup.value || creatingGroup.value)
const messages = defineMessages({
ariaLabel: {
id: 'app.library.selection.aria-label',
defaultMessage: 'Selected instances',
},
selectedCount: {
id: 'app.library.selection.selected-count',
defaultMessage: '{count} selected',
},
deleting: {
id: 'app.library.selection.deleting',
defaultMessage: 'Deleting selected instances',
},
newGroup: {
id: 'app.library.selection.new-group',
defaultMessage: 'New group',
},
removeFromGroup: {
id: 'app.library.selection.remove-from-group',
defaultMessage: 'Remove from group',
},
})
async function createGroupFromSelection() {
if (busy.value) return
const instanceIds = selectedInstanceIds.value
clearLibraryInstanceSelection()
await createDefaultGroup(instanceIds)
}
async function removeSelectedInstancesFromGroups() {
if (busy.value || selectedGroupedInstances.value.length === 0) return
removingFromGroup.value = true
const selectedGroupIdsByInstanceId = new Map<string, Set<string>>()
for (const { instance, selection } of selectedGroupedInstances.value) {
const groupIds = selectedGroupIdsByInstanceId.get(instance.id) ?? new Set()
groupIds.add(selection.groupId)
selectedGroupIdsByInstanceId.set(instance.id, groupIds)
}
const operations = [...selectedGroupIdsByInstanceId].flatMap(([instanceId, groupIds]) => {
const instance = instances.value.find((candidate) => candidate.id === instanceId)
return instance ? [{ instance, groupIds }] : []
})
try {
await setInstanceGroupMemberships(
operations.map(({ instance, groupIds }) => ({
instance_id: instance.id,
group_ids: instance.group_ids.filter((groupId) => !groupIds.has(groupId)),
})),
)
const nextSelectedInstances = new Map(selectedLibraryInstances.value)
for (const { instance, groupIds } of operations) {
for (const groupId of groupIds) {
nextSelectedInstances.delete(
getLibraryInstanceSelectionKey({
instanceId: instance.id,
groupId,
}),
)
}
}
setSelectedLibraryInstances(nextSelectedInstances.values())
} catch (error) {
handleError(toError(error))
} finally {
removingFromGroup.value = false
}
}
async function deleteSelectedInstances() {
if (busy.value || selectedInstanceCount.value === 0) return
deleting.value = true
const instanceIds = [...selectedInstanceIds.value]
const results = await Promise.allSettled(instanceIds.map((instanceId) => remove(instanceId)))
const deletedInstanceIds = new Set<string>()
for (const [index, result] of results.entries()) {
if (result.status === 'rejected') {
handleError(toError(result.reason))
} else {
deletedInstanceIds.add(instanceIds[index])
}
}
setSelectedLibraryInstances(
[...selectedLibraryInstances.value.values()].filter(
(selection) => !deletedInstanceIds.has(selection.instanceId),
),
)
deleting.value = false
}
</script>

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