Compare commits

...
Author SHA1 Message Date
tdgao be0bf7177b add uk to europe 2026-03-03 10:25:31 -08:00
tdgao 0a6fccff4e remove EU countries and add EU option for server country 2026-03-03 09:47:18 -08:00
tdgao 7bc47d5e2f Merge branch 'shared/server-project-app-fixes' of github.com:modrinth/code into shared/server-project-app-fixes 2026-03-03 09:26:15 -08:00
tdgao e11b570968 fix gallery appearing in nav with no items 2026-03-03 09:26:13 -08:00
aecsocket dac4929098 Add optional timeout to server ping 2026-03-03 14:13:52 +00:00
aecsocket e4a2b2866e make backend ping use both impls 2026-03-03 14:02:54 +00:00
aecsocket 318bd307a2 fix cache.rs logic 2026-03-03 13:56:58 +00:00
tdgao f91c3e21b8 fix lint 2026-03-02 21:30:51 -08:00
tdgao d51bdf513f rename worlds until functions for more clarity 2026-03-02 21:27:39 -08:00
tdgao 433a3abff3 delete useFetch util and just use native fetch 2026-03-02 20:43:51 -08:00
tdgao d5690e690d fix: pinging to be more accurate
TCP_NODELAY — Set on the TCP stream right after connect, preventing Nagle's algorithm from buffering the small ping packet (could save up to ~40ms)

Instant over Utc::now() — Switched to monotonic std::time::Instant for timing, which is more precise and designed for measuring elapsed time (still using chrono just for the ping magic value)
2026-03-02 20:06:53 -08:00
tdgao 0df1928e55 ping 3 times for average latency 2026-03-02 19:50:11 -08:00
tdgao ba5eef8e21 clean up code in fetch instance 2026-03-02 19:33:50 -08:00
tdgao 7944c9aa96 remove unused hasContent ref 2026-03-02 19:27:39 -08:00
aecsocket ab83ab3376 #[serde(untagged)] my BEHATED (still kinda broken) 2026-03-03 02:40:21 +00:00
aecsocket 90b3287a25 fixes 2026-03-03 02:10:22 +00:00
51ceb9d851 feat: linked server instances (#5221)
* ping queue with tests

* mc ping server info + timeout

* sqlx prepare

* tombi fmt

* tombi fmt

* allow querying server ping data

* fix shear

* wip: resolve comments with pings

* Switch to Redis for server pings

* tombi fmt

* fix compile error

* clear cache on project ping, add server store link

* Schema changes

* Improve server messages for app pinging

* synthetic server project version for search indexing

* wip: clean up server ping, background tasks

* fix migration to sync with main, propagate background task errors

* wip: server modpack content query, components in search

* wip: massive component query refactor

* fix more defaults stuff

* sqlx

* fix serde deser flatten

* fix search indexing not showing fields

* remove leftover prompt

* fix import

* add diff detection for version dependencies without version_id/project_id

* move servers tab to end

* hide app nav tabs if only one tab

* fix undefined property

* on click link for server side bar info

* show recommended & supported versions for vanilla

* fix how install.js installs instance with modpack content title instead of server project title and dont fetch icon when installing to existing instance

* use large play button instance

* show update success instead of launching right into the game

* add global installing server project state

* add comment

* small change: open discover to modpack

* implement ping server projects for latency in app

* add projectV3 to nag context for moderation package

* fix play server project button when instance is launched

* add ping to project header

* wip: server verified plays

* server verified plays compiling

* queue up server plays in batches

* report server plays improved in frontend

* fixes to tracking server joins

* fix: server project detection to do loose null check

* fix server projects showing license

* fix empty server info card

* fix server projects links title

* Fix backend impl for server player count analytics

* fix: allow for links to be set to empty

* hook up server recent plays

* cargo sqlx prepare

* add project sidebar stories

* feat: update project sidebar server info card to new design

* update server project header and project card

* feat: add hide label for project cards

* feat: add tags sidebar card

* small fix to keep color consistent

* fix: remove required content tab from server project page

* many small fixes

* handle locking server instance content

* fix hiding modal after saving server compatibility version

* copy content card item and table from content tab update branch

* fix nav tabs active tag

* fix switching between server instance vs regular instance persisted invalid state

* fix a lot of the bugginess of navtabs when theres hidden/shown tabs between instances. match frontend nav tabs

* hook up backend searchfor frontend in websiet

* fix: server project card tags

* hook up search v3 in app backend for app frontend

* Don't return missing components in project query

* Add game versions to server filters

* move reporting server joins to backend

* send account UUID along with server play analytics

* update java server ping schema

* feat: implement use server search for search sorting and filter facets

* pnpm prepr

* fix game version filter facet

* fix: allow java and bedrock addresses to be deleted

* feat: hook up languages

* Default deserialize `ProjectSerial`

* feat: show server project tags

* small fix on languages multi select

* also default java server content

* fix: update compatibility modal not closing after successful upload

* remove play button in website discovery for servers

* reenable fence in app backend

* update online/offline tag

* add online status indicator pulsing

* revert pulsing

* disable link for custom modpack project and show tooltip

* change modpack to modded type

* update ip address entire button to be clickable

* polish server info card styles

* make offline tag red and properly hook up online tag

* move server related settings into own tab

* fix setting project compatibility resets unsaved changes

* fix javaServerPatchaData wiping content field

* updates to compatibility card, add download button and display supported versions better

* fix unsaved changes popup for tags

* remove console.log

* fix incorrect project type in projects in dashboard

* fix: savable.ts to reset currentValues to data() after save

* upload server banner as gallery image with title == "__mc_server_banner__" and filter it from frontend gallery

* fix error handling and helper text copy

* ensure gallery banners are filtered in app backend gallery display

* add grouped filters for search

* add query params for server search

* feat: deep linking to open server project page then open install to play

* fix search in app frontend

* fix: server project showing offline

* fix: profile create error app backend

Here's what was happening and the fix:

Root cause: In create.rs:107, profile_create assumed the icon_path parameter was always a local filename relative to the caches directory. It did caches_dir().join(icon) which produced a path like ...\caches\https://staging-cdn.modrinth.com/... — the colons in https:// are illegal in Windows paths (OS error 123).

The frontend's installServerProject and createVanillaInstance in install.js:290 both pass project.icon_url (a full URL) directly as the icon parameter.

Fix: Modified profile_create to detect when the icon parameter is a URL (starts with http:// or https://). When it is, it downloads the icon via fetch(), extracts the filename from the URL path, and passes the downloaded bytes and filename to set_icon() which hashes and caches it properly. The existing local-file path continues to work as before.

* pass undefined instead of unknown for modpack content modal

* fix: wrong way to determine offline status

* delete required content page placeholder

* fix: redirect running function instead of passing function

* add in wiki page

* fix diffs which have unknown project/filename

* pnpm prepr

* feat: add handling for "stop" instance state for server project card and page play button

* fix updating modpack shouldn't launch right into game

* small fix on external icon

* fix refresh search causing infinite rerender i.e. maximum call stack size exceeded

watch(route) → watch(() => [route.query.i, route.query.ai, route.path]) (line 102): The deep watch on the entire Vue Router route object was the most likely cause of the stack overflow. Vue Router's route object contains matched records with component definitions and other deeply nested structures. Deep-watching it triggers recursive traversal on every route change (including those from router.replace() inside refreshSearch()). Now it only watches the specific properties that updateInstanceContext() actually needs.

ref → shallowRef for serverHits and serverPings (line 189-190): The v3 search results can be deeply nested objects (minecraft_java_server.ping.data, content, etc.). Using shallowRef prevents Vue from creating deep reactive proxies on these objects, which is consistent with how results already uses shallowRef on line 295.

Re-entrance guard + try/catch on refreshSearch() (line 310): The watcher calls refreshSearch() without awaiting, so state changes during the async execution could trigger the watcher again, causing concurrent calls. The guard prevents overlapping calls, and the try/catch ensures loading.value = false is always reached (fixing the infinite loading).

* don't require auth token for logging server play

* fetch latest server player count from redis instead of search doc

* remove components. in search facet

* Category and search sort fixes

* add logging for refreshSearch in browse.vue

* fix: use windows.history.replace instead of router.replace due to vue production bug and remove logs

* fix: server refresh search reactivity

* fix: type errors

* conquer the type errors in Browse.vue

* update search input background

* fix tags location

* slight change to color

* feat: add linked to modpack project for regular modpack instances

* feat: installation tab updates

* fix: copy ip missing hover effect

* feat: implement category and countries negative filters

* fix servers tab label in profile page

* implement add server to instance

* feat: implement allow editing server instances

* update installation settings to handle vanilla server instance case

* hide servers tab when installing content to instance

* add sorting for user installed content to be top of list in content

* update categories filters from one group filter card to separate filters cards

* add active scale

* fix offline server showing online

* update language display

* update tooltip

* hide navtabs if theres only one tab

* fix: modpack content name truncate in project card

* feat: add server projects to moderation queue

* update redirect middleware no longer needs projectV3

* update comment

* fix: server tags labels

* feat: add the mf icons finally

* Revert "update redirect middleware no longer needs projectV3"

This reverts commit 1289cb5286.

* fix open in browser

* revert any handling for handling base linked modpack content for content tab

* update instance online players to be client ping

* fix showing modpack/loader version for server instance in installation settings

* server projects are not marked as modpacks

* skip license check for server projects

* feat: add the concept of linked worlds for server instances and keep in sync with server project

* fix: router.push doesn't add history state, use nagivateTo instead

* fix: get server modpack content wrong link

* update some categories to default collapse

* small fixes

* optional languages & bedrock

* move creator below tags

* sort linked worlds to be first

* add red orange and green ping variants

* bring back content tab

* add download button in required content in app

* fix: server info card loading

* fix: brief flash of normal project before server project stuff loads in

* misc fixes

* invalidate project v3

* fix unused imports

* Quick pass for moderation related changes (#5429)

* filter certain nags out from server projects.

* move add-links nag to links.ts

* first few server related nags

* moderation checklist groundwork

* Prevent undefined stage from appearing on servers.

* add projectV3 to shouldShow callback

* Filter buttons by server project type

* fix, revert private use msg, adjust server & link nags

* starting tags + servers msg

* fix no projectV3

* fix: router.push doesn't add history state, use nagivateTo instead

* Tags nag works with servers now

* support servers' v3 exclusive links

* reupload, and status messages + nag tweaks.

* fixes

* Update tags.vue warning for server projects.

* don't suggest adding a bedrock IP

* Tweak phrasing on servers alert msg

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>

* only show unique tags in project card

* add projectV3 to cache purge

* fix type: add projectV3 to cache purge

* update caching behaviour for installing

* max 3 plays per user

* accept date_modified and date_created for sorting

* add locking environment filter for server instance and update copy

* custom pack button only shows when needed (#5444)

* expose server pinging route to frontend

* feat: add server field validation with pinging on unfocus

* improve pinging logs

* try another pinging crate

* small fixes

* prefill published project id for updating published project

* fix running app bar for mac

* cargo sqlx prepare

* fix app login avatar

* pnpm prepr

* fix download menu for mac

* FIX CI

* fix lint errors

* cargo fmt

* fix toml

* fix more lint

* add server copy

* more lint

* fix any types

* also ping unlisted and private servers

* fix lint

* remove option for showTypeSelector

* fix cannot read user from undefined

* pnpm prepr

* update pinging to make it better

* update copy

* fix login cache issue

* add project select default icon

* fix: minecraft_java_server not redirecting

* pnpm prepr

* fix required content card in project page for custom modpack

* fix app project cards custom modpacks

* update pre-collapsed for app frontend

* don't send server projects to discord webhook

* add lock icon to linked world managed by server project

* pnpm prepr

* make automod msgs on server projects private

* fix pagination for server projects tab

* fix recent plays copy

* fix sync linked world with server project

* pnpm prepr

* add 0.11.0 changelog

* update date

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: aecsocket <aecsocket@tutanota.com>
Co-authored-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
2026-03-02 23:38:09 +00:00
Calum H.andGitHub 51066c476a devex: fix claude.md (#5439)
* feat: start on agents.md/claude.md

* feat: set up

* feat: api-client claude + skills

* feat: apps/frontend

* feat: skills list

* fix: lint issues
2026-02-27 17:21:35 +00:00
aecsocketandGitHub 47ef7ee42e When deleting a project, retain the thread's project ID (#5418) 2026-02-27 01:03:08 +00:00
XanderandGitHub 6fba33d443 Fix payouts notifications not delivering (#5430)
* fix FK violation when inserting rows into `notifications_deliveries`

* add test for FK violation when inserting into notifications_deliveries

* sqlx prepare

* add migration to prevent stale notifications from being dequeued all at once upon fix

* Revert "add migration to prevent stale notifications from being dequeued all at once upon fix"

This reverts commit 446f398752.
2026-02-26 19:42:25 +00:00
XanderandGitHub 017f6a5afb fix: session refresh works as intended now (#5330)
* fix: session refresh works as intended now

* use code-defined defaults for expires and session_expires

* fix sqlx

* database migration drop defaults

* run fmt

* remove comment in migration

Signed-off-by: Xander <xander@isxander.dev>

---------

Signed-off-by: Xander <xander@isxander.dev>
2026-02-26 17:33:09 +00:00
1ab722411a Ignore old migrations from prettier formatting (#5408)
* Ignore old migrations from prettier formatting

* Simplify ignore syntax

---------

Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
2026-02-26 11:44:59 +00:00
45387e5fb6 Fix fields for pix payout (#5428)
Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
2026-02-25 21:30:31 +00:00
357 changed files with 22031 additions and 4946 deletions
+156
View File
@@ -0,0 +1,156 @@
# Adding a New API Module
How to add a new API endpoint module to `packages/api-client`.
## Steps
### 1. Define types in the module's `types.ts`
Types must match 1:1 with the backend API response. Do not reshape, rename, or omit fields.
Add to an existing namespace or create a new one:
```ts
// modules/labrinth/types.ts (existing namespace)
export namespace Labrinth {
export namespace MyDomain {
export namespace v3 {
export type Thing = {
id: string
name: string
created: string
// ... matches API response exactly
}
export type CreateThingRequest = {
name: string
}
}
}
}
```
For a new API service, create `modules/<service>/types.ts` with a new top-level namespace and re-export it from `modules/types.ts`.
### 2. Create the module class
Create `modules/<api>/<domain>/v<N>.ts`:
```ts
// modules/labrinth/things/v3.ts
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthThingsV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_things_v3'
}
public async get(id: string): Promise<Labrinth.MyDomain.v3.Thing> {
return this.client.request<Labrinth.MyDomain.v3.Thing>(`/thing/${id}`, {
api: 'labrinth',
version: 3,
method: 'GET',
})
}
public async create(data: Labrinth.MyDomain.v3.CreateThingRequest): Promise<Labrinth.MyDomain.v3.Thing> {
return this.client.request<Labrinth.MyDomain.v3.Thing>(`/thing`, {
api: 'labrinth',
version: 3,
method: 'POST',
body: data,
})
}
public async delete(id: string): Promise<void> {
return this.client.request(`/thing/${id}`, {
api: 'labrinth',
version: 3,
method: 'DELETE',
})
}
}
```
#### Request options
| Field | Values | Purpose |
|-------|--------|---------|
| `api` | `'labrinth'`, `'archon'`, or a full URL | Which base URL to use |
| `version` | `2`, `3`, `'internal'`, `'modrinth/v0'`, etc. | URL version segment |
| `method` | `'GET'`, `'POST'`, `'PUT'`, `'PATCH'`, `'DELETE'` | HTTP method |
| `body` | object | JSON request body |
| `params` | `Record<string, string>` | Query parameters |
| `skipAuth` | `boolean` | Skip auth feature for this request |
| `useNodeAuth` | `boolean` | Use node-level auth (kyros) |
| `timeout` | `number` | Request timeout in ms |
| `retry` | `boolean \| number` | Override retry behavior |
#### For uploads
Return an `UploadHandle` instead of a `Promise`:
```ts
public uploadThing(id: string, file: File): UploadHandle<void> {
return this.client.upload<void>(`/thing/${id}/file`, {
api: 'labrinth',
version: 3,
file,
})
}
// Or with FormData for multipart:
public createWithFiles(data: CreateRequest, files: File[]): UploadHandle<Thing> {
const formData = new FormData()
formData.append('data', JSON.stringify(data))
files.forEach((f, i) => formData.append(`file-${i}`, f, f.name))
return this.client.upload<Thing>(`/thing`, {
api: 'labrinth',
version: 3,
formData,
timeout: 60 * 5 * 1000, // longer timeout for uploads
})
}
```
### 3. Register in the MODULE_REGISTRY
Add to `modules/index.ts`:
```ts
import { LabrinthThingsV3Module } from './labrinth/things/v3'
export const MODULE_REGISTRY = {
// ... existing modules
labrinth_things_v3: LabrinthThingsV3Module,
} as const
```
The naming convention is `<api>_<domain>_<version>`. This flat key gets transformed into nested access: `client.labrinth.things_v3`.
### 4. Export types
If you added to an existing namespace, types are already re-exported. If you created a new `types.ts`, add it to `modules/types.ts`:
```ts
export * from './<service>/types'
```
## Naming Conventions
| Convention | Example |
|-----------|---------|
| Module class | `LabrinthThingsV3Module``{Api}{Domain}V{N}Module` |
| Module ID | `labrinth_things_v3``{api}_{domain}_v{n}` |
| Type namespace | `Labrinth.MyDomain.v3.Thing` |
| File path | `modules/labrinth/things/v3.ts` |
## Key Files
- `src/core/abstract-module.ts` — base class all modules extend
- `src/core/abstract-client.ts``request()` and `upload()` methods
- `src/modules/index.ts``MODULE_REGISTRY` and `buildModuleStructure()`
- `src/modules/<api>/types.ts` — type definitions per API
- `src/types/upload.ts``UploadHandle`, `UploadProgress`, `UploadRequestOptions`
@@ -0,0 +1,144 @@
# Cross-Platform Page System
When a page needs to exist in both the Modrinth App (`apps/app-frontend`) and the Modrinth Website (`apps/frontend`), use the cross-platform page system.
## How It Works
1. **Pages live as Vue SFCs in `packages/ui`** — either in `src/pages/` or `src/layout/` (if `src/pages/` doesn't exist, it's been renamed to `src/layout/`).
2. **Platform-dependent data flows via DI** — the app uses Tauri `invoke` commands, the website uses `api-client` or the legacy `useBaseFetch` composable. The shared page never knows which. See the `dependency-injection` skill for full DI docs.
3. **Non-platform-dependent data flows via props** — if data doesn't change based on _how_ it's fetched, just pass it as a prop.
## Example: Content Page
`ContentPageLayout` demonstrates the full pattern.
### 1. Define a DI contract in `packages/ui/src/providers/`
The provider interface abstracts all platform-specific operations:
```ts
// packages/ui/src/providers/content-manager.ts
export interface ContentManagerContext {
items: Ref<ContentItem[]>
loading: Ref<boolean>
error: Ref<Error | null>
contentTypeLabel: Ref<string>
// These are the platform-abstracted operations:
// App uses invoke(), website uses api-client
toggleEnabled: (item: ContentItem) => Promise<void>
deleteItem: (item: ContentItem) => Promise<void>
refresh: () => Promise<void>
browse: () => void
uploadFiles: () => void
// Optional capabilities — not every platform supports everything
hasUpdateSupport: boolean
updateItem?: (item: ContentItem) => Promise<void>
bulkUpdateItem?: (items: ContentItem[]) => Promise<void>
mapToTableItem: (item: ContentItem) => ContentCardTableItem
}
export const [injectContentManager, provideContentManager] =
createContext<ContentManagerContext>('ContentManager')
```
### 2. Build the shared page in `packages/ui`
The page component injects the context and handles all UI logic (search, filtering, selection, bulk operations, empty states, modals) without knowing the platform:
```vue
<!-- packages/ui/src/components/instances/ContentPageLayout.vue -->
<script setup lang="ts">
import { injectContentManager } from '../../providers/content-manager'
const { items, loading, toggleEnabled, deleteItem, refresh, mapToTableItem } =
injectContentManager()
// All UI logic lives here — search, filters, sort, bulk ops, etc.
</script>
<template>
<ContentCardTable :items="filteredItems" />
</template>
```
### 3. Each platform provides its implementation
**Website (Nuxt)** — uses `api-client` or `useBaseFetch`:
```vue
<!-- apps/frontend/src/pages/hosting/manage/[id]/content.vue -->
<script setup lang="ts">
import { provideContentManager, ContentPageLayout } from '@modrinth/ui'
const { labrinth } = injectModrinthClient()
const { data: items } = useQuery({
queryKey: ['content', serverId],
queryFn: () => labrinth.servers_v0.getAddons(serverId),
})
provideContentManager({
items: computed(() => items.value?.map(addonToContentItem) ?? []),
deleteItem: async (item) => {
await labrinth.servers_v0.deleteAddon(serverId, item.id)
},
// ... rest of the contract
})
</script>
<template>
<ContentPageLayout />
</template>
```
**App (Tauri)** — uses `invoke`:
```vue
<!-- apps/app-frontend/src/pages/instance/Content.vue -->
<script setup lang="ts">
import { provideContentManager, ContentPageLayout } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
const items = ref<ContentItem[]>([])
await invoke('get_instance_content', { instanceId }).then(/* map to ContentItem[] */)
provideContentManager({
items,
deleteItem: async (item) => {
await invoke('delete_content', { instanceId, path: item.file_path })
},
// ... rest of the contract
})
</script>
<template>
<ContentPageLayout />
</template>
```
## When to Use Props vs DI
| Use | When |
| --------- | -------------------------------------------------------------------------------------------------------- |
| **DI** | The data depends on _how_ it's fetched (different per platform) — API calls, file operations, navigation |
| **Props** | The data is the same regardless of platform — configuration flags, display options |
## Composables for Shared Logic
Extract reusable stateful logic into composables in `packages/ui/src/composables/`. The shared page orchestrates them internally:
- Search (Fuse.js fuzzy search over items)
- Filtering (dynamic filter pills)
- Selection (multi-select with bulk operations)
- Bulk operations (sequential execution with progress tracking)
## Key Files
- `packages/ui/src/pages/` (or `src/layout/`) — shared page components
- `packages/ui/src/providers/` — DI contracts
- `packages/ui/src/composables/` — shared stateful logic
- `apps/frontend/src/app.vue` — website root provider setup
- `apps/app-frontend/src/App.vue` — app root provider setup
- `apps/app-frontend/src/routes.js` — app route definitions
@@ -0,0 +1,174 @@
# Dependency Injection
Modrinth uses a lightweight DI layer built on Vue's `provide`/`inject` for sharing platform-specific capabilities and page-level state across shared UI components.
## The `createContext` Factory
All providers are defined using `createContext` from `packages/ui/src/providers/index.ts` (adapted from Reka UI). It produces a typed `[inject, provide]` tuple:
```ts
import { createContext } from '@modrinth/ui'
interface MyContext {
someValue: Ref<string>
doSomething: () => void
}
export const [injectMyContext, provideMyContext] = createContext<MyContext>('MyComponent')
```
- **`provideMyContext(value)`** — call in a parent component's `setup()`.
- **`injectMyContext()`** — call in any descendant's `setup()`. Throws if never provided.
- **`injectMyContext(null)`** — returns `null` instead of throwing (for optional contexts).
## When to Use DI
Use DI when:
- **The same interface needs different implementations** depending on the platform (web vs desktop app).
- **Deeply nested components** need access to shared page-level state without prop drilling through 3+ levels.
### Platform Abstraction (Primary Use Case)
`packages/ui` components need capabilities that each frontend fulfils differently:
| Provider | App Frontend | Website Frontend |
|----------|-------------|-----------------|
| API client | Tauri IPC client | REST fetch client |
| Notifications | `ref()` state + app window mgmt | `useState()` for SSR hydration |
| File picker | Native Tauri dialogs | Browser file inputs |
| Tags | Tauri commands | Nuxt server state |
| Page context | `sidebar: true`, ad window hooks | `sidebar: false`, no ads |
### Page-Level Context
Sharing data between a page and deeply nested children — e.g. project page data consumed by sidebar, header, and version components.
## Creating a New Provider
### 1. Define the interface in `packages/ui/src/providers/`
```ts
// packages/ui/src/providers/my-feature.ts
import type { Ref } from 'vue'
import { createContext } from '.'
export interface MyFeatureContext {
items: Ref<Item[]>
addItem: (item: Item) => Promise<void>
removeItem: (id: string) => Promise<void>
}
export const [injectMyFeature, provideMyFeature] = createContext<MyFeatureContext>('MyFeature')
```
Re-export from the barrel file (`packages/ui/src/providers/index.ts`).
### 2. For complex platform-specific logic, use an abstract class
```ts
export abstract class AbstractMyFeatureManager {
abstract items: Ref<Item[]>
abstract addItem(item: Item): Promise<void>
// Shared logic lives on the base class
handleError(err: unknown) {
console.error(err)
}
}
export const [injectMyFeature, provideMyFeature] =
createContext<AbstractMyFeatureManager>('MyFeature')
```
See `AbstractWebNotificationManager` in `packages/ui/src/providers/web-notifications.ts` for a real example.
## Wiring Up Providers
### App Frontend (Tauri)
Create a setup function in `apps/app-frontend/src/providers/setup/`:
```ts
// apps/app-frontend/src/providers/setup/my-feature.ts
import { ref } from 'vue'
import { provideMyFeature } from '@modrinth/ui'
export function setupMyFeatureProvider() {
const items = ref<Item[]>([])
provideMyFeature({
items,
addItem: async (item) => {
await invoke('add_item', { item })
items.value.push(item)
},
removeItem: async (id) => {
await invoke('remove_item', { id })
items.value = items.value.filter(i => i.id !== id)
},
})
}
```
Register it in `apps/app-frontend/src/providers/setup.ts`, which is called from `App.vue`'s `setup()`.
### Website Frontend (Nuxt)
Provide directly in `apps/frontend/src/app.vue`, using Nuxt's `useState()` where SSR hydration is needed:
```ts
provideMyFeature({
items: useState<Item[]>('my-feature-items', () => []),
addItem: async (item) => {
await $fetch('/api/items', { method: 'POST', body: item })
},
removeItem: async (id) => {
await $fetch(`/api/items/${id}`, { method: 'DELETE' })
},
})
```
## Consuming Providers
In any component across `packages/ui`, `apps/frontend`, or `apps/app-frontend`:
```vue
<script setup lang="ts">
import { injectMyFeature } from '@modrinth/ui'
const { items, addItem } = injectMyFeature()
</script>
<template>
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
<button @click="addItem({ id: '1', name: 'New' })">Add</button>
</template>
```
## When NOT to Use DI
Default to props and emits. DI adds indirection — only use it with a concrete reason.
- **Parent to direct child** — use props.
- **Data only exists in one frontend** — keep context local to that app, not in `packages/ui`.
- **Shallow prop drilling (12 levels)** — passing through one intermediate is fine.
- **Component-local state** — use `ref()` / `reactive()` locally.
## Existing Providers
| Provider | File | Purpose |
|----------|------|---------|
| `provideModrinthClient` | `providers/api-client.ts` | API client instance |
| `provideNotificationManager` | `providers/web-notifications.ts` | Notification management |
| `providePageContext` | `providers/page-context.ts` | Page config (sidebar, ads) |
| `provideProjectPageContext` | `providers/project-page.ts` | Project page state + mutations |
| `provideServerContext` | `providers/server-context.ts` | Server hosting state |
| `provideUserPageContext` | `providers/user-page.ts` | User page state |
## Key Files
- `packages/ui/src/providers/index.ts``createContext` factory + barrel exports
- `packages/ui/src/providers/*.ts` — Provider definitions
- `apps/frontend/src/app.vue` — Nuxt root provider setup
- `apps/app-frontend/src/App.vue` — Tauri root provider setup
- `apps/app-frontend/src/providers/setup/` — App provider setup functions
+45
View File
@@ -0,0 +1,45 @@
# Figma MCP Usage
When the Figma MCP server is connected, use it to translate Figma designs into production-ready Vue components for this monorepo.
## Workflow
### 1. Get the design context
Use `get_design_context` with the node ID from a Figma URL. If the URL is `https://figma.com/design/:fileKey/:fileName?node-id=1-2`, the node ID is `1:2`.
```
get_design_context(nodeId: "1:2", clientLanguages: "typescript,html,css", clientFrameworks: "vue")
```
This returns reference code, a screenshot, and metadata. Always start here.
### 2. Get a screenshot for visual reference
Use `get_screenshot` if you need to see the design without full code context:
```
get_screenshot(nodeId: "1:2")
```
### 3. Get variable definitions
Use `get_variable_defs` to see what design tokens are applied to a node:
```
get_variable_defs(nodeId: "1:2")
```
### 4. Get metadata for structure overview
Use `get_metadata` to get an XML overview of node IDs, layer types, names, positions and sizes — useful for understanding the structure of a complex frame before diving into individual nodes.
## Adapting Figma Output
The Figma MCP returns generic reference code. Adapt it to match the Modrinth codebase:
1. **Read `packages/ui/CLAUDE.md`** for color usage rules, surface token mapping, and component patterns.
2. **Map Figma color variables to `surface-*` tokens** — never use Figma's aliased names like `bg/default` or `bg/raised` directly. The CLAUDE.md has the full mapping table.
3. **Check `packages/assets/styles/variables.scss`** for tokens not exposed in Figma (brand highlights, semantic backgrounds, shadows).
4. **Check for existing components** in `packages/ui/src/components/` before building from scratch.
5. **Match spacing exactly** — do not approximate values from the design.
+105
View File
@@ -0,0 +1,105 @@
# i18n String Conversion
Convert hard-coded natural-language strings in Vue SFCs into the localization system using utilities from `@modrinth/ui`.
## Rules
### 1. Identify translatable strings
- Scan `<template>` for all user-visible strings: inner text, alt attributes, placeholders, button labels, etc.
- Check `<script>` too: dropdown option labels, notification messages, etc.
- Do NOT extract dynamic expressions (`{{ user.name }}`) or HTML tags — only static human-readable text.
### 2. Create message definitions
Import `defineMessage` or `defineMessages` from `@modrinth/ui` in `<script setup>`. Define messages with a unique `id` (descriptive prefix based on component path) and `defaultMessage` equal to the original English string:
```ts
const messages = defineMessages({
welcomeTitle: { id: 'auth.welcome.title', defaultMessage: 'Welcome' },
welcomeDescription: { id: 'auth.welcome.description', defaultMessage: "You're now part of the community…" },
})
```
### 3. Handle variables and ICU formats
- Dynamic parts become ICU placeholders: `"Hello, ${user.name}!"``defaultMessage: 'Hello, {name}!'`
- Numbers/dates/times use ICU options: `{price, number, ::currency/USD}`
- Plurals/selects use ICU: `'{count, plural, one {# message} other {# messages}}'`
### 4. Rich-text messages (links/markup)
Wrap link/markup ranges with tags in `defaultMessage`:
```
"By creating an account, you agree to our <terms-link>Terms</terms-link> and <privacy-link>Privacy Policy</privacy-link>."
```
Render with `<IntlFormatted>` from `@modrinth/ui` using named slots:
```vue
<IntlFormatted :message-id="messages.tosLabel">
<template #terms-link="{ children }">
<NuxtLink to="/terms">
<component :is="() => children" />
</NuxtLink>
</template>
<template #privacy-link="{ children }">
<NuxtLink to="/privacy">
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
```
For simple emphasis: `'Welcome to <strong>Modrinth</strong>!'` with a slot:
```vue
<template #strong="{ children }">
<strong><component :is="() => children" /></strong>
</template>
```
For complex child handling, use `normalizeChildren` from `@modrinth/ui`:
```vue
<template #bold="{ children }">
<strong><component :is="() => normalizeChildren(children)" /></strong>
</template>
```
### 5. Formatting in templates
Use `useVIntl()` from `@modrinth/ui`; prefer `formatMessage` for simple strings:
```ts
const { formatMessage } = useVIntl()
```
```vue
<button>{{ formatMessage(messages.welcomeTitle) }}</button>
{{ formatMessage(messages.greeting, { name: user.name }) }}
```
### 6. Naming conventions
Make `id`s descriptive and stable (e.g., `error.generic.default.title`). Group related messages with `defineMessages`.
### 7. Avoid Vue/ICU delimiter collisions
If an ICU placeholder ends right before `}}` in a Vue template, insert a space: `} }` to avoid parsing issues.
### 8. Imports
Ensure these are imported from `@modrinth/ui` as needed: `defineMessage`/`defineMessages`, `useVIntl`, `IntlFormatted`, `normalizeChildren`.
### 9. Preserve functionality
Do not change logic, layout, reactivity, or bindings — only refactor strings into i18n.
## Reference Examples
- Variables/plurals: `apps/frontend/src/pages/frog.vue`
- Rich-text link tags: `apps/frontend/src/pages/auth/welcome.vue` and `apps/frontend/src/error.vue`
When finished, there should be no hard-coded English strings left in the template — everything comes from `formatMessage` or `<IntlFormatted>`.
+215
View File
@@ -0,0 +1,215 @@
# Multistage Modals
The `MultiStageModal` component (`packages/ui/src/components/base/MultiStageModal.vue`) provides a wizard-like modal with progress tracking, conditional stages, and per-stage button configuration.
## Architecture
A multistage modal has three parts:
1. **Context** — A DI provider that holds all state, business logic, and stage configs
2. **Stage configs** — Data objects describing each stage (title, component, buttons, skip conditions)
3. **Stage components** — Vue components rendered inside the modal, consuming the context
## Building a Multistage Modal
### 1. Define the context
Create a DI provider with all the state your wizard needs. Include the modal ref and stage configs.
```ts
// providers/my-feature/my-modal.ts
import type { ShallowRef } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import type { MultiStageModal, StageConfigInput } from '@modrinth/ui'
import { createContext } from '@modrinth/ui'
export interface MyModalContext {
// State
formData: Ref<MyFormData>
isSubmitting: Ref<boolean>
// Modal control
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
stageConfigs: StageConfigInput<MyModalContext>[]
// Business logic
handleSubmit: () => Promise<void>
}
export const [injectMyModalContext, provideMyModalContext] =
createContext<MyModalContext>('MyModal')
export function createMyModalContext(
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>,
): MyModalContext {
const formData = ref<MyFormData>({ ... })
const isSubmitting = ref(false)
async function handleSubmit() {
isSubmitting.value = true
try {
await saveData(formData.value)
modal.value?.hide()
} finally {
isSubmitting.value = false
}
}
return { formData, isSubmitting, modal, stageConfigs, handleSubmit }
}
```
### 2. Define stage configs
Each stage is a `StageConfigInput<T>` where `T` is your context type. Most fields accept either a static value or a function receiving the context (`MaybeCtxFn<T, R>`).
```ts
// providers/my-feature/stages/details-stage.ts
import { markRaw } from 'vue'
import type { StageConfigInput } from '@modrinth/ui'
import type { MyModalContext } from '../my-modal'
import DetailsStage from './DetailsStage.vue'
import { RightArrowIcon, SaveIcon } from '@modrinth/assets'
export const detailsStageConfig: StageConfigInput<MyModalContext> = {
id: 'details',
stageContent: markRaw(DetailsStage),
title: 'Details',
// Conditional behavior based on context
skip: (ctx) => ctx.shouldSkipDetails.value,
cannotNavigateForward: (ctx) => !ctx.formData.value.name,
disableClose: (ctx) => ctx.isSubmitting.value,
leftButtonConfig: (ctx) => ({
label: 'Cancel',
onClick: () => ctx.modal.value?.hide(),
}),
rightButtonConfig: (ctx) => ({
label: 'Next',
icon: RightArrowIcon,
iconPosition: 'after',
disabled: !ctx.formData.value.name,
onClick: () => ctx.modal.value?.nextStage(),
}),
}
```
**Stage config fields:**
| Field | Type | Purpose |
|-------|------|---------|
| `id` | `string` | Unique stage identifier (used with `setStage()`) |
| `stageContent` | `Component` | Vue component to render (wrap with `markRaw()`) |
| `title` | `MaybeCtxFn<T, string>` | Stage title in breadcrumbs |
| `skip` | `MaybeCtxFn<T, boolean>` | Skip this stage conditionally |
| `nonProgressStage` | `MaybeCtxFn<T, boolean>` | Exclude from progress bar (for edit sub-flows) |
| `hideStageInBreadcrumb` | `MaybeCtxFn<T, boolean>` | Hide from breadcrumb nav |
| `cannotNavigateForward` | `MaybeCtxFn<T, boolean>` | Block forward navigation (validation) |
| `disableClose` | `MaybeCtxFn<T, boolean>` | Disable closing the modal |
| `leftButtonConfig` | `MaybeCtxFn<T, StageButtonConfig \| null>` | Left action button |
| `rightButtonConfig` | `MaybeCtxFn<T, StageButtonConfig \| null>` | Right action button |
| `maxWidth` | `MaybeCtxFn<T, string>` | Per-stage max width (default `560px`) |
**Button config fields:**
| Field | Purpose |
|-------|---------|
| `label` | Button text |
| `icon` | Icon component |
| `iconPosition` | `'before'` or `'after'` |
| `color` | ButtonStyled color prop |
| `disabled` | Disable the button |
| `onClick` | Click handler |
### 3. Create stage components
Stage components inject the context and render their UI:
```vue
<!-- providers/my-feature/stages/DetailsStage.vue -->
<script setup lang="ts">
import { injectMyModalContext } from '../my-modal'
const { formData } = injectMyModalContext()
</script>
<template>
<div class="flex flex-col gap-4">
<StyledInput v-model="formData.name" label="Name" />
<StyledInput v-model="formData.description" label="Description" />
</div>
</template>
```
### 4. Create the wrapper component
The wrapper provides context and renders `MultiStageModal`:
```vue
<!-- components/MyModalWrapper.vue -->
<script setup lang="ts">
import { shallowRef } from 'vue'
import { MultiStageModal } from '@modrinth/ui'
import { createMyModalContext, provideMyModalContext } from '../providers/my-feature/my-modal'
const modal = shallowRef<InstanceType<typeof MultiStageModal> | null>(null)
const ctx = createMyModalContext(modal)
provideMyModalContext(ctx)
defineExpose({ show: () => modal.value?.show() })
</script>
<template>
<MultiStageModal ref="modal" :stages="ctx.stageConfigs" :context="ctx" />
</template>
```
## Modal API
`MultiStageModal` exposes via ref:
| Method/Property | Description |
|----------------|-------------|
| `show()` | Open the modal |
| `hide()` | Close the modal |
| `setStage(indexOrId)` | Jump to stage by index or string id |
| `nextStage()` | Advance to next non-skipped stage |
| `prevStage()` | Go back to previous stage |
| `currentStageIndex` | Ref to current stage index |
## Non-Progress Stages (Edit Sub-Flows)
For stages that shouldn't appear in the progress bar (e.g. editing a specific field from a summary page):
```ts
export const editLoadersStageConfig: StageConfigInput<MyContext> = {
id: 'edit-loaders',
nonProgressStage: true,
stageContent: markRaw(EditLoadersStage),
title: 'Edit loaders',
leftButtonConfig: (ctx) => ({
label: 'Back',
onClick: () => ctx.modal.value?.setStage('summary'),
}),
rightButtonConfig: (ctx) => ({
...ctx.saveButtonConfig(),
label: 'Save',
}),
}
```
Navigate to it with `modal.value?.setStage('edit-loaders')` — it won't affect the progress indicator.
## Reference Implementation
The version creation/edit modal is the most complete example:
| File | Purpose |
|------|---------|
| `apps/frontend/src/providers/version/manage-version-modal.ts` | Context creation + business logic |
| `apps/frontend/src/providers/version/stages/index.ts` | Stage config barrel export |
| `apps/frontend/src/providers/version/stages/*-stage.ts` | Individual stage configs |
The context includes computed properties for conditional UI, watchers for auto-fetching dependencies, loading states for granular button disabling, and both "create" and "edit" flows sharing the same stages with different button configs.
+154
View File
@@ -0,0 +1,154 @@
# TanStack Query
TanStack Query (`@tanstack/vue-query` v5) is used for server state management — caching, background refetching, and cache invalidation. Use it instead of manual `ref()` + `await` patterns for any data that comes from an API.
A TanStack MCP server is available — use `tanstack_doc` and `tanstack_search_docs` tools to look up API details when needed.
## Setup
TanStack Query is configured in `apps/frontend/src/plugins/tanstack.ts` as a Nuxt plugin with SSR hydration support. Default stale time is 5 seconds. The `QueryClient` is available via `useQueryClient()` or `useAppQueryClient()` (which also works in middleware).
## Queries
Use `useQuery` with the api-client for data fetching:
```ts
const client = injectModrinthClient()
const { data, isPending, isError, error } = useQuery({
queryKey: ['project', 'v3', projectId],
queryFn: () => client.labrinth.projects_v3.get(projectId),
staleTime: 1000 * 60 * 5,
})
```
In templates:
```vue
<span v-if="isPending">Loading...</span>
<span v-else-if="isError">Error: {{ error.message }}</span>
<div v-else>{{ data.title }}</div>
```
### Query Option Factories
For queries used across multiple components, define reusable query option factories in `packages/ui/src/queries/`:
```ts
// composables/queries/project.ts
export const STALE_TIME = 1000 * 60 * 5
export const STALE_TIME_LONG = 1000 * 60 * 10
export const projectQueryOptions = {
v3: (projectId: string, client: AbstractModrinthClient) => ({
queryKey: ['project', 'v3', projectId] as const,
queryFn: () => client.labrinth.projects_v3.get(projectId),
staleTime: STALE_TIME,
}),
members: (projectId: string, client: AbstractModrinthClient) => ({
queryKey: ['project', projectId, 'members'] as const,
queryFn: () => client.labrinth.projects_v3.getMembers(projectId),
staleTime: STALE_TIME,
}),
}
```
Then use them:
```ts
const { data } = useQuery(projectQueryOptions.v3(projectId, client))
```
### Conditional Queries
Use `enabled` as a computed for queries that depend on other data:
```ts
const { data: members } = useQuery({
queryKey: ['project', projectId, 'members'],
queryFn: () => client.labrinth.projects_v3.getMembers(projectId),
enabled: computed(() => !!projectId.value),
})
```
## Mutations
Use `useMutation` for create/update/delete operations. Invalidate related queries on success:
```ts
const queryClient = useQueryClient()
const client = injectModrinthClient()
const createMutation = useMutation({
mutationFn: (name: string) => client.archon.backups_v0.create(serverId, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['backups', 'list', serverId] }),
})
```
Use `createMutation.isPending.value` to disable buttons during submission.
### Optimistic Updates
For mutations where responsiveness matters, use optimistic updates with rollback:
```ts
const patchMutation = useMutation({
mutationFn: async ({ projectId, data }) => {
await client.labrinth.projects_v3.patch(projectId, data)
return data
},
onMutate: async ({ projectId, data }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v3', projectId] })
const previous = queryClient.getQueryData(['project', 'v3', projectId])
queryClient.setQueryData(['project', 'v3', projectId], (old) => {
if (!old) return old
return { ...old, ...data }
})
return { previous }
},
onError: (_err, _variables, context) => {
if (context?.previous) {
queryClient.setQueryData(['project', 'v3', projectId], context.previous)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId] })
},
})
```
## Query Keys
Keys use a hierarchical array pattern:
```ts
// Resource type → version/qualifier → ID
['project', 'v3', projectId]
// Resource type → ID → sub-resource
['project', projectId, 'members']
['project', projectId, 'versions', 'v3']
// Domain → action → ID
['backups', 'list', serverId]
['tech-reviews']
```
Use `as const` for type safety. Put the resource ID last when possible — this makes partial key matching work for invalidation:
```ts
// Invalidates all project queries for this ID
queryClient.invalidateQueries({ queryKey: ['project', projectId] })
```
## Key Files
- `apps/frontend/src/plugins/tanstack.ts` — QueryClient setup + SSR hydration
- `apps/frontend/src/composables/query-client.ts``useAppQueryClient()` helper
- `apps/frontend/src/composables/queries/` — reusable query option factories
+11 -1
View File
@@ -21,6 +21,14 @@ on:
type: boolean
default: false
required: false
environment:
description: Environment
type: choice
options:
- prod
- staging
default: prod
required: false
jobs:
build:
@@ -94,12 +102,14 @@ jobs:
shell: bash
run: |
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
BUILD_ENVIRONMENT="${{ inputs.environment || 'prod' }}"
echo "Setting application version to $APP_VERSION"
echo "Using environment $BUILD_ENVIRONMENT"
dasel put -f apps/app/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version'
cp packages/app-lib/.env.prod packages/app-lib/.env
cp "packages/app-lib/.env.${BUILD_ENVIRONMENT}" packages/app-lib/.env
- name: Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8
+2 -1
View File
@@ -64,7 +64,8 @@ generated
app-playground-data/*
.astro
.claude
.claude/*
!.claude/skills/
.letta
# labrinth demo fixtures
+13
View File
@@ -0,0 +1,13 @@
See [CLAUDE.md](./CLAUDE.md) for all project instructions and guidelines.
## Skills
Project-specific skills (patterns, conventions, and implementation guides) are located in [`.claude/skills/`](./.claude/skills/). Each skill has a `SKILL.md` describing the pattern:
- **[Dependency Injection](./.claude/skills/dependency-injection/SKILL.md)** — Vue provide/inject DI layer using `createContext`
- **[Cross-Platform Pages](./.claude/skills/cross-platform-pages/SKILL.md)** — Shared component architecture across Nuxt and Tauri frontends
- **[Multistage Modals](./.claude/skills/multistage-modals/SKILL.md)** — Wizard-like modal flows with `MultiStageModal`
- **[Figma MCP](./.claude/skills/figma-mcp/SKILL.md)** — Translating Figma designs to Modrinth Vue components
- **[i18n Convert](./.claude/skills/i18n-convert/SKILL.md)** — Converting hard-coded strings to vue-i18n localization
- **[API Module](./.claude/skills/api-module/SKILL.md)** — Adding new endpoint modules to `@modrinth/api-client`
- **[TanStack Query](./.claude/skills/tanstack-query/SKILL.md)** — Server state management with `@tanstack/vue-query` v5
+65 -42
View File
@@ -1,63 +1,86 @@
# Architecture
# Modrinth Monorepo
Use TAB instead of spaces.
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.
## Frontend
## Architecture
There are two similar frontends in the Modrinth monorepo, the website (apps/frontend) and the app frontend (apps/app-frontend).
- **Monorepo tooling:** [Turborepo](https://turbo.build/) (`turbo.jsonc`) + [pnpm workspaces](https://pnpm.io/workspaces) (`pnpm-workspace.yaml`)
- **Frontend:** Vue 3 / Nuxt 3, Tailwind CSS v3
- **Backend:** Rust (Labrinth API), Postgres, Clickhouse
- **Indentation:** Use TAB everywhere, never spaces
Both use Tailwind v3, and their respective configs can be seen at `tailwind.config.ts` and `tailwind.config.js` respectively.
### Apps (`apps/`)
Both utilize shared and common components from `@modrinth/ui` which can be found at `packages/ui`, and stylings from `@modrinth/assets` which can be found at `packages/assets`.
| App | Description |
| ----------------- | ------------------------------ |
| `frontend` | Main Modrinth website (Nuxt 3) |
| `app-frontend` | Desktop/app frontend (Vue 3) |
| `app` | Desktop/app shell (Tauri) |
| `app-playground` | Testing playground for app |
| `labrinth` | Backend API service |
| `daedalus_client` | Daedalus client implementation |
| `docs` | Documentation site (Astro) |
Both can utilize icons from `@modrinth/assets`, which are automatically generated based on what's available within the `icons` folder of the `packages/assets` directory. You can see the generated icons list in `generated-icons.ts`.
### Packages (`packages/`)
Both have access to our dependency injection framework, examples as seen in `packages/ui/src/providers/`. Ideally any state which is shared between a page and it's subpages should be shared using this dependency injection framework.
| 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 |
| `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 |
### Website (apps/frontend)
## Pre-PR Commands
Before a pull request can be opened for the website, run `pnpm prepr:frontend:web` from the root folder, otherwise CI will fail.
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.
To run a development version of the frontend, you must first copy over the relevant `.env` template file (prod, staging or local, usually prod) within the `apps/frontend` folder into `apps/frontend/.env`. Then you can run the frontend by running `pnpm web:dev` in the root folder.
- **Website:** `pnpm prepr:frontend:web`
- **App frontend:** `pnpm prepr:frontend:app`
- **Frontend libs:** `pnpm prepr:frontend:lib`
- **All frontend (app+web):** `pnpm prepr`
- **Labrinth (backend):** See `apps/labrinth/CLAUDE.md`
### App Frontend (apps/app-frontend)
The website and app `prepr` commands
Before a pull request can be opened for the app frontend, run `pnpm prepr:frontend:app` from the root folder, otherwise CI will fail.
## Dev Commands
To run a development version of the app frontend, you must first copy over the relevant `.env` template file (prod, staging or local, usually prod) within `packages/app-lib` into `packages/app-lib/.env`. Then you must run the app itself by running `pnpm app:dev` in the root folder.
- **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`
### Localization
## Project-Specific Instructions
Refer to `.github/instructions/i18n-convert.instructions.md` if the user asks you to perform any i18n conversion work on a component, set of components, pages or sets of pages.
Each project may have its own `CLAUDE.md` with detailed instructions:
## Labrinth
- [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
Labrinth is the backend API service for Modrinth.
## Code Guidelines
### Testing
### 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!
Before a pull request can be opened, run `cargo clippy -p labrinth --all-targets` and make sure there are ZERO warnings, otherwise CI will fail.
## Bash Guidelines
Use `cargo test -p labrinth --all-targets` to test your changes. All tests must pass, otherwise CI will fail.
### Output handling
- DO NOT pipe output through `head`, `tail`, `less`, or `more`
- NEVER use `| head -n X` or `| tail -n X` to truncate output
- Run commands directly without pipes when possible
- 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
To prepare the sqlx cache, cd into `apps/labrinth` and run `cargo sqlx prepare`. Make sure to NEVER run `cargo sqlx prepare --workspace`.
Read the root `docker-compose.yml` to see what running services are available while developing. Use `docker exec` to access these services.
When the user refers to "performing pre-PR checks", do the following:
- Run clippy as described above
- DO NOT run tests unless explicitly requested (they take a long time)
- Prepare the sqlx cache
### Clickhouse
Use `docker exec labrinth-clickhouse clickhouse-client` to access the Clickhouse instance. We use the `staging_ariadne` database to store data in testing.
### Postgres
Use `docker exec labrinth-postgres psql -U labrinth -d labrinth -c "SELECT 1"` to access the PostgreSQL instance, replacing the `SELECT 1` with your query.
# Guidelines
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to.
### General
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to
Generated
+291 -82
View File
@@ -640,6 +640,19 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-minecraft-ping"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b459c14dd8d9ef21e296af3f2a3651ff7dc3536e092fb0b09e528daaa6d89"
dependencies = [
"async-trait",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
]
[[package]]
name = "async-process"
version = "2.5.0"
@@ -1883,7 +1896,7 @@ checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
dependencies = [
"cookie 0.18.1",
"document-features",
"idna",
"idna 1.1.0",
"log",
"publicsuffix",
"serde",
@@ -2134,7 +2147,7 @@ dependencies = [
"futures",
"indexmap 2.11.4",
"itertools 0.14.0",
"reqwest",
"reqwest 0.12.24",
"rust-s3",
"serde",
"serde-xml-rs",
@@ -2167,6 +2180,16 @@ dependencies = [
"darling_macro 0.21.3",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core 0.23.0",
"darling_macro 0.23.0",
]
[[package]]
name = "darling_core"
version = "0.20.11"
@@ -2195,6 +2218,19 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.106",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
@@ -2217,6 +2253,17 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core 0.23.0",
"quote",
"syn 2.0.106",
]
[[package]]
name = "dashmap"
version = "6.1.0"
@@ -2651,6 +2698,24 @@ dependencies = [
"zeroize",
]
[[package]]
name = "elytra-ping"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "086ee116d28d0eb35ae108d1059d135e585dd1254e5df881c5b2505a6d67c445"
dependencies = [
"bytes",
"chrono",
"mc-varint",
"rand 0.9.2",
"serde",
"serde_json",
"snafu",
"tokio",
"tracing",
"trust-dns-resolver",
]
[[package]]
name = "email-encoding"
version = "0.4.1"
@@ -3754,7 +3819,7 @@ dependencies = [
"futures-channel",
"futures-io",
"futures-util",
"idna",
"idna 1.1.0",
"ipnet",
"once_cell",
"rand 0.9.2",
@@ -4124,9 +4189,9 @@ dependencies = [
[[package]]
name = "ico"
version = "0.4.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
dependencies = [
"byteorder",
"png 0.17.16",
@@ -4224,6 +4289,16 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c"
dependencies = [
"unicode-bidi",
"unicode-normalization",
]
[[package]]
name = "idna"
version = "1.1.0"
@@ -4607,9 +4682,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.81"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [
"once_cell",
"wasm-bindgen",
@@ -4730,6 +4805,7 @@ dependencies = [
"arc-swap",
"argon2",
"ariadne",
"async-minecraft-ping",
"async-stripe",
"async-trait",
"base64 0.22.1",
@@ -4748,6 +4824,7 @@ dependencies = [
"dotenv-build",
"dotenvy",
"either",
"elytra-ping",
"eyre",
"futures",
"futures-util",
@@ -4773,7 +4850,7 @@ dependencies = [
"rand_chacha 0.3.1",
"redis",
"regex",
"reqwest",
"reqwest 0.12.24",
"rust-s3",
"rust_decimal",
"rust_iso3166",
@@ -4811,6 +4888,16 @@ dependencies = [
"zxcvbn",
]
[[package]]
name = "labrinth-derive"
version = "0.0.0"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "language-tags"
version = "0.3.2"
@@ -4848,7 +4935,7 @@ dependencies = [
"futures-util",
"hostname",
"httpdate",
"idna",
"idna 1.1.0",
"mime",
"nom 8.0.0",
"percent-encoding",
@@ -4974,6 +5061,12 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -5039,6 +5132,15 @@ dependencies = [
"imgref",
]
[[package]]
name = "lru-cache"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c"
dependencies = [
"linked-hash-map",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -5150,6 +5252,12 @@ dependencies = [
"rayon",
]
[[package]]
name = "mc-varint"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6407d50d4e371d5f450a4a5f1abd4531e48e9a1627409c051b5d4c7c84f6bb09"
[[package]]
name = "md-5"
version = "0.10.6"
@@ -5197,7 +5305,7 @@ dependencies = [
"log",
"meilisearch-index-setting-macro",
"pin-project-lite",
"reqwest",
"reqwest 0.12.24",
"serde",
"serde_json",
"thiserror 2.0.17",
@@ -5306,7 +5414,7 @@ dependencies = [
"flate2",
"maxminddb",
"modrinth-util",
"reqwest",
"reqwest 0.12.24",
"tar",
"tokio",
"tracing",
@@ -5385,7 +5493,7 @@ dependencies = [
"bytes",
"chrono",
"derive_more 2.0.1",
"reqwest",
"reqwest 0.12.24",
"rust_decimal",
"rust_iso3166",
"secrecy",
@@ -6139,7 +6247,7 @@ dependencies = [
"bytes",
"http 1.3.1",
"opentelemetry",
"reqwest",
"reqwest 0.12.24",
]
[[package]]
@@ -6154,7 +6262,7 @@ dependencies = [
"opentelemetry-proto",
"opentelemetry_sdk",
"prost 0.13.5",
"reqwest",
"reqwest 0.12.24",
"thiserror 2.0.17",
"tokio",
"tonic 0.13.1",
@@ -7070,7 +7178,7 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"idna 1.1.0",
"psl-types",
]
@@ -7595,11 +7703,45 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots 1.0.3",
]
[[package]]
name = "reqwest"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"hyper 1.7.0",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.5.0",
"web-sys",
]
[[package]]
name = "resolv-conf"
version = "0.7.5"
@@ -7785,7 +7927,7 @@ dependencies = [
"minidom",
"percent-encoding",
"quick-xml 0.38.3",
"reqwest",
"reqwest 0.12.24",
"serde",
"serde_derive",
"serde_json",
@@ -8253,7 +8395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48b85e25e8a1fc13928885e8bf13abe8a09e15c46993aed05d6405f7755d6e20"
dependencies = [
"httpdate",
"reqwest",
"reqwest 0.12.24",
"rustls 0.23.32",
"sentry-backtrace",
"sentry-contexts",
@@ -8776,6 +8918,28 @@ dependencies = [
"serde",
]
[[package]]
name = "snafu"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2"
dependencies = [
"backtrace",
"snafu-derive",
]
[[package]]
name = "snafu-derive"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "socket2"
version = "0.5.10"
@@ -9334,9 +9498,9 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "tao"
version = "0.34.3"
version = "0.34.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "959469667dbcea91e5485fc48ba7dd6023face91bb0f1a14681a70f99847c3f7"
checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
dependencies = [
"bitflags 2.9.4",
"block2 0.6.2",
@@ -9408,9 +9572,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.8.5"
version = "2.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d1d3b3dc4c101ac989fd7db77e045cc6d91a25349cd410455cb5c57d510c1c"
checksum = "463ae8677aa6d0f063a900b9c41ecd4ac2b7ca82f0b058cc4491540e55b20129"
dependencies = [
"anyhow",
"bytes",
@@ -9437,7 +9601,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.2",
"serde",
"serde_json",
"serde_repr",
@@ -9452,7 +9616,6 @@ dependencies = [
"tokio",
"tray-icon",
"url",
"urlpattern",
"webkit2gtk",
"webview2-com",
"window-vibrancy",
@@ -9461,9 +9624,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.4.1"
version = "2.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c432ccc9ff661803dab74c6cd78de11026a578a9307610bbc39d3c55be7943f"
checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74"
dependencies = [
"anyhow",
"cargo_toml",
@@ -9485,9 +9648,9 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.4.0"
version = "2.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ab3a62cf2e6253936a8b267c2e95839674e7439f104fa96ad0025e149d54d8a"
checksum = "aac423e5859d9f9ccdd32e3cf6a5866a15bedbf25aa6630bcb2acde9468f6ae3"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -9512,9 +9675,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.4.0"
version = "2.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4368ea8094e7045217edb690f493b55b30caf9f3e61f79b4c24b6db91f07995e"
checksum = "1b6a1bd2861ff0c8766b1d38b32a6a410f6dc6532d4ef534c47cfb2236092f59"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -9526,9 +9689,9 @@ dependencies = [
[[package]]
name = "tauri-plugin"
version = "2.4.0"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9946a3cede302eac0c6eb6c6070ac47b1768e326092d32efbb91f21ed58d978f"
checksum = "692a77abd8b8773e107a42ec0e05b767b8d2b7ece76ab36c6c3947e34df9f53f"
dependencies = [
"anyhow",
"glob",
@@ -9582,9 +9745,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-fs"
version = "2.4.2"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "315784ec4be45e90a987687bae7235e6be3d6e9e350d2b75c16b8a4bf22c1db7"
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
dependencies = [
"anyhow",
"dunce",
@@ -9604,16 +9767,16 @@ dependencies = [
[[package]]
name = "tauri-plugin-http"
version = "2.5.2"
version = "2.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "938a3d7051c9a82b431e3a0f3468f85715b3442b3c3a3913095e9fa509e2652c"
checksum = "d8f069451c4e87e7e2636b7f065a4c52866c4ce5e60e2d53fa1038edb6d184dc"
dependencies = [
"bytes",
"cookie_store",
"data-url",
"http 1.3.1",
"regex",
"reqwest",
"reqwest 0.12.24",
"schemars 0.8.22",
"serde",
"serde_json",
@@ -9697,7 +9860,7 @@ dependencies = [
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"reqwest 0.12.24",
"semver",
"serde",
"serde_json",
@@ -9730,9 +9893,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.8.0"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4cfc9ad45b487d3fded5a4731a567872a4812e9552e3964161b08edabf93846"
checksum = "b885ffeac82b00f1f6fd292b6e5aabfa7435d537cef57d11e38a489956535651"
dependencies = [
"cookie 0.18.1",
"dpi",
@@ -9755,9 +9918,9 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "2.8.1"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1fe9d48bd122ff002064e88cfcd7027090d789c4302714e68fcccba0f4b7807"
checksum = "5204682391625e867d16584fedc83fc292fb998814c9f7918605c789cd876314"
dependencies = [
"gtk",
"http 1.3.1",
@@ -9782,9 +9945,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.7.0"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a3852fdf9a4f8fbeaa63dc3e9a85284dd6ef7200751f0bd66ceee30c93f212"
checksum = "fcd169fccdff05eff2c1033210b9b94acd07a47e6fa9a3431cf09cfd4f01c87e"
dependencies = [
"anyhow",
"brotli",
@@ -9938,7 +10101,7 @@ dependencies = [
"quick-xml 0.38.3",
"rand 0.8.5",
"regex",
"reqwest",
"reqwest 0.12.24",
"rgb",
"serde",
"serde_ini",
@@ -10459,9 +10622,9 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.6"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags 2.9.4",
"bytes",
@@ -10629,6 +10792,52 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "trust-dns-proto"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3119112651c157f4488931a01e586aa459736e9d6046d3bd9105ffb69352d374"
dependencies = [
"async-trait",
"cfg-if",
"data-encoding",
"enum-as-inner",
"futures-channel",
"futures-io",
"futures-util",
"idna 0.4.0",
"ipnet",
"once_cell",
"rand 0.8.5",
"smallvec",
"thiserror 1.0.69",
"tinyvec",
"tokio",
"tracing",
"url",
]
[[package]]
name = "trust-dns-resolver"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a3e6c3aff1718b3c73e395d1f35202ba2ffa847c6a62eea0db8fb4cfe30be6"
dependencies = [
"cfg-if",
"futures-util",
"ipconfig",
"lru-cache",
"once_cell",
"parking_lot",
"rand 0.8.5",
"resolv-conf",
"smallvec",
"thiserror 1.0.69",
"tokio",
"tracing",
"trust-dns-proto",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -10848,7 +11057,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
dependencies = [
"form_urlencoded",
"idna",
"idna 1.1.0",
"percent-encoding",
"serde",
]
@@ -10996,7 +11205,7 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa"
dependencies = [
"idna",
"idna 1.1.0",
"once_cell",
"regex",
"serde",
@@ -11143,9 +11352,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.104"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
dependencies = [
"cfg-if",
"once_cell",
@@ -11154,27 +11363,14 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn 2.0.106",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.54"
version = "0.4.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c"
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
dependencies = [
"cfg-if",
"futures-util",
"js-sys",
"once_cell",
"wasm-bindgen",
@@ -11183,9 +11379,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.104"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -11193,22 +11389,22 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.104"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.106",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.104"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
dependencies = [
"unicode-ident",
]
@@ -11226,6 +11422,19 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wayland-backend"
version = "0.3.11"
@@ -11288,9 +11497,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.81"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -11308,9 +11517,9 @@ dependencies = [
[[package]]
name = "webkit2gtk"
version = "2.0.1"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a"
checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793"
dependencies = [
"bitflags 1.3.2",
"cairo-rs",
@@ -11332,9 +11541,9 @@ dependencies = [
[[package]]
name = "webkit2gtk-sys"
version = "2.0.1"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c"
checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5"
dependencies = [
"bitflags 1.3.2",
"cairo-sys-rs",
@@ -12037,9 +12246,9 @@ checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
[[package]]
name = "wry"
version = "0.53.4"
version = "0.54.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d78ec082b80fa088569a970d043bb3050abaabf4454101d44514ee8d9a8c9f6"
checksum = "bb26159b420aa77684589a744ae9a9461a95395b848764ad12290a14d960a11a"
dependencies = [
"base64 0.22.1",
"block2 0.6.2",
+8 -1
View File
@@ -8,6 +8,7 @@ members = [
"packages/app-lib",
"packages/ariadne",
"packages/daedalus",
"packages/labrinth-derive",
"packages/modrinth-log",
"packages/modrinth-maxmind",
"packages/modrinth-util",
@@ -32,6 +33,7 @@ arc-swap = "1.7.1"
argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" }
async-compression = { version = "0.4.32", default-features = false }
async-minecraft-ping = { version = "0.8.0" }
async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
@@ -58,6 +60,7 @@ color-eyre = "0.6.5"
color-thief = "0.2.2"
const_format = "0.2.34"
daedalus = { path = "packages/daedalus" }
darling = { version = "0.23" }
dashmap = "6.1.0"
data-url = "0.3.2"
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "db5fb00b036ecc8fe5f18853c559b745ffe47bde", version = "0.22.1" }
@@ -69,6 +72,7 @@ dotenv-build = "0.1.1"
dotenvy = "0.15.7"
dunce = "1.0.5"
either = "1.15.0"
elytra-ping = "6.0.1"
encoding_rs = "0.8.35"
enumset = "1.1.10"
eyre = "0.6.12"
@@ -121,9 +125,11 @@ paste = "1.0.15"
path-util = { path = "packages/path-util" }
phf = { version = "0.13.1", features = ["macros"] }
png = "0.18.0"
proc-macro2 = { version = "1.0" }
prometheus = "0.14.0"
quartz_nbt = "0.2.9"
quick-xml = "0.38.3"
quote = { version = "1.0" }
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
redis = "0.32.7"
@@ -166,13 +172,14 @@ spdx = "0.12.0"
sqlx = { version = "0.8.6", default-features = false }
sqlx-tracing = { path = "packages/sqlx-tracing" }
strum = "0.27.2"
syn = { version = "2.0" }
sysinfo = { version = "0.37.2", default-features = false }
tar = "0.4.44"
tauri = "2.8.5"
tauri-build = "2.4.1"
tauri-plugin-deep-link = "2.4.3"
tauri-plugin-dialog = "2.4.0"
tauri-plugin-http = "2.5.2"
tauri-plugin-http = "2.5.7"
tauri-plugin-opener = "2.5.0"
tauri-plugin-os = "2.3.1"
tauri-plugin-single-instance = "2.3.4"
+148 -83
View File
@@ -36,14 +36,16 @@ import {
NewsArticleCard,
NotificationPanel,
OverflowMenu,
PopupNotificationPanel,
ProgressSpinner,
provideModrinthClient,
provideNotificationManager,
providePageContext,
providePopupNotificationManager,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { formatBytes, renderString } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
import { invoke } from '@tauri-apps/api/core'
@@ -61,6 +63,7 @@ import AccountsCard from '@/components/ui/AccountsCard.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import ErrorModal from '@/components/ui/ErrorModal.vue'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue'
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
@@ -68,13 +71,13 @@ import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import UpdateAvailableToast from '@/components/ui/UpdateAvailableToast.vue'
import UpdateToast from '@/components/ui/UpdateToast.vue'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js'
@@ -82,7 +85,6 @@ import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js'
import { get_user } from '@/helpers/cache.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { useFetch } from '@/helpers/fetch.js'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { list } from '@/helpers/profile.js'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
@@ -101,13 +103,14 @@ import {
subscribeToDownloadProgress,
} from '@/providers/download-progress.ts'
import { useError } from '@/store/error.js'
import { useInstall } from '@/store/install.js'
import { playServerProject, useInstall } from '@/store/install.js'
import { useLoading, useTheming } from '@/store/state'
import { create_profile_and_install_from_file } from './helpers/pack'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { AppNotificationManager } from './providers/app-notifications'
import { AppPopupNotificationManager } from './providers/app-popup-notifications'
const themeStore = useTheming()
@@ -115,6 +118,10 @@ const notificationManager = new AppNotificationManager()
provideNotificationManager(notificationManager)
const { handleError, addNotification } = notificationManager
const popupNotificationManager = new AppPopupNotificationManager()
providePopupNotificationManager(popupNotificationManager)
const { addPopupNotification } = popupNotificationManager
const tauriApiClient = new TauriModrinthClient({
userAgent: `modrinth/theseus/${getVersion()} (support@modrinth.com)`,
features: [
@@ -295,11 +302,7 @@ async function setupApp() {
}),
)
useFetch(
`https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
'criticalAnnouncements',
true,
)
fetch(`https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`)
.then((response) => response.json())
.then((res) => {
if (res && res.header && res.body) {
@@ -312,23 +315,21 @@ async function setupApp() {
)
})
useFetch(`https://modrinth.com/news/feed/articles.json`, 'news', true)
fetch(`https://modrinth.com/news/feed/articles.json`)
.then((response) => response.json())
.then((res) => {
if (res && res.articles) {
// Format expected by NewsArticleCard component.
news.value = res.articles
.map((article) => ({
...article,
path: article.link,
thumbnail: article.thumbnail,
title: article.title,
summary: article.summary,
date: article.date,
}))
.slice(0, 4)
}
})
.catch((error) => {
console.error('Failed to fetch news articles', error)
})
get_opening_command().then(handleCommand)
fetchCredentials()
@@ -393,8 +394,11 @@ const minecraftAuthErrorModal = ref()
const install = useInstall()
const modInstallModal = ref()
const addServerToInstanceModal = ref()
const installConfirmModal = ref()
const incompatibilityWarningModal = ref()
const installToPlayModal = ref()
const updateToPlayModal = ref()
const credentials = ref()
@@ -403,7 +407,7 @@ const modrinthLoginFlowWaitModal = ref()
async function fetchCredentials() {
const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) {
creds.user = await get_user(creds.user_id).catch(handleError)
creds.user = await get_user(creds.user_id, 'bypass').catch(handleError)
}
credentials.value = creds ?? null
}
@@ -473,6 +477,10 @@ onMounted(() => {
install.setIncompatibilityWarningModal(incompatibilityWarningModal)
install.setInstallConfirmModal(installConfirmModal)
install.setModInstallModal(modInstallModal)
install.setAddServerToInstanceModal(addServerToInstanceModal)
install.setInstallToPlayModal(installToPlayModal)
install.setUpdateToPlayModal(updateToPlayModal)
install.setPopupNotificationManager(popupNotificationManager)
})
const accounts = ref(null)
@@ -490,6 +498,9 @@ async function handleCommand(e) {
source: 'CreationModalFileDrop',
})
}
} else if (e.event === 'InstallServer') {
await router.push(`/project/${e.id}`)
await playServerProject(e.id).catch(handleError)
} else {
// Other commands are URL-based (deep linking)
urlModal.value.show(e)
@@ -508,14 +519,60 @@ const downloadPercent = computed(() => Math.trunc(appUpdateDownload.progress.val
const metered = ref(true)
const finishedDownloading = ref(false)
const restarting = ref(false)
const updateToastDismissed = ref(false)
const availableUpdate = ref(null)
const updateSize = ref(null)
const updatesEnabled = ref(true)
const updatePopupMessages = defineMessages({
updateAvailable: {
id: 'app.update-popup.title',
defaultMessage: 'Update available',
},
downloadComplete: {
id: 'app.update-popup.download-complete',
defaultMessage: 'Download complete',
},
body: {
id: 'app.update-popup.body',
defaultMessage:
'Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App.',
},
meteredBody: {
id: 'app.update-popup.body.metered',
defaultMessage: `Modrinth App v{version} is available now! Since you're on a metered network, we didn't automatically download it.`,
},
downloadedBody: {
id: 'app.update-popup.body.download-complete',
defaultMessage: `Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App.`,
},
linuxBody: {
id: 'app.update-popup.body.linux',
defaultMessage:
'Modrinth App v{version} is available. Use your package manager to update for the latest features and fixes!',
},
reload: {
id: 'app.update-popup.reload',
defaultMessage: 'Reload',
},
download: {
id: 'app.update-popup.download',
defaultMessage: 'Download ({size})',
},
changelog: {
id: 'app.update-popup.changelog',
defaultMessage: 'Changelog',
},
})
async function checkUpdates() {
if (!(await areUpdatesEnabled())) {
console.log('Skipping update check as updates are disabled in this build or environment')
updatesEnabled.value = false
if (os.value === 'Linux' && !isDevEnvironment.value) {
checkLinuxUpdates()
setInterval(checkLinuxUpdates, 5 * 60 * 1000)
}
return
}
@@ -535,7 +592,6 @@ async function checkUpdates() {
appUpdateDownload.progress.value = 0
finishedDownloading.value = false
updateToastDismissed.value = false
console.log(`Update ${update.version} is available.`)
@@ -545,6 +601,28 @@ async function checkUpdates() {
downloadUpdate(update)
} else {
console.log(`Metered connection detected, not auto-downloading update.`)
getUpdateSize(update.rid).then((size) => {
updateSize.value = size
addPopupNotification({
title: formatMessage(updatePopupMessages.updateAvailable),
text: formatMessage(updatePopupMessages.meteredBody, { version: update.version }),
type: 'info',
autoCloseMs: null,
buttons: [
{
label: formatMessage(updatePopupMessages.download, {
size: formatBytes(updateSize.value ?? 0),
}),
action: () => downloadAvailableUpdate(),
color: 'brand',
},
{
label: formatMessage(updatePopupMessages.changelog),
action: () => openUrl('https://modrinth.com/news/changelog?filter=app'),
},
],
})
})
}
getUpdateSize(update.rid).then((size) => (updateSize.value = size))
@@ -561,8 +639,26 @@ async function checkUpdates() {
)
}
async function showUpdateToast() {
updateToastDismissed.value = false
async function checkLinuxUpdates() {
try {
const [response, currentVersion] = await Promise.all([
fetch('https://launcher-files.modrinth.com/updates.json'),
getVersion(),
])
const updates = await response.json()
const latestVersion = updates?.version
if (latestVersion && latestVersion !== currentVersion) {
addPopupNotification({
title: formatMessage(updatePopupMessages.updateAvailable),
text: formatMessage(updatePopupMessages.linuxBody, { version: latestVersion }),
type: 'info',
autoCloseMs: null,
})
}
} catch (e) {
console.error('Failed to check for updates:', e)
}
}
async function downloadAvailableUpdate() {
@@ -588,6 +684,26 @@ async function downloadUpdate(versionToDownload) {
unlistenUpdateDownload = null
})
console.log('Finished downloading!')
addPopupNotification({
title: formatMessage(updatePopupMessages.downloadComplete),
text: formatMessage(updatePopupMessages.downloadedBody, {
version: versionToDownload.version,
}),
type: 'success',
autoCloseMs: null,
buttons: [
{
label: formatMessage(updatePopupMessages.reload),
action: () => installUpdate(),
color: 'brand',
},
{
label: formatMessage(updatePopupMessages.changelog),
action: () => openUrl('https://modrinth.com/news/changelog?filter=app'),
},
],
})
})
unlistenUpdateDownload = await subscribeToDownloadProgress(
appUpdateDownload,
@@ -761,25 +877,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="app-grid-layout experimental-styles-within relative"
:class="{ 'disable-advanced-rendering': !themeStore.advancedRendering }"
>
<Suspense>
<Transition name="toast">
<UpdateToast
v-if="
!!availableUpdate &&
!updateToastDismissed &&
!restarting &&
(finishedDownloading || metered)
"
:version="availableUpdate.version"
:size="updateSize"
:metered="metered"
@close="updateToastDismissed = true"
@restart="installUpdate"
@download="downloadAvailableUpdate"
/>
<UpdateAvailableToast v-else-if="!updatesEnabled && os === 'Linux' && !isDevEnvironment" />
</Transition>
</Suspense>
<Transition name="fade">
<div
v-if="restarting"
@@ -856,14 +953,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</NavButton>
<div class="flex flex-grow"></div>
<Transition name="nav-button-animated">
<div
v-if="
availableUpdate &&
updateToastDismissed &&
!restarting &&
(finishedDownloading || metered)
"
>
<div v-if="availableUpdate && !restarting && (finishedDownloading || metered)">
<NavButton
v-tooltip.right="
formatMessage(
@@ -877,13 +967,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
},
)
"
:to="
finishedDownloading
? installUpdate
: downloadProgress > 0 && downloadProgress < 1
? showUpdateToast
: downloadAvailableUpdate
"
:to="finishedDownloading ? installUpdate : downloadAvailableUpdate"
>
<ProgressSpinner
v-if="downloadProgress > 0 && downloadProgress < 1"
@@ -902,7 +986,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<SettingsIcon />
</NavButton>
<OverflowMenu
v-if="credentials"
v-if="credentials?.user"
v-tooltip.right="`Modrinth account`"
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast border-0 cursor-pointer"
:options="[
@@ -918,14 +1002,14 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
]"
placement="right-end"
>
<Avatar :src="credentials.user.avatar_url" alt="" size="32px" circle />
<Avatar :src="credentials?.user?.avatar_url" alt="" size="32px" circle />
<template #view-profile>
<UserIcon />
<span class="inline-flex items-center gap-1">
Signed in as
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
<Avatar :src="credentials.user.avatar_url" alt="" size="20px" circle />
{{ credentials.user.username }}
<Avatar :src="credentials?.user?.avatar_url" alt="" size="20px" circle />
{{ credentials?.user?.username }}
</span>
</span>
<ExternalIcon />
@@ -1133,11 +1217,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<URLConfirmModal ref="urlModal" />
<I18nDebugPanel />
<NotificationPanel has-sidebar />
<PopupNotificationPanel has-sidebar />
<ErrorModal ref="errorModal" />
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
<ModInstallModal ref="modInstallModal" />
<AddServerToInstanceModal ref="addServerToInstanceModal" />
<IncompatibilityWarningModal ref="incompatibilityWarningModal" />
<InstallConfirmModal ref="installConfirmModal" />
<InstallToPlayModal ref="installToPlayModal" />
<UpdateToPlayModal ref="updateToPlayModal" />
</template>
<style lang="scss" scoped>
@@ -1366,38 +1454,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
transform: translateY(10rem) scale(0.8) scaleY(1.6);
}
.toast-enter-active {
transition: opacity 0.25s linear;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: no-preference) {
.toast-enter-active,
.nav-button-animated-enter-active {
transition: all 0.5s cubic-bezier(0.15, 1.4, 0.64, 0.96);
}
.toast-leave-active,
.nav-button-animated-leave-active {
transition: all 0.25s ease;
}
.toast-enter-from {
scale: 0.5;
translate: 0 -10rem;
opacity: 0;
}
.toast-leave-to {
scale: 0.96;
translate: 20rem 0;
opacity: 0;
}
.nav-button-animated-enter-active {
position: relative;
}
@@ -1461,7 +1526,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
}
.info-card {
right: 8rem;
right: 22rem;
}
.profile-card {
+62 -38
View File
@@ -1,5 +1,7 @@
<template>
<nav
v-if="filteredLinks.length > 1"
ref="scrollContainer"
class="card-shadow experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<RouterLink
@@ -14,13 +16,17 @@
<span class="text-nowrap">{{ link.label }}</span>
</RouterLink>
<div
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'}`"
:class="[
'pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1',
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
{ 'navtabs-transition': transitionsEnabled },
]"
:style="{
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1,
opacity: sliderReady && activeIndex !== -1 ? 1 : 0,
}"
aria-hidden="true"
></div>
@@ -28,7 +34,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { RouterLink, useRoute } from 'vue-router'
@@ -47,13 +53,16 @@ const props = defineProps<{
query?: string
}>()
const scrollContainer = ref<HTMLElement | null>(null)
const sliderLeft = ref(4)
const sliderTop = ref(4)
const sliderRight = ref(4)
const sliderBottom = ref(4)
const activeIndex = ref(-1)
const oldIndex = ref(-1)
const subpageSelected = ref(false)
const sliderReady = ref(false)
const transitionsEnabled = ref(false)
const sliderDelays = ref({ left: '0ms', top: '0ms', right: '0ms', bottom: '0ms' })
const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
@@ -63,6 +72,11 @@ const sliderTopPx = computed(() => `${sliderTop.value}px`)
const sliderRightPx = computed(() => `${sliderRight.value}px`)
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
const leftDelay = computed(() => sliderDelays.value.left)
const rightDelay = computed(() => sliderDelays.value.right)
const topDelay = computed(() => sliderDelays.value.top)
const bottomDelay = computed(() => sliderDelays.value.bottom)
function pickLink() {
let index = -1
subpageSelected.value = false
@@ -83,57 +97,54 @@ function pickLink() {
if (activeIndex.value !== -1) {
startAnimation()
} else {
oldIndex.value = -1
sliderLeft.value = 0
sliderRight.value = 0
}
}
const tabLinkElements = ref()
function getTabElement(index: number): HTMLElement | null {
if (index === -1) return null
const container = scrollContainer.value
if (!container) return null
const tabs = container.querySelectorAll('.button-animation')
return (tabs[index] as HTMLElement) ?? null
}
function startAnimation() {
const el = tabLinkElements.value[activeIndex.value].$el
if (!el || !el.offsetParent) return
const el = getTabElement(activeIndex.value)
if (!el?.offsetParent) return
const parent = el.offsetParent as HTMLElement
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
right: parent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: parent.offsetHeight - el.offsetTop - el.offsetHeight,
}
if (sliderLeft.value === 4 && sliderRight.value === 4) {
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
if (isInitialPosition) {
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
sliderReady.value = true
requestAnimationFrame(() => {
transitionsEnabled.value = true
})
} else {
const delay = 200
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left
setTimeout(() => {
sliderRight.value = newValues.right
}, delay)
} else {
sliderRight.value = newValues.right
setTimeout(() => {
sliderLeft.value = newValues.left
}, delay)
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top
setTimeout(() => {
sliderBottom.value = newValues.bottom
}, delay)
} else {
sliderBottom.value = newValues.bottom
setTimeout(() => {
sliderTop.value = newValues.top
}, delay)
const STAGGER_DELAY = '200ms'
sliderDelays.value = {
left: newValues.left < sliderLeft.value ? '0ms' : STAGGER_DELAY,
right: newValues.left < sliderLeft.value ? STAGGER_DELAY : '0ms',
top: newValues.top < sliderTop.value ? '0ms' : STAGGER_DELAY,
bottom: newValues.top < sliderTop.value ? STAGGER_DELAY : '0ms',
}
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
}
}
@@ -146,7 +157,17 @@ onUnmounted(() => {
window.removeEventListener('resize', pickLink)
})
watch(route, () => {
watch(
filteredLinks,
async () => {
await nextTick()
pickLink()
},
{ deep: true },
)
watch(route, async () => {
await nextTick()
pickLink()
})
</script>
@@ -154,7 +175,10 @@ watch(route, () => {
.navtabs-transition {
/* Delay on opacity is to hide any jankiness as the page loads */
transition:
all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s,
left 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(leftDelay),
right 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(rightDelay),
top 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(topDelay),
bottom 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(bottomDelay),
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
</style>
@@ -69,7 +69,10 @@ onUnmounted(() => {
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-divider"></div>
<div
v-if="instances && recentInstances.length > 0"
class="h-px w-6 mx-auto my-2 bg-divider"
></div>
</template>
<style scoped lang="scss"></style>
@@ -52,10 +52,12 @@
<h3 class="info-title">
{{ loadingBar.title }}
</h3>
<ProgressBar :progress="Math.floor((100 * loadingBar.current) / loadingBar.total)" />
<div class="row">
{{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}%
{{ loadingBar.message }}
<div class="flex flex-col gap-2 w-full">
<ProgressBar :progress="Math.floor((100 * loadingBar.current) / loadingBar.total)" />
<div class="row">
{{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}%
{{ loadingBar.message }}
</div>
</div>
</div>
</Card>
@@ -346,7 +348,7 @@ onBeforeUnmount(() => {
.info-card {
position: absolute;
top: 3.5rem;
right: 0.5rem;
right: 2rem;
z-index: 9;
width: 20rem;
background-color: var(--color-raised-bg);
@@ -420,7 +422,7 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
gap: 0.75rem;
margin: 0;
padding: 0;
}
@@ -1,84 +0,0 @@
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { onMounted, onUnmounted, ref } from 'vue'
const { formatMessage } = useVIntl()
const dismissed = ref(false)
const availableUpdate = ref<{ version: string } | null>(null)
let checkInterval: ReturnType<typeof setInterval> | null = null
async function checkForUpdate() {
try {
const [response, currentVersion] = await Promise.all([
fetch('https://launcher-files.modrinth.com/updates.json'),
getVersion(),
])
const updates = await response.json()
const latestVersion = updates?.version
if (latestVersion && latestVersion !== currentVersion) {
if (latestVersion !== availableUpdate.value?.version) {
availableUpdate.value = { version: latestVersion }
dismissed.value = false
}
}
} catch (e) {
console.error('Failed to check for updates:', e)
}
}
function dismiss() {
dismissed.value = true
}
onMounted(() => {
checkForUpdate()
checkInterval = setInterval(checkForUpdate, 5 * 60 * 1000)
})
onUnmounted(() => {
if (checkInterval) {
clearInterval(checkInterval)
}
})
const messages = defineMessages({
title: {
id: 'app.update-toast.title',
defaultMessage: 'Update available',
},
body: {
id: 'app.update-toast.body.linux',
defaultMessage:
'Modrinth App v{version} is available. Use your package manager to update for the latest features and fixes!',
},
download: {
id: 'app.update-toast.download-page',
defaultMessage: 'Download',
},
})
</script>
<template>
<div
v-if="availableUpdate && !dismissed"
class="grid grid-cols-[min-content] fixed card-shadow rounded-2xl top-[--top-bar-height] mt-6 right-6 p-4 z-10 bg-bg-raised border-surface-5 border-solid border-[2px]"
>
<div class="flex min-w-[25rem] gap-4">
<h2 class="whitespace-nowrap text-base text-contrast font-semibold m-0 grow">
{{ formatMessage(messages.title) }}
</h2>
<ButtonStyled size="small" circular>
<button v-tooltip="formatMessage(commonMessages.closeButton)" @click="dismiss">
<XIcon />
</button>
</ButtonStyled>
</div>
<p class="text-sm mt-2 mb-0">
{{ formatMessage(messages.body, { version: availableUpdate.version }) }}
</p>
</div>
</template>
@@ -1,130 +0,0 @@
<script setup lang="ts">
import { DownloadIcon, ExternalIcon, RefreshCwIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages, defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
import { formatBytes } from '@modrinth/utils'
import { ref } from 'vue'
import { injectAppUpdateDownloadProgress } from '@/providers/download-progress.ts'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'close' | 'restart' | 'download'): void
}>()
defineProps<{
version: string
size: number | null
metered: boolean
}>()
const downloading = ref(false)
const { progress } = injectAppUpdateDownloadProgress()
function download() {
emit('download')
downloading.value = true
}
const messages = defineMessages({
title: {
id: 'app.update-toast.title',
defaultMessage: 'Update available',
},
body: {
id: 'app.update-toast.body',
defaultMessage:
'Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App.',
},
reload: {
id: 'app.update-toast.reload',
defaultMessage: 'Reload',
},
download: {
id: 'app.update-toast.download',
defaultMessage: 'Download ({size})',
},
downloading: {
id: 'app.update-toast.downloading',
defaultMessage: 'Downloading...',
},
changelog: {
id: 'app.update-toast.changelog',
defaultMessage: 'Changelog',
},
meteredBody: {
id: 'app.update-toast.body.metered',
defaultMessage: `Modrinth App v{version} is available now! Since you're on a metered network, we didn't automatically download it.`,
},
downloadCompleteTitle: {
id: 'app.update-toast.title.download-complete',
defaultMessage: 'Download complete',
},
downloadedBody: {
id: 'app.update-toast.body.download-complete',
defaultMessage: `Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App.`,
},
})
</script>
<template>
<div
class="grid grid-cols-[min-content] fixed card-shadow rounded-2xl top-[--top-bar-height] mt-6 right-6 p-4 z-10 bg-bg-raised border-surface-5 border-solid border-[2px]"
:class="{
'download-complete': progress === 1,
}"
>
<div class="flex min-w-[25rem] gap-4">
<h2 class="whitespace-nowrap text-base text-contrast font-semibold m-0 grow">
{{
formatMessage(metered && progress === 1 ? messages.downloadCompleteTitle : messages.title)
}}
</h2>
<ButtonStyled size="small" circular>
<button v-tooltip="formatMessage(commonMessages.closeButton)" @click="emit('close')">
<XIcon />
</button>
</ButtonStyled>
</div>
<p class="text-sm mt-2 mb-0">
{{
formatMessage(
metered
? progress === 1
? messages.downloadedBody
: messages.meteredBody
: messages.body,
{ version },
)
}}
</p>
<p
v-if="metered && progress < 1"
class="text-sm text-secondary mt-2 mb-0 flex items-center gap-1"
>
<template v-if="progress > 0">
<ProgressBar :progress="progress" class="max-w-[unset]" />
</template>
</p>
<div class="flex gap-2 mt-4">
<ButtonStyled color="brand">
<button v-if="metered && progress < 1" :disabled="downloading" @click="download">
<SpinnerIcon v-if="downloading" class="animate-spin" />
<DownloadIcon v-else />
{{
formatMessage(downloading ? messages.downloading : messages.download, {
size: formatBytes(size ?? 0),
})
}}
</button>
<button v-else @click="emit('restart')">
<RefreshCwIcon /> {{ formatMessage(messages.reload) }}
</button>
</ButtonStyled>
<ButtonStyled>
<a href="https://modrinth.com/news/changelog?filter=app">
{{ formatMessage(messages.changelog) }} <ExternalIcon />
</a>
</ButtonStyled>
</div>
</div>
</template>
@@ -0,0 +1,121 @@
<script setup>
import { CheckIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
injectNotificationManager,
StyledInput,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { list } from '@/helpers/profile'
import { add_server_to_profile, get_profile_worlds } from '@/helpers/worlds.ts'
const { handleError } = injectNotificationManager()
const modal = ref()
const searchFilter = ref('')
const profiles = ref([])
const serverName = ref('')
const serverAddress = ref('')
const shownProfiles = computed(() =>
profiles.value.filter((profile) => {
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
}),
)
defineExpose({
show: async (name, address) => {
serverName.value = name
serverAddress.value = address
searchFilter.value = ''
const profilesVal = await list().catch(handleError)
for (const profile of profilesVal) {
profile.adding = false
profile.added = false
try {
const worlds = await get_profile_worlds(profile.path)
profile.added = worlds.some((w) => w.type === 'server' && w.address === serverAddress.value)
} catch {
// Ignore - will show as not added
}
}
profiles.value = profilesVal
modal.value.show()
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
},
})
async function addServer(profile) {
profile.adding = true
try {
await add_server_to_profile(profile.path, serverName.value, serverAddress.value, 'prompt')
profile.added = true
trackEvent('AddServerToInstance', {
server_name: serverName.value,
instance_name: profile.name,
source: 'AddServerToInstanceModal',
})
} catch (err) {
handleError(err)
}
profile.adding = false
}
</script>
<template>
<ModalWrapper ref="modal" header="Add server to instance">
<div class="flex flex-col gap-4 min-w-[350px]">
<Admonition type="warning" body="This server may not be compatible with all instances." />
<StyledInput
v-model="searchFilter"
:icon="SearchIcon"
type="search"
placeholder="Search for an instance"
autocomplete="off"
/>
<div class="max-h-[21rem] overflow-y-auto">
<div
v-for="profile in shownProfiles"
:key="profile.path"
class="flex w-full items-center justify-between gap-2 bg-bg-raised text-icon shadow-none"
>
<router-link
class="btn btn-transparent p-2 text-left"
:to="`/instance/${encodeURIComponent(profile.path)}`"
@click="modal.hide()"
>
<Avatar
:src="profile.icon_path ? convertFileSrc(profile.icon_path) : null"
class="mr-2 [--size:2rem]"
/>
{{ profile.name }}
</router-link>
<ButtonStyled>
<button :disabled="profile.added || profile.adding" @click="addServer(profile)">
<PlusIcon v-if="!profile.added && !profile.adding" />
<CheckIcon v-else-if="profile.added" />
{{ profile.adding ? 'Adding...' : profile.added ? 'Added' : 'Add' }}
</button>
</ButtonStyled>
</div>
</div>
<div class="input-group push-right">
<ButtonStyled>
<button @click="modal.hide()">Cancel</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
</template>
@@ -16,6 +16,7 @@ import { useRouter } from 'vue-router'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3_many } from '@/helpers/cache.js'
import {
add_project_from_version as installMod,
check_installed,
@@ -81,6 +82,22 @@ defineExpose({
handleError,
)
}
const linkedProjectIds = profilesVal
.filter((p) => p.linked_data?.project_id)
.map((p) => p.linked_data.project_id)
if (linkedProjectIds.length > 0) {
const linkedProjects = await get_project_v3_many(linkedProjectIds, 'must_revalidate').catch(
() => [],
)
const serverProjectIds = new Set(
linkedProjects.filter((p) => p?.minecraft_server != null).map((p) => p.id),
)
for (const profile of profilesVal) {
profile.isServerInstance = serverProjectIds.has(profile.linked_data?.project_id)
}
}
profiles.value = profilesVal
installModal.value.show()
@@ -29,7 +29,7 @@ import { computed, type ComputedRef, type Ref, ref, shallowRef, watch } from 'vu
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_version_many } from '@/helpers/cache'
import { get_project, get_version, get_version_many } from '@/helpers/cache'
import { get_loader_versions } from '@/helpers/metadata'
import { edit, install, update_repair_modrinth } from '@/helpers/profile'
import { get_game_versions, get_loaders } from '@/helpers/tags'
@@ -110,6 +110,12 @@ if (props.instance.linked_data && props.instance.linked_data.project_id && !prop
versions.find(
(version: Version) => version.id === props.instance.linked_data?.version_id,
) ?? null
if (!modpackVersion.value) {
get_version(props.instance.linked_data?.version_id, 'bypass')
.then((version: Version) => (modpackVersion.value = version ?? null))
.catch(handleError)
}
})
.catch(handleError)
.finally(() => {
@@ -424,6 +430,18 @@ const messages = defineMessages({
id: 'instance.settings.tabs.installation.unlink.description',
defaultMessage: `This instance is linked to a modpack, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the modpack.`,
},
unlinkServerTitle: {
id: 'instance.settings.tabs.installation.unlink-server.title',
defaultMessage: 'Unlink from server',
},
unlinkServerDescription: {
id: 'instance.settings.tabs.installation.unlink-server.description',
defaultMessage: `This instance is linked to a server, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the server.`,
},
unlinkServerVanillaDescription: {
id: 'instance.settings.tabs.installation.unlink-server-vanilla.description',
defaultMessage: `This instance is linked to a server, which means you can't change the Minecraft version. Unlinking will permanently disconnect this instance from the server.`,
},
unlinkInstanceButton: {
id: 'instance.settings.tabs.installation.unlink.button',
defaultMessage: 'Unlink instance',
@@ -557,17 +575,27 @@ const messages = defineMessages({
})
}}
</span>
<span class="text-sm text-secondary leading-none">
<span class="text-sm text-secondary leading-none capitalize">
{{
modpackProject
? modpackVersion
? modpackVersion?.version_number
: 'Unknown version'
: props.isMinecraftServer
? ''
: 'Unknown version'
: formatLoader(formatMessage, instance.loader)
}}
<template v-if="instance.loader !== 'vanilla' && !modpackProject">
{{ instance.loader_version || formatMessage(messages.unknownVersion) }}
</template>
<template
v-else-if="
instance.loader && instance.loader !== 'vanilla' && props.isMinecraftServer
"
>
{{ instance.loader }}
{{ instance.loader_version }}
</template>
</span>
</div>
</div>
@@ -599,7 +627,10 @@ const messages = defineMessages({
}}
</button>
</ButtonStyled>
<ButtonStyled v-if="modpackProject" hover-color-fill="background">
<ButtonStyled
v-if="modpackProject && !props.isMinecraftServer"
hover-color-fill="background"
>
<button
v-tooltip="
changingVersion
@@ -754,17 +785,29 @@ const messages = defineMessages({
<template v-else>
<template v-if="instance.linked_data && instance.linked_data.locked">
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.unlinkInstanceTitle) }}
{{
formatMessage(
props.isMinecraftServer ? messages.unlinkServerTitle : messages.unlinkInstanceTitle,
)
}}
</h2>
<p class="m-0">
{{ formatMessage(messages.unlinkInstanceDescription) }}
{{
formatMessage(
props.isMinecraftServer
? instance.loader === 'vanilla'
? messages.unlinkServerVanillaDescription
: messages.unlinkServerDescription
: messages.unlinkInstanceDescription,
)
}}
</p>
<ButtonStyled>
<button class="mt-2" @click="modalConfirmUnpair.show()">
<UnlinkIcon /> {{ formatMessage(messages.unlinkInstanceButton) }}
</button>
</ButtonStyled>
<template v-if="modpackProject">
<template v-if="modpackProject && !props.isMinecraftServer">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast block mt-4">
{{ formatMessage(messages.reinstallModpackTitle) }}
@@ -1,36 +1,41 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true">
<div class="flex flex-col gap-6 max-w-[500px]">
<Admonition type="info" :header="formatMessage(messages.sharedServerInstance)">
<div v-if="requiredContentProject" class="flex flex-col gap-6 max-w-[500px]">
<Admonition type="info" :header="formatMessage(messages.contentRequired)">
{{ formatMessage(messages.serverRequiresMods) }}
</Admonition>
<div v-if="sharedBy?.name" class="flex items-center gap-2 text-sm text-secondary">
<Avatar
v-if="sharedBy?.icon_url"
:src="sharedBy.icon_url"
:alt="sharedBy.name"
size="24px"
/>
<span>
<IntlFormatted :message-id="messages.sharedByToday">
<template #~name>
<span class="font-semibold text-contrast">{{ sharedBy.name }}</span>
</template>
</IntlFormatted>
</span>
</div>
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<span class="font-semibold text-contrast">{{
formatMessage(messages.requiredModpack)
}}</span>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.sharedInstance) }}
</span>
<div class="flex items-center gap-3 rounded-xl bg-surface-4 p-3">
<Avatar :src="project.icon_url" :alt="project.title" size="48px" />
<ButtonStyled type="transparent">
<button @click="openViewContents">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
<Avatar
:src="requiredContentProject.icon_url"
:alt="requiredContentProject.title"
size="48px"
/>
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-contrast">{{ project.title }}</span>
<span class="font-semibold text-contrast">
<template v-if="usingCustomModpack && modpackVersion">
{{ modpackVersion.name }}
</template>
<template v-else>
{{ requiredContentProject.title }}
</template>
</span>
<span class="text-sm text-secondary">
{{ loaderDisplay }} {{ project.game_versions?.[0] }}
{{ loaderDisplay }} {{ requiredContentProject.game_versions?.[0] }}
<template v-if="modCount">
· {{ formatMessage(messages.modCount, { count: modCount }) }}
</template>
@@ -57,11 +62,17 @@
</div>
</template>
</NewModal>
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="project?.name ?? ''"
:modpack-icon-url="project?.icon_url ?? undefined"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { DownloadIcon, EyeIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
@@ -69,75 +80,59 @@ import {
commonMessages,
defineMessages,
formatLoader,
IntlFormatted,
ModpackContentModal,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { get_organization, get_team, get_version } from '@/helpers/cache.js'
import { install } from '@/store/install.js'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { installServerProject, useInstall } from '@/store/install.js'
const props = defineProps<{
project: Labrinth.Projects.v2.Project
}>()
import type { ContentItem } from '../../../../../../packages/ui/src/components/instances/types'
const modal = ref<InstanceType<typeof NewModal>>()
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const project = ref<Labrinth.Projects.v3.Project | null>(null)
const requiredContentProject = ref<Labrinth.Projects.v2.Project | null>(null)
const onInstallComplete = ref<() => void>(() => {})
const { formatMessage } = useVIntl()
const installStore = useInstall()
const { data: organization } = useQuery({
queryKey: computed(() => ['organization', props.project.organization]),
queryFn: () => get_organization(props.project.organization!, 'must_revalidate'),
enabled: computed(() => !!props.project.organization),
})
const { data: teamMembers } = useQuery({
queryKey: computed(() => ['team', props.project.team]),
queryFn: () => get_team(props.project.team, 'must_revalidate'),
enabled: computed(() => !!props.project.team && !props.project.organization),
})
const sharedBy = computed(() => {
if (organization.value) {
return {
name: organization.value.name,
icon_url: organization.value.icon_url,
}
}
if (teamMembers.value) {
const owner = teamMembers.value.find((member: { is_owner: boolean }) => member.is_owner)
if (owner) {
return {
name: owner.user.username,
icon_url: owner.user.avatar_url,
}
}
}
return null
const usingCustomModpack = computed(() => {
return requiredContentProject.value?.id === project.value?.id
})
const loaderDisplay = computed(() => {
const loader = props.project.loaders?.[0]
const loader = requiredContentProject.value?.loaders?.[0]
if (!loader) return ''
return formatLoader(formatMessage, loader)
})
// Fetch the most recent version to get mod count from dependencies
const latestVersionId = computed(() => props.project.versions?.[0] ?? null)
const { data: latestVersion } = useQuery({
queryKey: computed(() => ['version', latestVersionId.value]),
queryFn: () => get_version(latestVersionId.value, 'must_revalidate'),
enabled: computed(() => !!latestVersionId.value),
})
const modCount = computed(() => latestVersion.value?.dependencies?.length)
const modCount = computed(() => modpackVersion.value?.dependencies?.length)
async function fetchData(versionId: string) {
// cache is making version null for some reason so bypassing for now
modpackVersion.value = await get_version(versionId, 'bypass')
if (modpackVersion.value?.project_id) {
requiredContentProject.value = await get_project(modpackVersion.value.project_id, 'bypass')
}
}
async function handleAccept() {
hide()
const serverProjectId = project.value?.id
installStore.startInstallingServer(serverProjectId)
try {
await install(props.project.id, null, null, 'ProjectPageInstallToPlayModal')
await installServerProject(serverProjectId)
onInstallComplete.value()
} catch (error) {
console.error('Failed to install project from InstallToPlayModal:', error)
console.error('Failed to install server project from InstallToPlayModal:', error)
} finally {
installStore.stopInstallingServer(serverProjectId)
}
}
@@ -145,12 +140,95 @@ function handleDecline() {
hide()
}
function show(e?: MouseEvent) {
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
async function openViewContents() {
modpackContentModal.value?.showLoading()
try {
// Ensure version data is available — the useQuery may not have resolved yet
const versionId = modpackVersionId.value
const version =
modpackVersion.value ?? (versionId ? await get_version(versionId, 'must_revalidate') : null)
const deps = version?.dependencies ?? []
const projectIds = deps
.map((d: { project_id?: string }) => d.project_id)
.filter((id: string | undefined): id is string => !!id)
const versionIds = deps
.map((d: { version_id?: string }) => d.version_id)
.filter((id: string | undefined): id is string => !!id)
const projects: Labrinth.Projects.v2.Project[] =
projectIds.length > 0 ? await get_project_many(projectIds, 'must_revalidate') : []
const versions: Labrinth.Versions.v2.Version[] =
versionIds.length > 0 ? await get_version_many(versionIds, 'must_revalidate') : []
const projectMap = new Map(projects.map((p: Labrinth.Projects.v2.Project) => [p.id, p]))
const contentItems: ContentItem[] = deps.map(
(dep: Labrinth.Versions.v2.Dependency): ContentItem => {
const depProject = dep.project_id ? projectMap.get(dep.project_id) : null
// @ts-expect-error - version_id is missing from the type for some reason
const depVersion = dep.version_id
? // @ts-expect-error - version_id is missing from the type for some reason
versions.find((v: Labrinth.Versions.v2.Version) => v.id === dep.version_id)
: null
return {
file_name: dep.file_name ?? depProject?.title ?? 'Unknown',
project_type: depProject?.project_type ?? 'mod',
has_update: false,
update_version_id: null,
project: {
id: depProject?.id ?? dep.project_id ?? dep.file_name ?? 'unknown',
slug: depProject?.slug ?? dep.project_id ?? 'unknown',
title: depProject?.title ?? dep.file_name ?? 'Unknown',
icon_url: depProject?.icon_url ?? undefined,
},
...(depVersion
? {
version: {
id: depVersion.id,
file_name: depVersion.files?.[0]?.filename ?? dep.file_name,
version_number: depVersion.version_number ?? undefined,
date_published: depVersion.date_published ?? undefined,
},
}
: {}),
}
},
)
modpackContentModal.value?.show(contentItems)
} catch (err) {
console.error('Failed to load modpack contents:', err)
modpackContentModal.value?.show([])
}
}
async function show(
projectVal: Labrinth.Projects.v3.Project,
modpackVersionIdVal: string | null = null,
callback: () => void = () => {},
e?: MouseEvent,
) {
project.value = projectVal
modpackVersionId.value = modpackVersionIdVal
modpackVersion.value = null
requiredContentProject.value = null
onInstallComplete.value = callback
if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
hide_ads_window()
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
show_ads_window()
}
const messages = defineMessages({
@@ -162,14 +240,18 @@ const messages = defineMessages({
id: 'app.modal.install-to-play.shared-server-instance',
defaultMessage: 'Shared server instance',
},
contentRequired: {
id: 'app.modal.install-to-play.content-required',
defaultMessage: 'Content required',
},
serverRequiresMods: {
id: 'app.modal.install-to-play.server-requires-mods',
defaultMessage:
'This server requires mods to play. Click install to set up the required files from Modrinth.',
'This server requires mods to play. Click Install to set up the required files from Modrinth, then launch directly into the server.',
},
sharedByToday: {
id: 'app.modal.install-to-play.shared-by-today',
defaultMessage: '{name} shared this instance with you today.',
requiredModpack: {
id: 'app.modal.install-to-play.required-modpack',
defaultMessage: 'Required modpack',
},
sharedInstance: {
id: 'app.modal.install-to-play.shared-instance',
@@ -183,6 +265,10 @@ const messages = defineMessages({
id: 'app.modal.install-to-play.install-button',
defaultMessage: 'Install',
},
viewContents: {
id: 'app.modal.install-to-play.view-contents',
defaultMessage: 'View contents',
},
})
defineExpose({ show, hide })
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
ChevronRightIcon,
CodeIcon,
@@ -16,7 +17,7 @@ import {
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { ref } from 'vue'
import { computed, ref, watch } from 'vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
@@ -24,6 +25,7 @@ import InstallationSettings from '@/components/ui/instance_settings/Installation
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_project_v3 } from '@/helpers/cache'
import type { InstanceSettingsTabProps } from '../../../helpers/types'
@@ -31,7 +33,26 @@ const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>()
const tabs: TabbedModalTab<InstanceSettingsTabProps>[] = [
const isMinecraftServer = ref(false)
watch(
() => props.instance,
(instance) => {
isMinecraftServer.value = false
if (instance.linked_data?.project_id) {
get_project_v3(instance.linked_data.project_id, 'must_revalidate')
.then((project: Labrinth.Projects.v3.Project | undefined) => {
if (project?.minecraft_server != null) {
isMinecraftServer.value = true
}
})
.catch(() => {})
}
},
{ immediate: true },
)
const tabs = computed<TabbedModalTab<InstanceSettingsTabProps>[]>(() => [
{
name: defineMessage({
id: 'instance.settings.tabs.general',
@@ -72,7 +93,7 @@ const tabs: TabbedModalTab<InstanceSettingsTabProps>[] = [
icon: CodeIcon,
content: HooksSettings,
},
]
])
const modal = ref()
@@ -98,6 +119,10 @@ defineExpose({ show })
</span>
</template>
<TabbedModal :tabs="tabs.map((tab) => ({ ...tab, props }))" />
<TabbedModal
:tabs="
tabs.map((tab) => ({ ...tab, props: { ...props, isMinecraftServer: isMinecraftServer } }))
"
/>
</ModalWrapper>
</template>
@@ -1,8 +1,14 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.updateToPlay)" :closable="true" no-padding>
<div class="max-w-[500px]">
<NewModal
ref="modal"
:header="formatMessage(messages.updateToPlay)"
:closable="true"
no-padding
@hide="() => show_ads_window()"
>
<div v-if="instance" class="max-w-[500px]">
<div class="flex flex-col gap-4 p-4">
<Admonition type="warning" :header="formatMessage(messages.updateRequired)">
<Admonition type="info" :header="formatMessage(messages.updateRequired)">
{{ formatMessage(messages.updateRequiredDescription, { name: instance.name }) }}
</Admonition>
@@ -26,18 +32,25 @@
</div>
</div>
</div>
<div v-if="diffs.length" class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto">
<div
v-if="diffs.length"
class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto border-t border-b border-r-0 border-l-0 border-solid border-surface-5"
>
<div
v-for="diff in diffs"
v-for="(diff, index) in diffs"
:key="diff.project_id"
class="grid grid-cols-[auto_1fr_1fr_1fr] items-center min-h-10 h-10 gap-2"
class="grid items-center min-h-10 h-10 gap-2"
:class="diff.project?.title ? 'grid-cols-[auto_1fr_1fr_1fr]' : 'grid-cols-[auto_1fr_1fr]'"
>
<div class="flex flex-col justify-between items-center">
<div class="w-[1px] h-2"></div>
<PlusIcon v-if="diff.type === 'added'" />
<MinusIcon v-else-if="diff.type === 'removed'" />
<RefreshCwIcon v-else />
<div class="bg-surface-5 w-[1px] h-2 relative top-1"></div>
<div
:class="index === diffs.length - 1 ? 'bg-transparent' : 'bg-surface-5'"
class="w-[1px] h-2 relative top-1"
></div>
</div>
<div class="flex gap-1 col-span-2">
@@ -49,14 +62,32 @@
>
{{ diff.project.title }}
</span>
<span
v-else-if="diff.fileName"
v-tooltip="diff.fileName"
class="text-sm text-contrast font-medium truncate"
>
{{ decodeURIComponent(diff.fileName) }}
</span>
</div>
<span
v-if="getFilename(diff.newVersion) || getFilename(diff.currentVersion)"
v-tooltip="getFilename(diff.newVersion) || getFilename(diff.currentVersion)"
v-if="
diff.project?.title &&
(getFilename(diff.newVersion) || getFilename(diff.currentVersion) || diff.fileName)
"
v-tooltip="
getFilename(diff.newVersion) ||
getFilename(diff.currentVersion) ||
decodeURIComponent(diff.fileName || '')
"
class="text-xs truncate text-right"
>
{{ getFilename(diff.newVersion) || getFilename(diff.currentVersion) }}
{{
getFilename(diff.newVersion) ||
getFilename(diff.currentVersion) ||
decodeURIComponent(diff.fileName || '')
}}
</span>
</div>
</div>
@@ -111,9 +142,11 @@ import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import { get_project, get_project_many, get_version_many } from '@/helpers/cache.js'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { update_managed_modrinth_version } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { useInstall } from '@/store/install.js'
type Dependency = Labrinth.Versions.v3.Dependency
type Version = Labrinth.Versions.v2.Version
@@ -129,6 +162,7 @@ interface BaseDiff {
newVersionId?: string
currentVersion?: Version
newVersion?: Version
fileName?: string
}
interface AddedDiff extends BaseDiff {
type: 'added'
@@ -151,22 +185,21 @@ type ProjectInfo = {
slug: string
}
const { instance } = defineProps<{
instance: GameInstance
}>()
const { formatMessage } = useVIntl()
const installStore = useInstall()
const modal = ref<InstanceType<typeof NewModal>>()
const instance = ref<GameInstance | null>(null)
const onUpdateComplete = ref<() => void>(() => {})
const diffs = ref<DependencyDiff[]>([])
const latestVersionId = ref<string | null>(null)
const latestVersion = ref<Version | null>(null)
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Version | null>(null)
const removedCount = computed(() => diffs.value.filter((d) => d.type === 'removed').length)
const addedCount = computed(() => diffs.value.filter((d) => d.type === 'added').length)
const updatedCount = computed(() => diffs.value.filter((d) => d.type === 'updated').length)
const publishedDate = computed(() =>
latestVersion.value?.date_published ? new Date(latestVersion.value.date_published) : null,
modpackVersion.value?.date_published ? new Date(modpackVersion.value.date_published) : null,
)
function getFilename(version?: Version): string | undefined {
@@ -177,18 +210,27 @@ async function computeDependencyDiffs(
currentDeps: Dependency[],
latestDeps: Dependency[],
): Promise<DependencyDiff[]> {
console.log('Computing dependency diffs', { currentDeps, latestDeps })
// Separate deps with project_id from file_name-only deps
const currentWithProject = currentDeps.filter((d) => d.project_id)
const latestWithProject = latestDeps.filter((d) => d.project_id)
const currentFileOnly = currentDeps.filter((d) => !d.project_id && d.file_name)
const latestFileOnly = latestDeps.filter((d) => !d.project_id && d.file_name)
const currentByProject = new Map<string, Dependency>(
currentDeps.map((d) => [d.project_id || '', d]),
currentWithProject.map((d) => [d.project_id!, d]),
)
const latestByProject = new Map<string, Dependency>(
latestDeps.map((d) => [d.project_id || '', d]),
latestWithProject.map((d) => [d.project_id!, d]),
)
const currentFilenames = new Set(currentFileOnly.map((d) => d.file_name!))
const latestFilenames = new Set(latestFileOnly.map((d) => d.file_name!))
const diffs: DependencyDiff[] = []
// Find added and updated dependencies
// Find added and updated dependencies (by project_id)
latestByProject.forEach((latestDep, projectId) => {
if (!projectId) return
const currentDep = currentByProject.get(projectId)
if (!currentDep && latestDep.version_id) {
diffs.push({ type: 'added', project_id: projectId, newVersionId: latestDep.version_id })
@@ -206,9 +248,8 @@ async function computeDependencyDiffs(
}
})
// Find removed dependencies
// Find removed dependencies (by project_id)
currentByProject.forEach((currentDep, projectId) => {
if (!projectId) return
if (!latestByProject.has(projectId)) {
diffs.push({
type: 'removed',
@@ -218,6 +259,19 @@ async function computeDependencyDiffs(
}
})
// Find added/removed file_name-only dependencies
// ideally in future, this should use the hash of the file instead of filename, but since version dependencies don't include file hashes, we'll use filename as a best effort approach
for (const fileName of latestFilenames) {
if (!currentFilenames.has(fileName)) {
diffs.push({ type: 'added', project_id: '', newVersionId: '' as string, fileName })
}
}
for (const fileName of currentFilenames) {
if (!latestFilenames.has(fileName)) {
diffs.push({ type: 'removed', project_id: '', fileName })
}
}
// Fetch projects and versions of diffs
const allProjectIds = [...new Set(diffs.map((d) => d.project_id).filter(Boolean))]
const allVersionIds = [
@@ -228,14 +282,14 @@ async function computeDependencyDiffs(
),
] as string[]
const [projects, versions] = await Promise.all([
get_project_many(allProjectIds, 'must_revalidate'),
get_version_many(allVersionIds, 'must_revalidate'),
get_project_many(allProjectIds, 'bypass'),
get_version_many(allVersionIds, 'bypass'),
])
const projectMap = new Map<string, ProjectInfo>(projects.map((p: ProjectInfo) => [p.id, p]))
const versionMap = new Map<string, Version>(versions.map((v: Version) => [v.id, v]))
return diffs
const mappedDiffs = diffs
.map((diff) => {
const project = projectMap.get(diff.project_id)
return {
@@ -256,34 +310,25 @@ async function computeDependencyDiffs(
const bDate = b.newVersion?.date_published || b.currentVersion?.date_published || ''
return dayjs(bDate).valueOf() - dayjs(aDate).valueOf()
})
.filter((d) => d.project || d.fileName) // filter out any diffs that couldn't be matched to a project or file
return mappedDiffs
}
async function checkUpdateAvailable(instance: GameInstance): Promise<DependencyDiff[] | null> {
if (!instance.linked_data) return null
async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[] | null> {
if (!inst.linked_data) return null
try {
const project = await get_project(instance.linked_data.project_id, 'must_revalidate')
if (!project || !project.versions || project.versions.length === 0) {
return null
}
const versions = await get_version_many(project.versions, 'must_revalidate')
const sortedVersions = versions.sort(
(a: { date_published: string }, b: { date_published: string }) =>
dayjs(b.date_published).valueOf() - dayjs(a.date_published).valueOf(),
)
latestVersion.value = sortedVersions[0]
latestVersionId.value = latestVersion.value?.id || null
const currentVersionId = instance.linked_data.version_id
const currentVersion = versions.find((v: { id: string }) => v.id === currentVersionId)
// For server projects, linked_data.project_id is the server project but
// linked_data.version_id references a content modpack version from a different project.
// Detect this by comparing the version's project_id with linked_data.project_id.
modpackVersion.value = await get_version(modpackVersionId.value, 'bypass')
const instanceModpackVersion = await get_version(inst.linked_data.version_id, 'bypass')
// Compute dependency diffs between current and latest version
if (currentVersion && latestVersion.value) {
if (instanceModpackVersion && modpackVersion.value) {
return await computeDependencyDiffs(
currentVersion.dependencies || [],
latestVersion.value.dependencies || [],
modpackVersion.value.dependencies || [],
instanceModpackVersion.dependencies || [],
)
}
} catch (error) {
@@ -294,9 +339,10 @@ async function checkUpdateAvailable(instance: GameInstance): Promise<DependencyD
}
watch(
() => instance,
async () => {
const result = await checkUpdateAvailable(instance)
() => instance.value,
async (newInstance) => {
if (!newInstance) return
const result = await checkUpdateAvailable(newInstance)
diffs.value = result || []
},
{ immediate: true, deep: true },
@@ -304,18 +350,25 @@ watch(
async function handleUpdate() {
hide()
const serverProjectId = instance.value?.linked_data?.project_id
if (serverProjectId) installStore.startInstallingServer(serverProjectId)
try {
if (latestVersionId.value) {
await update_managed_modrinth_version(instance.path, latestVersionId.value)
if (modpackVersionId.value && instance.value) {
await update_managed_modrinth_version(instance.value.path, modpackVersionId.value)
onUpdateComplete.value()
}
} catch (error) {
console.error('Error updating instance:', error)
} finally {
if (serverProjectId) installStore.stopInstallingServer(serverProjectId)
}
}
function handleReport() {
if (instance.linked_data?.project_id) {
openUrl(`https://modrinth.com/report?item=project&itemID=${instance.linked_data.project_id}`)
if (instance.value?.linked_data?.project_id) {
openUrl(
`https://modrinth.com/report?item=project&itemID=${instance.value.linked_data.project_id}`,
)
}
}
@@ -323,7 +376,16 @@ function handleDecline() {
hide()
}
function show(e?: MouseEvent) {
function show(
instanceVal: GameInstance,
modpackVersionIdVal: string | null = null,
callback: () => void = () => {},
e?: MouseEvent,
) {
instance.value = instanceVal
modpackVersionId.value = modpackVersionIdVal
onUpdateComplete.value = callback
hide_ads_window()
modal.value?.show(e)
}
@@ -378,5 +440,13 @@ const diffTypeMessages = defineMessages({
},
})
defineExpose({ show, hide })
const hasUpdate = computed(() => {
if (!instance.value?.linked_data) return false
return (
modpackVersionId.value != null &&
modpackVersionId.value !== instance.value.linked_data.version_id
)
})
defineExpose({ show, hide, hasUpdate })
</script>
@@ -28,10 +28,12 @@ watch(
async function purgeCache() {
await purge_cache_types([
'project',
'project_v3',
'version',
'user',
'team',
'organization',
'file',
'loader_manifest',
'minecraft_manifest',
'categories',
@@ -39,8 +41,10 @@ async function purgeCache() {
'loaders',
'game_versions',
'donation_platforms',
'file_hash',
'file_update',
'search_results',
'search_results_v3',
]).catch(handleError)
}
@@ -25,6 +25,7 @@ import {
defineMessages,
OverflowMenu,
SmartClickable,
TagItem,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
@@ -44,7 +45,9 @@ import type {
SingleplayerWorld,
World,
} from '@/helpers/worlds.ts'
import { getWorldIdentifier, set_world_display_status } from '@/helpers/worlds.ts'
import { getWorldIdentifier, isLinkedWorld, set_world_display_status } from '@/helpers/worlds.ts'
import { LockIcon } from '../../../../../../packages/assets/generated-icons'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
@@ -117,6 +120,7 @@ const serverIncompatible = computed(
)
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
const linked = computed(() => isLinkedWorld(props.world))
const messages = defineMessages({
hardcore: {
@@ -171,6 +175,10 @@ const messages = defineMessages({
id: 'instance.worlds.dont_show_on_home',
defaultMessage: `Don't show on Home`,
},
linkedServer: {
id: 'instance.worlds.linked_server',
defaultMessage: 'Managed by server project',
},
})
</script>
<template>
@@ -200,6 +208,14 @@ const messages = defineMessages({
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
{{ world.name }}
</div>
<TagItem
v-if="linked"
v-tooltip="formatMessage(messages.linkedServer)"
class="border !border-solid border-blue bg-highlight-blue text-xs"
:style="`--_color: var(--color-blue)`"
>
<LockIcon aria-hidden="true" class="h-5 w-5" />
</TagItem>
<div
v-if="world.type === 'singleplayer'"
class="text-sm text-secondary flex items-center gap-1 font-semibold"
@@ -396,8 +412,12 @@ const messages = defineMessages({
id: 'edit',
action: () => emit('edit'),
shown: !instancePath,
disabled: locked,
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
disabled: locked || linked,
tooltip: locked
? formatMessage(messages.worldInUse)
: linked
? formatMessage(messages.linkedServer)
: undefined,
},
{
id: 'open-folder',
@@ -432,8 +452,12 @@ const messages = defineMessages({
hoverFilled: true,
action: () => emit('delete'),
shown: !instancePath,
disabled: locked,
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
disabled: locked || linked,
tooltip: locked
? formatMessage(messages.worldInUse)
: linked
? formatMessage(messages.linkedServer)
: undefined,
},
]"
>
+16
View File
@@ -8,6 +8,14 @@ export async function get_project_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_project_many', { ids, cacheBehaviour })
}
export async function get_project_v3(id, cacheBehaviour) {
return await invoke('plugin:cache|get_project_v3', { id, cacheBehaviour })
}
export async function get_project_v3_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_project_v3_many', { ids, cacheBehaviour })
}
export async function get_version(id, cacheBehaviour) {
return await invoke('plugin:cache|get_version', { id, cacheBehaviour })
}
@@ -48,6 +56,14 @@ export async function get_search_results_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_search_results_many', { ids, cacheBehaviour })
}
export async function get_search_results_v3(id, cacheBehaviour) {
return await invoke('plugin:cache|get_search_results_v3', { id, cacheBehaviour })
}
export async function get_search_results_v3_many(ids, cacheBehaviour) {
return await invoke('plugin:cache|get_search_results_v3_many', { ids, cacheBehaviour })
}
export async function purge_cache_types(cacheTypes) {
return await invoke('plugin:cache|purge_cache_types', { cacheTypes })
}
-18
View File
@@ -1,18 +0,0 @@
import { getVersion } from '@tauri-apps/api/app'
import { fetch } from '@tauri-apps/plugin-http'
export const useFetch = async (url, item, isSilent) => {
try {
const version = await getVersion()
return await fetch(url, {
method: 'GET',
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
})
} catch (err) {
if (!isSilent) {
throw err
} else {
console.error(err)
}
}
}
+12 -3
View File
@@ -18,7 +18,15 @@ import { install_to_existing_profile } from '@/helpers/pack.js'
- icon is a path to an image file, which will be copied into the profile directory
*/
export async function create(name, gameVersion, modloader, loaderVersion, icon, skipInstall) {
export async function create(
name,
gameVersion,
modloader,
loaderVersion,
icon,
skipInstall,
linkedData,
) {
//Trim string name to avoid "Unable to find directory"
name = name.trim()
return await invoke('plugin:profile-create|profile_create', {
@@ -28,6 +36,7 @@ export async function create(name, gameVersion, modloader, loaderVersion, icon,
loaderVersion,
icon,
skipInstall,
linkedData,
})
}
@@ -174,8 +183,8 @@ export async function get_pack_export_candidates(profilePath) {
// Run Minecraft using a pathed profile
// Returns PID of child
export async function run(path) {
return await invoke('plugin:profile|profile_run', { path })
export async function run(path, serverAddress = null) {
return await invoke('plugin:profile|profile_run', { path, serverAddress })
}
export async function kill(path) {
+1
View File
@@ -139,4 +139,5 @@ type AppSettings = {
export type InstanceSettingsTabProps = {
instance: GameInstance
offline?: boolean
isMinecraftServer?: boolean
}
+39 -1
View File
@@ -30,6 +30,7 @@ export type ServerWorld = BaseWorld & {
index: number
address: string
pack_status: ServerPackStatus
linked_project_id?: string
}
export type World = SingleplayerWorld | ServerWorld
@@ -140,8 +141,15 @@ export async function add_server_to_profile(
name: string,
address: string,
packStatus: ServerPackStatus,
linkedProjectId?: string,
): Promise<number> {
return await invoke('plugin:worlds|add_server_to_profile', { path, name, address, packStatus })
return await invoke('plugin:worlds|add_server_to_profile', {
path,
name,
address,
packStatus,
linkedProjectId,
})
}
export async function edit_server_in_profile(
@@ -150,6 +158,7 @@ export async function edit_server_in_profile(
name: string,
address: string,
packStatus: ServerPackStatus,
linkedProjectId?: string,
): Promise<void> {
return await invoke('plugin:worlds|edit_server_in_profile', {
path,
@@ -157,6 +166,7 @@ export async function edit_server_in_profile(
name,
address,
packStatus,
linkedProjectId,
})
}
@@ -194,6 +204,11 @@ export function getWorldIdentifier(world: World) {
export function sortWorlds(worlds: World[]) {
worlds.sort((a, b) => {
const aLinked = isLinkedWorld(a)
const bLinked = isLinkedWorld(b)
if (aLinked !== bLinked) {
return aLinked ? -1 : 1
}
if (!a.last_played) {
return 1
}
@@ -212,6 +227,29 @@ export function isServerWorld(world: World): world is ServerWorld {
return world.type === 'server'
}
export function isLinkedWorld(world: World): boolean {
return world.type === 'server' && !!world.linked_project_id
}
export async function getServerLatency(
address: string,
protocolVersion: ProtocolVersion | null = null,
): Promise<number | undefined> {
const pings: number[] = []
for (let i = 0; i < 3; i++) {
try {
const status = await get_server_status(address, protocolVersion)
if (status.ping != null) {
pings.push(status.ping)
}
} catch {
// Ignore individual ping failures
}
}
if (pings.length === 0) return undefined
return Math.round(pings.reduce((sum, p) => sum + p, 0) / pings.length)
}
export async function refreshServerData(
serverData: ServerData,
protocolVersion: ProtocolVersion | null,
+44 -20
View File
@@ -5,6 +5,9 @@
"app.auth-servers.unreachable.header": {
"message": "Cannot reach authentication servers"
},
"app.modal.install-to-play.content-required": {
"message": "Content required"
},
"app.modal.install-to-play.header": {
"message": "Install to play"
},
@@ -14,11 +17,11 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
"app.modal.install-to-play.server-requires-mods": {
"message": "This server requires mods to play. Click install to set up the required files from Modrinth."
"app.modal.install-to-play.required-modpack": {
"message": "Required modpack"
},
"app.modal.install-to-play.shared-by-today": {
"message": "{name} shared this instance with you today."
"app.modal.install-to-play.server-requires-mods": {
"message": "This server requires mods to play. Click Install to set up the required files from Modrinth, then launch directly into the server."
},
"app.modal.install-to-play.shared-instance": {
"message": "Shared instance"
@@ -26,6 +29,9 @@
"app.modal.install-to-play.shared-server-instance": {
"message": "Shared server instance"
},
"app.modal.install-to-play.view-contents": {
"message": "View contents"
},
"app.modal.update-to-play.added-count": {
"message": "{count} added"
},
@@ -83,39 +89,33 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"app.update-toast.body": {
"app.update-popup.body": {
"message": "Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App."
},
"app.update-toast.body.download-complete": {
"app.update-popup.body.download-complete": {
"message": "Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App."
},
"app.update-toast.body.linux": {
"app.update-popup.body.linux": {
"message": "Modrinth App v{version} is available. Use your package manager to update for the latest features and fixes!"
},
"app.update-toast.body.metered": {
"app.update-popup.body.metered": {
"message": "Modrinth App v{version} is available now! Since you're on a metered network, we didn't automatically download it."
},
"app.update-toast.changelog": {
"app.update-popup.changelog": {
"message": "Changelog"
},
"app.update-toast.download": {
"app.update-popup.download": {
"message": "Download ({size})"
},
"app.update-toast.download-page": {
"message": "Download"
"app.update-popup.download-complete": {
"message": "Download complete"
},
"app.update-toast.downloading": {
"message": "Downloading..."
},
"app.update-toast.reload": {
"app.update-popup.reload": {
"message": "Reload"
},
"app.update-toast.title": {
"app.update-popup.title": {
"message": "Update available"
},
"app.update-toast.title.download-complete": {
"message": "Download complete"
},
"app.update.complete-toast.text": {
"message": "Click here to view the changelog."
},
@@ -464,6 +464,15 @@
"instance.settings.tabs.installation.unknown-version": {
"message": "(unknown version)"
},
"instance.settings.tabs.installation.unlink-server-vanilla.description": {
"message": "This instance is linked to a server, which means you can't change the Minecraft version. Unlinking will permanently disconnect this instance from the server."
},
"instance.settings.tabs.installation.unlink-server.description": {
"message": "This instance is linked to a server, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the server."
},
"instance.settings.tabs.installation.unlink-server.title": {
"message": "Unlink from server"
},
"instance.settings.tabs.installation.unlink.button": {
"message": "Unlink instance"
},
@@ -551,6 +560,9 @@
"instance.worlds.incompatible_server": {
"message": "Server is incompatible"
},
"instance.worlds.linked_server": {
"message": "Managed by server project"
},
"instance.worlds.no_contact": {
"message": "Server couldn't be contacted"
},
@@ -586,5 +598,17 @@
},
"search.filter.locked.instance.sync": {
"message": "Sync with instance"
},
"search.filter.locked.server": {
"message": "Provided by the server"
},
"search.filter.locked.server-environment.title": {
"message": "Only client-side mods can be added to the server instance"
},
"search.filter.locked.server-game-version.title": {
"message": "Game version is provided by the server"
},
"search.filter.locked.server-loader.title": {
"message": "Loader is provided by the server"
}
}
+488 -136
View File
@@ -1,23 +1,35 @@
<script setup lang="ts">
import { ClipboardCopyIcon, ExternalIcon, GlobeIcon, SearchIcon } from '@modrinth/assets'
import type { Category, GameVersion, Platform, ProjectType, SortType, Tags } from '@modrinth/ui'
import type { Labrinth } from '@modrinth/api-client'
import {
ClipboardCopyIcon,
ExternalIcon,
GlobeIcon,
PlayIcon,
PlusIcon,
SearchIcon,
StopCircleIcon,
} from '@modrinth/assets'
import type { ProjectType, SortType, Tags } from '@modrinth/ui'
import {
ButtonStyled,
Checkbox,
defineMessages,
DropdownSelect,
injectNotificationManager,
LoadingIndicator,
Pagination,
ProjectCard,
ProjectCardList,
SearchFilterControl,
SearchSidebarFilter,
StyledInput,
useSearch,
useServerSearch,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import type { Ref } from 'vue'
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import { computed, nextTick, onUnmounted, ref, shallowRef, toRaw, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
@@ -26,13 +38,24 @@ import type Instance from '@/components/ui/Instance.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get_search_results } from '@/helpers/cache.js'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile.js'
import { get_project_v3, get_search_results, get_search_results_v3 } from '@/helpers/cache.js'
import { process_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import {
get as getInstance,
get_projects as getInstanceProjects,
kill,
list as listInstances,
} from '@/helpers/profile.js'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types'
import { getServerLatency } from '@/helpers/worlds'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { getServerAddress, playServerProject, useInstall } from '@/store/install.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const installStore = useInstall()
const router = useRouter()
const route = useRoute()
@@ -42,15 +65,21 @@ const projectTypes = computed(() => {
})
const [categories, loaders, availableGameVersions] = await Promise.all([
get_categories().catch(handleError).then(ref),
get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref),
get_categories()
.catch(handleError)
.then(ref<Labrinth.Tags.v2.Category[]>),
get_loaders()
.catch(handleError)
.then(ref<Labrinth.Tags.v2.Loader[]>),
get_game_versions()
.catch(handleError)
.then(ref<Labrinth.Tags.v2.GameVersion[]>),
])
const tags: Ref<Tags> = computed(() => ({
gameVersions: availableGameVersions.value as GameVersion[],
loaders: loaders.value as Platform[],
categories: categories.value as Category[],
gameVersions: availableGameVersions.value ?? [],
loaders: loaders.value ?? [],
categories: categories.value ?? [],
}))
type Instance = {
@@ -60,6 +89,11 @@ type Instance = {
install_stage: string
icon_path?: string
name: string
linked_data?: {
project_id: string
version_id: string
locked: boolean
}
}
type InstanceProject = {
@@ -71,15 +105,19 @@ type InstanceProject = {
const instance: Ref<Instance | null> = ref(null)
const instanceProjects: Ref<InstanceProject[] | null> = ref(null)
const instanceHideInstalled = ref(false)
const newlyInstalled = ref([])
const newlyInstalled = ref<string[]>([])
const isServerInstance = ref(false)
const PERSISTENT_QUERY_PARAMS = ['i', 'ai']
await updateInstanceContext()
watch(route, () => {
updateInstanceContext()
})
watch(
() => [route.query.i, route.query.ai, route.path],
() => {
updateInstanceContext()
},
)
async function updateInstanceContext() {
if (route.query.i) {
@@ -88,6 +126,17 @@ async function updateInstanceContext() {
getInstanceProjects(route.query.i).catch(handleError),
])
newlyInstalled.value = []
isServerInstance.value = false
if (instance.value?.linked_data?.project_id) {
const projectV3 = await get_project_v3(
instance.value.linked_data.project_id,
'must_revalidate',
).catch(handleError)
if (projectV3?.minecraft_server != null) {
isServerInstance.value = true
}
}
}
if (route.query.ai && !(projectTypes.value.length === 1 && projectTypes.value[0] === 'modpack')) {
@@ -123,6 +172,13 @@ const instanceFilters = computed(() => {
})
}
if (isServerInstance.value) {
filters.push({
type: 'environment',
option: 'client',
})
}
if (instanceHideInstalled.value && instanceProjects.value) {
const installedMods = Object.values(instanceProjects.value)
.filter((x) => x.metadata)
@@ -164,7 +220,89 @@ const {
createPageParams,
} = useSearch(projectTypes, tags, instanceFilters)
const serverHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
const serverPings = shallowRef<Record<string, number | undefined>>({})
const runningServerProjects = ref<Record<string, string>>({})
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
const packs = await listInstances()
const newRunning: Record<string, string> = {}
for (const hit of hits) {
const inst = packs.find((p: GameInstance) => p.linked_data?.project_id === hit.project_id)
if (inst) {
const processes = await get_by_profile_path(inst.path).catch(() => [])
if (Array.isArray(processes) && processes.length > 0) {
newRunning[hit.project_id] = inst.path
}
}
}
runningServerProjects.value = newRunning
}
async function handleStopServerProject(projectId: string) {
const instancePath = runningServerProjects.value[projectId]
if (!instancePath) return
await kill(instancePath).catch(() => {})
const { [projectId]: _, ...rest } = runningServerProjects.value
runningServerProjects.value = rest
}
async function handlePlayServerProject(projectId: string) {
await playServerProject(projectId)
checkServerRunningStates(serverHits.value)
}
function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) {
const address = getServerAddress(project.minecraft_java_server)
if (!address) return
installStore.showAddServerToInstanceModal(project.name, address)
}
const unlistenProcesses = await process_listener(
(e: { event: string; profile_path_id: string }) => {
if (e.event === 'finished') {
const projectId = Object.entries(runningServerProjects.value).find(
([, path]) => path === e.profile_path_id,
)?.[0]
if (projectId) {
const { [projectId]: _, ...rest } = runningServerProjects.value
runningServerProjects.value = rest
}
}
},
)
onUnmounted(() => {
unlistenProcesses()
})
const {
serverCurrentSortType,
serverCurrentFilters,
serverToggledGroups,
serverSortTypes,
serverFilterTypes,
serverRequestParams,
createServerPageParams,
} = useServerSearch({ tags, query, maxResults, currentPage })
async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
const pingsToFetch = hits.filter((hit) => hit.minecraft_java_server?.address)
await Promise.all(
pingsToFetch.map(async (hit) => {
const address = hit.minecraft_java_server!.address!
try {
const latency = await getServerLatency(address)
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
} catch (err) {
console.error(`Failed to ping server ${address}:`, err)
}
}),
)
}
const previousFilterState = ref('')
const isRefreshing = ref(false)
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
@@ -179,20 +317,14 @@ breadcrumbs.setContext({ name: 'Discover content', link: route.path, query: rout
const loading = ref(true)
const projectType = ref(route.params.projectType)
const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
watch(projectType, () => {
loading.value = true
})
type SearchResult = {
project_id: string
}
type SearchResults = {
total_hits: number
limit: number
hits: SearchResult[]
interface SearchResults extends Labrinth.Search.v2.SearchResults {
hits: (Labrinth.Search.v2.ResultSearchProject & { installed?: boolean })[]
}
const results: Ref<SearchResults | null> = shallowRef(null)
@@ -200,73 +332,125 @@ const pageCount = computed(() =>
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
)
watch(requestParams, () => {
const effectiveRequestParams = computed(() => {
return projectType.value === 'server' ? serverRequestParams.value : requestParams.value
})
watch(effectiveRequestParams, async () => {
if (!route.params.projectType) return
await nextTick()
refreshSearch()
})
async function refreshSearch() {
let rawResults = await get_search_results(requestParams.value)
if (!rawResults) {
rawResults = {
result: {
if (isRefreshing.value) return
isRefreshing.value = true
try {
const isServer = projectType.value === 'server'
if (isServer) {
const rawResults = (await get_search_results_v3(serverRequestParams.value)) as {
result: Labrinth.Search.v3.SearchResults
} | null
const searchResults = rawResults?.result ?? { hits: [], total_hits: 0 }
const hits = searchResults.hits ?? []
serverHits.value = hits
serverPings.value = {}
pingServerHits(hits)
checkServerRunningStates(hits)
results.value = {
hits: [],
total_hits: 0,
limit: 1,
},
total_hits: searchResults.total_hits ?? 0,
limit: maxResults.value,
offset: 0,
}
} else {
let rawResults = (await get_search_results(requestParams.value)) as {
result: SearchResults
} | null
if (!rawResults) {
rawResults = {
result: {
hits: [],
total_hits: 0,
limit: 1,
offset: 0,
},
}
}
if (instance.value) {
const installedProjectIds = new Set([
...newlyInstalled.value,
...Object.values(instanceProjects.value ?? {})
.filter((x) => x.metadata)
.map((x) => x.metadata.project_id),
])
rawResults.result.hits = rawResults.result.hits.map((val) => ({
...val,
installed: installedProjectIds.has(val.project_id),
}))
}
results.value = rawResults.result
}
}
if (instance.value) {
for (const val of rawResults.result.hits) {
val.installed =
newlyInstalled.value.includes(val.project_id) ||
Object.values(instanceProjects.value).some(
(x) => x.metadata && x.metadata.project_id === val.project_id,
)
const currentFilterState = JSON.stringify({
query: query.value,
filters: toRaw(currentFilters.value),
sort: toRaw(currentSortType.value),
maxResults: maxResults.value,
projectTypes: toRaw(projectTypes.value),
})
if (previousFilterState.value && previousFilterState.value !== currentFilterState) {
currentPage.value = 1
}
}
results.value = rawResults.result
const currentFilterState = JSON.stringify({
query: query.value,
filters: currentFilters.value,
sort: currentSortType.value,
maxResults: maxResults.value,
projectTypes: projectTypes.value,
})
previousFilterState.value = currentFilterState
if (previousFilterState.value && previousFilterState.value !== currentFilterState) {
currentPage.value = 1
}
const persistentParams: LocationQuery = {}
previousFilterState.value = currentFilterState
const persistentParams: LocationQuery = {}
for (const [key, value] of Object.entries(route.query)) {
if (PERSISTENT_QUERY_PARAMS.includes(key)) {
persistentParams[key] = value
for (const [key, value] of Object.entries(route.query)) {
if (PERSISTENT_QUERY_PARAMS.includes(key)) {
persistentParams[key] = value
}
}
}
if (instanceHideInstalled.value) {
persistentParams.ai = 'true'
} else {
delete persistentParams.ai
}
if (instanceHideInstalled.value) {
persistentParams.ai = 'true'
} else {
delete persistentParams.ai
}
const params = {
...persistentParams,
...createPageParams(),
}
const params = {
...persistentParams,
...(isServer ? createServerPageParams() : createPageParams()),
}
breadcrumbs.setContext({
name: 'Discover content',
link: `/browse/${projectType.value}`,
query: params,
})
await router.replace({ path: route.path, query: params })
loading.value = false
breadcrumbs.setContext({
name: 'Discover content',
link: `/browse/${projectType.value}`,
query: params,
})
const queryString = Object.entries(params)
.flatMap(([key, value]) => {
const values = Array.isArray(value) ? value : [value]
return values
.filter((v): v is string => v != null)
.map((v) => `${encodeURIComponent(key)}=${encodeURIComponent(v)}`)
})
.join('&')
const newUrl = `${route.path}${queryString ? '?' + queryString : ''}`
window.history.replaceState(window.history.state, '', newUrl)
} catch (err) {
console.error('Error refreshing search:', err)
} finally {
loading.value = false
isRefreshing.value = false
}
}
async function setPage(newPageNumber: number) {
@@ -289,7 +473,7 @@ function clearSearch() {
}
watch(
() => route.params.projectType,
() => route.params.projectType as ProjectType,
async (newType) => {
// Check if the newType is not the same as the current value
if (!newType || newType === projectType.value) return
@@ -308,8 +492,9 @@ const selectableProjectTypes = computed(() => {
if (instance.value) {
if (
availableGameVersions.value.findIndex((x) => x.version === instance.value.game_version) <=
availableGameVersions.value.findIndex((x) => x.version === '1.13')
availableGameVersions.value &&
availableGameVersions.value.findIndex((x) => x.version === instance.value?.game_version) <=
availableGameVersions.value.findIndex((x) => x.version === '1.13')
) {
dataPacks = true
}
@@ -338,6 +523,7 @@ const selectableProjectTypes = computed(() => {
{ label: 'Resource Packs', href: `/browse/resourcepack` },
{ label: 'Data Packs', href: `/browse/datapack`, shown: dataPacks },
{ label: 'Shaders', href: `/browse/shader` },
{ label: 'Servers', href: `/browse/server`, shown: !instance.value },
]
if (params) {
@@ -360,23 +546,61 @@ const messages = defineMessages({
id: 'search.filter.locked.instance-game-version.title',
defaultMessage: 'Game version is provided by the instance',
},
gameVersionProvidedByServer: {
id: 'search.filter.locked.server-game-version.title',
defaultMessage: 'Game version is provided by the server',
},
modLoaderProvidedByInstance: {
id: 'search.filter.locked.instance-loader.title',
defaultMessage: 'Loader is provided by the instance',
},
modLoaderProvidedByServer: {
id: 'search.filter.locked.server-loader.title',
defaultMessage: 'Loader is provided by the server',
},
environmentProvidedByServer: {
id: 'search.filter.locked.server-environment.title',
defaultMessage: 'Only client-side mods can be added to the server instance',
},
providedByInstance: {
id: 'search.filter.locked.instance',
defaultMessage: 'Provided by the instance',
},
providedByServer: {
id: 'search.filter.locked.server',
defaultMessage: 'Provided by the server',
},
syncFilterButton: {
id: 'search.filter.locked.instance.sync',
defaultMessage: 'Sync with instance',
},
})
const getServerModpackContent = (project: Labrinth.Search.v3.ResultSearchProject) => {
const content = project.minecraft_java_server?.content
if (content?.kind === 'modpack') {
const { project_name, project_icon, project_id } = content
if (!project_name) return undefined
return {
name: project_name,
icon: project_icon,
onclick:
project_id !== project.project_id
? () => {
router.push(`/project/${project_id}`)
}
: undefined,
showCustomModpackTooltip: project_id === project.project_id,
}
}
return undefined
}
const options = ref(null)
// @ts-expect-error - no event types
const handleRightClick = (event, result) => {
options.value.showMenu(event, result, [
// @ts-ignore
options.value?.showMenu(event, result, [
{
name: 'open_link',
},
@@ -385,6 +609,7 @@ const handleRightClick = (event, result) => {
},
])
}
// @ts-expect-error - no event types
const handleOptionsClick = (args) => {
switch (args.option) {
case 'open_link':
@@ -424,33 +649,75 @@ previousFilterState.value = JSON.stringify({
@click.prevent.stop
/>
</div>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="instanceFilters"
:filter-type="filter"
class="border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid"
button-class="button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg"
content-class="mb-3"
inner-panel-class="ml-2 mr-3"
:open-by-default="
filter.id.startsWith('category') || filter.id === 'environment' || filter.id === 'license'
"
>
<template #header>
<h3 class="text-base m-0">{{ filter.formatted_name }}</h3>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByInstance) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByInstance) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }} </template>
</SearchSidebarFilter>
<template v-if="projectType === 'server'">
<SearchSidebarFilter
v-for="filterType in serverFilterTypes.filter((f) => f.options.length > 0)"
:key="`server-filter-${filterType.id}`"
v-model:selected-filters="serverCurrentFilters"
v-model:toggled-groups="serverToggledGroups"
:provided-filters="[]"
:filter-type="filterType"
class="border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid"
button-class="button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg"
content-class="mb-3"
inner-panel-class="ml-2 mr-3"
:open-by-default="
![
'server_category_minecraft_server_meta',
'server_category_minecraft_server_community',
'server_game_version',
].includes(filterType.id)
"
>
<template #header>
<h3 class="text-base m-0">{{ filterType.formatted_name }}</h3>
</template>
</SearchSidebarFilter>
</template>
<template v-else>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="instanceFilters"
:filter-type="filter"
class="border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid"
button-class="button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg"
content-class="mb-3"
inner-panel-class="ml-2 mr-3"
:open-by-default="
filter.id.startsWith('category') || filter.id === 'environment' || filter.id === 'license'
"
>
<template #header>
<h3 class="text-base m-0">{{ filter.formatted_name }}</h3>
</template>
<template #locked-game_version>
{{
formatMessage(
isServerInstance
? messages.gameVersionProvidedByServer
: messages.gameVersionProvidedByInstance,
)
}}
</template>
<template #locked-mod_loader>
{{
formatMessage(
isServerInstance
? messages.modLoaderProvidedByServer
: messages.modLoaderProvidedByInstance,
)
}}
</template>
<template #locked-environment>
{{ formatMessage(messages.environmentProvidedByServer) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }} </template>
</SearchSidebarFilter>
</template>
</Teleport>
<div ref="searchWrapper" class="flex flex-col gap-3 p-6">
<template v-if="instance">
@@ -472,11 +739,17 @@ previousFilterState.value = JSON.stringify({
<div class="flex gap-2">
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
:model-value="projectType === 'server' ? serverCurrentSortType : currentSortType"
class="max-w-[16rem]"
name="Sort by"
:options="sortTypes as any"
:options="(projectType === 'server' ? serverSortTypes : sortTypes) as any"
:display-name="(option: SortType | undefined) => option?.display"
@update:model-value="
(v: SortType) => {
if (projectType === 'server') serverCurrentSortType = v
else currentSortType = v
}
"
>
<span class="font-semibold text-primary">Sort by: </span>
<span class="font-semibold text-secondary">{{ selected }}</span>
@@ -494,46 +767,125 @@ previousFilterState.value = JSON.stringify({
<Pagination :page="currentPage" :count="pageCount" class="ml-auto" @switch-page="setPage" />
</div>
<SearchFilterControl
v-if="projectType === 'server'"
v-model:selected-filters="serverCurrentFilters"
:filters="serverFilterTypes"
:provided-filters="[]"
:overridden-provided-filter-types="[]"
/>
<SearchFilterControl
v-else
v-model:selected-filters="currentFilters"
:filters="filters.filter((f) => f.display !== 'none')"
:provided-filters="instanceFilters"
:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-message="messages.providedByInstance"
:provided-message="isServerInstance ? messages.providedByServer : messages.providedByInstance"
/>
<div class="search">
<section v-if="loading" class="offline">
<LoadingIndicator />
</section>
<section v-else-if="offline && results.total_hits === 0" class="offline">
<section v-else-if="offline && results?.total_hits === 0" class="offline">
You are currently offline. Connect to the internet to browse Modrinth!
</section>
<section
v-else-if="
projectType === 'server'
? serverHits.length === 0
: results && results.hits && results.hits.length === 0
"
class="offline"
>
No results found for your query!
</section>
<ProjectCardList v-else :layout="'list'">
<SearchCard
v-for="result in results.hits"
:key="result?.project_id"
:project-type="projectType"
:project="result"
:instance="instance"
:categories="[
...categories.filter(
(cat) =>
result?.display_categories.includes(cat.name) && cat.project_type === projectType,
),
...loaders.filter(
(loader) =>
result?.display_categories.includes(loader.name) &&
loader.supported_project_types?.includes(projectType),
),
]"
:installed="result.installed || newlyInstalled.includes(result.project_id)"
@install="
(id) => {
newlyInstalled.push(id)
}
"
@contextmenu.prevent.stop="(event) => handleRightClick(event, result)"
/>
<template v-if="projectType === 'server'">
<ProjectCard
v-for="project in serverHits"
:key="`server-card-${project.project_id}`"
:title="project.name"
:icon-url="project.icon_url || undefined"
:summary="project.summary"
:tags="project.categories"
:link="`/project/${project.slug ?? project.project_id}`"
:server-online-players="project.minecraft_java_server?.ping?.data?.players_online ?? 0"
:server-region-code="project.minecraft_server?.country"
:server-recent-plays="project.minecraft_java_server?.verified_plays_4w ?? 0"
:server-modpack-content="getServerModpackContent(project)"
:server-ping="serverPings[project.project_id]"
:server-status-online="!!project.minecraft_java_server?.ping?.data"
:hide-online-players-label="true"
:hide-recent-plays-label="true"
layout="list"
:max-tags="2"
is-server-project
exclude-loaders
@contextmenu.prevent.stop="
(event: any) =>
handleRightClick(event, { project_type: 'server', slug: project.slug })
"
>
<template #actions>
<div class="flex gap-2">
<ButtonStyled circular>
<button
v-tooltip="'Add server to instance'"
@click.stop="() => handleAddServerToInstance(project)"
>
<PlusIcon />
</button>
</ButtonStyled>
<ButtonStyled
v-if="runningServerProjects[project.project_id]"
color="red"
type="outlined"
>
<button @click="() => handleStopServerProject(project.project_id)">
<StopCircleIcon />
Stop
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" type="outlined">
<button
:disabled="
(installStore.installingServerProjects as string[]).includes(
project.project_id,
)
"
@click="() => handlePlayServerProject(project.project_id)"
>
<PlayIcon />
{{
(installStore.installingServerProjects as string[]).includes(
project.project_id,
)
? 'Installing...'
: 'Play'
}}
</button>
</ButtonStyled>
</div>
</template>
</ProjectCard>
</template>
<template v-else>
<SearchCard
v-for="result in results?.hits ?? []"
:key="result?.project_id"
:project-type="projectType"
:project="result"
:instance="instance ?? undefined"
:installed="result.installed || newlyInstalled.includes(result.project_id || '')"
@install="
(id) => {
newlyInstalled.push(id)
}
"
@contextmenu.prevent.stop="(event: any) => handleRightClick(event, result)"
/>
</template>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
+272 -102
View File
@@ -1,36 +1,95 @@
<template>
<div>
<div
class="p-6 pr-2 pb-4"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
>
<div v-if="instance">
<div class="p-6 pr-2 pb-4" @contextmenu.prevent.stop="(event) => handleRightClick(event)">
<ExportModal ref="exportModal" :instance="instance" />
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
<ButtonStyled v-if="themeStore.featureFlags.server_project_qa">
<button @click="updateToPlayModal.show()">Update to play modal</button>
</ButtonStyled>
<ContentPageHeader>
<template #icon>
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
<Avatar
:src="icon ? icon : undefined"
:alt="instance.name"
size="64px"
:tint-by="instance.path"
/>
</template>
<template #title>
{{ instance.name }}
</template>
<template #summary> </template>
<template #stats>
<div
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
>
<GameIcon class="h-6 w-6 text-secondary" />
{{ instance.loader }} {{ instance.game_version }}
</div>
<div class="flex items-center gap-2 font-semibold">
<TimerIcon class="h-6 w-6 text-secondary" />
<template v-if="timePlayed > 0">
{{ timePlayedHumanized }}
<div class="flex items-center flex-wrap gap-2">
<template v-if="!isServerInstance">
<div class="flex items-center gap-2 capitalize font-medium">
{{ instance.loader }} {{ instance.game_version }}
</div>
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div class="flex items-center gap-2 font-medium">
<template v-if="timePlayed > 0">
{{ timePlayedHumanized }}
</template>
<template v-else> Never played </template>
</div>
<div v-if="linkedProjectV3" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div
v-if="linkedProjectV3"
class="flex gap-1.5 items-center font-medium text-primary"
>
Linked to
<Avatar
:src="linkedProjectV3.icon_url"
:alt="linkedProjectV3.name"
:tint-by="instance.path"
size="24px"
/>
<router-link
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
class="hover:underline text-primary"
>
{{ linkedProjectV3.name }}
</router-link>
</div>
</template>
<template v-else>
<ServerOnlinePlayers :online="playersOnline ?? 0" :status-online="statusOnline" />
<div
v-if="playersOnline !== undefined && (minecraftServer?.country || ping)"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<ServerRegion v-if="minecraftServer?.country" :region="minecraftServer?.country" />
<ServerPing v-if="ping" :ping="ping" />
<div
v-if="minecraftServer?.country || ping"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<div
v-if="linkedProjectV3"
class="flex gap-1.5 items-center font-medium text-primary"
>
Linked to
<Avatar
:src="linkedProjectV3.icon_url"
:alt="linkedProjectV3.name"
:tint-by="instance.path"
size="24px"
/>
<router-link
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
class="hover:underline text-primary"
>
{{ linkedProjectV3.name }}
</router-link>
</div>
</template>
<template v-else> Never played </template>
</div>
</template>
<template #actions>
@@ -63,7 +122,7 @@
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="playing === false && loading === false"
v-else-if="playing === false && loading === false && !isServerInstance"
color="brand"
size="large"
>
@@ -72,6 +131,44 @@
Play
</button>
</ButtonStyled>
<div
v-else-if="playing === false && loading === false && isServerInstance"
class="joined-buttons"
>
<ButtonStyled color="brand" size="large">
<button @click="handlePlayServer()">
<PlayIcon />
Play
</button>
</ButtonStyled>
<ButtonStyled color="brand" size="large">
<OverflowMenu
:options="[
{
id: 'join_server',
action: () => handlePlayServer(),
},
{
id: 'launch_instance',
action: () => startInstance('InstancePage'),
},
]"
>
<div class="w-0 text-xl relative top-0.5 right-2.5">
<DropdownIcon />
</div>
<template #join_server>
<PlayIcon />
Join server
</template>
<template #launch_instance>
<PlayIcon />
Launch instance
</template>
</OverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled
v-else-if="loading === true && playing === false"
color="brand"
@@ -79,21 +176,23 @@
>
<button disabled>Loading...</button>
</ButtonStyled>
<ButtonStyled size="large" circular>
<button v-tooltip="'Instance settings'" @click="settingsModal.show()">
<ButtonStyled circular size="large">
<button v-tooltip="'Instance settings'" @click="settingsModal?.show()">
<SettingsIcon />
</button>
</ButtonStyled>
<ButtonStyled size="large" type="transparent" circular>
<ButtonStyled type="transparent" circular size="large">
<OverflowMenu
:options="[
{
id: 'open-folder',
action: () => showProfileInFolder(instance.path),
action: () => {
if (instance) showProfileInFolder(instance.path)
},
},
{
id: 'export-mrpack',
action: () => $refs.exportModal.show(),
action: () => exportModal?.show(),
},
]"
>
@@ -112,7 +211,11 @@
<NavTabs :links="tabs" />
</div>
<div v-if="!!instance" class="p-6 pt-4">
<RouterView v-slot="{ Component }" :key="instance.path">
<RouterView
v-if="route.path.startsWith('/instance')"
v-slot="{ Component }"
:key="instance.path"
>
<template v-if="Component">
<Suspense
:key="instance.path"
@@ -127,6 +230,7 @@
:playing="playing"
:versions="modrinthVersions"
:installed="instance.install_stage !== 'installed'"
:is-server-instance="isServerInstance"
@play="updatePlayState"
@stop="() => stopInstance('InstanceSubpage')"
></component>
@@ -160,16 +264,17 @@
</ContextMenu>
</div>
</template>
<script setup>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
CheckCircleIcon,
ClipboardCopyIcon,
DownloadIcon,
DropdownIcon,
EditIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GameIcon,
GlobeIcon,
HashIcon,
MoreVerticalIcon,
@@ -179,7 +284,6 @@ import {
ServerIcon,
SettingsIcon,
StopCircleIcon,
TimerIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
@@ -191,6 +295,9 @@ import {
injectNotificationManager,
LoadingIndicator,
OverflowMenu,
ServerOnlinePlayers,
ServerPing,
ServerRegion,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
@@ -205,20 +312,21 @@ import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.v
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_version_many } from '@/helpers/cache.js'
import { get_project_v3, get_version_many } from '@/helpers/cache.js'
import { process_listener, profile_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { showProfileInFolder } from '@/helpers/utils.js'
import { get_server_status, getServerLatency } from '@/helpers/worlds'
import { handleSevereError } from '@/store/error.js'
import { playServerProject } from '@/store/install.js'
import { useBreadcrumbs, useLoading } from '@/store/state'
import { useTheming } from '@/store/theme'
dayjs.extend(duration)
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
const themeStore = useTheming()
const route = useRoute()
const router = useRouter()
@@ -232,38 +340,73 @@ window.addEventListener('online', () => {
offline.value = false
})
const instance = ref()
const modrinthVersions = ref([])
const instance = ref<GameInstance>()
const modrinthVersions = ref<Labrinth.Versions.v2.Version[]>([])
const playing = ref(false)
const loading = ref(false)
const updateToPlayModal = ref()
const exportModal = ref<InstanceType<typeof ExportModal>>()
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
const isServerInstance = ref(false)
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
const selected = ref<unknown[]>([])
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
const statusOnline = computed(() => !!javaServerPingData.value)
const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
async function fetchInstance() {
instance.value = await get(route.params.id).catch(handleError)
isServerInstance.value = false
linkedProjectV3.value = undefined
modrinthVersions.value = []
ping.value = undefined
if (!offline.value && instance.value.linked_data && instance.value.linked_data.project_id) {
get_project(instance.value.linked_data.project_id, 'must_revalidate')
.catch(handleError)
.then((project) => {
if (project && project.versions) {
get_version_many(project.versions, 'must_revalidate')
.catch(handleError)
.then((versions) => {
modrinthVersions.value = versions.sort(
(a, b) => dayjs(b.date_published) - dayjs(a.date_published),
)
})
instance.value = await get(route.params.id as string).catch(handleError)
if (!offline.value && instance.value?.linked_data && instance.value.linked_data.project_id) {
try {
linkedProjectV3.value = await get_project_v3(
instance.value.linked_data.project_id,
'must_revalidate',
)
if (linkedProjectV3.value && linkedProjectV3.value.versions) {
const versions = await get_version_many(linkedProjectV3.value.versions, 'must_revalidate')
modrinthVersions.value = versions.sort(
(a: Labrinth.Versions.v2.Version, b: Labrinth.Versions.v2.Version) =>
dayjs(b.date_published).valueOf() - dayjs(a.date_published).valueOf(),
)
}
if (linkedProjectV3.value?.minecraft_server != null) {
isServerInstance.value = true
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
if (serverAddress) {
try {
const status = await get_server_status(serverAddress)
const latency = await getServerLatency(serverAddress)
ping.value = latency
playersOnline.value = status.players?.online
} catch (err) {
console.error(`Failed to ping server ${serverAddress}:`, err)
}
}
})
}
} catch (error) {
handleError(error as Error)
}
}
await updatePlayState()
}
async function updatePlayState() {
const runningProcesses = await get_by_profile_path(route.params.id).catch(handleError)
const runningProcesses = await get_by_profile_path(route.params.id as string).catch(handleError)
playing.value = runningProcesses.length > 0
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
}
await fetchInstance()
@@ -276,7 +419,7 @@ watch(
},
)
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id)}`)
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id as string)}`)
const tabs = computed(() => [
{
@@ -293,44 +436,52 @@ const tabs = computed(() => [
},
])
breadcrumbs.setName(
'Instance',
instance.value.name.length > 40
? instance.value.name.substring(0, 40) + '...'
: instance.value.name,
)
breadcrumbs.setContext({
name: instance.value.name,
link: route.path,
query: route.query,
})
if (instance.value) {
breadcrumbs.setName(
'Instance',
instance.value.name.length > 40
? instance.value.name.substring(0, 40) + '...'
: instance.value.name,
)
breadcrumbs.setContext({
name: instance.value.name,
link: route.path,
query: route.query,
})
}
const loadingBar = useLoading()
const options = ref(null)
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const startInstance = async (context: string) => {
if (!instance.value) return
if (updateToPlayModal.value?.hasUpdate) {
updateToPlayModal.value.show(instance.value)
return
}
const startInstance = async (context) => {
loading.value = true
try {
await run(route.params.id)
await run(route.params.id as string)
playing.value = true
} catch (err) {
handleSevereError(err, { profilePath: route.params.id })
handleSevereError(err, { profilePath: route.params.id as string })
}
loading.value = false
trackEvent('InstanceStart', {
trackEvent('InstancePlay', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: context,
})
}
const stopInstance = async (context) => {
const stopInstance = async (context: string) => {
playing.value = false
await kill(route.params.id).catch(handleError)
await kill(route.params.id as string).catch(handleError)
if (!instance.value) return
trackEvent('InstanceStop', {
loader: instance.value.loader,
game_version: instance.value.game_version,
@@ -338,11 +489,22 @@ const stopInstance = async (context) => {
})
}
const handlePlayServer = async () => {
if (!instance.value?.linked_data?.project_id) return
loading.value = true
try {
await playServerProject(instance.value.linked_data.project_id)
} finally {
await updatePlayState()
loading.value = false
}
}
const repairInstance = async () => {
await finish_install(instance.value).catch(handleError)
}
const handleRightClick = (event) => {
const handleRightClick = (event: MouseEvent) => {
const baseOptions = [
{ name: 'add_content' },
{ type: 'divider' },
@@ -351,7 +513,7 @@ const handleRightClick = (event) => {
{ name: 'copy_path' },
]
options.value.showMenu(
options.value?.showMenu(
event,
instance.value,
playing.value
@@ -372,7 +534,7 @@ const handleRightClick = (event) => {
)
}
const handleOptionsClick = async (args) => {
const handleOptionsClick = async (args: { option: string; item: unknown }) => {
switch (args.option) {
case 'play':
await startInstance('InstancePageContextMenu')
@@ -382,52 +544,60 @@ const handleOptionsClick = async (args) => {
break
case 'add_content':
await router.push({
path: `/browse/${instance.value.loader === 'vanilla' ? 'datapack' : 'mod'}`,
path: `/browse/${instance.value?.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: route.params.id },
})
break
case 'edit':
await router.push({
path: `/instance/${encodeURIComponent(route.params.id)}/options`,
path: `/instance/${encodeURIComponent(route.params.id as string)}/options`,
})
break
case 'open_folder':
await showProfileInFolder(instance.value.path)
if (instance.value) await showProfileInFolder(instance.value.path)
break
case 'copy_path': {
const fullPath = await get_full_path(instance.value.path)
await navigator.clipboard.writeText(fullPath)
if (instance.value) {
const fullPath = await get_full_path(instance.value?.path)
await navigator.clipboard.writeText(fullPath)
}
break
}
}
}
const unlistenProfiles = await profile_listener(async (event) => {
if (event.profile_path_id === route.params.id) {
if (event.event === 'removed') {
await router.push({
path: '/',
})
return
const unlistenProfiles = await profile_listener(
async (event: { profile_path_id: string; event: string }) => {
if (event.profile_path_id === route.params.id) {
if (event.event === 'removed') {
await router.push({
path: '/',
})
return
}
instance.value = await get(route.params.id as string).catch(handleError)
}
instance.value = await get(route.params.id).catch(handleError)
}
})
const unlistenProcesses = await process_listener((e) => {
if (e.event === 'finished' && e.profile_path_id === route.params.id) {
playing.value = false
}
})
const icon = computed(() =>
instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
},
)
const settingsModal = ref()
const unlistenProcesses = await process_listener(
(e: { event: string; profile_path_id: string }) => {
if (e.event === 'finished' && e.profile_path_id === route.params.id) {
playing.value = false
}
},
)
const icon = computed(() =>
instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : null,
)
const settingsModal = ref<InstanceType<typeof InstanceSettingsModal>>()
const timePlayed = computed(() => {
return instance.value.recent_time_played + instance.value.submitted_time_played
return instance.value
? instance.value.recent_time_played + instance.value.submitted_time_played
: 0
})
const timePlayedHumanized = computed(() => {
@@ -323,6 +323,7 @@ const props = defineProps<{
playing: boolean
versions: Version[]
installed: boolean
isServerInstance?: boolean
}>()
type ProjectListEntryAuthor = {
@@ -352,6 +353,7 @@ type ProjectListEntry = {
const isPackLocked = computed(() => {
return props.instance.linked_data && props.instance.linked_data.locked
})
const canUpdatePack = computed(() => {
if (!props.instance.linked_data || !props.versions || !props.versions[0]) return false
return props.instance.linked_data.version_id !== props.versions[0].id
@@ -80,9 +80,13 @@
@refresh="() => refreshServer((world as ServerWorld).address)"
@edit="
() =>
world.type === 'server' ? editServerModal?.show(world) : editWorldModal?.show(world)
isLinkedWorld(world)
? undefined
: world.type === 'server'
? editServerModal?.show(world)
: editWorldModal?.show(world)
"
@delete="() => promptToRemoveWorld(world)"
@delete="() => !isLinkedWorld(world) && promptToRemoveWorld(world)"
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
/>
</div>
@@ -150,6 +154,7 @@ import {
handleDefaultProfileUpdateEvent,
hasServerQuickPlaySupport,
hasWorldQuickPlaySupport,
isLinkedWorld,
type ProfileEvent,
type ProtocolVersion,
refreshServerData,
@@ -166,6 +171,7 @@ import {
start_join_singleplayer_world,
type World,
} from '@/helpers/worlds.ts'
import { playServerProject } from '@/store/install'
const { handleError } = injectNotificationManager()
const route = useRoute()
@@ -328,6 +334,9 @@ async function joinWorld(world: World) {
startingInstance.value = true
worldPlaying.value = world
if (world.type === 'server') {
if (isLinkedWorld(world)) {
playServerProject(world.linked_project_id)
}
await start_join_server(instance.value.path, world.address).catch(handleJoinError)
} else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(instance.value.path, world.path).catch(handleJoinError)
@@ -10,7 +10,7 @@ defineProps({
</script>
<template>
<GridDisplay
v-if="instances.length > 0"
v-if="instances && instances.length > 0"
label="Instances"
:instances="instances.filter((i) => !i.linked_data)"
/>
@@ -10,7 +10,7 @@ defineProps({
</script>
<template>
<GridDisplay
v-if="instances.length > 0"
v-if="instances && instances.length > 0"
label="Instances"
:instances="instances.filter((i) => i.linked_data)"
/>
@@ -47,7 +47,7 @@ onUnmounted(() => {
{ label: 'Saved', href: `/library/saved`, shown: false },
]"
/>
<template v-if="instances.length > 0">
<template v-if="instances && instances.length > 0">
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
</template>
<div v-else class="no-instance">
@@ -9,5 +9,5 @@ defineProps({
})
</script>
<template>
<GridDisplay v-if="instances.length > 0" label="Instances" :instances="instances" />
<GridDisplay v-if="instances && instances.length > 0" label="Instances" :instances="instances" />
</template>
@@ -1,6 +1,6 @@
<template>
<div class="gallery">
<Card v-for="(image, index) in project.gallery" :key="image.url" class="gallery-item">
<Card v-for="(image, index) in filteredGallery" :key="image.url" class="gallery-item">
<a @click="expandImage(image, index)">
<img :src="image.url" :alt="image.title" class="gallery-image" />
</a>
@@ -64,14 +64,14 @@
<ContractIcon v-else aria-hidden="true" />
</Button>
<Button
v-if="project.gallery.length > 1"
v-if="filteredGallery.length > 1"
class="previous"
icon-only
@click="previousImage()"
>
<LeftArrowIcon aria-hidden="true" />
</Button>
<Button v-if="project.gallery.length > 1" class="next" icon-only @click="nextImage()">
<Button v-if="filteredGallery.length > 1" class="next" icon-only @click="nextImage()">
<RightArrowIcon aria-hidden="true" />
</Button>
</div>
@@ -92,11 +92,13 @@ import {
XIcon,
} from '@modrinth/assets'
import { Button, Card } from '@modrinth/ui'
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { trackEvent } from '@/helpers/analytics'
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
const props = defineProps({
project: {
type: Object,
@@ -104,6 +106,10 @@ const props = defineProps({
},
})
const filteredGallery = computed(
() => props.project.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
)
const expandedGalleryItem = ref(null)
const expandedGalleryIndex = ref(0)
const zoomedIn = ref(false)
@@ -115,10 +121,10 @@ const hideImage = () => {
const nextImage = () => {
expandedGalleryIndex.value++
if (expandedGalleryIndex.value >= props.project.gallery.length) {
if (expandedGalleryIndex.value >= filteredGallery.value.length) {
expandedGalleryIndex.value = 0
}
expandedGalleryItem.value = props.project.gallery[expandedGalleryIndex.value]
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
trackEvent('GalleryImageNext', {
project_id: props.project.id,
url: expandedGalleryItem.value.url,
@@ -128,9 +134,9 @@ const nextImage = () => {
const previousImage = () => {
expandedGalleryIndex.value--
if (expandedGalleryIndex.value < 0) {
expandedGalleryIndex.value = props.project.gallery.length - 1
expandedGalleryIndex.value = filteredGallery.value.length - 1
}
expandedGalleryItem.value = props.project.gallery[expandedGalleryIndex.value]
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
trackEvent('GalleryImagePrevious', {
project_id: props.project.id,
url: expandedGalleryItem.value,
+230 -14
View File
@@ -1,13 +1,31 @@
<template>
<div>
<InstallToPlayModal ref="installToPlayModal" :project="data" />
<div v-if="data">
<Teleport to="#sidebar-teleport-target">
<ProjectSidebarCompatibility
v-if="!isServerProject"
:project="data"
:tags="{ loaders: allLoaders, gameVersions: allGameVersions }"
:v3-metadata="projectV3"
class="project-sidebar-section"
/>
<ProjectSidebarServerInfo
v-if="isServerProject"
:project-v3="projectV3"
:tags="{ loaders: allLoaders, gameVersions: allGameVersions }"
:required-content="serverRequiredContent"
:recommended-version="serverRecommendedVersion"
:supported-versions="serverSupportedVersions"
:loaders="serverModpackLoaders"
:ping="serverPing"
:status-online="serverStatusOnline"
class="project-sidebar-section"
/>
<ProjectSidebarLinks
link-target="_blank"
:project="data"
:project-v3="projectV3"
class="project-sidebar-section"
/>
<ProjectSidebarLinks link-target="_blank" :project="data" class="project-sidebar-section" />
<ProjectSidebarCreators
:organization="null"
:members="members"
@@ -16,17 +34,16 @@
link-target="_blank"
class="project-sidebar-section"
/>
<ProjectSidebarTags :project="data" class="project-sidebar-section" />
<ProjectSidebarDetails
:project="data"
:has-versions="versions.length > 0"
:link-target="`_blank`"
:hide-license="isServerProject"
class="project-sidebar-section"
/>
</Teleport>
<div class="flex flex-col gap-4 p-6">
<ButtonStyled v-if="themeStore.featureFlags.server_project_qa">
<button @click="installToPlayModal.show()">Install to play modal</button>
</ButtonStyled>
<InstanceIndicator v-if="instance" :instance="instance" />
<template v-if="data">
<Teleport
@@ -35,7 +52,67 @@
>
<ProjectBackgroundGradient :project="data" />
</Teleport>
<ProjectHeader :project="data" @contextmenu.prevent.stop="handleRightClick">
<ServerProjectHeader
v-if="isServerProject"
:project="data"
:project-v3="projectV3"
:ping="serverPing"
@contextmenu.prevent.stop="handleRightClick"
>
<template #actions>
<ButtonStyled v-if="serverPlaying" size="large" color="red">
<button @click="handleStopServer">
<StopCircleIcon />
Stop
</button>
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand">
<button
:disabled="data && installStore.installingServerProjects.includes(data.id)"
@click="handleClickPlay"
>
<PlayIcon />
{{
data && installStore.installingServerProjects.includes(data.id)
? 'Installing...'
: 'Play'
}}
</button>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<button v-tooltip="'Add server to instance'" @click="handleAddServerToInstance">
<PlusIcon />
</button>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:tooltip="`More options`"
:options="[
{
id: 'open-in-browser',
link: `https://modrinth.com/project/${data.slug}`,
external: true,
},
{
divider: true,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
link: `https://modrinth.com/report?item=project&itemID=${data.id}`,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
<template #report> <ReportIcon /> Report </template>
</OverflowMenu>
</ButtonStyled>
</template>
</ServerProjectHeader>
<ProjectHeader v-else :project="data" @contextmenu.prevent.stop="handleRightClick">
<template #actions>
<ButtonStyled size="large" color="brand">
<button
@@ -103,6 +180,7 @@
query: instanceFilters,
},
subpages: ['version'],
shown: projectV3?.minecraft_server == null,
},
{
label: 'Gallery',
@@ -112,6 +190,7 @@
]"
/>
<RouterView
v-if="route.path.startsWith('/project')"
:project="data"
:versions="versions"
:members="members"
@@ -142,7 +221,10 @@ import {
GlobeIcon,
HeartIcon,
MoreVerticalIcon,
PlayIcon,
PlusIcon,
ReportIcon,
StopCircleIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
@@ -154,22 +236,43 @@ import {
ProjectSidebarCreators,
ProjectSidebarDetails,
ProjectSidebarLinks,
ProjectSidebarServerInfo,
ProjectSidebarTags,
ServerProjectHeader,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, ref, shallowRef, watch } from 'vue'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { get_project, get_team, get_version_many } from '@/helpers/cache.js'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
import {
get_project,
get_project_v3,
get_team,
get_version,
get_version_many,
} from '@/helpers/cache.js'
import { process_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import {
get as getInstance,
get_projects as getInstanceProjects,
kill,
list as listInstances,
} from '@/helpers/profile'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { getServerLatency } from '@/helpers/worlds'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { install as installVersion } from '@/store/install.js'
import {
getServerAddress,
install as installVersion,
playServerProject,
useInstall,
} from '@/store/install.js'
import { useTheming } from '@/store/state.js'
dayjs.extend(relativeTime)
@@ -180,6 +283,7 @@ const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const installStore = useInstall()
const installing = ref(false)
const data = shallowRef(null)
const versions = shallowRef([])
@@ -190,8 +294,16 @@ const instanceProjects = ref(null)
const installed = ref(false)
const installedVersion = ref(null)
const installToPlayModal = ref()
const isServerProject = ref(false)
const projectV3 = shallowRef(null)
const serverRequiredContent = shallowRef(null)
const serverRecommendedVersion = shallowRef(null)
const serverSupportedVersions = shallowRef([])
const serverModpackLoaders = shallowRef([])
const serverPing = ref(undefined)
const serverStatusOnline = ref(false)
const serverInstancePath = ref(null)
const serverPlaying = ref(false)
const instanceFilters = computed(() => {
if (!instance.value) {
@@ -216,8 +328,41 @@ const [allLoaders, allGameVersions] = await Promise.all([
get_game_versions().catch(handleError).then(ref),
])
async function handleClickPlay() {
if (!isServerProject.value) return
await playServerProject(data.value.id).catch(handleError)
await updateServerPlayState()
}
async function updateServerPlayState() {
if (!isServerProject.value || !data.value) return
const packs = await listInstances()
const inst = packs.find((p) => p.linked_data?.project_id === data.value.id)
if (inst) {
serverInstancePath.value = inst.path
const processes = await get_by_profile_path(inst.path).catch(() => [])
serverPlaying.value = Array.isArray(processes) && processes.length > 0
} else {
serverInstancePath.value = null
serverPlaying.value = false
}
}
async function handleStopServer() {
if (!serverInstancePath.value) return
await kill(serverInstancePath.value).catch(() => {})
serverPlaying.value = false
}
function handleAddServerToInstance() {
const address = getServerAddress(projectV3.value?.minecraft_java_server)
if (!address || !data.value) return
installStore.showAddServerToInstanceModal(data.value.title, address)
}
async function fetchProjectData() {
const project = await get_project(route.params.id, 'must_revalidate').catch(handleError)
projectV3.value = await get_project_v3(route.params.id, 'must_revalidate').catch(handleError)
if (!project) {
handleError('Error loading project')
@@ -245,11 +390,82 @@ async function fetchProjectData() {
installedVersion.value = installedFile.metadata.version_id
}
}
isServerProject.value = projectV3.value?.minecraft_server != null
// Ping server for latency
const serverAddress = projectV3.value?.minecraft_java_server?.address
serverStatusOnline.value = !!projectV3.value?.minecraft_java_server?.ping?.data
if (serverAddress) {
serverPing.value = undefined
try {
serverPing.value = await getServerLatency(serverAddress)
} catch (error) {
console.error(`Failed to ping server ${serverAddress}:`, error)
}
}
// Fetch server sidebar data (modpack version + project)
const content = projectV3.value?.minecraft_java_server?.content
if (content?.kind === 'modpack' && content.version_id) {
const modpackVersion = await get_version(content.version_id, 'bypass').catch(handleError)
if (modpackVersion) {
serverRecommendedVersion.value = modpackVersion.game_versions?.[0] ?? null
serverModpackLoaders.value = modpackVersion.mrpack_loaders ?? []
if (modpackVersion.project_id) {
const modpackProject = await get_project_v3(
modpackVersion.project_id,
'must_revalidate',
).catch(handleError)
if (modpackProject) {
const primaryFile =
modpackVersion.files?.find((f) => f.primary) ?? modpackVersion.files?.[0]
serverRequiredContent.value = {
name: modpackProject.name,
versionNumber: modpackVersion.version_number ?? '',
icon: modpackProject.icon_url,
onclickName:
modpackProject.id !== project.id
? () => router.push(`/project/${modpackProject.id}`)
: undefined,
onclickVersion:
modpackProject.id !== project.id
? () => router.push(`/project/${modpackProject.id}/version/${modpackVersion.id}`)
: undefined,
onclickDownload: primaryFile?.url ? () => openUrl(primaryFile.url) : undefined,
showCustomModpackTooltip: modpackProject.id === project.id,
}
}
}
}
} else if (content?.kind === 'vanilla') {
serverRecommendedVersion.value = content.recommended_game_version ?? null
const supported = content.supported_game_versions ?? []
serverSupportedVersions.value = supported.filter((v) => !!v)
}
breadcrumbs.setName('Project', data.value.title)
await updateServerPlayState()
}
await fetchProjectData()
const unlistenProcesses = await process_listener((e) => {
if (
e.event === 'finished' &&
serverInstancePath.value &&
e.profile_path_id === serverInstancePath.value
) {
serverPlaying.value = false
}
})
onUnmounted(() => {
unlistenProcesses()
})
watch(
() => route.params.id,
async () => {
@@ -0,0 +1,30 @@
import { AbstractPopupNotificationManager, type PopupNotification } from '@modrinth/ui'
import { type Ref, ref } from 'vue'
export class AppPopupNotificationManager extends AbstractPopupNotificationManager {
private readonly state: Ref<PopupNotification[]>
public constructor() {
super()
this.state = ref<PopupNotification[]>([])
}
public getNotifications(): PopupNotification[] {
return this.state.value
}
protected addNotificationToStorage(notification: PopupNotification): void {
this.state.value.push(notification)
}
protected removeNotificationFromStorage(id: string | number): void {
const index = this.state.value.findIndex((n) => n.id === id)
if (index > -1) {
this.state.value.splice(index, 1)
}
}
protected clearAllNotificationsFromStorage(): void {
this.state.value.splice(0)
}
}
+367 -7
View File
@@ -2,22 +2,42 @@ import dayjs from 'dayjs'
import { defineStore } from 'pinia'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_version_many } from '@/helpers/cache.js'
import { create_profile_and_install as packInstall } from '@/helpers/pack.js'
import { get_project, get_project_v3, get_version, get_version_many } from '@/helpers/cache.js'
import {
create_profile_and_install as packInstall,
install_to_existing_profile,
} from '@/helpers/pack.js'
import {
add_project_from_version,
check_installed,
create,
edit,
edit_icon,
get,
get_projects,
install as installProfile,
list,
remove_project,
} from '@/helpers/profile.js'
import {
add_server_to_profile,
edit_server_in_profile,
get_profile_worlds,
start_join_server,
} from '@/helpers/worlds.ts'
import router from '@/routes.js'
import { handleSevereError } from '@/store/error.js'
export const useInstall = defineStore('installStore', {
state: () => ({
installConfirmModal: null,
modInstallModal: null,
incompatibilityWarningModal: null,
installToPlayModal: null,
updateToPlayModal: null,
popupNotificationManager: null,
installingServerProjects: [],
addServerToInstanceModal: null,
}),
actions: {
setInstallConfirmModal(ref) {
@@ -38,6 +58,38 @@ export const useInstall = defineStore('installStore', {
showModInstallModal(project, versions, onInstall) {
this.modInstallModal.show(project, versions, onInstall)
},
setInstallToPlayModal(ref) {
this.installToPlayModal = ref
},
showInstallToPlayModal(projectV3, modpackVersionId, onInstallComplete) {
this.installToPlayModal.show(projectV3, modpackVersionId, onInstallComplete)
},
setUpdateToPlayModal(ref) {
this.updateToPlayModal = ref
},
showUpdateToPlayModal(instance, activeVersionId, onUpdateComplete) {
this.updateToPlayModal.show(instance, activeVersionId, onUpdateComplete)
},
setPopupNotificationManager(manager) {
this.popupNotificationManager = manager
},
setAddServerToInstanceModal(ref) {
this.addServerToInstanceModal = ref
},
showAddServerToInstanceModal(serverName, serverAddress) {
this.addServerToInstanceModal.show(serverName, serverAddress)
},
startInstallingServer(projectId) {
if (!this.installingServerProjects.includes(projectId)) {
this.installingServerProjects.push(projectId)
}
},
stopInstallingServer(projectId) {
this.installingServerProjects = this.installingServerProjects.filter((id) => id !== projectId)
},
isServerInstalling(projectId) {
return this.installingServerProjects.includes(projectId)
},
},
})
@@ -87,9 +139,10 @@ export const install = async (
callback = () => {},
createInstanceCallback = () => {},
) => {
const project = await get_project(projectId, 'must_revalidate')
const project = await get_project(projectId, 'bypass')
const projectV3 = await get_project_v3(projectId, 'bypass')
if (project.project_type === 'modpack') {
if (project.project_type === 'modpack' || projectV3?.minecraft_server != null) {
const version = versionId ?? project.versions[project.versions.length - 1]
const packs = await list()
@@ -119,7 +172,7 @@ export const install = async (
const [instance, instanceProjects, versions] = await Promise.all([
await get(instancePath),
await get_projects(instancePath),
await get_version_many(project.versions, 'must_revalidate'),
await get_version_many(project.versions, 'bypass'),
])
const projectVersions = versions.sort(
@@ -207,9 +260,9 @@ export const installVersionDependencies = async (profile, version) => {
} else {
if (dep.project_id && (await check_installed(profile.path, dep.project_id))) continue
const depProject = await get_project(dep.project_id, 'must_revalidate')
const depProject = await get_project(dep.project_id, 'bypass')
const depVersions = (await get_version_many(depProject.versions, 'must_revalidate')).sort(
const depVersions = (await get_version_many(depProject.versions, 'bypass')).sort(
(a, b) => dayjs(b.date_published) - dayjs(a.date_published),
)
@@ -220,3 +273,310 @@ export const installVersionDependencies = async (profile, version) => {
}
}
}
/**
* Server projects that use modpack content use have linked_data.project_id as
* the server project id and linked_data.version_id as the modpack content version id
*
* The modpack content version can be of the same server project, or from a different project
*/
export const installServerProject = async (serverProjectId) => {
const [project, projectV3] = await Promise.all([
get_project(serverProjectId, 'bypass'),
get_project_v3(serverProjectId, 'bypass'),
])
const serverAddress = getServerAddress(projectV3?.minecraft_java_server)
const content = projectV3?.minecraft_java_server?.content
if (!content || content.kind !== 'modpack') return
const contentVersionId = content.version_id
const contentVersion = await get_version(contentVersionId, 'bypass')
const contentProjectId = contentVersion.project_id
const gameVersion = contentVersion.game_versions?.[0] ?? ''
const profilePath = await create(
project.title,
gameVersion,
'vanilla',
null,
project.icon_url,
true,
{
project_id: serverProjectId,
version_id: contentVersionId,
locked: true,
},
)
// Save the icon path before pack install overwrites it
const profileBeforeInstall = await get(profilePath)
const originalIconPath = profileBeforeInstall?.icon_path ?? null
await install_to_existing_profile(contentProjectId, contentVersionId, project.title, profilePath)
// Pack install overwrites name, icon, and linked_data with the content project's values.
// Restore them to point to the server project.
await edit(profilePath, {
name: project.title,
linked_data: {
project_id: serverProjectId,
version_id: contentVersionId,
locked: true,
},
})
await edit_icon(profilePath, originalIconPath)
await syncServerProjectAsWorld(profilePath, project.title, serverAddress, serverProjectId)
}
export const getServerAddress = (javaServer) => {
if (!javaServer) return null
const { address, port } = javaServer
return port !== 25565 ? `${address}:${port}` : address
}
const syncServerProjectAsWorld = async (
profilePath,
serverName,
serverAddress,
serverProjectId = null,
) => {
if (!profilePath || !serverAddress) return
try {
const worlds = await get_profile_worlds(profilePath)
if (serverProjectId) {
// Check if a linked world for this project already exists
const linkedWorld = worlds.find(
(w) => w.type === 'server' && w.linked_project_id === serverProjectId,
)
if (linkedWorld) {
// Sync linked world data with project details
if (linkedWorld.address !== serverAddress || linkedWorld.name !== serverName) {
await edit_server_in_profile(
profilePath,
linkedWorld.index,
serverName,
serverAddress,
linkedWorld.pack_status,
serverProjectId,
)
}
return
}
}
const existingServer = worlds.find((w) => w.type === 'server' && w.address === serverAddress)
if (existingServer) {
// Re-link and sync existing server (link may have been lost by Minecraft rewriting servers.dat)
if (serverProjectId || existingServer.name !== serverName) {
await edit_server_in_profile(
profilePath,
existingServer.index,
serverName,
serverAddress,
existingServer.pack_status,
serverProjectId ?? undefined,
)
}
} else {
await add_server_to_profile(
profilePath,
serverName,
serverAddress,
'prompt',
serverProjectId ?? undefined,
)
}
} catch (err) {
console.error('Failed to add server to instance worlds:', err)
}
}
const joinServer = async (profilePath, serverAddress) => {
if (!serverAddress) return
await start_join_server(profilePath, serverAddress)
}
const findInstalledInstance = async (projectId) => {
const packs = await list()
return packs.find((pack) => pack.linked_data?.project_id === projectId) ?? null
}
const createVanillaServerInstance = async (project, gameVersion, serverAddress) => {
const profilePath = await create(
project.title,
gameVersion,
'fabric',
'latest',
project.icon_url,
false,
{
project_id: project.id,
version_id: '',
locked: true,
},
)
//
await syncServerProjectAsWorld(profilePath, project.title, serverAddress, project.id)
return profilePath
}
const updateVanillaGameVersion = async (instance, targetGameVersion) => {
if (instance.game_version === targetGameVersion) return
await edit(instance.path, { game_version: targetGameVersion })
await installProfile(instance.path, false)
}
const showModpackInstallSuccess = (installStore, project, serverAddress) => {
installStore.popupNotificationManager?.addPopupNotification({
title: 'Install complete',
text: `${project.name} is installed and ready to play.`,
type: 'success',
buttons: [
...(serverAddress
? [
{
label: 'Launch game',
action: async () => {
try {
await joinServer(
project.path,
serverAddress,
project.linked_data?.project_id ?? null,
)
} catch (err) {
handleSevereError(err, { profilePath: project.path })
}
},
color: 'brand',
},
]
: []),
{
label: 'Instance',
action: () => router.push(`/instance/${encodeURIComponent(project.path)}`),
},
],
autoCloseMs: null,
})
}
const showUpdateSuccess = (installStore, instance, serverAddress) => {
installStore.popupNotificationManager?.addPopupNotification({
title: 'Update complete',
text: `${instance.name} has been updated and is ready to play.`,
type: 'success',
buttons: [
...(serverAddress
? [
{
label: 'Launch game',
action: async () => {
try {
if (serverAddress) await start_join_server(instance.path, serverAddress)
} catch (err) {
handleSevereError(err, { profilePath: instance.path })
}
},
color: 'brand',
},
]
: []),
{
label: 'Instance',
action: () => router.push(`/instance/${encodeURIComponent(instance.path)}`),
},
],
autoCloseMs: null,
})
}
/**
* Handles logic when clicking "Play" on a server project. This includes:
* - Checking if need to install modpack content. If so, opens install to play modal
* - Checking if need to update modpack content. If so, open update to play modal
* - Checking if need to create instance for vanilla server. If so, creates instance.
* - Adding server to worlds list if not already there
* - Joining server
*/
export const playServerProject = async (projectId) => {
const installStore = useInstall()
const [project, projectV3] = await Promise.all([
get_project(projectId, 'bypass'),
get_project_v3(projectId, 'bypass'),
])
if (projectV3?.minecraft_server == null) {
console.warn('playServerProject failed: project is not a server project')
return
}
const content = projectV3?.minecraft_java_server?.content
const serverAddress = getServerAddress(projectV3?.minecraft_java_server)
const isVanilla = content?.kind === 'vanilla'
const isModpack = content?.kind === 'modpack'
const modpackVersionId = content?.version_id ?? null
const recommendedGameVersion = content?.recommended_game_version
let instance = await findInstalledInstance(project.id)
if (isVanilla && !instance) {
if (installStore.installingServerProjects.includes(projectId)) return
installStore.startInstallingServer(projectId)
try {
const path = await createVanillaServerInstance(project, recommendedGameVersion, serverAddress)
if (path) {
instance = await get(path)
showModpackInstallSuccess(installStore, instance, serverAddress)
}
} finally {
installStore.stopInstallingServer(projectId)
}
return
}
if (isModpack && !instance) {
installStore.showInstallToPlayModal(projectV3, modpackVersionId, async () => {
const newInstance = await findInstalledInstance(project.id)
if (!newInstance) return
showModpackInstallSuccess(installStore, newInstance, serverAddress)
})
return
}
if (!instance) return
await syncServerProjectAsWorld(instance.path, project.title, serverAddress, project.id)
// Update existing instance if needed
if (isModpack && instance.linked_data?.version_id !== modpackVersionId) {
installStore.showUpdateToPlayModal(instance, modpackVersionId, () => {
showUpdateSuccess(installStore, instance, serverAddress)
})
return
}
if (isVanilla && instance.game_version !== recommendedGameVersion) {
if (installStore.installingServerProjects.includes(projectId)) return
installStore.startInstallingServer(projectId)
try {
await updateVanillaGameVersion(instance, recommendedGameVersion)
showUpdateSuccess(installStore, instance, serverAddress)
} finally {
installStore.stopInstallingServer(projectId)
}
return
}
// join server
try {
await joinServer(instance.path, serverAddress, project.id)
} catch (err) {
handleSevereError(err, { profilePath: instance.path })
}
}
+4
View File
@@ -29,6 +29,8 @@ fn main() {
.commands(&[
"get_project",
"get_project_many",
"get_project_v3",
"get_project_v3_many",
"get_version",
"get_version_many",
"get_user",
@@ -39,6 +41,8 @@ fn main() {
"get_organization_many",
"get_search_results",
"get_search_results_many",
"get_search_results_v3",
"get_search_results_v3_many",
"purge_cache_types",
])
.default_permission(
+7 -1
View File
@@ -30,11 +30,13 @@ macro_rules! impl_cache_methods {
impl_cache_methods!(
(Project, Project),
(ProjectV3, ProjectV3),
(Version, Version),
(User, User),
(Team, Vec<TeamMember>),
(Organization, Organization),
(SearchResults, SearchResults)
(SearchResults, SearchResults),
(SearchResultsV3, SearchResultsV3)
);
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
@@ -42,6 +44,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
.invoke_handler(tauri::generate_handler![
get_project,
get_project_many,
get_project_v3,
get_project_v3_many,
get_version,
get_version_many,
get_user,
@@ -52,6 +56,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
get_organization_many,
get_search_results,
get_search_results_many,
get_search_results_v3,
get_search_results_v3_many,
purge_cache_types,
])
.build()
+10 -2
View File
@@ -6,6 +6,7 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use theseus::prelude::*;
use theseus::profile::QuickPlayType;
use theseus::server_address::ServerAddress;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("profile")
@@ -250,8 +251,15 @@ pub async fn profile_get_pack_export_candidates(
// for the actual Child in the state.
// invoke('plugin:profile|profile_run', path)
#[tauri::command]
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
let process = profile::run(path, QuickPlayType::None).await?;
pub async fn profile_run(
path: &str,
server_address: Option<String>,
) -> Result<ProcessMetadata> {
let quick_play = match server_address {
Some(addr) => QuickPlayType::Server(ServerAddress::Unresolved(addr)),
None => QuickPlayType::None,
};
let process = profile::run(path, quick_play).await?;
Ok(process)
}
+2 -1
View File
@@ -20,6 +20,7 @@ pub async fn profile_create(
loader_version: Option<String>, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
icon: Option<String>, // the icon for the profile
skip_install: Option<bool>,
linked_data: Option<LinkedData>,
) -> Result<String> {
let res = profile::create::profile_create(
name,
@@ -27,7 +28,7 @@ pub async fn profile_create(
modloader,
loader_version,
icon,
None,
linked_data,
skip_install,
)
.await?;
+18 -5
View File
@@ -151,12 +151,17 @@ pub async fn add_server_to_profile(
name: String,
address: String,
pack_status: ServerPackStatus,
linked_project_id: Option<String>,
) -> Result<usize> {
let path = get_full_path(path).await?;
Ok(
worlds::add_server_to_profile(&path, name, address, pack_status)
.await?,
Ok(worlds::add_server_to_profile(
&path,
name,
address,
pack_status,
linked_project_id,
)
.await?)
}
#[tauri::command]
@@ -166,10 +171,18 @@ pub async fn edit_server_in_profile(
name: String,
address: String,
pack_status: ServerPackStatus,
linked_project_id: Option<String>,
) -> Result<()> {
let path = get_full_path(path).await?;
worlds::edit_server_in_profile(&path, index, name, address, pack_status)
.await?;
worlds::edit_server_in_profile(
&path,
index,
name,
address,
pack_status,
linked_project_id,
)
.await?;
Ok(())
}
+43
View File
@@ -0,0 +1,43 @@
# apps/frontend — Modrinth Website
Nuxt 3 application serving the main Modrinth website. Uses Vue 3, Tailwind CSS v3, and file-based routing.
## Architecture
Nuxt 3 with SSR — pages are server-rendered and hydrated on the client. Uses `$fetch` for server-side data fetching and `@modrinth/api-client` (via `NuxtModrinthClient`) for client-side API calls.
## Key Directories
- **`src/pages/`** — file-based routing (`[param].vue` for dynamic segments, nested folders for nested routes)
- **`src/components/`** — website-specific components (not shared with the app)
- **`src/composables/`** — Vue composables, including `queries/` for TanStack Query options
- **`src/providers/`** — page-level DI context providers (e.g., version modal, project page)
- **`src/plugins/`** — Nuxt plugins (TanStack Query setup, theme, etc.)
- **`src/middleware/`** — route guards and auth checks
- **`src/layouts/`** — Nuxt layout components
- **`src/server/`** — server-side plugins, routes, and utilities
- **`src/store/`** — Pinia state management
- **`src/helpers/`** — utility functions
- **`src/locales/`** — i18n translation files
## Components
**Website-specific components go in `src/components/`.** These are components that only make sense in the website context — admin panels, moderation tools, dashboard widgets, brand components, etc.
**Shared components go in `packages/ui`.** If a component could be used by both the website and the desktop app, it belongs in `packages/ui/src/components/`. See `packages/ui/CLAUDE.md` for UI standards, color rules, and component patterns.
Rule of thumb: if it doesn't depend on Nuxt-specific APIs or website-only features, it should be in `packages/ui`.
## Data Fetching
Use `@modrinth/api-client` via `injectModrinthClient()` for all API calls. See `packages/api-client/CLAUDE.md` for the full API client documentation.
For caching and server state, use TanStack Query (`@tanstack/vue-query`). See the `tanstack-query` skill (`.claude/skills/tanstack-query/SKILL.md`) for patterns and conventions used in this codebase.
### Deprecated Composables
These composables are deprecated and should not be used in new code:
- **`useAsyncData`** - we use tanstack, not nuxt's built in async data utility.
- **`useBaseFetch`** (`src/composables/fetch.js`) — legacy Labrinth fetch wrapper. Use `client.labrinth.*` modules instead.
- **`useServersFetch`** (`src/composables/servers/servers-fetch.ts`) — legacy Archon fetch wrapper with manual retry/circuit-breaker. Use `client.archon.*` modules instead — refer to the `packages/api-client/CLAUDE.md` for more information.
@@ -34,7 +34,7 @@
}
.value {
color: var(--color-text-dark);
color: var(--color-text-primary);
font-weight: bold;
font-size: 2rem;
}
@@ -99,7 +99,7 @@
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
// Same styling as h3
color: var(--color-text-dark);
color: var(--color-text-primary);
font-size: 1.17rem;
font-weight: bold;
@@ -381,7 +381,7 @@
}
&:active {
color: var(--color-text-dark);
color: var(--color-text-primary);
}
}
@@ -1113,7 +1113,7 @@ svg.inline-svg {
display: flex;
flex-direction: column;
gap: var(--gap-12);
padding: var(--gap-16) var(--gap-24);
padding: var(--gap-16);
h2 {
font-size: var(--text-18);
+3 -3
View File
@@ -380,18 +380,18 @@ a {
}
h1 {
color: var(--color-text-dark);
color: var(--color-text-primary);
}
h2 {
margin-top: 0;
margin-bottom: 1rem;
color: var(--color-text-dark);
color: var(--color-text-primary);
}
h3 {
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
color: var(--color-text-dark);
color: var(--color-text-primary);
}
input {
@@ -114,28 +114,19 @@ iframe[id^='google_ads_iframe'] {
color: var(--color-base);
}
#qc-cmp2-ui::before {
background: var(--color-raised-bg);
}
#qc-cmp2-ui::before,
#qc-cmp2-ui::after {
background: var(--color-raised-bg);
}
#qc-cmp2-ui button[mode='primary'] {
#qc-cmp2-ui button[mode='primary'],
#qc-cmp2-ui button[mode='secondary'] {
background: var(--color-brand);
color: var(--color-accent-contrast);
border-radius: var(--radius-lg);
border: none;
}
#qc-cmp2-ui button[mode='secondary'] {
background: var(--color-button-bg);
color: var(--color-base);
border-radius: var(--radius-lg);
border: none;
}
#qc-cmp2-ui button[mode='link'] {
color: var(--color-link);
}
+9 -1
View File
@@ -1,5 +1,6 @@
<template>
<nav
v-if="filteredLinks.length > 1"
ref="scrollContainer"
class="experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
:class="{ 'card-shadow': mode === 'navigation' }"
@@ -299,7 +300,14 @@ watch(
},
)
watch(() => props.links, updateActiveTab, { deep: true })
watch(
() => props.links,
async () => {
await nextTick()
updateActiveTab()
},
{ deep: true },
)
</script>
<style scoped>
@@ -1,6 +1,6 @@
<template>
<div class="space-y-2.5">
<div class="flex items-center justify-between">
<div v-if="!noHeader" class="flex items-center justify-between">
<span class="font-semibold text-contrast">
Minecraft versions <span class="text-red">*</span>
</span>
@@ -45,6 +45,7 @@
versionType === 'all' && !group.isReleaseGroup ? 'w-max' : 'w-16',
modelValue.includes(version) ? '!text-contrast' : '',
]"
:disabled="disabled"
@click="() => handleToggleVersion(version)"
@blur="
() => {
@@ -76,6 +77,8 @@ type GameVersion = Labrinth.Tags.v2.GameVersion
const props = defineProps<{
modelValue: string[]
gameVersions: Labrinth.Tags.v2.GameVersion[]
noHeader?: boolean
disabled?: boolean
}>()
const emit = defineEmits<{
@@ -158,7 +161,7 @@ function groupVersions(gameVersions: GameVersion[]) {
const groups: Record<string, string[]> = {}
let currentGroupKey
let currentGroupKey: string
gameVersions.forEach((gameVersion) => {
if (gameVersion.version_type === 'release') {
@@ -1,12 +1,35 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)">
<div class="min-w-md flex max-w-md flex-col gap-3">
<NewModal
ref="modal"
:header="
projectType === 'server'
? formatMessage(messages.serverProjectTitle)
: formatMessage(messages.title)
"
:max-width="'550px'"
>
<div class="flex w-full flex-col gap-6">
<CreateLimitAlert v-model="hasHitLimit" type="project" />
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2.5">
<label for="project-type">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.typeLabel) }}
</span>
</label>
<Combobox
id="project-type"
v-model="projectType"
name="project-type"
:options="projectTypeOptions"
:disabled="hasHitLimit"
/>
</div>
<div class="flex flex-col gap-2.5">
<label for="name">
<span class="text-lg font-semibold text-contrast">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.nameLabel) }}
<span class="text-brand-red">*</span>
</span>
</label>
<StyledInput
@@ -19,32 +42,46 @@
@update:model-value="updatedName()"
/>
</div>
<div class="flex flex-col gap-2">
<label for="slug">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.urlLabel) }}
<span class="text-brand-red">*</span>
</span>
</label>
<div class="text-input-wrapper">
<label for="slug" class="flex flex-col gap-2.5">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.urlLabel) }}
</span>
<div class="text-input-wrapper !w-full">
<div class="text-input-wrapper__before">https://modrinth.com/project/</div>
<StyledInput
id="slug"
v-model="slug"
:maxlength="64"
class="w-full"
type="text"
autocomplete="off"
:disabled="hasHitLimit"
@update:model-value="manualSlug = true"
/>
</div>
</label>
<div class="flex flex-col gap-2.5">
<label for="owner">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.ownerLabel) }}
</span>
</label>
<Combobox
id="owner"
v-model="owner"
name="owner"
:options="[userOption, ...ownerOptions]"
searchable
:disabled="hasHitLimit"
show-icon-in-selected
/>
<span>{{ formatMessage(messages.ownerDescription) }}</span>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2.5">
<label for="visibility" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(commonMessages.visibilityLabel) }}
<span class="text-brand-red">*</span>
</span>
<span>{{ formatMessage(messages.visibilityDescription) }}</span>
</label>
<Chips
id="visibility"
@@ -54,14 +91,13 @@
:capitalize="false"
:disabled="hasHitLimit"
/>
<span>{{ formatMessage(messages.visibilityDescription) }}</span>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2.5">
<label for="additional-information" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.summaryLabel) }}
<span class="text-brand-red">*</span>
</span>
<span>{{ formatMessage(messages.summaryDescription) }}</span>
</label>
<StyledInput
id="additional-information"
@@ -71,8 +107,9 @@
:placeholder="formatMessage(messages.summaryPlaceholder)"
:disabled="hasHitLimit"
/>
<span>{{ formatMessage(messages.summaryDescription) }}</span>
</div>
<div class="flex justify-end gap-2">
<div class="flex justify-end gap-2.5">
<ButtonStyled class="w-24">
<button @click="cancel">
<XIcon aria-hidden="true" />
@@ -80,7 +117,7 @@
</button>
</ButtonStyled>
<ButtonStyled color="brand" class="w-32">
<button :disabled="hasHitLimit" @click="createProject">
<button v-tooltip="missingFieldsTooltip" :disabled="disableCreate" @click="createProject">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.createProject) }}
</button>
@@ -90,30 +127,76 @@
</NewModal>
</template>
<script setup>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { PlusIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Chips,
Combobox,
type ComboboxOption,
commonMessages,
defineMessages,
injectModrinthClient,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, defineAsyncComponent, h } from 'vue'
import CreateLimitAlert from './CreateLimitAlert.vue'
type ProjectTypes = 'server' | 'project'
interface VisibilityOption {
actual: Labrinth.Projects.v2.ProjectStatus
display: string
}
interface ShowOptions {
type?: 'server' | 'project'
}
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const router = useRouter()
defineExpose({
show,
})
const auth = (await useAuth()) as Ref<{
user: { id: string; username: string; avatar_url: string } | null
}>
const messages = defineMessages({
title: {
id: 'create.project.title',
defaultMessage: 'Creating a project',
},
serverProjectTitle: {
id: 'create.project.server-project-title',
defaultMessage: 'Creating a server project',
},
typeLabel: {
id: 'create.project.type-label',
defaultMessage: 'Type',
},
typeProject: {
id: 'create.project.type-project',
defaultMessage: 'Project',
},
typeServer: {
id: 'create.project.type-server',
defaultMessage: 'Server',
},
ownerLabel: {
id: 'create.project.owner-label',
defaultMessage: 'Owner',
},
ownerDescription: {
id: 'create.project.owner-description',
defaultMessage: `Set the project owner as yourself or an organization you're a member of.`,
},
nameLabel: {
id: 'create.project.name-label',
defaultMessage: 'Name',
@@ -146,6 +229,10 @@ const messages = defineMessages({
id: 'create.project.create-project',
defaultMessage: 'Create project',
},
createServerProject: {
id: 'create.project.create-server-project',
defaultMessage: 'Create server',
},
visibilityPublic: {
id: 'create.project.visibility-public',
defaultMessage: 'Public',
@@ -158,24 +245,38 @@ const messages = defineMessages({
id: 'create.project.visibility-private',
defaultMessage: 'Private',
},
})
const props = defineProps({
organizationId: {
type: String,
required: false,
default: null,
missingFieldsTooltip: {
id: 'create.project.missing-fields-tooltip',
defaultMessage: 'Missing fields: {fields}',
},
})
const modal = ref()
const props = defineProps<{
organizationId?: string | null
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const hasHitLimit = ref(false)
const name = ref('')
const slug = ref('')
const description = ref('')
const manualSlug = ref(false)
const visibilities = ref([
const projectType = ref<ProjectTypes>('project')
const projectTypeOptions = computed<ComboboxOption<ProjectTypes>[]>(() => [
{
value: 'project',
label: formatMessage(messages.typeProject),
},
{
value: 'server',
label: formatMessage(messages.typeServer),
},
])
const ownerOptions = ref<ComboboxOption<string>[]>([])
const owner = ref<string | undefined>('self')
const organizations = ref<Labrinth.Projects.v3.Organization[]>([])
const visibilities = ref<VisibilityOption[]>([
{
actual: 'approved',
display: formatMessage(messages.visibilityPublic),
@@ -189,10 +290,84 @@ const visibilities = ref([
display: formatMessage(messages.visibilityPrivate),
},
])
const visibility = ref(visibilities.value[0])
const visibility = ref<VisibilityOption>(visibilities.value[0])
const disableCreate = computed(() => {
if (hasHitLimit.value) return true
if (!name.value.trim() || !slug.value.trim()) return true
if (description.value.trim().length < 3) return true
if (owner.value !== 'self' && !organizations.value.find((org) => org.id === owner.value))
return true
return false
})
const missingFieldsTooltip = computed(() => {
const missingFields = []
if (!name.value.trim()) missingFields.push(formatMessage(messages.nameLabel))
if (!slug.value.trim()) missingFields.push(formatMessage(messages.urlLabel))
if (description.value.trim().length < 3) missingFields.push(formatMessage(messages.summaryLabel))
if (owner.value !== 'self' && !organizations.value.find((org) => org.id === owner.value))
missingFields.push(formatMessage(messages.ownerLabel))
if (missingFields.length === 0) return ''
return formatMessage(messages.missingFieldsTooltip, {
fields: missingFields.join(', '),
})
})
const cancel = () => {
modal.value.hide()
modal.value?.hide()
}
const userOption = computed(() => ({
value: 'self',
label: auth.value.user?.username || 'Unknown user',
icon: auth.value.user?.avatar_url
? defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: auth.value.user?.avatar_url,
alt: 'User Avatar',
class: 'h-5 w-5 rounded',
}),
}),
)
: undefined,
}))
const { labrinth } = injectModrinthClient()
async function fetchOrganizations() {
if (!auth.value.user?.id) return
try {
const orgs = (await useBaseFetch(`user/${auth.value.user.id}/organizations`, {
apiVersion: 3,
})) as Labrinth.Projects.v3.Organization[]
organizations.value = orgs || []
ownerOptions.value = organizations.value.map((org) => ({
value: org.id,
label: org.name,
icon: org.icon_url
? defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: org.icon_url,
alt: `${org.name} Icon`,
class: 'h-5 w-5 rounded',
}),
}),
)
: undefined,
}))
if (props.organizationId) owner.value = props.organizationId
} catch (err) {
console.error('Failed to fetch organizations:', err)
}
}
async function createProject() {
@@ -200,9 +375,7 @@ async function createProject() {
const formData = new FormData()
const auth = await useAuth()
const projectData = {
const projectData: Labrinth.Projects.v2.CreateProjectBase = {
title: name.value.trim(),
project_type: 'mod',
slug: slug.value,
@@ -212,8 +385,8 @@ async function createProject() {
initial_versions: [],
team_members: [
{
user_id: auth.value.user.id,
name: auth.value.user.username,
user_id: auth.value.user?.id,
name: auth.value.user?.username,
role: 'Owner',
},
],
@@ -224,45 +397,70 @@ async function createProject() {
is_draft: true,
}
if (props.organizationId) {
projectData.organization_id = props.organizationId
}
formData.append('data', JSON.stringify(projectData))
try {
await useBaseFetch('project', {
method: 'POST',
body: formData,
headers: {
'Content-Disposition': formData,
},
})
let createdProjectId: string | undefined
modal.value.hide()
if (projectType.value === 'server') {
const result = await labrinth.projects_v3.createServerProject({
base: {
name: projectData.title,
slug: projectData.slug,
summary: projectData.description,
description: '',
requested_status: projectData.requested_status,
organization_id: owner.value !== 'self' ? owner.value : undefined,
},
minecraft_server: {
// empty component
},
minecraft_java_server: {
address: '',
port: 25565,
},
minecraft_bedrock_server: {
address: '',
port: 19132,
},
})
createdProjectId = result.id
} else {
const result = (await useBaseFetch('project', {
method: 'POST',
body: formData,
headers: {
'Content-Disposition': formData as unknown as string,
},
})) as Labrinth.Projects.v3.Project
createdProjectId = result.id
console.log(createdProjectId)
}
modal.value?.hide()
await router.push(`/project/${slug.value}/settings`)
} catch (err) {
} catch (err: unknown) {
const error = err as { data?: { description?: string } }
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
text: error.data?.description ?? String(err),
type: 'error',
})
}
stopLoading()
}
function show(event) {
async function show(event?: MouseEvent, options?: ShowOptions) {
name.value = ''
slug.value = ''
description.value = ''
manualSlug.value = false
modal.value.show(event)
owner.value = 'self'
projectType.value = options?.type ?? 'project'
await fetchOrganizations()
modal.value?.show(event)
}
defineExpose({
show,
})
function updatedName() {
if (!manualSlug.value) {
slug.value = name.value
@@ -100,7 +100,7 @@
/>
</div>
<div v-for="field in selectedRail?.fields" :key="field.name" class="flex flex-col gap-2.5">
<div v-for="field in visibleFields" :key="field.name" class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(field.label) }}
@@ -398,6 +398,26 @@ const isBusinessEntity = computed(() => {
return providerDataValue.kycData?.type === 'business'
})
const visibleFields = computed(() => {
const rail = selectedRail.value
if (!rail) return []
return rail.fields.filter((field) => {
if (!field.dependsOn) return true
const { field: dependsOnField, value: dependsOnValue } = field.dependsOn
const currentValue = formData.value[dependsOnField]
if (!currentValue) return false
if (Array.isArray(dependsOnValue)) {
return dependsOnValue.includes(currentValue)
} else {
return currentValue === dependsOnValue
}
})
})
const allRequiredFieldsFilled = computed(() => {
const rail = selectedRail.value
if (!rail) return false
@@ -407,7 +427,7 @@ const allRequiredFieldsFilled = computed(() => {
if (rail.requiresBankName && !formData.value.bankName) return false
const requiredFields = rail.fields.filter((f) => f.required)
const requiredFields = visibleFields.value.filter((f) => f.required)
const allRequiredPresent = requiredFields.every((f) => {
const value = formData.value[f.name]
return value !== undefined && value !== null && value !== ''
@@ -100,6 +100,7 @@ interface Tags {
interface Props {
project: Labrinth.Projects.v2.Project
projectV3: Labrinth.Projects.v3.Project
versions?: Labrinth.Versions.v2.Version[]
currentMember?: Labrinth.Projects.v3.TeamMember | null
collapsed?: boolean
@@ -172,6 +173,7 @@ const emit = defineEmits<{
const nagContext = computed<NagContext>(() => ({
project: props.project,
projectV3: props.projectV3,
versions: props.versions,
currentMember: props.currentMember?.user as Labrinth.Users.v2.User,
currentRoute: props.routeName,
@@ -1654,7 +1654,7 @@ function shouldShowStage(stage: Stage): boolean {
function shouldShowAction(action: Action): boolean {
if (typeof action.shouldShow === 'function') {
return action.shouldShow(projectV2.value)
return action.shouldShow(projectV2.value, projectV3.value)
}
return true
@@ -1663,7 +1663,7 @@ function shouldShowAction(action: Action): boolean {
function getVisibleDropdownOptions(action: DropdownAction) {
return action.options.filter((option) => {
if (typeof option.shouldShow === 'function') {
return option.shouldShow(projectV2.value)
return option.shouldShow(projectV2.value, projectV3.value)
}
return true
})
@@ -1672,7 +1672,7 @@ function getVisibleDropdownOptions(action: DropdownAction) {
function getVisibleMultiSelectOptions(action: MultiSelectChipsAction) {
return action.options.filter((option) => {
if (typeof option.shouldShow === 'function') {
return option.shouldShow(projectV2.value)
return option.shouldShow(projectV2.value, projectV3.value)
}
return true
})
@@ -0,0 +1,303 @@
<template>
<div>
<div class="flex flex-col gap-3">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-1">
<div class="text-xl font-semibold text-contrast">Server compatibility</div>
<div v-if="!content" class="text-sm text-secondary">
Select whether your server is vanilla or modded and which versions it supports. The
Modrinth App uses this when a player joins.
</div>
<div v-else>
<div v-if="content.kind === 'vanilla'" class="flex items-center gap-1.5">
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-solid border-surface-5 bg-surface-4 font-medium"
>
<BoxIcon class="h-4 w-4 shrink-0 text-secondary" />
</div>
Vanilla server
</div>
<div
v-else-if="content.kind === 'modpack' && !usingCustomMrpack"
class="flex items-center gap-1.5"
>
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-solid border-surface-5 bg-surface-4 font-medium"
>
<PackageIcon class="h-4 w-4 shrink-0 text-secondary" />
</div>
Published modpack
</div>
<div v-else class="flex items-center gap-1.5">
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-solid border-surface-5 bg-surface-4 font-medium"
>
<PackagePlusIcon class="h-4 w-4 shrink-0 text-secondary" />
</div>
Custom modpack
</div>
</div>
</div>
<ButtonStyled v-if="content" type="outlined">
<button class="!border-[1px]" @click="handleSwitchCompatibility">
<ArrowLeftRightIcon />
Switch type
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button @click="handleSetCompatibility">
<ComponentIcon />
Set compatibility
</button>
</ButtonStyled>
</div>
<div
v-if="content"
class="flex justify-between gap-4 rounded-2xl border border-solid border-surface-5 p-4"
>
<!-- kind = vanilla -->
<div
v-if="content?.kind === 'vanilla'"
class="flex flex-col items-start justify-between gap-2.5"
>
<div class="flex flex-col gap-2">
<div class="font-medium text-secondary">Recommended version</div>
<div class="text-2xl font-semibold text-contrast">
{{ content.recommended_game_version ?? '—' }}
</div>
</div>
<div class="flex flex-col gap-2">
<div class="font-medium text-secondary">Supported versions</div>
<div class="flex flex-wrap gap-1.5">
<TagItem
v-for="v in formatVersionsForDisplay(
content.supported_game_versions,
tags.gameVersions,
)"
:key="v"
>
{{ v }}
</TagItem>
<div v-if="!content.supported_game_versions.length">—</div>
</div>
</div>
</div>
<!-- kind = modpack -->
<div
v-if="content?.kind === 'modpack' && modpackProject"
class="flex w-full max-w-[500px] flex-col items-start justify-between gap-4"
>
<div class="flex w-full flex-col gap-2">
<div class="font-medium text-secondary">Required modpack</div>
<div class="w-fullitems-center flex gap-3 rounded-2xl bg-surface-1 p-3">
<Avatar
v-if="!usingCustomMrpack"
:src="modpackProject.icon_url"
size="56px"
:tint-by="modpackProject.name"
/>
<div
v-else
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-solid border-surface-5 bg-surface-3"
>
<PackagePlusIcon class="h-10 w-10 shrink-0 text-secondary" />
</div>
<div class="flex flex-col justify-center gap-1">
<div class="font-semibold text-contrast">
<NuxtLink
v-if="!usingCustomMrpack"
:to="`/modpack/${modpackProject.slug}`"
class="hover:underline"
>
{{ modpackProject.name }}
</NuxtLink>
<span v-else>{{ modpackFileName }}</span>
</div>
<div class="flex h-6 items-center gap-1.5 text-secondary">
<NuxtLink
v-if="modpackOrg?.name"
:to="`/organization/${modpackOrg.slug}`"
class="flex items-center gap-1 hover:underline"
>
<Avatar
v-if="modpackOrg?.icon_url"
:src="modpackOrg.icon_url"
size="24px"
class="rounded-2xl"
/>
{{ modpackOrg.name }}
</NuxtLink>
<div
v-if="modpackOrg?.name && modpackVersion"
class="h-1.5 w-1.5 rounded-full bg-surface-5"
></div>
<NuxtLink
v-if="modpackVersion && !usingCustomMrpack"
:to="`/modpack/${modpackProject?.slug}/version/${modpackVersion.id}`"
class="hover:underline"
>
v{{ modpackVersion.version_number }}
</NuxtLink>
<div v-else-if="modpackVersion" class="flex items-center gap-1.5">
<div>v{{ modpackVersion.version_number }}</div>
<div class="h-1.5 w-1.5 rounded-full bg-surface-5"></div>
<a
v-if="modpackVersion.files?.length"
:href="
modpackVersion.files.find((f) => f.primary)?.url ??
modpackVersion.files[0]?.url
"
class="flex items-center gap-0.5 hover:underline"
>
<DownloadIcon />
Download
</a>
</div>
</div>
</div>
</div>
</div>
<div v-if="modpackVersion" class="flex flex-col gap-2">
<div class="font-medium text-secondary">Required version</div>
<div class="flex flex-wrap gap-1.5">
<TagItem v-for="gv in modpackVersion.game_versions" :key="gv">
{{ gv }}
</TagItem>
<TagItem
v-for="loader in modpackVersion.mrpack_loaders"
:key="loader"
:style="`--_color: var(--color-platform-${loader})`"
>
<component
:is="getLoaderIcon(loader)"
v-if="getLoaderIcon(loader)"
class="h-4 w-4"
/>
<FormattedTag :tag="loader" enforce-type="loader" />
</TagItem>
</div>
</div>
</div>
<ButtonStyled v-if="content">
<button class="!w-full !max-w-[160px]" @click="handleUpdateContent">
<RefreshCwIcon />
Update
</button>
</ButtonStyled>
</div>
</div>
<ServerCompatibilityModal ref="serverCompatibilityModal" />
</div>
</template>
<script setup lang="ts">
import {
ArrowLeftRightIcon,
BoxIcon,
ComponentIcon,
DownloadIcon,
getLoaderIcon,
PackageIcon,
PackagePlusIcon,
RefreshCwIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
FormattedTag,
injectModrinthClient,
injectProjectPageContext,
TagItem,
} from '@modrinth/ui'
import { formatVersionsForDisplay } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { useGeneratedState } from '~/composables/generated'
import ServerCompatibilityModal from './ServerCompatibilityModal/ServerCompatibilityModal.vue'
const serverCompatibilityModal = useTemplateRef<InstanceType<typeof ServerCompatibilityModal>>(
'serverCompatibilityModal',
)
const { projectV3 } = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const tags = useGeneratedState()
const content = computed(() => {
if (!projectV3.value) return null
const content = projectV3.value.minecraft_java_server?.content
if (!content) return null
if (content?.kind === 'vanilla' && !content.recommended_game_version) {
return null
}
return content
})
const modpackVersionId = computed(() => {
if (content.value?.kind === 'modpack') return content.value.version_id
return null
})
const { data: modpackVersion } = useQuery({
queryKey: computed(() => ['versions', 'detail', modpackVersionId.value]),
queryFn: () => labrinth.versions_v3.getVersion(modpackVersionId.value!),
enabled: computed(() => !!modpackVersionId.value),
})
const modpackProjectId = computed(() => modpackVersion.value?.project_id ?? null)
const { data: modpackProject } = useQuery({
queryKey: computed(() => ['project', 'v3', modpackProjectId.value]),
queryFn: () => labrinth.projects_v3.get(modpackProjectId.value!),
enabled: computed(() => !!modpackProjectId.value),
})
const { data: modpackOrg } = useQuery({
queryKey: computed(() => ['project', 'org', modpackProjectId.value]),
queryFn: () => labrinth.projects_v3.getOrganization(modpackProjectId.value!),
enabled: computed(() => !!modpackProjectId.value && !!modpackProject.value?.organization),
})
const usingCustomMrpack = computed(() => modpackVersion.value?.project_id === projectV3.value?.id)
const modpackFileName = computed(() => {
if (!modpackVersion.value?.files?.length) return null
const primary = modpackVersion.value.files.find((f) => f.primary)
return (primary ?? modpackVersion.value.files[0]).filename
})
function handleSetCompatibility() {
serverCompatibilityModal.value?.show()
}
function handleSwitchCompatibility() {
serverCompatibilityModal.value?.show({ isSwitchingCompatibilityType: true })
}
function handleUpdateContent() {
if (!content.value?.kind) return
switch (content.value.kind) {
case 'vanilla':
serverCompatibilityModal.value?.show({ updateContentKind: 'vanilla' })
break
case 'modpack':
if (usingCustomMrpack.value) {
serverCompatibilityModal.value?.show({ updateContentKind: 'custom-modpack' })
} else {
serverCompatibilityModal.value?.show({ updateContentKind: 'published-modpack' })
}
break
default:
break
}
}
</script>
@@ -0,0 +1,14 @@
<template>
<Admonition v-if="isSwitchingCompatibilityType" type="critical" header="Data loss warning">
Changing the compatibility type will reset your previous compatibility settings and redistribute
the new settings to users in the Modrinth App.
</Admonition>
</template>
<script setup lang="ts">
import { Admonition } from '@modrinth/ui'
import { injectServerCompatibilityContext } from '../../../../providers/manage-server-compatibility-modal'
const { isSwitchingCompatibilityType } = injectServerCompatibilityContext()
</script>
@@ -0,0 +1,76 @@
<template>
<MultiStageModal
ref="modal"
:stages="ctx.stageConfigs"
:context="ctx"
:fade="ctx.isSwitchingCompatibilityType.value ? 'danger' : 'standard'"
@hide="handleHide"
/>
</template>
<script setup lang="ts">
import { injectProjectPageContext, MultiStageModal } from '@modrinth/ui'
import type { ComponentExposed } from 'vue-component-type-helpers'
import {
type CompatibilityType,
createServerCompatibilityContext,
provideServerCompatibilityContext,
} from '../../../../providers/manage-server-compatibility-modal'
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
const { projectV3 } = injectProjectPageContext()
const ctx = createServerCompatibilityContext(modal)
provideServerCompatibilityContext(ctx)
interface ShowModalOptions {
stageId?: string | null
updateContentKind?: CompatibilityType
isSwitchingCompatibilityType?: boolean
}
async function show(options?: ShowModalOptions) {
const content = projectV3.value?.minecraft_java_server?.content
if (options?.updateContentKind) {
ctx.compatibilityType.value = options.updateContentKind
ctx.isEditingExistingCompatibility.value = true
// Prefill existing values for vanilla
if (options.updateContentKind === 'vanilla' && content && content.kind === 'vanilla') {
ctx.supportedGameVersions.value = content.supported_game_versions ?? []
ctx.recommendedGameVersion.value = content.recommended_game_version ?? null
}
if (options.updateContentKind === 'published-modpack') {
ctx.selectedProjectId.value = content?.kind === 'modpack' ? content.project_id || '' : ''
}
await nextTick()
modal.value?.setStage(1)
} else {
modal.value?.setStage(options?.stageId ?? 0)
}
if (options?.isSwitchingCompatibilityType) {
ctx.isSwitchingCompatibilityType.value = true
}
modal.value?.show()
}
function handleHide() {
ctx.resetContext()
}
function hide() {
handleHide()
modal.value?.hide()
}
defineExpose({
show,
hide,
})
</script>
@@ -0,0 +1,68 @@
<template>
<div class="flex flex-col gap-6">
<DataLossWarningBanner />
<div class="flex flex-col gap-4">
<div class="font-semibold text-contrast">Select compatibility type</div>
<div class="flex w-full flex-col gap-2">
<button
v-for="option in options"
:key="option.value"
class="flex !w-full items-center gap-4 rounded-3xl bg-surface-4 p-3 text-left transition-all hover:brightness-125"
@click="selectType(option.value)"
>
<div
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-solid border-surface-5"
>
<component :is="option.icon" class="h-9 w-9 text-secondary" />
</div>
<div class="flex flex-col gap-0.5">
<span class="font-bold text-contrast">{{ option.label }}</span>
<span class="text-sm text-secondary">{{ option.description }}</span>
</div>
</button>
</div>
<div class="text-sm text-secondary">
Servers with custom modpacks should not be uploaded as separate modpack projects.
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { BoxIcon, PackageIcon, PackagePlusIcon } from '@modrinth/assets'
import {
type CompatibilityType,
injectServerCompatibilityContext,
} from '~/providers/manage-server-compatibility-modal'
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
const ctx = injectServerCompatibilityContext()
const options = [
{
value: 'vanilla' as CompatibilityType,
label: 'Vanilla server',
description: 'A vanilla server with no mods.',
icon: BoxIcon,
},
{
value: 'published-modpack' as CompatibilityType,
label: 'Published modpack',
description: 'A modded server using a published modpack.',
icon: PackageIcon,
},
{
value: 'custom-modpack' as CompatibilityType,
label: 'Custom modpack',
description: 'A modded server using a custom modpack.',
icon: PackagePlusIcon,
},
]
function selectType(type: CompatibilityType) {
ctx.compatibilityType.value = type
ctx.modal.value?.nextStage()
}
</script>
@@ -0,0 +1,140 @@
<template>
<div class="flex flex-col gap-6">
<DataLossWarningBanner />
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Select modpack</div>
<div class="flex flex-col gap-6 rounded-2xl border border-solid border-surface-5 p-4">
<div class="flex flex-col gap-2">
<label class="font-semibold text-contrast">Project</label>
<ProjectCombobox
v-model="selectedProjectId"
:project-types="['modpack']"
:exclude-project-ids="[currentProjectId]"
placeholder="Select modpack"
search-placeholder="Search by name or paste ID..."
loading-message="Loading..."
no-results-message="No results found"
/>
</div>
<div v-if="selectedProjectId" class="flex flex-col gap-2">
<label class="font-semibold text-contrast">Version</label>
<Combobox
v-model="selectedVersionId"
placeholder="Select version"
:options="versionOptions"
:searchable="true"
search-placeholder="Search versions..."
:no-options-message="versionsLoading ? 'Loading...' : 'No results found'"
/>
</div>
</div>
<div v-if="selectedVersion" class="flex flex-col gap-4 rounded-2xl bg-surface-2 p-4">
<div class="flex items-center justify-between">
<div class="text-secondary">Game version</div>
<div class="flex flex-wrap gap-1">
<TagItem v-for="gv in selectedVersion.game_versions" :key="gv">
{{ gv }}
</TagItem>
</div>
</div>
<div
v-if="selectedVersion.mrpack_loaders?.length"
class="flex items-center justify-between"
>
<div class="text-secondary">Platform</div>
<div class="flex flex-wrap gap-1">
<TagItem
v-for="loader in selectedVersion.mrpack_loaders"
:key="loader"
:style="`--_color: var(--color-platform-${loader})`"
>
<component :is="getLoaderIcon(loader)" v-if="getLoaderIcon(loader)" class="h-4 w-4" />
<FormattedTag :tag="loader" enforce-type="loader" />
</TagItem>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { getLoaderIcon } from '@modrinth/assets'
import {
Combobox,
FormattedTag,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
ProjectCombobox,
TagItem,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import { injectServerCompatibilityContext } from '~/providers/manage-server-compatibility-modal'
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
const { projectV3 } = injectProjectPageContext()
const currentProjectId = computed(() => projectV3.value?.id)
const { selectedProjectId, selectedVersionId } = injectServerCompatibilityContext()
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
interface VersionInfo {
id: string
version_number: string
name: string
}
const versionsLoading = ref(false)
const projectVersions = ref<VersionInfo[]>([])
const versionOptions = computed(() =>
projectVersions.value.map((v) => ({
label: v.version_number,
value: v.id,
})),
)
const { data: selectedVersion } = useQuery({
queryKey: computed(() => ['versions', 'detail', selectedVersionId.value]),
queryFn: () => labrinth.versions_v3.getVersion(selectedVersionId.value),
enabled: computed(() => !!selectedVersionId.value),
})
watch(
() => selectedProjectId.value,
async (newProjectId) => {
selectedVersionId.value = ''
projectVersions.value = []
if (!newProjectId) return
versionsLoading.value = true
try {
const versions = await labrinth.versions_v3.getProjectVersions(newProjectId)
projectVersions.value = versions.map((v) => ({
id: v.id,
version_number: v.version_number,
name: v.name,
}))
} catch (error: unknown) {
const err = error as { data?: { description?: string } }
addNotification({
title: 'Failed to load versions',
text: err.data?.description || String(error),
type: 'error',
})
} finally {
versionsLoading.value = false
}
},
{ immediate: true },
)
</script>
@@ -0,0 +1,64 @@
<template>
<div class="flex w-full flex-col gap-6">
<DataLossWarningBanner />
<div class="flex w-full flex-col gap-4">
<label>
<span class="label__title">Supported MC versions</span>
<McVersionPicker v-model="supportedGameVersions" no-header :game-versions="gameVersions" />
</label>
<div>
<label>
<span class="label__title">Recommended MC version</span>
<Combobox
v-model="recommendedGameVersion"
v-tooltip="
!recommendedOptions.length
? 'Set supported versions before selecting the recommended version'
: undefined
"
:options="recommendedOptions"
searchable
:display-name="(val: string) => val"
placeholder="Select version"
:disabled="!recommendedOptions.length"
/>
<div class="mt-2 text-secondary">
Players joining the server from the Modrinth App will connect using this version.
</div>
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Combobox } from '@modrinth/ui'
import { computed, watch } from 'vue'
import McVersionPicker from '~/components/ui/create-project-version/components/McVersionPicker.vue'
import { useGeneratedState } from '~/composables/generated'
import { injectServerCompatibilityContext } from '~/providers/manage-server-compatibility-modal'
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
const { supportedGameVersions, recommendedGameVersion } = injectServerCompatibilityContext()
const generatedState = useGeneratedState()
const gameVersions = generatedState.value.gameVersions
const recommendedOptions = computed(() =>
gameVersions
.filter((v) => v.version_type === 'release')
.filter((v) => supportedGameVersions.value.includes(v.version))
.map((v) => ({ label: v.version, value: v.version })),
)
watch(
() => supportedGameVersions.value,
(supported) => {
if (recommendedGameVersion.value && !supported.includes(recommendedGameVersion.value)) {
recommendedGameVersion.value = null
}
},
)
</script>
@@ -0,0 +1,92 @@
<template>
<div class="flex w-full flex-col gap-6">
<DataLossWarningBanner />
<div class="flex w-full flex-col gap-4">
<div class="font-semibold text-contrast">Upload custom modpack</div>
<DropzoneFileInput
v-if="!ctx.customModpackFile.value"
primary-prompt="Drag and drop your .mrpack file"
secondary-prompt="Or click to browse"
accept=".mrpack"
size="medium"
@change="handleFileUpload"
/>
<div
v-if="ctx.customModpackFile.value"
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-2 text-button-text"
>
<div class="flex items-center gap-2 overflow-hidden">
<FileIcon />
<span
v-tooltip="ctx.customModpackFile.value.name"
class="overflow-hidden text-ellipsis whitespace-nowrap font-medium"
>
{{ ctx.customModpackFile.value.name }}
</span>
</div>
<ButtonStyled size="standard" :circular="true">
<button
v-tooltip="'Replace file'"
aria-label="Replace file"
class="!shadow-none"
@click="fileInput?.click()"
>
<ArrowLeftRightIcon aria-hidden="true" />
<input
ref="fileInput"
class="hidden"
type="file"
accept=".mrpack"
@change="handleFileInputChange"
/>
</button>
</ButtonStyled>
</div>
<Checkbox v-model="ctx.hasLicensePermission.value">
<span class="max-w-[90%] text-left text-primary">
Do you have the appropriate licenses to redistribute all content in this Modpack?
<NuxtLink
to="https://support.modrinth.com/en/articles/8797527-obtaining-modpack-permissions"
external
target="_blank"
class="font-medium text-blue underline"
>
Learn more
</NuxtLink>
</span>
</Checkbox>
</div>
</div>
</template>
<script setup lang="ts">
import { ArrowLeftRightIcon, FileIcon } from '@modrinth/assets'
import { ButtonStyled, Checkbox, DropzoneFileInput } from '@modrinth/ui'
import { injectServerCompatibilityContext } from '~/providers/manage-server-compatibility-modal'
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
const ctx = injectServerCompatibilityContext()
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
function handleFileUpload(files: File[]) {
if (files.length > 0) {
ctx.customModpackFile.value = files[0]
}
}
function handleFileInputChange(e: Event) {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
ctx.customModpackFile.value = file
}
target.value = ''
}
</script>
@@ -38,7 +38,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
newProjectGeneralSettings: false,
newProjectEnvironmentSettings: true,
hideRussiaCensorshipBanner: false,
serverDiscovery: false,
serverDiscovery: true,
disablePrettyProjectUrlRedirects: false,
hidePreviewBanner: false,
i18nDebug: false,
+5
View File
@@ -1,3 +1,8 @@
/**
* @deprecated Use `@modrinth/api-client` via `injectModrinthClient()` instead.
* This composable is kept for legacy code that hasn't been migrated yet.
*/
let cachedRateLimitKey = undefined
let rateLimitKeyPromise = undefined
@@ -92,6 +92,11 @@ export const useGeneratedState = () =>
id: 'modpack',
display: 'modpack',
},
{
actual: 'server',
id: 'server',
display: 'server',
},
],
loaderData: {
pluginLoaders: ['bukkit', 'spigot', 'paper', 'purpur', 'sponge', 'folia'],
@@ -1,3 +1,10 @@
/**
* @deprecated Use `@modrinth/api-client` via `injectModrinthClient()` instead.
* The api-client's archon modules (`client.archon.servers_v0`, etc.) handle auth,
* retry, and circuit breaking automatically. This composable is kept for legacy
* code that hasn't been migrated yet.
*/
import { PANEL_VERSION } from '@modrinth/api-client'
import type { V1ErrorInfo } from '@modrinth/utils'
import { ModrinthServerError, ModrinthServersFetchError } from '@modrinth/utils'
+2
View File
@@ -3,6 +3,8 @@ export const getProjectTypeForUrl = (type, categories) => {
}
export const getProjectTypeForUrlShorthand = (type, categories, overrideTags) => {
if (type === 'minecraft_java_server') return 'server'
const tags = overrideTags ?? useGeneratedState().value
if (type === 'mod') {
+16 -1
View File
@@ -398,6 +398,10 @@
id: 'new-project',
action: (event) => $refs.modal_creation.show(event),
},
{
id: 'new-server-project',
action: (event) => $refs.modal_creation.show(event, { type: 'server' }),
},
{
id: 'new-collection',
action: (event) => $refs.modal_collection_creation.show(event),
@@ -410,10 +414,13 @@
]"
>
<PlusIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
{{ formatMessage(messages.publish) }}
<template #new-project>
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.newProject) }}
</template>
<template #new-server-project>
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.newServerProject) }}
</template>
<!-- <template #import-project> <BoxImportIcon /> Import project </template>-->
<template #new-collection>
<CollectionIcon aria-hidden="true" /> {{ formatMessage(messages.newCollection) }}
@@ -835,6 +842,10 @@ const messages = defineMessages({
id: 'layout.action.create-new',
defaultMessage: 'Create new...',
},
publish: {
id: 'layout.action.publish',
defaultMessage: 'Publish',
},
reviewProjects: {
id: 'layout.action.review-projects',
defaultMessage: 'Project review',
@@ -867,6 +878,10 @@ const messages = defineMessages({
id: 'layout.action.new-project',
defaultMessage: 'New project',
},
newServerProject: {
id: 'layout.action.new-server-project',
defaultMessage: 'New server',
},
newCollection: {
id: 'layout.action.new-collection',
defaultMessage: 'New collection',
@@ -497,12 +497,27 @@
"create.project.create-project": {
"message": "Create project"
},
"create.project.create-server-project": {
"message": "Create server"
},
"create.project.missing-fields-tooltip": {
"message": "Missing fields: {fields}"
},
"create.project.name-label": {
"message": "Name"
},
"create.project.name-placeholder": {
"message": "Enter project name..."
},
"create.project.owner-description": {
"message": "Set the project owner as yourself or an organization you're a member of."
},
"create.project.owner-label": {
"message": "Owner"
},
"create.project.server-project-title": {
"message": "Creating a server project"
},
"create.project.summary-description": {
"message": "A sentence or two that describes your project."
},
@@ -515,6 +530,15 @@
"create.project.title": {
"message": "Creating a project"
},
"create.project.type-label": {
"message": "Type"
},
"create.project.type-project": {
"message": "Project"
},
"create.project.type-server": {
"message": "Server"
},
"create.project.url-label": {
"message": "URL"
},
@@ -1433,6 +1457,12 @@
"layout.action.new-project": {
"message": "New project"
},
"layout.action.new-server-project": {
"message": "New server"
},
"layout.action.publish": {
"message": "Publish"
},
"layout.action.reports": {
"message": "Review reports"
},
@@ -2132,6 +2162,12 @@
"project-type.resourcepack.singular": {
"message": "Resource Pack"
},
"project-type.server.plural": {
"message": "Servers"
},
"project-type.server.singular": {
"message": "Server"
},
"project-type.shader.plural": {
"message": "Shaders"
},
+14 -6
View File
@@ -5,7 +5,17 @@ import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
import { useServerModrinthClient } from '~/server/utils/api-client'
// All valid project type URL segments
const PROJECT_TYPES = ['project', 'mod', 'plugin', 'datapack', 'shader', 'resourcepack', 'modpack']
const PROJECT_TYPES = [
'project',
'mod',
'plugin',
'datapack',
'shader',
'resourcepack',
'modpack',
'server',
'minecraft_java_server',
]
export default defineNuxtRouteMiddleware(async (to) => {
// Only handle project routes
@@ -23,6 +33,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
// Fetch v2 project for redirect check AND cache it for the page
// Using fetchQuery ensures the page's useQuery gets this cached result
const project = await queryClient.fetchQuery(projectQueryOptions.v2(projectId, client))
const projectV3 = await queryClient.fetchQuery(projectQueryOptions.v3(projectId, client))
// Let page handle 404
if (!project) return
@@ -35,12 +46,9 @@ export default defineNuxtRouteMiddleware(async (to) => {
queryClient.setQueryData(['project', 'v2', project.id], project)
}
const projectType = projectV3.minecraft_server != null ? 'server' : project.project_type
// Determine the correct URL type
const correctType = getProjectTypeForUrlShorthand(
project.project_type,
project.loaders,
tags.value,
)
const correctType = getProjectTypeForUrlShorthand(projectType, project.loaders, tags.value)
// Preserve the rest of the path (subpages like /versions, /settings, etc.)
const pathParts = to.path.split('/')
+271 -15
View File
@@ -73,6 +73,7 @@
"
/>
</NewModal>
<OpenInAppModal ref="openInAppModal" />
<div
class="over-the-top-download-animation"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
@@ -432,7 +433,15 @@
}"
>
<div class="normal-page__header relative my-4">
<ProjectHeader :project="project" :member="!!currentMember">
<component
:is="isServerProject ? ServerProjectHeader : ProjectHeader"
v-if="projectV3Loaded"
v-bind="
isServerProject
? { project, projectV3, member: !!currentMember }
: { project, member: !!currentMember }
"
>
<template #actions>
<ButtonStyled v-if="auth.user && currentMember" size="large" color="brand">
<nuxt-link
@@ -446,6 +455,7 @@
<div class="hidden sm:contents">
<ButtonStyled
v-if="!isServerProject"
size="large"
:color="
(auth.user && currentMember) || route.name === 'type-id-version-version'
@@ -458,8 +468,6 @@
v-tooltip="
auth.user && currentMember ? formatMessage(commonMessages.downloadButton) : ''
"
@mouseenter="loadVersions"
@focus="loadVersions"
@click="(event) => downloadModal.show(event)"
>
<DownloadIcon aria-hidden="true" />
@@ -468,10 +476,29 @@
}}
</button>
</ButtonStyled>
<ButtonStyled
v-else
size="large"
:color="
(auth.user && currentMember) || route.name === 'type-id-version-version'
? `standard`
: `brand`
"
:circular="!!auth.user && !!currentMember"
>
<button
v-tooltip="auth.user && currentMember && !openInAppModal?.open ? 'Play' : ''"
@click="handlePlayServerProject"
>
<PlayIcon aria-hidden="true" />
{{ auth.user && currentMember ? '' : 'Play' }}
</button>
</ButtonStyled>
</div>
<div class="contents sm:hidden">
<ButtonStyled
v-if="!isServerProject"
size="large"
circular
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
@@ -479,13 +506,21 @@
<button
:aria-label="formatMessage(commonMessages.downloadButton)"
class="flex sm:hidden"
@mouseenter="loadVersions"
@focus="loadVersions"
@click="(event) => downloadModal.show(event)"
>
<DownloadIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled
v-else
size="large"
circular
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
>
<button aria-label="Play" class="flex sm:hidden" @click="handlePlayServerProject">
<PlayIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<Tooltip
v-if="canCreateServerFrom && flags.showProjectPageQuickServerButton"
@@ -737,7 +772,7 @@
</OverflowMenu>
</ButtonStyled>
</template>
</ProjectHeader>
</component>
<ProjectMemberHeader
v-if="currentMember"
:project="project"
@@ -796,7 +831,19 @@
</div>
<div class="normal-page__sidebar">
<ProjectSidebarServerInfo
v-if="isServerProject && serverDataLoaded"
:project-v3="projectV3"
:tags="tags"
:required-content="serverRequiredContent"
:recommended-version="serverRecommendedVersion"
:supported-versions="serverSupportedVersions"
:loaders="serverModpackLoaders"
:status-online="projectV3?.minecraft_java_server?.ping?.data != null"
class="card flex-card experimental-styles-within"
/>
<ProjectSidebarCompatibility
v-if="projectV3Loaded && !isServerProject"
:project="project"
:tags="tags"
:v3-metadata="projectV3"
@@ -805,9 +852,14 @@
<AdPlaceholder v-if="!auth.user && tags.approvedStatuses.includes(project.status)" />
<ProjectSidebarLinks
:project="project"
:project-v3="projectV3"
:link-target="$external()"
class="card flex-card experimental-styles-within"
/>
<ProjectSidebarTags
:project="project"
class="card flex-card experimental-styles-within"
/>
<ProjectSidebarCreators
:organization="organization"
:members="members"
@@ -827,7 +879,7 @@
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
<div class="details-list">
<div class="details-list__item">
<div v-if="projectV3Loaded && !isServerProject" class="details-list__item">
<BookTextIcon aria-hidden="true" />
<div>
{{ formatMessage(messages.licensedLabel) }}
@@ -953,6 +1005,7 @@ import {
ListIcon,
ModrinthIcon,
MoreVerticalIcon,
PlayIcon,
PlusIcon,
ReportIcon,
ScaleIcon,
@@ -976,6 +1029,7 @@ import {
injectNotificationManager,
IntlFormatted,
NewModal,
OpenInAppModal,
OverflowMenu,
PopoutMenu,
ProjectBackgroundGradient,
@@ -985,8 +1039,11 @@ import {
ProjectSidebarCreators,
ProjectSidebarDetails,
ProjectSidebarLinks,
ProjectSidebarServerInfo,
ProjectSidebarTags,
provideProjectPageContext,
ScrollablePanel,
ServerProjectHeader,
ServersPromo,
StyledInput,
TagItem,
@@ -1041,6 +1098,7 @@ const debug = useDebugLogger('DownloadModal')
const settingsModal = ref()
const downloadModal = ref()
const openInAppModal = ref()
const overTheTopDownloadAnimation = ref()
const userSelectedGameVersion = ref(null)
@@ -1051,6 +1109,9 @@ const gameVersionFilterInput = ref()
const versionFilter = ref('')
const projectV3Loaded = computed(() => !projectV3Pending.value || projectV3.value != null)
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
const baseId = useId()
@@ -1121,6 +1182,21 @@ const showVersionsCheckbox = computed(() => {
return false
})
const serverProject = computed(() => ({
name: project.value.title,
slug: project.value.slug || project.value.id,
numPlayers: projectV3.value?.minecraft_java_server?.ping?.data?.players_online,
icon: project.value.icon_url,
statusOnline: !!projectV3.value?.minecraft_java_server?.ping?.data,
region: projectV3.value?.minecraft_server?.country,
}))
function handlePlayServerProject() {
openInAppModal.value?.show({
serverProject: serverProject.value,
})
}
function installWithApp() {
setTimeout(() => {
getModrinthAppAccordion.value.open()
@@ -1536,13 +1612,104 @@ const project = computed(() => {
const projectId = computed(() => projectRaw.value?.id)
// V3 Project
const { data: projectV3, error: _projectV3Error } = useQuery({
const {
data: projectV3,
error: _projectV3Error,
isPending: projectV3Pending,
} = useQuery({
queryKey: computed(() => ['project', 'v3', projectId.value]),
queryFn: () => client.labrinth.projects_v3.get(projectId.value),
staleTime: STALE_TIME,
enabled: computed(() => !!projectId.value),
})
// Server sidebar: modpack version + project for required content
const serverModpackVersionId = computed(() => {
const content = projectV3.value?.minecraft_java_server?.content
return content?.kind === 'modpack' ? content.version_id : null
})
const { data: serverModpackVersion, isPending: serverModpackVersionPending } = useQuery({
queryKey: computed(() => ['sidebar-modpack-version', serverModpackVersionId.value]),
queryFn: () => client.labrinth.versions_v3.getVersion(serverModpackVersionId.value),
staleTime: STALE_TIME,
enabled: computed(() => !!serverModpackVersionId.value),
})
const serverModpackProjectId = computed(() => serverModpackVersion.value?.project_id ?? null)
const { data: serverModpackProject, isPending: serverModpackProjectPending } = useQuery({
queryKey: computed(() => ['sidebar-modpack-project', serverModpackProjectId.value]),
queryFn: () => client.labrinth.projects_v3.get(serverModpackProjectId.value),
staleTime: STALE_TIME,
enabled: computed(() => !!serverModpackProjectId.value),
})
const serverDataLoaded = computed(() => {
if (!projectV3.value) return false
if (serverModpackVersionId.value && serverModpackVersionPending.value) return false
if (serverModpackProjectId.value && serverModpackProjectPending.value) return false
return true
})
const serverRequiredContent = computed(() => {
if (!serverModpackProject.value) return null
const primaryFile =
serverModpackVersion.value?.files?.find((f) => f.primary) ??
serverModpackVersion.value?.files?.[0]
return {
name: serverModpackProject.value.name,
versionNumber: serverModpackVersion.value?.version_number ?? '',
icon: serverModpackProject.value.icon_url,
onclickName:
serverModpackProject.value.id !== projectId.value
? () => navigateTo(`/modpack/${serverModpackProject.value.slug}`)
: undefined,
onclickVersion:
serverModpackProject.value.id !== projectId.value
? () =>
navigateTo(
`/modpack/${serverModpackProject.value.slug}/version/${serverModpackVersion.value?.id}`,
)
: undefined,
onclickDownload: primaryFile?.url
? () => navigateTo(primaryFile.url, { external: true })
: undefined,
showCustomModpackTooltip: serverModpackProject.value.id === projectId.value,
}
})
const serverRecommendedVersion = computed(() => {
const content = projectV3.value?.minecraft_java_server?.content
if (!content) return null
if (content.kind === 'modpack') {
return serverModpackVersion.value?.game_versions?.[0] ?? null
}
if (content.kind === 'vanilla') {
return content.recommended_game_version ?? null
}
return null
})
const serverSupportedVersions = computed(() => {
const content = projectV3.value?.minecraft_java_server?.content
if (!content) return []
if (content.kind === 'vanilla') {
return content.supported_game_versions?.filter((v) => !!v) ?? []
}
return []
})
const serverModpackLoaders = computed(() => {
if (!serverModpackVersion.value) return []
return serverModpackVersion.value.mrpack_loaders ?? []
})
// Members
const { data: allMembersRaw, error: _membersError } = useQuery({
queryKey: computed(() => ['project', projectId.value, 'members']),
@@ -1664,8 +1831,10 @@ async function updateProjectRoute() {
async function invalidateProject() {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', routeProjectId.value] })
if (routeProjectId.value !== projectId.value) {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId.value] })
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId.value] })
}
// Prefix match invalidates members, versions, dependencies, organization
await queryClient.invalidateQueries({ queryKey: ['project', projectId.value] })
@@ -1711,7 +1880,6 @@ const patchProjectMutation = useMutation({
text: err.data ? err.data.description : err.message,
type: 'error',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
},
onSettled: async () => {
@@ -1764,6 +1932,57 @@ const patchStatusMutation = useMutation({
},
})
// Mutation for patching V3 project data
const patchProjectV3Mutation = useMutation({
mutationFn: async ({ projectId, data }) => {
await client.labrinth.projects_v3.edit(projectId, data)
return data
},
onMutate: async ({ projectId, data }) => {
await queryClient.cancelQueries({ queryKey: ['project', 'v3', projectId] })
const previousProject = queryClient.getQueryData(['project', 'v3', projectId])
queryClient.setQueryData(['project', 'v3', projectId], (old) => {
if (!old) return old
const merged = { ...old }
for (const [key, value] of Object.entries(data)) {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
merged[key] &&
typeof merged[key] === 'object' &&
!Array.isArray(merged[key])
) {
merged[key] = { ...merged[key], ...value }
} else {
merged[key] = value
}
}
return merged
})
return { previousProject, projectId }
},
onError: (err, _variables, context) => {
if (context?.previousProject) {
queryClient.setQueryData(['project', 'v3', context.projectId], context.previousProject)
}
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err.message,
type: 'error',
})
},
onSettled: async () => {
await invalidateProject()
},
})
// Mutation for patching project icon
const patchIconMutation = useMutation({
mutationFn: async ({ projectId, icon }) => {
@@ -1790,7 +2009,6 @@ const patchIconMutation = useMutation({
text: err.data ? err.data.description : err.message,
type: 'error',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
},
onSettled: async () => {
@@ -2147,7 +2365,30 @@ async function patchProject(resData, quiet = false) {
text: formatMessage(messages.projectUpdatedMessage),
type: 'success',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}
resolve(true)
},
onError: () => resolve(false),
onSettled: () => stopLoading(),
},
)
})
}
async function patchProjectV3(resData, quiet = false) {
startLoading()
return new Promise((resolve) => {
patchProjectV3Mutation.mutate(
{ projectId: project.value.id, data: resData },
{
onSuccess: async () => {
if (!quiet) {
addNotification({
title: formatMessage(messages.projectUpdated),
text: formatMessage(messages.projectUpdatedMessage),
type: 'success',
})
}
resolve(true)
},
@@ -2279,7 +2520,15 @@ async function deleteVersion(id) {
}
const navLinks = computed(() => {
const projectUrl = `/${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
const routeType = route.params.type || project.value.project_type
const projectUrl = `/${routeType}/${project.value.slug ? project.value.slug : project.value.id}`
const galleryCount =
routeType === 'server'
? project.value.gallery.filter((item) => item.name === '__mc_server_banner__').length
: project.value.gallery.length
console.log('galleryCount', galleryCount, !!currentMember.value)
return [
{
@@ -2289,18 +2538,24 @@ const navLinks = computed(() => {
{
label: formatMessage(messages.galleryTab),
href: `${projectUrl}/gallery`,
shown: project.value.gallery.length > 0 || !!currentMember.value,
shown: galleryCount > 0 || !!currentMember.value,
},
{
label: formatMessage(messages.changelogTab),
href: `${projectUrl}/changelog`,
shown: hasVersions.value,
shown:
hasVersions.value &&
projectV3Loaded.value &&
projectV3.value?.minecraft_server === undefined,
onHover: loadVersions,
},
{
label: formatMessage(messages.versionsTab),
href: `${projectUrl}/versions`,
shown: hasVersions.value || !!currentMember.value,
shown:
(hasVersions.value || !!currentMember.value) &&
projectV3Loaded.value &&
projectV3.value?.minecraft_server === undefined,
subpages: [`${projectUrl}/version/`],
onHover: loadVersions,
},
@@ -2335,6 +2590,7 @@ provideProjectPageContext({
// Mutation functions
patchProject,
patchProjectV3,
patchIcon,
setProcessing,
+18 -13
View File
@@ -32,8 +32,8 @@
:src="
previewImage
? previewImage
: project.gallery?.[editIndex]?.url
? project.gallery[editIndex].url
: filteredGallery[editIndex]?.url
? filteredGallery[editIndex].url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
alt="gallery-preview"
@@ -175,14 +175,14 @@
<ContractIcon v-else aria-hidden="true" />
</button>
<button
v-if="(project?.gallery?.length ?? 0) > 1"
v-if="filteredGallery.length > 1"
class="previous circle-button"
@click="previousImage()"
>
<LeftArrowIcon aria-hidden="true" />
</button>
<button
v-if="(project?.gallery?.length ?? 0) > 1"
v-if="filteredGallery.length > 1"
class="next circle-button"
@click="nextImage()"
>
@@ -222,7 +222,7 @@
</div>
</template>
</Admonition>
<div v-if="currentMember && project?.gallery?.length" class="card header-buttons">
<div v-if="currentMember && filteredGallery.length" class="card header-buttons">
<FileInput
:max-size="5242880"
:accept="acceptFileTypes"
@@ -243,8 +243,8 @@
@change="handleFiles"
/>
</div>
<div v-if="project?.gallery?.length" class="items">
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
<div v-if="filteredGallery.length" class="items">
<div v-for="(item, index) in filteredGallery" :key="index" class="card gallery-item">
<a class="gallery-thumbnail" @click="expandImage(item as GalleryItem, index)">
<img
:src="item.url ? item.url : 'https://cdn.modrinth.com/placeholder-banner.svg'"
@@ -414,8 +414,13 @@ const previewImage = ref<string | null>(null)
const shouldPreventActions = ref(false)
// Constant for accepted file types
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
const filteredGallery = computed(
() => project.value.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
)
// Keyboard navigation for expanded image modal
useEventListener(document, 'keydown', (e) => {
if (expandedGalleryItem.value) {
@@ -435,18 +440,18 @@ useEventListener(document, 'keydown', (e) => {
// Navigation functions
function nextImage() {
expandedGalleryIndex.value++
if (expandedGalleryIndex.value >= project.value.gallery!.length) {
if (expandedGalleryIndex.value >= filteredGallery.value.length) {
expandedGalleryIndex.value = 0
}
expandedGalleryItem.value = project.value.gallery![expandedGalleryIndex.value] as GalleryItem
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value] as GalleryItem
}
function previousImage() {
expandedGalleryIndex.value--
if (expandedGalleryIndex.value < 0) {
expandedGalleryIndex.value = project.value.gallery!.length - 1
expandedGalleryIndex.value = filteredGallery.value.length - 1
}
expandedGalleryItem.value = project.value.gallery![expandedGalleryIndex.value] as GalleryItem
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value] as GalleryItem
}
function expandImage(item: GalleryItem, index: number) {
@@ -509,7 +514,7 @@ async function editGalleryItem() {
shouldPreventActions.value = true
startLoading()
const imageUrl = project.value!.gallery![editIndex.value].url
const imageUrl = filteredGallery.value[editIndex.value].url
const success = await contextEditGalleryItem(
imageUrl,
editTitle.value || undefined,
@@ -529,7 +534,7 @@ async function editGalleryItem() {
async function deleteGalleryImage() {
startLoading()
const imageUrl = project.value!.gallery![deleteIndex.value].url!
const imageUrl = filteredGallery.value[deleteIndex.value].url!
await contextDeleteGalleryItem(imageUrl)
stopLoading()
@@ -7,6 +7,7 @@ import {
ImageIcon,
InfoIcon,
LinkIcon,
ServerIcon,
TagsIcon,
UsersIcon,
VersionIcon,
@@ -36,6 +37,8 @@ const {
const flags = useFeatureFlags()
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const navItems = computed(() => {
const base = `${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
@@ -57,26 +60,36 @@ const navItems = computed(() => {
icon: InfoIcon,
}
: null,
isServerProject.value && {
link: `/${base}/settings/server`,
label: formatMessage(commonProjectSettingsMessages.server),
icon: ServerIcon,
},
{
link: `/${base}/settings/tags`,
label: formatMessage(commonProjectSettingsMessages.tags),
icon: TagsIcon,
},
{
!isServerProject.value && {
link: `/${base}/settings/description`,
label: formatMessage(commonProjectSettingsMessages.description),
icon: AlignLeftIcon,
},
{
!isServerProject.value && {
link: `/${base}/settings/versions`,
label: formatMessage(commonProjectSettingsMessages.versions),
icon: VersionIcon,
},
{
!isServerProject.value && {
link: `/${base}/settings/license`,
label: formatMessage(commonProjectSettingsMessages.license),
icon: BookTextIcon,
},
isServerProject.value && {
link: `/${base}/settings/description`,
label: formatMessage(commonProjectSettingsMessages.description),
icon: AlignLeftIcon,
},
{
link: `/${base}/settings/gallery`,
label: formatMessage(commonProjectSettingsMessages.gallery),
@@ -92,13 +105,13 @@ const navItems = computed(() => {
label: formatMessage(commonProjectSettingsMessages.members),
icon: UsersIcon,
},
{
!isServerProject.value && {
link: `/${base}/settings/analytics`,
label: formatMessage(commonProjectSettingsMessages.analytics),
icon: ChartIcon,
},
{ type: 'heading', label: 'moderation', shown: showEnvironment },
{
!isServerProject.value && { type: 'heading', label: 'moderation', shown: showEnvironment },
!isServerProject.value && {
link: `/${base}/settings/environment`,
label: formatMessage(commonProjectSettingsMessages.environment),
icon: GlobeIcon,
@@ -125,14 +138,16 @@ watch(route, () => {
<div class="mb-8 flex w-full flex-col gap-4">
<ModerationProjectNags
v-if="
(currentMember && project.status === 'draft') ||
tags.rejectedStatuses.includes(project.status)
projectV3 &&
((currentMember && project.status === 'draft') ||
tags.rejectedStatuses.includes(project.status))
"
:project="project"
:versions="versions"
:project-v3="projectV3"
:versions="versions ?? undefined"
:current-member="currentMember"
:collapsed="collapsedChecklist"
:route-name="route.name"
:route-name="route.name as string"
:tags="tags"
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@set-processing="setProcessing"
@@ -32,8 +32,8 @@
:src="
previewImage
? previewImage
: project.gallery[editIndex] && project.gallery[editIndex].url
? project.gallery[editIndex].url
: filteredGallery[editIndex] && filteredGallery[editIndex].url
? filteredGallery[editIndex].url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
alt="gallery-preview"
@@ -175,14 +175,14 @@
<ContractIcon v-else aria-hidden="true" />
</button>
<button
v-if="project.gallery.length > 1"
v-if="filteredGallery.length > 1"
class="previous circle-button"
@click="previousImage()"
>
<LeftArrowIcon aria-hidden="true" />
</button>
<button
v-if="project.gallery.length > 1"
v-if="filteredGallery.length > 1"
class="next circle-button"
@click="nextImage()"
>
@@ -215,7 +215,7 @@
/>
</div>
<div class="items">
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
<div v-for="(item, index) in filteredGallery" :key="index" class="card gallery-item">
<a class="gallery-thumbnail" @click="expandImage(item, index)">
<img
:src="item.url ? item.url : 'https://cdn.modrinth.com/placeholder-banner.svg'"
@@ -340,22 +340,27 @@ const editFile = ref(null)
const previewImage = ref(null)
const shouldPreventActions = ref(false)
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
const filteredGallery = computed(
() => project.value.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
)
const nextImage = () => {
expandedGalleryIndex.value++
if (expandedGalleryIndex.value >= project.value.gallery.length) {
if (expandedGalleryIndex.value >= filteredGallery.value.length) {
expandedGalleryIndex.value = 0
}
expandedGalleryItem.value = project.value.gallery[expandedGalleryIndex.value]
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
}
const previousImage = () => {
expandedGalleryIndex.value--
if (expandedGalleryIndex.value < 0) {
expandedGalleryIndex.value = project.value.gallery.length - 1
expandedGalleryIndex.value = filteredGallery.value.length - 1
}
expandedGalleryItem.value = project.value.gallery[expandedGalleryIndex.value]
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
}
const expandImage = (item, index) => {
@@ -414,7 +419,7 @@ const editGalleryItem = async () => {
shouldPreventActions.value = true
const success = await editGalleryItemMutation(
project.value.gallery[editIndex.value].url,
filteredGallery.value[editIndex.value].url,
editTitle.value || undefined,
editDescription.value || undefined,
editFeatured.value,
@@ -429,7 +434,7 @@ const editGalleryItem = async () => {
}
const deleteGalleryImage = async () => {
await deleteGalleryItemMutation(project.value.gallery[deleteIndex.value].url)
await deleteGalleryItemMutation(filteredGallery.value[deleteIndex.value].url)
}
const handleKeydown = (e) => {
@@ -10,154 +10,236 @@
@proceed="deleteProject"
/>
<section class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Project information</span>
</h3>
</div>
<label for="project-icon">
<span class="label__title">Icon</span>
</label>
<div class="input-group">
<Avatar
:src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
:alt="project.title"
size="md"
class="project__icon"
/>
<div class="input-stack">
<FileInput
id="project-icon"
:max-size="262144"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image iconified-button"
prompt="Upload icon"
aria-label="Upload icon"
:disabled="!hasPermission"
@change="showPreviewImage"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<button
v-if="!deletedIcon && (previewImage || project.icon_url)"
class="iconified-button"
:disabled="!hasPermission"
@click="markIconForDeletion"
>
<TrashIcon aria-hidden="true" />
Remove icon
</button>
<div class="flex max-w-[600px] flex-col gap-6">
<div class="label">
<h3>
<span class="label__title size-card-header">Project information</span>
</h3>
</div>
</div>
<label for="project-name">
<span class="label__title">Name</span>
</label>
<StyledInput id="project-name" v-model="name" :maxlength="2048" :disabled="!hasPermission" />
<label for="project-slug">
<span class="label__title">URL</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">
<span class="hidden sm:inline">https://modrinth.com</span>/{{
$getProjectTypeForUrl(project.project_type, project.loaders)
}}/
</div>
<StyledInput
id="project-slug"
v-model="slug"
:maxlength="64"
autocomplete="off"
:disabled="!hasPermission"
/>
</div>
<label for="project-summary">
<span class="label__title">Summary</span>
</label>
<div v-if="summaryWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ summaryWarning }}
</div>
<StyledInput
id="project-summary"
v-model="summary"
multiline
:maxlength="256"
:disabled="!hasPermission"
wrapper-class="summary-input"
/>
<template
v-if="
!flags.newProjectEnvironmentSettings &&
project.versions?.length !== 0 &&
project.project_type !== 'resourcepack' &&
project.project_type !== 'plugin' &&
project.project_type !== 'shader' &&
project.project_type !== 'datapack'
"
>
<div class="adjacent-input">
<label for="project-env-client">
<span class="label__title">Client-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
client side. Just because a mod works in Singleplayer doesn't mean it has actual
client-side functionality.
</span>
<div>
<label for="project-name">
<span class="label__title">Name</span>
</label>
<Multiselect
id="project-env-client"
v-model="clientSide"
class="small-multiselect"
placeholder="Select one"
:options="sideTypes"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
<StyledInput
id="project-name"
v-model="name"
:maxlength="2048"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label for="project-env-server">
<span class="label__title">Server-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
server.
</span>
</label>
<Multiselect
id="project-env-server"
v-model="serverSide"
class="small-multiselect"
placeholder="Select one"
:options="sideTypes"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
</template>
<div class="adjacent-input">
<label for="project-visibility">
<span class="label__title">Visibility</span>
<div class="label__description">
Public and archived projects are visible in search. Unlisted projects are published, but
not visible in search or on user profiles. Private projects are only accessible by
members of the project.
<p>If approved by the moderators:</p>
<ul class="visibility-info">
<div>
<label for="project-slug">
<span class="label__title">URL</span>
</label>
<div class="text-input-wrapper !w-full">
<div class="text-input-wrapper__before">
<span class="hidden sm:inline">https://modrinth.com</span>/{{
$getProjectTypeForUrl(project.project_type, project.loaders)
}}/
</div>
<StyledInput
id="project-slug"
v-model="slug"
:maxlength="64"
autocomplete="off"
:disabled="!hasPermission"
/>
</div>
</div>
<div>
<label for="project-summary">
<span class="label__title">Summary</span>
</label>
<StyledInput
id="project-summary"
v-model="summary"
multiline
:maxlength="256"
:disabled="!hasPermission"
resize="vertical"
/>
<div v-if="summaryWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ summaryWarning }}
</div>
</div>
<div>
<label for="project-icon">
<span class="label__title"
>Icon <span class="font-normal text-secondary">(optional)</span></span
>
</label>
<div class="input-group">
<Avatar
:src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
:alt="project.title"
size="md"
class="project__icon"
/>
<div class="input-stack">
<FileInput
id="project-icon"
:max-size="262144"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image iconified-button"
prompt="Upload icon"
aria-label="Upload icon"
:disabled="!hasPermission"
@change="showPreviewImage"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<button
v-if="!deletedIcon && (previewImage || project.icon_url)"
class="iconified-button"
:disabled="!hasPermission"
@click="markIconForDeletion"
>
<TrashIcon aria-hidden="true" />
Remove icon
</button>
</div>
</div>
</div>
<!-- Server Project Settings -->
<template v-if="isServerProject">
<!-- Banner -->
<div>
<label>
<span class="label__title"
>Banner <span class="font-normal text-secondary">(optional)</span></span
>
</label>
<div class="mt-2">
<label
class="flex cursor-pointer flex-col items-center justify-center rounded-2xl border-dashed border-surface-5 transition-colors"
:class="
!deletedBanner && (bannerPreview || bannerGalleryImage?.url)
? 'border-none'
: 'aspect-[468/60] border-2 bg-surface-2'
"
>
<div
v-if="!deletedBanner && (bannerPreview || bannerGalleryImage?.url)"
class="relative h-full w-full overflow-hidden rounded-2xl"
>
<img
:src="bannerPreview || bannerGalleryImage?.url"
alt="Banner preview"
class="h-full w-full object-cover"
/>
</div>
<ImageIcon v-else aria-hidden="true" class="h-8 w-8 text-secondary" />
<input
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
class="hidden"
:disabled="!hasPermission"
@change="
(e) => {
const input = e.target
if (input.files?.length) {
if (fileIsValid(input.files[0], { maxSize: 524288, alertOnInvalid: true }))
showBannerPreview(Array.from(input.files))
}
}
"
/>
</label>
</div>
<div class="mt-2 flex items-center gap-2">
<FileInput
:max-size="524288"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="iconified-button"
prompt="Upload banner"
:disabled="!hasPermission"
@change="showBannerPreview"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<button
v-if="!deletedBanner && (bannerPreview || bannerGalleryImage?.url)"
class="iconified-button"
:disabled="!hasPermission"
@click="markBannerForDeletion"
>
<TrashIcon aria-hidden="true" />
Remove banner
</button>
</div>
<div class="mt-2 text-secondary">Gif, 468×60px recommended.</div>
</div>
</template>
<template
v-if="
!isServerProject &&
!flags.newProjectEnvironmentSettings &&
project.versions?.length !== 0 &&
project.project_type !== 'resourcepack' &&
project.project_type !== 'plugin' &&
project.project_type !== 'shader' &&
project.project_type !== 'datapack'
"
>
<div class="adjacent-input">
<label for="project-env-client">
<span class="label__title">Client-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
client side. Just because a mod works in Singleplayer doesn't mean it has actual
client-side functionality.
</span>
</label>
<Combobox
v-model="clientSide"
:options="sideTypeOptions"
placeholder="Select one"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label for="project-env-server">
<span class="label__title">Server-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
server.
</span>
</label>
<Combobox
v-model="serverSide"
:options="sideTypeOptions"
placeholder="Select one"
:disabled="!hasPermission"
/>
</div>
</template>
<div>
<label>
<span class="label__title">Visibility</span>
</label>
<div class="flex flex-col gap-2.5">
<Combobox
v-model="visibility"
:options="visibilityOptions"
placeholder="Select one"
:disabled="!hasPermission"
:max-height="500"
/>
<div>If approved by the moderators:</div>
<ul class="visibility-info m-0">
<li>
<CheckIcon
v-if="visibility === 'approved' || visibility === 'archived'"
@@ -187,31 +269,7 @@
</li>
</ul>
</div>
</label>
<Multiselect
id="project-visibility"
v-model="visibility"
class="small-multiselect"
placeholder="Select one"
:options="tags.approvedStatuses"
:custom-label="(value) => formatProjectStatus(value)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
@click="saveChanges()"
>
<SaveIcon aria-hidden="true" />
Save changes
</button>
</div>
</div>
</section>
@@ -235,14 +293,21 @@
Delete project
</button>
</section>
<UnsavedChangesPopup
:original="original"
:modified="modified"
:saving="saving"
@reset="resetChanges"
@save="handleSave"
/>
</div>
</template>
<script setup>
import {
CheckIcon,
ImageIcon,
IssuesIcon,
SaveIcon,
TrashIcon,
TriangleAlertIcon,
UploadIcon,
@@ -251,13 +316,15 @@ import {
import { MIN_SUMMARY_CHARS } from '@modrinth/moderation'
import {
Avatar,
Combobox,
ConfirmModal,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
StyledInput,
UnsavedChangesPopup,
} from '@modrinth/ui'
import { formatProjectStatus, formatProjectType } from '@modrinth/utils'
import { Multiselect } from 'vue-multiselect'
import { fileIsValid, formatProjectStatus, formatProjectType } from '@modrinth/utils'
import FileInput from '~/components/ui/FileInput.vue'
import { useFeatureFlags } from '~/composables/featureFlags.ts'
@@ -265,11 +332,13 @@ import { useFeatureFlags } from '~/composables/featureFlags.ts'
const { addNotification } = injectNotificationManager()
const {
projectV2: project,
projectV3,
currentMember,
patchProject,
patchIcon,
invalidate,
} = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const flags = useFeatureFlags()
@@ -290,6 +359,15 @@ const visibility = ref(
: project.value.requested_status,
)
// Server project specific refs
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const bannerPreview = ref(null)
const deletedBanner = ref(false)
const bannerFile = ref(null)
const bannerGalleryImage = computed(() =>
project.value.gallery?.find((img) => img.title === MC_SERVER_BANNER_NAME),
)
const hasPermission = computed(() => {
const EDIT_DETAILS = 1 << 2
return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
@@ -311,9 +389,37 @@ const summaryWarning = computed(() => {
return null
})
const sideTypes = ['required', 'optional', 'unsupported']
const sideTypeOptions = [
{ value: 'required', label: 'Required' },
{ value: 'optional', label: 'Optional' },
{ value: 'unsupported', label: 'Unsupported' },
]
const patchData = computed(() => {
const visibilityOptions = computed(() =>
tags.value.approvedStatuses.map((status) => {
const subLabel = () => {
switch (status) {
case 'approved':
return 'Visible via URL, on your profile, and in search.'
case 'archived':
return 'Visible via URL, on your profile, and in search, but marked as archived.'
case 'unlisted':
return 'Visible via URL only. Not shown on your profile or in search.'
case 'private':
return 'Not publicly visible. Only accessible to project members.'
default:
return ''
}
}
return {
value: status,
label: formatProjectStatus(status),
subLabel: subLabel(),
}
}),
)
const basePatchData = computed(() => {
const data = {}
if (name.value !== project.value.title) {
@@ -342,9 +448,52 @@ const patchData = computed(() => {
return data
})
const hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0 || deletedIcon.value || icon.value
})
const saving = ref(false)
const original = computed(() => ({
name: project.value.title,
slug: project.value.slug,
summary: project.value.description,
clientSide: project.value.client_side,
serverSide: project.value.server_side,
visibility: tags.value.approvedStatuses.includes(project.value.status)
? project.value.status
: project.value.requested_status,
icon: null,
deletedIcon: false,
bannerFile: null,
deletedBanner: false,
}))
const modified = computed(() => ({
name: name.value,
slug: slug.value,
summary: summary.value,
clientSide: clientSide.value,
serverSide: serverSide.value,
visibility: visibility.value,
icon: icon.value,
deletedIcon: deletedIcon.value,
bannerFile: bannerFile.value,
deletedBanner: deletedBanner.value,
}))
function resetChanges() {
name.value = project.value.title
slug.value = project.value.slug
summary.value = project.value.description
clientSide.value = project.value.client_side
serverSide.value = project.value.server_side
visibility.value = tags.value.approvedStatuses.includes(project.value.status)
? project.value.status
: project.value.requested_status
icon.value = null
previewImage.value = null
deletedIcon.value = false
bannerFile.value = null
bannerPreview.value = null
deletedBanner.value = false
}
const hasModifiedVisibility = () => {
const originalVisibility = tags.value.approvedStatuses.includes(project.value.status)
@@ -354,17 +503,33 @@ const hasModifiedVisibility = () => {
return originalVisibility !== visibility.value
}
const saveChanges = async () => {
if (hasChanges.value) {
await patchProject(patchData.value)
}
async function handleSave() {
saving.value = true
try {
const hasV2Changes = Object.keys(basePatchData.value).length > 0
if (deletedIcon.value) {
await deleteIcon()
deletedIcon.value = false
} else if (icon.value) {
await patchIcon(icon.value)
icon.value = null
if (hasV2Changes) {
await patchProject(basePatchData.value)
}
if (deletedIcon.value) {
await deleteIcon()
deletedIcon.value = false
} else if (icon.value) {
await patchIcon(icon.value)
icon.value = null
}
if (deletedBanner.value) {
await deleteBanner()
deletedBanner.value = false
} else if (bannerFile.value) {
await uploadBanner()
bannerFile.value = null
bannerPreview.value = null
}
} finally {
saving.value = false
}
}
@@ -378,6 +543,79 @@ const showPreviewImage = (files) => {
}
}
const showBannerPreview = (files) => {
const file = files[0]
if (file) {
bannerFile.value = file
const reader = new FileReader()
reader.onload = (e) => {
bannerPreview.value = e.target.result
}
reader.readAsDataURL(file)
deletedBanner.value = false
}
}
const markBannerForDeletion = () => {
bannerPreview.value = null
bannerFile.value = null
deletedBanner.value = true
}
const uploadBanner = async () => {
if (!bannerFile.value) return
try {
// First, delete existing banner image if there is one
const existingBanner = project.value.gallery?.find((img) => img.title === MC_SERVER_BANNER_NAME)
if (existingBanner) {
await labrinth.projects_v2.deleteGalleryImage(project.value.id, existingBanner.url)
}
// Upload new banner as gallery image with special title
const ext = bannerFile.value.type.split('/').pop() ?? 'png'
await labrinth.projects_v2.createGalleryImage(project.value.id, bannerFile.value, {
ext,
featured: false,
title: MC_SERVER_BANNER_NAME,
})
await invalidate()
addNotification({
title: 'Banner updated',
text: 'Your project banner has been updated.',
type: 'success',
})
} catch (err) {
addNotification({
title: 'Failed to update banner',
text: err.data?.description ?? String(err),
type: 'error',
})
}
}
const deleteBanner = async () => {
try {
const bannerImage = project.value.gallery?.find((img) => img.title === MC_SERVER_BANNER_NAME)
if (bannerImage) {
await labrinth.projects_v2.deleteGalleryImage(project.value.id, bannerImage.url)
await invalidate()
addNotification({
title: 'Banner removed',
text: 'Your project banner has been removed.',
type: 'success',
})
}
} catch (err) {
addNotification({
title: 'Failed to remove banner',
text: err.data?.description ?? String(err),
type: 'error',
})
}
}
const deleteProject = async () => {
await useBaseFetch(`project/${project.value.id}`, {
method: 'DELETE',
@@ -409,10 +647,14 @@ const deleteIcon = async () => {
})
}
</script>
<style lang="scss" scoped>
.visibility-info {
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--spacing-card-xs);
li {
display: flex;
@@ -435,15 +677,6 @@ svg {
}
}
.summary-input {
min-height: 8rem;
max-width: 24rem;
}
.small-multiselect {
max-width: 15rem;
}
.button-group {
justify-content: flex-start;
}
@@ -1,6 +1,84 @@
<template>
<div>
<section class="universal-card">
<!-- Server Project Links -->
<section v-if="isServerProject" class="universal-card">
<h2>External links</h2>
<div class="adjacent-input">
<label id="server-website" title="Your server's website.">
<span class="label__title">Website</span>
<span class="label__description">Your server's official website.</span>
</label>
<input
id="server-website"
v-model="siteUrl"
type="url"
placeholder="Enter a valid URL"
maxlength="2048"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label id="server-store" title="Your server's store page.">
<span class="label__title">Store</span>
<span class="label__description">A link to your server's store or shop.</span>
</label>
<input
id="server-store"
v-model="storeUrl"
type="url"
placeholder="Enter a valid URL"
maxlength="2048"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label
id="server-wiki"
title="A page containing information, documentation, and help for the server."
>
<span class="label__title">Wiki page</span>
<span class="label__description"
>A page containing information, documentation, and help for the server.</span
>
</label>
<input
id="server-wiki"
v-model="serverWikiUrl"
type="url"
placeholder="Enter a valid URL"
maxlength="2048"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label id="server-discord" title="An invitation link to your Discord server.">
<span class="label__title">Discord</span>
<span class="label__description">An invitation link to your Discord server.</span>
</label>
<input
id="server-discord"
v-model="serverDiscordUrl"
type="url"
placeholder="Enter a valid URL"
maxlength="2048"
:disabled="!hasPermission"
/>
</div>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasServerChanges"
@click="saveServerChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
<!-- Standard Project Links -->
<section v-if="!isServerProject" class="universal-card">
<h2>External links</h2>
<div class="adjacent-input">
<label
@@ -174,17 +252,51 @@
<script setup>
import { SaveIcon, TriangleAlertIcon } from '@modrinth/assets'
import { commonLinkDomains, isCommonUrl, isDiscordUrl, isLinkShortener } from '@modrinth/moderation'
import { DropdownSelect, injectProjectPageContext, StyledInput } from '@modrinth/ui'
import {
DropdownSelect,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
StyledInput,
} from '@modrinth/ui'
const tags = useGeneratedState()
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
const {
projectV2: project,
projectV3,
currentMember,
patchProject,
invalidate,
} = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const issuesUrl = ref(project.value.issues_url)
const sourceUrl = ref(project.value.source_url)
const wikiUrl = ref(project.value.wiki_url)
const discordUrl = ref(project.value.discord_url)
// Server project links
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const siteUrl = ref(projectV3.value?.link_urls?.site?.url ?? '')
const storeUrl = ref(projectV3.value?.link_urls?.store?.url ?? '')
const serverWikiUrl = ref(projectV3.value?.link_urls?.wiki?.url ?? '')
const serverDiscordUrl = ref(projectV3.value?.link_urls?.discord?.url ?? '')
watch(
projectV3,
(newVal) => {
if (newVal) {
siteUrl.value = newVal.link_urls?.site?.url ?? ''
storeUrl.value = newVal.link_urls?.store?.url ?? ''
serverWikiUrl.value = newVal.link_urls?.wiki?.url ?? ''
serverDiscordUrl.value = newVal.link_urls?.discord?.url ?? ''
}
},
{ immediate: true },
)
const isIssuesUrlCommon = computed(() => {
if (!issuesUrl.value || issuesUrl.value.trim().length === 0) return true
return isCommonUrl(issuesUrl.value, commonLinkDomains.issues)
@@ -281,6 +393,56 @@ const hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0
})
// Server project links
const serverPatchData = computed(() => {
const data = {}
const originalSite = projectV3.value?.link_urls?.site?.url ?? ''
const originalStore = projectV3.value?.link_urls?.store?.url ?? ''
const originalWiki = projectV3.value?.link_urls?.wiki?.url ?? ''
const originalDiscord = projectV3.value?.link_urls?.discord?.url ?? ''
if (checkDifference(siteUrl.value, originalSite)) {
data.site = siteUrl.value === '' ? null : siteUrl.value?.trim()
}
if (checkDifference(storeUrl.value, originalStore)) {
data.store = storeUrl.value === '' ? null : storeUrl.value?.trim()
}
if (checkDifference(serverWikiUrl.value, originalWiki)) {
data.wiki = serverWikiUrl.value === '' ? null : serverWikiUrl.value?.trim()
}
if (checkDifference(serverDiscordUrl.value, originalDiscord)) {
data.discord = serverDiscordUrl.value === '' ? null : serverDiscordUrl.value?.trim()
}
return data
})
const hasServerChanges = computed(() => {
return Object.keys(serverPatchData.value).length > 0
})
async function saveServerChanges() {
const linkUpdates = serverPatchData.value
if (Object.keys(linkUpdates).length === 0) return
try {
await labrinth.projects_v3.edit(project.value.id, {
link_urls: linkUpdates,
})
await invalidate()
addNotification({
title: 'Links updated',
text: 'Your server links have been updated.',
type: 'success',
})
} catch (err) {
addNotification({
title: 'Failed to update links',
text: err.data?.description ?? String(err),
type: 'error',
})
}
}
async function saveChanges() {
if (patchData.value && (await patchProject(patchData.value))) {
donationLinks.value = JSON.parse(JSON.stringify(project.value.donation_urls))
@@ -321,13 +483,7 @@ function updateDonationLinks() {
}
function checkDifference(newLink, existingLink) {
if (newLink === '' && existingLink !== null) {
return true
}
if (!newLink && !existingLink) {
return false
}
return newLink !== existingLink
return newLink != existingLink
}
</script>
<style lang="scss" scoped>
@@ -0,0 +1,507 @@
<template>
<div>
<section class="universal-card">
<div class="flex flex-col gap-6">
<div class="text-2xl font-semibold text-contrast">Server details</div>
<!-- Country -->
<div class="max-w-[600px]">
<label for="server-country">
<span class="label__title">Country</span>
</label>
<Combobox
id="server-country"
v-model="country"
:options="countryOptions"
searchable
placeholder="Select country"
:disabled="!hasPermission"
/>
</div>
<!-- Language -->
<div class="max-w-[600px]">
<label for="server-language">
<span class="label__title"
>Languages <span class="font-normal text-secondary">(optional)</span></span
>
</label>
<Multiselect
id="server-language"
v-model="languages"
:options="languageOptions.map((l) => l.value)"
:custom-label="(code) => languageOptions.find((l) => l.value === code)?.label ?? code"
:multiple="true"
:searchable="true"
:show-labels="false"
:close-on-select="false"
placeholder="Select languages"
:disabled="!hasPermission"
/>
</div>
<!-- Java Address -->
<div class="max-w-[600px]">
<div class="flex items-center justify-between">
<label for="java-address">
<span class="label__title !m-0 !text-contrast">Java address</span>
</label>
</div>
<div
class="mt-2 flex items-center gap-2"
@focusout="
() => {
if (!lastPingAddressChanged && javaPingResult) return
pingJavaServer()
}
"
>
<StyledInput
id="java-address"
v-model="javaAddress"
placeholder="Enter address"
:disabled="!hasPermission"
wrapper-class="flex-grow"
autocomplete="off"
/>
<StyledInput
v-model="javaPort"
type="number"
:min="1"
:max="65535"
:disabled="!hasPermission"
wrapper-class="w-24"
input-class="text-center"
autocomplete="off"
/>
</div>
<div
v-if="javaAddress"
class="mt-2 flex gap-1.5"
:class="{
'items-center': javaPingResult?.online,
'items-start': javaPingResult && !javaPingResult.online,
}"
>
<ButtonStyled
v-if="(javaAddress && javaPingResult) || javaPingLoading"
circular
type="transparent"
size="small"
color="oranges"
>
<button
v-tooltip="'Refresh ping'"
:disabled="javaPingLoading"
@click="pingJavaServer"
>
<SpinnerIcon v-if="javaPingLoading" class="animate-spin" />
<RefreshCwIcon v-else />
</button>
</ButtonStyled>
<div
v-if="javaPingResult !== null && !javaPingLoading && javaPingResult.online"
class="mt-0.5 flex items-center gap-1.5 text-green"
>
Server is online!
<template v-if="javaPingResult.latency">
Latency: {{ javaPingResult.latency }}ms
</template>
</div>
<div v-else-if="javaPingResult !== null && !javaPingLoading" class="mt-0.5 text-orange">
We couldnt ping this server. It may be blocked by your host so try refreshing a few
times. If it still doesnt respond please
<a
class="inline underline"
href="https://support.modrinth.com"
target="_blank"
rel="noopener noreferrer"
>
contact support</a
>.
</div>
</div>
</div>
<!-- Bedrock Address -->
<div class="max-w-[600px]">
<label for="bedrock-address">
<span class="label__title !text-contrast"
>Bedrock address
<span class="font-normal text-secondary">(optional)</span>
</span>
</label>
<div class="mt-2 flex items-center gap-2">
<StyledInput
id="bedrock-address"
v-model="bedrockAddress"
placeholder="Enter address"
:disabled="!hasPermission"
wrapper-class="flex-grow"
autocomplete="off"
/>
<StyledInput
v-model="bedrockPort"
type="number"
:min="1"
:max="65535"
:disabled="!hasPermission"
wrapper-class="w-24"
input-class="text-center"
autocomplete="off"
/>
</div>
</div>
<CompatibilityCard />
</div>
</section>
<UnsavedChangesPopup
:original="original"
:modified="modified"
:saving="saving"
@reset="resetChanges"
@save="handleSave"
/>
</div>
</template>
<script setup>
import { RefreshCwIcon, SpinnerIcon } from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
StyledInput,
UnsavedChangesPopup,
} from '@modrinth/ui'
import { Multiselect } from 'vue-multiselect'
import CompatibilityCard from '~/components/ui/project-settings/CompatibilityCard.vue'
const PING_TIMEOUT_MS = 5000
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { projectV3, currentMember, patchProjectV3 } = injectProjectPageContext()
const javaAddress = ref('')
const javaPort = ref(25565)
const bedrockAddress = ref('')
const bedrockPort = ref(19132)
const country = ref('')
const languages = ref([])
const javaPingLoading = ref(false)
const javaPingResult = ref(null)
const lastPingedAddress = ref({ address: '', port: null })
const lastPingAddressChanged = computed(() => {
return (
javaAddress.value.trim() !== lastPingedAddress.value.address ||
javaPort.value !== lastPingedAddress.value.port
)
})
let pingDebounceTimer = null
watch([javaAddress, javaPort], () => {
clearTimeout(pingDebounceTimer)
pingDebounceTimer = setTimeout(() => {
pingJavaServer()
}, 500)
})
const hasPermission = computed(() => {
const EDIT_DETAILS = 1 << 2
return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
})
async function pingJavaServer() {
const address = javaAddress.value?.trim()
if (!address) {
javaPingResult.value = null
return
}
javaPingLoading.value = true
javaPingResult.value = null
const port = javaPort.value || 25565
try {
await Promise.race([
client.labrinth.server_ping_internal.pingMinecraftJava({
address,
port,
}),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Ping timed out')), PING_TIMEOUT_MS),
),
])
javaPingResult.value = { online: true, latency: null }
} catch {
javaPingResult.value = { online: false, latency: null }
} finally {
javaPingLoading.value = false
lastPingedAddress.value = { address, port }
}
}
function initFromProjectV3(v3) {
if (!v3) return
javaAddress.value = v3.minecraft_java_server?.address ?? ''
javaPort.value = v3.minecraft_java_server?.port ?? 25565
bedrockAddress.value = v3.minecraft_bedrock_server?.address ?? ''
bedrockPort.value = v3.minecraft_bedrock_server?.port ?? 19132
country.value = v3.minecraft_server?.country ?? ''
languages.value = v3.minecraft_server?.languages ?? []
pingJavaServer()
}
// initialize projectV3 values once
if (projectV3.value) {
initFromProjectV3(projectV3.value)
} else {
const stop = watch(
() => projectV3.value,
(v3) => {
if (!v3) return
initFromProjectV3(v3)
stop()
},
)
}
const countryOptions = [
{ value: 'US', label: 'United States' },
{ value: 'CA', label: 'Canada' },
{
value: 'EU',
label: 'Europe',
searchTerms: [
'Germany',
'France',
'Netherlands',
'Finland',
'Sweden',
'Denmark',
'Poland',
'Czech Republic',
'Romania',
'Austria',
'Belgium',
'Ireland',
'Spain',
'Italy',
'Portugal',
'Lithuania',
'Latvia',
'Estonia',
'Bulgaria',
'Croatia',
'Hungary',
'Slovakia',
'Greece',
'Luxembourg',
'Malta',
'Cyprus',
'Slovenia',
'Great Britain',
'United Kingdom',
],
},
{ value: 'NO', label: 'Norway' },
{ value: 'CH', label: 'Switzerland' },
{ value: 'RU', label: 'Russia' },
{ value: 'UA', label: 'Ukraine' },
{ value: 'RS', label: 'Serbia' },
{ value: 'TR', label: 'Turkey' },
{ value: 'IL', label: 'Israel' },
{ value: 'AE', label: 'United Arab Emirates' },
{ value: 'SA', label: 'Saudi Arabia' },
{ value: 'IN', label: 'India' },
{ value: 'SG', label: 'Singapore' },
{ value: 'JP', label: 'Japan' },
{ value: 'KR', label: 'South Korea' },
{ value: 'CN', label: 'China' },
{ value: 'HK', label: 'Hong Kong' },
{ value: 'TW', label: 'Taiwan' },
{ value: 'AU', label: 'Australia' },
{ value: 'NZ', label: 'New Zealand' },
{ value: 'BR', label: 'Brazil' },
{ value: 'AR', label: 'Argentina' },
{ value: 'CL', label: 'Chile' },
{ value: 'CO', label: 'Colombia' },
{ value: 'MX', label: 'Mexico' },
{ value: 'ZA', label: 'South Africa' },
{ value: 'NG', label: 'Nigeria' },
{ value: 'KE', label: 'Kenya' },
{ value: 'EG', label: 'Egypt' },
{ value: 'MY', label: 'Malaysia' },
{ value: 'TH', label: 'Thailand' },
{ value: 'VN', label: 'Vietnam' },
{ value: 'PH', label: 'Philippines' },
{ value: 'ID', label: 'Indonesia' },
{ value: 'PK', label: 'Pakistan' },
{ value: 'BD', label: 'Bangladesh' },
]
const languageOptions = [
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Spanish' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'German' },
{ value: 'it', label: 'Italian' },
{ value: 'nl', label: 'Dutch' },
{ value: 'ru', label: 'Russian' },
{ value: 'uk', label: 'Ukrainian' },
{ value: 'pl', label: 'Polish' },
{ value: 'cs', label: 'Czech' },
{ value: 'sk', label: 'Slovak' },
{ value: 'hu', label: 'Hungarian' },
{ value: 'ro', label: 'Romanian' },
{ value: 'bg', label: 'Bulgarian' },
{ value: 'hr', label: 'Croatian' },
{ value: 'sr', label: 'Serbian' },
{ value: 'el', label: 'Greek' },
{ value: 'tr', label: 'Turkish' },
{ value: 'ar', label: 'Arabic' },
{ value: 'he', label: 'Hebrew' },
{ value: 'hi', label: 'Hindi' },
{ value: 'bn', label: 'Bengali' },
{ value: 'ur', label: 'Urdu' },
{ value: 'zh', label: 'Chinese' },
{ value: 'ja', label: 'Japanese' },
{ value: 'ko', label: 'Korean' },
{ value: 'th', label: 'Thai' },
{ value: 'vi', label: 'Vietnamese' },
{ value: 'id', label: 'Indonesian' },
{ value: 'ms', label: 'Malay' },
{ value: 'tl', label: 'Filipino' },
{ value: 'sv', label: 'Swedish' },
{ value: 'no', label: 'Norwegian' },
{ value: 'da', label: 'Danish' },
{ value: 'fi', label: 'Finnish' },
{ value: 'lt', label: 'Lithuanian' },
{ value: 'lv', label: 'Latvian' },
{ value: 'et', label: 'Estonian' },
]
const javaServerPatchData = computed(() => {
const addressChanged =
javaAddress.value.trim() !== (projectV3.value?.minecraft_java_server?.address ?? '') ||
javaPort.value !== (projectV3.value?.minecraft_java_server?.port ?? 25565)
if (addressChanged) {
return {
address: javaAddress.value.trim(),
port: javaPort.value,
}
}
return {}
})
const bedrockServerPatchData = computed(() => {
const origBedrock = projectV3.value?.minecraft_bedrock_server
if (
bedrockAddress.value !== (origBedrock?.address ?? '') ||
bedrockPort.value !== (origBedrock?.port ?? 19132)
) {
return {
address: bedrockAddress.value.trim(),
port: bedrockPort.value,
}
}
return {}
})
const serverPatchData = computed(() => {
const origServer = projectV3.value?.minecraft_server
const countryChanged = country.value && country.value !== origServer?.country
const languagesChanged =
JSON.stringify([...languages.value].sort()) !==
JSON.stringify([...(origServer?.languages ?? [])].sort())
if (countryChanged || languagesChanged) {
return {
...origServer,
...(countryChanged ? { country: country.value } : {}),
...(languagesChanged ? { languages: languages.value } : {}),
}
}
return {}
})
const v3PatchData = computed(() => {
const data = {}
if (Object.keys(serverPatchData.value).length > 0) {
data.minecraft_server = serverPatchData.value
}
if (Object.keys(javaServerPatchData.value).length > 0) {
data.minecraft_java_server = javaServerPatchData.value
}
if (Object.keys(bedrockServerPatchData.value).length > 0) {
data.minecraft_bedrock_server = bedrockServerPatchData.value
}
return data
})
const saving = ref(false)
const original = computed(() => ({
javaAddress: projectV3.value?.minecraft_java_server?.address ?? '',
javaPort: projectV3.value?.minecraft_java_server?.port ?? 25565,
bedrockAddress: projectV3.value?.minecraft_bedrock_server?.address ?? '',
bedrockPort: projectV3.value?.minecraft_bedrock_server?.port ?? 19132,
country: projectV3.value?.minecraft_server?.country ?? '',
languages: projectV3.value?.minecraft_server?.languages ?? [],
}))
const modified = computed(() => ({
javaAddress: javaAddress.value,
javaPort: javaPort.value,
bedrockAddress: bedrockAddress.value,
bedrockPort: bedrockPort.value,
country: country.value,
languages: languages.value,
}))
function resetChanges() {
javaAddress.value = projectV3.value?.minecraft_java_server?.address ?? ''
javaPort.value = projectV3.value?.minecraft_java_server?.port ?? 25565
bedrockAddress.value = projectV3.value?.minecraft_bedrock_server?.address ?? ''
bedrockPort.value = projectV3.value?.minecraft_bedrock_server?.port ?? 19132
country.value = projectV3.value?.minecraft_server?.country ?? ''
languages.value = projectV3.value?.minecraft_server?.languages ?? []
}
async function handleSave() {
if (javaAddress.value.trim() && !javaPingResult.value?.online) {
addNotification({
title: 'Cannot save',
text: 'The Java server must be reachable before saving. Please ensure the ping succeeds.',
type: 'error',
})
return
}
saving.value = true
try {
const hasV3Changes = Object.keys(v3PatchData.value).length > 0
if (hasV3Changes) {
await patchProjectV3(v3PatchData.value)
}
} finally {
saving.value = false
}
}
</script>
@@ -31,7 +31,10 @@
that apply.
</p>
<p v-if="project.versions.length === 0" class="known-errors">
<p
v-if="project.versions.length === 0 && projectV3?.minecraft_server == null"
class="known-errors"
>
Please upload a version first in order to select tags!
</p>
<template v-else>
@@ -156,24 +159,28 @@ interface Category {
const tags = useGeneratedState()
const { formatMessage, locale } = useVIntl()
const { projectV2: project, patchProject } = injectProjectPageContext()
const { projectV2: project, projectV3, patchProject } = injectProjectPageContext()
const formatCategoryName = (categoryName: string) => {
return formatCategory(formatMessage, categoryName)
}
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const matchesProjectType = (x: Category) =>
x.project_type === project.value.actualProjectType ||
(x.project_type === 'minecraft_java_server' && isServerProject.value)
const { saved, current, saving, reset, save } = useSavable(
() => ({
selectedTags: sortedCategories(tags.value, formatCategoryName, locale.value).filter(
(x: Category) =>
x.project_type === project.value.actualProjectType &&
matchesProjectType(x) &&
(project.value.categories.includes(x.name) ||
project.value.additional_categories.includes(x.name)),
) as Category[],
featuredTags: sortedCategories(tags.value, formatCategoryName, locale.value).filter(
(x: Category) =>
x.project_type === project.value.actualProjectType &&
project.value.categories.includes(x.name),
(x: Category) => matchesProjectType(x) && project.value.categories.includes(x.name),
) as Category[],
}),
async () => {
@@ -217,7 +224,7 @@ const { saved, current, saving, reset, save } = useSavable(
const categoryLists = computed(() => {
const lists: Record<string, Category[]> = {}
sortedCategories(tags.value, formatCategoryName, locale.value).forEach((x: Category) => {
if (x.project_type === project.value.actualProjectType) {
if (matchesProjectType(x)) {
const header = x.header
if (!lists[header]) {
lists[header] = []
@@ -230,7 +237,13 @@ const categoryLists = computed(() => {
const tooManyTagsWarning = computed(() => {
const tagCount = current.value.selectedTags.length
if (tagCount > 8) {
if (projectV3?.value?.minecraft_server != null) {
if (tagCount > 18) {
return `You've selected ${tagCount} tags. Please reduce to 18 or fewer to keep your server focused and easier to discover.`
} else if (tagCount > 12) {
return `You've selected ${tagCount} tags. Consider reducing to 12 or fewer to keep your server focused and easier to discover.`
}
} else if (tagCount > 8) {
return `You've selected ${tagCount} tags. Consider reducing to 8 or fewer to keep your project focused and easier to discover.`
}
return null
@@ -208,7 +208,7 @@
</ProjectPageVersions>
<template v-if="!versions?.length">
<div class="grid place-content-center py-10">
<div class="grid place-items-center py-10">
<svg
width="250"
height="200"
+1 -1
View File
@@ -20,7 +20,7 @@
link: '/dashboard/affiliate-links',
label: formatMessage(commonMessages.affiliateLinksButton),
icon: AffiliateIcon,
shown: isAffiliate,
shown: !!isAffiliate,
},
{ link: '/dashboard/revenue', label: 'Revenue', icon: CurrencyIcon, matchNested: true },
]"
@@ -159,7 +159,7 @@
<div class="header__row">
<h2 class="header__title text-2xl">Projects</h2>
<div class="input-group">
<button class="iconified-button brand-button" @click="$refs.modal_creation.show()">
<button class="iconified-button brand-button" @click="$refs.modal_creation.show($event)">
<PlusIcon />
{{ formatMessage(commonMessages.createAProjectButton) }}
</button>
@@ -454,6 +454,14 @@ async function bulkEditLinks() {
await initUserProjects()
if (user.value?.projects) {
projects.value = updateSort(user.value.projects, 'Name', false)
// minecraft_java_server type determined from component on projectV3
projects.value = projects.value.map((project) => {
const projectV3 = user.value?.projectsV3?.find((p) => p.id === project.id)
if (projectV3?.minecraft_server != null)
return { ...project, project_type: 'minecraft_java_server' }
return project
})
user.value?.projectsV3?.forEach((project) => {
if (
project.side_types_migration_review_status === 'pending' &&
+272 -125
View File
@@ -33,6 +33,7 @@ import {
StyledInput,
Toggle,
useSearch,
useServerSearch,
useVIntl,
} from '@modrinth/ui'
import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils'
@@ -84,6 +85,10 @@ const currentType = computed(() =>
queryAsStringOrEmpty(route.params.type).replaceAll(/^\/|s\/?$/g, ''),
)
watch(currentType, (newType) => {
console.log('currentType changed:', newType)
})
const projectType = computed(() => tags.value.projectTypes.find((x) => x.id === currentType.value))
const projectTypes = computed(() => (projectType.value ? [projectType.value.id] : []))
@@ -336,6 +341,28 @@ async function serverInstall(project: InstallableSearchResult) {
}
const noLoad = ref(false)
const {
serverCurrentSortType,
serverCurrentFilters,
serverToggledGroups,
serverSortTypes,
serverFilterTypes,
serverRequestParams,
createServerPageParams,
} = useServerSearch({ tags, query, maxResults, currentPage })
const effectiveSortType = computed({
get: () => (currentType.value === 'server' ? serverCurrentSortType.value : currentSortType.value),
set: (v: SortType) => {
if (currentType.value === 'server') serverCurrentSortType.value = v
else currentSortType.value = v
},
})
const effectiveSortTypes = computed(() =>
currentType.value === 'server' ? serverSortTypes : [...sortTypes],
)
const {
data: rawResults,
refresh: refreshSearch,
@@ -343,15 +370,30 @@ const {
} = useLazyFetch(
() => {
const config = useRuntimeConfig()
const base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
if (currentType.value === 'server') {
base = base.replace(/\/v\d\//, '/v3/').replace(/\/v\d$/, '/v3')
return `${base}search${serverRequestParams.value}`
}
return `${base}search${requestParams.value}`
},
{
watch: false,
transform: (hits) => {
transform: (
hits: Labrinth.Search.v2.SearchResults | Labrinth.Search.v3.SearchResults,
): Labrinth.Search.v2.SearchResults => {
noLoad.value = false
return hits as Labrinth.Search.v2.SearchResults
if ('hits_per_page' in hits) {
return {
hits: hits.hits as unknown as Labrinth.Search.v2.ResultSearchProject[],
total_hits: hits.total_hits,
limit: hits.hits_per_page,
offset: (hits.page - 1) * hits.hits_per_page,
}
}
return hits
},
},
)
@@ -395,7 +437,7 @@ function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
const params = {
...persistentParams,
...createPageParams(),
...(currentType.value === 'server' ? createServerPageParams() : createPageParams()),
}
router.replace({ path: route.path, query: params })
@@ -406,6 +448,12 @@ watch([currentFilters], () => {
updateSearchResults(1, false)
})
watch([serverCurrentFilters, serverCurrentSortType], () => {
if (currentType.value === 'server') {
updateSearchResults(1, false)
}
})
const throttledSearch = useThrottleFn(() => updateSearchResults(), 500, true)
function cycleSearchDisplayMode() {
@@ -444,6 +492,32 @@ useSeoMeta({
ogTitle,
ogDescription: description,
})
const serverHits = computed(
() =>
((rawResults.value as unknown as Labrinth.Search.v3.SearchResults)
?.hits as Labrinth.Search.v3.ResultSearchProject[]) ?? [],
)
const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) => {
const content = hit.minecraft_java_server?.content
if (content?.kind === 'modpack') {
const { project_name, project_icon, project_id } = content
if (!project_name) return undefined
return {
name: project_name,
icon: project_icon,
onclick:
project_id !== hit.project_id
? () => {
navigateTo(`/project/${project_id}`)
}
: undefined,
showCustomModpackTooltip: project_id === hit.project_id,
}
}
return undefined
}
</script>
<template>
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
@@ -547,41 +621,72 @@ useSeoMeta({
@update:model-value="updateSearchResults()"
/>
</div>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="serverFilters"
:filter-type="filter"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="!(currentType === 'shader' && filter.id === 'game_version')"
>
<template #header>
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
</template>
<template v-if="currentType === 'shader' && filter.id === 'game_version'" #prefix>
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
<InfoIcon class="mt-1 size-4" />
<span> {{ formatMessage(messages.gameVersionShaderMessage) }}</span>
</div>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByServer) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByServer) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
</SearchSidebarFilter>
<template v-if="currentType === 'server'">
<SearchSidebarFilter
v-for="filterType in serverFilterTypes.filter((f) => f.options.length > 0)"
:key="`server-filter-${filterType.id}`"
v-model:selected-filters="serverCurrentFilters"
v-model:toggled-groups="serverToggledGroups"
:provided-filters="serverFilters"
:filter-type="filterType"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="
![
'server_category_minecraft_server_meta',
'server_category_minecraft_server_community',
'server_game_version',
].includes(filterType.id)
"
>
<template #header>
<h3 class="m-0 text-lg">{{ filterType.formatted_name }}</h3>
</template>
</SearchSidebarFilter>
</template>
<template v-else>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="serverFilters"
:filter-type="filter"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="!(currentType === 'shader' && filter.id === 'game_version')"
>
<template #header>
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
</template>
<template v-if="currentType === 'shader' && filter.id === 'game_version'" #prefix>
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
<InfoIcon class="mt-1 size-4" />
<span> {{ formatMessage(messages.gameVersionShaderMessage) }}</span>
</div>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByServer) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByServer) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
</SearchSidebarFilter>
</template>
</div>
</aside>
<section class="normal-page__content">
@@ -601,10 +706,10 @@ useSeoMeta({
<div class="flex flex-wrap items-center gap-2">
<DropdownSelect
v-slot="{ selected }"
v-model="currentSortType"
v-model="effectiveSortType"
class="!w-auto flex-grow md:flex-grow-0"
name="Sort by"
:options="[...sortTypes]"
:options="effectiveSortTypes"
:display-name="(option?: SortType) => option?.display"
@change="updateSearchResults()"
>
@@ -650,6 +755,14 @@ useSeoMeta({
/>
</div>
<SearchFilterControl
v-if="currentType === 'server'"
v-model:selected-filters="serverCurrentFilters"
:filters="serverFilterTypes"
:provided-filters="[]"
:overridden-provided-filter-types="[]"
/>
<SearchFilterControl
v-else
v-model:selected-filters="currentFilters"
:filters="filters.filter((f) => f.display !== 'none')"
:provided-filters="serverFilters"
@@ -657,7 +770,14 @@ useSeoMeta({
:provided-message="messages.providedByServer"
/>
<LogoAnimated v-if="searchLoading && !noLoad" />
<div v-else-if="results && results.hits && results.hits.length === 0" class="no-results">
<div
v-else-if="
currentType === 'server'
? serverHits.length === 0
: results && results.hits && results.hits.length === 0
"
class="no-results"
>
<p>No results found for your query!</p>
</div>
<div v-else class="search-results-container">
@@ -667,90 +787,117 @@ useSeoMeta({
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
>
<ProjectCard
v-for="result in results?.hits"
:key="result.project_id"
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
:title="result.title"
:icon-url="result.icon_url"
:author="{ name: result.author, link: `/user/${result.author}` }"
:date-updated="result.date_modified"
:date-published="result.date_created"
:displayed-date="currentSortType.name === 'newest' ? 'published' : 'updated'"
:downloads="result.downloads"
:summary="result.description"
:tags="result.display_categories"
:all-tags="result.categories"
:deprioritized-tags="deprioritizedTags"
:exclude-loaders="excludeLoaders"
:followers="result.follows"
:banner="result.featured_gallery ?? undefined"
:color="result.color ?? undefined"
:environment="
['mod', 'modpack'].includes(currentType)
? {
clientSide: result.client_side,
serverSide: result.server_side,
}
: undefined
"
:layout="
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
@mouseenter="handleProjectMouseEnter(result)"
@mouseleave="handleProjectHoverEnd"
>
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
<template v-if="flags.showDiscoverProjectButtons">
<ButtonStyled color="brand">
<button>
<DownloadIcon />
Download
</button>
</ButtonStyled>
<ButtonStyled circular>
<button>
<HeartIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
<button>
<BookmarkIcon />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button>
<MoreVerticalIcon />
</button>
</ButtonStyled>
<template v-if="currentType === 'server'">
<ProjectCard
v-for="project in serverHits"
:key="`server-card-${project.project_id}`"
:title="project.name"
:icon-url="project.icon_url || undefined"
:summary="project.summary"
:tags="project.categories"
:link="`/server/${project.slug}`"
:server-online-players="
project.minecraft_java_server?.ping?.data?.players_online ?? 0
"
:server-recent-plays="project.minecraft_java_server?.verified_plays_2w ?? 0"
:server-region-code="project.minecraft_server?.country"
:server-status-online="!!project.minecraft_java_server?.ping?.data"
:server-modpack-content="getServerModpackContent(project)"
:layout="
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
:max-tags="2"
is-server-project
exclude-loaders
>
</ProjectCard>
</template>
<template v-else>
<ProjectCard
v-for="result in results?.hits"
:key="result.project_id"
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
:title="result.title"
:icon-url="result.icon_url"
:author="{ name: result.author, link: `/user/${result.author}` }"
:date-updated="result.date_modified"
:date-published="result.date_created"
:displayed-date="currentSortType.name === 'newest' ? 'published' : 'updated'"
:downloads="result.downloads"
:summary="result.description"
:tags="result.display_categories"
:all-tags="result.categories"
:deprioritized-tags="deprioritizedTags"
:exclude-loaders="excludeLoaders"
:followers="result.follows"
:banner="result.featured_gallery ?? undefined"
:color="result.color ?? undefined"
:environment="
['mod', 'modpack'].includes(currentType)
? {
clientSide: result.client_side as Labrinth.Projects.v2.Environment,
serverSide: result.server_side as Labrinth.Projects.v2.Environment,
}
: undefined
"
:layout="
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
@mouseenter="handleProjectMouseEnter(result)"
@mouseleave="handleProjectHoverEnd"
>
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
<template v-if="flags.showDiscoverProjectButtons">
<ButtonStyled color="brand">
<button>
<DownloadIcon />
Download
</button>
</ButtonStyled>
<ButtonStyled circular>
<button>
<HeartIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
<button>
<BookmarkIcon />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button>
<MoreVerticalIcon />
</button>
</ButtonStyled>
</template>
<template v-else-if="server">
<ButtonStyled color="brand" type="outlined">
<button
v-if="
(result as InstallableSearchResult).installed ||
(server?.content?.data &&
server.content.data.find(
(x: InstallableMod) => x.project_id === result.project_id,
)) ||
server.general?.project?.id === result.project_id
"
disabled
>
<CheckIcon />
Installed
</button>
<button v-else-if="(result as InstallableSearchResult).installing" disabled>
Installing...
</button>
<button v-else @click="serverInstall(result as InstallableSearchResult)">
<DownloadIcon />
Install
</button>
</ButtonStyled>
</template>
</template>
<template v-else-if="server">
<ButtonStyled color="brand" type="outlined">
<button
v-if="
(result as InstallableSearchResult).installed ||
(server?.content?.data &&
server.content.data.find(
(x: InstallableMod) => x.project_id === result.project_id,
)) ||
server.general?.project?.id === result.project_id
"
disabled
>
<CheckIcon />
Installed
</button>
<button v-else-if="(result as InstallableSearchResult).installing" disabled>
Installing...
</button>
<button v-else @click="serverInstall(result as InstallableSearchResult)">
<DownloadIcon />
Install
</button>
</ButtonStyled>
</template>
</template>
</ProjectCard>
</ProjectCard>
</template>
</ProjectCardList>
</div>
<div class="pagination-after">
+6 -3
View File
@@ -221,6 +221,7 @@ const filterTypes: ComboboxOption<string>[] = [
{ value: 'Data Packs', label: 'Data Packs' },
{ value: 'Plugins', label: 'Plugins' },
{ value: 'Shaders', label: 'Shaders' },
{ value: 'Servers', label: 'Servers' },
]
const currentSortType = ref('Oldest')
@@ -282,15 +283,17 @@ const typeFiltered = computed(() => {
'Data Packs': 'datapack',
Plugins: 'plugin',
Shaders: 'shader',
Servers: 'minecraft_java_server',
}
const projectType = filterMap[currentFilterType.value]
if (!projectType) return baseFiltered.value
return baseFiltered.value.filter(
(queueItem) =>
queueItem.project.project_types.length > 0 &&
queueItem.project.project_types[0] === projectType,
(queueItem.project.project_types.length > 0 &&
queueItem.project.project_types[0] === projectType) ||
(projectType === 'minecraft_java_server' &&
queueItem.project.project_types.includes('minecraft_java_server')),
)
})
@@ -218,7 +218,7 @@ const onDeleteOrganization = useClientTry(async () => {
multiline
:maxlength="256"
:disabled="!hasPermission"
wrapper-class="summary-input"
resize="vertical"
/>
</div>
<div class="universal-card">
@@ -245,10 +245,3 @@ const onDeleteOrganization = useClientTry(async () => {
/>
</div>
</template>
<style scoped lang="scss">
.summary-input {
min-height: 8rem;
max-width: 24rem;
}
</style>
@@ -159,7 +159,7 @@
<div class="header__row">
<h2 class="header__title text-2xl">Projects</h2>
<div class="input-group">
<button class="iconified-button brand-button" @click="$refs.modal_creation.show()">
<button class="iconified-button brand-button" @click="$refs.modal_creation.show($event)">
<PlusIcon />
{{ formatMessage(commonMessages.createAProjectButton) }}
</button>
+1
View File
@@ -48,6 +48,7 @@ export default defineNuxtPlugin({
modpack: 'list',
shader: 'gallery',
datapack: 'list',
server: 'list',
user: 'list',
collection: 'list',
},
@@ -0,0 +1,426 @@
import type { Labrinth, UploadProgress } from '@modrinth/api-client'
import { ArrowLeftRightIcon, LeftArrowIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
createContext,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
type MultiStageModal,
type StageConfigInput,
} from '@modrinth/ui'
import JSZip from 'jszip'
import type { Ref, ShallowRef } from 'vue'
import { markRaw, toRaw } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import SelectCompatibilityType from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectCompatibilityType.vue'
import SelectPublishedModpack from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectPublishedModpack.vue'
import SelectVanillaVersions from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectVanillaVersions.vue'
import UploadCustomModpack from '~/components/ui/project-settings/ServerCompatibilityModal/stages/UploadCustomModpack.vue'
export type CompatibilityType = 'vanilla' | 'published-modpack' | 'custom-modpack'
export interface ServerCompatibilityContextValue {
// Stage management
stageConfigs: StageConfigInput<ServerCompatibilityContextValue>[]
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
isSubmitting: Ref<boolean>
isUploading: Ref<boolean>
uploadProgress: Ref<UploadProgress>
// State
compatibilityType: Ref<CompatibilityType | null>
selectedProjectId: Ref<string>
selectedVersionId: Ref<string>
supportedGameVersions: Ref<string[]>
recommendedGameVersion: Ref<string | null>
customModpackFile: Ref<File | null>
hasLicensePermission: Ref<boolean>
isEditingExistingCompatibility: Ref<boolean>
isSwitchingCompatibilityType: Ref<boolean>
// Actions
resetContext: () => void
handleSave: () => Promise<void>
}
export const [injectServerCompatibilityContext, provideServerCompatibilityContext] =
createContext<ServerCompatibilityContextValue>('ServerCompatibilityModal')
export function createServerCompatibilityContext(
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>,
): ServerCompatibilityContextValue {
const { projectV3, patchProjectV3 } = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const isSubmitting = ref(false)
const isUploading = ref(false)
const uploadProgress = ref<UploadProgress>({ loaded: 0, total: 0, progress: 0 })
const compatibilityType = ref<CompatibilityType | null>(null)
const selectedProjectId = ref('')
const selectedVersionId = ref('')
const supportedGameVersions = ref<string[]>([])
const recommendedGameVersion = ref<string | null>(null)
const customModpackFile = ref<File | null>(null)
const hasLicensePermission = ref(false)
const isEditingExistingCompatibility = ref(false)
const isSwitchingCompatibilityType = ref(false)
async function uploadCustomModpackFile(file: File): Promise<Labrinth.Versions.v3.Version> {
const rawFile = toRaw(file)
// Default to filename if we can't parse the mrpack
let versionName = rawFile.name.replace(/\.(zip|mrpack)$/i, '')
let versionNumber = versionName
const loaders: string[] = []
let gameVersions: string[] = []
try {
const zip = await JSZip.loadAsync(rawFile)
const indexFile = zip.file('modrinth.index.json')
if (indexFile) {
const indexContent = await indexFile.async('text')
const metadata = JSON.parse(indexContent) as {
name?: string
versionId?: string
dependencies?: Record<string, string>
}
if (metadata.name) {
versionName = metadata.name
}
if (metadata.versionId) {
versionNumber = metadata.versionId
}
if (metadata.dependencies) {
if ('forge' in metadata.dependencies) loaders.push('forge')
if ('neoforge' in metadata.dependencies) loaders.push('neoforge')
if ('fabric-loader' in metadata.dependencies) loaders.push('fabric')
if ('quilt-loader' in metadata.dependencies) loaders.push('quilt')
if (metadata.dependencies.minecraft) {
gameVersions = [metadata.dependencies.minecraft]
}
}
}
} catch {
console.warn('Could not parse modrinth.index.json from mrpack')
}
const draftVersion: Labrinth.Versions.v3.DraftVersion = {
project_id: projectV3.value.id,
name: versionName,
version_number: versionNumber,
version_type: 'release',
loaders,
game_versions: gameVersions,
featured: false,
status: 'listed',
changelog: '',
dependencies: [],
environment: 'client_and_server',
}
const files: Labrinth.Versions.v3.DraftVersionFile[] = [{ file: rawFile, fileType: undefined }]
const uploadHandle = labrinth.versions_v3.createVersion(draftVersion, files, 'modpack')
uploadHandle.onProgress((progress) => {
uploadProgress.value = progress
})
return await uploadHandle.promise
}
async function handleSave() {
isSubmitting.value = true
setTimeout(() => {
if (compatibilityType.value === 'custom-modpack' && isSubmitting.value === true) {
isUploading.value = true
uploadProgress.value = { loaded: 0, total: 0, progress: 0 }
}
}, 1500)
try {
switch (compatibilityType.value) {
case 'vanilla':
await patchProjectV3({
minecraft_java_server: {
content: {
kind: 'vanilla',
supported_game_versions: supportedGameVersions.value,
recommended_game_version: recommendedGameVersion.value,
},
},
})
break
case 'published-modpack':
await patchProjectV3({
minecraft_java_server: {
content: {
kind: 'modpack',
version_id: selectedVersionId.value,
},
},
})
break
case 'custom-modpack': {
if (!customModpackFile.value) break
// Upload the modpack file as a new version
const uploadedVersion: Labrinth.Versions.v3.Version = await uploadCustomModpackFile(
customModpackFile.value,
)
// Patch the project to point to the newly uploaded version
try {
await patchProjectV3({
minecraft_java_server: {
content: {
kind: 'modpack',
version_id: uploadedVersion.id,
},
},
})
} catch (err) {
// If patch fails, clean up the uploaded version
try {
await labrinth.versions_v3.deleteVersion(uploadedVersion.id)
} catch {
console.error('Failed to clean up uploaded version after patch failure')
}
throw err
}
break
}
}
isUploading.value = false
isSubmitting.value = false
await nextTick()
modal.value?.hide()
} catch (err) {
isUploading.value = false
isSubmitting.value = false
const error = err as { data?: { description?: string } }
addNotification({
title: 'Failed to save server compatibility',
text: error.data?.description || String(err),
type: 'error',
})
}
}
function resetContext() {
compatibilityType.value = null
selectedProjectId.value = ''
selectedVersionId.value = ''
supportedGameVersions.value = []
recommendedGameVersion.value = null
customModpackFile.value = null
hasLicensePermission.value = false
isEditingExistingCompatibility.value = false
isSwitchingCompatibilityType.value = false
}
return {
stageConfigs,
modal,
isSubmitting,
isUploading,
uploadProgress,
compatibilityType,
selectedProjectId,
selectedVersionId,
supportedGameVersions,
recommendedGameVersion,
customModpackFile,
hasLicensePermission,
isEditingExistingCompatibility,
isSwitchingCompatibilityType,
resetContext,
handleSave,
}
}
const selectCompatibilityTypeStage: StageConfigInput<ServerCompatibilityContextValue> = {
id: 'select-compatibility-type',
stageContent: markRaw(SelectCompatibilityType),
title: 'Compatibility type',
cannotNavigateForward: (ctx) => !ctx.compatibilityType.value,
leftButtonConfig: null,
rightButtonConfig: null,
}
const selectVanillaVersionsStage: StageConfigInput<ServerCompatibilityContextValue> = {
id: 'select-vanilla-versions',
stageContent: markRaw(SelectVanillaVersions),
title: 'Vanilla versions',
skip: (ctx) => ctx.compatibilityType.value !== 'vanilla' && !!ctx.compatibilityType.value,
leftButtonConfig: (ctx) =>
ctx.isEditingExistingCompatibility.value
? {
label: 'Cancel',
icon: XIcon,
onClick: () => ctx.modal.value?.hide(),
}
: {
label: 'Back',
icon: LeftArrowIcon,
onClick: () => ctx.modal.value?.prevStage(),
},
rightButtonConfig: (ctx) =>
ctx.isSwitchingCompatibilityType.value
? {
label: 'Change type',
icon: ArrowLeftRightIcon,
iconPosition: 'before' as const,
color: 'red' as const,
disabled:
ctx.isSubmitting.value ||
ctx.supportedGameVersions.value.length === 0 ||
!ctx.recommendedGameVersion.value,
onClick: () => ctx.handleSave(),
}
: {
label: ctx.isSubmitting.value
? ctx.isEditingExistingCompatibility.value
? 'Updating…'
: 'Saving…'
: ctx.isEditingExistingCompatibility.value
? 'Save changes'
: 'Save',
icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon,
iconPosition: 'before' as const,
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
color: 'green' as const,
disabled:
ctx.isSubmitting.value ||
ctx.supportedGameVersions.value.length === 0 ||
!ctx.recommendedGameVersion.value,
onClick: () => ctx.handleSave(),
},
nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value,
}
const selectPublishedModpackStage: StageConfigInput<ServerCompatibilityContextValue> = {
id: 'select-published-modpack',
stageContent: markRaw(SelectPublishedModpack),
title: 'Select modpack',
skip: (ctx) => ctx.compatibilityType.value !== 'published-modpack',
cannotNavigateForward: (ctx) => !ctx.selectedProjectId.value || !ctx.selectedVersionId.value,
leftButtonConfig: (ctx) =>
ctx.isEditingExistingCompatibility.value
? {
label: 'Cancel',
icon: XIcon,
onClick: () => ctx.modal.value?.hide(),
}
: {
label: 'Back',
icon: LeftArrowIcon,
onClick: () => ctx.modal.value?.prevStage(),
},
rightButtonConfig: (ctx) =>
ctx.isSwitchingCompatibilityType.value
? {
label: 'Change type',
icon: ArrowLeftRightIcon,
iconPosition: 'before' as const,
color: 'red' as const,
disabled:
ctx.isSubmitting.value || !ctx.selectedProjectId.value || !ctx.selectedVersionId.value,
onClick: () => ctx.handleSave(),
}
: {
label: ctx.isSubmitting.value
? ctx.isEditingExistingCompatibility.value
? 'Updating…'
: 'Saving…'
: ctx.isEditingExistingCompatibility.value
? 'Save changes'
: 'Save',
icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon,
iconPosition: 'before' as const,
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
color: 'green' as const,
disabled:
ctx.isSubmitting.value || !ctx.selectedProjectId.value || !ctx.selectedVersionId.value,
onClick: () => ctx.handleSave(),
},
nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value,
}
function getUploadLabel(ctx: ServerCompatibilityContextValue): string {
if (ctx.isUploading.value) {
if (ctx.uploadProgress.value.progress >= 1) {
return 'Saving…'
}
return `Uploading ${Math.round(ctx.uploadProgress.value.progress * 100)}%`
}
if (ctx.isSubmitting.value) {
return ctx.isEditingExistingCompatibility.value ? 'Updating…' : 'Saving…'
}
return ctx.isEditingExistingCompatibility.value ? 'Save changes' : 'Save'
}
const uploadCustomModpackStage: StageConfigInput<ServerCompatibilityContextValue> = {
id: 'upload-custom-modpack',
stageContent: markRaw(UploadCustomModpack),
title: 'Custom modpack',
skip: (ctx) => ctx.compatibilityType.value !== 'custom-modpack',
disableClose: (ctx) => ctx.isUploading.value,
leftButtonConfig: (ctx) =>
ctx.isEditingExistingCompatibility.value
? {
label: 'Cancel',
icon: XIcon,
disabled: ctx.isUploading.value,
onClick: () => ctx.modal.value?.hide(),
}
: {
label: 'Back',
icon: LeftArrowIcon,
disabled: ctx.isUploading.value,
onClick: () => ctx.modal.value?.prevStage(),
},
rightButtonConfig: (ctx) =>
ctx.isSwitchingCompatibilityType.value
? {
label: ctx.isUploading.value
? `Uploading ${Math.round(ctx.uploadProgress.value.progress * 100)}%`
: 'Change type',
icon: ctx.isSubmitting.value ? SpinnerIcon : ArrowLeftRightIcon,
iconPosition: 'before' as const,
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
color: 'red' as const,
disabled:
ctx.isSubmitting.value ||
!ctx.customModpackFile.value ||
!ctx.hasLicensePermission.value,
onClick: () => ctx.handleSave(),
buttonClass: 'tabular-nums',
}
: {
label: getUploadLabel(ctx),
icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon,
iconPosition: 'before' as const,
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
color: 'green' as const,
disabled:
ctx.isSubmitting.value ||
!ctx.customModpackFile.value ||
!ctx.hasLicensePermission.value,
onClick: () => ctx.handleSave(),
buttonClass: 'tabular-nums',
},
nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value,
}
const stageConfigs: StageConfigInput<ServerCompatibilityContextValue>[] = [
selectCompatibilityTypeStage,
selectVanillaVersionsStage,
selectPublishedModpackStage,
uploadCustomModpackStage,
]
-2
View File
@@ -1,2 +0,0 @@
User-agent: *
Disallow: /
@@ -41,6 +41,14 @@ const projectTypeMessages = defineMessages({
id: 'project-type.resourcepack.plural',
defaultMessage: 'Resource Packs',
},
server: {
id: 'project-type.server.singular',
defaultMessage: 'Server',
},
servers: {
id: 'project-type.server.plural',
defaultMessage: 'Servers',
},
shader: {
id: 'project-type.shader.singular',
defaultMessage: 'Shader',
+20
View File
@@ -13,6 +13,10 @@ export interface FieldConfig {
pattern?: string
validate?: (value: string) => string | null
autocomplete?: string
dependsOn?: {
field: string
value?: string | string[]
}
}
export interface RailConfig {
@@ -330,6 +334,10 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
defaultMessage: 'Enter PIX email',
}),
autocomplete: 'email',
dependsOn: {
field: 'pixAccountType',
value: 'EMAIL',
},
},
{
name: 'pixPhone',
@@ -341,6 +349,10 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
defaultMessage: '+55...',
}),
autocomplete: 'tel',
dependsOn: {
field: 'pixAccountType',
value: 'PHONE',
},
},
{
name: 'branchCode',
@@ -352,6 +364,10 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
defaultMessage: 'Enter branch code',
}),
autocomplete: 'off',
dependsOn: {
field: 'pixAccountType',
value: 'BANK_ACCOUNT',
},
},
{
name: 'documentNumber',
@@ -367,6 +383,10 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
defaultMessage: 'Brazilian tax identification number',
}),
autocomplete: 'off',
dependsOn: {
field: 'pixAccountType',
value: 'DOCUMENT',
},
},
],
},
+2
View File
@@ -157,3 +157,5 @@ MURALPAY_TRANSFER_API_KEY=none
MURALPAY_SOURCE_ACCOUNT_ID=00000000-0000-0000-0000-000000000000
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
SERVER_PING_TIMEOUT=10000
SERVER_PING_RETRIES=3
+3
View File
@@ -167,3 +167,6 @@ MURALPAY_TRANSFER_API_KEY=none
MURALPAY_SOURCE_ACCOUNT_ID=00000000-0000-0000-0000-000000000000
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
SERVER_PING_TIMEOUT=10000
SERVER_PING_RETRIES=3
SERVER_PING_MIN_INTERVAL_SEC=1800
+2 -114
View File
@@ -4,120 +4,8 @@
# Migrations existing before Prettier formatted them shall always be ignored,
# as any changes to them will break existing deployments
migrations/20200716160921_init.sql
migrations/20200717192808_Make_categories_non-null.sql
migrations/20200722031742_initial-release-channels.sql
migrations/20200722033157_rename-release-channels.sql
migrations/20200722153930_version-filename.sql
migrations/20200730223151_more-not-null.sql
migrations/20200812183213_unique-loaders.sql
migrations/20200928020509_states.sql
migrations/20200928033759_edit-states.sql
migrations/20200928053955_make-url-not-null.sql
migrations/20200928170310_create-users.sql
migrations/20200928195220_add-roles-to-users.sql
migrations/20200929034101_add-author-to-versions.sql
migrations/20201001015631_not-null-github-avatar.sql
migrations/20201003211651_make-name-null.sql
migrations/20201014165954_create-statuses.sql
migrations/20201021214908_extend-game-version.sql
migrations/20201029190804_add-game-version-datetime.sql
migrations/20201109200208_edit-teams.sql
migrations/20201112052516_moderation.sql
migrations/20201122043349_more-mod-data.sql
migrations/20201213013358_remove-member-name.sql
migrations/20210113202021_add-descriptions.sql
migrations/20210118161307_remove-version-access.sql
migrations/20210129224854_dependency-types.sql
migrations/20210201001429_reports.sql
migrations/20210224174945_notifications.sql
migrations/20210301041252_follows.sql
migrations/20210509010206_project_types.sql
migrations/20210611024943_archived-status-notifications-icon-rejection-reasons.sql
migrations/20210718223710_gallery.sql
migrations/20210727160151_gallery_featuring_rejection_rename.sql
migrations/20210805044459_more_gallery_info.sql
migrations/20210820053031_version-optimization.sql
migrations/20220210032959_remove-categories-unique.sql
migrations/20220220035037_remove_downloads_table.sql
migrations/20220329182356_file-sizes.sql
migrations/20220526040434_dep-file-names.sql
migrations/20220725204351_more-project-data.sql
migrations/20220801184215_banned-users.sql
migrations/20220902025606_initial-payouts.sql
migrations/20220928044123_payouts-scheduling.sql
migrations/20221107171016_payouts-overhaul.sql
migrations/20221111163753_fix-precision.sql
migrations/20221111202802_fix-precision-again.sql
migrations/20221116200727_flame-anvil-integration.sql
migrations/20221126222222_spdx-licenses.sql
migrations/20221129161609_status-types-changes.sql
migrations/20221206221021_webhook-sent.sql
migrations/20221217041358_ordering-galore.sql
migrations/20221217215337_drop-body_url-changelog_url.sql
migrations/20221223192812_file-labels.sql
migrations/20221227010515_project-colors.sql
migrations/20230104214503_random-projects.sql
migrations/20230127233123_loader-gv-mod.sql
migrations/20230221212958_queue-date.sql
migrations/20230324202117_messaging.sql
migrations/20230414203933_threads-fix.sql
migrations/20230416033024_deps-project-mandatory.sql
migrations/20230421174120_remove-threads-ref.sql
migrations/20230502141522_minos-support.sql
migrations/20230628180115_kill-ory.sql
migrations/20230710034250_flows.sql
migrations/20230711004131_2fa.sql
migrations/20230714235551_fix-2fa-type.sql
migrations/20230808043323_threads-index.sql
migrations/20230808162652_gv-loader-fixes.sql
migrations/20230816085700_collections_and_more.sql
migrations/20230913024611_organizations.sql
migrations/20231005230721_dynamic-fields.sql
migrations/20231016190056_oauth_provider.sql
migrations/20231027195838_version_ordering.sql
migrations/20231110010322_adds_game_version_minmax.sql
migrations/20231113104902_games_metadata.sql
migrations/20231114175920_new-payment-methods.sql
migrations/20231115105022_plugins_datapacks_v3.sql
migrations/20231116112800_side_types_overhaul.sql
migrations/20231117073600_links_overhaul.sql
migrations/20231122230639_oauth_client_metadata.sql
migrations/20231124070100_renaming_consistency.sql
migrations/20231125080100_drops_mods_dp_plugins.sql
migrations/20231130153100_loader_fields_loaders.sql
migrations/20231205095400_remaining_loader_field_loaders.sql
migrations/20231211184922_collections_description_nullable.sql
migrations/20231213103100_enforces-owner-unique.sql
migrations/20240104203711_orgs-names.sql
migrations/20240115052708_base62-helper-functions.sql
migrations/20240131224610_moderation_packs.sql
migrations/20240221215354_moderation_pack_fixes.sql
migrations/20240319195753_threads-updates.sql
migrations/20240701213559_remove-user-names.sql
migrations/20240702213250_subscriptions.sql
migrations/20240907192840_raw-images.sql
migrations/20240911044738_payouts-updates.sql
migrations/20240923163452_charges-fix.sql
migrations/20241121232522_friends.sql
migrations/20241204190127_revenue_updates.sql
migrations/20241224001114_teams-precision-fix.sql
migrations/20250117013050_missing-primary-keys.sql
migrations/20250519184051_shared-instances.sql
migrations/20250523174544_project-versions-environments.sql
migrations/20250608183828_random-project-index.sql
migrations/20250609134334_spatial-random-project-index.sql
migrations/20250611164523_lowercase-user-email-index.sql
migrations/20250628213541_payout-sources-recording.sql
migrations/20250725230041_reports-closed-status-index.sql
migrations/20250727184120_user-newsletter-subscription-column.sql
migrations/20250804221014_users-redeemals.sql
migrations/20250805001654_product-prices-public.sql
migrations/20250823233518_user-compliance.sql
migrations/20250902133943_notification-extension.sql
migrations/20250914190749_affiliate_codes.sql
migrations/20250927120742_user_limits.sql
migrations/**
# Prettier reformats some of the PostgreSQL-specific COPY syntax here,
# which is very likely to break things
fixtures/labrinth-seed-data-202508052143.sql
fixtures/**
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12\n )\n ",
"query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering,\n components\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12,\n $13\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -16,10 +16,11 @@
"Varchar",
"Bool",
"Varchar",
"Int4"
"Int4",
"Jsonb"
]
},
"nullable": []
},
"hash": "a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce"
"hash": "07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c"
}
@@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id,\n status AS \"status: PayoutStatus\"\n FROM payouts\n ORDER BY id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "status: PayoutStatus",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286"
}

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