mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be0bf7177b | ||
|
|
0a6fccff4e | ||
|
|
7bc47d5e2f | ||
|
|
e11b570968 | ||
|
|
dac4929098 | ||
|
|
e4a2b2866e | ||
|
|
318bd307a2 | ||
|
|
f91c3e21b8 | ||
|
|
d51bdf513f | ||
|
|
433a3abff3 | ||
|
|
d5690e690d | ||
|
|
0df1928e55 | ||
|
|
ba5eef8e21 | ||
|
|
7944c9aa96 | ||
|
|
ab83ab3376 | ||
|
|
90b3287a25 | ||
|
|
51ceb9d851 | ||
|
|
51066c476a | ||
|
|
47ef7ee42e | ||
|
|
6fba33d443 | ||
|
|
017f6a5afb | ||
|
|
1ab722411a | ||
|
|
45387e5fb6 |
@@ -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 (1–2 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
|
||||
@@ -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.
|
||||
@@ -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>`.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -64,7 +64,8 @@ generated
|
||||
app-playground-data/*
|
||||
|
||||
.astro
|
||||
.claude
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
.letta
|
||||
|
||||
# labrinth demo fixtures
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]"
|
||||
>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -139,4 +139,5 @@ type AppSettings = {
|
||||
export type InstanceSettingsTabProps = {
|
||||
instance: GameInstance
|
||||
offline?: boolean
|
||||
isMinecraftServer?: boolean
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+5
-2
@@ -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>
|
||||
+14
@@ -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>
|
||||
+76
@@ -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>
|
||||
+68
@@ -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>
|
||||
+140
@@ -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>
|
||||
+64
@@ -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>
|
||||
+92
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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('/')
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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 couldn’t ping this server. It may be blocked by your host so try refreshing a few
|
||||
times. If it still doesn’t 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"
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/**
|
||||
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
Generated
-26
@@ -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
Reference in New Issue
Block a user