mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
25
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 |
@@ -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
|
||||
@@ -92,7 +92,6 @@ jobs:
|
||||
run: corepack enable
|
||||
|
||||
- name: Set up caches
|
||||
if: contains(matrix.artifact-target-name, 'linux') || contains(matrix.artifact-target-name, 'darwin')
|
||||
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
|
||||
with:
|
||||
cache: |
|
||||
@@ -100,7 +99,6 @@ jobs:
|
||||
pnpm
|
||||
|
||||
- name: Configure sccache
|
||||
if: contains(matrix.artifact-target-name, 'linux') || contains(matrix.artifact-target-name, 'darwin')
|
||||
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate tauri-dev.conf.json
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -260,6 +260,9 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
preferencesDiffer,
|
||||
provideBrowseManager,
|
||||
requestInstall,
|
||||
resolveInstallPlan,
|
||||
stripServerRuntimeInstallFilters,
|
||||
stripServerRuntimeInstallOverrides,
|
||||
useBrowseSearch,
|
||||
@@ -50,6 +51,7 @@ 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'
|
||||
@@ -171,7 +173,7 @@ const instance: Ref<Instance | null> = ref(
|
||||
queryClient.getQueryData<Instance>(['instances', 'summary', initialInstanceId]) ?? null,
|
||||
)
|
||||
const installedProjectIds: Ref<string[] | null> = ref(null)
|
||||
const instanceHideInstalled = ref(false)
|
||||
const instanceHideInstalled = ref(route.query.ai === 'true')
|
||||
const newlyInstalled = ref<string[]>([])
|
||||
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
|
||||
const hiddenInstanceProjectIdsInitialized = ref(false)
|
||||
@@ -288,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)
|
||||
@@ -318,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
|
||||
@@ -327,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(
|
||||
@@ -341,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 = []
|
||||
|
||||
@@ -367,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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,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) {
|
||||
@@ -500,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',
|
||||
@@ -553,6 +588,7 @@ function resetInstanceContext() {
|
||||
hiddenInstanceProjectIdsInitialized.value = false
|
||||
isServerInstance.value = false
|
||||
browseBreadcrumb.reset()
|
||||
void refreshInstalledProjectIds()
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -763,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,
|
||||
@@ -781,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 &&
|
||||
@@ -790,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
|
||||
@@ -810,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 () => {
|
||||
@@ -875,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 () => {
|
||||
@@ -891,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
|
||||
@@ -994,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)
|
||||
}
|
||||
|
||||
@@ -1057,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()
|
||||
}
|
||||
},
|
||||
@@ -1091,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()
|
||||
@@ -1156,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()
|
||||
@@ -1168,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,
|
||||
|
||||
@@ -42,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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+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 || [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "上傳圖示"
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+48
-13
@@ -10,6 +10,41 @@ 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',
|
||||
@@ -71,21 +106,21 @@ const VERSIONS: VersionEntry[] = [
|
||||
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.
|
||||
- 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)
|
||||
## 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.`,
|
||||
## 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`,
|
||||
|
||||
@@ -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>
|
||||
@@ -5,7 +5,8 @@
|
||||
:class="{ shown: visible }"
|
||||
class="tauri-overlay"
|
||||
data-tauri-drag-region
|
||||
@click="() => (closeOnClickOutside && closable ? hide() : {})"
|
||||
@pointerdown="onTauriOverlayPointerDown"
|
||||
@click="onTauriOverlayClick"
|
||||
/>
|
||||
<div
|
||||
:class="[
|
||||
@@ -218,6 +219,30 @@ const props = withDefaults(
|
||||
|
||||
const effectiveNoblur = computed(() => props.noblur ?? modalBehavior?.noblur.value ?? false)
|
||||
|
||||
const TAURI_DRAG_THRESHOLD_PX = 4
|
||||
let tauriPointerScreen: { x: number; y: number } | null = null
|
||||
|
||||
function onTauriOverlayPointerDown(event: PointerEvent) {
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
tauriPointerScreen = { x: event.screenX, y: event.screenY }
|
||||
}
|
||||
|
||||
function onTauriOverlayClick(event: MouseEvent) {
|
||||
const start = tauriPointerScreen
|
||||
tauriPointerScreen = null
|
||||
if (
|
||||
start &&
|
||||
Math.hypot(event.screenX - start.x, event.screenY - start.y) >= TAURI_DRAG_THRESHOLD_PX
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (props.closeOnClickOutside && props.closable && !props.disableClose) {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
const computedFade = computed(() => {
|
||||
if (props.fade) return props.fade
|
||||
if (props.danger) return 'danger'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export { default as ConfirmLeaveModal } from './ConfirmLeaveModal.vue'
|
||||
export { default as ConfirmModal } from './ConfirmModal.vue'
|
||||
export { default as Modal } from './Modal.vue'
|
||||
export { default as NewModal } from './NewModal.vue'
|
||||
export type { ServerProject as OpenInAppModalServerProject } from './OpenInAppModal.vue'
|
||||
export { default as OpenInAppModal } from './OpenInAppModal.vue'
|
||||
|
||||
@@ -21,7 +21,7 @@ import OverflowMenu, {
|
||||
import TagTagItem from '#ui/components/base/TagTagItem.vue'
|
||||
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
|
||||
import { useRelativeTime } from '#ui/composables/how-ago'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type {
|
||||
@@ -33,6 +33,13 @@ import type {
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
installationSettingsTooltip: {
|
||||
id: 'content.modpack-card.installation-settings',
|
||||
defaultMessage: 'Installation settings',
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
project: ContentModpackCardProject
|
||||
projectLink?: string | RouteLocationRaw
|
||||
@@ -218,7 +225,10 @@ onUnmounted(() => {
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled v-if="hasSettingsListener" type="outlined" circular>
|
||||
<button @click="emit('settings')">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.installationSettingsTooltip)"
|
||||
@click="emit('settings')"
|
||||
>
|
||||
<Settings2Icon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -244,7 +254,7 @@ onUnmounted(() => {
|
||||
</template>
|
||||
<template #settings>
|
||||
<Settings2Icon class="size-5" />
|
||||
{{ formatMessage(commonMessages.settingsLabel) }}
|
||||
{{ formatMessage(messages.installationSettingsTooltip) }}
|
||||
</template>
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
|
||||
@@ -566,6 +566,9 @@
|
||||
"content.inline-backup.world-label": {
|
||||
"defaultMessage": "world"
|
||||
},
|
||||
"content.modpack-card.installation-settings": {
|
||||
"defaultMessage": "Installation settings"
|
||||
},
|
||||
"content.page-layout.additional-content": {
|
||||
"defaultMessage": "Additional content"
|
||||
},
|
||||
|
||||
@@ -153,9 +153,9 @@ For components that need user interaction to show:
|
||||
```typescript
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { Modal, ButtonStyled },
|
||||
components: { NewModal, ButtonStyled },
|
||||
setup() {
|
||||
const modalRef = ref<InstanceType<typeof Modal> | null>(null)
|
||||
const modalRef = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
return { modalRef }
|
||||
},
|
||||
template: /* html */ `
|
||||
@@ -163,9 +163,9 @@ export const Default: Story = {
|
||||
<ButtonStyled @click="modalRef?.show()">
|
||||
<button>Open Modal</button>
|
||||
</ButtonStyled>
|
||||
<Modal ref="modalRef" header="Example Modal">
|
||||
<NewModal ref="modalRef" header="Example Modal">
|
||||
<p>Modal content</p>
|
||||
</Modal>
|
||||
</NewModal>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user