Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a560c64c7 | ||
|
|
98aadb1f6a | ||
|
|
a9b774005b | ||
|
|
5d47594302 | ||
|
|
8a0d683ae2 | ||
|
|
ffd864a9e2 | ||
|
|
263aaedc8f | ||
|
|
d3b5ba2567 | ||
|
|
7c67cca7a7 | ||
|
|
2bd108c278 | ||
|
|
25fb06ce27 | ||
|
|
e0cd019869 | ||
|
|
b68a199814 | ||
|
|
3439a43880 | ||
|
|
c88efd48af | ||
|
|
529c6f14dd | ||
|
|
c3c6760818 | ||
|
|
36f9dbb47a | ||
|
|
715f4954cf | ||
|
|
ffdf7ff745 | ||
|
|
a921cfdb7a | ||
|
|
1007b7b69e | ||
|
|
c28ec83060 | ||
|
|
7701aef048 | ||
|
|
7420560784 | ||
|
|
e33ef5f252 | ||
|
|
bb35950340 | ||
|
|
c67f980c4a | ||
|
|
80607c1963 | ||
|
|
ad83117ebf | ||
|
|
4b5edb9a99 | ||
|
|
e753801e0c | ||
|
|
aed00aab13 | ||
|
|
8d7e491126 | ||
|
|
fb39dabe43 | ||
|
|
bc50ed5f65 | ||
|
|
fe9ae94a5d | ||
|
|
871a161327 | ||
|
|
797ec2ec3c | ||
|
|
237963bb35 | ||
|
|
ba667921d1 | ||
|
|
56dba8ccc2 | ||
|
|
82d0184a85 | ||
|
|
5f9522b5cf | ||
|
|
75d2109be8 | ||
|
|
7e611c7678 | ||
|
|
0bd341371a | ||
|
|
32a43f2a22 | ||
|
|
2909aa49ca | ||
|
|
816830eea3 | ||
|
|
087752312b | ||
|
|
bcd7133251 | ||
|
|
a85b8eed3f | ||
|
|
52e055b20f | ||
|
|
5181370293 | ||
|
|
5d54cfa0dc | ||
|
|
5eb64cea3e | ||
|
|
6670e3184e | ||
|
|
12928edbcb | ||
|
|
c94034f4e2 | ||
|
|
8eb12d5d24 | ||
|
|
6a92ab0c2c | ||
|
|
d74211e827 | ||
|
|
49628a0ebc | ||
|
|
5b3177d27a | ||
|
|
a6043fc51f | ||
|
|
f08d977aff | ||
|
|
bee0c14b31 | ||
|
|
f39a602be3 | ||
|
|
3e07267e10 | ||
|
|
79fcf41316 | ||
|
|
10b1d7002a | ||
|
|
906ead762d | ||
|
|
e568efa2a1 | ||
|
|
c8816418ea | ||
|
|
12540a5fd7 | ||
|
|
fd7794c807 | ||
|
|
b18081d962 | ||
|
|
e79e0d024b | ||
|
|
d5556271f9 | ||
|
|
1b341a8e07 | ||
|
|
bac2a55bb7 | ||
|
|
2f01c29b7d | ||
|
|
04b1d70823 | ||
|
|
1c02e2fc50 | ||
|
|
d0f85edcfa | ||
|
|
39c0951ecb | ||
|
|
46d07163cd | ||
|
|
b3b0b85691 | ||
|
|
248c7bb217 | ||
|
|
65df8b90b8 | ||
|
|
377ebfe746 | ||
|
|
14b2943dbe | ||
|
|
5b16a774f9 | ||
|
|
b0ec93f8ab | ||
|
|
2070428080 | ||
|
|
9582c1a6fd | ||
|
|
4897c2da94 | ||
|
|
457254f210 | ||
|
|
283f38c936 | ||
|
|
1dba3bfee8 | ||
|
|
37a5e14657 | ||
|
|
cc66d80165 | ||
|
|
9628b4c269 | ||
|
|
99683f570a | ||
|
|
762d9fe4f4 | ||
|
|
f948b5d358 | ||
|
|
4dbea80e65 | ||
|
|
74c672df49 | ||
|
|
3597bb4821 | ||
|
|
cf50e28dda | ||
|
|
4ca48e4589 | ||
|
|
17f13097f2 | ||
|
|
a9de420d57 | ||
|
|
ab7359a4c5 | ||
|
|
97b5138672 | ||
|
|
579109a478 | ||
|
|
5f7493491f | ||
|
|
755825b09a | ||
|
|
06b7c44dcc | ||
|
|
e087e571f3 | ||
|
|
fbdde8f3d8 | ||
|
|
290bd2b934 | ||
|
|
9ecbe8eaa6 | ||
|
|
549b333eb2 | ||
|
|
4b82cca3bf | ||
|
|
734fd9cdb3 | ||
|
|
2fd4495104 | ||
|
|
552bc3f739 | ||
|
|
4758cabbf9 | ||
|
|
6ec979a076 | ||
|
|
c34bd26f87 | ||
|
|
2a43792fd9 | ||
|
|
2d567b93ce | ||
|
|
26e05ee9e5 | ||
|
|
ca8c5a717f | ||
|
|
759f67a551 | ||
|
|
66628fd69d | ||
|
|
1ffcd67562 | ||
|
|
fda5c62bc6 | ||
|
|
f8f05ce91b | ||
|
|
8b2438aebb | ||
|
|
07bac8ffde | ||
|
|
d0856d46f2 | ||
|
|
e8aee15664 | ||
|
|
98cb6b6b44 | ||
|
|
f27387462e | ||
|
|
ef90f08813 | ||
|
|
9fd45ef2ce | ||
|
|
92971a3e0d | ||
|
|
357224f22a | ||
|
|
e2612d1a33 | ||
|
|
318840e403 |
@@ -0,0 +1,23 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,4 @@
|
||||
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."
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,4 @@
|
||||
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."
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,4 @@
|
||||
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."
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,4 @@
|
||||
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."
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Review Changelog"
|
||||
short_description: "Review changelog entries for style problems"
|
||||
default_prompt: "Use $review-changelog to review the latest changelog entry."
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Migrate to TanStack Query"
|
||||
short_description: "Migrate Vue server state to TanStack Query"
|
||||
default_prompt: "Use $tanstack-query to migrate this Vue component to TanStack Query."
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: api-module
|
||||
description: Add a new API endpoint module to packages/api-client from an OpenAPI schema. Use when adding new backend endpoints, creating API client modules, or when an openapi.yml is provided.
|
||||
argument-hint: <path-to-openapi.yml>
|
||||
---
|
||||
|
||||
Refer to the standard: @standards/frontend/ADDING_API_MODULES.md
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Read the OpenAPI schema** at `$ARGUMENTS` — identify the endpoints, request/response shapes, and path parameters.
|
||||
2. **Read the standard above** for naming conventions, type rules, and the module registration pattern.
|
||||
3. **Determine the service and version** — the URL path prefix tells you which service directory and version namespace to use (e.g. `/v3/projects` → `labrinth/v3/`).
|
||||
4. **Define types in `types.ts`** — types must match the API response 1:1. Use the OpenAPI schema as the source of truth. Do not reshape or rename fields.
|
||||
5. **Create the module class** — extend `BaseModule`, implement each endpoint as a method. Use the correct HTTP verb and request options pattern from the standard.
|
||||
6. **Register in `MODULE_REGISTRY`** — add the module entry so it's auto-instantiated on the client.
|
||||
7. **Export types** from the service's barrel `index.ts`.
|
||||
8. **Verify** — check that the module compiles and the types are accessible from `@modrinth/api-client`.
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
name: cross-platform-pages
|
||||
description: Convert a page to the cross-platform page system so it works in both the website and the desktop app. Use when moving a page into packages/ui/src/layouts/, creating shared or wrapped layouts, or setting up DI contracts for platform abstraction.
|
||||
argument-hint: <path-to-page>
|
||||
---
|
||||
|
||||
Refer to the standards: @standards/frontend/CROSS_PLATFORM_PAGES.md and @standards/frontend/DEPENDENCY_INJECTION.md
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Read the target page** at `$ARGUMENTS` and understand its data sources, mutations, and navigation.
|
||||
2. **Read the standards above** to understand the shared vs wrapped distinction and the DI pattern.
|
||||
3. **Decide the category:**
|
||||
- **Wrapped** (`layouts/wrapped/`) — if the page uses the same API source on both platforms (e.g. web requests, not Tauri plugins). Just move the page component into `packages/ui` and import it from both frontends.
|
||||
- **Shared** (`layouts/shared/`) — if the page has different data-fetching logic per platform (e.g. website uses `api-client`, app uses Tauri `invoke`). Requires a DI contract.
|
||||
4. **For shared layouts:**
|
||||
- Define a DI contract interface in `providers/` capturing all platform-specific operations.
|
||||
- Create the layout component that injects the context and handles all UI logic.
|
||||
- Extract reusable stateful logic (search, filtering, selection) into `composables/`.
|
||||
- Implement the contract separately in each frontend (`apps/frontend/`, `apps/app-frontend/`).
|
||||
5. **For wrapped pages:**
|
||||
- Move the page component into `packages/ui/src/layouts/wrapped/` matching the route structure.
|
||||
- Replace any platform-specific imports with shared utilities.
|
||||
- Import and render the wrapped page from both frontends as a simple component.
|
||||
- If the layout uses TanStack Query for initial route paint with `ReadyTransition` / `useReadyState`, each platform route shell must call `ensureQueryData` for those queries with matching keys and fetchers — see **Platform route shells: prefetch with `ensureQueryData`** in `standards/frontend/CROSS_PLATFORM_PAGES.md`.
|
||||
6. **Verify** the page renders correctly by checking for missing imports and that all DI contracts are satisfied.
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
name: figma-mcp
|
||||
description: Use the Figma MCP server to translate a Figma design into a Vue page or component layout. Use when the user provides a Figma URL, asks to implement a design, or wants to draft a page layout from Figma.
|
||||
argument-hint: <figma-url>
|
||||
---
|
||||
|
||||
Refer to the standard: @standards/frontend/FIGMA_MCP_USAGE.md
|
||||
Also read @packages/ui/CLAUDE.md for color token mapping and component conventions.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Parse the Figma URL** from `$ARGUMENTS` — extract the `fileKey` and `nodeId`. Convert `-` to `:` in the node ID.
|
||||
2. **Read the standards above** for the available tools, adaptation rules, and color usage.
|
||||
3. **Call `get_design_context`** with the extracted `nodeId` and `fileKey`, using `clientLanguages: "typescript,html,css"` and `clientFrameworks: "vue"`. This is always the first tool to call.
|
||||
5. **Adapt the output to the Modrinth codebase:**
|
||||
- Map Figma color variables to `surface-*` / `text-*` tokens — never use Figma's aliased names directly.
|
||||
- Check `packages/ui/src/components/` for existing components that match elements in the design (buttons, cards, modals, inputs, etc.).
|
||||
- Check `packages/assets/styles/variables.scss` for tokens not exposed in Figma.
|
||||
- Match spacing values exactly from the design.
|
||||
6. **Use `get_screenshot`** if you need a closer visual reference of specific nodes.
|
||||
7. **Use `get_variable_defs`** to verify which design tokens are applied to ambiguous elements.
|
||||
8. **Build the component** as a Vue SFC using Tailwind classes and the project's existing component library.
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: i18n-pass
|
||||
description: Perform an i18n localization pass on changed files or a pull request, converting hard-coded English strings to the @modrinth/ui i18n system. Use when internationalizing a set of changes, reviewing a PR for untranslated strings, or converting a specific component.
|
||||
argument-hint: [file-path-or-pr-number]
|
||||
---
|
||||
|
||||
Refer to the standard: @standards/frontend/INTERNATIONALIZATION.md
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Identify the scope of changes:**
|
||||
- If `$ARGUMENTS` is a PR number, run `gh pr diff $ARGUMENTS` to get the changed files.
|
||||
- If `$ARGUMENTS` is a file path, use that directly.
|
||||
- If no argument, check `git diff` for uncommitted changes.
|
||||
2. **Read the standard above** for the message definition pattern, ICU format rules, and `IntlFormatted` usage.
|
||||
3. **Filter to Vue SFCs** — only `.vue` files need i18n passes. Skip non-component files.
|
||||
4. **For each file, scan for hard-coded strings:**
|
||||
- `<template>`: inner text, `alt`, `placeholder`, `aria-label`, button labels, tooltip text.
|
||||
- `<script>`: string literals passed to user-visible UI (notification messages, dropdown labels, error messages).
|
||||
- Skip: dynamic expressions, HTML tag names, CSS classes, internal identifiers, log messages.
|
||||
5. **Define messages** with `defineMessages` — use descriptive, stable `id`s based on the component's domain (e.g. `project.settings.title`).
|
||||
6. **Replace strings in templates** with `formatMessage()` calls, or `<IntlFormatted>` for strings containing links or markup.
|
||||
7. **Handle ICU edge cases** — add a space before `}}` if an ICU placeholder ends at a Vue template delimiter boundary.
|
||||
8. **Verify** no hard-coded English strings remain in the changed templates. Do not alter logic, layout, or reactivity.
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
name: review-changelog
|
||||
description: Review the latest changelog entry in packages/blog/changelog.ts against the project's changelog style guide and flag bullets that need rewriting. Use when checking a freshly added changelog entry before opening a PR, or when the user asks to review/lint the latest changelog.
|
||||
argument-hint: [product?]
|
||||
---
|
||||
|
||||
Refer to the standard: @standards/maintaining/CHANGELOG.md
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Locate the latest entry:**
|
||||
- Open `packages/blog/changelog.ts`.
|
||||
- The latest entries are at the top of the `VERSIONS` array.
|
||||
- If `$ARGUMENTS` specifies a product (`web`, `hosting`, `app`), review the most recent entry for that product. Otherwise, review the most recent entry overall, plus any sibling entries sharing the same `date` (coordinated releases ship together).
|
||||
|
||||
2. **Read the standard above** in full before reviewing. The bullet rules, section/verb agreement, and "Don't" list are the source of truth.
|
||||
|
||||
3. **Check the entry shell:**
|
||||
- `date` is a valid ISO 8601 timestamp.
|
||||
- `product` is one of `web`, `hosting`, `app`.
|
||||
- `version` is present for `app` entries and omitted for `web`/`hosting`.
|
||||
- Section headings use `## Added`, `## Changed`, `## Fixed`, `## Security` (or a featured-release linked heading). Flag legacy `## Improvements`.
|
||||
|
||||
4. **Review each bullet** against the standard. For each bullet, check:
|
||||
- Voice/tense matches the section heading.
|
||||
- Opening verb agrees with its section.
|
||||
- Describes observable behavior, not implementation.
|
||||
- Specific enough to identify the surface (names the tab/page/modal).
|
||||
- One sentence, ends with a period, sentence case.
|
||||
- Uses branded names (Modrinth App, Modrinth Hosting) correctly.
|
||||
- No filler ("issue with", "issue where", "various", "some"), no vague intensifiers, no apologies, no PR/commit references (unless crediting a third-party contributor with a linked GitHub profile).
|
||||
- Not a duplicate sub-fix of a bigger change already listed.
|
||||
|
||||
5. **Report findings** as a short list grouped by entry. For each problem bullet, show the original line and a suggested rewrite. If the entry is clean, say so explicitly. Do not edit the file unless the user asks - this skill is a review pass, not a rewrite pass.
|
||||
|
||||
6. **If the user then asks to apply fixes**, edit `packages/blog/changelog.ts` directly using the suggested rewrites. Preserve tab indentation and template literal formatting.
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: tanstack-query
|
||||
description: Convert a page or component from useAsyncData/manual ref patterns to TanStack Query for server state management. Use when migrating data fetching to useQuery/useMutation, adding cache invalidation, or replacing useAsyncData with TanStack Query.
|
||||
argument-hint: <path-to-file>
|
||||
---
|
||||
|
||||
Refer to the standard: @standards/frontend/FETCHING_DATA.md
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Read the target file** at `$ARGUMENTS` and identify all data-fetching patterns: `useAsyncData`, `useFetch`, manual `ref()` + `await`, or `onMounted` fetch calls.
|
||||
2. **Read the standard above** for the query/mutation patterns, query key conventions, and optimistic update approach.
|
||||
3. **Convert queries:**
|
||||
- Replace `useAsyncData` / `useFetch` / manual fetches with `useQuery`.
|
||||
- Use the `api-client` via `injectModrinthClient()` for the `queryFn`.
|
||||
- Design query keys with the `['resource', 'version', ...params]` convention.
|
||||
- Use `computed` query keys for reactive parameters.
|
||||
- Use the `enabled` option for conditional queries that depend on other data.
|
||||
4. **Convert mutations:**
|
||||
- Replace manual `try/catch` + `ref` patterns with `useMutation`.
|
||||
- Add `onSuccess` handlers that invalidate or update related query caches.
|
||||
- Consider optimistic updates for UI-critical mutations (follow the pattern in the standard).
|
||||
5. **Clean up:**
|
||||
- Remove manual loading/error `ref()`s that are now handled by TanStack Query's return values (`isPending`, `isError`, `error`).
|
||||
- Remove manual `onMounted` fetch calls.
|
||||
- Ensure SSR compatibility — queries in Nuxt pages are automatically awaited during SSR.
|
||||
6. **Verify** the page still renders correctly and that cache invalidation triggers re-fetches where expected.
|
||||
@@ -14,12 +14,14 @@ 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: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const marker = '<!-- changelog -->';
|
||||
const mergedMarker = '<!-- changelog-merged -->';
|
||||
|
||||
@@ -20,8 +20,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
|
||||
- uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7
|
||||
with:
|
||||
tool: tombi
|
||||
tool: tombi@1.4.1
|
||||
- run: tombi lint
|
||||
- run: tombi fmt --check
|
||||
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- 'packages/ui/**/*'
|
||||
- 'packages/utils/**/*'
|
||||
- 'packages/assets/**/*'
|
||||
- 'packages/moderation/**/*'
|
||||
- '**/wrangler.jsonc'
|
||||
- '**/pnpm-*.yaml'
|
||||
- '.github/workflows/frontend-deploy.yml'
|
||||
@@ -124,8 +125,7 @@ jobs:
|
||||
run: pnpm install
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: ./apps/frontend
|
||||
run: pnpm build
|
||||
run: pnpm web:build
|
||||
env:
|
||||
CF_PAGES_BRANCH: ${{ inputs.head_ref || github.ref_name }}
|
||||
CF_PAGES_COMMIT_SHA: ${{ inputs.head_sha || github.sha }}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Frontend Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- prod
|
||||
paths:
|
||||
- 'apps/frontend/**/*'
|
||||
- 'packages/ui/**/*'
|
||||
- 'packages/utils/**/*'
|
||||
- 'packages/assets/**/*'
|
||||
- 'packages/moderation/**/*'
|
||||
- 'packages/api-client/**/*'
|
||||
- 'packages/blog/**/*'
|
||||
- '**/pnpm-*.yaml'
|
||||
- .github/workflows/frontend-docker.yml
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build frontend
|
||||
runs-on: namespace-profile-modrinth-frontend
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Configure environment
|
||||
id: meta
|
||||
env:
|
||||
REF: ${{ github.ref }}
|
||||
run: |
|
||||
if [ "$REF" = "refs/heads/main" ]; then
|
||||
echo "env=staging" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "env=production" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Set up caches
|
||||
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
|
||||
with:
|
||||
cache: |
|
||||
pnpm
|
||||
|
||||
- name: Inject build variables
|
||||
working-directory: ./apps/frontend
|
||||
run: |
|
||||
if [ "${{ steps.meta.outputs.env }}" == "staging" ]; then
|
||||
echo "Injecting staging variables from wrangler.jsonc..."
|
||||
jq -r '.env.staging.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV
|
||||
else
|
||||
echo "Injecting production variables from wrangler.jsonc..."
|
||||
jq -r '.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./apps/frontend
|
||||
run: pnpm install
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm web:build
|
||||
env:
|
||||
NITRO_PRESET: node-server
|
||||
BUILD_ENV: ${{ steps.meta.outputs.env }}
|
||||
CF_PAGES_BRANCH: ${{ github.ref_name }}
|
||||
CF_PAGES_COMMIT_SHA: ${{ github.sha }}
|
||||
|
||||
- name: Stage Docker context
|
||||
run: |
|
||||
mkdir -p apps/frontend/docker-stage
|
||||
cp -r apps/frontend/.output apps/frontend/docker-stage/.output
|
||||
|
||||
- name: Upload Docker context
|
||||
uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3
|
||||
with:
|
||||
name: frontend-docker-context
|
||||
retention-days: 1
|
||||
path: apps/frontend/docker-stage
|
||||
|
||||
docker-build:
|
||||
needs: [build]
|
||||
uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main
|
||||
with:
|
||||
image-name: frontend
|
||||
dockerfile-path: apps/frontend/Dockerfile
|
||||
artifacts-name: frontend-docker-context
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- 'packages/ui/**/*'
|
||||
- 'packages/utils/**/*'
|
||||
- 'packages/assets/**/*'
|
||||
- 'packages/moderation/**/*'
|
||||
- '**/wrangler.jsonc'
|
||||
- '**/pnpm-*.yaml'
|
||||
- '.github/workflows/frontend-preview.yml'
|
||||
@@ -69,10 +70,9 @@ jobs:
|
||||
run: pnpm install
|
||||
|
||||
- name: Build Storybook
|
||||
working-directory: ./packages/ui
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
run: pnpm run build-storybook
|
||||
run: pnpm build-storybook
|
||||
|
||||
- name: Configure short SHA
|
||||
id: meta
|
||||
@@ -117,6 +117,8 @@ 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
|
||||
@@ -157,7 +159,7 @@ jobs:
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
id: fc
|
||||
with:
|
||||
token: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
token: ${{ github.token }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body-includes: Frontend previews
|
||||
|
||||
@@ -165,7 +167,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
with:
|
||||
token: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
token: ${{ github.token }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
body: |
|
||||
|
||||
@@ -8,6 +8,10 @@ on:
|
||||
concurrency:
|
||||
group: i18n-management
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pull_translations:
|
||||
name: 'Pull translations from Crowdin'
|
||||
@@ -38,23 +42,17 @@ jobs:
|
||||
flight_failure "CROWDIN_PERSONAL_TOKEN secret is not defined (required to push)"
|
||||
fi
|
||||
|
||||
if [ "$CROWDIN_GH_TOKEN_DEFINED" != true ]; then
|
||||
flight_failure "CROWDIN_GH_TOKEN secret is not defined (required to make pull requests)"
|
||||
fi
|
||||
|
||||
if [ "$PREFLIGHT_CHECK_RESULT" = false ]; then
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
CROWDIN_PROJECT_ID_DEFINED: ${{ vars.CROWDIN_PROJECT_ID != '' }}
|
||||
CROWDIN_PERSONAL_TOKEN_DEFINED: ${{ secrets.CROWDIN_PERSONAL_TOKEN != '' }}
|
||||
CROWDIN_GH_TOKEN_DEFINED: ${{ secrets.CROWDIN_GH_TOKEN != '' }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -67,12 +65,6 @@ jobs:
|
||||
- name: Install script dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Configure Git author
|
||||
id: git-author
|
||||
uses: MarcoIeni/git-config@59144859caf016f8b817a2ac9b051578729173c4 # v0.1.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
|
||||
# # Because --all flag of Crowdin CLI is currently broken we need to create a fake source file
|
||||
# # so that the CLI won't omit translations for it. See https://github.com/crowdin/crowdin-cli/issues/724
|
||||
# - name: Write fake sources
|
||||
@@ -116,7 +108,4 @@ jobs:
|
||||
body-path: .github/templates/crowdin-pr.md
|
||||
commit-message: 'New translations from Crowdin (${{ steps.branch-name.outputs.branch_name }})'
|
||||
branch: crowdin-pull/${{ steps.branch-name.outputs.branch_name }}
|
||||
author: '${{ steps.git-author.outputs.name }} <${{ steps.git-author.outputs.email }}>'
|
||||
committer: '${{ steps.git-author.outputs.name }} <${{ steps.git-author.outputs.email }}>'
|
||||
labels: sync
|
||||
token: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
|
||||
@@ -147,7 +147,7 @@ jobs:
|
||||
|
||||
deploy:
|
||||
needs: [skip-if-clean, docker-build]
|
||||
if: ${{ needs.skip-if-clean.outputs.internal == 'true' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/prod') }}
|
||||
if: ${{ needs.skip-if-clean.outputs.internal == 'true' && github.ref == 'refs/heads/prod' }}
|
||||
uses: SparkUniverse/workflows/.github/workflows/argo-update.yaml@main
|
||||
secrets:
|
||||
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
|
||||
|
||||
@@ -55,18 +55,26 @@ Thumbs.db
|
||||
|
||||
# frontend generated files
|
||||
apps/frontend/src/generated
|
||||
/packages/ui/src/layouts/wrapped/settings/language-settings/language-settings-coverage.generated.ts
|
||||
|
||||
.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/skills/
|
||||
.claude/
|
||||
.letta
|
||||
|
||||
# labrinth demo fixtures
|
||||
|
||||
@@ -17,6 +17,18 @@
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# 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.
|
||||
@@ -1,110 +0,0 @@
|
||||
# Modrinth Monorepo
|
||||
|
||||
This is the Modrinth monorepo — it contains all Modrinth projects, both frontend and backend. When entering a project, either to edit or analyse, you should read it's CLAUDE.md.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Monorepo tooling:** [Turborepo](https://turbo.build/) (`turbo.jsonc`) + [pnpm workspaces](https://pnpm.io/workspaces) (`pnpm-workspace.yaml`)
|
||||
- **Frontend:** Vue 3 / Nuxt 3, Tailwind CSS v3
|
||||
- **Backend:** Rust (Labrinth API), Postgres, Clickhouse
|
||||
- **Indentation:** Use TAB everywhere, never spaces
|
||||
|
||||
### Apps (`apps/`)
|
||||
|
||||
| App | Description |
|
||||
| ----------------- | ------------------------------ |
|
||||
| `frontend` | Main Modrinth website (Nuxt 3) |
|
||||
| `app-frontend` | Desktop/app frontend (Vue 3) |
|
||||
| `app` | Desktop/app shell (Tauri) |
|
||||
| `app-playground` | Testing playground for app |
|
||||
| `labrinth` | Backend API service |
|
||||
| `daedalus_client` | Daedalus client implementation |
|
||||
| `docs` | Documentation site (Astro) |
|
||||
|
||||
### Packages (`packages/`)
|
||||
|
||||
| Package | Description |
|
||||
| ------------------ | ----------------------------------------------------- |
|
||||
| `ui` | Shared Vue component library (`@modrinth/ui`) |
|
||||
| `assets` | Styling and auto-generated icons (`@modrinth/assets`) |
|
||||
| `api-client` | API client for Nuxt, Tauri, and Node/browser |
|
||||
| `app-lib` | Shared app library |
|
||||
| `blog` | Blog system and changelog data |
|
||||
| `utils` | Shared utility functions (mostly deprecated) |
|
||||
| `moderation` | Moderation utilities |
|
||||
| `daedalus` | Daedalus protocol |
|
||||
| `tooling-config` | ESLint, Prettier, TypeScript configs |
|
||||
| `ariadne` | Analytics library |
|
||||
| `modrinth-log` | Logging utilities |
|
||||
| `modrinth-maxmind` | MaxMind GeoIP |
|
||||
| `modrinth-util` | General utilities |
|
||||
| `muralpay` | Payment processing |
|
||||
| `path-util` | Path utilities |
|
||||
| `sqlx-tracing` | SQLx query tracing |
|
||||
|
||||
## Pre-PR Commands
|
||||
|
||||
Run these from the **root** folder before opening a pull request - do not run these after each prompt the user gives you, only run when asked, ask the user a question if they want to run it if the user indicates that they are about to create a pull request.
|
||||
|
||||
- **Website:** `pnpm prepr:frontend:web`
|
||||
- **App frontend:** `pnpm prepr:frontend:app`
|
||||
- **Frontend libs:** `pnpm prepr:frontend:lib`
|
||||
- **All frontend (app+web):** `pnpm prepr`
|
||||
- **Labrinth (backend):** See `apps/labrinth/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.
|
||||
@@ -2116,7 +2116,7 @@ checksum = "ff6669899e23cb87b43daf7996f0ea3b9c07d0fb933d745bb7b815b052515ae3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"serde_derive_internals 0.29.1",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
@@ -2223,6 +2223,16 @@ 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"
|
||||
@@ -2340,6 +2350,15 @@ 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"
|
||||
@@ -2657,10 +2676,10 @@ dependencies = [
|
||||
"futures",
|
||||
"indexmap 2.11.4",
|
||||
"itertools 0.14.0",
|
||||
"quick-xml 0.38.3",
|
||||
"reqwest 0.12.24",
|
||||
"rust-s3",
|
||||
"serde",
|
||||
"serde-xml-rs",
|
||||
"serde_json",
|
||||
"sha1_smol",
|
||||
"thiserror 2.0.17",
|
||||
@@ -3991,6 +4010,28 @@ 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"
|
||||
@@ -5347,6 +5388,16 @@ 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"
|
||||
@@ -5439,6 +5490,7 @@ dependencies = [
|
||||
"clickhouse",
|
||||
"color-eyre",
|
||||
"color-thief",
|
||||
"component-derive",
|
||||
"const_format",
|
||||
"dashmap",
|
||||
"derive_more 2.1.1",
|
||||
@@ -5516,16 +5568,6 @@ 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"
|
||||
@@ -7499,6 +7541,45 @@ 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"
|
||||
@@ -8381,12 +8462,27 @@ 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"
|
||||
@@ -9052,7 +9148,7 @@ checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde_derive_internals",
|
||||
"serde_derive_internals 0.29.1",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
@@ -9295,6 +9391,16 @@ 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"
|
||||
@@ -9327,18 +9433,6 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-xml-rs"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53630160a98edebde0123eb4dfd0fce6adff091b2305db3154a9e920206eb510"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"thiserror 1.0.69",
|
||||
"xml-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_bytes"
|
||||
version = "0.11.19"
|
||||
@@ -9400,6 +9494,17 @@ 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"
|
||||
@@ -9840,6 +9945,9 @@ 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"
|
||||
@@ -10267,6 +10375,17 @@ 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"
|
||||
@@ -10880,6 +10999,15 @@ 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"
|
||||
@@ -10959,6 +11087,7 @@ dependencies = [
|
||||
"image",
|
||||
"indicatif",
|
||||
"itertools 0.14.0",
|
||||
"json5",
|
||||
"modrinth-content-management",
|
||||
"notify",
|
||||
"notify-debouncer-mini",
|
||||
@@ -10968,6 +11097,8 @@ 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",
|
||||
@@ -10975,6 +11106,7 @@ dependencies = [
|
||||
"reqwest 0.12.24",
|
||||
"rgb",
|
||||
"serde",
|
||||
"serde-binhum",
|
||||
"serde_ini",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
@@ -10988,9 +11120,11 @@ 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",
|
||||
@@ -11667,12 +11801,49 @@ 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"
|
||||
@@ -11710,6 +11881,12 @@ 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"
|
||||
|
||||
@@ -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",
|
||||
@@ -42,9 +42,7 @@ async-stripe = { version = "0.41.0", default-features = false, features = [
|
||||
"runtime-tokio-hyper-rustls",
|
||||
] }
|
||||
async-trait = "0.1.89"
|
||||
async-tungstenite = { version = "0.31.0", default-features = false, features = [
|
||||
"futures-03-sink"
|
||||
] }
|
||||
async-tungstenite = { version = "0.31.0", default-features = false, features = ["futures-03-sink"] }
|
||||
async-walkdir = "2.1.0"
|
||||
async_zip = "0.0.18"
|
||||
aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
|
||||
@@ -61,13 +59,12 @@ censor = "0.3.0"
|
||||
chardetng = "0.1.17"
|
||||
chrono = "0.4.42"
|
||||
chumsky = "0.9.3"
|
||||
cidre = { version = "0.15.0", default-features = false, features = [
|
||||
"macos_15_0"
|
||||
] }
|
||||
cidre = { version = "0.15.0", default-features = false, features = ["macos_15_0"] }
|
||||
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"
|
||||
@@ -113,6 +110,7 @@ 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",
|
||||
@@ -148,6 +146,7 @@ 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"
|
||||
@@ -160,10 +159,7 @@ redis = "1.4.1"
|
||||
regex = "1.12.2"
|
||||
reqwest = { version = "0.12.24", default-features = false }
|
||||
rgb = "0.8.52"
|
||||
rust_decimal = { version = "1.39.0", features = [
|
||||
"serde-with-float",
|
||||
"serde-with-str"
|
||||
] }
|
||||
rust_decimal = { version = "1.39.0", features = ["serde-with-float", "serde-with-str"] }
|
||||
rust_iso3166 = "0.1.14"
|
||||
rust-s3 = { version = "0.37.0", default-features = false, features = [
|
||||
"fail-on-err",
|
||||
@@ -189,7 +185,6 @@ serde_cbor = "0.11.2"
|
||||
serde_ini = "0.2.0"
|
||||
serde_json = "1.0.145"
|
||||
serde_with = "3.15.0"
|
||||
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
|
||||
sha1 = "0.10.6"
|
||||
sha1_smol = { version = "1.0.1", features = ["std"] }
|
||||
sha2 = "0.10.9"
|
||||
@@ -224,12 +219,14 @@ 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"
|
||||
|
||||
@@ -6,13 +6,16 @@ extend-exclude = [
|
||||
"packages/utils/",
|
||||
"packages/ui/",
|
||||
"packages/blog/",
|
||||
# contains licenses like `CC-BY-ND-4.0`
|
||||
"packages/moderation/src/data/stages/license.ts",
|
||||
"packages/moderation/src/utils.ts",
|
||||
# contains payment card IDs like `IY1VMST1MOXS` which are flagged
|
||||
"apps/labrinth/src/queue/payouts/mod.rs",
|
||||
# contains domain names with deliberate typos
|
||||
"apps/labrinth/assets/disposable_email_blocklist.txt"
|
||||
]
|
||||
|
||||
[default]
|
||||
extend-ignore-re = [
|
||||
# Ignore lines that end with `// typos:ignore`
|
||||
"(?Rm)^.*(#|//)\\s*typos:ignore$",
|
||||
# Ignore the line after `// typos:ignore`:
|
||||
"(#|//)\\s*typos:ignore\\n.*",
|
||||
# Ignore blocks between `// typos:off` and `// typos:on`
|
||||
"(?s)(#|//)\\s*typos:off.*?\\n\\s*(#|//)\\s*typos:on",
|
||||
]
|
||||
|
||||
[default.extend-words]
|
||||
@@ -24,3 +27,7 @@ gam = "gam"
|
||||
consts = "consts"
|
||||
# short for "Copy"
|
||||
Cpy = "Cpy"
|
||||
|
||||
[default.extend-identifiers]
|
||||
# Constant from the `zip` crate
|
||||
ZIP64_BYTES_THR = "ZIP64_BYTES_THR"
|
||||
|
||||
@@ -2,3 +2,7 @@
|
||||
*.gltf
|
||||
src/locales/
|
||||
src/assets/**/*.svg
|
||||
|
||||
# Generated app-event bindings
|
||||
src/generated/app-events/*.ts
|
||||
src/generated/app-events/postcard/**
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
import config from '@modrinth/tooling-config/eslint/nuxt.mjs'
|
||||
export default config
|
||||
|
||||
export default config.append([
|
||||
{
|
||||
ignores: ['src/generated/app-events/*.ts', 'src/generated/app-events/postcard/**'],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"test": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/vue": "^0.5.0",
|
||||
"@modrinth/api-client": "workspace:^",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
@@ -31,13 +32,13 @@
|
||||
"@tauri-apps/plugin-window-state": "^2.2.2",
|
||||
"@types/three": "^0.172.0",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"ace-builds": "^1.43.5",
|
||||
"dayjs": "^1.11.10",
|
||||
"floating-vue": "^5.2.2",
|
||||
"fuse.js": "^6.6.2",
|
||||
"intl-messageformat": "^10.7.7",
|
||||
"ofetch": "^1.3.4",
|
||||
"overlayscrollbars": "^2.15.1",
|
||||
"pinia": "^3.0.0",
|
||||
"posthog-js": "^1.158.2",
|
||||
"three": "^0.172.0",
|
||||
"vite-svg-loader": "^5.1.0",
|
||||
@@ -45,6 +46,7 @@
|
||||
"vue-i18n": "^10.0.0",
|
||||
"vue-router": "^4.6.0",
|
||||
"vue-virtual-scroller": "v2.0.0-beta.8",
|
||||
"vue3-ace-editor": "^2.2.4",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 334 KiB |
@@ -55,6 +55,10 @@
|
||||
);
|
||||
}
|
||||
|
||||
html.light-mode {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
|
||||
|
After Width: | Height: | Size: 228 KiB |
@@ -1,360 +0,0 @@
|
||||
<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>
|
||||
@@ -1,371 +0,0 @@
|
||||
<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>
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="bg-button-bg pt-1 pb-2 border border-solid border-surface-5">
|
||||
<div class="bg-button-bg pt-1 pb-2 border-0 border-t border-solid border-surface-5">
|
||||
<template v-if="accounts.length > 0">
|
||||
<div v-for="account in accounts" :key="account.profile.id" class="flex gap-1 items-center">
|
||||
<button
|
||||
@@ -104,8 +104,10 @@ import {
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useAppEvent } from '@/composables/use-app-event'
|
||||
import { handleSevereError } from '@/composables/use-error.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import {
|
||||
get_default_user,
|
||||
@@ -114,11 +116,9 @@ 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'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
@@ -185,6 +185,7 @@ defineExpose({
|
||||
refreshValues,
|
||||
setEquippedSkin,
|
||||
setLoginDisabled,
|
||||
login,
|
||||
loginDisabled,
|
||||
})
|
||||
|
||||
@@ -251,16 +252,12 @@ async function logout(id: string) {
|
||||
trackEvent('AccountLogOut')
|
||||
}
|
||||
|
||||
const unlisten = await process_listener(async (e) => {
|
||||
useAppEvent('process', async (e) => {
|
||||
if (e.event === 'launched') {
|
||||
await refreshValues()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlisten()
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
notSignedIn: {
|
||||
id: 'minecraft-account.not-signed-in',
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<template>
|
||||
<div class="flex gap-2 items-center">
|
||||
<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="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>
|
||||
<div v-if="offline" class="flex items-center gap-1">
|
||||
<UnplugIcon class="text-secondary" />
|
||||
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
|
||||
@@ -136,8 +137,8 @@ import {
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
injectPopupNotificationManager,
|
||||
type PopupNotification,
|
||||
type PopupNotificationProgressItem,
|
||||
type PopupNotificationStandard,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
@@ -147,8 +148,9 @@ 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 { loading_listener, process_listener } from '@/helpers/events'
|
||||
import { toError } from '@/helpers/errors'
|
||||
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'
|
||||
@@ -218,8 +220,35 @@ 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>()
|
||||
|
||||
@@ -266,7 +295,7 @@ onMounted(() => {
|
||||
window.addEventListener('online', handleOnline)
|
||||
})
|
||||
|
||||
const unlistenProcess = await process_listener(async () => {
|
||||
useAppEvent('process', async () => {
|
||||
await refresh()
|
||||
})
|
||||
|
||||
@@ -296,6 +325,7 @@ 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 {
|
||||
@@ -323,14 +353,16 @@ function getDisplayIconUrl(icon: string | null | undefined): string | null {
|
||||
return convertFileSrc(icon)
|
||||
}
|
||||
|
||||
function getNotification(): PopupNotification | null {
|
||||
function getNotification(): PopupNotificationStandard | null {
|
||||
if (!notificationId.value) {
|
||||
return null
|
||||
}
|
||||
const notification = popupNotificationManager
|
||||
.getNotifications()
|
||||
.find((notification) => notification.id === notificationId.value)
|
||||
return notification ?? null
|
||||
return notification?.contentType === 'standard' && notification.type === 'download'
|
||||
? notification
|
||||
: null
|
||||
}
|
||||
|
||||
function removeNotification(): void {
|
||||
@@ -341,10 +373,54 @@ 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((bar) => ({
|
||||
...currentLoadingBars.value.map<PopupNotificationProgressItem>((bar) => ({
|
||||
id: getLoadingBarKey(bar),
|
||||
title: bar.title ?? '',
|
||||
text: getLoadingText(bar),
|
||||
@@ -358,12 +434,13 @@ 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
|
||||
}
|
||||
@@ -383,7 +460,7 @@ function updateNotification(resummon = false): void {
|
||||
return
|
||||
}
|
||||
|
||||
let notif = getNotification()
|
||||
const notif = getNotification()
|
||||
const progressItems = buildDownloadItems()
|
||||
|
||||
if (notif) {
|
||||
@@ -392,20 +469,19 @@ 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 {
|
||||
notif = popupNotificationManager.addPopupNotification({
|
||||
const notification = popupNotificationManager.addPopupNotification({
|
||||
contentType: 'standard',
|
||||
title: installJobNotifications.active.value
|
||||
? installJobNotifications.title.value
|
||||
: formatMessage(messages.downloads),
|
||||
type: 'download',
|
||||
autoCloseMs: null,
|
||||
progressItems,
|
||||
buttons: installJobNotifications.buttons.value,
|
||||
})
|
||||
notificationId.value = notif.id
|
||||
notificationId.value = notification.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,13 +558,13 @@ async function refreshLoadingBars() {
|
||||
|
||||
const installJobNotifications = await useInstallJobNotifications({
|
||||
router,
|
||||
handleError,
|
||||
handleError: (error) => handleError(toError(error)),
|
||||
onChange: updateNotification,
|
||||
})
|
||||
|
||||
await refreshLoadingBars()
|
||||
|
||||
const unlistenLoading = await loading_listener(async () => {
|
||||
useAppEvent('loading', async () => {
|
||||
await refreshLoadingBars()
|
||||
})
|
||||
|
||||
@@ -502,11 +578,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>
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-show="shown"
|
||||
ref="contextMenu"
|
||||
class="context-menu"
|
||||
:style="{
|
||||
left: left,
|
||||
top: top,
|
||||
}"
|
||||
>
|
||||
<div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)">
|
||||
<hr v-if="option.type === 'divider'" class="divider" />
|
||||
<div
|
||||
v-else-if="!(isInstanceLink(item) && option.name === `add_content`)"
|
||||
class="item clickable"
|
||||
:class="[option.color ?? 'base']"
|
||||
>
|
||||
<slot :name="option.name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['menu-closed', 'option-clicked'])
|
||||
|
||||
const item = ref(null)
|
||||
const contextMenu = ref(null)
|
||||
const options = ref([])
|
||||
const left = ref('0px')
|
||||
const top = ref('0px')
|
||||
const shown = ref(false)
|
||||
|
||||
defineExpose({
|
||||
showMenu: (event, passedItem, passedOptions) => {
|
||||
item.value = passedItem
|
||||
options.value = passedOptions
|
||||
|
||||
// show to get dimensions
|
||||
shown.value = true
|
||||
|
||||
// then, adjust position if overflowing
|
||||
nextTick(() => {
|
||||
const menuWidth = contextMenu.value?.clientWidth || 200
|
||||
const menuHeight = contextMenu.value?.clientHeight || 100
|
||||
const minFromEdge = 10
|
||||
|
||||
if (event.pageX + menuWidth + minFromEdge >= window.innerWidth) {
|
||||
left.value = Math.max(minFromEdge, event.pageX - menuWidth - minFromEdge) + 'px'
|
||||
} else {
|
||||
left.value = event.pageX + minFromEdge + 'px'
|
||||
}
|
||||
|
||||
if (event.pageY + menuHeight + minFromEdge >= window.innerHeight) {
|
||||
top.value = Math.max(minFromEdge, event.pageY - menuHeight - minFromEdge) + 'px'
|
||||
} else {
|
||||
top.value = event.pageY + minFromEdge + 'px'
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const isInstanceLink = (item) => {
|
||||
if (item.instance != undefined && item.instance.link) {
|
||||
return true
|
||||
} else if (item != undefined && item.link) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const hideContextMenu = () => {
|
||||
shown.value = false
|
||||
emit('menu-closed')
|
||||
}
|
||||
|
||||
const optionClicked = (option) => {
|
||||
emit('option-clicked', {
|
||||
item: item.value,
|
||||
option: option,
|
||||
})
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
const onEscKeyRelease = (event) => {
|
||||
if (event.keyCode === 27) {
|
||||
hideContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
const elements = document.elementsFromPoint(event.clientX, event.clientY)
|
||||
if (
|
||||
contextMenu.value &&
|
||||
contextMenu.value.$el !== event.target &&
|
||||
!elements.includes(contextMenu.value.$el)
|
||||
) {
|
||||
hideContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
document.body.addEventListener('keyup', onEscKeyRelease)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('keyup', onEscKeyRelease)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.context-menu {
|
||||
background-color: var(--color-raised-bg);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-floating);
|
||||
border: 1px solid var(--color-divider);
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
z-index: 1000000;
|
||||
overflow: hidden;
|
||||
padding: var(--gap-sm);
|
||||
|
||||
.item {
|
||||
align-items: center;
|
||||
color: var(--color-base);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: var(--gap-sm);
|
||||
padding: var(--gap-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
&.base {
|
||||
background-color: var(--color-button-bg);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background-color: var(--color-red);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.contrast {
|
||||
background-color: var(--color-orange);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: 1px solid var(--color-divider);
|
||||
margin: var(--gap-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DropdownIcon,
|
||||
FolderOpenIcon,
|
||||
HammerIcon,
|
||||
LogInIcon,
|
||||
UpdatedIcon,
|
||||
@@ -13,20 +14,31 @@ import {
|
||||
Button,
|
||||
ButtonLink,
|
||||
Collapsible,
|
||||
defineMessages,
|
||||
IconButton,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { ChatIcon } from '@/assets/icons'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { handleSevereError } from '@/composables/use-error.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { install_existing_instance } from '@/helpers/install'
|
||||
import { cancel_directory_change } from '@/helpers/settings.ts'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { showAppDbBackupsFolder } from '@/helpers/utils.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
openBackupsFolder: {
|
||||
id: 'app.error.state-init.open-backups-folder',
|
||||
defaultMessage: 'Open backups folder',
|
||||
},
|
||||
})
|
||||
|
||||
const errorModal = ref()
|
||||
const error = ref()
|
||||
@@ -123,6 +135,10 @@ function retryDirectoryChange() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
async function openDbBackupsFolder() {
|
||||
await showAppDbBackupsFolder().catch(handleError)
|
||||
}
|
||||
|
||||
const loadingRepair = ref(false)
|
||||
async function repairInstance() {
|
||||
loadingRepair.value = true
|
||||
@@ -310,6 +326,10 @@ async function copyToClipboard(text) {
|
||||
class="m-0 p-0 rounded-none bg-transparent text-sm font-mono break-words overflow-auto"
|
||||
>
|
||||
{{ debugInfo }}
|
||||
<button class="btn" @click="openDbBackupsFolder">
|
||||
<FolderOpenIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.openBackupsFolder) }}
|
||||
</button>
|
||||
</div>
|
||||
<IconButton
|
||||
v-tooltip="'Copy debug info'"
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup>
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { FolderOpenIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
FileTreeSelect,
|
||||
injectNotificationManager,
|
||||
injectPopupNotificationManager,
|
||||
Input,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
Textarea,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
@@ -15,8 +17,10 @@ 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({
|
||||
@@ -39,6 +43,14 @@ 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({
|
||||
@@ -93,16 +105,35 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +195,7 @@ function normalizeExportPath(path) {
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="labeled_input w-full">
|
||||
<p class="text-contrast font-semibold">{{ formatMessage(messages.modpackNameLabel) }}</p>
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="nameInput"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
|
||||
@@ -176,7 +207,7 @@ function normalizeExportPath(path) {
|
||||
<p class="text-contrast font-semibold">
|
||||
{{ formatMessage(messages.versionNumberLabel) }}
|
||||
</p>
|
||||
<StyledInput
|
||||
<Input
|
||||
v-model="versionInput"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
|
||||
@@ -189,9 +220,8 @@ function normalizeExportPath(path) {
|
||||
<p class="m-0 text-contrast font-semibold">
|
||||
{{ formatMessage(commonMessages.descriptionLabel) }}
|
||||
</p>
|
||||
<StyledInput
|
||||
<Textarea
|
||||
v-model="exportDescription"
|
||||
multiline
|
||||
:placeholder="formatMessage(messages.descriptionPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<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,22 +7,39 @@ import {
|
||||
StopCircleIcon,
|
||||
TimerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, IconButton, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Avatar,
|
||||
defineMessages,
|
||||
IconButton,
|
||||
injectNotificationManager,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useAppEvent } from '@/composables/use-app-event'
|
||||
import { handleSevereError } from '@/composables/use-error.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import { getInstanceIconUrl, 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: {
|
||||
@@ -136,7 +153,7 @@ defineExpose({
|
||||
|
||||
const currentEvent = ref(null)
|
||||
|
||||
const unlisten = await process_listener((e) => {
|
||||
useAppEvent('process', (e) => {
|
||||
if (e.instance_id === props.instance.id) {
|
||||
currentEvent.value = e.event
|
||||
if (e.event === 'finished') {
|
||||
@@ -148,7 +165,6 @@ const unlisten = await process_listener((e) => {
|
||||
onMounted(() => {
|
||||
checkProcess()
|
||||
})
|
||||
onUnmounted(() => unlisten())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -160,9 +176,10 @@ onUnmounted(() => unlisten())
|
||||
>
|
||||
<Avatar
|
||||
size="48px"
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
:src="getInstanceIconUrl(instance.icon_path)"
|
||||
:tint-by="instance.id"
|
||||
alt="Mod card"
|
||||
:alt="formatMessage(messages.instanceIcon)"
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<div class="h-full flex items-center font-bold text-contrast leading-normal">
|
||||
<span class="line-clamp-2">{{ instance.name }}</span>
|
||||
@@ -170,10 +187,10 @@ onUnmounted(() => unlisten())
|
||||
<div class="flex items-center">
|
||||
<IconButton
|
||||
v-if="playing"
|
||||
v-tooltip="'Stop'"
|
||||
v-tooltip="formatMessage(messages.stop)"
|
||||
type="colored"
|
||||
color="red"
|
||||
:label="'Stop'"
|
||||
:label="formatMessage(messages.stop)"
|
||||
@mouseenter="checkProcess"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
>
|
||||
@@ -181,18 +198,18 @@ onUnmounted(() => unlisten())
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="modLoading"
|
||||
v-tooltip="'Instance is loading...'"
|
||||
:label="'Instance is loading...'"
|
||||
v-tooltip="formatMessage(messages.loading)"
|
||||
:label="formatMessage(messages.loading)"
|
||||
disabled
|
||||
>
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="!instance.quarantined"
|
||||
v-tooltip="'Play'"
|
||||
v-tooltip="formatMessage(messages.play)"
|
||||
:type="first ? 'colored' : 'base'"
|
||||
:color="first ? 'brand' : undefined"
|
||||
label="Play"
|
||||
:label="formatMessage(messages.play)"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
@@ -204,9 +221,13 @@ onUnmounted(() => unlisten())
|
||||
<TimerIcon />
|
||||
<span class="text-sm">
|
||||
<template v-if="instance.last_played">
|
||||
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
|
||||
{{
|
||||
formatMessage(messages.played, {
|
||||
relativeTime: formatRelativeTime(dayjs(instance.last_played).toISOString()),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
<template v-else>{{ formatMessage(messages.neverPlayed) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,19 +241,20 @@ onUnmounted(() => unlisten())
|
||||
<div class="relative flex items-center justify-center">
|
||||
<Avatar
|
||||
size="48px"
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
:src="getInstanceIconUrl(instance.icon_path)"
|
||||
:tint-by="instance.id"
|
||||
alt="Mod card"
|
||||
:alt="formatMessage(messages.instanceIcon)"
|
||||
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<IconButton
|
||||
v-if="playing"
|
||||
v-tooltip="'Stop'"
|
||||
v-tooltip="formatMessage(messages.stop)"
|
||||
type="colored"
|
||||
color="red"
|
||||
size="xl"
|
||||
:label="'Stop'"
|
||||
:label="formatMessage(messages.stop)"
|
||||
:class="{ 'scale-100 opacity-100': playing }"
|
||||
class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
@@ -242,17 +264,17 @@ onUnmounted(() => unlisten())
|
||||
</IconButton>
|
||||
<SpinnerIcon
|
||||
v-else-if="modLoading || installing"
|
||||
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
|
||||
v-tooltip="formatMessage(modLoading ? messages.loading : messages.installing)"
|
||||
class="animate-spin w-8 h-8"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<IconButton
|
||||
v-else-if="!installed && !instance.quarantined"
|
||||
v-tooltip="'Repair'"
|
||||
v-tooltip="formatMessage(messages.repair)"
|
||||
type="colored"
|
||||
color="brand"
|
||||
size="xl"
|
||||
:label="'Repair'"
|
||||
:label="formatMessage(messages.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)"
|
||||
>
|
||||
@@ -260,11 +282,11 @@ onUnmounted(() => unlisten())
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-else-if="!instance.quarantined"
|
||||
v-tooltip="'Play'"
|
||||
v-tooltip="formatMessage(messages.play)"
|
||||
type="colored"
|
||||
color="brand"
|
||||
size="xl"
|
||||
:label="'Play'"
|
||||
:label="formatMessage(messages.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,9 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
|
||||
import { Avatar, ButtonLink, FormattedTag } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { Avatar, ButtonLink, defineMessages, FormattedTag, useVIntl } from '@modrinth/ui'
|
||||
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
|
||||
@@ -32,9 +41,10 @@ const instanceLink = computed(() => {
|
||||
<router-link :to="instanceLink" tabindex="-1" class="flex flex-col gap-4 text-primary">
|
||||
<span class="flex items-center gap-2">
|
||||
<Avatar
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
|
||||
:src="getInstanceIconUrl(instance.icon_path)"
|
||||
:alt="instance.name"
|
||||
size="48px"
|
||||
pad-transparent-corners
|
||||
/>
|
||||
<span class="flex flex-col gap-2">
|
||||
<span class="font-extrabold bold text-contrast">
|
||||
@@ -48,7 +58,9 @@ const instanceLink = computed(() => {
|
||||
</span>
|
||||
</span>
|
||||
</router-link>
|
||||
<ButtonLink :to="instanceLink"> <LeftArrowIcon /> Back to instance </ButtonLink>
|
||||
<ButtonLink :to="instanceLink">
|
||||
<LeftArrowIcon /> {{ formatMessage(messages.backToInstance) }}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||