mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b753a52ad | ||
|
|
a53c9c3771 | ||
|
|
919b235621 | ||
|
|
18b0b2848d | ||
|
|
65a3ac4b34 | ||
|
|
295db3fb7a | ||
|
|
7abe6f8c73 | ||
|
|
c5ce5bc9b3 | ||
|
|
572b8027ff | ||
|
|
0a53211389 | ||
|
|
22b2b7746e | ||
|
|
39b3e20d0a | ||
|
|
3892b62451 | ||
|
|
5ffffec2e5 | ||
|
|
a0724cc5fb | ||
|
|
a0320f425e | ||
|
|
06262e7bb1 | ||
|
|
9ccaab5fad | ||
|
|
39745086fa | ||
|
|
a8dcfa8a5d | ||
|
|
0f6a74b87f | ||
|
|
d5b763c8e3 | ||
|
|
7a98f45c3d | ||
|
|
0ea47ad7ef | ||
|
|
cc804625ec | ||
|
|
20235b7d32 | ||
|
|
920dc8dcbb | ||
|
|
30f64c0020 | ||
|
|
cfafa25ed4 | ||
|
|
7495b99ddd | ||
|
|
800271a606 | ||
|
|
36791a33d8 | ||
|
|
20ec70b917 | ||
|
|
ab6edbee48 | ||
|
|
b17523e7fd |
@@ -34,13 +34,13 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract PR Number and Commit ID
|
||||
id: extract-pr-info
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const githubRef = process.env.GITHUB_REF;
|
||||
@@ -90,20 +90,26 @@ runs:
|
||||
- name: Print PR Branch
|
||||
if: env.CAN_SKIP_CHECKS != 'false'
|
||||
shell: bash
|
||||
env:
|
||||
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
|
||||
run: |
|
||||
echo "PR Branch: ${{ steps.get-pr-branch.outputs.prBranch }}"
|
||||
echo "PR Branch: $PR_BRANCH"
|
||||
|
||||
- name: Check if PR branch contains the Merge Queue target commit ID
|
||||
if: env.CAN_SKIP_CHECKS != 'false'
|
||||
shell: bash
|
||||
env:
|
||||
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
|
||||
COMMIT_ID: ${{ steps.extract-pr-info.outputs.commitId }}
|
||||
TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }}
|
||||
run: |
|
||||
# Get the branch name from previous steps
|
||||
branch_name="origin/${{ steps.get-pr-branch.outputs.prBranch }}"
|
||||
commit_id="${{ steps.extract-pr-info.outputs.commitId }}"
|
||||
branch_name="origin/$PR_BRANCH"
|
||||
commit_id="$COMMIT_ID"
|
||||
|
||||
# Check if the branch history contains the commit
|
||||
if git branch -r --contains "$commit_id" | grep -q "$branch_name"; then
|
||||
echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with ${{ steps.extract-pr-info.outputs.targetBranchName }}."
|
||||
if git branch -r --contains "$commit_id" | grep -qF "$branch_name"; then
|
||||
echo "Branch '$branch_name' contains commit '$commit_id'. It is up to date with $TARGET_BRANCH."
|
||||
else
|
||||
echo "Branch '$branch_name' does not contain commit '$commit_id'. It is outdated. Setting CAN_SKIP_CHECKS to false."
|
||||
echo "CAN_SKIP_CHECKS=false" >> "$GITHUB_ENV"
|
||||
@@ -112,8 +118,10 @@ runs:
|
||||
- name: Compare PR Branch with Current Branch
|
||||
if: env.CAN_SKIP_CHECKS != 'false'
|
||||
shell: bash
|
||||
env:
|
||||
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
|
||||
run: |
|
||||
if git diff --quiet "origin/${{ steps.get-pr-branch.outputs.prBranch }}"; then
|
||||
if git diff --quiet "origin/$PR_BRANCH"; then
|
||||
echo "No differences found. PR branch is identical with this merge queue branch."
|
||||
else
|
||||
echo "Differences detected. PR branch has been updated after PR was added to merge queue. Setting CAN_SKIP_CHECKS to false."
|
||||
@@ -122,9 +130,11 @@ runs:
|
||||
|
||||
- name: Compute/publish skip result
|
||||
id: passed-checks
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
env:
|
||||
SECRET: ${{ inputs.secret }}
|
||||
PR_BRANCH: ${{ steps.get-pr-branch.outputs.prBranch }}
|
||||
TARGET_BRANCH: ${{ steps.extract-pr-info.outputs.targetBranchName }}
|
||||
with:
|
||||
github-token: ${{ inputs.secret != '' && inputs.secret || github.token }}
|
||||
script: |
|
||||
@@ -144,7 +154,7 @@ runs:
|
||||
const { data: branchProtection } = await github.rest.repos.getBranchProtection({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
branch: "${{ steps.extract-pr-info.outputs.targetBranchName }}",
|
||||
branch: process.env.TARGET_BRANCH,
|
||||
});
|
||||
const requiredCheckNames = branchProtection.required_status_checks.contexts;
|
||||
console.log(`requiredCheckNames = ${requiredCheckNames}`);
|
||||
@@ -152,7 +162,7 @@ runs:
|
||||
const { data: checks } = await github.rest.checks.listForRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: "refs/heads/${{ steps.get-pr-branch.outputs.prBranch }}",
|
||||
ref: `refs/heads/${process.env.PR_BRANCH}`,
|
||||
});
|
||||
|
||||
console.log(`checks.check_runs = ${checks.check_runs.map(check => `${check.status},${check.conclusion},${check.name};`)}`);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Deploy Command
|
||||
run-name: Deploy PR #${{ github.event.client_payload.github.payload.issue.number }}
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [deploy-command]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: ${{ github.event.client_payload.pull_request.head.repo.full_name == github.repository }}
|
||||
uses: SparkUniverse/workflows/.github/workflows/deploy-command.yaml@main
|
||||
secrets:
|
||||
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
|
||||
CMD_DISPATCH_GH_TOKEN: ${{ secrets.SLASH_CMD_GH_TOKEN }}
|
||||
with:
|
||||
application-set: labrinth
|
||||
build-workflow: labrinth-build.yml
|
||||
branch: ${{ github.event.client_payload.pull_request.head.ref }}
|
||||
issue-number: ${{ github.event.client_payload.github.payload.issue.number }}
|
||||
head-sha: ${{ github.event.client_payload.pull_request.head.sha }}
|
||||
@@ -1,18 +1,19 @@
|
||||
name: docker-build
|
||||
name: Labrinth Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'prod'
|
||||
paths:
|
||||
- .github/workflows/labrinth-docker.yml
|
||||
- .github/workflows/labrinth-build.yml
|
||||
- 'apps/labrinth/**'
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
paths:
|
||||
- .github/workflows/labrinth-docker.yml
|
||||
- .github/workflows/labrinth-build.yml
|
||||
- 'apps/labrinth/**'
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
@@ -69,7 +70,8 @@ jobs:
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
docker:
|
||||
build:
|
||||
name: Build Labrinth
|
||||
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-labrinth' || 'ubuntu-latest' }}
|
||||
needs: [skip-if-clean]
|
||||
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
|
||||
@@ -120,42 +122,30 @@ jobs:
|
||||
cp -r apps/labrinth/migrations apps/labrinth/docker-stage/migrations
|
||||
cp -r apps/labrinth/assets apps/labrinth/docker-stage/assets
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Generate Docker image metadata
|
||||
id: docker-meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
env:
|
||||
# GitHub Packages requires annotations metadata in at least the index descriptor to show them
|
||||
# up properly in its UI it seems, but it's not clear about it, because the docs refer to the
|
||||
# image manifest only. See:
|
||||
# https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images
|
||||
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
|
||||
- name: Upload Docker context
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3
|
||||
with:
|
||||
images: ghcr.io/modrinth/labrinth
|
||||
labels: |
|
||||
org.opencontainers.image.title=labrinth
|
||||
org.opencontainers.image.description=Modrinth API
|
||||
org.opencontainers.image.licenses=AGPL-3.0-only
|
||||
annotations: |
|
||||
org.opencontainers.image.title=labrinth
|
||||
org.opencontainers.image.description=Modrinth API
|
||||
org.opencontainers.image.licenses=AGPL-3.0-only
|
||||
name: labrinth-docker-context
|
||||
retention-days: 1
|
||||
path: apps/labrinth/docker-stage
|
||||
|
||||
- name: Login to GitHub Packages
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
docker-build:
|
||||
needs: [skip-if-clean, build]
|
||||
if: ${{ needs.skip-if-clean.outputs.internal == 'true' }}
|
||||
uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main
|
||||
with:
|
||||
image-name: labrinth
|
||||
dockerfile-path: apps/labrinth/Dockerfile
|
||||
artifacts-name: labrinth-docker-context
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: ./apps/labrinth/docker-stage
|
||||
file: ./apps/labrinth/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.docker-meta.outputs.tags }}
|
||||
labels: ${{ steps.docker-meta.outputs.labels }}
|
||||
annotations: ${{ steps.docker-meta.outputs.annotations }}
|
||||
deploy:
|
||||
needs: [skip-if-clean, docker-build]
|
||||
if: ${{ needs.skip-if-clean.outputs.internal == 'true' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/prod') }}
|
||||
uses: SparkUniverse/workflows/.github/workflows/argo-update.yaml@main
|
||||
secrets:
|
||||
ARGOCD_DEPLOY_KEY: ${{ secrets.ARGOCD_DEPLOY_KEY }}
|
||||
with:
|
||||
application-set: labrinth
|
||||
branch: ${{ github.ref == 'refs/heads/prod' && 'main' || 'develop' }}
|
||||
environment-name: ${{ github.ref == 'refs/heads/prod' && 'production' || 'staging' }}
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Slash Command Dispatch
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
dispatch-command:
|
||||
if: ${{ github.event.sender.type == 'User' && contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association) }}
|
||||
runs-on: namespace-profile-tiny-arm64
|
||||
steps:
|
||||
- name: Slash Command Dispatch
|
||||
uses: peter-evans/slash-command-dispatch@9bdcd7914ec1b75590b790b844aa3b8eee7c683a # v5.0.2
|
||||
with:
|
||||
token: ${{ secrets.SLASH_CMD_GH_TOKEN }}
|
||||
issue-type: pull-request
|
||||
commands: |
|
||||
deploy
|
||||
@@ -74,10 +74,10 @@ jobs:
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
with:
|
||||
rustflags: ''
|
||||
target: ${{ contains(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
|
||||
target: ${{ contains(matrix.artifact-target-name, 'darwin') && 'x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Setup mold
|
||||
if: contains(matrix.platform, 'ubuntu')
|
||||
if: contains(matrix.artifact-target-name, 'linux')
|
||||
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
|
||||
|
||||
- name: Setup sccache
|
||||
@@ -92,7 +92,6 @@ jobs:
|
||||
run: corepack enable
|
||||
|
||||
- name: Set up caches
|
||||
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'macos')
|
||||
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
|
||||
with:
|
||||
cache: |
|
||||
@@ -100,7 +99,6 @@ jobs:
|
||||
pnpm
|
||||
|
||||
- name: Configure sccache
|
||||
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'macos')
|
||||
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate tauri-dev.conf.json
|
||||
@@ -119,7 +117,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
if: contains(matrix.platform, 'ubuntu')
|
||||
if: contains(matrix.artifact-target-name, 'linux')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
@@ -136,7 +134,7 @@ jobs:
|
||||
repo: TomWright/dasel
|
||||
tag: v2.8.1
|
||||
extension-matching: disable
|
||||
rename-to: ${{ contains(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
|
||||
rename-to: ${{ contains(matrix.artifact-target-name, 'windows') && 'dasel.exe' || 'dasel' }}
|
||||
chmod: 0755
|
||||
|
||||
- name: Set application version and environment
|
||||
@@ -159,7 +157,7 @@ jobs:
|
||||
run: pnpm install
|
||||
|
||||
- name: Set up Windows code signing
|
||||
if: contains(matrix.platform, 'windows')
|
||||
if: contains(matrix.artifact-target-name, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
if [ '${{ startsWith(github.ref, 'refs/tags/v') || inputs.sign-windows-binaries }}' = 'true' ]; then
|
||||
@@ -170,7 +168,7 @@ jobs:
|
||||
|
||||
- name: Build macOS app
|
||||
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-dev.conf.json' }}
|
||||
if: contains(matrix.platform, 'macos')
|
||||
if: contains(matrix.artifact-target-name, 'darwin')
|
||||
env:
|
||||
TAURI_BUNDLER_DMG_IGNORE_CI: 'true'
|
||||
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -185,7 +183,7 @@ jobs:
|
||||
|
||||
- name: Build Linux app
|
||||
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json' }}
|
||||
if: contains(matrix.platform, 'ubuntu')
|
||||
if: contains(matrix.artifact-target-name, 'linux')
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
@@ -197,7 +195,7 @@ jobs:
|
||||
$env:JAVA_HOME = "$env:JAVA_HOME_17_X64"
|
||||
${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles "nsis,updater"' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json --verbose --bundles "nsis,updater"' }}
|
||||
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
|
||||
if: contains(matrix.platform, 'windows')
|
||||
if: contains(matrix.artifact-target-name, 'windows')
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
} from '@modrinth/api-client'
|
||||
import {
|
||||
ArrowBigUpDashIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CompassIcon,
|
||||
HomeIcon,
|
||||
LeftArrowIcon,
|
||||
LibraryIcon,
|
||||
LogInIcon,
|
||||
LogOutIcon,
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
provideNotificationManager,
|
||||
providePageContext,
|
||||
providePopupNotificationManager,
|
||||
TextLogo,
|
||||
useDebugLogger,
|
||||
useFormatBytes,
|
||||
useHostingIntercom,
|
||||
@@ -63,7 +65,6 @@ import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
|
||||
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
||||
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import AppActionBar from '@/components/ui/AppActionBar.vue'
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
||||
@@ -129,6 +130,7 @@ import {
|
||||
openAppUpdateChangelog,
|
||||
setAppUpdateActions,
|
||||
} from '@/providers/app-update.ts'
|
||||
import { createBreadcrumbManager, provideBreadcrumbManager } from '@/providers/breadcrumbs'
|
||||
import { createContentInstall, provideContentInstall } from '@/providers/content-install'
|
||||
import {
|
||||
provideAppUpdateDownloadProgress,
|
||||
@@ -151,6 +153,19 @@ import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
|
||||
const themeStore = useTheming()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const breadcrumbManager = createBreadcrumbManager()
|
||||
provideBreadcrumbManager(breadcrumbManager)
|
||||
const canNavigateBack = ref(false)
|
||||
const canNavigateForward = ref(false)
|
||||
|
||||
function updateHistoryNavigationState() {
|
||||
const historyState = window.history.state
|
||||
canNavigateBack.value = historyState?.back != null
|
||||
canNavigateForward.value = historyState?.forward != null
|
||||
}
|
||||
|
||||
updateHistoryNavigationState()
|
||||
|
||||
const APP_LEFT_NAV_WIDTH = '4rem'
|
||||
const APP_SIDEBAR_WIDTH = 300
|
||||
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
|
||||
@@ -655,6 +670,7 @@ router.beforeEach(() => {
|
||||
routerToken = loading.begin()
|
||||
})
|
||||
router.afterEach((to, from, failure) => {
|
||||
updateHistoryNavigationState()
|
||||
trackEvent('PageView', {
|
||||
path: to.path,
|
||||
fromPath: from.path,
|
||||
@@ -1623,23 +1639,37 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
</NavButton>
|
||||
</div>
|
||||
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
|
||||
<div data-tauri-drag-region class="flex min-w-0 flex-1 overflow-hidden p-3">
|
||||
<ModrinthAppLogo class="h-full w-auto shrink-0 text-contrast pointer-events-none" />
|
||||
<div data-tauri-drag-region class="flex shrink-0 items-center gap-1 ml-3">
|
||||
<button
|
||||
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||
@click="router.back()"
|
||||
>
|
||||
<LeftArrowIcon />
|
||||
</button>
|
||||
<button
|
||||
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||
@click="router.forward()"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
<div data-tauri-drag-region class="flex min-w-0 flex-1 items-center overflow-hidden p-2">
|
||||
<TextLogo class="h-7 w-auto shrink-0 text-contrast pointer-events-none" />
|
||||
<div data-tauri-drag-region class="ml-2 flex shrink-0 items-center gap-2">
|
||||
<ButtonStyled type="outlined" circular>
|
||||
<button
|
||||
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
|
||||
:disabled="!canNavigateBack"
|
||||
aria-label="Go back"
|
||||
@click="router.back()"
|
||||
>
|
||||
<ChevronLeftIcon
|
||||
class="!size-4 !text-primary"
|
||||
:class="{ 'opacity-20': !canNavigateBack }"
|
||||
/>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined" circular>
|
||||
<button
|
||||
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
|
||||
:disabled="!canNavigateForward"
|
||||
aria-label="Go forward"
|
||||
@click="router.forward()"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
class="!size-4 !text-primary"
|
||||
:class="{ 'opacity-20': !canNavigateForward }"
|
||||
/>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<Breadcrumbs class="pt-[2px]" />
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
|
||||
<ButtonStyled
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
ref="outerRef"
|
||||
data-tauri-drag-region
|
||||
class="min-w-0 overflow-hidden pl-3"
|
||||
class="min-w-0 overflow-hidden pl-4"
|
||||
:class="{ 'breadcrumb-fade-mask': isOverflowing }"
|
||||
:style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined"
|
||||
@mouseenter="onMouseEnter"
|
||||
@@ -11,30 +11,48 @@
|
||||
<div
|
||||
ref="innerRef"
|
||||
data-tauri-drag-region
|
||||
class="flex w-fit items-center gap-1"
|
||||
class="flex w-fit items-center gap-2 pr-4"
|
||||
:class="{ 'breadcrumbs-scroll': isAnimating }"
|
||||
@animationiteration="onAnimationIteration"
|
||||
>
|
||||
{{ breadcrumbData.resetToNames(breadcrumbs) }}
|
||||
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name">
|
||||
<router-link
|
||||
v-if="breadcrumb.link"
|
||||
:to="{
|
||||
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id as string)),
|
||||
query: breadcrumb.query,
|
||||
}"
|
||||
class="shrink-0 whitespace-nowrap text-primary"
|
||||
<template v-for="(breadcrumb, index) in breadcrumbs" :key="breadcrumb.slot">
|
||||
<component
|
||||
:is="index < breadcrumbs.length - 1 && breadcrumb.to ? RouterLink : 'span'"
|
||||
v-bind="index < breadcrumbs.length - 1 && breadcrumb.to ? { to: breadcrumb.to } : {}"
|
||||
:data-tauri-drag-region="index === breadcrumbs.length - 1 ? '' : undefined"
|
||||
class="flex shrink-0 items-center gap-1.5 whitespace-nowrap text-base font-medium leading-6"
|
||||
:class="
|
||||
index === breadcrumbs.length - 1
|
||||
? 'cursor-default select-none text-contrast'
|
||||
: 'text-primary hover:text-contrast'
|
||||
"
|
||||
:aria-current="index === breadcrumbs.length - 1 ? 'page' : undefined"
|
||||
>
|
||||
{{ resolveLabel(breadcrumb.name) }}
|
||||
</router-link>
|
||||
<span
|
||||
v-else
|
||||
<Avatar
|
||||
v-if="breadcrumb.visual?.type === 'image'"
|
||||
:src="breadcrumb.visual.src"
|
||||
:alt="breadcrumb.visual.alt ?? breadcrumb.label"
|
||||
:circle="breadcrumb.visual.circle"
|
||||
:tint-by="breadcrumb.visual.tintBy ?? breadcrumb.id"
|
||||
size="20px"
|
||||
no-shadow
|
||||
raised
|
||||
class="inline-block shrink-0 align-middle"
|
||||
:class="{ '!rounded-md': !breadcrumb.visual.circle }"
|
||||
/>
|
||||
<component
|
||||
:is="breadcrumb.visual.component"
|
||||
v-else-if="breadcrumb.visual?.type === 'icon'"
|
||||
class="size-5 shrink-0 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{{ breadcrumb.label }}</span>
|
||||
</component>
|
||||
<ChevronRightIcon
|
||||
v-if="index < breadcrumbs.length - 1"
|
||||
data-tauri-drag-region
|
||||
class="shrink-0 whitespace-nowrap text-contrast font-semibold cursor-default select-none"
|
||||
>
|
||||
{{ resolveLabel(breadcrumb.name) }}
|
||||
</span>
|
||||
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5 shrink-0" />
|
||||
class="size-5 shrink-0 text-primary"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,34 +60,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Avatar } from '@modrinth/ui'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { injectBreadcrumbManager } from '@/providers/breadcrumbs'
|
||||
|
||||
interface Breadcrumb {
|
||||
name: string
|
||||
link?: string
|
||||
query?: Record<string, string>
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const breadcrumbData = useBreadcrumbs()
|
||||
|
||||
const breadcrumbs = computed<Breadcrumb[]>(() => {
|
||||
const additionalContext =
|
||||
route.meta.useContext === true
|
||||
? breadcrumbData.context
|
||||
: route.meta.useRootContext === true
|
||||
? breadcrumbData.rootContext
|
||||
: null
|
||||
const crumbs = (route.meta.breadcrumb ?? []) as Breadcrumb[]
|
||||
return additionalContext ? [additionalContext as Breadcrumb, ...crumbs] : crumbs
|
||||
})
|
||||
|
||||
function resolveLabel(name: string): string {
|
||||
return name.charAt(0) === '?' ? breadcrumbData.getName(name.slice(1)) : name
|
||||
}
|
||||
const { entries: breadcrumbs } = injectBreadcrumbManager()
|
||||
|
||||
// Overflow detection
|
||||
const outerRef = ref<HTMLDivElement | null>(null)
|
||||
@@ -83,9 +80,13 @@ let stopping = false
|
||||
|
||||
function checkOverflow() {
|
||||
if (!outerRef.value || !innerRef.value) return
|
||||
const overflow = innerRef.value.scrollWidth - outerRef.value.clientWidth
|
||||
const outerStyles = window.getComputedStyle(outerRef.value)
|
||||
const horizontalPadding =
|
||||
Number.parseFloat(outerStyles.paddingLeft) + Number.parseFloat(outerStyles.paddingRight)
|
||||
const availableWidth = outerRef.value.clientWidth - horizontalPadding
|
||||
const overflow = innerRef.value.scrollWidth - availableWidth
|
||||
isOverflowing.value = overflow > 0
|
||||
overflowAmount.value = overflow + 12
|
||||
overflowAmount.value = overflow
|
||||
}
|
||||
|
||||
function onMouseEnter() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
@@ -14,6 +15,7 @@ const APPROX_USED_VERTICAL_SPACE = 513 // doesn't need to be exact lol just clos
|
||||
const STORAGE_KEY = 'modrinth-quick-instance-count'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -120,6 +122,10 @@ const onDividerPointerUp = (event) => {
|
||||
const getInstances = async () => {
|
||||
const instances = await list().catch(handleError)
|
||||
|
||||
for (const instance of instances) {
|
||||
queryClient.setQueryData(['instances', 'summary', instance.id], instance)
|
||||
}
|
||||
|
||||
allInstances.value = instances.sort((a, b) => {
|
||||
const dateACreated = dayjs(a.created)
|
||||
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
|
||||
|
||||
@@ -13,7 +13,14 @@
|
||||
<Table :columns="inviteColumns" :data="activeInvites" row-key="id" table-min-width="36rem">
|
||||
<template #empty-state>
|
||||
<div class="flex h-40 items-center justify-center text-secondary">
|
||||
{{ formatMessage(messages.noActiveInvites) }}
|
||||
<SpinnerIcon
|
||||
v-if="activeInvitesQuery.isLoading.value"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ formatMessage(messages.noActiveInvites) }}
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-id="{ row }">
|
||||
@@ -41,10 +48,19 @@
|
||||
code: row.id,
|
||||
})
|
||||
"
|
||||
:disabled="revokeInviteMutation.isPending.value || isBusy"
|
||||
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none"
|
||||
@click="revokeInviteModal?.show(row.id)"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
<SpinnerIcon
|
||||
v-if="
|
||||
revokeInviteMutation.isPending.value &&
|
||||
revokeInviteMutation.variables.value?.inviteId === row.id
|
||||
"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<XIcon v-else aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -64,7 +80,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
CopyCode,
|
||||
@@ -75,13 +91,18 @@ import {
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ConfirmRevokeSharedInstanceInviteModal from '@/components/ui/shared-instances/ConfirmRevokeSharedInstanceInviteModal.vue'
|
||||
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
|
||||
import { config } from '@/config'
|
||||
import { type SharedInstanceInvite, unpublish_shared_instance } from '@/helpers/instance'
|
||||
import {
|
||||
get_shared_instance_invites,
|
||||
revoke_shared_instance_invite,
|
||||
type SharedInstanceInvite,
|
||||
unpublish_shared_instance,
|
||||
} from '@/helpers/instance'
|
||||
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
@@ -93,11 +114,51 @@ const unpublishing = ref(false)
|
||||
const revokeInviteModal = ref<InstanceType<typeof ConfirmRevokeSharedInstanceInviteModal>>()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const formatDateTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
|
||||
const isBusy = computed(
|
||||
() => instance.value.install_stage !== 'installed' || unpublishing.value || !!offline,
|
||||
)
|
||||
|
||||
type InviteTableColumn = 'id' | 'uses' | 'expiration' | 'actions'
|
||||
type ActiveInvitesQueryKey = readonly ['sharedInstanceInvites', string]
|
||||
|
||||
const activeInvitesQueryKey = computed(
|
||||
() => ['sharedInstanceInvites', instance.value.id] as const satisfies ActiveInvitesQueryKey,
|
||||
)
|
||||
const activeInvitesQuery = useQuery({
|
||||
queryKey: activeInvitesQueryKey,
|
||||
queryFn: async ({ queryKey }) => {
|
||||
try {
|
||||
return await get_shared_instance_invites(queryKey[1])
|
||||
} catch (error) {
|
||||
notifySharedInstanceError(error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
enabled: () => !!instance.value.id && !offline,
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: 'always',
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
const activeInvites = computed(() => activeInvitesQuery.data.value ?? [])
|
||||
|
||||
const revokeInviteMutation = useMutation({
|
||||
mutationFn: ({ instanceId, inviteId }: { instanceId: string; inviteId: string }) =>
|
||||
revoke_shared_instance_invite(instanceId, inviteId),
|
||||
onSuccess: (_data, { instanceId, inviteId }) => {
|
||||
queryClient.setQueryData<SharedInstanceInvite[]>(
|
||||
['sharedInstanceInvites', instanceId],
|
||||
(invites = []) => invites.filter((invite) => invite.id !== inviteId),
|
||||
)
|
||||
},
|
||||
onError: notifySharedInstanceError,
|
||||
})
|
||||
|
||||
const isBusy = computed(
|
||||
() =>
|
||||
instance.value.install_stage !== 'installed' ||
|
||||
unpublishing.value ||
|
||||
revokeInviteMutation.isPending.value ||
|
||||
!!offline,
|
||||
)
|
||||
|
||||
const inviteColumns = computed<TableColumn<InviteTableColumn>[]>(() => [
|
||||
{
|
||||
@@ -123,32 +184,9 @@ const inviteColumns = computed<TableColumn<InviteTableColumn>[]>(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
// TODO: Use actual endpoint
|
||||
const activeInvites = ref<SharedInstanceInvite[]>([
|
||||
{
|
||||
id: 'wqHPxNagZr',
|
||||
expiration: new Date(now + 6 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
maxUses: 10,
|
||||
uses: 0,
|
||||
},
|
||||
{
|
||||
id: 'GbRGfY7hbs',
|
||||
expiration: new Date(now + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
maxUses: 10,
|
||||
uses: 2,
|
||||
},
|
||||
{
|
||||
id: 'k9mvD2QxLc',
|
||||
expiration: new Date(now + 90 * 60 * 1000).toISOString(),
|
||||
maxUses: 5,
|
||||
uses: 4,
|
||||
},
|
||||
])
|
||||
|
||||
function revokeInvite(inviteId: string) {
|
||||
activeInvites.value = activeInvites.value.filter((invite) => invite.id !== inviteId)
|
||||
if (revokeInviteMutation.isPending.value) return
|
||||
revokeInviteMutation.mutate({ instanceId: instance.value.id, inviteId })
|
||||
}
|
||||
|
||||
async function unpublishSharedInstance() {
|
||||
|
||||
@@ -26,12 +26,13 @@
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex min-w-0 flex-col gap-3 pt-4">
|
||||
<div class="max-h-[292px] overflow-y-auto rounded-[20px]">
|
||||
<div ref="configFileTreeContainer" class="max-h-[292px] overflow-y-auto rounded-[20px]">
|
||||
<FileTreeSelect
|
||||
v-model="selectedConfigPaths"
|
||||
:items="configFileItems"
|
||||
:show-size="false"
|
||||
:show-modified="false"
|
||||
@navigate="scrollConfigFileTreeToTop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,6 +76,7 @@ const emit = defineEmits<{
|
||||
const { formatMessage } = useVIntl()
|
||||
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
|
||||
const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>()
|
||||
const configFileTreeContainer = ref<HTMLElement>()
|
||||
const publishDiffs = ref<ContentDiffItem[]>([])
|
||||
const configFilePaths = ref<string[]>([])
|
||||
const selectedConfigPaths = ref<string[]>([])
|
||||
@@ -132,6 +134,12 @@ async function publishChanges() {
|
||||
}
|
||||
}
|
||||
|
||||
function scrollConfigFileTreeToTop() {
|
||||
if (configFileTreeContainer.value) {
|
||||
configFileTreeContainer.value.scrollTop = 0
|
||||
}
|
||||
}
|
||||
|
||||
function finishReview() {
|
||||
if (state.value === 'reviewing') {
|
||||
setState('idle')
|
||||
|
||||
@@ -251,12 +251,18 @@
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Back to instance"
|
||||
},
|
||||
"app.browse.discover-project-type": {
|
||||
"message": "Discover {projectType}"
|
||||
},
|
||||
"app.browse.discover-servers": {
|
||||
"message": "Discover servers"
|
||||
},
|
||||
"app.browse.hide-added-servers": {
|
||||
"message": "Hide servers already added"
|
||||
},
|
||||
"app.browse.hide-installed-modpacks": {
|
||||
"message": "Hide already installed"
|
||||
},
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modpacks"
|
||||
},
|
||||
@@ -803,6 +809,9 @@
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Back to discover"
|
||||
},
|
||||
"app.project.install-context.back-to-instance": {
|
||||
"message": "Back to instance"
|
||||
},
|
||||
"app.project.version.all-versions": {
|
||||
"message": "All versions"
|
||||
},
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckIcon,
|
||||
ClipboardCopyIcon,
|
||||
CompassIcon,
|
||||
ExternalIcon,
|
||||
GlobeIcon,
|
||||
PlusIcon,
|
||||
ServerStackIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { BrowseInstallContentType, CardAction, ProjectType, Tags } from '@modrinth/ui'
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
commonMessages,
|
||||
CreationFlowModal,
|
||||
defineMessages,
|
||||
formatProjectTypeSentence,
|
||||
getLatestMatchingInstallVersion,
|
||||
getSelectedInstallPreferences,
|
||||
getTargetInstallPreferences,
|
||||
@@ -22,6 +25,7 @@ import {
|
||||
preferencesDiffer,
|
||||
provideBrowseManager,
|
||||
requestInstall,
|
||||
resolveInstallPlan,
|
||||
stripServerRuntimeInstallFilters,
|
||||
stripServerRuntimeInstallOverrides,
|
||||
useBrowseSearch,
|
||||
@@ -31,9 +35,9 @@ import {
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
|
||||
@@ -47,18 +51,23 @@ import { instance_listener } from '@/helpers/events.js'
|
||||
import {
|
||||
get as getInstance,
|
||||
get_installed_project_ids as getInstalledProjectIds,
|
||||
list as listInstances,
|
||||
} from '@/helpers/instance'
|
||||
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { get_instance_worlds } from '@/helpers/worlds'
|
||||
import {
|
||||
type BreadcrumbDefinition,
|
||||
useBreadcrumb,
|
||||
useRootBreadcrumb,
|
||||
} from '@/providers/breadcrumbs'
|
||||
import { injectContentInstall } from '@/providers/content-install'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import {
|
||||
createServerInstallContent,
|
||||
provideServerInstallContent,
|
||||
} from '@/providers/setup/server-install-content'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
@@ -71,6 +80,40 @@ const debugLog = useDebugLogger('Browse')
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const displayedBrowseRoute = shallowRef(router.currentRoute.value)
|
||||
watch(
|
||||
() => router.currentRoute.value,
|
||||
(nextRoute) => {
|
||||
if (nextRoute.path.startsWith('/browse/')) {
|
||||
displayedBrowseRoute.value = nextRoute
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
const breadcrumbMessages = defineMessages({
|
||||
discoverProjectType: {
|
||||
id: 'app.browse.discover-project-type',
|
||||
defaultMessage: 'Discover {projectType}',
|
||||
},
|
||||
discoverServers: {
|
||||
id: 'app.browse.discover-servers',
|
||||
defaultMessage: 'Discover servers',
|
||||
},
|
||||
})
|
||||
const breadcrumbLabel = computed(() => {
|
||||
const browseRoute = displayedBrowseRoute.value
|
||||
if (browseRoute.query.from === 'worlds' || browseRoute.params.projectType === 'server') {
|
||||
return formatMessage(breadcrumbMessages.discoverServers)
|
||||
}
|
||||
|
||||
return formatMessage(breadcrumbMessages.discoverProjectType, {
|
||||
projectType: formatProjectTypeSentence(
|
||||
formatMessage,
|
||||
String(browseRoute.params.projectType ?? ''),
|
||||
2,
|
||||
),
|
||||
})
|
||||
})
|
||||
const themeStore = useTheming()
|
||||
const browseRouteActive = computed(() => route.path.startsWith('/browse/'))
|
||||
const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
|
||||
@@ -111,6 +154,86 @@ const {
|
||||
markServerProjectInstalled,
|
||||
} = serverInstallContent
|
||||
|
||||
type Instance = {
|
||||
game_version: string
|
||||
loader: string
|
||||
path: string
|
||||
install_stage: string
|
||||
icon_path?: string
|
||||
name: string
|
||||
link?: {
|
||||
type: string
|
||||
project_id: string
|
||||
version_id: string
|
||||
}
|
||||
}
|
||||
|
||||
const initialInstanceId = String(route.query.i ?? '')
|
||||
const instance: Ref<Instance | null> = ref(
|
||||
queryClient.getQueryData<Instance>(['instances', 'summary', initialInstanceId]) ?? null,
|
||||
)
|
||||
const installedProjectIds: Ref<string[] | null> = ref(null)
|
||||
const instanceHideInstalled = ref(route.query.ai === 'true')
|
||||
const newlyInstalled = ref<string[]>([])
|
||||
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
|
||||
const hiddenInstanceProjectIdsInitialized = ref(false)
|
||||
const isServerInstance = ref(false)
|
||||
|
||||
const instanceBreadcrumb = route.query.i
|
||||
? useBreadcrumb({
|
||||
slot: 'instance',
|
||||
id: () => `instance:${String(displayedBrowseRoute.value.query.i ?? '')}`,
|
||||
label: () => instance.value?.name ?? formatMessage(commonMessages.loadingLabel),
|
||||
visual: () => ({
|
||||
type: 'image',
|
||||
src: instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
|
||||
alt: instance.value?.name,
|
||||
tintBy: String(displayedBrowseRoute.value.query.i ?? ''),
|
||||
}),
|
||||
to: () => {
|
||||
const instancePath = `/instance/${encodeURIComponent(
|
||||
String(displayedBrowseRoute.value.query.i ?? ''),
|
||||
)}`
|
||||
return displayedBrowseRoute.value.query.from === 'worlds'
|
||||
? `${instancePath}/worlds`
|
||||
: instancePath
|
||||
},
|
||||
})
|
||||
: undefined
|
||||
const serverBreadcrumbTo = ref(serverBackUrl.value)
|
||||
watch(serverBackUrl, (value) => {
|
||||
if (route.path.startsWith('/browse/')) {
|
||||
serverBreadcrumbTo.value = value
|
||||
}
|
||||
})
|
||||
const serverBreadcrumb =
|
||||
!instanceBreadcrumb && serverIdQuery.value
|
||||
? useBreadcrumb({
|
||||
slot: 'server',
|
||||
id: () => `server:${String(displayedBrowseRoute.value.query.sid ?? '')}`,
|
||||
label: () =>
|
||||
serverContextServerData.value?.name ?? formatMessage(commonMessages.loadingLabel),
|
||||
visual: { type: 'icon', component: ServerStackIcon },
|
||||
to: serverBreadcrumbTo,
|
||||
})
|
||||
: undefined
|
||||
const breadcrumbParent = instanceBreadcrumb ?? serverBreadcrumb
|
||||
const breadcrumbDefinition = {
|
||||
slot: 'browse',
|
||||
id: () =>
|
||||
`browse:${String(displayedBrowseRoute.value.params.projectType ?? '')}:${String(
|
||||
displayedBrowseRoute.value.query.i ?? '',
|
||||
)}:${String(displayedBrowseRoute.value.query.sid ?? '')}:${String(
|
||||
displayedBrowseRoute.value.query.from ?? '',
|
||||
)}`,
|
||||
label: breadcrumbLabel,
|
||||
to: () => displayedBrowseRoute.value.fullPath,
|
||||
visual: { type: 'icon', component: CompassIcon },
|
||||
} satisfies BreadcrumbDefinition
|
||||
const browseBreadcrumb = breadcrumbParent
|
||||
? useBreadcrumb(breadcrumbDefinition, { parent: breadcrumbParent })
|
||||
: useRootBreadcrumb(breadcrumbDefinition)
|
||||
|
||||
debugLog('fetching tags (categories, loaders, gameVersions)')
|
||||
const [categories, loaders, availableGameVersions] = await Promise.all([
|
||||
get_categories()
|
||||
@@ -130,28 +253,6 @@ const tags: Ref<Tags> = computed(() => ({
|
||||
categories: categories.value ?? [],
|
||||
}))
|
||||
|
||||
type Instance = {
|
||||
game_version: string
|
||||
loader: string
|
||||
path: string
|
||||
install_stage: string
|
||||
icon_path?: string
|
||||
name: string
|
||||
link?: {
|
||||
type: string
|
||||
project_id: string
|
||||
version_id: string
|
||||
}
|
||||
}
|
||||
|
||||
const instance: Ref<Instance | null> = ref(null)
|
||||
const installedProjectIds: Ref<string[] | null> = ref(null)
|
||||
const instanceHideInstalled = ref(false)
|
||||
const newlyInstalled = ref<string[]>([])
|
||||
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
|
||||
const hiddenInstanceProjectIdsInitialized = ref(false)
|
||||
const isServerInstance = ref(false)
|
||||
|
||||
if (isFromWorlds.value && route.params.projectType !== 'server') {
|
||||
router.replace({
|
||||
path: '/browse/server',
|
||||
@@ -189,7 +290,17 @@ watchServerContextChanges()
|
||||
await initInstanceContext()
|
||||
|
||||
async function refreshInstalledProjectIds() {
|
||||
if (!route.query.i) return
|
||||
if (!route.query.i) {
|
||||
const instances = await listInstances().catch(handleError)
|
||||
if (!instances) return
|
||||
|
||||
const ids = instances
|
||||
.map((gameInstance) => gameInstance.link?.project_id)
|
||||
.filter((id): id is string => !!id)
|
||||
debugLog('installedInstanceProjectIds loaded', { count: ids.length })
|
||||
installedProjectIds.value = ids
|
||||
return
|
||||
}
|
||||
|
||||
if (route.query.from === 'worlds') {
|
||||
const worlds = await get_instance_worlds(route.query.i as string).catch(handleError)
|
||||
@@ -219,6 +330,7 @@ async function initInstanceContext() {
|
||||
queryFrom: route.query.from,
|
||||
})
|
||||
await initServerContext()
|
||||
await refreshInstalledProjectIds()
|
||||
|
||||
if (route.query.i) {
|
||||
instance.value = (await getInstance(route.query.i as string).catch(handleError)) ?? null
|
||||
@@ -228,8 +340,6 @@ async function initInstanceContext() {
|
||||
gameVersion: instance.value?.game_version,
|
||||
})
|
||||
|
||||
await refreshInstalledProjectIds()
|
||||
|
||||
if (instance.value?.link?.project_id) {
|
||||
debugLog('checking linked project for server status', instance.value.link.project_id)
|
||||
const projectV3 = await get_project_v3(
|
||||
@@ -242,13 +352,23 @@ async function initInstanceContext() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (route.query.ai && !(route.params.projectType === 'modpack')) {
|
||||
debugLog('setting instanceHideInstalled from query', route.query.ai)
|
||||
instanceHideInstalled.value = route.query.ai === 'true'
|
||||
}
|
||||
}
|
||||
|
||||
function setBrowseHideInstalledFlag(flag: 'hide_installed_modpacks', value: boolean) {
|
||||
themeStore.featureFlags[flag] = value
|
||||
getSettings()
|
||||
.then((settings) => {
|
||||
settings.feature_flags[flag] = value
|
||||
return setSettings(settings)
|
||||
})
|
||||
.catch(handleError)
|
||||
}
|
||||
|
||||
const hideInstalledModpacks = computed({
|
||||
get: () => themeStore.getFeatureFlag('hide_installed_modpacks'),
|
||||
set: (value: boolean) => setBrowseHideInstalledFlag('hide_installed_modpacks', value),
|
||||
})
|
||||
|
||||
const instanceFilters = computed(() => {
|
||||
const filters = []
|
||||
|
||||
@@ -268,11 +388,15 @@ const instanceFilters = computed(() => {
|
||||
if (isServerInstance.value) {
|
||||
filters.push({ type: 'environment', option: 'client' })
|
||||
}
|
||||
}
|
||||
|
||||
if (instanceHideInstalled.value && hiddenInstanceProjectIds.value.size > 0) {
|
||||
for (const id of hiddenInstanceProjectIds.value) {
|
||||
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
|
||||
}
|
||||
if (
|
||||
(instance.value || projectType.value === 'modpack') &&
|
||||
(projectType.value === 'modpack' ? hideInstalledModpacks.value : instanceHideInstalled.value) &&
|
||||
hiddenInstanceProjectIds.value.size > 0
|
||||
) {
|
||||
for (const id of hiddenInstanceProjectIds.value) {
|
||||
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +455,12 @@ const serverContextFilters = computed(() => {
|
||||
{ type: 'environment', option: 'client' },
|
||||
{ type: 'environment', option: 'server' },
|
||||
)
|
||||
|
||||
if (hideInstalledModpacks.value && hiddenInstanceProjectIds.value.size > 0) {
|
||||
for (const id of hiddenInstanceProjectIds.value) {
|
||||
filters.push({ type: 'project_id', option: `project_id:${id}`, negative: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (serverHideInstalled.value && hiddenServerContentProjectIds.value.size > 0) {
|
||||
@@ -385,10 +515,6 @@ const messages = defineMessages({
|
||||
id: 'app.browse.add-to-an-instance',
|
||||
defaultMessage: 'Add to an instance',
|
||||
},
|
||||
discoverServers: {
|
||||
id: 'app.browse.discover-servers',
|
||||
defaultMessage: 'Discover servers',
|
||||
},
|
||||
environmentProvidedByServer: {
|
||||
id: 'search.filter.locked.server-environment.title',
|
||||
defaultMessage: 'Only client-side mods can be added to the server instance',
|
||||
@@ -405,6 +531,10 @@ const messages = defineMessages({
|
||||
id: 'app.browse.hide-added-servers',
|
||||
defaultMessage: 'Hide servers already added',
|
||||
},
|
||||
hideInstalledModpacks: {
|
||||
id: 'app.browse.hide-installed-modpacks',
|
||||
defaultMessage: 'Hide already installed',
|
||||
},
|
||||
installingToServer: {
|
||||
id: 'app.browse.server.installing',
|
||||
defaultMessage: 'Installing',
|
||||
@@ -444,31 +574,6 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const browseTitle = computed(() =>
|
||||
formatMessage(
|
||||
isFromWorlds.value ? messages.discoverServers : commonMessages.discoverContentLabel,
|
||||
),
|
||||
)
|
||||
breadcrumbs.setName('BrowseTitle', browseTitle.value)
|
||||
if (instance.value) {
|
||||
const instanceLink = `/instance/${encodeURIComponent(instance.value.id)}`
|
||||
breadcrumbs.setContext({
|
||||
name: instance.value.name,
|
||||
link: isFromWorlds.value ? `${instanceLink}/worlds` : instanceLink,
|
||||
})
|
||||
} else {
|
||||
breadcrumbs.setContext(null)
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
breadcrumbs.setContext({
|
||||
name: browseTitle.value,
|
||||
link: `/browse/${projectType.value}`,
|
||||
query: route.query,
|
||||
})
|
||||
})
|
||||
|
||||
const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
|
||||
|
||||
function resetInstanceContext() {
|
||||
@@ -482,8 +587,8 @@ function resetInstanceContext() {
|
||||
hiddenInstanceProjectIds.value = new Set()
|
||||
hiddenInstanceProjectIdsInitialized.value = false
|
||||
isServerInstance.value = false
|
||||
breadcrumbs.setName('BrowseTitle', formatMessage(commonMessages.discoverContentLabel))
|
||||
breadcrumbs.setContext(null)
|
||||
browseBreadcrumb.reset()
|
||||
void refreshInstalledProjectIds()
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -694,6 +799,27 @@ async function chooseInstanceInstallVersion(
|
||||
return { versionId: selectedVersion.id }
|
||||
}
|
||||
|
||||
async function chooseFilterMatchingInstallVersion(
|
||||
project: Labrinth.Search.v3.ResultSearchProject,
|
||||
projectTypeValue: string,
|
||||
) {
|
||||
const plan = await resolveInstallPlan({
|
||||
project: {
|
||||
project_id: project.project_id,
|
||||
title: project.title,
|
||||
icon_url: project.icon_url,
|
||||
},
|
||||
contentType: projectTypeValue as BrowseInstallContentType,
|
||||
selectedFilters: searchState.currentFilters.value,
|
||||
providedFilters: combinedProvidedFilters.value,
|
||||
overriddenProvidedFilterTypes: searchState.overriddenProvidedFilterTypes.value,
|
||||
targetPreferences: {},
|
||||
getProjectVersions: getInstallProjectVersions,
|
||||
})
|
||||
|
||||
return { versionId: plan.versionId }
|
||||
}
|
||||
|
||||
function getCardActions(
|
||||
result: Labrinth.Search.v3.ResultSearchProject,
|
||||
currentProjectType: string,
|
||||
@@ -712,6 +838,7 @@ function getCardActions(
|
||||
serverContentProjectIds.value.has(projectResult.project_id || '') ||
|
||||
serverContextServerData.value?.upstream?.project_id === projectResult.project_id
|
||||
const isInstalling = installingProjectIds.value.has(projectResult.project_id)
|
||||
const showAsInstalled = isInstalled && currentProjectType !== 'modpack'
|
||||
|
||||
if (
|
||||
isServerContext.value &&
|
||||
@@ -721,7 +848,7 @@ function getCardActions(
|
||||
const isInstallingSelection = isInstallingQueuedServerInstalls.value
|
||||
const validatingInstall =
|
||||
isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection
|
||||
const installLabel = isInstalled
|
||||
const installLabel = showAsInstalled
|
||||
? commonMessages.installedLabel
|
||||
: isQueued
|
||||
? isInstalling || isInstallingSelection
|
||||
@@ -741,11 +868,11 @@ function getCardActions(
|
||||
icon:
|
||||
isInstalling || isInstallingSelection
|
||||
? SpinnerIcon
|
||||
: isQueued || isInstalled
|
||||
: isQueued || showAsInstalled
|
||||
? CheckIcon
|
||||
: PlusIcon,
|
||||
iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
|
||||
disabled: isInstalled || isInstalling || isInstallingSelection,
|
||||
disabled: showAsInstalled || isInstalling || isInstallingSelection,
|
||||
color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
|
||||
type: 'outlined',
|
||||
onClick: async () => {
|
||||
@@ -806,15 +933,15 @@ function getCardActions(
|
||||
label: formatMessage(
|
||||
isInstalling
|
||||
? messages.installingToServer
|
||||
: isInstalled
|
||||
: showAsInstalled
|
||||
? commonMessages.installedLabel
|
||||
: shouldUseInstallIcon
|
||||
? commonMessages.installButton
|
||||
: messages.addToAnInstance,
|
||||
),
|
||||
icon: isInstalling ? SpinnerIcon : isInstalled ? CheckIcon : PlusIcon,
|
||||
icon: isInstalling ? SpinnerIcon : showAsInstalled ? CheckIcon : PlusIcon,
|
||||
iconClass: isInstalling ? 'animate-spin' : undefined,
|
||||
disabled: isInstalled || isInstalling,
|
||||
disabled: showAsInstalled || isInstalling,
|
||||
color: 'brand',
|
||||
type: 'outlined',
|
||||
onClick: async () => {
|
||||
@@ -822,7 +949,9 @@ function getCardActions(
|
||||
try {
|
||||
const selectedInstall = instance.value
|
||||
? await chooseInstanceInstallVersion(projectResult, currentProjectType)
|
||||
: { versionId: null as string | null }
|
||||
: isModpack
|
||||
? await chooseFilterMatchingInstallVersion(projectResult, currentProjectType)
|
||||
: { versionId: null as string | null }
|
||||
if (selectedInstall === null) {
|
||||
setProjectInstalling(projectResult.project_id, false)
|
||||
return
|
||||
@@ -901,6 +1030,14 @@ async function search(requestParams: string) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const hit of rawResults.result.hits) {
|
||||
for (const identifier of [hit.project_id, hit.slug]) {
|
||||
if (identifier) {
|
||||
queryClient.setQueryData(['projects', 'summary', identifier], hit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isServer) {
|
||||
const hits = rawResults.result.hits ?? []
|
||||
updateServerHits(hits)
|
||||
@@ -917,10 +1054,11 @@ async function search(requestParams: string) {
|
||||
...hit,
|
||||
}
|
||||
|
||||
if (instance.value || isServerContext.value) {
|
||||
const installedIds = instance.value
|
||||
? new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])])
|
||||
: serverContentProjectIds.value
|
||||
if (instance.value || isServerContext.value || projectType.value === 'modpack') {
|
||||
const installedIds =
|
||||
isServerContext.value && projectType.value !== 'modpack'
|
||||
? serverContentProjectIds.value
|
||||
: new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])])
|
||||
mapped.installed = installedIds.has(hit.project_id)
|
||||
}
|
||||
|
||||
@@ -980,9 +1118,9 @@ watch(
|
||||
() => projectType.value,
|
||||
],
|
||||
() => {
|
||||
if (isServerContext.value) {
|
||||
if (isServerContext.value && projectType.value !== 'modpack') {
|
||||
syncHiddenServerContentProjectIds()
|
||||
} else if (instance.value) {
|
||||
} else if (instance.value || projectType.value === 'modpack') {
|
||||
syncHiddenInstanceProjectIds()
|
||||
}
|
||||
},
|
||||
@@ -1014,6 +1152,18 @@ let unlistenInstances: UnlistenFn | null = null
|
||||
|
||||
onMounted(() => {
|
||||
instance_listener(async (event: { event: string; instance_id: string }) => {
|
||||
if (event.event === 'added' || event.event === 'created' || event.event === 'removed') {
|
||||
if (!route.query.i) {
|
||||
await refreshInstalledProjectIds()
|
||||
if (projectType.value === 'modpack') {
|
||||
if (event.event === 'removed') {
|
||||
syncHiddenInstanceProjectIds()
|
||||
}
|
||||
await searchState.refreshSearch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (instance.value && event.instance_id === instance.value.id && event.event === 'synced') {
|
||||
await refreshInstalledProjectIds()
|
||||
await searchState.refreshSearch()
|
||||
@@ -1079,8 +1229,17 @@ provideBrowseManager({
|
||||
installContext,
|
||||
providedFilters: combinedProvidedFilters,
|
||||
hideInstalled: computed({
|
||||
get: () => (isServerContext.value ? serverHideInstalled.value : instanceHideInstalled.value),
|
||||
get: () => {
|
||||
if (projectType.value === 'modpack') return hideInstalledModpacks.value
|
||||
if (isServerContext.value) return serverHideInstalled.value
|
||||
return instanceHideInstalled.value
|
||||
},
|
||||
set: (val: boolean) => {
|
||||
if (projectType.value === 'modpack') {
|
||||
hideInstalledModpacks.value = val
|
||||
if (val) syncHiddenInstanceProjectIds()
|
||||
return
|
||||
}
|
||||
if (isServerContext.value) {
|
||||
serverHideInstalled.value = val
|
||||
if (val) syncHiddenServerContentProjectIds()
|
||||
@@ -1091,11 +1250,18 @@ provideBrowseManager({
|
||||
},
|
||||
}),
|
||||
showHideInstalled: computed(
|
||||
() => (isServerContext.value && projectType.value !== 'modpack') || !!instance.value,
|
||||
() =>
|
||||
projectType.value === 'modpack' ||
|
||||
(isServerContext.value && projectType.value !== 'modpack') ||
|
||||
!!instance.value,
|
||||
),
|
||||
hideInstalledLabel: computed(() =>
|
||||
formatMessage(
|
||||
isFromWorlds.value ? messages.hideAddedServers : commonMessages.hideInstalledContentLabel,
|
||||
isFromWorlds.value
|
||||
? messages.hideAddedServers
|
||||
: projectType.value === 'modpack'
|
||||
? messages.hideInstalledModpacks
|
||||
: commonMessages.hideInstalledContentLabel,
|
||||
),
|
||||
),
|
||||
hideSelected: hideSelectedServerInstalls,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { HomeIcon } from '@modrinth/assets'
|
||||
import { injectNotificationManager } from '@modrinth/ui'
|
||||
import type { SearchResult } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import RowDisplay from '@/components/RowDisplay.vue'
|
||||
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
|
||||
@@ -11,13 +11,17 @@ import { get_search_results } from '@/helpers/cache.js'
|
||||
import { instance_listener } from '@/helpers/events'
|
||||
import { list } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const route = useRoute()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
|
||||
breadcrumbs.setRootContext({ name: 'Home', link: route.path })
|
||||
useRootBreadcrumb({
|
||||
slot: 'root',
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
to: '/',
|
||||
visual: { type: 'icon', component: HomeIcon },
|
||||
})
|
||||
|
||||
const instances = ref<GameInstance[]>([])
|
||||
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ServerStackIcon } from '@modrinth/assets'
|
||||
import { injectModrinthClient, ServersManagePageIndex } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
|
||||
|
||||
import { config } from '../config'
|
||||
|
||||
const stripePublishableKey = (config.stripePublishableKey as string) || ''
|
||||
|
||||
const client = injectModrinthClient()
|
||||
|
||||
useRootBreadcrumb({
|
||||
slot: 'root',
|
||||
id: 'servers',
|
||||
label: 'Servers',
|
||||
to: '/hosting/manage/',
|
||||
visual: { type: 'icon', component: ServerStackIcon },
|
||||
})
|
||||
|
||||
const { data: products } = useQuery({
|
||||
queryKey: ['billing', 'products'],
|
||||
queryFn: () => client.labrinth.billing_internal.getProducts(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
EyeIcon,
|
||||
LogInIcon,
|
||||
RotateCounterClockwiseIcon,
|
||||
ShirtIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
@@ -56,10 +57,19 @@ import {
|
||||
set_custom_skin_order,
|
||||
} from '@/helpers/skins.ts'
|
||||
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
|
||||
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
import { useTheming } from '@/store/state'
|
||||
import { appMessages } from '@/utils/app-messages'
|
||||
|
||||
useRootBreadcrumb({
|
||||
slot: 'root',
|
||||
id: 'skins',
|
||||
label: 'Skin selector',
|
||||
to: '/skins',
|
||||
visual: { type: 'icon', component: ShirtIcon },
|
||||
})
|
||||
|
||||
type UnlistenFn = () => void
|
||||
type VirtualSkinSectionListExpose = {
|
||||
getAddSkinButtonElement: () => HTMLElement | null | undefined
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { provideUserProfile, UserProfilePageLayout } from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, inject, watch } from 'vue'
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { onBeforeRouteUpdate, useRoute } from 'vue-router'
|
||||
|
||||
import {
|
||||
@@ -29,12 +30,11 @@ import {
|
||||
unblock_user,
|
||||
} from '@/helpers/users'
|
||||
import { appSettingsModalOpenProfileKey } from '@/providers/app-settings-modal'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { useBreadcrumb } from '@/providers/breadcrumbs'
|
||||
|
||||
const route = useRoute()
|
||||
const openProfileSettings = inject(appSettingsModalOpenProfileKey, () => {})
|
||||
const queryClient = useQueryClient()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const userProfile = provideUserProfile({
|
||||
getUser: get_user_profile,
|
||||
getProjects: get_user_projects,
|
||||
@@ -55,17 +55,54 @@ const projectType = computed(() => {
|
||||
return Array.isArray(value) ? value[0] : value
|
||||
})
|
||||
|
||||
function getCachedUserSummary(id: string) {
|
||||
return queryClient.getQueryData<Labrinth.Users.v3.User>(['users', 'summary', id])
|
||||
}
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: computed(() => ['user', userId.value]),
|
||||
queryFn: () => userProfile.getUser(userId.value),
|
||||
enabled: false,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const breadcrumbUserId = ref(userId.value)
|
||||
const breadcrumbLabel = ref(getCachedUserSummary(userId.value)?.username ?? userId.value)
|
||||
const breadcrumbTo = ref(route.fullPath)
|
||||
watch(
|
||||
[userId, user, () => route.fullPath],
|
||||
([currentUserId, currentUser, currentPath]) => {
|
||||
if (route.name !== 'User') return
|
||||
breadcrumbUserId.value = currentUserId
|
||||
breadcrumbLabel.value = currentUser?.username ?? currentUserId
|
||||
breadcrumbTo.value = currentPath
|
||||
},
|
||||
{ immediate: true, flush: 'sync' },
|
||||
)
|
||||
|
||||
useBreadcrumb({
|
||||
slot: 'user',
|
||||
id: () => `user:${breadcrumbUserId.value}`,
|
||||
label: breadcrumbLabel,
|
||||
to: breadcrumbTo,
|
||||
visual: () => ({
|
||||
type: 'image',
|
||||
src: user.value?.avatar_url ?? getCachedUserSummary(breadcrumbUserId.value)?.avatar_url,
|
||||
alt: breadcrumbLabel.value,
|
||||
circle: true,
|
||||
tintBy: breadcrumbUserId.value,
|
||||
}),
|
||||
})
|
||||
|
||||
async function ensureUserProfileData(id: string): Promise<void> {
|
||||
if (!id) return
|
||||
|
||||
let breadcrumbName = id
|
||||
try {
|
||||
const user = await queryClient.ensureQueryData({
|
||||
await queryClient.ensureQueryData({
|
||||
queryKey: ['user', id],
|
||||
queryFn: () => userProfile.getUser(id),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
breadcrumbName = user.username
|
||||
} catch {
|
||||
// Let the mounted layout's useQuery surface errors; do not fail route setup.
|
||||
}
|
||||
@@ -87,8 +124,6 @@ async function ensureUserProfileData(id: string): Promise<void> {
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
])
|
||||
|
||||
breadcrumbs.setName('User', breadcrumbName)
|
||||
}
|
||||
|
||||
onBeforeRouteUpdate(async (to) => {
|
||||
@@ -97,21 +132,5 @@ onBeforeRouteUpdate(async (to) => {
|
||||
await ensureUserProfileData(id)
|
||||
})
|
||||
|
||||
breadcrumbs.setName('User', userId.value)
|
||||
await ensureUserProfileData(userId.value)
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: computed(() => ['user', userId.value]),
|
||||
queryFn: () => userProfile.getUser(userId.value),
|
||||
enabled: false,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
watch(
|
||||
[userId, user],
|
||||
([currentUserId, value]) => {
|
||||
breadcrumbs.setName('User', value?.username ?? currentUserId)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -48,15 +48,22 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import { injectAuth, injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
|
||||
import { ServerStackIcon } from '@modrinth/assets'
|
||||
import {
|
||||
commonMessages,
|
||||
injectAuth,
|
||||
injectModrinthClient,
|
||||
ServersManageRootLayout,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { get_user } from '@/helpers/cache'
|
||||
import { get as getCreds } from '@/helpers/mr_auth'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { provideBreadcrumbParent, useBreadcrumb } from '@/providers/breadcrumbs'
|
||||
import { useTheming } from '@/store/theme'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -65,47 +72,63 @@ const auth = injectAuth()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const themeStore = useTheming()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const isContainedServerRoute = computed(() => route.name === 'ServerManageOverview')
|
||||
|
||||
const serverId = computed(() => {
|
||||
const rawId = route.params.id
|
||||
return Array.isArray(rawId) ? rawId[0] : (rawId ?? '')
|
||||
return Array.isArray(rawId) ? (rawId[0] ?? '') : (rawId ?? '')
|
||||
})
|
||||
|
||||
if (serverId.value) {
|
||||
try {
|
||||
await queryClient.ensureQueryData({
|
||||
queryKey: ['servers', 'detail', serverId.value],
|
||||
queryFn: () => client.archon.servers_v0.get(serverId.value)!,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
} catch {
|
||||
// Let mounted layouts' useQuery surface errors; do not fail route setup.
|
||||
}
|
||||
function getCachedServerName(id: string): string | undefined {
|
||||
return queryClient
|
||||
.getQueryData<Archon.Servers.v0.ServerGetResponse>(['servers'])
|
||||
?.servers.find((server) => server.server_id === id)?.name
|
||||
}
|
||||
|
||||
const { data: serverData } = useQuery({
|
||||
queryKey: computed(() => ['servers', 'detail', serverId.value]),
|
||||
queryFn: () => null as unknown as Archon.Servers.v0.Server,
|
||||
enabled: false,
|
||||
queryFn: () => client.archon.servers_v0.get(serverId.value),
|
||||
enabled: computed(() => Boolean(serverId.value)),
|
||||
placeholderData: () =>
|
||||
queryClient
|
||||
.getQueryData<Archon.Servers.v0.ServerGetResponse>(['servers'])
|
||||
?.servers.find((server) => server.server_id === serverId.value),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const breadcrumbServerId = ref(serverId.value)
|
||||
const breadcrumbLabel = ref(
|
||||
getCachedServerName(serverId.value) ?? formatMessage(commonMessages.loadingLabel),
|
||||
)
|
||||
watch(
|
||||
serverId,
|
||||
(value) => {
|
||||
if (!route.path.startsWith('/hosting/manage/') || route.name === 'Servers') return
|
||||
breadcrumbServerId.value = value
|
||||
breadcrumbLabel.value = getCachedServerName(value) ?? formatMessage(commonMessages.loadingLabel)
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
watch(
|
||||
serverData,
|
||||
(server) => {
|
||||
if (server?.name) {
|
||||
breadcrumbs.setName('Server', server.name)
|
||||
breadcrumbs.setContext({
|
||||
name: server.name,
|
||||
link: `/hosting/manage/${serverId.value}/content`,
|
||||
})
|
||||
}
|
||||
if (!route.path.startsWith('/hosting/manage/') || !server?.name) return
|
||||
breadcrumbLabel.value = server.name
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const serverBreadcrumb = useBreadcrumb({
|
||||
slot: 'server',
|
||||
id: () => `server:${breadcrumbServerId.value}`,
|
||||
label: breadcrumbLabel,
|
||||
visual: { type: 'icon', component: ServerStackIcon },
|
||||
to: () => `/hosting/manage/${encodeURIComponent(breadcrumbServerId.value)}`,
|
||||
})
|
||||
provideBreadcrumbParent(serverBreadcrumb)
|
||||
|
||||
watch(
|
||||
() => auth.user.value,
|
||||
(user, previousUser) => {
|
||||
|
||||
@@ -138,7 +138,14 @@ import {
|
||||
UserPlusIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { injectAuth, injectNotificationManager, NavTabs, useLoadingBarToken } from '@modrinth/ui'
|
||||
import {
|
||||
commonMessages,
|
||||
injectAuth,
|
||||
injectNotificationManager,
|
||||
NavTabs,
|
||||
useLoadingBarToken,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -185,9 +192,10 @@ import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
|
||||
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
|
||||
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { useBreadcrumbs, useTheming } from '@/store/state'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
import { provideSharedInstanceState, useSharedInstanceState } from './use-shared-instance-state'
|
||||
|
||||
@@ -198,10 +206,10 @@ const { playServerProject } = injectServerInstall()
|
||||
const auth = injectAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const route = useRoute()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const router = useRouter()
|
||||
const displayedInstanceRoute = shallowRef(router.currentRoute.value)
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const themeStore = useTheming()
|
||||
const showInstancePlayTime = computed(() => themeStore.getFeatureFlag('show_instance_play_time'))
|
||||
const contentSubpageRouteNames = new Set(['Mods', 'ModsFilter'])
|
||||
@@ -214,7 +222,23 @@ window.addEventListener('online', () => {
|
||||
offline.value = false
|
||||
})
|
||||
|
||||
const instance = ref<GameInstance>()
|
||||
const initialInstanceId = String(displayedInstanceRoute.value.params.id ?? '')
|
||||
const instance = ref<GameInstance | undefined>(
|
||||
queryClient.getQueryData<GameInstance>(['instances', 'summary', initialInstanceId]),
|
||||
)
|
||||
useRootBreadcrumb({
|
||||
slot: 'instance',
|
||||
id: () => `instance:${String(displayedInstanceRoute.value.params.id ?? '')}`,
|
||||
label: () => instance.value?.name ?? formatMessage(commonMessages.loadingLabel),
|
||||
visual: () => ({
|
||||
type: 'image',
|
||||
src: instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
|
||||
alt: instance.value?.name,
|
||||
tintBy: instance.value?.id ?? String(displayedInstanceRoute.value.params.id ?? ''),
|
||||
}),
|
||||
to: () => `/instance/${encodeURIComponent(String(displayedInstanceRoute.value.params.id ?? ''))}`,
|
||||
})
|
||||
|
||||
const preloadedContent = ref<InstanceContentData | null>(null)
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -339,6 +363,9 @@ async function fetchInstance() {
|
||||
}
|
||||
|
||||
instance.value = nextInstance ?? undefined
|
||||
if (nextInstance) {
|
||||
queryClient.setQueryData(['instances', 'summary', nextInstance.id], nextInstance)
|
||||
}
|
||||
displayedInstanceRoute.value = nextRoute
|
||||
sharedInstanceState.reset()
|
||||
sharedInstanceState.refreshAvailability()
|
||||
@@ -503,20 +530,6 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
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: displayedInstanceRoute.value.path,
|
||||
query: displayedInstanceRoute.value.query,
|
||||
})
|
||||
}
|
||||
|
||||
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
|
||||
const launchInstance = async (context: string) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon } from '@modrinth/assets'
|
||||
import { LibraryIcon, PlusIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectNotificationManager, NavTabs } from '@modrinth/ui'
|
||||
import { inject, onUnmounted, ref, shallowRef } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
@@ -7,14 +7,19 @@ import { useRoute } from 'vue-router'
|
||||
import { NewInstanceImage } from '@/assets/icons'
|
||||
import { instance_listener } from '@/helpers/events.js'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs.js'
|
||||
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const showCreationModal = inject('showCreationModal')
|
||||
const route = useRoute()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
|
||||
breadcrumbs.setRootContext({ name: 'Library', link: route.path })
|
||||
useRootBreadcrumb({
|
||||
slot: 'root',
|
||||
id: 'library',
|
||||
label: 'Library',
|
||||
to: '/library',
|
||||
visual: { type: 'icon', component: LibraryIcon },
|
||||
})
|
||||
|
||||
const instances = shallowRef(await list().catch(handleError))
|
||||
|
||||
@@ -37,16 +42,24 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div class="p-6 flex flex-col gap-3">
|
||||
<h1 class="m-0 text-2xl hidden">Library</h1>
|
||||
<NavTabs
|
||||
:links="[
|
||||
{ label: 'All instances', href: `/library` },
|
||||
{ label: 'Modpacks', href: `/library/modpacks` },
|
||||
{ label: 'Servers', href: `/library/servers` },
|
||||
{ label: 'Custom', href: `/library/custom` },
|
||||
{ label: 'Shared with me', href: `/library/shared`, shown: false },
|
||||
{ label: 'Saved', href: `/library/saved`, shown: false },
|
||||
]"
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<NavTabs
|
||||
:links="[
|
||||
{ label: 'All instances', href: `/library` },
|
||||
{ label: 'Modpacks', href: `/library/modpacks` },
|
||||
{ label: 'Servers', href: `/library/servers` },
|
||||
{ label: 'Custom', href: `/library/custom` },
|
||||
{ label: 'Shared with me', href: `/library/shared`, shown: false },
|
||||
{ label: 'Saved', href: `/library/saved`, shown: false },
|
||||
]"
|
||||
/>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="offline" @click="showCreationModal?.()">
|
||||
<PlusIcon />
|
||||
New instance
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<template v-if="instances && instances.length > 0">
|
||||
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
|
||||
</template>
|
||||
|
||||
@@ -295,10 +295,10 @@ import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { getServerAddress } from '@/helpers/worlds'
|
||||
import { provideBreadcrumbParent, useBreadcrumb } from '@/providers/breadcrumbs'
|
||||
import { injectContentInstall } from '@/providers/content-install'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { createServerInstallContent } from '@/providers/setup/server-install-content'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { useTheming } from '@/store/state.js'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
@@ -307,8 +307,29 @@ const { handleError } = injectNotificationManager()
|
||||
const { install: installVersion } = injectContentInstall()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const displayedProjectRoute = shallowRef(router.currentRoute.value)
|
||||
watch(
|
||||
() => router.currentRoute.value,
|
||||
(nextRoute) => {
|
||||
if (nextRoute.path.startsWith('/project/')) {
|
||||
displayedProjectRoute.value = nextRoute
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
const projectBreadcrumbTo = computed(() => {
|
||||
const currentRoute = displayedProjectRoute.value
|
||||
if (currentRoute.name === 'Version') {
|
||||
return {
|
||||
name: 'Versions',
|
||||
params: { id: currentRoute.params.id },
|
||||
query: currentRoute.query,
|
||||
}
|
||||
}
|
||||
|
||||
return currentRoute.fullPath
|
||||
})
|
||||
const queryClient = useQueryClient()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const themeStore = useTheming()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -317,6 +338,10 @@ const messages = defineMessages({
|
||||
id: 'app.project.install-context.back-to-browse',
|
||||
defaultMessage: 'Back to discover',
|
||||
},
|
||||
backToInstance: {
|
||||
id: 'app.project.install-context.back-to-instance',
|
||||
defaultMessage: 'Back to instance',
|
||||
},
|
||||
alreadyInstalled: {
|
||||
id: 'app.project.install-button.already-installed',
|
||||
defaultMessage: 'This project is already installed',
|
||||
@@ -331,6 +356,40 @@ const { installingServerProjects, playServerProject, showAddServerToInstanceModa
|
||||
injectServerInstall()
|
||||
const installing = ref(false)
|
||||
const data = shallowRef(null)
|
||||
|
||||
function getProjectBreadcrumbSummary(projectId) {
|
||||
const identifier = Array.isArray(projectId) ? projectId[0] : projectId
|
||||
if (typeof identifier !== 'string' || !identifier) return undefined
|
||||
|
||||
return queryClient.getQueryData(['projects', 'summary', identifier])
|
||||
}
|
||||
|
||||
function getProjectBreadcrumbLabel(projectId) {
|
||||
const summary = getProjectBreadcrumbSummary(projectId)
|
||||
return summary?.name ?? summary?.title ?? formatMessage(commonMessages.loadingLabel)
|
||||
}
|
||||
|
||||
const projectBreadcrumbLabel = ref(getProjectBreadcrumbLabel(route.params.id))
|
||||
const projectBreadcrumb = useBreadcrumb({
|
||||
slot: 'project',
|
||||
id: () => `project:${String(displayedProjectRoute.value.params.id ?? '')}`,
|
||||
label: projectBreadcrumbLabel,
|
||||
visual: () => {
|
||||
const identifier = String(displayedProjectRoute.value.params.id ?? '')
|
||||
const loadedProject =
|
||||
data.value?.id === identifier || data.value?.slug === identifier ? data.value : undefined
|
||||
const project = loadedProject ?? getProjectBreadcrumbSummary(identifier)
|
||||
return {
|
||||
type: 'image',
|
||||
src: project?.icon_url,
|
||||
alt: projectBreadcrumbLabel.value,
|
||||
tintBy: identifier,
|
||||
}
|
||||
},
|
||||
to: projectBreadcrumbTo,
|
||||
})
|
||||
provideBreadcrumbParent(projectBreadcrumb)
|
||||
|
||||
const versions = shallowRef([])
|
||||
const members = shallowRef([])
|
||||
const categories = shallowRef([])
|
||||
@@ -410,9 +469,18 @@ const projectGalleryHref = computed(() => buildProjectHref(`/project/${route.par
|
||||
const projectBrowseBackUrl = computed(() => {
|
||||
const browsePath = route.query.b
|
||||
if (typeof browsePath === 'string' && browsePath.startsWith('/browse/')) return browsePath
|
||||
const instanceId = route.query.i
|
||||
if (typeof instanceId === 'string' && instanceId) {
|
||||
return `/instance/${encodeURIComponent(instanceId)}`
|
||||
}
|
||||
const type = data.value?.project_type ? `${data.value.project_type}` : 'mod'
|
||||
return buildBrowseHref(`/browse/${type}`)
|
||||
})
|
||||
const projectBackLabel = computed(() =>
|
||||
typeof route.query.i === 'string' && typeof route.query.b !== 'string'
|
||||
? formatMessage(messages.backToInstance)
|
||||
: formatMessage(messages.backToBrowse),
|
||||
)
|
||||
|
||||
const projectInstallContext = computed(() => {
|
||||
const serverData = serverInstallContent.serverContextServerData.value
|
||||
@@ -426,7 +494,7 @@ const projectInstallContext = computed(() => {
|
||||
iconSrc: null,
|
||||
isMedal: serverData.is_medal,
|
||||
backUrl: projectBrowseBackUrl.value,
|
||||
backLabel: formatMessage(messages.backToBrowse),
|
||||
backLabel: projectBackLabel.value,
|
||||
heading: serverInstallContent.serverBrowseHeading.value,
|
||||
queuedCount: serverInstallContent.queuedServerInstallCount.value,
|
||||
selectedProjects: serverInstallContent.selectedServerInstallProjects.value,
|
||||
@@ -446,7 +514,7 @@ const projectInstallContext = computed(() => {
|
||||
gameVersion: instance.value.game_version,
|
||||
iconSrc: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
|
||||
backUrl: projectBrowseBackUrl.value,
|
||||
backLabel: formatMessage(messages.backToBrowse),
|
||||
backLabel: projectBackLabel.value,
|
||||
heading: formatMessage(commonMessages.installingContentLabel),
|
||||
}
|
||||
}
|
||||
@@ -607,6 +675,7 @@ function reportProject() {
|
||||
}
|
||||
|
||||
async function fetchProjectData() {
|
||||
projectBreadcrumbLabel.value = getProjectBreadcrumbLabel(route.params.id)
|
||||
const [project, projectV3Result] = await Promise.all([
|
||||
get_project(route.params.id, 'must_revalidate').catch(handleError),
|
||||
get_project_v3(route.params.id, 'must_revalidate').catch(handleError),
|
||||
@@ -619,6 +688,7 @@ async function fetchProjectData() {
|
||||
}
|
||||
|
||||
data.value = project
|
||||
projectBreadcrumbLabel.value = project.title
|
||||
;[versions.value, members.value, categories.value, instance.value, instanceProjects.value] =
|
||||
await Promise.all([
|
||||
get_version_many(project.versions, 'must_revalidate').catch(handleError),
|
||||
@@ -628,6 +698,14 @@ async function fetchProjectData() {
|
||||
route.query.i ? getInstanceProjects(route.query.i).catch(handleError) : Promise.resolve(),
|
||||
])
|
||||
|
||||
for (const member of members.value ?? []) {
|
||||
for (const identifier of [member.user.id, member.user.username]) {
|
||||
if (identifier) {
|
||||
queryClient.setQueryData(['users', 'summary', identifier], member.user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
versions.value = versions.value.sort((a, b) => dayjs(b.date_published) - dayjs(a.date_published))
|
||||
|
||||
if (instanceProjects.value) {
|
||||
@@ -647,8 +725,6 @@ async function fetchProjectData() {
|
||||
isServerProject.value = projectV3.value?.minecraft_server != null
|
||||
serverStatusOnline.value = !!projectV3.value?.minecraft_java_server?.ping?.data
|
||||
|
||||
breadcrumbs.setName('Project', data.value.title)
|
||||
|
||||
fetchDeferredServerData(project)
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
ExternalIcon,
|
||||
MoreVerticalIcon,
|
||||
ReportIcon,
|
||||
VersionIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
@@ -95,12 +96,12 @@ import {
|
||||
useVIntl,
|
||||
VersionPage,
|
||||
} from '@modrinth/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { SwapIcon } from '@/assets/icons'
|
||||
import { get_project_many, get_version_many } from '@/helpers/cache.js'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { useBreadcrumb } from '@/providers/breadcrumbs'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -119,8 +120,18 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const displayedVersionRoute = shallowRef(router.currentRoute.value)
|
||||
watch(
|
||||
() => router.currentRoute.value,
|
||||
(nextRoute) => {
|
||||
if (nextRoute.name === 'Version') {
|
||||
displayedVersionRoute.value = nextRoute
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
project: Labrinth.Projects.v2.Project
|
||||
@@ -133,9 +144,19 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const version = ref(props.versions.find((version) => version.id === route.params.version))
|
||||
if (version.value) {
|
||||
breadcrumbs.setName('Version', version.value.name)
|
||||
}
|
||||
const versionBreadcrumbLabel = computed(() => {
|
||||
const versionNumber = version.value?.version_number
|
||||
const versionLabel = formatMessage(commonMessages.versionLabel)
|
||||
return versionNumber ? `${versionLabel} ${versionNumber}` : versionLabel
|
||||
})
|
||||
useBreadcrumb({
|
||||
slot: 'project-version',
|
||||
id: () =>
|
||||
`version:${props.project.id}:${String(displayedVersionRoute.value.params.version ?? '')}`,
|
||||
label: versionBreadcrumbLabel,
|
||||
visual: { type: 'icon', component: VersionIcon },
|
||||
to: () => displayedVersionRoute.value.fullPath,
|
||||
})
|
||||
|
||||
const enrichment = ref<Labrinth.Projects.v2.DependencyInfo | undefined>(undefined)
|
||||
const enrichmentLoading = ref(false)
|
||||
@@ -202,18 +223,12 @@ async function refreshEnrichment() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.versions,
|
||||
async () => {
|
||||
if (route.params.version) {
|
||||
version.value = props.versions.find((v) => v.id === route.params.version)
|
||||
if (version.value) {
|
||||
breadcrumbs.setName('Version', version.value.name)
|
||||
}
|
||||
await refreshEnrichment()
|
||||
}
|
||||
},
|
||||
)
|
||||
watch([() => props.versions, () => route.params.version], async () => {
|
||||
if (route.params.version) {
|
||||
version.value = props.versions.find((v) => v.id === route.params.version)
|
||||
await refreshEnrichment()
|
||||
}
|
||||
})
|
||||
|
||||
await refreshEnrichment()
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { createContext } from '@modrinth/ui'
|
||||
import {
|
||||
type Component,
|
||||
computed,
|
||||
type ComputedRef,
|
||||
type MaybeRefOrGetter,
|
||||
shallowRef,
|
||||
toValue,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
export type BreadcrumbVisual =
|
||||
| {
|
||||
type: 'icon'
|
||||
component: Component
|
||||
}
|
||||
| {
|
||||
type: 'image'
|
||||
src?: string | null
|
||||
alt?: string
|
||||
circle?: boolean
|
||||
tintBy?: string | null
|
||||
}
|
||||
|
||||
export interface BreadcrumbDefinition {
|
||||
slot: string
|
||||
id: MaybeRefOrGetter<string>
|
||||
label: MaybeRefOrGetter<string>
|
||||
to?: MaybeRefOrGetter<RouteLocationRaw | undefined>
|
||||
visual?: MaybeRefOrGetter<BreadcrumbVisual | undefined>
|
||||
}
|
||||
|
||||
export interface ResolvedBreadcrumb {
|
||||
slot: string
|
||||
id: string
|
||||
label: string
|
||||
to?: RouteLocationRaw
|
||||
visual?: BreadcrumbVisual
|
||||
}
|
||||
|
||||
export interface BreadcrumbHandle {
|
||||
readonly slot: string
|
||||
activate: () => void
|
||||
reset: () => void
|
||||
pop: () => void
|
||||
}
|
||||
|
||||
export interface BreadcrumbManager {
|
||||
readonly entries: ComputedRef<ResolvedBreadcrumb[]>
|
||||
reset: (definition: BreadcrumbDefinition) => BreadcrumbHandle
|
||||
push: (
|
||||
definition: BreadcrumbDefinition,
|
||||
options?: { parent?: BreadcrumbHandle },
|
||||
) => BreadcrumbHandle
|
||||
find: (slot: string) => BreadcrumbHandle | undefined
|
||||
}
|
||||
|
||||
interface InternalBreadcrumb {
|
||||
token: symbol
|
||||
definition: BreadcrumbDefinition
|
||||
parentToken?: symbol
|
||||
parentSlot?: string
|
||||
root: boolean
|
||||
handle: BreadcrumbHandle
|
||||
}
|
||||
|
||||
const [injectBreadcrumbManager, provideBreadcrumbManager] = createContext<BreadcrumbManager>(
|
||||
'root',
|
||||
'breadcrumbManager',
|
||||
)
|
||||
const [injectBreadcrumbParent, provideBreadcrumbParent] = createContext<BreadcrumbHandle>(
|
||||
'BreadcrumbParent',
|
||||
'breadcrumbParent',
|
||||
)
|
||||
|
||||
export { injectBreadcrumbManager, provideBreadcrumbManager, provideBreadcrumbParent }
|
||||
|
||||
export function createBreadcrumbManager(): BreadcrumbManager {
|
||||
const stack = shallowRef<InternalBreadcrumb[]>([])
|
||||
|
||||
function findIndex(entry: InternalBreadcrumb): number {
|
||||
const tokenIndex = stack.value.findIndex((candidate) => candidate.token === entry.token)
|
||||
if (tokenIndex !== -1) return tokenIndex
|
||||
return stack.value.findIndex((candidate) => candidate.definition.slot === entry.definition.slot)
|
||||
}
|
||||
|
||||
function findParentIndex(entry: InternalBreadcrumb): number {
|
||||
if (entry.parentToken) {
|
||||
const tokenIndex = stack.value.findIndex((candidate) => candidate.token === entry.parentToken)
|
||||
if (tokenIndex !== -1) return tokenIndex
|
||||
}
|
||||
if (entry.parentSlot) {
|
||||
return stack.value.findIndex((candidate) => candidate.definition.slot === entry.parentSlot)
|
||||
}
|
||||
return stack.value.length - 1
|
||||
}
|
||||
|
||||
function activate(entry: InternalBreadcrumb) {
|
||||
if (entry.root) {
|
||||
stack.value = [entry]
|
||||
return
|
||||
}
|
||||
|
||||
const existingIndex = findIndex(entry)
|
||||
if (existingIndex !== -1) {
|
||||
stack.value = [...stack.value.slice(0, existingIndex), entry]
|
||||
return
|
||||
}
|
||||
|
||||
const parentIndex = findParentIndex(entry)
|
||||
stack.value = [...stack.value.slice(0, parentIndex + 1), entry]
|
||||
}
|
||||
|
||||
function resetTo(entry: InternalBreadcrumb) {
|
||||
stack.value = [entry]
|
||||
}
|
||||
|
||||
function pop(entry: InternalBreadcrumb) {
|
||||
const index = stack.value.findIndex((candidate) => candidate.token === entry.token)
|
||||
if (index !== -1) {
|
||||
stack.value = stack.value.slice(0, index)
|
||||
}
|
||||
}
|
||||
|
||||
function createHandle(
|
||||
definition: BreadcrumbDefinition,
|
||||
options: { parent?: BreadcrumbHandle; root: boolean },
|
||||
): BreadcrumbHandle {
|
||||
const entry = {} as InternalBreadcrumb
|
||||
const handle: BreadcrumbHandle = {
|
||||
slot: definition.slot,
|
||||
activate: () => activate(entry),
|
||||
reset: () => resetTo(entry),
|
||||
pop: () => pop(entry),
|
||||
}
|
||||
|
||||
Object.assign(entry, {
|
||||
token: Symbol(definition.slot),
|
||||
definition,
|
||||
parentToken: options.parent
|
||||
? stack.value.find((candidate) => candidate.handle === options.parent)?.token
|
||||
: undefined,
|
||||
parentSlot: options.parent?.slot,
|
||||
root: options.root,
|
||||
handle,
|
||||
})
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
function reset(definition: BreadcrumbDefinition): BreadcrumbHandle {
|
||||
const handle = createHandle(definition, { root: true })
|
||||
handle.reset()
|
||||
return handle
|
||||
}
|
||||
|
||||
function push(
|
||||
definition: BreadcrumbDefinition,
|
||||
options: { parent?: BreadcrumbHandle } = {},
|
||||
): BreadcrumbHandle {
|
||||
const handle = createHandle(definition, { ...options, root: false })
|
||||
handle.activate()
|
||||
return handle
|
||||
}
|
||||
|
||||
const entries = computed<ResolvedBreadcrumb[]>(() =>
|
||||
stack.value.map(({ definition }) => ({
|
||||
slot: definition.slot,
|
||||
id: toValue(definition.id),
|
||||
label: toValue(definition.label),
|
||||
to: definition.to === undefined ? undefined : toValue(definition.to),
|
||||
visual: definition.visual === undefined ? undefined : toValue(definition.visual),
|
||||
})),
|
||||
)
|
||||
|
||||
return {
|
||||
entries,
|
||||
reset,
|
||||
push,
|
||||
find: (slot) => stack.value.find((entry) => entry.definition.slot === slot)?.handle,
|
||||
}
|
||||
}
|
||||
|
||||
function watchBreadcrumbIdentity(definition: BreadcrumbDefinition, handle: BreadcrumbHandle) {
|
||||
watch(
|
||||
() => toValue(definition.id),
|
||||
() => handle.activate(),
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
}
|
||||
|
||||
export function useRootBreadcrumb(definition: BreadcrumbDefinition): BreadcrumbHandle {
|
||||
const manager = injectBreadcrumbManager()
|
||||
const handle = manager.reset(definition)
|
||||
watchBreadcrumbIdentity(definition, handle)
|
||||
return handle
|
||||
}
|
||||
|
||||
export function useBreadcrumb(
|
||||
definition: BreadcrumbDefinition,
|
||||
options: { parent?: BreadcrumbHandle } = {},
|
||||
): BreadcrumbHandle {
|
||||
const manager = injectBreadcrumbManager()
|
||||
const parent = options.parent ?? injectBreadcrumbParent(null) ?? undefined
|
||||
const handle = manager.push(definition, { parent })
|
||||
watchBreadcrumbIdentity(definition, handle)
|
||||
return handle
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { ContentInstallInstance, ContentInstallProjectInfo, ContentItem } from '@modrinth/ui'
|
||||
import { createContext, defineMessage, useVIntl } from '@modrinth/ui'
|
||||
import {
|
||||
createContext,
|
||||
defineMessage,
|
||||
getLatestMatchingInstallVersion,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -813,7 +818,22 @@ export function createContentInstall(opts: {
|
||||
}
|
||||
|
||||
if (project.project_type === 'modpack') {
|
||||
const version = versionId ?? project.versions[project.versions.length - 1]
|
||||
let version = versionId ?? null
|
||||
if (!version) {
|
||||
const hasHints = !!(hints?.preferredGameVersion || hints?.preferredLoader)
|
||||
if (hasHints) {
|
||||
const versions = (await get_version_many(
|
||||
project.versions,
|
||||
'must_revalidate',
|
||||
)) as Labrinth.Versions.v2.Version[]
|
||||
const matching = getLatestMatchingInstallVersion(versions, {
|
||||
gameVersions: hints?.preferredGameVersion ? [hints.preferredGameVersion] : undefined,
|
||||
loaders: hints?.preferredLoader ? [hints.preferredLoader] : undefined,
|
||||
})
|
||||
version = matching?.id ?? null
|
||||
}
|
||||
version ??= project.versions[project.versions.length - 1]
|
||||
}
|
||||
const packs = await list()
|
||||
const existingPack = packs.find((pack) => pack.link?.project_id === project.id)
|
||||
|
||||
|
||||
@@ -16,17 +16,11 @@ export default new createRouter({
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: Pages.Index,
|
||||
meta: {
|
||||
breadcrumb: [{ name: 'Home' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/hosting/manage/',
|
||||
name: 'Servers',
|
||||
component: Pages.Servers,
|
||||
meta: {
|
||||
breadcrumb: [{ name: 'Servers' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/hosting/manage/:id',
|
||||
@@ -37,41 +31,26 @@ export default new createRouter({
|
||||
path: '',
|
||||
name: 'ServerManageOverview',
|
||||
component: Hosting.Overview,
|
||||
meta: {
|
||||
breadcrumb: [{ name: '?Server' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'content',
|
||||
name: 'ServerManageContent',
|
||||
component: Hosting.Content,
|
||||
meta: {
|
||||
breadcrumb: [{ name: '?Server' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'files',
|
||||
name: 'ServerManageFiles',
|
||||
component: Hosting.Files,
|
||||
meta: {
|
||||
breadcrumb: [{ name: '?Server' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'backups',
|
||||
name: 'ServerManageBackups',
|
||||
component: Hosting.Backups,
|
||||
meta: {
|
||||
breadcrumb: [{ name: '?Server' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'access',
|
||||
name: 'ServerManageAccess',
|
||||
component: Hosting.Access,
|
||||
meta: {
|
||||
breadcrumb: [{ name: '?Server' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -79,34 +58,21 @@ export default new createRouter({
|
||||
path: '/browse/:projectType',
|
||||
name: 'Discover content',
|
||||
component: Pages.Browse,
|
||||
meta: {
|
||||
useContext: true,
|
||||
breadcrumb: [{ name: '?BrowseTitle' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/skins',
|
||||
name: 'Skin selector',
|
||||
component: Pages.Skins,
|
||||
meta: {
|
||||
breadcrumb: [{ name: 'Skin selector' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/user/:user/:projectType?',
|
||||
name: 'User',
|
||||
component: Pages.User,
|
||||
meta: {
|
||||
breadcrumb: [{ name: '?User' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/library',
|
||||
name: 'Library',
|
||||
component: Library.Index,
|
||||
meta: {
|
||||
breadcrumb: [{ name: 'Library' }],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -152,42 +118,22 @@ export default new createRouter({
|
||||
path: '',
|
||||
name: 'Description',
|
||||
component: Project.Description,
|
||||
meta: {
|
||||
useContext: true,
|
||||
breadcrumb: [{ name: '?Project' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'versions',
|
||||
name: 'Versions',
|
||||
component: Project.Versions,
|
||||
meta: {
|
||||
useContext: true,
|
||||
breadcrumb: [{ name: '?Project', link: '/project/{id}/' }, { name: 'Versions' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'version/:version',
|
||||
name: 'Version',
|
||||
component: Project.Version,
|
||||
props: true,
|
||||
meta: {
|
||||
useContext: true,
|
||||
breadcrumb: [
|
||||
{ name: '?Project', link: '/project/{id}/' },
|
||||
{ name: 'Versions', link: '/project/{id}/versions' },
|
||||
{ name: '?Version' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'gallery',
|
||||
name: 'Gallery',
|
||||
component: Project.Gallery,
|
||||
meta: {
|
||||
useContext: true,
|
||||
breadcrumb: [{ name: '?Project', link: '/project/{id}/' }, { name: 'Gallery' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -197,59 +143,30 @@ export default new createRouter({
|
||||
component: Instance.Index,
|
||||
props: true,
|
||||
children: [
|
||||
// {
|
||||
// path: '',
|
||||
// name: 'Overview',
|
||||
// component: Instance.Overview,
|
||||
// meta: {
|
||||
// useRootContext: true,
|
||||
// breadcrumb: [{ name: '?Instance' }],
|
||||
// },
|
||||
// },
|
||||
{
|
||||
path: 'worlds',
|
||||
name: 'InstanceWorlds',
|
||||
component: Instance.Worlds,
|
||||
meta: {
|
||||
useRootContext: true,
|
||||
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'share',
|
||||
name: 'InstanceShare',
|
||||
component: Instance.Share,
|
||||
meta: {
|
||||
useRootContext: true,
|
||||
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Share' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
name: 'Mods',
|
||||
component: Instance.Mods,
|
||||
meta: {
|
||||
useRootContext: true,
|
||||
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'projects/:type',
|
||||
name: 'ModsFilter',
|
||||
component: Instance.Mods,
|
||||
meta: {
|
||||
useRootContext: true,
|
||||
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'files',
|
||||
name: 'Files',
|
||||
component: Instance.Files,
|
||||
meta: {
|
||||
useRootContext: true,
|
||||
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Files' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
@@ -257,8 +174,6 @@ export default new createRouter({
|
||||
component: Instance.Logs,
|
||||
meta: {
|
||||
renderMode: 'fixed',
|
||||
useRootContext: true,
|
||||
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Logs' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useBreadcrumbs = defineStore('breadcrumbsStore', {
|
||||
state: () => ({
|
||||
names: new Map(),
|
||||
context: null,
|
||||
rootContext: null,
|
||||
}),
|
||||
actions: {
|
||||
getName(route) {
|
||||
return this.names.get(route) ?? ''
|
||||
},
|
||||
setName(route, title) {
|
||||
this.names.set(route, title)
|
||||
},
|
||||
// resets breadcrumbs to only included ones as to not have stale breadcrumbs
|
||||
resetToNames(breadcrumbs) {
|
||||
if (!breadcrumbs) return
|
||||
// names is an array of every breadcrumb.name that starts with a ?
|
||||
const names = breadcrumbs
|
||||
.filter((breadcrumb) => breadcrumb.name.charAt(0) === '?')
|
||||
.map((breadcrumb) => breadcrumb.name.slice(1))
|
||||
// remove all names that are not in the names array
|
||||
for (const [route] of this.names) {
|
||||
if (!names.includes(route)) {
|
||||
this.names.delete(route)
|
||||
}
|
||||
}
|
||||
},
|
||||
setContext(context) {
|
||||
this.context = context
|
||||
},
|
||||
setRootContext(context) {
|
||||
this.rootContext = context
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useBreadcrumbs } from './breadcrumbs'
|
||||
import { useTheming } from './theme.ts'
|
||||
|
||||
export { useBreadcrumbs, useTheming }
|
||||
export { useTheming }
|
||||
|
||||
@@ -17,6 +17,7 @@ export const DEFAULT_FEATURE_FLAGS = {
|
||||
show_instance_play_time: true,
|
||||
advanced_filters_collapsed: true,
|
||||
always_show_copy_details: false,
|
||||
hide_installed_modpacks: false,
|
||||
}
|
||||
|
||||
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'retro', 'system'] as const
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"decorations": true,
|
||||
"trafficLightPosition": {
|
||||
"x": 15.0,
|
||||
"y": 22.0
|
||||
"y": 26.0
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+7
@@ -27,6 +27,7 @@ import {
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
type ScriptableLineSegmentContext,
|
||||
Tooltip,
|
||||
} from 'chart.js'
|
||||
|
||||
@@ -446,6 +447,12 @@ function buildDatasets() {
|
||||
pointHoverBackgroundColor: colors.borderColor,
|
||||
pointHoverBorderWidth: 0,
|
||||
pointHitRadius: 16,
|
||||
segment: dataset.lastDataPointUnavailable
|
||||
? {
|
||||
borderColor: (context: ScriptableLineSegmentContext) =>
|
||||
context.p1DataIndex === dataset.data.length - 1 ? 'transparent' : undefined,
|
||||
}
|
||||
: undefined,
|
||||
stack: props.stacked ? 'analytics' : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
+10
-2
@@ -59,7 +59,9 @@
|
||||
<span class="font-medium text-primary">
|
||||
{{ formatMessage(analyticsChartMessages.total) }}
|
||||
</span>
|
||||
<span class="font-semibold text-contrast">{{ formattedTotal }}</span>
|
||||
<span class="font-semibold" :class="totalHasData ? 'text-contrast' : 'text-primary'">
|
||||
{{ formattedTotal }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="entry in entries"
|
||||
@@ -111,7 +113,11 @@
|
||||
:class="[
|
||||
'shrink-0',
|
||||
entry.isPreviousPeriod ? 'font-medium text-secondary' : 'font-semibold',
|
||||
entry.hidden ? 'text-primary line-through opacity-70' : 'text-contrast',
|
||||
entry.hidden
|
||||
? 'text-primary line-through opacity-70'
|
||||
: entry.noData
|
||||
? 'text-primary'
|
||||
: 'text-contrast',
|
||||
]"
|
||||
>
|
||||
{{ entry.formattedValue }}
|
||||
@@ -148,6 +154,7 @@ export type AnalyticsChartTooltipEntry = {
|
||||
tooltip?: string
|
||||
color: string
|
||||
formattedValue: string
|
||||
noData: boolean
|
||||
hidden: boolean
|
||||
toggleDisabled: boolean
|
||||
isPreviousPeriod?: boolean
|
||||
@@ -164,6 +171,7 @@ const props = defineProps<{
|
||||
chartStart: Date | null
|
||||
chartEnd: Date | null
|
||||
formattedTotal: string
|
||||
totalHasData: boolean
|
||||
entries: AnalyticsChartTooltipEntry[]
|
||||
containerWidth: number
|
||||
containerHeight: number
|
||||
|
||||
+28
-3
@@ -64,6 +64,7 @@
|
||||
:chart-start="chartRangeBounds?.start ?? null"
|
||||
:chart-end="chartRangeBounds?.end ?? null"
|
||||
:formatted-total="hoverFormattedTotal"
|
||||
:total-has-data="hoverHasData"
|
||||
:entries="hoverEntries"
|
||||
:container-width="containerSize.width"
|
||||
:container-height="containerSize.height"
|
||||
@@ -89,6 +90,7 @@ import type {
|
||||
AnalyticsGroupByPreset,
|
||||
} from '~/providers/analytics/analytics'
|
||||
|
||||
import { analyticsChartMessages } from '../../analytics-messages.ts'
|
||||
import type {
|
||||
AnalyticsChartLegendEntry,
|
||||
AnalyticsChartRangeBounds,
|
||||
@@ -188,7 +190,26 @@ const hoverTotalValue = computed(() => {
|
||||
}, 0)
|
||||
})
|
||||
|
||||
function hasDatasetDataAtSlice(dataset: ChartDataset | undefined, sliceIndex: number) {
|
||||
if (!dataset) return false
|
||||
return !dataset.lastDataPointUnavailable || sliceIndex !== dataset.data.length - 1
|
||||
}
|
||||
|
||||
const hoverHasData = computed(() => {
|
||||
if (hoverState.sliceIndex === null) return false
|
||||
const sliceIndex = hoverState.sliceIndex
|
||||
|
||||
return props.currentLegendEntries.some((legendEntry) => {
|
||||
if (legendEntry.hidden) return false
|
||||
const dataset = props.chartDatasetById.get(legendEntry.id)
|
||||
return hasDatasetDataAtSlice(dataset, sliceIndex)
|
||||
})
|
||||
})
|
||||
|
||||
const hoverFormattedTotal = computed(() => {
|
||||
if (!hoverHasData.value) {
|
||||
return formatMessage(analyticsChartMessages.noData)
|
||||
}
|
||||
if (props.isRatioMode) {
|
||||
return hoverTotalValue.value > 0 ? '100%' : '0%'
|
||||
}
|
||||
@@ -203,6 +224,7 @@ const hoverEntries = computed(() => {
|
||||
return props.legendEntries.map((legendEntry) => {
|
||||
const dataset = props.chartDatasetById.get(legendEntry.id)
|
||||
const value = dataset?.data[sliceIndex] ?? 0
|
||||
const hasData = hasDatasetDataAtSlice(dataset, sliceIndex)
|
||||
const ratioValue = legendEntry.hidden || totalValue === 0 ? 0 : (value / totalValue) * 100
|
||||
return {
|
||||
projectId: legendEntry.id,
|
||||
@@ -210,9 +232,12 @@ const hoverEntries = computed(() => {
|
||||
projectName: legendEntry.projectName,
|
||||
tooltip: legendEntry.tooltip,
|
||||
color: legendEntry.color,
|
||||
formattedValue: props.isRatioMode
|
||||
? `${ratioValue.toFixed(1)}%`
|
||||
: formatMetricValue(value, props.activeStat, formatNumber, formatMessage),
|
||||
formattedValue: hasData
|
||||
? props.isRatioMode
|
||||
? `${ratioValue.toFixed(1)}%`
|
||||
: formatMetricValue(value, props.activeStat, formatNumber, formatMessage)
|
||||
: formatMessage(analyticsChartMessages.noData),
|
||||
noData: !hasData,
|
||||
hidden: legendEntry.hidden,
|
||||
toggleDisabled: !legendEntry.hidden && isLegendEntryToggleDisabled(legendEntry),
|
||||
isPreviousPeriod: legendEntry.isPreviousPeriod,
|
||||
|
||||
+1
@@ -38,6 +38,7 @@ export type ChartDataset = {
|
||||
projectName?: string
|
||||
tooltip?: string
|
||||
data: number[]
|
||||
lastDataPointUnavailable?: boolean
|
||||
borderColor: string
|
||||
backgroundColor: string
|
||||
borderDash?: number[]
|
||||
|
||||
+27
-4
@@ -32,6 +32,7 @@ import {
|
||||
type ChartDataset,
|
||||
getChartDatasetTotal,
|
||||
getShortHourlyAxisTickLimit,
|
||||
getSliceBucketRange,
|
||||
getSliceCount,
|
||||
shouldCapitalizeBreakdownLabel,
|
||||
} from './analytics-chart-utils'
|
||||
@@ -161,8 +162,8 @@ export function useAnalyticsChartDatasets(
|
||||
)
|
||||
: undefined
|
||||
})
|
||||
const chartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
|
||||
buildDatasetsByStat(
|
||||
const chartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() => {
|
||||
const datasets = buildDatasetsByStat(
|
||||
context.displayedTimeSlices.value,
|
||||
selectedProjects.value,
|
||||
legendPalette.value,
|
||||
@@ -175,8 +176,20 @@ export function useAnalyticsChartDatasets(
|
||||
showProjectVersionNames.value ? context.getVersionProjectName : undefined,
|
||||
formatMessage,
|
||||
sliceCount.value,
|
||||
),
|
||||
)
|
||||
)
|
||||
const nextFetchRequest = context.displayedFetchRequest.value
|
||||
if (
|
||||
nextFetchRequest &&
|
||||
context.displayedSelectedGroupBy.value === 'day' &&
|
||||
isLastBucketCurrentDay(nextFetchRequest.time_range, sliceCount.value)
|
||||
) {
|
||||
datasets.revenue = datasets.revenue.map((dataset) => ({
|
||||
...dataset,
|
||||
lastDataPointUnavailable: true,
|
||||
}))
|
||||
}
|
||||
return datasets
|
||||
})
|
||||
const previousChartDatasetsByStat = computed<Record<AnalyticsDashboardStat, ChartDataset[]>>(() =>
|
||||
buildDatasetsByStat(
|
||||
context.displayedPreviousTimeSlices.value,
|
||||
@@ -406,6 +419,16 @@ function buildDatasetsByStat(
|
||||
return datasetsByStat
|
||||
}
|
||||
|
||||
function isLastBucketCurrentDay(timeRange: Labrinth.Analytics.v3.TimeRange, sliceCount: number) {
|
||||
const lastBucket = getSliceBucketRange(timeRange, sliceCount, sliceCount - 1)
|
||||
const todayStart = new Date()
|
||||
todayStart.setHours(0, 0, 0, 0)
|
||||
const tomorrowStart = new Date(todayStart)
|
||||
tomorrowStart.setDate(tomorrowStart.getDate() + 1)
|
||||
|
||||
return lastBucket.start < tomorrowStart && lastBucket.end > todayStart
|
||||
}
|
||||
|
||||
function sortDatasetsByTotal(datasets: ChartDataset[]) {
|
||||
return [...datasets]
|
||||
.sort((a, b) => {
|
||||
|
||||
@@ -688,6 +688,10 @@ export const analyticsChartMessages = defineMessages({
|
||||
id: 'analytics.chart.tooltip.hide-entry',
|
||||
defaultMessage: 'Hide {name} in graph',
|
||||
},
|
||||
noData: {
|
||||
id: 'analytics.chart.tooltip.no-data',
|
||||
defaultMessage: 'No data',
|
||||
},
|
||||
durationDays: {
|
||||
id: 'analytics.chart.tooltip.duration.days',
|
||||
defaultMessage: '{count, plural, one {# day} other {# days}}',
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
<template>
|
||||
<div v-if="shown">
|
||||
<div
|
||||
:class="{
|
||||
shown: actuallyShown,
|
||||
noblur: !$orElse(cosmetics.advancedRendering, true),
|
||||
}"
|
||||
class="modal-overlay"
|
||||
@click="hide"
|
||||
/>
|
||||
<div class="modal-container" :class="{ shown: actuallyShown }">
|
||||
<div class="modal-body">
|
||||
<div v-if="header" class="header">
|
||||
<strong>{{ header }}</strong>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button @click="hide">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ButtonStyled,
|
||||
XIcon,
|
||||
},
|
||||
props: {
|
||||
header: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const cosmetics = useCosmetics()
|
||||
|
||||
return { cosmetics }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
shown: false,
|
||||
actuallyShown: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
show() {
|
||||
this.shown = true
|
||||
setTimeout(() => {
|
||||
this.actuallyShown = true
|
||||
}, 50)
|
||||
},
|
||||
hide() {
|
||||
this.actuallyShown = false
|
||||
setTimeout(() => {
|
||||
this.shown = false
|
||||
}, 300)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal-overlay {
|
||||
visibility: hidden;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 20;
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
background: hsla(0, 0%, 0%, 0.5);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
&.noblur {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 21;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
&.shown {
|
||||
visibility: visible;
|
||||
|
||||
.modal-body {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
position: fixed;
|
||||
box-shadow: var(--shadow-raised), var(--shadow-inset);
|
||||
border-radius: var(--size-rounded-lg);
|
||||
max-height: calc(100% - 2 * var(--spacing-card-bg));
|
||||
overflow-y: auto;
|
||||
width: 600px;
|
||||
pointer-events: auto;
|
||||
outline: 3px solid transparent;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--color-bg);
|
||||
padding: var(--spacing-card-md) var(--spacing-card-lg);
|
||||
|
||||
strong {
|
||||
font-size: 1.25rem;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
background-color: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
transform: translateY(50vh);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all 0.25s ease-in-out;
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
width: calc(100% - 2 * var(--spacing-card-bg));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -86,10 +86,17 @@ export function provideDownloadModalProvider(
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const shouldResolveDependencies = computed(
|
||||
() => !!options.project.value && !!options.selectedVersion.value,
|
||||
() =>
|
||||
!!options.project.value &&
|
||||
!!options.selectedVersion.value &&
|
||||
!!options.currentGameVersion.value,
|
||||
)
|
||||
const dependencyResolutionPreferences = computed(() =>
|
||||
createResolutionPreferences(options.selectedVersion.value, options.currentPlatform.value),
|
||||
createResolutionPreferences(
|
||||
options.selectedVersion.value,
|
||||
options.currentGameVersion.value,
|
||||
options.currentPlatform.value,
|
||||
),
|
||||
)
|
||||
|
||||
const { data: dependencyResolution, isFetching: dependencyResolutionFetching } = useQuery({
|
||||
@@ -309,10 +316,12 @@ export function provideDownloadModalProvider(
|
||||
})
|
||||
|
||||
async function preloadDependenciesForSelection(selection: ProjectDownloadSelection) {
|
||||
if (!options.project.value || !selection.selectedVersion) return
|
||||
if (!options.project.value || !selection.selectedVersion || !selection.currentGameVersion)
|
||||
return
|
||||
|
||||
const preferences = createResolutionPreferences(
|
||||
selection.selectedVersion,
|
||||
selection.currentGameVersion,
|
||||
selection.currentPlatform,
|
||||
)
|
||||
|
||||
@@ -548,10 +557,11 @@ function dependencyProjectsQueryOptions(
|
||||
|
||||
function createResolutionPreferences(
|
||||
version: Labrinth.Versions.v3.Version | null,
|
||||
currentGameVersion: string | null,
|
||||
currentPlatform: string | null,
|
||||
): Labrinth.Content.v3.ResolutionPreferences {
|
||||
return {
|
||||
game_versions: version?.game_versions || [],
|
||||
game_versions: currentGameVersion ? [currentGameVersion] : [],
|
||||
loaders: currentPlatform ? [currentPlatform] : version?.loaders || [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
'no-actions': noLinks,
|
||||
private: isPrivateMessage,
|
||||
'show-private-bg': settings.get(moderationSettings.General.PrivateMessageHighlight),
|
||||
'show-info-bg mb-2 !flex-nowrap py-6': message.body.type === 'legacy_project_message',
|
||||
}"
|
||||
>
|
||||
<template v-if="members[message.author_id]">
|
||||
@@ -51,23 +52,28 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
class="message__icon backed-svg circle moderation-color"
|
||||
class="message__icon backed-svg circle moderation-color shrink-0"
|
||||
:class="{
|
||||
raised: raised,
|
||||
'system-message-icon': [
|
||||
'legacy_project_message',
|
||||
'tech_review_entered',
|
||||
'tech_review_exited',
|
||||
'tech_review_exit_file_deleted',
|
||||
].includes(message.body.type),
|
||||
}"
|
||||
>
|
||||
<ScaleIcon />
|
||||
<InfoIcon v-if="message.body.type === 'legacy_project_message'" class="text-blue" />
|
||||
<ScaleIcon v-else />
|
||||
</div>
|
||||
<span
|
||||
v-if="
|
||||
!['tech_review_entered', 'tech_review_exited', 'tech_review_exit_file_deleted'].includes(
|
||||
message.body.type,
|
||||
)
|
||||
![
|
||||
'legacy_project_message',
|
||||
'tech_review_entered',
|
||||
'tech_review_exited',
|
||||
'tech_review_exit_file_deleted',
|
||||
].includes(message.body.type)
|
||||
"
|
||||
class="message__author moderation-color"
|
||||
>
|
||||
@@ -81,6 +87,10 @@
|
||||
v-html="formattedMessage"
|
||||
/>
|
||||
<div v-else class="message__body status-message">
|
||||
<span v-if="message.body.type === 'legacy_project_message'">
|
||||
This project was published on Modrinth before moderation threads existed and may be missing
|
||||
moderation history.
|
||||
</span>
|
||||
<span v-if="message.body.type === 'deleted'"> posted a message that has been deleted. </span>
|
||||
<template v-else-if="message.body.type === 'status_change'">
|
||||
<span v-if="message.body.new_status === 'processing'">
|
||||
@@ -114,7 +124,7 @@
|
||||
the user.
|
||||
</span>
|
||||
</div>
|
||||
<span class="message__date">
|
||||
<span class="message__date shrink-0">
|
||||
<span v-tooltip="formatDateTime(message.created)">
|
||||
{{ timeSincePosted }}
|
||||
</span>
|
||||
@@ -143,6 +153,7 @@
|
||||
<script setup>
|
||||
import {
|
||||
EyeOffIcon,
|
||||
InfoIcon,
|
||||
MicrophoneIcon,
|
||||
ModrinthIcon,
|
||||
MoreHorizontalIcon,
|
||||
@@ -293,6 +304,15 @@ async function deleteMessage() {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.show-info-bg::before {
|
||||
content: '';
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
background-color: var(--color-blue);
|
||||
opacity: 0.05;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.no-actions {
|
||||
padding: 0;
|
||||
|
||||
|
||||
@@ -30,6 +30,29 @@ const normalizeAuthToken = (value: unknown) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
const getErrorStatus = (error: unknown): number | undefined => {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const typedError = error as { statusCode?: unknown; status?: unknown }
|
||||
const status = typedError.statusCode ?? typedError.status
|
||||
|
||||
return typeof status === 'number' ? status : undefined
|
||||
}
|
||||
|
||||
// only when labrinth actually gives us an auth error
|
||||
const isAuthFailure = (error: unknown): boolean => {
|
||||
const status = getErrorStatus(error)
|
||||
return status === 401 || status === 403
|
||||
}
|
||||
|
||||
const clearAuthCookie = (auth: AuthState, authCookie: { value: string | null }) => {
|
||||
authCookie.value = null
|
||||
auth.token = ''
|
||||
auth.user = null
|
||||
}
|
||||
|
||||
const getQueryString = (value: QueryValue) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
@@ -90,6 +113,7 @@ export const initAuth = async (oldToken: string | null | undefined = null) => {
|
||||
}
|
||||
|
||||
const tokenStr = normalizeAuthToken(authCookie.value)
|
||||
let shouldRefresh = false
|
||||
|
||||
if (authCookie.value != null && tokenStr === '') {
|
||||
authCookie.value = null
|
||||
@@ -111,12 +135,13 @@ export const initAuth = async (oldToken: string | null | undefined = null) => {
|
||||
},
|
||||
true,
|
||||
)) as Labrinth.Users.v2.User
|
||||
} catch {
|
||||
/* empty */
|
||||
} catch (error) {
|
||||
// only refresh when the token was rejected. not on timeouts or other errors (think this was the cause of random logouts)
|
||||
shouldRefresh = isAuthFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!auth.user && auth.token) {
|
||||
if (!auth.user && auth.token && shouldRefresh) {
|
||||
try {
|
||||
const session = (await useBaseFetch(
|
||||
'session/refresh',
|
||||
@@ -132,22 +157,29 @@ export const initAuth = async (oldToken: string | null | undefined = null) => {
|
||||
auth.token = normalizeAuthToken(session.session)
|
||||
if (auth.token) {
|
||||
authCookie.value = auth.token
|
||||
auth.user = (await useBaseFetch(
|
||||
'user',
|
||||
{
|
||||
apiVersion: 3,
|
||||
headers: {
|
||||
Authorization: auth.token,
|
||||
try {
|
||||
auth.user = (await useBaseFetch(
|
||||
'user',
|
||||
{
|
||||
apiVersion: 3,
|
||||
headers: {
|
||||
Authorization: auth.token,
|
||||
},
|
||||
},
|
||||
},
|
||||
true,
|
||||
)) as Labrinth.Users.v2.User
|
||||
true,
|
||||
)) as Labrinth.Users.v2.User
|
||||
} catch (error) {
|
||||
if (isAuthFailure(error)) {
|
||||
clearAuthCookie(auth, authCookie)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
authCookie.value = null
|
||||
auth.token = ''
|
||||
clearAuthCookie(auth, authCookie)
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAuthFailure(error)) {
|
||||
clearAuthCookie(auth, authCookie)
|
||||
}
|
||||
} catch {
|
||||
authCookie.value = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,10 @@ const useModerationCookies = () => {
|
||||
moderationKeybindsId,
|
||||
getCookieOptions(),
|
||||
)
|
||||
const optionsCookie = useCookie<Partial<StoredOptions>>(moderationSettingsId, getCookieOptions())
|
||||
const optionsCookie = useCookie<Partial<StoredOptions> | null>(
|
||||
moderationSettingsId,
|
||||
getCookieOptions(),
|
||||
)
|
||||
|
||||
if (keybindCookie.value && !optionsCookie.value) {
|
||||
optionsCookie.value = {
|
||||
@@ -45,16 +48,17 @@ const useModerationCookies = () => {
|
||||
const useModerationOptions = () =>
|
||||
useState<{ keybinds: StoredKeybinds; settings: StoredSettings }>(moderationKeybindsId, () => {
|
||||
const cookie = useModerationCookies()
|
||||
const stored = cookie.value ?? {}
|
||||
|
||||
const keybindOutput: StoredKeybinds = {}
|
||||
|
||||
for (const [id, definition] of Object.entries(cookie.value.keybinds || {})) {
|
||||
for (const [id, definition] of Object.entries(stored.keybinds || {})) {
|
||||
if (!definition) continue
|
||||
keybindOutput[id] = definition
|
||||
}
|
||||
|
||||
const settingsOutput: StoredSettings = {}
|
||||
for (const [id, setting] of Object.entries(cookie.value.settings || {})) {
|
||||
for (const [id, setting] of Object.entries(stored.settings || {})) {
|
||||
settingsOutput[id] = setting
|
||||
}
|
||||
|
||||
|
||||
@@ -2744,24 +2744,15 @@
|
||||
"settings.applications.about": {
|
||||
"message": "O"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Zrušit"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Vytvořit aplikaci"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Smazat"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Upravit"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Nová aplikace"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Uložit změny"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Nahrát ikonu"
|
||||
},
|
||||
|
||||
@@ -2945,24 +2945,15 @@
|
||||
"settings.applications.button.add-more": {
|
||||
"message": "Tilføj mere"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Annuller"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Opret app"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Slet"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Rediger"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Ny applikation"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Gem ændringer"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Upload ikon"
|
||||
},
|
||||
|
||||
@@ -4679,24 +4679,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Weiterleitungs-URI hinzufügen"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Abbrechen"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "App erstellen"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Löschen"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Bearbeiten"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Neue Anwendung"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Änderungen speichern"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Icon hochladen"
|
||||
},
|
||||
|
||||
@@ -4679,24 +4679,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Weiterleitungs-URI hinzufügen"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Abbrechen"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Anwendung erstellen"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Löschen"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Bearbeiten"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Neue Anwendung"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Änderungen speichern"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Icon hochladen"
|
||||
},
|
||||
|
||||
@@ -191,6 +191,9 @@
|
||||
"analytics.chart.tooltip.hide-entry": {
|
||||
"message": "Hide {name} in graph"
|
||||
},
|
||||
"analytics.chart.tooltip.no-data": {
|
||||
"message": "No data"
|
||||
},
|
||||
"analytics.chart.tooltip.pinned": {
|
||||
"message": "Chart tooltip pinned"
|
||||
},
|
||||
@@ -4709,24 +4712,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Add a redirect URI"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Cancel"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Create app"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Delete"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Edit"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "New application"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Save changes"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Upload icon"
|
||||
},
|
||||
|
||||
@@ -4187,24 +4187,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Agregar un URI de redirección"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Cancelar"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Crear aplicación"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Borrar"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Editar"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Nueva aplicación"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Guardar cambios"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Cargar icono"
|
||||
},
|
||||
|
||||
@@ -3860,24 +3860,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Añadir una URI de redirección"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Cancelar"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Crear aplicación"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Eliminar"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Editar"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Nueva aplicación"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Guardar cambios"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Subir icono"
|
||||
},
|
||||
|
||||
@@ -2840,24 +2840,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Magdagdag ng redirect URI"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Kanselahin"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Lumikha ng app"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Tanggalin"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Baguhin"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Bagong aplikasyon"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "I-save ang mga pagbabago"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "I-upload ang ikono"
|
||||
},
|
||||
|
||||
@@ -4667,24 +4667,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Ajouter une URI de redirection"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Annuler"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Créer une appli"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Supprimer"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Modifier"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Nouvelle application"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Sauvegarder les changements"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Charger une icône"
|
||||
},
|
||||
|
||||
@@ -2282,9 +2282,6 @@
|
||||
"settings.account.data-export.description": {
|
||||
"message": "תבקש עותק של כל הדאטה האישי שהעלית ל-Modrinth. זה יכול לקחת כמה דקות."
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "ערוך"
|
||||
},
|
||||
"settings.applications.field.name": {
|
||||
"message": "שם"
|
||||
},
|
||||
|
||||
@@ -4130,24 +4130,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Átírányítás URI hozzáadása"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Mégse"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Alkalmazás létrehozása"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Törlés"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Szerkesztés"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Új alkalmazás"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Módosítások mentése"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Ikon feltöltése"
|
||||
},
|
||||
|
||||
@@ -2918,24 +2918,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Tambah URI pengarahan ulang"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Batal"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Buat aplikasi"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Hapus"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Sunting"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Aplikasi baru"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Simpan perubahan"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Unggah ikon"
|
||||
},
|
||||
|
||||
@@ -4667,24 +4667,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Aggiungi reindirizzamento"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Annulla"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Crea app"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Elimina"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Modifica"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Nuova applicazione"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Salva modifiche"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Carica icona"
|
||||
},
|
||||
|
||||
@@ -3620,24 +3620,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "リダイレクトURIを追加"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "キャンセル"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "アプリを作成する"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "削除"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "編集"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "新規アプリケーション"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "変更を保存する"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "アイコンをアップロード"
|
||||
},
|
||||
|
||||
@@ -4631,24 +4631,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "리디렉션 URI 를 추가하세요"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "취소"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "앱 만들기"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "지우기"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "편집"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "새 애플리케이션"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "변경사항 저장"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "아이콘 업로드"
|
||||
},
|
||||
|
||||
@@ -3653,24 +3653,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Tambah URI pengubah hala"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Batal"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Cipta aplikasi"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Padam"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Sunting"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Aplikasi baharu"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Simpan perubahan"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Muat naik ikon"
|
||||
},
|
||||
|
||||
@@ -4706,24 +4706,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Voeg een URI toe"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Annuleren"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Maak nieuwe"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Verwijder"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Bewerk"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Nieuw project"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Wijzigingen opslaan"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Pictogram uploaden"
|
||||
},
|
||||
|
||||
@@ -3539,24 +3539,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Legg til en omdirings-URI"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Avbryt"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Lag app"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Slett"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Regiser"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Ny søknad"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Lagre endringer"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Last opp ikon"
|
||||
},
|
||||
|
||||
@@ -4622,24 +4622,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Dodaj URI przekierowywania"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Anuluj"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Stwórz aplikację"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Usuń"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Edytuj"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Nowa aplikacja"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Zapisz zmiany"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Prześlij ikonę"
|
||||
},
|
||||
|
||||
@@ -4709,24 +4709,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Adicionar URI de redirecionamento"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Cancelar"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Criar app"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Excluir"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Editar"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Novo aplicativo"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Salvar alterações"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Enviar ícone"
|
||||
},
|
||||
|
||||
@@ -3350,24 +3350,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Adicionar um endereço de redirecionamento"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Cancelar"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Criar app"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Apagar"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Editar"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Nova aplicação"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Guardar alterações"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Carregar ícone"
|
||||
},
|
||||
|
||||
@@ -1814,21 +1814,12 @@
|
||||
"settings.applications.about": {
|
||||
"message": "Despre"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Anuleaza"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Creaza aplicația"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Sterge"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Editeaza"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Salvează schimbările"
|
||||
},
|
||||
"settings.applications.client-id": {
|
||||
"message": "ID Client"
|
||||
},
|
||||
|
||||
@@ -4655,24 +4655,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Добавить адрес перенаправления"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Отмена"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Создать приложение"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Удалить"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Изменить"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Новое приложение"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Сохранить изменения"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Загрузить иконку"
|
||||
},
|
||||
|
||||
@@ -3713,24 +3713,15 @@
|
||||
"settings.applications.button.add-more": {
|
||||
"message": "Lägg till mer"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Avbryt"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Skapa app"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Ta bort"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Redigera"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Ny applikation"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Spara ändringar"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Ladda upp ikon"
|
||||
},
|
||||
|
||||
@@ -4118,24 +4118,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Yönlendirme URI'ları ekle"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "İptal"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Uygulama oluştur"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Sil"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Düzenle"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Yeni uygulama"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Değişiklikleri kaydet"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Simge yükle"
|
||||
},
|
||||
|
||||
@@ -4631,24 +4631,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Додати переадресацію URI"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Скасувати"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Створити застосунок"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Видалити"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Редагувати"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Новий застосунок"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Зберегти зміни"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Завантажити значок"
|
||||
},
|
||||
|
||||
@@ -3818,24 +3818,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "Thêm URI chuyển hướng"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "Huỷ"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "Tạo ứng dụng"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "Xoá"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "Chỉnh sửa"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "Ứng dụng mới"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "Lưu thay đổi"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "Tải lên biểu tượng"
|
||||
},
|
||||
|
||||
@@ -4709,24 +4709,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "添加重定向 URI"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "取消"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "新建 App"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "删除"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "编辑"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "新建应用程序"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "保存更改"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "上传图标"
|
||||
},
|
||||
|
||||
@@ -4709,24 +4709,15 @@
|
||||
"settings.applications.button.add-redirect-uri": {
|
||||
"message": "新增重新導向 URI"
|
||||
},
|
||||
"settings.applications.button.cancel": {
|
||||
"message": "取消"
|
||||
},
|
||||
"settings.applications.button.create": {
|
||||
"message": "建立應用程式"
|
||||
},
|
||||
"settings.applications.button.delete": {
|
||||
"message": "刪除"
|
||||
},
|
||||
"settings.applications.button.edit": {
|
||||
"message": "編輯"
|
||||
},
|
||||
"settings.applications.button.new": {
|
||||
"message": "新的應用程式"
|
||||
},
|
||||
"settings.applications.button.save-changes": {
|
||||
"message": "儲存變更"
|
||||
},
|
||||
"settings.applications.button.upload-icon": {
|
||||
"message": "上傳圖示"
|
||||
},
|
||||
|
||||
@@ -109,8 +109,8 @@
|
||||
</template>
|
||||
</div>
|
||||
<ConversationThread
|
||||
v-if="thread"
|
||||
:thread="thread"
|
||||
v-if="prefixedThread"
|
||||
:thread="prefixedThread"
|
||||
:project="project"
|
||||
:set-status="setStatus"
|
||||
:current-member="currentMember ?? undefined"
|
||||
@@ -151,6 +151,7 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { isStaff } from '@modrinth/utils'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
|
||||
@@ -217,6 +218,26 @@ const {
|
||||
thread,
|
||||
} = injectProjectPageContext()
|
||||
|
||||
const THREADS_RELEASE_DATE = '2023-08-05T12:00:00-07:00'
|
||||
|
||||
const prefixedThread = computed(() => {
|
||||
const projectDate = project.value?.queued ?? project.value?.approved ?? project.value?.published
|
||||
if (thread.value && projectDate && dayjs(projectDate).isBefore(dayjs(THREADS_RELEASE_DATE))) {
|
||||
const newThread = JSON.parse(JSON.stringify(thread.value))
|
||||
newThread.messages.unshift({
|
||||
id: '69',
|
||||
author_id: null,
|
||||
body: {
|
||||
type: 'legacy_project_message',
|
||||
},
|
||||
created: THREADS_RELEASE_DATE,
|
||||
hide_identity: false,
|
||||
})
|
||||
return newThread
|
||||
}
|
||||
return thread.value
|
||||
})
|
||||
|
||||
const canAccess = computed(() => !!currentMember.value)
|
||||
const staff = computed(() => isStaff(currentMember.value?.user))
|
||||
const userFacingUiVisible = computed(
|
||||
|
||||
@@ -485,9 +485,9 @@ async function saveEvent() {
|
||||
const payload = buildEventPayload()
|
||||
|
||||
if (modalMode.value === 'edit' && editingEventId.value !== null) {
|
||||
await client.labrinth.analytics_v3.editEvent(editingEventId.value, payload)
|
||||
await client.labrinth.analytics_internal.editEvent(editingEventId.value, payload)
|
||||
} else {
|
||||
await client.labrinth.analytics_v3.createEvent(payload)
|
||||
await client.labrinth.analytics_internal.createEvent(payload)
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey })
|
||||
@@ -528,7 +528,7 @@ async function deleteEvent(eventId: Labrinth.Analytics.v3.AnalyticsEventId) {
|
||||
setDeletingEvent(eventId, true)
|
||||
|
||||
try {
|
||||
await client.labrinth.analytics_v3.deleteEvent(eventId)
|
||||
await client.labrinth.analytics_internal.deleteEvent(eventId)
|
||||
await queryClient.invalidateQueries({ queryKey: analyticsEventsQueryKey })
|
||||
addNotification({
|
||||
title: 'Analytics event deleted',
|
||||
@@ -581,7 +581,7 @@ function commitAnnouncementUrl() {
|
||||
committedAnnouncementUrl.value = form.value.announcementUrl
|
||||
}
|
||||
|
||||
function buildEventPayload(): Labrinth.Analytics.v3.AnalyticsEventUpsert {
|
||||
function buildEventPayload(): Labrinth.Analytics.Internal.AnalyticsEventUpsert {
|
||||
const selectedRange = getEventFormDateRange()
|
||||
if (!selectedRange) {
|
||||
throw new Error('Select a valid start and end date')
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
:proceed-label="formatMessage(messages.deleteConfirmButton)"
|
||||
@proceed="removeApp(editingId)"
|
||||
/>
|
||||
<Modal ref="appModal" :header="formatMessage(messages.modalHeader)">
|
||||
<div class="universal-modal">
|
||||
<label for="app-name"
|
||||
><span class="label__title">{{ formatMessage(messages.nameLabel) }}</span>
|
||||
<NewModal ref="appModal" :header="formatMessage(messages.modalHeader)" width="40rem" scrollable>
|
||||
<div class="flex flex-col">
|
||||
<label for="app-name" class="m-0 mb-2 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.nameLabel) }}
|
||||
</label>
|
||||
<StyledInput
|
||||
id="app-name"
|
||||
@@ -19,8 +19,12 @@
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(messages.namePlaceholder)"
|
||||
/>
|
||||
<label v-if="editingId" for="app-icon"
|
||||
><span class="label__title">{{ formatMessage(messages.iconLabel) }}</span>
|
||||
<label
|
||||
v-if="editingId"
|
||||
for="app-icon"
|
||||
class="mb-2 mt-4 text-lg font-semibold text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.iconLabel) }}
|
||||
</label>
|
||||
<div v-if="editingId" class="icon-submission">
|
||||
<Avatar size="md" :src="icon" />
|
||||
@@ -36,8 +40,8 @@
|
||||
</FileInput>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<label v-if="editingId" for="app-url">
|
||||
<span class="label__title">{{ formatMessage(messages.urlLabel) }}</span>
|
||||
<label v-if="editingId" for="app-url" class="mb-2 mt-4 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.urlLabel) }}
|
||||
</label>
|
||||
<StyledInput
|
||||
v-if="editingId"
|
||||
@@ -48,8 +52,12 @@
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(messages.urlPlaceholder)"
|
||||
/>
|
||||
<label v-if="editingId" for="app-description">
|
||||
<span class="label__title">{{ formatMessage(messages.descriptionLabel) }}</span>
|
||||
<label
|
||||
v-if="editingId"
|
||||
for="app-description"
|
||||
class="mb-2 mt-4 text-lg font-semibold text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.descriptionLabel) }}
|
||||
</label>
|
||||
<StyledInput
|
||||
v-if="editingId"
|
||||
@@ -61,15 +69,12 @@
|
||||
:placeholder="formatMessage(messages.descriptionPlaceholder)"
|
||||
input-class="h-24 resize-y"
|
||||
/>
|
||||
<label for="app-scopes"
|
||||
><span class="label__title">{{ formatMessage(messages.scopesLabel) }}</span>
|
||||
<label for="app-scopes" class="mb-2 mt-4 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.scopesLabel) }}
|
||||
</label>
|
||||
<div
|
||||
id="app-scopes"
|
||||
class="scope-items mt-2 grid grid-cols-1 gap-x-6 gap-y-4 min-[600px]:grid-cols-2"
|
||||
>
|
||||
<div id="app-scopes" class="scope-items grid grid-cols-1 gap-6 min-[600px]:grid-cols-2">
|
||||
<div v-for="category in scopeCategories" :key="category.name" class="flex flex-col gap-2">
|
||||
<h4 class="m-0 border-b border-divider pb-1 text-base font-bold text-contrast">
|
||||
<h4 class="m-0 text-base font-medium text-primary">
|
||||
{{ category.name }}
|
||||
</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -83,8 +88,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label for="app-redirect-uris" class="mt-4"
|
||||
><span class="label__title">{{ formatMessage(messages.redirectUrisLabel) }}</span>
|
||||
<label for="app-redirect-uris" class="mb-2 mt-4 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.redirectUrisLabel) }}
|
||||
</label>
|
||||
<div class="uri-input-list">
|
||||
<div v-for="(_, index) in redirectUris" :key="index">
|
||||
@@ -93,6 +98,7 @@
|
||||
v-model="redirectUris[index]"
|
||||
:maxlength="2048"
|
||||
type="url"
|
||||
class="w-80"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(messages.redirectUriPlaceholder)"
|
||||
/>
|
||||
@@ -116,18 +122,20 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="submit-row input-group push-right">
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2 p-2">
|
||||
<ButtonStyled>
|
||||
<button @click="$refs.appModal.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="editingId" color="brand">
|
||||
<button :disabled="!canSubmit" @click="editApp">
|
||||
<SaveIcon />
|
||||
{{ formatMessage(messages.saveChanges) }}
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand">
|
||||
@@ -137,8 +145,8 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
</NewModal>
|
||||
|
||||
<div class="header__row">
|
||||
<div class="header__title">
|
||||
@@ -220,7 +228,7 @@
|
||||
"
|
||||
>
|
||||
<EditIcon />
|
||||
{{ formatMessage(messages.edit) }}
|
||||
{{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
@@ -255,6 +263,7 @@ import {
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
NewModal,
|
||||
normalizeChildren,
|
||||
StyledInput,
|
||||
useFormatDateTime,
|
||||
@@ -262,7 +271,6 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
import {
|
||||
getScopeValue,
|
||||
hasScope,
|
||||
@@ -355,14 +363,6 @@ const messages = defineMessages({
|
||||
id: 'settings.applications.button.add-redirect-uri',
|
||||
defaultMessage: 'Add a redirect URI',
|
||||
},
|
||||
cancel: {
|
||||
id: 'settings.applications.button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
saveChanges: {
|
||||
id: 'settings.applications.button.save-changes',
|
||||
defaultMessage: 'Save changes',
|
||||
},
|
||||
createApp: {
|
||||
id: 'settings.applications.button.create',
|
||||
defaultMessage: 'Create app',
|
||||
@@ -396,10 +396,6 @@ const messages = defineMessages({
|
||||
id: 'settings.applications.created-on',
|
||||
defaultMessage: 'Created on {date}',
|
||||
},
|
||||
edit: {
|
||||
id: 'settings.applications.button.edit',
|
||||
defaultMessage: 'Edit',
|
||||
},
|
||||
delete: {
|
||||
id: 'settings.applications.button.delete',
|
||||
defaultMessage: 'Delete',
|
||||
@@ -549,7 +545,7 @@ async function onImageSelection(files) {
|
||||
const file = files[0]
|
||||
const extFromType = file.type.split('/')[1]
|
||||
|
||||
await client.labrinth.oauth_internal.uploadAppIcon(editingId.value, file, extFromType).promise
|
||||
await client.labrinth.oauth_internal.uploadAppIcon(editingId.value, file, extFromType)
|
||||
|
||||
await refresh()
|
||||
|
||||
|
||||
@@ -62,7 +62,13 @@
|
||||
<label for="pat-expires">
|
||||
<span class="font-semibold">{{ formatMessage(createModalMessages.expiresLabel) }}</span>
|
||||
</label>
|
||||
<DatePicker id="pat-expires" v-model="expires" show-today wrapper-class="w-full" />
|
||||
<DatePicker
|
||||
id="pat-expires"
|
||||
v-model="expires"
|
||||
:min-date="minimumPatExpiry"
|
||||
show-today
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto mt-4 flex gap-2">
|
||||
@@ -73,13 +79,13 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="editPatId !== null" color="brand">
|
||||
<button :disabled="loading || !name || !expires" @click="editPat">
|
||||
<button :disabled="loading || !name || !isExpiryInFuture" @click="editPat">
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand">
|
||||
<button :disabled="loading || !name || !expires" @click="createPat">
|
||||
<button :disabled="loading || !name || !isExpiryInFuture" @click="createPat">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(createModalMessages.action) }}
|
||||
</button>
|
||||
@@ -123,8 +129,8 @@
|
||||
<strong>{{ pat.name }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="pat.access_token">
|
||||
<CopyCode :text="pat.access_token" />
|
||||
<template v-if="createdPatTokens[pat.id] || pat.access_token">
|
||||
<CopyCode :text="createdPatTokens[pat.id] || pat.access_token" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-tooltip="pat.last_used ? formatDateTime(pat.last_used) : null">
|
||||
@@ -331,12 +337,17 @@ useHead({
|
||||
const data = useNuxtApp()
|
||||
const { scopesToLabels } = useScopes()
|
||||
const patModal = ref()
|
||||
const minimumPatExpiry = data.$dayjs().add(1, 'day').format('YYYY-MM-DD')
|
||||
|
||||
const editPatId = ref(null)
|
||||
|
||||
const name = ref(null)
|
||||
const scopesVal = ref(BigInt(0))
|
||||
const expires = ref(null)
|
||||
const createdPatTokens = ref({})
|
||||
const isExpiryInFuture = computed(
|
||||
() => expires.value && data.$dayjs(expires.value).isAfter(data.$dayjs(), 'day'),
|
||||
)
|
||||
|
||||
const deletePatIndex = ref(null)
|
||||
|
||||
@@ -415,6 +426,9 @@ async function createPat() {
|
||||
scopes: Number(scopesVal.value),
|
||||
expires: data.$dayjs(expires.value).toISOString(),
|
||||
})
|
||||
if (res.access_token) {
|
||||
createdPatTokens.value[res.id] = res.access_token
|
||||
}
|
||||
queryClient.setQueryData(['pat'], (old) => [...(old || []), res])
|
||||
patModal.value.hide()
|
||||
} catch (err) {
|
||||
|
||||
@@ -11,7 +11,7 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates dumb-init curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY labrinth /labrinth/labrinth
|
||||
COPY --chmod=0755 labrinth /labrinth/labrinth
|
||||
COPY migrations /labrinth/migrations
|
||||
COPY assets /labrinth/assets
|
||||
|
||||
|
||||
@@ -168,6 +168,9 @@ pub async fn update_bank_balances(pool: PgPool) -> eyre::Result<()> {
|
||||
|
||||
pub async fn run_migrations() -> eyre::Result<()> {
|
||||
database::check_for_migrations().await?;
|
||||
crate::clickhouse::run_migrations()
|
||||
.await
|
||||
.wrap_err("failed to run ClickHouse migrations")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -19,29 +19,44 @@ pub async fn init_client() -> clickhouse::error::Result<clickhouse::Client> {
|
||||
pub async fn init_client_with_database(
|
||||
database: &str,
|
||||
) -> clickhouse::error::Result<clickhouse::Client> {
|
||||
const MINECRAFT_JAVA_SERVER_PINGS: &str = server_ping::CLICKHOUSE_TABLE;
|
||||
Ok(connect()?.with_database(database))
|
||||
}
|
||||
|
||||
let client = {
|
||||
let https_connector = HttpsConnectorBuilder::new()
|
||||
.with_native_roots()?
|
||||
.https_or_http()
|
||||
.enable_all_versions()
|
||||
.build();
|
||||
let hyper_client =
|
||||
hyper_util::client::legacy::Client::builder(TokioExecutor::new())
|
||||
.build(https_connector);
|
||||
fn connect() -> clickhouse::error::Result<clickhouse::Client> {
|
||||
let https_connector = HttpsConnectorBuilder::new()
|
||||
.with_native_roots()?
|
||||
.https_or_http()
|
||||
.enable_all_versions()
|
||||
.build();
|
||||
let hyper_client =
|
||||
hyper_util::client::legacy::Client::builder(TokioExecutor::new())
|
||||
.build(https_connector);
|
||||
|
||||
clickhouse::Client::with_http_client(hyper_client)
|
||||
.with_url(&ENV.CLICKHOUSE_URL)
|
||||
.with_user(&ENV.CLICKHOUSE_USER)
|
||||
.with_password(&ENV.CLICKHOUSE_PASSWORD)
|
||||
.with_validation(false)
|
||||
};
|
||||
Ok(clickhouse::Client::with_http_client(hyper_client)
|
||||
.with_url(&ENV.CLICKHOUSE_URL)
|
||||
.with_user(&ENV.CLICKHOUSE_USER)
|
||||
.with_password(&ENV.CLICKHOUSE_PASSWORD)
|
||||
.with_validation(false))
|
||||
}
|
||||
|
||||
client
|
||||
#[cfg(feature = "test")]
|
||||
pub async fn create_database(database: &str) -> clickhouse::error::Result<()> {
|
||||
connect()?
|
||||
.query(&format!("CREATE DATABASE IF NOT EXISTS {database}"))
|
||||
.execute()
|
||||
.await?;
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn run_migrations() -> clickhouse::error::Result<()> {
|
||||
run_migrations_on_database(&ENV.CLICKHOUSE_DATABASE).await
|
||||
}
|
||||
|
||||
pub async fn run_migrations_on_database(
|
||||
database: &str,
|
||||
) -> clickhouse::error::Result<()> {
|
||||
const MINECRAFT_JAVA_SERVER_PINGS: &str = server_ping::CLICKHOUSE_TABLE;
|
||||
|
||||
let client = connect()?;
|
||||
|
||||
let clickhouse_replicated = ENV.CLICKHOUSE_REPLICATED;
|
||||
let cluster_line = if clickhouse_replicated {
|
||||
@@ -79,9 +94,10 @@ pub async fn init_client_with_database(
|
||||
ip IPv6,
|
||||
country String,
|
||||
user_agent String,
|
||||
headers Array(Tuple(String, String))
|
||||
headers Array(Tuple(String, String)) DEFAULT [] TTL toDateTime(recorded) + INTERVAL 60 DAY
|
||||
)
|
||||
ENGINE = {engine}
|
||||
PARTITION BY toYYYYMM(recorded)
|
||||
{ttl}
|
||||
PRIMARY KEY (project_id, recorded, ip)
|
||||
SETTINGS index_granularity = 8192
|
||||
@@ -106,9 +122,10 @@ pub async fn init_client_with_database(
|
||||
ip IPv6,
|
||||
country String,
|
||||
user_agent String,
|
||||
headers Array(Tuple(String, String))
|
||||
headers Array(Tuple(String, String)) DEFAULT [] TTL toDateTime(recorded) + INTERVAL 60 DAY
|
||||
)
|
||||
ENGINE = {engine}
|
||||
PARTITION BY toYYYYMM(recorded)
|
||||
{ttl}
|
||||
PRIMARY KEY (project_id, recorded, ip)
|
||||
SETTINGS index_granularity = 8192
|
||||
@@ -134,6 +151,7 @@ pub async fn init_client_with_database(
|
||||
parent UInt64
|
||||
)
|
||||
ENGINE = {engine}
|
||||
PARTITION BY toYYYYMM(recorded)
|
||||
{ttl}
|
||||
PRIMARY KEY (project_id, recorded, user_id)
|
||||
SETTINGS index_granularity = 8192
|
||||
@@ -156,9 +174,10 @@ pub async fn init_client_with_database(
|
||||
ip IPv6,
|
||||
country String,
|
||||
user_agent String,
|
||||
headers Array(Tuple(String, String))
|
||||
headers Array(Tuple(String, String)) DEFAULT [] TTL toDateTime(recorded) + INTERVAL 60 DAY
|
||||
)
|
||||
ENGINE = {engine}
|
||||
PARTITION BY toYYYYMM(recorded)
|
||||
{ttl}
|
||||
PRIMARY KEY (affiliate_code_id, recorded)
|
||||
SETTINGS index_granularity = 8192
|
||||
@@ -184,6 +203,7 @@ pub async fn init_client_with_database(
|
||||
players_max Nullable(Int32)
|
||||
)
|
||||
ENGINE = {engine}
|
||||
PARTITION BY toYYYYMM(recorded)
|
||||
{ttl}
|
||||
PRIMARY KEY (project_id, recorded)
|
||||
SETTINGS index_granularity = 8192
|
||||
@@ -203,6 +223,7 @@ pub async fn init_client_with_database(
|
||||
minecraft_uuid UUID
|
||||
)
|
||||
ENGINE = {engine}
|
||||
PARTITION BY toYYYYMM(recorded)
|
||||
{ttl}
|
||||
PRIMARY KEY (project_id, recorded)
|
||||
SETTINGS index_granularity = 8192
|
||||
@@ -264,5 +285,5 @@ pub async fn init_client_with_database(
|
||||
.execute()
|
||||
.await?;
|
||||
|
||||
Ok(client.with_database(database))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ async fn app() -> std::io::Result<()> {
|
||||
info!("Starting labrinth on {}", &ENV.BIND_ADDR);
|
||||
|
||||
if !args.no_migrations {
|
||||
database::check_for_migrations()
|
||||
labrinth::background_task::run_migrations()
|
||||
.await
|
||||
.expect("An error occurred while running migrations.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
use actix_web::{HttpRequest, delete, patch, post, web};
|
||||
use chrono::{DateTime, Utc};
|
||||
use eyre::eyre;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use xredis::RedisPool;
|
||||
|
||||
use crate::{
|
||||
auth::get_user_from_headers,
|
||||
database::{
|
||||
PgPool,
|
||||
models::{
|
||||
DBAnalyticsEvent, DBAnalyticsEventId, generate_analytics_event_id,
|
||||
},
|
||||
},
|
||||
models::{
|
||||
ids::AnalyticsEventId,
|
||||
pats::Scopes,
|
||||
v3::analytics_event::{AnalyticsEvent, AnalyticsEventMeta},
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(analytics_event_create)
|
||||
.service(analytics_event_edit)
|
||||
.service(analytics_event_delete);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AnalyticsEventUpsert {
|
||||
#[serde(flatten)]
|
||||
pub meta: AnalyticsEventMeta,
|
||||
pub starts: DateTime<Utc>,
|
||||
pub ends: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Create an analytics event.
|
||||
#[utoipa::path(
|
||||
context_path = "/analytics-event",
|
||||
tag = "analytics events", responses((status = OK, body = AnalyticsEvent))
|
||||
)]
|
||||
#[post("")]
|
||||
pub async fn analytics_event_create(
|
||||
req: HttpRequest,
|
||||
event: web::Json<AnalyticsEventUpsert>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut transaction = pool
|
||||
.begin()
|
||||
.await
|
||||
.wrap_internal_err("failed to begin transaction")?;
|
||||
let id = generate_analytics_event_id(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to generate analytics event ID")?;
|
||||
|
||||
let event = DBAnalyticsEvent {
|
||||
id,
|
||||
meta: event.meta.clone(),
|
||||
starts: event.starts,
|
||||
ends: event.ends,
|
||||
};
|
||||
event
|
||||
.insert(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to insert analytics event")?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.wrap_internal_err("failed to commit transaction")?;
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(web::Json(event.into()))
|
||||
}
|
||||
|
||||
/// Update an analytics event.
|
||||
#[utoipa::path(
|
||||
context_path = "/analytics-event",
|
||||
tag = "analytics events", responses((status = OK, body = AnalyticsEvent))
|
||||
)]
|
||||
#[patch("/{id}")]
|
||||
pub async fn analytics_event_edit(
|
||||
req: HttpRequest,
|
||||
id: web::Path<(AnalyticsEventId,)>,
|
||||
event: web::Json<AnalyticsEventUpsert>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let event = DBAnalyticsEvent {
|
||||
id: DBAnalyticsEventId::from(id.into_inner().0),
|
||||
meta: event.meta.clone(),
|
||||
starts: event.starts,
|
||||
ends: event.ends,
|
||||
};
|
||||
|
||||
let updated = event
|
||||
.update(&**pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to update analytics event")?;
|
||||
if !updated {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(web::Json(event.into()))
|
||||
}
|
||||
|
||||
/// Delete an analytics event.
|
||||
#[utoipa::path(
|
||||
context_path = "/analytics-event",
|
||||
tag = "analytics events", responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[delete("/{id}")]
|
||||
pub async fn analytics_event_delete(
|
||||
req: HttpRequest,
|
||||
id: web::Path<(AnalyticsEventId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<(), ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let deleted = DBAnalyticsEvent::remove(
|
||||
DBAnalyticsEventId::from(id.into_inner().0),
|
||||
&**pool,
|
||||
)
|
||||
.await
|
||||
.wrap_internal_err("failed to delete analytics event")?;
|
||||
if !deleted {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod admin;
|
||||
pub mod affiliate;
|
||||
pub mod analytics_event;
|
||||
pub mod attribution;
|
||||
pub mod billing;
|
||||
pub mod blocked_users;
|
||||
@@ -35,6 +36,10 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.configure(flows::config)
|
||||
.configure(pats::config)
|
||||
.configure(oauth_clients::config)
|
||||
.service(
|
||||
web::scope("/analytics-event")
|
||||
.configure(analytics_event::config),
|
||||
)
|
||||
.service(web::scope("/moderation").configure(moderation::config))
|
||||
.service(web::scope("/affiliate").configure(affiliate::config))
|
||||
.service(web::scope("/campaign").configure(campaign::config))
|
||||
@@ -50,11 +55,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.configure(medal::config)
|
||||
.configure(mural::config)
|
||||
.configure(statuses::config),
|
||||
)
|
||||
.service(
|
||||
web::scope("/v3/analytics-event")
|
||||
.wrap(default_cors())
|
||||
.configure(super::v3::analytics_event::config),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,10 +180,9 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
medal::redeem,
|
||||
mural::get_bank_details,
|
||||
statuses::ws_init,
|
||||
super::v3::analytics_event::analytics_events_get,
|
||||
super::v3::analytics_event::analytics_event_create,
|
||||
super::v3::analytics_event::analytics_event_edit,
|
||||
super::v3::analytics_event::analytics_event_delete,
|
||||
analytics_event::analytics_event_create,
|
||||
analytics_event::analytics_event_edit,
|
||||
analytics_event::analytics_event_delete,
|
||||
),
|
||||
modifiers(&InternalPathModifier, &SecurityAddon)
|
||||
)]
|
||||
|
||||
@@ -1,48 +1,22 @@
|
||||
use actix_web::{HttpRequest, delete, get, patch, post, web};
|
||||
use chrono::{DateTime, Utc};
|
||||
use eyre::eyre;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use actix_web::{get, web};
|
||||
use xredis::RedisPool;
|
||||
|
||||
use crate::{
|
||||
auth::get_user_from_headers,
|
||||
database::{
|
||||
PgPool,
|
||||
models::{
|
||||
DBAnalyticsEvent, DBAnalyticsEventId, generate_analytics_event_id,
|
||||
},
|
||||
},
|
||||
models::{
|
||||
ids::AnalyticsEventId,
|
||||
pats::Scopes,
|
||||
v3::analytics_event::{AnalyticsEvent, AnalyticsEventMeta},
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
database::{PgPool, models::DBAnalyticsEvent},
|
||||
models::v3::analytics_event::AnalyticsEvent,
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(analytics_events_get)
|
||||
.service(analytics_event_create)
|
||||
.service(analytics_event_edit)
|
||||
.service(analytics_event_delete);
|
||||
cfg.service(analytics_events_get);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct AnalyticsEventUpsert {
|
||||
#[serde(flatten)]
|
||||
pub meta: AnalyticsEventMeta,
|
||||
pub starts: DateTime<Utc>,
|
||||
pub ends: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// List analytics events.
|
||||
/// List analytics events.
|
||||
#[utoipa::path(
|
||||
context_path = "/v3/analytics-event",
|
||||
tag = "v3 analytics", responses((status = OK, body = Vec<AnalyticsEvent>))
|
||||
)]
|
||||
#[get("")]
|
||||
#[get("/analytics-event")]
|
||||
pub async fn analytics_events_get(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -56,158 +30,3 @@ pub async fn analytics_events_get(
|
||||
|
||||
Ok(web::Json(events))
|
||||
}
|
||||
|
||||
/// Create an analytics event.
|
||||
#[utoipa::path(
|
||||
context_path = "/v3/analytics-event",
|
||||
tag = "v3 analytics", responses((status = OK, body = AnalyticsEvent))
|
||||
)]
|
||||
#[post("")]
|
||||
pub async fn analytics_event_create(
|
||||
req: HttpRequest,
|
||||
event: web::Json<AnalyticsEventUpsert>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut transaction = pool
|
||||
.begin()
|
||||
.await
|
||||
.wrap_internal_err("failed to begin transaction")?;
|
||||
let id = generate_analytics_event_id(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to generate analytics event ID")?;
|
||||
|
||||
let event = DBAnalyticsEvent {
|
||||
id,
|
||||
meta: event.meta.clone(),
|
||||
starts: event.starts,
|
||||
ends: event.ends,
|
||||
};
|
||||
event
|
||||
.insert(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to insert analytics event")?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.wrap_internal_err("failed to commit transaction")?;
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(web::Json(event.into()))
|
||||
}
|
||||
|
||||
/// Update an analytics event.
|
||||
#[utoipa::path(
|
||||
context_path = "/v3/analytics-event",
|
||||
tag = "v3 analytics", responses((status = OK, body = AnalyticsEvent))
|
||||
)]
|
||||
#[patch("/{id}")]
|
||||
pub async fn analytics_event_edit(
|
||||
req: HttpRequest,
|
||||
id: web::Path<(AnalyticsEventId,)>,
|
||||
event: web::Json<AnalyticsEventUpsert>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let event = DBAnalyticsEvent {
|
||||
id: DBAnalyticsEventId::from(id.into_inner().0),
|
||||
meta: event.meta.clone(),
|
||||
starts: event.starts,
|
||||
ends: event.ends,
|
||||
};
|
||||
|
||||
let updated = event
|
||||
.update(&**pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to update analytics event")?;
|
||||
if !updated {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(web::Json(event.into()))
|
||||
}
|
||||
|
||||
/// Delete an analytics event.
|
||||
#[utoipa::path(
|
||||
context_path = "/v3/analytics-event",
|
||||
tag = "v3 analytics", responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[delete("/{id}")]
|
||||
pub async fn analytics_event_delete(
|
||||
req: HttpRequest,
|
||||
id: web::Path<(AnalyticsEventId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<(), ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::empty(),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::Auth(eyre!(
|
||||
"you do not have permission to manage analytics events"
|
||||
)));
|
||||
}
|
||||
|
||||
let deleted = DBAnalyticsEvent::remove(
|
||||
DBAnalyticsEventId::from(id.into_inner().0),
|
||||
&**pool,
|
||||
)
|
||||
.await
|
||||
.wrap_internal_err("failed to delete analytics event")?;
|
||||
if !deleted {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
DBAnalyticsEvent::clear_cache(&redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear analytics event cache")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/v3")
|
||||
.wrap(default_cors())
|
||||
.configure(analytics_event::config)
|
||||
.configure(limits::config)
|
||||
.configure(collections::config)
|
||||
.configure(images::config)
|
||||
@@ -80,6 +81,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
description = include_str!("../../api_v3_description.md"),
|
||||
),
|
||||
paths(
|
||||
analytics_event::analytics_events_get,
|
||||
analytics_get::fetch_analytics,
|
||||
analytics_get::facets::fetch_facets,
|
||||
analytics_get::old::playtimes_get,
|
||||
|
||||
@@ -37,6 +37,10 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
|
||||
let search_backend = db.search_backend.clone();
|
||||
let file_host: Arc<dyn FileHost> = Arc::new(file_hosting::MockHost::new());
|
||||
let file_host = web::Data::<dyn FileHost>::from(file_host);
|
||||
clickhouse::create_database(&ENV.CLICKHOUSE_DATABASE)
|
||||
.await
|
||||
.unwrap();
|
||||
clickhouse::run_migrations().await.unwrap();
|
||||
let mut clickhouse = clickhouse::init_client().await.unwrap();
|
||||
|
||||
let stripe_client = stripe::Client::new(ENV.STRIPE_API_KEY.clone());
|
||||
|
||||
@@ -19,6 +19,7 @@ import { KyrosLogsV1Module } from './kyros/logs/v1'
|
||||
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
|
||||
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
|
||||
import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal'
|
||||
import { LabrinthAnalyticsInternalModule } from './labrinth/analytics/internal'
|
||||
import { LabrinthAnalyticsV3Module } from './labrinth/analytics/v3'
|
||||
import { LabrinthAttributionInternalModule } from './labrinth/attribution/internal'
|
||||
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
|
||||
@@ -97,6 +98,7 @@ export const MODULE_REGISTRY = {
|
||||
kyros_logs_v1: KyrosLogsV1Module,
|
||||
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module,
|
||||
labrinth_affiliate_internal: LabrinthAffiliateInternalModule,
|
||||
labrinth_analytics_internal: LabrinthAnalyticsInternalModule,
|
||||
labrinth_analytics_v3: LabrinthAnalyticsV3Module,
|
||||
labrinth_auth_internal: LabrinthAuthInternalModule,
|
||||
labrinth_auth_v2: LabrinthAuthV2Module,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
export class LabrinthAnalyticsInternalModule extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'labrinth_analytics_internal'
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an analytics event.
|
||||
* POST /_internal/analytics-event
|
||||
*/
|
||||
public async createEvent(
|
||||
data: Labrinth.Analytics.Internal.AnalyticsEventUpsert,
|
||||
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
|
||||
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>('/analytics-event', {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an analytics event.
|
||||
* PATCH /_internal/analytics-event/{id}
|
||||
*/
|
||||
public async editEvent(
|
||||
id: Labrinth.Analytics.v3.AnalyticsEventId,
|
||||
data: Labrinth.Analytics.Internal.AnalyticsEventUpsert,
|
||||
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
|
||||
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>(`/analytics-event/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an analytics event.
|
||||
* DELETE /_internal/analytics-event/{id}
|
||||
*/
|
||||
public async deleteEvent(id: Labrinth.Analytics.v3.AnalyticsEventId): Promise<void> {
|
||||
return this.client.request<void>(`/analytics-event/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -70,47 +70,4 @@ export class LabrinthAnalyticsV3Module extends AbstractModule {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an analytics event.
|
||||
* POST /v3/analytics-event
|
||||
*/
|
||||
public async createEvent(
|
||||
data: Labrinth.Analytics.v3.AnalyticsEventUpsert,
|
||||
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
|
||||
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>('/analytics-event', {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'POST',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an analytics event.
|
||||
* PATCH /v3/analytics-event/{id}
|
||||
*/
|
||||
public async editEvent(
|
||||
id: Labrinth.Analytics.v3.AnalyticsEventId,
|
||||
data: Labrinth.Analytics.v3.AnalyticsEventUpsert,
|
||||
): Promise<Labrinth.Analytics.v3.AnalyticsEvent> {
|
||||
return this.client.request<Labrinth.Analytics.v3.AnalyticsEvent>(`/analytics-event/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an analytics event.
|
||||
* DELETE /v3/analytics-event/{id}
|
||||
*/
|
||||
public async deleteEvent(id: Labrinth.Analytics.v3.AnalyticsEventId): Promise<void> {
|
||||
return this.client.request<void>(`/analytics-event/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './analytics/internal'
|
||||
export * from './analytics/v3'
|
||||
export * from './attribution/internal'
|
||||
export * from './auth/internal'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { UploadHandle } from '../../../types/upload'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
export class LabrinthOAuthInternalModule extends AbstractModule {
|
||||
@@ -110,14 +109,14 @@ export class LabrinthOAuthInternalModule extends AbstractModule {
|
||||
* @param id - The OAuth client ID
|
||||
* @param file - The icon file
|
||||
* @param ext - The file extension (e.g. 'png', 'jpeg')
|
||||
* @returns UploadHandle for progress tracking and cancellation
|
||||
*/
|
||||
public uploadAppIcon(id: string, file: File | Blob, ext: string): UploadHandle<void> {
|
||||
return this.client.upload<void>(`/oauth/app/${id}/icon`, {
|
||||
public async uploadAppIcon(id: string, file: File | Blob, ext: string): Promise<void> {
|
||||
return this.client.request(`/oauth/app/${id}/icon`, {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
file,
|
||||
method: 'PATCH',
|
||||
params: { ext },
|
||||
body: file,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -463,6 +463,16 @@ export namespace Labrinth {
|
||||
}
|
||||
|
||||
export namespace Analytics {
|
||||
export namespace Internal {
|
||||
export type AnalyticsEventUpsert = {
|
||||
announcement_url: string | null
|
||||
for_metric_kind: v3.AnalyticsEventMetricKind[] | null
|
||||
title: string
|
||||
ends: string
|
||||
starts: string
|
||||
}
|
||||
}
|
||||
|
||||
export namespace v3 {
|
||||
export type AnalyticsEventId = number
|
||||
export type AnalyticsEventMetricKind = 'views' | 'revenue' | 'downloads' | 'playtime'
|
||||
@@ -476,14 +486,6 @@ export namespace Labrinth {
|
||||
starts: string
|
||||
}
|
||||
|
||||
export type AnalyticsEventUpsert = {
|
||||
announcement_url: string | null
|
||||
for_metric_kind: AnalyticsEventMetricKind[] | null
|
||||
title: string
|
||||
ends: string
|
||||
starts: string
|
||||
}
|
||||
|
||||
export type FetchRequest = {
|
||||
time_range: TimeRange
|
||||
return_metrics: ReturnMetrics
|
||||
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tUPDATE install_jobs\n\t\tSET instance_id = ?, state = ?, modified = ?\n\t\tWHERE id = ?\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "15b4f72d367d329690f5daddafbdf0e51a285e35404053d62492e8df5fc7132f"
|
||||
}
|
||||
@@ -77,6 +77,11 @@ pub async fn remove(instance_id: &str) -> crate::Result<()> {
|
||||
let instance =
|
||||
instance_rows::get_instance_display_info(instance_id, &state.pool)
|
||||
.await?;
|
||||
crate::install::runner::cancel_jobs_for_instance_deletion(
|
||||
instance_id,
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
crate::state::remove_instance(instance_id, &state).await?;
|
||||
|
||||
if let Some(instance) = instance {
|
||||
|
||||
@@ -836,11 +836,29 @@ async fn config_bundle_bytes(
|
||||
entries: &BTreeMap<String, Vec<u8>>,
|
||||
) -> crate::Result<Vec<u8>> {
|
||||
if entries.len() > MAX_CONFIG_BUNDLE_ENTRIES {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Shared instance config bundle contains too many entries"
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
let mut folder_entry_counts = HashMap::new();
|
||||
for path in entries.keys() {
|
||||
if let Some((folder, _)) = path.split_once('/') {
|
||||
*folder_entry_counts.entry(folder).or_insert(0_usize) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((folder, count)) = folder_entry_counts
|
||||
.into_iter()
|
||||
.filter(|(_, count)| *count > MAX_CONFIG_BUNDLE_ENTRIES)
|
||||
.max_by_key(|(_, count)| *count)
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"The \"{folder}\" config folder has too many files to share ({count}; maximum {MAX_CONFIG_BUNDLE_ENTRIES}). Select fewer files from this folder."
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Too many config files were selected to share ({}; maximum {MAX_CONFIG_BUNDLE_ENTRIES}). Select fewer files.",
|
||||
entries.len()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let mut total_size = 0_u64;
|
||||
for bytes in entries.values() {
|
||||
|
||||
@@ -281,6 +281,38 @@ async fn recover_interrupted_job(
|
||||
if job.state.display.is_none() {
|
||||
job.state.display = display_from_request(&job.state);
|
||||
}
|
||||
|
||||
if let Some(instance_id) = target_instance_id(&job.state.target)
|
||||
&& instance_rows::get_instance_by_id(instance_id, &state.pool)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
let canceled_phase = job.state.progress.phase;
|
||||
job.state.error = Some(InstallErrorView::from_message(
|
||||
"canceled",
|
||||
canceled_phase,
|
||||
"Install canceled because the instance was deleted",
|
||||
));
|
||||
job.state.record_event(InstallJobEventKind::JobCanceled {
|
||||
phase: canceled_phase,
|
||||
});
|
||||
|
||||
if let Some(record) = store::finish_active(
|
||||
job.id,
|
||||
InstallJobStatus::Canceled,
|
||||
&job.state,
|
||||
state,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
store::dismiss(job.id, state).await?;
|
||||
clear_staging_dir(&job.state).await;
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let interrupted_phase = job.state.progress.phase;
|
||||
job.state.record_event(InstallJobEventKind::Interrupted {
|
||||
reason: InstallInterruptReason::AppClosed,
|
||||
@@ -341,6 +373,13 @@ async fn recover_interrupted_job(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn target_instance_id(target: &InstallTarget) -> Option<&str> {
|
||||
match target {
|
||||
InstallTarget::NewInstance { instance_id } => instance_id.as_deref(),
|
||||
InstallTarget::ExistingInstance { instance_id } => Some(instance_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_deleted_new_instance_id(job_state: &mut InstallJobState) {
|
||||
if matches!(job_state.cleanup, InstallCleanup::DeleteNewInstance { .. }) {
|
||||
job_state.target = InstallTarget::NewInstance { instance_id: None };
|
||||
|
||||
@@ -295,6 +295,39 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
|
||||
Ok(record.snapshot())
|
||||
}
|
||||
|
||||
pub(crate) async fn cancel_jobs_for_instance_deletion(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
for mut job in store::list_active_for_instance(instance_id, state).await? {
|
||||
let canceled_phase = job.state.progress.phase;
|
||||
job.state.error = Some(InstallErrorView::from_message(
|
||||
"canceled",
|
||||
canceled_phase,
|
||||
"Install canceled because the instance was deleted",
|
||||
));
|
||||
job.state.record_event(InstallJobEventKind::JobCanceled {
|
||||
phase: canceled_phase,
|
||||
});
|
||||
|
||||
let Some(record) = store::finish_active(
|
||||
job.id,
|
||||
InstallJobStatus::Canceled,
|
||||
&job.state,
|
||||
state,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
store::dismiss(job.id, state).await?;
|
||||
emit_install_job(&record.snapshot()).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn dismiss_job(job_id: Uuid) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
store::dismiss(job_id, &state).await
|
||||
@@ -577,7 +610,11 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
|
||||
|
||||
let result = Box::pin(run_request(job_id, &mut job_state, &state)).await;
|
||||
if let Ok(record) = store::get_required(job_id, &state).await {
|
||||
let status = record.status;
|
||||
job_state = record.state;
|
||||
if status != InstallJobStatus::Running {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let result = match result {
|
||||
|
||||
@@ -201,6 +201,17 @@ pub async fn list_interrupted_candidates(
|
||||
Ok(deserialize_rows(rows))
|
||||
}
|
||||
|
||||
pub async fn list_active_for_instance(
|
||||
instance_id: &str,
|
||||
app_state: &State,
|
||||
) -> crate::Result<Vec<InstallJobRecord>> {
|
||||
Ok(list_interrupted_candidates(app_state)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|job| job.instance_id.as_deref() == Some(instance_id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn update_state(
|
||||
id: Uuid,
|
||||
state: &InstallJobState,
|
||||
@@ -212,20 +223,30 @@ pub async fn update_state(
|
||||
let id_value = id.to_string();
|
||||
let modified = now.timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
let result = sqlx::query(
|
||||
"
|
||||
UPDATE install_jobs
|
||||
SET instance_id = ?, state = ?, modified = ?
|
||||
WHERE id = ?
|
||||
SET
|
||||
instance_id = (SELECT id FROM instances WHERE id = ?),
|
||||
state = ?,
|
||||
modified = ?
|
||||
WHERE id = ? AND status IN ('queued', 'running')
|
||||
",
|
||||
instance_id,
|
||||
json,
|
||||
modified,
|
||||
id_value,
|
||||
)
|
||||
.bind(instance_id)
|
||||
.bind(json)
|
||||
.bind(modified)
|
||||
.bind(id_value)
|
||||
.execute(&app_state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Install job {id} is no longer active"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
get_required(id, app_state).await
|
||||
}
|
||||
|
||||
@@ -319,7 +340,12 @@ pub async fn finish_active(
|
||||
let result = sqlx::query(
|
||||
"
|
||||
UPDATE install_jobs
|
||||
SET instance_id = ?, status = ?, state = ?, modified = ?, finished = ?
|
||||
SET
|
||||
instance_id = (SELECT id FROM instances WHERE id = ?),
|
||||
status = ?,
|
||||
state = ?,
|
||||
modified = ?,
|
||||
finished = ?
|
||||
WHERE id = ? AND status IN ('queued', 'running')
|
||||
",
|
||||
)
|
||||
@@ -356,19 +382,24 @@ pub async fn complete_success(
|
||||
let mut transaction = app_state.pool.begin().await?;
|
||||
|
||||
let job_result = sqlx::query(
|
||||
"
|
||||
"
|
||||
UPDATE install_jobs
|
||||
SET instance_id = ?, status = 'succeeded', state = ?, modified = ?, finished = ?
|
||||
SET
|
||||
instance_id = (SELECT id FROM instances WHERE id = ?),
|
||||
status = 'succeeded',
|
||||
state = ?,
|
||||
modified = ?,
|
||||
finished = ?
|
||||
WHERE id = ? AND status = 'running'
|
||||
",
|
||||
)
|
||||
.bind(&instance_id)
|
||||
.bind(json)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.bind(id_value)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
)
|
||||
.bind(&instance_id)
|
||||
.bind(json)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.bind(id_value)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if job_result.rows_affected() == 0 {
|
||||
transaction.rollback().await?;
|
||||
|
||||
@@ -55,8 +55,12 @@ pub(crate) async fn create_instance(
|
||||
None
|
||||
};
|
||||
|
||||
let icon_path =
|
||||
resolve_icon_path(input.icon_path.as_deref(), state).await?;
|
||||
let icon_path = resolve_icon_path(
|
||||
input.icon_path.as_deref(),
|
||||
matches!(&input.link, InstanceLink::SharedInstance { .. }),
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
let now = Utc::now();
|
||||
let instance_id = format!("local:{}", Uuid::new_v4());
|
||||
let content_set_id = format!("content-set:{}", Uuid::new_v4());
|
||||
@@ -168,6 +172,7 @@ async fn path_available(
|
||||
|
||||
async fn resolve_icon_path(
|
||||
icon_path: Option<&str>,
|
||||
ignore_missing_remote_icon: bool,
|
||||
state: &State,
|
||||
) -> crate::Result<Option<String>> {
|
||||
let Some(icon) = icon_path else {
|
||||
@@ -175,7 +180,7 @@ async fn resolve_icon_path(
|
||||
};
|
||||
|
||||
let file = if icon.starts_with("https://") || icon.starts_with("http://") {
|
||||
let bytes = fetch::fetch(
|
||||
let bytes = match fetch::fetch(
|
||||
icon,
|
||||
None,
|
||||
None,
|
||||
@@ -183,7 +188,16 @@ async fn resolve_icon_path(
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(bytes) => bytes,
|
||||
Err(error)
|
||||
if ignore_missing_remote_icon && is_not_found_error(&error) =>
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
crate::api::instance::cache_icon(bytes, state).await?
|
||||
} else {
|
||||
crate::api::instance::cache_icon_from_path(
|
||||
@@ -196,6 +210,18 @@ async fn resolve_icon_path(
|
||||
Ok(Some(file.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
fn is_not_found_error(error: &crate::Error) -> bool {
|
||||
match error.raw.as_ref() {
|
||||
crate::ErrorKind::FetchError(error) => {
|
||||
error.status() == Some(reqwest::StatusCode::NOT_FOUND)
|
||||
}
|
||||
crate::ErrorKind::LabrinthError(error) => {
|
||||
error.status == Some(reqwest::StatusCode::NOT_FOUND.as_u16())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn content_source_kind(link: &InstanceLink) -> ContentSourceKind {
|
||||
match link {
|
||||
InstanceLink::Unmanaged => ContentSourceKind::Local,
|
||||
|
||||
@@ -65,6 +65,7 @@ pub enum FeatureFlag {
|
||||
SkipNonEssentialWarnings,
|
||||
AdvancedFiltersCollapsed,
|
||||
AlwaysShowCopyDetails,
|
||||
HideInstalledModpacks,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
|
||||
@@ -66,7 +66,6 @@ import _BugIcon from './icons/bug.svg?component'
|
||||
import _CalendarIcon from './icons/calendar.svg?component'
|
||||
import _CalendarArrowDownIcon from './icons/calendar-arrow-down.svg?component'
|
||||
import _CardIcon from './icons/card.svg?component'
|
||||
import _ChangeSkinIcon from './icons/change-skin.svg?component'
|
||||
import _ChartIcon from './icons/chart.svg?component'
|
||||
import _ChartAreaIcon from './icons/chart-area.svg?component'
|
||||
import _ChartColumnBigIcon from './icons/chart-column-big.svg?component'
|
||||
@@ -499,7 +498,6 @@ export const BugIcon = _BugIcon
|
||||
export const CalendarIcon = _CalendarIcon
|
||||
export const CalendarArrowDownIcon = _CalendarArrowDownIcon
|
||||
export const CardIcon = _CardIcon
|
||||
export const ChangeSkinIcon = _ChangeSkinIcon
|
||||
export const ChartIcon = _ChartIcon
|
||||
export const ChartAreaIcon = _ChartAreaIcon
|
||||
export const ChartColumnBigIcon = _ChartColumnBigIcon
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" transform="scale(-1 1)" viewBox="0 0 49.915 52.72">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4.331" d="M15.71 31.484v19.07h18.63v-19.07l6.538 6.539 6.871-6.872-11.203-11.733H14.122L2.166 31.375l6.827 6.827z"/>
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.993" d="M24.872 19.548v-6.44"/>
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4.331" d="M24.704 13.202a5.518 5.518 0 0 1-5.518-5.518 5.518 5.518 0 0 1 5.518-5.518 5.518 5.518 0 0 1 5.518 5.518"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 695 B |
@@ -10,6 +10,127 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2026-07-31T06:10:23+00:00`,
|
||||
product: 'web',
|
||||
body: `## Fixed
|
||||
- Fixed randomly getting signed out of Modrinth account due to random non-auth related errors.`,
|
||||
},
|
||||
{
|
||||
date: `2026-07-29T21:32:06+00:00`,
|
||||
product: 'app',
|
||||
version: '0.17.3',
|
||||
body: `## Added
|
||||
- Added button to create a new instance on the Library page.
|
||||
|
||||
## Changed
|
||||
- Added a toggle to hide modpacks that are already installed.
|
||||
- Added tooltip to installation settings button in installed modpack card.
|
||||
|
||||
## Fixed
|
||||
- Improved error when shared instances reach a config file limit.
|
||||
- When pushing updates to shared instances, will now scroll to the top when entering a sub-page.
|
||||
- Fixed instance installations getting stuck when the instance is deleted.
|
||||
- Fixed error when failing to fetch a shared instance icon.
|
||||
- Installing content from Discover content will now install the latest that matches the game version and loader filters, not the absolute latest.`,
|
||||
},
|
||||
{
|
||||
date: `2026-07-29T21:32:06+00:00`,
|
||||
product: 'web',
|
||||
body: `## Changed
|
||||
- Incomplete current day revenue hides that day's line segment instead of showing a dip to \$0.
|
||||
- Updated the modal for creating OAuth applications.
|
||||
|
||||
## Fixed
|
||||
- Fixed dependencies in project download modal could give dependency with wrong Minecraft version.
|
||||
- Fixed PATS page new generated tokens invalidating in the same session.`,
|
||||
},
|
||||
{
|
||||
date: `2026-07-28T23:36:10+00:00`,
|
||||
product: 'web',
|
||||
body: `## Changed
|
||||
- Added message to legacy moderation threads showing that there may be undocumented moderation history.
|
||||
|
||||
## Fixed
|
||||
- Fixed OAuth application icon upload not working.
|
||||
- Fixed moderation messages not showing.`,
|
||||
},
|
||||
{
|
||||
date: `2026-07-28T23:36:10+00:00`,
|
||||
product: 'app',
|
||||
version: '0.17.2',
|
||||
body: `## Fixed
|
||||
- Fixed broken shared instances invite management table.`,
|
||||
},
|
||||
{
|
||||
date: `2026-07-28T20:40:11+00:00`,
|
||||
product: 'app',
|
||||
version: '0.17.1',
|
||||
body: `## Added
|
||||
- Added user pages.
|
||||
- Added a banner for users of a shared instance which let's them know that they need to review an update to play the instance - alongside the existing checks when you click Play.
|
||||
- Added a way to see and manage the invite links you have created for a shared instance in the Sharing tab of the Instance Settings modal.
|
||||
- Added a way to see where a shared instance content is coming from when in the Install to play modal, either the linked modpack or if it was added on top. Content which is a part of the linked modpack will show the modpack information underneath it's name.
|
||||
- Added user blocking
|
||||
- You can block users on their profile page.
|
||||
- You can block users when reporting a shared instance. This prevents the user from sending you invites to shared instances and Modrinth Hosting server panels.
|
||||
- You can manage who you've blocked in the new Social settings in the app's Settings menu, or on the Modrinth website.
|
||||
- Added the ability to edit your Modrinth profile in the app's Settings menu.
|
||||
- Added the ability to adjust the amount of quick instances shown in the sidebar by dragging the divider up and down.
|
||||
- Added an option to always show "Copy details" on the installation job notifications, rather than just on failed and interrupted instance install jobs.
|
||||
- Clicking on friends in the friends list will take you to their profile page.
|
||||
|
||||
## Changed
|
||||
|
||||
- More than three instances now show up in the left sidebar's quick instance selection area for larger window sizes.
|
||||
- Updated Modrinth App logo to just use the standard Modrinth logo to save space.
|
||||
- Updated the design of the back/forward buttons.
|
||||
- Limited shared instances to 50 users.
|
||||
- Changed the expiry date picker in the shared instance invite edit modal to be a dropdown of common dates, rather than a complicated date picker. You can still use the fine-grained date picker by choosing "Custom"
|
||||
- Instance icons must now be smaller than 4MB - any existing instances will have their icons compressed to 512x512px size to conform to the new limit. This will break any instances which have .GIF icons.
|
||||
- Split up the App Settings modal into categories.
|
||||
- Moved out behavioural settings into it's own subpage, rather than being in Appearance settings.
|
||||
- Updated "Advanced" toggle filter design to be the same as the other filters, just with only an exclude button as the primary action.
|
||||
- Re-aligned the traffic light buttons on macOS with the top bar.
|
||||
- Updated translations. Want to help translate the Modrinth App? [Click here](https://translate.modrinth.com)
|
||||
- **Modrinth Hosting:** Updated translations. Want to help translate Modrinth Hosting? [Click here](https://translate.modrinth.com)
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Modrinth Hosting:** Fixed issue where when browsing content for your server panel, when visiting a project page and going back to search, it would reset the page back to one.
|
||||
- Fixed issue where when browsing content for an instance, when visiting a project page and going back to search, it would reset the page back to one.
|
||||
- Refactored how breadcrumbs work in the app, this should solve many issues you might have encountered using the back and forward navigation buttons in the app header.
|
||||
- Fixed error spam when the shared instances API is not accessible, it will cleanly provide feedback that the app cannot connect.`,
|
||||
},
|
||||
{
|
||||
date: `2026-07-28T20:40:11+00:00`,
|
||||
product: 'web',
|
||||
body: `## Added
|
||||
- You can now block users on their profile page. This prevents the user from sending you invites to shared instances and Modrinth Hosting server panels.
|
||||
- Added new social settings page, where you can manage users you have blocked and in the future set who can send you friend requests and invitations to shared instances and Modrinth Hosting server panels.
|
||||
|
||||
## Changed
|
||||
- Cleaned up the layout of and renamed the Public profile settings page to Profile settings.
|
||||
- Updated "Advanced" toggle filter design to be the same as the other filters, just with only an exclude button as the primary action.
|
||||
- Updated translations. Want to help translate Modrinth's website? [Click here](https://translate.modrinth.com)
|
||||
|
||||
## Fixed
|
||||
- Fixed profile pictures still being deleted after resetting a pending removal in Profile settings.
|
||||
- Fixed shared instance reports not showing up in the dashboard's Reports page.
|
||||
- Fixed shared instance report emails saying "Unknown" rather than the shared instance's name.
|
||||
- Fixed the settings page for Authorized apps being broken.
|
||||
- Broken Retro theme colors
|
||||
- Improved vertical alignment of status indicators in notifications.`,
|
||||
},
|
||||
{
|
||||
date: `2026-07-28T20:40:11+00:00`,
|
||||
product: 'hosting',
|
||||
body: `## Changed
|
||||
- Updated translations. Want to help translate Modrinth Hosting? [Click here](https://translate.modrinth.com)
|
||||
|
||||
## Fixed
|
||||
- Fixed issue where when browsing content for your server panel, when visiting a project page and going back to search, it would reset the page back to one.`,
|
||||
},
|
||||
{
|
||||
date: `2026-07-26T19:06:47+00:00`,
|
||||
product: 'web',
|
||||
|
||||
@@ -198,6 +198,9 @@ async function nudge(): Promise<void> {
|
||||
}
|
||||
|
||||
defineExpose({ nudge })
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -205,6 +208,7 @@ defineExpose({ nudge })
|
||||
<Transition name="floating-action-bar" appear>
|
||||
<div
|
||||
v-if="shown"
|
||||
v-bind="$attrs"
|
||||
ref="barEl"
|
||||
class="floating-action-bar drop-shadow-2xl"
|
||||
:class="inline ? 'floating-action-bar--inline z-10' : 'fixed bottom-0 p-4'"
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
<template>
|
||||
<div v-if="shown">
|
||||
<div
|
||||
:class="{ shown: actuallyShown }"
|
||||
class="tauri-overlay"
|
||||
data-tauri-drag-region
|
||||
@click="() => (closable ? hide() : {})"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
shown: actuallyShown,
|
||||
noblur: props.noblur,
|
||||
}"
|
||||
class="modal-overlay"
|
||||
@click="() => (closable ? hide() : {})"
|
||||
/>
|
||||
<div class="modal-container" :class="{ shown: actuallyShown }">
|
||||
<div class="modal-body">
|
||||
<div v-if="props.header" class="header">
|
||||
<h1>{{ props.header }}</h1>
|
||||
<button v-if="closable" class="btn icon-only transparent" @click="hide">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
noblur: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
onHide: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const shown = ref(false)
|
||||
const actuallyShown = ref(false)
|
||||
|
||||
function show() {
|
||||
shown.value = true
|
||||
setTimeout(() => {
|
||||
actuallyShown.value = true
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
props.onHide?.()
|
||||
actuallyShown.value = false
|
||||
setTimeout(() => {
|
||||
shown.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tauri-overlay {
|
||||
position: fixed;
|
||||
visibility: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
z-index: 20;
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
visibility: hidden;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 19;
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
background: hsla(0, 0%, 0%, 0.5);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
&.noblur {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 21;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
&.shown {
|
||||
visibility: visible;
|
||||
.modal-body {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
position: fixed;
|
||||
box-shadow: var(--shadow-raised), var(--shadow-inset);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--color-raised-bg);
|
||||
max-height: calc(100% - 2 * var(--gap-lg));
|
||||
overflow-y: visible;
|
||||
width: 600px;
|
||||
pointer-events: auto;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--color-bg);
|
||||
padding: var(--gap-md) var(--gap-lg);
|
||||
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
transform: translateY(50vh);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all 0.25s ease-in-out;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
width: calc(100% - 2 * var(--gap-lg));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user