mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73ed1bc249 |
@@ -22,7 +22,6 @@ node_modules
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/i18n-ally-custom-framework.yml
|
||||
|
||||
# IDE - IntelliJ
|
||||
.idea/*
|
||||
|
||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["esbenp.prettier-vscode", "Vue.volar", "rust-lang.rust-analyzer", "lokalise.i18n-ally"]
|
||||
"recommendations": ["esbenp.prettier-vscode", "Vue.volar", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
languageIds:
|
||||
- vue
|
||||
- typescript
|
||||
- javascript
|
||||
- typescriptreact
|
||||
|
||||
usageMatchRegex:
|
||||
- id:\s*['"]({key})['"]
|
||||
|
||||
monopoly: true
|
||||
Vendored
+2
-17
@@ -1,12 +1,7 @@
|
||||
{
|
||||
"prettier.endOfLine": "lf",
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact"
|
||||
],
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
||||
"editor.detectIndentation": false,
|
||||
"editor.insertSpaces": false,
|
||||
"files.eol": "\n",
|
||||
@@ -36,15 +31,5 @@
|
||||
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
||||
},
|
||||
"css.lint.unknownAtRules": "ignore",
|
||||
"scss.lint.unknownAtRules": "ignore",
|
||||
"i18n-ally.localesPaths": [
|
||||
"packages/ui/src/locales",
|
||||
"apps/frontend/src/locales",
|
||||
"packages/moderation/src/locales"
|
||||
],
|
||||
"i18n-ally.pathMatcher": "{locale}/index.{ext}",
|
||||
"i18n-ally.keystyle": "flat",
|
||||
"i18n-ally.sourceLanguage": "en-US",
|
||||
"i18n-ally.namespace": false,
|
||||
"i18n-ally.includeSubfolders": true
|
||||
"scss.lint.unknownAtRules": "ignore"
|
||||
}
|
||||
|
||||
@@ -44,8 +44,7 @@
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^10.0.0",
|
||||
"vue-router": "^4.6.0",
|
||||
"vue-virtual-scroller": "v2.0.0-beta.8",
|
||||
"vuedraggable": "^4.1.0"
|
||||
"vue-virtual-scroller": "v2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.1.1",
|
||||
|
||||
@@ -148,6 +148,7 @@ import { arrayBufferToBase64 } from '@modrinth/utils'
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import {
|
||||
add_and_equip_custom_skin,
|
||||
type Cape,
|
||||
determineModelType,
|
||||
equip_skin,
|
||||
@@ -439,22 +440,9 @@ async function save() {
|
||||
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
|
||||
|
||||
if (mode.value === 'new') {
|
||||
const addedSkin = await save_custom_skin(
|
||||
{
|
||||
texture_key: '',
|
||||
variant: variant.value,
|
||||
cape_id: selectedCape.value?.id,
|
||||
texture: textureUrl,
|
||||
source: 'custom',
|
||||
is_equipped: false,
|
||||
},
|
||||
bytes,
|
||||
variant.value,
|
||||
selectedCape.value,
|
||||
true,
|
||||
)
|
||||
const addedSkin = await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
emit('saved', {
|
||||
applied: false,
|
||||
applied: true,
|
||||
skin: addedSkin,
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import type { Skin } from '@/helpers/skins.ts'
|
||||
@@ -83,14 +82,12 @@ const props = defineProps<{
|
||||
isSkinSelected: (skin: Skin) => boolean
|
||||
isSkinActive: (skin: Skin) => boolean
|
||||
isAddSkinButtonDragActive: boolean
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [skin: Skin]
|
||||
edit: [skin: Skin, event: MouseEvent]
|
||||
delete: [skin: Skin]
|
||||
'reorder-saved-skins': [skins: Skin[]]
|
||||
'add-skin': []
|
||||
'add-skin-dragenter': [event: DragEvent]
|
||||
'add-skin-dragover': [event: DragEvent]
|
||||
@@ -156,11 +153,6 @@ const sections = computed<SkinSection[]>(() => [
|
||||
})),
|
||||
])
|
||||
|
||||
const draggableSavedSkins = ref<Skin[]>([])
|
||||
const isDraggingSavedSkin = ref(false)
|
||||
const canReorderSavedSkins = computed(() => draggableSavedSkins.value.length > 1)
|
||||
const fixedSavedSkins = computed(() => props.savedSkins.filter((skin) => !canDragSavedSkin(skin)))
|
||||
|
||||
const sectionLayouts = computed(() => {
|
||||
const layouts: Array<{ section: SkinSection; top: number; height: number; index: number }> = []
|
||||
let top = 0
|
||||
@@ -217,18 +209,6 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.savedSkins,
|
||||
(nextSkins) => {
|
||||
if (isDraggingSavedSkin.value) {
|
||||
return
|
||||
}
|
||||
|
||||
draggableSavedSkins.value = nextSkins.filter(canDragSavedSkin)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
listWidth,
|
||||
(width) => {
|
||||
@@ -277,40 +257,6 @@ function skinKey(skin: Skin, prefix: string) {
|
||||
return `${prefix}-${skin.source}-${skin.texture_key}-${skin.variant}-${skin.cape_id ?? 'no-cape'}`
|
||||
}
|
||||
|
||||
function savedSkinKey(skin: Skin) {
|
||||
return skinKey(skin, 'saved-skin')
|
||||
}
|
||||
|
||||
function canDragSavedSkin(skin: Skin) {
|
||||
return skin.source === 'custom' || skin.source === 'custom_external'
|
||||
}
|
||||
|
||||
function doSkinOrdersMatch(firstSkins: Skin[], secondSkins: Skin[]) {
|
||||
const draggableSecondSkins = secondSkins.filter(canDragSavedSkin)
|
||||
|
||||
return (
|
||||
firstSkins.length === draggableSecondSkins.length &&
|
||||
firstSkins.every(
|
||||
(skin, index) => savedSkinKey(skin) === savedSkinKey(draggableSecondSkins[index]),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function onSavedSkinDragStart() {
|
||||
isDraggingSavedSkin.value = true
|
||||
}
|
||||
|
||||
function onSavedSkinDragEnd() {
|
||||
isDraggingSavedSkin.value = false
|
||||
|
||||
if (doSkinOrdersMatch(draggableSavedSkins.value, props.savedSkins)) {
|
||||
draggableSavedSkins.value = props.savedSkins.filter(canDragSavedSkin)
|
||||
return
|
||||
}
|
||||
|
||||
emit('reorder-saved-skins', [...draggableSavedSkins.value])
|
||||
}
|
||||
|
||||
function isSectionOpen(key: string) {
|
||||
return openSectionKeys.value.has(key)
|
||||
}
|
||||
@@ -408,140 +354,36 @@ defineExpose({ getAddSkinButtonElement })
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<Draggable
|
||||
v-if="section.kind === 'saved'"
|
||||
:list="draggableSavedSkins"
|
||||
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
|
||||
:item-key="savedSkinKey"
|
||||
:disabled="readOnly || !canReorderSavedSkins"
|
||||
:animation="250"
|
||||
:swap-threshold="1"
|
||||
:invert-swap="false"
|
||||
:force-fallback="true"
|
||||
:fallback-on-body="true"
|
||||
:fallback-tolerance="4"
|
||||
ghost-class="skin-reorder-ghost"
|
||||
chosen-class="skin-reorder-chosen"
|
||||
drag-class="skin-reorder-drag"
|
||||
fallback-class="skin-reorder-fallback"
|
||||
@start="onSavedSkinDragStart"
|
||||
@end="onSavedSkinDragEnd"
|
||||
>
|
||||
<template #header>
|
||||
<SkinLikeTextButton
|
||||
ref="addSkinButton"
|
||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
dropzone
|
||||
:disabled="readOnly"
|
||||
:drag-active="!readOnly && isAddSkinButtonDragActive"
|
||||
@click="emit('add-skin')"
|
||||
@dragenter="emit('add-skin-dragenter', $event)"
|
||||
@dragover="emit('add-skin-dragover', $event)"
|
||||
@dragleave="emit('add-skin-dragleave', $event)"
|
||||
@drop="emit('add-skin-drop', $event)"
|
||||
>
|
||||
<template #icon>
|
||||
<PlusIcon class="size-8" />
|
||||
</template>
|
||||
{{ formatMessage(messages.addSkinButton) }}
|
||||
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
|
||||
</SkinLikeTextButton>
|
||||
</template>
|
||||
|
||||
<template #item="{ element: skin }">
|
||||
<div
|
||||
:key="savedSkinKey(skin)"
|
||||
class="relative aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
>
|
||||
<SkinButton
|
||||
class="h-full w-full min-w-0 box-border rounded-[20px]"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
:disabled="readOnly"
|
||||
:is-dragging="isDraggingSavedSkin"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<template v-if="!readOnly" #overlay-buttons>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:aria-label="formatMessage(messages.editSkinButton)"
|
||||
class="pointer-events-auto"
|
||||
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
|
||||
>
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.deleteSkinButton)"
|
||||
:aria-label="formatMessage(messages.deleteSkinButton)"
|
||||
class="!rounded-[100%] pointer-events-auto"
|
||||
@click.stop="emit('delete', skin)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</SkinButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div
|
||||
v-for="skin in fixedSavedSkins"
|
||||
:key="savedSkinKey(skin)"
|
||||
class="relative aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
>
|
||||
<SkinButton
|
||||
class="h-full w-full min-w-0 box-border rounded-[20px]"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
:disabled="readOnly"
|
||||
:is-dragging="isDraggingSavedSkin"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<template v-if="!readOnly" #overlay-buttons>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:aria-label="formatMessage(messages.editSkinButton)"
|
||||
class="pointer-events-auto"
|
||||
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
|
||||
>
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.deleteSkinButton)"
|
||||
:aria-label="formatMessage(messages.deleteSkinButton)"
|
||||
class="!rounded-[100%] pointer-events-auto"
|
||||
@click.stop="emit('delete', skin)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</SkinButton>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-if="section.kind === 'saved'"
|
||||
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
|
||||
>
|
||||
<SkinLikeTextButton
|
||||
ref="addSkinButton"
|
||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
dropzone
|
||||
:drag-active="isAddSkinButtonDragActive"
|
||||
@click="emit('add-skin')"
|
||||
@dragenter="emit('add-skin-dragenter', $event)"
|
||||
@dragover="emit('add-skin-dragover', $event)"
|
||||
@dragleave="emit('add-skin-dragleave', $event)"
|
||||
@drop="emit('add-skin-drop', $event)"
|
||||
>
|
||||
<template #icon>
|
||||
<PlusIcon class="size-8" />
|
||||
</template>
|
||||
{{ formatMessage(messages.addSkinButton) }}
|
||||
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
|
||||
</SkinLikeTextButton>
|
||||
|
||||
<SkinButton
|
||||
v-for="skin in section.skins"
|
||||
:key="skinKey(skin, section.key)"
|
||||
:key="skinKey(skin, 'saved-skin')"
|
||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
:tooltip="skin.name"
|
||||
:disabled="readOnly"
|
||||
:is-dragging="isDraggingSavedSkin"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<template #overlay-buttons>
|
||||
@@ -554,25 +396,37 @@ defineExpose({ getAddSkinButtonElement })
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.deleteSkinButton)"
|
||||
:aria-label="formatMessage(messages.deleteSkinButton)"
|
||||
class="!rounded-[100%] pointer-events-auto"
|
||||
@click.stop="emit('delete', skin)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</SkinButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
|
||||
>
|
||||
<SkinButton
|
||||
v-for="skin in section.skins"
|
||||
:key="skinKey(skin, section.key)"
|
||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
:tooltip="skin.name"
|
||||
@select="emit('select', skin)"
|
||||
/>
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.skin-reorder-ghost) {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
:global(.skin-reorder-drag) {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
:global(.skin-reorder-fallback) {
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -197,13 +197,11 @@ export async function add_project_from_version(
|
||||
path: string,
|
||||
versionId: string,
|
||||
reason: DownloadReason,
|
||||
dependentOnVersionId?: string,
|
||||
): Promise<string> {
|
||||
return await invoke('plugin:profile|profile_add_project_from_version', {
|
||||
path,
|
||||
versionId,
|
||||
reason,
|
||||
dependentOnVersionId,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,12 @@ import { skinPreviewStorage } from '../storage/skin-preview-storage'
|
||||
|
||||
export interface RenderResult {
|
||||
forwards: string
|
||||
backwards: string
|
||||
}
|
||||
|
||||
export interface RawRenderResult {
|
||||
forwards: Blob
|
||||
backwards: Blob
|
||||
}
|
||||
|
||||
class BatchSkinRenderer {
|
||||
@@ -90,9 +92,12 @@ class BatchSkinRenderer {
|
||||
}
|
||||
|
||||
const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3]
|
||||
const forwards = await this.renderView(frontCameraPos, lookAtTarget)
|
||||
const backCameraPos: [number, number, number] = [-1.3, 1, -2.5]
|
||||
|
||||
return { forwards }
|
||||
const forwards = await this.renderView(frontCameraPos, lookAtTarget)
|
||||
const backwards = await this.renderView(backCameraPos, lookAtTarget)
|
||||
|
||||
return { forwards, backwards }
|
||||
}
|
||||
|
||||
private async renderView(
|
||||
@@ -399,15 +404,16 @@ async function generateSkinPreviewsForGeneration(
|
||||
const headKey = headKeys[i]
|
||||
|
||||
const rawCached = cachedSkinPreviews[skinKey]
|
||||
if (rawCached && !skinBlobUrlMap.has(skinKey)) {
|
||||
if (rawCached) {
|
||||
const cached: RenderResult = {
|
||||
forwards: URL.createObjectURL(rawCached.forwards),
|
||||
backwards: URL.createObjectURL(rawCached.backwards),
|
||||
}
|
||||
skinBlobUrlMap.set(skinKey, cached)
|
||||
}
|
||||
|
||||
const cachedHead = cachedHeadPreviews[headKey]
|
||||
if (cachedHead && !headBlobUrlMap.has(headKey)) {
|
||||
if (cachedHead) {
|
||||
headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead))
|
||||
}
|
||||
}
|
||||
@@ -421,6 +427,7 @@ async function generateSkinPreviewsForGeneration(
|
||||
if (DEBUG_MODE) {
|
||||
const result = skinBlobUrlMap.get(key)!
|
||||
URL.revokeObjectURL(result.forwards)
|
||||
URL.revokeObjectURL(result.backwards)
|
||||
skinBlobUrlMap.delete(key)
|
||||
} else continue
|
||||
}
|
||||
@@ -449,6 +456,7 @@ async function generateSkinPreviewsForGeneration(
|
||||
|
||||
const renderResult: RenderResult = {
|
||||
forwards: URL.createObjectURL(rawRenderResult.forwards),
|
||||
backwards: URL.createObjectURL(rawRenderResult.backwards),
|
||||
}
|
||||
|
||||
skinBlobUrlMap.set(key, renderResult)
|
||||
|
||||
@@ -142,12 +142,6 @@ export async function remove_custom_skin(skin: Skin): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function set_custom_skin_order(textureKeys: string[]): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|set_custom_skin_order', {
|
||||
textureKeys,
|
||||
})
|
||||
}
|
||||
|
||||
export async function save_custom_skin(
|
||||
skin: Skin,
|
||||
textureBlob: Uint8Array,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RawRenderResult } from '../rendering/batch-skin-renderer'
|
||||
|
||||
interface StoredPreview {
|
||||
forwards: Blob
|
||||
backwards: Blob
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ export class SkinPreviewStorage {
|
||||
|
||||
const storedPreview: StoredPreview = {
|
||||
forwards: result.forwards,
|
||||
backwards: result.backwards,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
@@ -65,7 +67,7 @@ export class SkinPreviewStorage {
|
||||
return
|
||||
}
|
||||
|
||||
resolve({ forwards: result.forwards })
|
||||
resolve({ forwards: result.forwards, backwards: result.backwards })
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
@@ -93,7 +95,7 @@ export class SkinPreviewStorage {
|
||||
const result = request.result as StoredPreview | undefined
|
||||
|
||||
if (result) {
|
||||
results[key] = { forwards: result.forwards }
|
||||
results[key] = { forwards: result.forwards, backwards: result.backwards }
|
||||
} else {
|
||||
results[key] = null
|
||||
}
|
||||
@@ -171,7 +173,7 @@ export class SkinPreviewStorage {
|
||||
const key = cursor.primaryKey as string
|
||||
const value = cursor.value as StoredPreview
|
||||
|
||||
const entrySize = value.forwards.size
|
||||
const entrySize = value.forwards.size + value.backwards.size
|
||||
totalSize += entrySize
|
||||
count++
|
||||
|
||||
|
||||
@@ -437,12 +437,6 @@
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Slow down!"
|
||||
},
|
||||
"app.skins.reorder-error.text": {
|
||||
"message": "Your skin order could not be saved."
|
||||
},
|
||||
"app.skins.reorder-error.title": {
|
||||
"message": "Failed to reorder skins"
|
||||
},
|
||||
"app.skins.section.builders-and-biomes": {
|
||||
"message": "Builders & Biomes"
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
|
||||
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { check_reachable, get_default_user, login as login_flow, users } from '@/helpers/auth'
|
||||
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
|
||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
|
||||
@@ -46,8 +46,6 @@ import {
|
||||
get_normalized_skin_texture,
|
||||
normalize_skin_texture,
|
||||
remove_custom_skin,
|
||||
save_custom_skin,
|
||||
set_custom_skin_order,
|
||||
} from '@/helpers/skins.ts'
|
||||
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
@@ -131,14 +129,6 @@ const messages = defineMessages({
|
||||
id: 'app.skins.dropped-file-error.text',
|
||||
defaultMessage: 'Failed to read the dropped file.',
|
||||
},
|
||||
reorderSkinErrorTitle: {
|
||||
id: 'app.skins.reorder-error.title',
|
||||
defaultMessage: 'Failed to reorder skins',
|
||||
},
|
||||
reorderSkinErrorText: {
|
||||
id: 'app.skins.reorder-error.text',
|
||||
defaultMessage: 'Your skin order could not be saved.',
|
||||
},
|
||||
deleteSkinTitle: {
|
||||
id: 'app.skins.delete-modal.title',
|
||||
defaultMessage: 'Are you sure you want to delete this skin?',
|
||||
@@ -191,7 +181,6 @@ const client = injectModrinthClient()
|
||||
const themeStore = useTheming()
|
||||
const skins = ref<Skin[]>([])
|
||||
const capes = ref<Cape[]>([])
|
||||
const offline = ref(!navigator.onLine)
|
||||
|
||||
const accountsCard = inject('accountsCard') as Ref<typeof AccountsCard>
|
||||
const currentUser = ref(undefined)
|
||||
@@ -211,16 +200,6 @@ const savedSkins = computed(() => {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const authServerQuery = useQuery({
|
||||
queryKey: ['authServerReachability'],
|
||||
queryFn: async () => {
|
||||
await check_reachable()
|
||||
return true
|
||||
},
|
||||
refetchInterval: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
const { data: modrinthUser } = useQuery({
|
||||
queryKey: computed(() => ['authenticated-user', 'campaigns', auth.user.value?.id]),
|
||||
queryFn: () => client.labrinth.users_v3.getAuthenticated(),
|
||||
@@ -270,18 +249,8 @@ const currentCape = computed(() => {
|
||||
})
|
||||
|
||||
const skinTexture = computedAsync(async () => {
|
||||
const skin = selectedSkin.value
|
||||
if (skin?.texture) {
|
||||
try {
|
||||
return await get_normalized_skin_texture(skin)
|
||||
} catch (error) {
|
||||
if (skin.texture.startsWith('data:image/')) {
|
||||
return skin.texture
|
||||
}
|
||||
|
||||
handleError(error as Error)
|
||||
return ''
|
||||
}
|
||||
if (selectedSkin.value?.texture) {
|
||||
return await get_normalized_skin_texture(selectedSkin.value)
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
@@ -289,9 +258,6 @@ const skinTexture = computedAsync(async () => {
|
||||
const capeTexture = computed(() => currentCape.value?.texture)
|
||||
const skinVariant = computed(() => selectedSkin.value?.variant)
|
||||
const skinNametag = computed(() => (themeStore.hideNametagSkinsPage ? undefined : username.value))
|
||||
const isSkinManagementReadOnly = computed(
|
||||
() => offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value),
|
||||
)
|
||||
const hasPendingSkinChange = computed(
|
||||
() => !skinsMatch(selectedSkin.value, originalSelectedSkin.value),
|
||||
)
|
||||
@@ -308,15 +274,11 @@ const deleteSkinModal = ref()
|
||||
const skinToDelete = ref<Skin | null>(null)
|
||||
|
||||
function confirmDeleteSkin(skin: Skin) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
skinToDelete.value = skin
|
||||
deleteSkinModal.value?.show()
|
||||
}
|
||||
|
||||
async function deleteSkin() {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
const deletedSkin = skinToDelete.value
|
||||
if (!deletedSkin) return
|
||||
|
||||
@@ -342,23 +304,7 @@ async function loadCapes() {
|
||||
|
||||
async function loadSkins() {
|
||||
try {
|
||||
const loadedSkins = (await get_available_skins()) ?? []
|
||||
const loadedEquippedSkin = loadedSkins.find((s) => s.is_equipped)
|
||||
const locallyKnownEquippedSkin =
|
||||
originalSelectedSkin.value &&
|
||||
(loadedSkins.find((skin) => skinsMatch(skin, originalSelectedSkin.value)) ??
|
||||
(originalSelectedSkin.value.texture.startsWith('data:image/')
|
||||
? originalSelectedSkin.value
|
||||
: undefined))
|
||||
const shouldPreserveKnownEquippedSkin =
|
||||
isSkinManagementReadOnly.value &&
|
||||
locallyKnownEquippedSkin &&
|
||||
!skinsMatch(loadedEquippedSkin, locallyKnownEquippedSkin)
|
||||
|
||||
skins.value =
|
||||
shouldPreserveKnownEquippedSkin && locallyKnownEquippedSkin
|
||||
? mergeEquippedSkin(loadedSkins, locallyKnownEquippedSkin)
|
||||
: loadedSkins
|
||||
skins.value = (await get_available_skins()) ?? []
|
||||
generateSkinPreviews(skins.value, capes.value)
|
||||
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
|
||||
originalSelectedSkin.value = selectedSkin.value
|
||||
@@ -369,28 +315,6 @@ async function loadSkins() {
|
||||
}
|
||||
}
|
||||
|
||||
function mergeEquippedSkin(list: Skin[], equippedSkin: Skin) {
|
||||
let foundEquippedSkin = false
|
||||
const mergedSkins = list.map((skin) => {
|
||||
const isEquipped = skinsMatch(skin, equippedSkin)
|
||||
foundEquippedSkin ||= isEquipped
|
||||
|
||||
return {
|
||||
...skin,
|
||||
is_equipped: isEquipped,
|
||||
}
|
||||
})
|
||||
|
||||
if (!foundEquippedSkin) {
|
||||
mergedSkins.unshift({
|
||||
...equippedSkin,
|
||||
is_equipped: true,
|
||||
})
|
||||
}
|
||||
|
||||
return mergedSkins
|
||||
}
|
||||
|
||||
function skinsMatch(a?: Skin | null, b?: Skin | null) {
|
||||
return (
|
||||
a?.source === b?.source &&
|
||||
@@ -400,14 +324,6 @@ function skinsMatch(a?: Skin | null, b?: Skin | null) {
|
||||
)
|
||||
}
|
||||
|
||||
function skinsMatchIgnoringSource(a?: Skin | null, b?: Skin | null) {
|
||||
return (
|
||||
a?.texture_key === b?.texture_key &&
|
||||
a?.variant === b?.variant &&
|
||||
(a?.cape_id ?? null) === (b?.cape_id ?? null)
|
||||
)
|
||||
}
|
||||
|
||||
function isSkinSelected(skin: Skin) {
|
||||
return skinsMatch(selectedSkin.value, skin)
|
||||
}
|
||||
@@ -469,8 +385,6 @@ function getDefaultSkinSectionSortIndex(section: string) {
|
||||
}
|
||||
|
||||
function changeSkin(newSkin: Skin) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
selectedSkin.value = newSkin
|
||||
}
|
||||
|
||||
@@ -509,19 +423,6 @@ function setLocallyEquippedSkin(skinToApply: Skin) {
|
||||
void accountsCard.value?.setEquippedSkin(originalSelectedSkin.value)
|
||||
}
|
||||
|
||||
function insertLocalSkin(savedSkin: Skin) {
|
||||
const firstNonCustomSkinIndex = skins.value.findIndex((skin) => skin.source !== 'custom')
|
||||
|
||||
if (firstNonCustomSkinIndex === -1) {
|
||||
skins.value = [...skins.value, savedSkin]
|
||||
return
|
||||
}
|
||||
|
||||
const nextSkins = [...skins.value]
|
||||
nextSkins.splice(firstNonCustomSkinIndex, 0, savedSkin)
|
||||
skins.value = nextSkins
|
||||
}
|
||||
|
||||
function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin) {
|
||||
let foundSkin = false
|
||||
const replacesSelectedSkin =
|
||||
@@ -550,7 +451,7 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin)
|
||||
})
|
||||
|
||||
if (!foundSkin) {
|
||||
insertLocalSkin({
|
||||
skins.value.unshift({
|
||||
...savedSkin,
|
||||
is_equipped: applied || savedSkin.is_equipped,
|
||||
})
|
||||
@@ -579,81 +480,6 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin)
|
||||
generateSkinPreviews(skins.value, capes.value)
|
||||
}
|
||||
|
||||
async function reorderSavedSkins(orderedSkins: Skin[]) {
|
||||
const previousSkins = skins.value
|
||||
const previousSelectedSkin = selectedSkin.value
|
||||
const previousOriginalSelectedSkin = originalSelectedSkin.value
|
||||
const orderedTextureKeys = orderedSkins.map((skin) => skin.texture_key)
|
||||
const orderedTextureKeySet = new Set(orderedTextureKeys)
|
||||
const remainingSavedSkins = previousSkins.filter(
|
||||
(skin) => skin.source !== 'default' && !orderedTextureKeySet.has(skin.texture_key),
|
||||
)
|
||||
const defaultSkins = previousSkins.filter((skin) => skin.source === 'default')
|
||||
const nextSavedSkins = [...orderedSkins, ...remainingSavedSkins]
|
||||
|
||||
skins.value = [...nextSavedSkins, ...defaultSkins]
|
||||
generateSkinPreviews(skins.value, capes.value)
|
||||
|
||||
try {
|
||||
const persistedSavedSkins = await preserveExternalSkins(nextSavedSkins)
|
||||
|
||||
if (persistedSavedSkins.some((skin, index) => skin !== nextSavedSkins[index])) {
|
||||
skins.value = [...persistedSavedSkins, ...defaultSkins]
|
||||
generateSkinPreviews(skins.value, capes.value)
|
||||
}
|
||||
|
||||
await set_custom_skin_order(
|
||||
persistedSavedSkins
|
||||
.filter((skin) => skin.source === 'custom')
|
||||
.map((skin) => skin.texture_key),
|
||||
)
|
||||
} catch (error) {
|
||||
skins.value = previousSkins
|
||||
selectedSkin.value = previousSelectedSkin
|
||||
originalSelectedSkin.value = previousOriginalSelectedSkin
|
||||
generateSkinPreviews(skins.value, capes.value)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.reorderSkinErrorTitle),
|
||||
text: error instanceof Error ? error.message : formatMessage(messages.reorderSkinErrorText),
|
||||
})
|
||||
await loadSkins()
|
||||
}
|
||||
}
|
||||
|
||||
async function preserveExternalSkins(skinsToPersist: Skin[]) {
|
||||
const preservedSkins: Skin[] = []
|
||||
|
||||
for (const skin of skinsToPersist) {
|
||||
if (skin.source !== 'custom_external') {
|
||||
preservedSkins.push(skin)
|
||||
continue
|
||||
}
|
||||
|
||||
const textureBlob = await normalize_skin_texture(skin.texture)
|
||||
const capeId = skin.cape_id ? capes.value.find((cape) => cape.id === skin.cape_id) : undefined
|
||||
const savedSkin = await save_custom_skin(skin, textureBlob, skin.variant, capeId, false)
|
||||
const preservedSkin: Skin = {
|
||||
...savedSkin,
|
||||
source: 'custom',
|
||||
is_equipped: skin.is_equipped,
|
||||
}
|
||||
|
||||
if (skinsMatchIgnoringSource(selectedSkin.value, skin)) {
|
||||
selectedSkin.value = preservedSkin
|
||||
}
|
||||
|
||||
if (skinsMatchIgnoringSource(originalSelectedSkin.value, skin)) {
|
||||
originalSelectedSkin.value = preservedSkin
|
||||
void accountsCard.value?.setEquippedSkin(preservedSkin)
|
||||
}
|
||||
|
||||
preservedSkins.push(preservedSkin)
|
||||
}
|
||||
|
||||
return preservedSkins
|
||||
}
|
||||
|
||||
function schedulePendingSkinRefresh() {
|
||||
if (pendingSkinRefreshTimeout !== null) {
|
||||
window.clearTimeout(pendingSkinRefreshTimeout)
|
||||
@@ -691,13 +517,7 @@ function schedulePendingSkinRefresh() {
|
||||
|
||||
async function applySelectedSkin() {
|
||||
const skinToApply = selectedSkin.value
|
||||
if (
|
||||
!skinToApply ||
|
||||
!hasPendingSkinChange.value ||
|
||||
isApplyingSkin.value ||
|
||||
isSkinManagementReadOnly.value
|
||||
)
|
||||
return
|
||||
if (!skinToApply || !hasPendingSkinChange.value || isApplyingSkin.value) return
|
||||
|
||||
isApplyingSkin.value = true
|
||||
try {
|
||||
@@ -766,14 +586,10 @@ async function login() {
|
||||
}
|
||||
|
||||
function openAddSkinFileBrowser() {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
addSkinFileInput.value?.click()
|
||||
}
|
||||
|
||||
async function onAddSkinFileInputChange(e: Event) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
const file = files?.[0]
|
||||
|
||||
@@ -816,8 +632,6 @@ function isPositionOverAddSkinButton(position: { x: number; y: number }) {
|
||||
}
|
||||
|
||||
async function handleAddSkinNativeDragDrop(event: { payload: DragDropEvent }) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
const payload = event.payload
|
||||
|
||||
if (payload.type === 'leave') {
|
||||
@@ -866,8 +680,6 @@ async function handleAddSkinNativeDragDrop(event: { payload: DragDropEvent }) {
|
||||
}
|
||||
|
||||
function onAddSkinDragOver(event: DragEvent) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
if (!isSkinFileDrag(event)) {
|
||||
return
|
||||
}
|
||||
@@ -876,14 +688,10 @@ function onAddSkinDragOver(event: DragEvent) {
|
||||
}
|
||||
|
||||
function onAddSkinDragLeave() {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
isAddSkinButtonDragActive.value = false
|
||||
}
|
||||
|
||||
async function onAddSkinDrop(event: DragEvent) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
isAddSkinButtonDragActive.value = false
|
||||
|
||||
const file = Array.from(event.dataTransfer?.files ?? []).find(
|
||||
@@ -913,8 +721,6 @@ async function setupAddSkinDragDropListener() {
|
||||
}
|
||||
|
||||
async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) {
|
||||
if (isSkinManagementReadOnly.value) return
|
||||
|
||||
const fakeEvent = new MouseEvent('click')
|
||||
const originalSkinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(buffer)
|
||||
try {
|
||||
@@ -934,24 +740,13 @@ watch(
|
||||
() => {},
|
||||
)
|
||||
|
||||
watch(isSkinManagementReadOnly, (readOnly) => {
|
||||
if (readOnly) {
|
||||
isDraggingSkinFile.value = false
|
||||
isAddSkinButtonDragActive.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('offline', onOffline)
|
||||
window.addEventListener('online', onOnline)
|
||||
userCheckInterval = window.setInterval(checkUserChanges, 250)
|
||||
void setupAddSkinDragDropListener()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
isUnmounted = true
|
||||
window.removeEventListener('offline', onOffline)
|
||||
window.removeEventListener('online', onOnline)
|
||||
|
||||
if (userCheckInterval !== null) {
|
||||
window.clearInterval(userCheckInterval)
|
||||
@@ -968,15 +763,6 @@ onUnmounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
function onOffline() {
|
||||
offline.value = true
|
||||
}
|
||||
|
||||
function onOnline() {
|
||||
offline.value = false
|
||||
void authServerQuery.refetch()
|
||||
}
|
||||
|
||||
async function checkUserChanges() {
|
||||
try {
|
||||
const defaultId = await get_default_user()
|
||||
@@ -1048,7 +834,7 @@ await loadSkins()
|
||||
>
|
||||
<button
|
||||
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 text-contrast shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
||||
:disabled="isApplyingSkin || isSkinManagementReadOnly"
|
||||
:disabled="isApplyingSkin"
|
||||
@click="resetSelectedSkin"
|
||||
>
|
||||
<RotateCounterClockwiseIcon />
|
||||
@@ -1056,7 +842,7 @@ await loadSkins()
|
||||
</button>
|
||||
<button
|
||||
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-brand px-4 py-2.5 text-base font-semibold leading-5 text-[rgba(0,0,0,0.9)] shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
||||
:disabled="isApplyingSkin || isSkinManagementReadOnly"
|
||||
:disabled="isApplyingSkin"
|
||||
@click="applySelectedSkin"
|
||||
>
|
||||
<SpinnerIcon v-if="isApplyingSkin" class="animate-spin" />
|
||||
@@ -1067,7 +853,7 @@ await loadSkins()
|
||||
<button
|
||||
v-else
|
||||
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
||||
:disabled="!selectedSkin || isSkinManagementReadOnly"
|
||||
:disabled="!selectedSkin"
|
||||
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
|
||||
>
|
||||
<EditIcon />
|
||||
@@ -1087,11 +873,9 @@ await loadSkins()
|
||||
:is-skin-selected="isSkinSelected"
|
||||
:is-skin-active="isSkinActive"
|
||||
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
|
||||
:read-only="isSkinManagementReadOnly"
|
||||
@select="changeSkin"
|
||||
@edit="(skin, event) => editSkinModal?.show(event, skin)"
|
||||
@delete="confirmDeleteSkin"
|
||||
@reorder-saved-skins="reorderSavedSkins"
|
||||
@add-skin="openAddSkinFileBrowser"
|
||||
@add-skin-dragenter="onAddSkinDragOver"
|
||||
@add-skin-dragover="onAddSkinDragOver"
|
||||
|
||||
@@ -248,6 +248,7 @@ export default new createRouter({
|
||||
component: Instance.Logs,
|
||||
meta: {
|
||||
useRootContext: true,
|
||||
// renderMode: 'fixed',
|
||||
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Logs' }],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -71,7 +71,7 @@ export const installVersionDependencies = async (profile, version, reason, onDep
|
||||
return installed
|
||||
}
|
||||
|
||||
const queueInstall = async (projectId, resolvedVersion, dependentOn) => {
|
||||
const queueInstall = async (projectId, resolvedVersion) => {
|
||||
if (!resolvedVersion?.id) return false
|
||||
|
||||
const versionId = resolvedVersion.id
|
||||
@@ -91,11 +91,7 @@ export const installVersionDependencies = async (profile, version, reason, onDep
|
||||
if (resolvedProjectId) {
|
||||
queuedProjectVersions.set(resolvedProjectId, versionId)
|
||||
}
|
||||
queuedInstalls.push({
|
||||
versionId,
|
||||
projectId: resolvedProjectId,
|
||||
dependentOnVersionId: dependentOn?.id,
|
||||
})
|
||||
queuedInstalls.push({ versionId, projectId: resolvedProjectId })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -163,7 +159,7 @@ export const installVersionDependencies = async (profile, version, reason, onDep
|
||||
if (!resolved) continue
|
||||
|
||||
const { depVersion, depProjectId } = resolved
|
||||
const queued = await queueInstall(depProjectId, depVersion, inputVersion)
|
||||
const queued = await queueInstall(depProjectId, depVersion)
|
||||
if (queued && depProjectId) {
|
||||
await announceDependency(depProjectId, depVersion)
|
||||
}
|
||||
@@ -180,8 +176,8 @@ export const installVersionDependencies = async (profile, version, reason, onDep
|
||||
for (let i = 0; i < queuedInstalls.length; i += batchSize) {
|
||||
const batch = queuedInstalls.slice(i, i + batchSize)
|
||||
await Promise.all(
|
||||
batch.map(async ({ versionId, dependentOnVersionId }) => {
|
||||
await add_project_from_version(profile.path, versionId, reason, dependentOnVersionId)
|
||||
batch.map(async ({ versionId }) => {
|
||||
await add_project_from_version(profile.path, versionId, reason)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
let systemThemeMq: MediaQueryList | null = null
|
||||
|
||||
export const DEFAULT_FEATURE_FLAGS = {
|
||||
project_background: false,
|
||||
page_path: false,
|
||||
@@ -55,22 +53,21 @@ export const useTheming = defineStore('themeStore', {
|
||||
this.setThemeClass()
|
||||
},
|
||||
setThemeClass() {
|
||||
const html = document.getElementsByTagName('html')[0]
|
||||
for (const theme of THEME_OPTIONS) {
|
||||
html.classList.remove(`${theme}-mode`)
|
||||
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
|
||||
}
|
||||
|
||||
systemThemeMq?.removeEventListener('change', this.setThemeClass)
|
||||
systemThemeMq = null
|
||||
|
||||
let theme = this.selectedTheme
|
||||
if (this.selectedTheme === 'system') {
|
||||
systemThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
systemThemeMq.addEventListener('change', this.setThemeClass)
|
||||
theme = systemThemeMq.matches ? 'dark' : 'light'
|
||||
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
if (darkThemeMq.matches) {
|
||||
theme = 'dark'
|
||||
} else {
|
||||
theme = 'light'
|
||||
}
|
||||
}
|
||||
|
||||
html.classList.add(`${theme}-mode`)
|
||||
document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
|
||||
},
|
||||
getFeatureFlag(key: FeatureFlag) {
|
||||
return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
|
||||
|
||||
@@ -117,7 +117,6 @@ fn main() {
|
||||
"equip_skin",
|
||||
"remove_custom_skin",
|
||||
"save_custom_skin",
|
||||
"set_custom_skin_order",
|
||||
"unequip_skin",
|
||||
"flush_pending_skin_change",
|
||||
"flush_pending_skin_change_for_profile",
|
||||
|
||||
@@ -14,7 +14,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
equip_skin,
|
||||
remove_custom_skin,
|
||||
save_custom_skin,
|
||||
set_custom_skin_order,
|
||||
unequip_skin,
|
||||
flush_pending_skin_change,
|
||||
flush_pending_skin_change_for_profile,
|
||||
@@ -92,14 +91,6 @@ pub async fn save_custom_skin(
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|set_custom_skin_order', texture_keys)`
|
||||
///
|
||||
/// See also: [minecraft_skins::set_custom_skin_order]
|
||||
#[tauri::command]
|
||||
pub async fn set_custom_skin_order(texture_keys: Vec<String>) -> Result<()> {
|
||||
Ok(minecraft_skins::set_custom_skin_order(texture_keys).await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|unequip_skin')`
|
||||
///
|
||||
/// See also: [minecraft_skins::unequip_skin]
|
||||
|
||||
@@ -251,15 +251,8 @@ pub async fn profile_add_project_from_version(
|
||||
path: &str,
|
||||
version_id: &str,
|
||||
reason: DownloadReason,
|
||||
dependent_on_version_id: Option<String>,
|
||||
) -> Result<String> {
|
||||
Ok(profile::add_project_from_version(
|
||||
path,
|
||||
version_id,
|
||||
reason,
|
||||
dependent_on_version_id,
|
||||
)
|
||||
.await?)
|
||||
Ok(profile::add_project_from_version(path, version_id, reason).await?)
|
||||
}
|
||||
|
||||
// Adds a project to a profile from a path
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
<template>
|
||||
<div class="relative overflow-clip rounded-xl bg-bg px-4 py-3">
|
||||
<div
|
||||
class="absolute bottom-0 left-0 top-0 w-1"
|
||||
:class="
|
||||
charge.type === 'refund' ? 'bg-purple' : (chargeStatuses[charge.status]?.color ?? 'bg-blue')
|
||||
"
|
||||
/>
|
||||
<div class="grid w-full grid-cols-[1fr_auto] items-center gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span>
|
||||
<span class="font-bold text-contrast">
|
||||
<template v-if="charge.status === 'succeeded'"> Succeeded </template>
|
||||
<template v-else-if="charge.status === 'failed'"> Failed </template>
|
||||
<template v-else-if="charge.status === 'cancelled'"> Cancelled </template>
|
||||
<template v-else-if="charge.status === 'processing'"> Processing </template>
|
||||
<template v-else-if="charge.status === 'open'"> Upcoming </template>
|
||||
<template v-else-if="charge.status === 'expiring'"> Expiring </template>
|
||||
<template v-else> {{ charge.status }} </template>
|
||||
</span>
|
||||
<span class="text-secondary opacity-50">•</span>
|
||||
<span>
|
||||
<template v-if="charge.type === 'refund'"> Refund </template>
|
||||
<template v-else-if="charge.type === 'subscription'">
|
||||
<template v-if="charge.status === 'cancelled'"> Subscription </template>
|
||||
<template v-else-if="isLatestCharge"> Started subscription </template>
|
||||
<template v-else> Subscription renewal </template>
|
||||
</template>
|
||||
<template v-else-if="charge.type === 'one-time'"> One-time charge </template>
|
||||
<template v-else-if="charge.type === 'proration'"> Proration charge </template>
|
||||
<template v-else> {{ charge.status }} </template>
|
||||
</span>
|
||||
<template v-if="charge.status !== 'cancelled'">
|
||||
<span class="text-secondary opacity-50">•</span>
|
||||
{{ formatPrice(charge.amount, charge.currency_code) }}
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
v-if="productMetadata && productMetadata.type === 'pyro'"
|
||||
class="flex items-center gap-1 text-sm text-secondary"
|
||||
>
|
||||
<span class="font-bold">Product:</span>
|
||||
<span v-if="productMetadata.ram">{{ productMetadata.ram / 1024 }}GB RAM</span>
|
||||
<span v-else>Unknown RAM</span>
|
||||
<span class="text-secondary opacity-50">•</span>
|
||||
<span v-if="productMetadata.cpu">{{ productMetadata.cpu }} vCPU</span>
|
||||
<span v-else>Unknown CPU</span>
|
||||
<span class="text-secondary opacity-50">•</span>
|
||||
<span v-if="productMetadata.storage">{{ productMetadata.storage / 1024 }}GB Storage</span>
|
||||
<span v-else>Unknown Storage</span>
|
||||
<span class="text-secondary opacity-50">•</span>
|
||||
<span v-if="productMetadata.swap">{{ productMetadata.swap }}MB Swap</span>
|
||||
<span v-else>Unknown Swap</span>
|
||||
</span>
|
||||
<span class="text-sm text-secondary">
|
||||
<span
|
||||
v-if="charge.status === 'cancelled' && dayjs(charge.due).isBefore(dayjs())"
|
||||
class="font-bold"
|
||||
>
|
||||
Ended:
|
||||
</span>
|
||||
<span v-else-if="charge.status === 'cancelled'" class="font-bold">Ends:</span>
|
||||
<span v-else-if="charge.type === 'refund'" class="font-bold">Issued:</span>
|
||||
<span v-else class="font-bold">Due:</span>
|
||||
{{ formatDateTime(charge.due) }}
|
||||
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
|
||||
</span>
|
||||
<span v-if="charge.last_attempt != null" class="text-sm text-secondary">
|
||||
<span v-if="charge.status === 'failed'" class="font-bold">Last attempt:</span>
|
||||
<span v-else class="font-bold">Charged:</span>
|
||||
{{ formatDateTime(charge.last_attempt) }}
|
||||
<span class="text-secondary">({{ formatRelativeTime(charge.last_attempt) }}) </span>
|
||||
</span>
|
||||
<div class="flex w-full items-center gap-1 text-xs text-secondary">
|
||||
{{ charge.status }}
|
||||
<span class="text-secondary opacity-50">•</span>
|
||||
{{ charge.type }}
|
||||
<span class="text-secondary opacity-50">•</span>
|
||||
{{ formatPrice(charge.amount, charge.currency_code) }}
|
||||
<span class="text-secondary opacity-50">•</span>
|
||||
|
||||
{{ formatDateTimeShort(charge.due) }}
|
||||
<template v-if="charge.subscription_interval">
|
||||
<span class="text-secondary opacity-50">•</span>
|
||||
{{ charge.subscription_interval }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled v-if="isRefunded">
|
||||
<div class="button-like disabled"><CheckIcon /> Charge refunded</div>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="charge.status === 'succeeded' && charge.type !== 'refund'"
|
||||
color="red"
|
||||
color-fill="text"
|
||||
>
|
||||
<button @click="emit('refund', charge)">
|
||||
<CurrencyIcon />
|
||||
Refund options
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="charge.status === 'failed' || charge.status === 'open'"
|
||||
color="red"
|
||||
color-fill="text"
|
||||
>
|
||||
<button @click="emit('modify', charge, subscription)">
|
||||
<CurrencyIcon />
|
||||
Modify charge
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { CheckIcon, CurrencyIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, useFormatDateTime, useFormatPrice, useRelativeTime } from '@modrinth/ui'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { products } from '~/generated/state.json'
|
||||
|
||||
const props = defineProps<{
|
||||
charge: Labrinth.Billing.Internal.Charge
|
||||
subscription: Labrinth.Billing.Internal.UserSubscription
|
||||
allCharges: Labrinth.Billing.Internal.Charge[]
|
||||
chargeIndex: number
|
||||
chargeCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refund: [charge: Labrinth.Billing.Internal.Charge]
|
||||
modify: [
|
||||
charge: Labrinth.Billing.Internal.Charge,
|
||||
subscription: Labrinth.Billing.Internal.UserSubscription,
|
||||
]
|
||||
}>()
|
||||
|
||||
const formatPrice = useFormatPrice()
|
||||
const formatDateTime = useFormatDateTime({
|
||||
timeStyle: 'short',
|
||||
dateStyle: 'long',
|
||||
})
|
||||
const formatDateTimeShort = useFormatDateTime({
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const isLatestCharge = computed(() => props.chargeIndex === props.chargeCount - 1)
|
||||
|
||||
const isRefunded = computed(() =>
|
||||
props.allCharges.some(
|
||||
(charge) => charge.type === 'refund' && charge.parent_charge_id === props.charge.id,
|
||||
),
|
||||
)
|
||||
|
||||
const productMetadata = computed(
|
||||
() =>
|
||||
products.find((product) => product.prices.some((price) => price.id === props.charge.price_id))
|
||||
?.metadata,
|
||||
)
|
||||
|
||||
const chargeStatuses = {
|
||||
open: {
|
||||
color: 'bg-blue',
|
||||
},
|
||||
processing: {
|
||||
color: 'bg-orange',
|
||||
},
|
||||
succeeded: {
|
||||
color: 'bg-green',
|
||||
},
|
||||
failed: {
|
||||
color: 'bg-red',
|
||||
},
|
||||
cancelled: {
|
||||
color: 'bg-red',
|
||||
},
|
||||
expiring: {
|
||||
color: 'bg-orange',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -383,12 +383,6 @@
|
||||
action: (event) => $refs.modal_batch_credit.show(event),
|
||||
shown: isAdmin(auth.user),
|
||||
},
|
||||
{
|
||||
id: 'analytics-events',
|
||||
color: 'primary',
|
||||
link: '/admin/analytics/events',
|
||||
shown: isAdmin(auth.user),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<ModrinthIcon aria-hidden="true" />
|
||||
@@ -423,9 +417,6 @@
|
||||
<template #servers-nodes>
|
||||
<ServerIcon aria-hidden="true" /> Credit server nodes
|
||||
</template>
|
||||
<template #analytics-events>
|
||||
<ChartIcon aria-hidden="true" /> {{ formatMessage(messages.analyticsEvents) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
@@ -963,10 +954,6 @@ const messages = defineMessages({
|
||||
id: 'layout.action.manage-affiliates',
|
||||
defaultMessage: 'Manage affiliate links',
|
||||
},
|
||||
analyticsEvents: {
|
||||
id: 'layout.action.analytics-events',
|
||||
defaultMessage: 'Analytics events',
|
||||
},
|
||||
newProject: {
|
||||
id: 'layout.action.new-project',
|
||||
defaultMessage: 'New project',
|
||||
|
||||
@@ -1466,24 +1466,6 @@
|
||||
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
|
||||
"message": "You've used up your <b>{withdrawLimit}</b> withdrawal limit. You must complete a tax form to withdraw more."
|
||||
},
|
||||
"dashboard.discord-roles.banner.body": {
|
||||
"message": "You're eligible for {roles}. Link your Discord account through Modrinth and we'll sync them automatically."
|
||||
},
|
||||
"dashboard.discord-roles.banner.cta": {
|
||||
"message": "Link Discord"
|
||||
},
|
||||
"dashboard.discord-roles.banner.title": {
|
||||
"message": "Claim your Discord roles"
|
||||
},
|
||||
"dashboard.discord-roles.role.big-creator": {
|
||||
"message": "1M+ Downloads"
|
||||
},
|
||||
"dashboard.discord-roles.role.creator": {
|
||||
"message": "Creator"
|
||||
},
|
||||
"dashboard.discord-roles.role.pride": {
|
||||
"message": "Pride 2026"
|
||||
},
|
||||
"dashboard.head-title": {
|
||||
"message": "Dashboard"
|
||||
},
|
||||
@@ -2315,9 +2297,6 @@
|
||||
"landing.subheading": {
|
||||
"message": "Discover, play, and share Minecraft content through our open-source platform built for the community."
|
||||
},
|
||||
"layout.action.analytics-events": {
|
||||
"message": "Analytics events"
|
||||
},
|
||||
"layout.action.change-theme": {
|
||||
"message": "Change theme"
|
||||
},
|
||||
|
||||
@@ -73,7 +73,6 @@
|
||||
placeholder="Select start..."
|
||||
input-class="w-full"
|
||||
wrapper-class="w-full"
|
||||
clearable
|
||||
show-today
|
||||
/>
|
||||
</div>
|
||||
@@ -90,7 +89,6 @@
|
||||
placeholder="Select end..."
|
||||
input-class="w-full"
|
||||
wrapper-class="w-full"
|
||||
clearable
|
||||
show-today
|
||||
/>
|
||||
</div>
|
||||
@@ -216,11 +214,7 @@
|
||||
|
||||
<template #empty-state>
|
||||
<div class="flex h-64 items-center justify-center text-secondary">
|
||||
<div v-if="isFetchingEvents" class="flex items-center gap-2">
|
||||
<SpinnerIcon class="size-5 animate-spin" aria-hidden="true" />
|
||||
Loading
|
||||
</div>
|
||||
<template v-else>No results.</template>
|
||||
{{ isLoadingEvents ? 'Loading analytics events...' : 'No results.' }}
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
@@ -230,15 +224,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
EditIcon,
|
||||
ExternalIcon,
|
||||
PlusIcon,
|
||||
SaveIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { EditIcon, ExternalIcon, PlusIcon, SaveIcon, SearchIcon, TrashIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
ConfirmModal,
|
||||
@@ -336,7 +322,7 @@ let resetFormTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const {
|
||||
data: analyticsEvents,
|
||||
error: eventsError,
|
||||
isFetching: isFetchingEvents,
|
||||
isLoading: isLoadingEvents,
|
||||
} = useQuery({
|
||||
queryKey: analyticsEventsQueryKey,
|
||||
queryFn: () => client.labrinth.analytics_v3.getEvents(),
|
||||
@@ -453,7 +439,7 @@ function openEditModal(event: Labrinth.Analytics.v3.AnalyticsEvent) {
|
||||
title: event.title,
|
||||
announcementUrl: event.announcement_url ?? '',
|
||||
startsAt: getDateTimeInputValue(event.starts),
|
||||
endsAt: isEventDateRange(event) ? getDateTimeInputValue(event.ends) : '',
|
||||
endsAt: getDateTimeInputValue(event.ends),
|
||||
metricKinds: event.for_metric_kind?.length ? [...event.for_metric_kind] : [...allMetricKinds],
|
||||
}
|
||||
committedAnnouncementUrl.value = event.announcement_url ?? ''
|
||||
|
||||
+140
-13
@@ -198,17 +198,115 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<AdminBillingChargeCard
|
||||
<div
|
||||
v-for="(charge, index) in subscription.charges"
|
||||
:key="charge.id"
|
||||
:charge="charge"
|
||||
:subscription="subscription"
|
||||
:all-charges="charges"
|
||||
:charge-index="index"
|
||||
:charge-count="subscription.charges.length"
|
||||
@refund="showRefundModal"
|
||||
@modify="showModifyModal"
|
||||
/>
|
||||
class="relative overflow-clip rounded-xl bg-bg px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="absolute bottom-0 left-0 top-0 w-1"
|
||||
:class="
|
||||
charge.type === 'refund'
|
||||
? 'bg-purple'
|
||||
: (chargeStatuses[charge.status]?.color ?? 'bg-blue')
|
||||
"
|
||||
/>
|
||||
<div class="grid w-full grid-cols-[1fr_auto] items-center gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span>
|
||||
<span class="font-bold text-contrast">
|
||||
<template v-if="charge.status === 'succeeded'"> Succeeded </template>
|
||||
<template v-else-if="charge.status === 'failed'"> Failed </template>
|
||||
<template v-else-if="charge.status === 'cancelled'"> Cancelled </template>
|
||||
<template v-else-if="charge.status === 'processing'"> Processing </template>
|
||||
<template v-else-if="charge.status === 'open'"> Upcoming </template>
|
||||
<template v-else-if="charge.status === 'expiring'"> Expiring </template>
|
||||
<template v-else> {{ charge.status }} </template>
|
||||
</span>
|
||||
⋅
|
||||
<span>
|
||||
<template v-if="charge.type === 'refund'"> Refund </template>
|
||||
<template v-else-if="charge.type === 'subscription'">
|
||||
<template v-if="charge.status === 'cancelled'"> Subscription </template>
|
||||
<template v-else-if="index === subscription.charges.length - 1">
|
||||
Started subscription
|
||||
</template>
|
||||
<template v-else> Subscription renewal </template>
|
||||
</template>
|
||||
<template v-else-if="charge.type === 'one-time'"> One-time charge </template>
|
||||
<template v-else-if="charge.type === 'proration'"> Proration charge </template>
|
||||
<template v-else> {{ charge.status }} </template>
|
||||
</span>
|
||||
<template v-if="charge.status !== 'cancelled'">
|
||||
⋅
|
||||
{{ formatPrice(charge.amount, charge.currency_code) }}
|
||||
</template>
|
||||
</span>
|
||||
<span class="text-sm text-secondary">
|
||||
<span
|
||||
v-if="charge.status === 'cancelled' && $dayjs(charge.due).isBefore($dayjs())"
|
||||
class="font-bold"
|
||||
>
|
||||
Ended:
|
||||
</span>
|
||||
<span v-else-if="charge.status === 'cancelled'" class="font-bold">Ends:</span>
|
||||
<span v-else-if="charge.type === 'refund'" class="font-bold">Issued:</span>
|
||||
<span v-else class="font-bold">Due:</span>
|
||||
{{ formatDateTime(charge.due) }}
|
||||
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
|
||||
</span>
|
||||
<span v-if="charge.last_attempt != null" class="text-sm text-secondary">
|
||||
<span v-if="charge.status === 'failed'" class="font-bold">Last attempt:</span>
|
||||
<span v-else class="font-bold">Charged:</span>
|
||||
{{ formatDateTime(charge.last_attempt) }}
|
||||
<span class="text-secondary"
|
||||
>({{ formatRelativeTime(charge.last_attempt) }})
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex w-full items-center gap-1 text-xs text-secondary">
|
||||
{{ charge.status }}
|
||||
⋅
|
||||
{{ charge.type }}
|
||||
⋅
|
||||
{{ formatPrice(charge.amount, charge.currency_code) }}
|
||||
⋅
|
||||
{{ formatDateTimeShort(charge.due) }}
|
||||
<template v-if="charge.subscription_interval">
|
||||
⋅ {{ charge.subscription_interval }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-if="
|
||||
charges.some((x) => x.type === 'refund' && x.parent_charge_id === charge.id)
|
||||
"
|
||||
>
|
||||
<div class="button-like disabled"><CheckIcon /> Charge refunded</div>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="charge.status === 'succeeded' && charge.type !== 'refund'"
|
||||
color="red"
|
||||
color-fill="text"
|
||||
>
|
||||
<button @click="showRefundModal(charge)">
|
||||
<CurrencyIcon />
|
||||
Refund options
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="charge.status === 'failed' || charge.status === 'open'"
|
||||
color="red"
|
||||
color-fill="text"
|
||||
>
|
||||
<button @click="showModifyModal(charge, subscription)">
|
||||
<CurrencyIcon />
|
||||
Modify charge
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,6 +334,7 @@ import {
|
||||
StyledInput,
|
||||
Toggle,
|
||||
useFormatDateTime,
|
||||
useFormatPrice,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
@@ -245,14 +344,21 @@ import { useQuery } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import ModrinthServersIcon from '~/components/brand/ModrinthServersIcon.vue'
|
||||
import AdminBillingChargeCard from '~/components/ui/admin/AdminBillingChargeCard.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const formatPrice = useFormatPrice()
|
||||
const formatDateTime = useFormatDateTime({
|
||||
timeStyle: 'short',
|
||||
dateStyle: 'long',
|
||||
})
|
||||
const formatDateTimeShort = useFormatDateTime({
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})
|
||||
|
||||
const vintl = useVIntl()
|
||||
|
||||
@@ -266,15 +372,15 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const userId = useRouteId('user')
|
||||
const chargeId = useRouteId('charge')
|
||||
|
||||
const {
|
||||
data: user,
|
||||
error: userError,
|
||||
suspense: userSuspense,
|
||||
} = useQuery({
|
||||
queryKey: ['user', userId],
|
||||
queryFn: () => labrinth.users_v2.get(userId),
|
||||
queryKey: ['user', chargeId],
|
||||
queryFn: () => labrinth.users_v2.get(chargeId),
|
||||
})
|
||||
|
||||
onServerPrefetch(userSuspense)
|
||||
@@ -427,6 +533,27 @@ async function modifyCharge() {
|
||||
}
|
||||
modifying.value = false
|
||||
}
|
||||
|
||||
const chargeStatuses = {
|
||||
open: {
|
||||
color: 'bg-blue',
|
||||
},
|
||||
processing: {
|
||||
color: 'bg-orange',
|
||||
},
|
||||
succeeded: {
|
||||
color: 'bg-green',
|
||||
},
|
||||
failed: {
|
||||
color: 'bg-red',
|
||||
},
|
||||
cancelled: {
|
||||
color: 'bg-red',
|
||||
},
|
||||
expiring: {
|
||||
color: 'bg-orange',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.page {
|
||||
@@ -48,69 +48,28 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="normal-page__content mt-4 lg:!mt-0">
|
||||
<Admonition
|
||||
v-if="showDiscordRoleBanner"
|
||||
class="mb-3"
|
||||
type="info"
|
||||
:header="formatMessage(messages.discordRoleBannerTitle)"
|
||||
show-actions-underneath
|
||||
dismissible
|
||||
@dismiss="dismissDiscordRoleBanner"
|
||||
>
|
||||
<div class="text-primary">
|
||||
{{
|
||||
formatMessage(messages.discordRoleBannerBody, {
|
||||
roles: eligibleDiscordRolesLabel,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<template #actions>
|
||||
<ButtonStyled color="blue">
|
||||
<NuxtLink to="/discord/link" class="w-fit !px-4">
|
||||
<ExternalIcon />
|
||||
{{ formatMessage(messages.discordRoleBannerCta) }}
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
<NuxtPage :route="route" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
AffiliateIcon,
|
||||
BellIcon as NotificationsIcon,
|
||||
ChartIcon,
|
||||
CurrencyIcon,
|
||||
DashboardIcon,
|
||||
ExternalIcon,
|
||||
LibraryIcon,
|
||||
ListIcon,
|
||||
OrganizationIcon,
|
||||
ReportIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { UserBadge } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { type User, UserBadge } from '@modrinth/utils'
|
||||
|
||||
import NavStack from '~/components/ui/NavStack.vue'
|
||||
|
||||
const auth = (await useAuth()) as Ref<{ user: Labrinth.Users.v3.User | null }>
|
||||
const client = injectModrinthClient()
|
||||
const dismissedDiscordRoleBannerUsers = useLocalStorage<string[]>(
|
||||
'dashboard-discord-role-banner-dismissed-users',
|
||||
[],
|
||||
)
|
||||
const auth = (await useAuth()) as Ref<{ user: User | null }>
|
||||
|
||||
const isAffiliate = computed(() => {
|
||||
return auth.value.user && auth.value.user.badges & UserBadge.AFFILIATE
|
||||
@@ -155,31 +114,6 @@ const messages = defineMessages({
|
||||
id: 'dashboard.sidebar.label.revenue',
|
||||
defaultMessage: 'Revenue',
|
||||
},
|
||||
discordRoleBannerTitle: {
|
||||
id: 'dashboard.discord-roles.banner.title',
|
||||
defaultMessage: 'Claim your Discord roles',
|
||||
},
|
||||
discordRoleBannerBody: {
|
||||
id: 'dashboard.discord-roles.banner.body',
|
||||
defaultMessage:
|
||||
"You're eligible for {roles}. Link your Discord account through Modrinth and we'll sync them automatically.",
|
||||
},
|
||||
discordRoleBannerCta: {
|
||||
id: 'dashboard.discord-roles.banner.cta',
|
||||
defaultMessage: 'Link Discord',
|
||||
},
|
||||
discordRolePride: {
|
||||
id: 'dashboard.discord-roles.role.pride',
|
||||
defaultMessage: 'Pride 2026',
|
||||
},
|
||||
discordRoleCreator: {
|
||||
id: 'dashboard.discord-roles.role.creator',
|
||||
defaultMessage: 'Creator',
|
||||
},
|
||||
discordRoleBigCreator: {
|
||||
id: 'dashboard.discord-roles.role.big-creator',
|
||||
defaultMessage: '1M+ Downloads',
|
||||
},
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
@@ -191,60 +125,4 @@ useSeoMeta({
|
||||
})
|
||||
|
||||
const route = useNativeRoute()
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: computed(() => ['dashboard-discord-role-eligibility', auth.value.user?.id, 'projects']),
|
||||
queryFn: () => {
|
||||
const userId = auth.value.user?.id
|
||||
if (!userId) return []
|
||||
|
||||
return client.labrinth.users_v2.getProjects(userId)
|
||||
},
|
||||
enabled: computed(() => !!auth.value.user?.id),
|
||||
})
|
||||
|
||||
const totalProjectDownloads = computed(() =>
|
||||
(projects.value ?? []).reduce((total, project) => total + (project.downloads ?? 0), 0),
|
||||
)
|
||||
|
||||
const eligibleDiscordRoles = computed(() => {
|
||||
const roles = []
|
||||
|
||||
if (auth.value.user?.campaigns?.pride_26?.has_badge === true) {
|
||||
roles.push(formatMessage(messages.discordRolePride))
|
||||
}
|
||||
|
||||
if (totalProjectDownloads.value >= 20_000) {
|
||||
roles.push(formatMessage(messages.discordRoleCreator))
|
||||
}
|
||||
|
||||
if (totalProjectDownloads.value >= 1_000_000) {
|
||||
roles.push(formatMessage(messages.discordRoleBigCreator))
|
||||
}
|
||||
|
||||
return roles
|
||||
})
|
||||
|
||||
const roleListFormatter = new Intl.ListFormat(undefined, {
|
||||
style: 'long',
|
||||
type: 'conjunction',
|
||||
})
|
||||
|
||||
const eligibleDiscordRolesLabel = computed(() =>
|
||||
roleListFormatter.format(eligibleDiscordRoles.value),
|
||||
)
|
||||
|
||||
const hasDismissedDiscordRoleBanner = computed(() =>
|
||||
dismissedDiscordRoleBannerUsers.value.includes(auth.value.user?.id ?? ''),
|
||||
)
|
||||
const showDiscordRoleBanner = computed(
|
||||
() => eligibleDiscordRoles.value.length > 0 && !hasDismissedDiscordRoleBanner.value,
|
||||
)
|
||||
|
||||
function dismissDiscordRoleBanner() {
|
||||
const userId = auth.value.user?.id
|
||||
if (!userId || dismissedDiscordRoleBannerUsers.value.includes(userId)) return
|
||||
|
||||
dismissedDiscordRoleBannerUsers.value = [...dismissedDiscordRoleBannerUsers.value, userId]
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { injectModrinthClient } from '@modrinth/ui'
|
||||
|
||||
import { getAuthUrl } from '~/composables/auth.js'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'empty',
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const auth = await useAuth()
|
||||
const client = injectModrinthClient()
|
||||
const error = ref<unknown>(null)
|
||||
const isLinkedCallback = computed(() => route.query.callback === 'linked')
|
||||
|
||||
onMounted(async () => {
|
||||
if (isLinkedCallback.value) return
|
||||
|
||||
try {
|
||||
if (!auth.value.user?.auth_providers?.includes('discord')) {
|
||||
window.location.href = `${getAuthUrl('discord', '/discord/link')}&token=${auth.value.token}`
|
||||
return
|
||||
}
|
||||
|
||||
const res = await client.labrinth.auth_internal.createDiscordCommunityLink()
|
||||
window.location.href = res.url
|
||||
} catch (err) {
|
||||
error.value = err
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="discord-link-container universal-card">
|
||||
<h1>{{ isLinkedCallback ? 'Modrinth account linked' : 'Linking Discord' }}</h1>
|
||||
<p v-if="isLinkedCallback">Your Modrinth account has been linked to the Discord server.</p>
|
||||
<p v-else-if="!error">Connecting your Modrinth account to the Discord server...</p>
|
||||
<p v-else>Discord linking failed. Please try again later.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.discord-link-container {
|
||||
width: 26rem;
|
||||
max-width: calc(100% - 2rem);
|
||||
margin: 1rem auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.discord-link-container h1 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin: 0 0 -1rem 0;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.discord-link-container p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { GitGraphIcon, RssIcon } from '@modrinth/assets'
|
||||
import { articles as rawArticles } from '@modrinth/blog'
|
||||
import {
|
||||
ArticleBody,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
injectModrinthClient,
|
||||
useFormatDateTime,
|
||||
} from '@modrinth/ui'
|
||||
import { Avatar, ButtonStyled, injectModrinthClient, useFormatDateTime } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted } from 'vue'
|
||||
@@ -175,7 +169,7 @@ onMounted(() => {
|
||||
class="aspect-video w-full rounded-xl border-[1px] border-solid border-button-border object-cover sm:rounded-2xl"
|
||||
:alt="article.title"
|
||||
/>
|
||||
<ArticleBody :html="article.html" />
|
||||
<div class="markdown-body" v-html="article.html" />
|
||||
<h3
|
||||
class="mb-0 mt-4 border-0 border-t-[1px] border-solid border-divider pt-4 text-base font-extrabold sm:text-lg"
|
||||
>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB |
@@ -1,12 +1,5 @@
|
||||
{
|
||||
"articles": [
|
||||
{
|
||||
"title": "Modrinth joins Spark Universe",
|
||||
"summary": "The next chapter. What it means and why we think it’s right for Modrinth.",
|
||||
"thumbnail": "https://modrinth.com/news/article/joining-spark-universe/thumbnail.webp",
|
||||
"date": "2026-06-15T14:00:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/joining-spark-universe"
|
||||
},
|
||||
{
|
||||
"title": "Manage servers together",
|
||||
"summary": "Add other users to your server, assign roles, and track what’s changed.",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,37 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Button, Heading, Section, Text } from '@vue-email/components'
|
||||
|
||||
import StyledEmail from '../shared/StyledEmail.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StyledEmail
|
||||
title="You're invited to the Creator Club"
|
||||
:manual-links="[{ link: '{discord.link_url}', label: 'Link Discord' }]"
|
||||
>
|
||||
<Heading as="h1" class="mb-2 text-2xl font-bold">You're invited to the Creator Club</Heading>
|
||||
|
||||
<Text class="text-base">Hey <span class="no-auto-link">{user.name}</span>!</Text>
|
||||
|
||||
<Text class="text-base"> Your projects just passed 20,000 total downloads, nice! </Text>
|
||||
|
||||
<Text class="text-base">
|
||||
We want to invite you to Modrinth's Creator Club, a space in our discord where you can chat
|
||||
with other creators, share feedback with us, and stay plugged in.
|
||||
</Text>
|
||||
|
||||
<Text class="text-base">
|
||||
To join just link your Discord account through Modrinth and we'll grant access automatically!
|
||||
</Text>
|
||||
|
||||
<Section class="mb-4 mt-4">
|
||||
<Button
|
||||
href="{discord.link_url}"
|
||||
target="_blank"
|
||||
class="text-accentContrast inline-block rounded-[12px] bg-brand pb-3 pl-4 pr-4 pt-3 text-[14px] font-bold"
|
||||
>
|
||||
Join the Creator Club
|
||||
</Button>
|
||||
</Section>
|
||||
</StyledEmail>
|
||||
</template>
|
||||
@@ -36,9 +36,6 @@ export default {
|
||||
'server-invited': () => import('./server/ServerInvited.vue'),
|
||||
'server-invited-no-account': () => import('./server/ServerInvitedNoAccount.vue'),
|
||||
|
||||
// Discord
|
||||
'discord-role-creator-club': () => import('./discord/DiscordRoleCreatorClub.vue'),
|
||||
|
||||
// Organizations
|
||||
'organization-invited': () => import('./organization/OrganizationInvited.vue'),
|
||||
} as Record<string, () => Promise<{ default: Component }>>
|
||||
|
||||
Generated
-53
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT v.mod_id dependent_project_id,\n d.mod_dependency_id dependency_project_id,\n d.dependency_type dependency_type,\n m.name dependency_name,\n m.slug dependency_slug,\n m.icon_url dependency_icon_url\n FROM versions v\n INNER JOIN dependencies d ON d.dependent_id = v.id\n INNER JOIN mods m ON m.id = d.mod_dependency_id\n WHERE v.mod_id = ANY($1)\n AND d.mod_dependency_id IS NOT NULL\n AND m.status = ANY($2)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "dependent_project_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "dependency_project_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "dependency_type",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "dependency_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "dependency_slug",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "dependency_icon_url",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3afdd6b9070ea0951682facf207b9056ac842c402bd0941c45ebd1dc6d627d43"
|
||||
}
|
||||
Generated
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id FROM versions\n WHERE mod_id = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3eacabccb1da975ceba03932880681c39ef3190c365e292c49dfe4acd7671395"
|
||||
}
|
||||
Generated
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT pg_try_advisory_xact_lock(hashtextextended('discord_role_email_campaign', 0)) AS \"lock_acquired!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "lock_acquired!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "67d6f3431c2c78227c10c1ff2658e89ffec91b671e65915d7f6923dc2c95f82b"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, username, avatar_url\n FROM users\n WHERE LOWER(username) LIKE $1 ESCAPE '\\'\n ORDER BY LOWER(username) = $2 DESC, LOWER(username), username\n LIMIT 25\n ",
|
||||
"query": "\n SELECT id, username, avatar_url\n FROM users\n WHERE LOWER(username) LIKE $1 ESCAPE ''\n ORDER BY LOWER(username) = $2 DESC, LOWER(username), username\n LIMIT 25\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -31,5 +31,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d0cabd1c74fa04c77a02e99e201e3f3c54b41e9f606db1f18accee33afdddf49"
|
||||
"hash": "8d0ae0da359ebd33801f2796c841b9b3cc1a59f7cdee756ac5ce1c459e69a531"
|
||||
}
|
||||
Generated
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT n.id AS \"id!\"\n FROM notifications n\n INNER JOIN notifications_types nt ON nt.name = n.body ->> 'type'\n WHERE n.id = ANY($1::BIGINT[])\n AND nt.expose_in_site_notifications = TRUE\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e89c5b87467dc019925f58ec789e15599d0f6121c41a4224746cfe2fde41ab60"
|
||||
}
|
||||
Generated
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH\n user_project_downloads AS (\n SELECT\n tm.user_id,\n SUM(m.downloads)::BIGINT total_downloads\n FROM team_members tm\n INNER JOIN mods m ON m.team_id = tm.team_id\n WHERE tm.accepted = TRUE\n GROUP BY tm.user_id\n )\n SELECT u.id AS \"id!\"\n FROM users u\n INNER JOIN user_project_downloads upd ON upd.user_id = u.id\n WHERE u.email IS NOT NULL\n AND u.email_verified = TRUE\n AND upd.total_downloads > 20000\n AND NOT EXISTS (\n SELECT 1\n FROM notifications n\n WHERE n.user_id = u.id\n AND n.body ->> 'type' = 'discord_role_creator_club'\n )\n ORDER BY upd.total_downloads DESC, u.id\n LIMIT 1000\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ffb5c12a0af95670946839a2f17247fc27601850a7b74b92863b740efbf794c9"
|
||||
}
|
||||
@@ -24,4 +24,3 @@
|
||||
- `Authorization: Bearer mra_user` for a regular user
|
||||
- `Modrinth-Admin: feedbeef` as admin key
|
||||
- If some steps require you to create a project/mod or version for testing, ask the user to go into the web frontend and manually create a project/version
|
||||
- When using `sqlx::query` etc. always use the macro form like `sqlx::query!` or `sqlx::query_scalar!` - never the plain function form. Avoid using `query_as!`.
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
-- Fixture for user HGwXDEgw.
|
||||
-- User 60829878552966 = HGwXDEgw
|
||||
-- Team 930000000000001 = 4G5AdLiy1
|
||||
-- Team member 930000000000002 = 4G5AdLiy2
|
||||
-- Project 930000000000003 = 4G5AdLiy3
|
||||
-- Thread 930000000000004 = 4G5AdLiy4
|
||||
-- Pride donation 930000000000005 = 4G5AdLiy5
|
||||
|
||||
INSERT INTO users (
|
||||
id, username, email, role, badges, balance, email_verified
|
||||
)
|
||||
VALUES (
|
||||
60829878552966, 'fixture_hgwxdegw', 'admin@modrinth.invalid',
|
||||
'developer', 15, 0, TRUE
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
badges = users.badges | EXCLUDED.badges,
|
||||
email = COALESCE(users.email, EXCLUDED.email),
|
||||
email_verified = TRUE;
|
||||
|
||||
INSERT INTO teams (id)
|
||||
VALUES (930000000000001)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO team_members (
|
||||
id, team_id, user_id, role, permissions, accepted, payouts_split, ordering,
|
||||
organization_permissions, is_owner
|
||||
)
|
||||
VALUES (
|
||||
930000000000002, 930000000000001, 60829878552966, 'Owner',
|
||||
1023, TRUE, 100, 0, NULL, TRUE
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
team_id = EXCLUDED.team_id,
|
||||
user_id = EXCLUDED.user_id,
|
||||
permissions = EXCLUDED.permissions,
|
||||
accepted = EXCLUDED.accepted,
|
||||
is_owner = EXCLUDED.is_owner;
|
||||
|
||||
INSERT INTO mods (
|
||||
id, team_id, name, summary, downloads, slug, description, follows,
|
||||
license, status, requested_status, monetization_status,
|
||||
side_types_migration_review_status, components
|
||||
)
|
||||
VALUES (
|
||||
930000000000003, 930000000000001, 'HGwXDEgw Million Download Fixture',
|
||||
'Project used to exercise badges and high download counts.', 1000000,
|
||||
'hgwxdegw-million-download-fixture', '', 0,
|
||||
'LicenseRef-All-Rights-Reserved', 'approved', 'approved',
|
||||
'monetized', 'reviewed', '{}'::jsonb
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
team_id = EXCLUDED.team_id,
|
||||
name = EXCLUDED.name,
|
||||
summary = EXCLUDED.summary,
|
||||
downloads = EXCLUDED.downloads,
|
||||
slug = EXCLUDED.slug,
|
||||
status = EXCLUDED.status,
|
||||
requested_status = EXCLUDED.requested_status,
|
||||
monetization_status = EXCLUDED.monetization_status,
|
||||
side_types_migration_review_status = EXCLUDED.side_types_migration_review_status,
|
||||
components = EXCLUDED.components;
|
||||
|
||||
INSERT INTO threads (id, thread_type, mod_id)
|
||||
VALUES (930000000000004, 'project', 930000000000003)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
thread_type = EXCLUDED.thread_type,
|
||||
mod_id = EXCLUDED.mod_id;
|
||||
|
||||
INSERT INTO campaign_donations (
|
||||
id, tiltify_event_id, raw_data, donated_at, amount_usd, user_id
|
||||
)
|
||||
VALUES (
|
||||
930000000000005, '00000000-0000-4000-8000-000000000005',
|
||||
'{"fixture": "hgwxdegw-badges-project"}'::jsonb,
|
||||
'2026-06-01T00:00:00Z', 5, 60829878552966
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
amount_usd = EXCLUDED.amount_usd,
|
||||
user_id = EXCLUDED.user_id;
|
||||
@@ -1,38 +0,0 @@
|
||||
INSERT INTO notifications_types
|
||||
(name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications)
|
||||
VALUES
|
||||
('discord_role_creator_club', 3, FALSE, FALSE);
|
||||
|
||||
INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
|
||||
VALUES
|
||||
(NULL, 'email', 'discord_role_creator_club', TRUE);
|
||||
|
||||
INSERT INTO notifications_templates
|
||||
(channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
|
||||
VALUES
|
||||
(
|
||||
'email',
|
||||
'discord_role_creator_club',
|
||||
'You''re invited to the Creator Club',
|
||||
'https://modrinth.com/_internal/templates/email/discord-role-creator-club',
|
||||
CONCAT(
|
||||
'Hi {user.name},',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'Thanks for building on Modrinth. Your projects have passed 20,000 total downloads, which is wild to think about.',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'That means thousands of players have found something useful, fun, or worth coming back to because of what you made.',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'We''re opening up a Creator Club role in the Modrinth Discord for creators like you. Link your Discord account through Modrinth and we''ll sync it automatically.',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'Join the Creator Club: {discord.link_url}',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'Thanks for making Modrinth what it is,',
|
||||
CHR(10),
|
||||
'The Modrinth Team'
|
||||
)
|
||||
);
|
||||
@@ -1,9 +1,6 @@
|
||||
use crate::database;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::models::ids::DBUserId;
|
||||
use crate::database::models::notification_item::NotificationBuilder;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::notifications::NotificationBody;
|
||||
use crate::queue::analytics::cache::cache_analytics;
|
||||
use crate::queue::billing::{index_billing, index_subscriptions};
|
||||
use crate::queue::email::EmailQueue;
|
||||
@@ -37,8 +34,6 @@ pub enum BackgroundTask {
|
||||
/// Attempts to ping Minecraft Java servers as if we were a client, to
|
||||
/// collect info on if they're online, game version, description, etc.
|
||||
PingMinecraftJavaServers,
|
||||
/// Queues Discord Creator Club role claim emails for newly eligible users.
|
||||
DiscordRoleEmailCampaign,
|
||||
}
|
||||
|
||||
impl BackgroundTask {
|
||||
@@ -95,9 +90,6 @@ impl BackgroundTask {
|
||||
PingMinecraftJavaServers => {
|
||||
ping_minecraft_java_servers(pool, redis_pool, clickhouse).await
|
||||
}
|
||||
DiscordRoleEmailCampaign => {
|
||||
discord_role_email_campaign(pool, redis_pool).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,83 +208,6 @@ pub async fn payouts(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn discord_role_email_campaign(
|
||||
pool: PgPool,
|
||||
redis_pool: RedisPool,
|
||||
) -> eyre::Result<()> {
|
||||
info!("Started indexing Discord role email campaign");
|
||||
|
||||
let mut txn = pool
|
||||
.begin()
|
||||
.await
|
||||
.wrap_err("failed to begin Discord role email campaign transaction")?;
|
||||
|
||||
let lock_acquired = sqlx::query_scalar!(
|
||||
r#"SELECT pg_try_advisory_xact_lock(hashtextextended('discord_role_email_campaign', 0)) AS "lock_acquired!""#,
|
||||
)
|
||||
.fetch_one(&mut txn)
|
||||
.await
|
||||
.wrap_err("failed to acquire Discord role email campaign lock")?;
|
||||
|
||||
if !lock_acquired {
|
||||
info!("Discord role email campaign is already running");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let user_ids = sqlx::query_scalar!(
|
||||
r#"
|
||||
WITH
|
||||
user_project_downloads AS (
|
||||
SELECT
|
||||
tm.user_id,
|
||||
SUM(m.downloads)::BIGINT total_downloads
|
||||
FROM team_members tm
|
||||
INNER JOIN mods m ON m.team_id = tm.team_id
|
||||
WHERE tm.accepted = TRUE
|
||||
GROUP BY tm.user_id
|
||||
)
|
||||
SELECT u.id AS "id!"
|
||||
FROM users u
|
||||
INNER JOIN user_project_downloads upd ON upd.user_id = u.id
|
||||
WHERE u.email IS NOT NULL
|
||||
AND u.email_verified = TRUE
|
||||
AND upd.total_downloads > 20000
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM notifications n
|
||||
WHERE n.user_id = u.id
|
||||
AND n.body ->> 'type' = 'discord_role_creator_club'
|
||||
)
|
||||
ORDER BY upd.total_downloads DESC, u.id
|
||||
LIMIT 1000
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&mut txn)
|
||||
.await
|
||||
.wrap_err("failed to fetch Discord role email campaign recipients")?
|
||||
.into_iter()
|
||||
.map(DBUserId)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let count = user_ids.len();
|
||||
|
||||
if !user_ids.is_empty() {
|
||||
NotificationBuilder {
|
||||
body: NotificationBody::DiscordRoleCreatorClub,
|
||||
}
|
||||
.insert_many(user_ids, &mut txn, &redis_pool)
|
||||
.await
|
||||
.wrap_err("failed to queue Discord role email notifications")?;
|
||||
}
|
||||
|
||||
txn.commit()
|
||||
.await
|
||||
.wrap_err("failed to commit Discord role email campaign transaction")?;
|
||||
|
||||
info!(count, "Finished indexing Discord role email campaign");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_payout_statuses(
|
||||
pool: PgPool,
|
||||
mural: muralpay::Client,
|
||||
|
||||
@@ -247,8 +247,7 @@ pub async fn init_client_with_database(
|
||||
ALTER TABLE {database}.{DOWNLOADS} {cluster_line}
|
||||
ADD COLUMN IF NOT EXISTS reason String,
|
||||
ADD COLUMN IF NOT EXISTS game_version String,
|
||||
ADD COLUMN IF NOT EXISTS loader String,
|
||||
ADD COLUMN IF NOT EXISTS dependent_on_version_id UInt64
|
||||
ADD COLUMN IF NOT EXISTS loader String
|
||||
"
|
||||
))
|
||||
.execute()
|
||||
|
||||
@@ -286,13 +286,13 @@ impl DBUser {
|
||||
let escaped_query = format!("{}%", escape_like(&lowercase_query));
|
||||
|
||||
let users = sqlx::query!(
|
||||
r#"
|
||||
"
|
||||
SELECT id, username, avatar_url
|
||||
FROM users
|
||||
WHERE LOWER(username) LIKE $1 ESCAPE '\'
|
||||
ORDER BY LOWER(username) = $2 DESC, LOWER(username), username
|
||||
LIMIT 25
|
||||
"#,
|
||||
",
|
||||
escaped_query,
|
||||
lowercase_query
|
||||
)
|
||||
|
||||
@@ -189,8 +189,6 @@ vars! {
|
||||
GITLAB_CLIENT_SECRET: String = "none";
|
||||
DISCORD_CLIENT_ID: String = "none";
|
||||
DISCORD_CLIENT_SECRET: String = "none";
|
||||
DISCORD_COMMUNITY_BOT_HANDOFF_URL: String = "http://localhost:3000/modrinth/handoff";
|
||||
DISCORD_COMMUNITY_LINK_SECRET: String = "";
|
||||
MICROSOFT_CLIENT_ID: String = "none";
|
||||
MICROSOFT_CLIENT_SECRET: String = "none";
|
||||
GOOGLE_CLIENT_ID: String = "none";
|
||||
|
||||
@@ -153,7 +153,6 @@ pub enum LegacyNotificationBody {
|
||||
amount: u64,
|
||||
date_available: DateTime<Utc>,
|
||||
},
|
||||
DiscordRoleCreatorClub,
|
||||
Custom {
|
||||
key: String,
|
||||
title: String,
|
||||
@@ -243,9 +242,6 @@ impl LegacyNotification {
|
||||
NotificationBody::PayoutAvailable { .. } => {
|
||||
Some("payout_available".to_string())
|
||||
}
|
||||
NotificationBody::DiscordRoleCreatorClub => {
|
||||
Some("discord_role_creator_club".to_string())
|
||||
}
|
||||
NotificationBody::Custom { .. } => Some("custom".to_string()),
|
||||
NotificationBody::LegacyMarkdown {
|
||||
notification_type, ..
|
||||
@@ -354,9 +350,6 @@ impl LegacyNotification {
|
||||
amount,
|
||||
date_available,
|
||||
},
|
||||
NotificationBody::DiscordRoleCreatorClub => {
|
||||
LegacyNotificationBody::DiscordRoleCreatorClub
|
||||
}
|
||||
NotificationBody::LegacyMarkdown {
|
||||
notification_type,
|
||||
name,
|
||||
|
||||
@@ -29,7 +29,6 @@ pub struct Download {
|
||||
pub reason: String,
|
||||
pub game_version: String,
|
||||
pub loader: String,
|
||||
pub dependent_on_version_id: u64,
|
||||
}
|
||||
|
||||
/// Why a project was downloaded.
|
||||
|
||||
@@ -59,7 +59,6 @@ pub enum NotificationType {
|
||||
ProjectStatusNeutral,
|
||||
ProjectTransferred,
|
||||
PayoutAvailable,
|
||||
DiscordRoleCreatorClub,
|
||||
Custom,
|
||||
Unknown,
|
||||
}
|
||||
@@ -99,9 +98,6 @@ impl NotificationType {
|
||||
NotificationType::Custom => "custom",
|
||||
NotificationType::ProjectStatusNeutral => "project_status_neutral",
|
||||
NotificationType::ProjectTransferred => "project_transferred",
|
||||
NotificationType::DiscordRoleCreatorClub => {
|
||||
"discord_role_creator_club"
|
||||
}
|
||||
NotificationType::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
@@ -138,9 +134,6 @@ impl NotificationType {
|
||||
}
|
||||
"project_status_neutral" => NotificationType::ProjectStatusNeutral,
|
||||
"project_transferred" => NotificationType::ProjectTransferred,
|
||||
"discord_role_creator_club" => {
|
||||
NotificationType::DiscordRoleCreatorClub
|
||||
}
|
||||
"custom" => NotificationType::Custom,
|
||||
"unknown" => NotificationType::Unknown,
|
||||
_ => NotificationType::Unknown,
|
||||
@@ -266,7 +259,6 @@ pub enum NotificationBody {
|
||||
date_available: DateTime<Utc>,
|
||||
amount: u64,
|
||||
},
|
||||
DiscordRoleCreatorClub,
|
||||
Custom {
|
||||
key: String,
|
||||
title: String,
|
||||
@@ -355,9 +347,6 @@ impl NotificationBody {
|
||||
NotificationBody::PayoutAvailable { .. } => {
|
||||
NotificationType::PayoutAvailable
|
||||
}
|
||||
NotificationBody::DiscordRoleCreatorClub => {
|
||||
NotificationType::DiscordRoleCreatorClub
|
||||
}
|
||||
NotificationBody::Custom { .. } => NotificationType::Custom,
|
||||
NotificationBody::Unknown => NotificationType::Unknown,
|
||||
}
|
||||
@@ -630,12 +619,6 @@ impl From<DBNotification> for Notification {
|
||||
"A payout is available!".to_string(),
|
||||
"#".to_string(),
|
||||
vec![],
|
||||
),
|
||||
NotificationBody::DiscordRoleCreatorClub => (
|
||||
"Join the Creator Club".to_string(),
|
||||
"Link your Discord account to claim your creator community role.".to_string(),
|
||||
"/discord/link".to_string(),
|
||||
vec![],
|
||||
),
|
||||
NotificationBody::ModerationMessageReceived { .. } => (
|
||||
"New message in moderation thread".to_string(),
|
||||
|
||||
@@ -90,8 +90,6 @@ const NEWOWNER_NAME: &str = "new_owner.name";
|
||||
const PAYOUTAVAILABLE_AMOUNT: &str = "payout.amount";
|
||||
const PAYOUTAVAILABLE_PERIOD: &str = "payout.period";
|
||||
|
||||
const DISCORD_LINK_URL: &str = "discord.link_url";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MailingIdentity {
|
||||
from_name: String,
|
||||
@@ -604,15 +602,6 @@ async fn collect_template_variables(
|
||||
| NotificationBody::PasswordChanged
|
||||
| NotificationBody::PasswordRemoved => Ok(EmailTemplate::Static(map)),
|
||||
|
||||
NotificationBody::DiscordRoleCreatorClub => {
|
||||
map.insert(
|
||||
DISCORD_LINK_URL,
|
||||
format!("{}/discord/link", ENV.SITE_URL.trim_end_matches('/')),
|
||||
);
|
||||
|
||||
Ok(EmailTemplate::Static(map))
|
||||
}
|
||||
|
||||
NotificationBody::EmailChanged {
|
||||
new_email,
|
||||
to_email: _,
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::auth::validate::get_user_record_from_bearer_token;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::analytics::{Download, DownloadReason};
|
||||
use crate::models::ids::{ProjectId, VersionId};
|
||||
use crate::models::ids::ProjectId;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::queue::analytics::AnalyticsQueue;
|
||||
use crate::queue::session::AuthQueue;
|
||||
@@ -13,12 +13,10 @@ use crate::util::error::Context;
|
||||
use crate::util::guards::admin_key_guard;
|
||||
use crate::util::tags::valid_download_tags;
|
||||
use actix_web::{HttpRequest, HttpResponse, patch, post, web};
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use eyre::eyre;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tracing::trace;
|
||||
|
||||
@@ -47,86 +45,10 @@ pub struct DownloadMeta {
|
||||
pub reason: Option<DownloadReason>,
|
||||
pub game_version: Option<String>,
|
||||
pub loader: Option<String>,
|
||||
pub dependent_on: Option<VersionId>,
|
||||
}
|
||||
|
||||
pub const DOWNLOAD_META_HEADER: &str = "modrinth-download-meta";
|
||||
|
||||
fn parse_download_meta_version(
|
||||
version_id: &str,
|
||||
field: &str,
|
||||
) -> Result<VersionId, ApiError> {
|
||||
parse_base62(version_id)
|
||||
.map(VersionId)
|
||||
.wrap_request_err_with(|| {
|
||||
eyre!("invalid `{field}` version id '{version_id}'")
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_download_meta_from_query(
|
||||
url: &url::Url,
|
||||
) -> Result<Option<DownloadMeta>, ApiError> {
|
||||
let mut meta = DownloadMeta {
|
||||
reason: None,
|
||||
game_version: None,
|
||||
loader: None,
|
||||
dependent_on: None,
|
||||
};
|
||||
|
||||
for (key, value) in url.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"mr_download_reason" => {
|
||||
meta.reason =
|
||||
Some(DownloadReason::from_str(&value).map_err(|_| {
|
||||
ApiError::Request(eyre!(
|
||||
"invalid download reason specified"
|
||||
))
|
||||
})?);
|
||||
}
|
||||
"mr_game_version" => {
|
||||
meta.game_version = Some(value.into_owned());
|
||||
}
|
||||
"mr_loader" => {
|
||||
meta.loader = Some(value.into_owned());
|
||||
}
|
||||
"mr_dependent_on" => {
|
||||
meta.dependent_on =
|
||||
Some(parse_download_meta_version(&value, "dependent_on")?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((meta.reason.is_some()
|
||||
|| meta.game_version.is_some()
|
||||
|| meta.loader.is_some()
|
||||
|| meta.dependent_on.is_some())
|
||||
.then_some(meta))
|
||||
}
|
||||
|
||||
async fn resolve_download_attribution_version(
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
version_id: Option<VersionId>,
|
||||
field: &str,
|
||||
) -> Result<u64, ApiError> {
|
||||
let Some(version_id) = version_id else {
|
||||
return Ok(0);
|
||||
};
|
||||
|
||||
let version_id =
|
||||
crate::database::models::ids::DBVersionId::from(version_id);
|
||||
|
||||
crate::database::models::DBVersion::get(version_id, pool, redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch download attribution version")?
|
||||
.ok_or_else(|| {
|
||||
ApiError::Request(eyre!("invalid `{field}` version specified"))
|
||||
})?;
|
||||
|
||||
Ok(version_id.0 as u64)
|
||||
}
|
||||
|
||||
// This is an internal route, cannot be used without key
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
@@ -167,9 +89,10 @@ pub async fn count_download(
|
||||
let project_id: crate::database::models::ids::DBProjectId =
|
||||
download_body.project_id.into();
|
||||
|
||||
let id_option = parse_base62(&download_body.version_name)
|
||||
.ok()
|
||||
.map(|x| x as i64);
|
||||
let id_option =
|
||||
ariadne::ids::base62_impl::parse_base62(&download_body.version_name)
|
||||
.ok()
|
||||
.map(|x| x as i64);
|
||||
|
||||
let (version_id, project_id) = if let Some(version) = sqlx::query!(
|
||||
"
|
||||
@@ -215,7 +138,7 @@ pub async fn count_download(
|
||||
.map(Some)
|
||||
.wrap_request_err("invalid download meta")?
|
||||
} else {
|
||||
parse_download_meta_from_query(&url)?
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(meta) = &meta {
|
||||
@@ -239,14 +162,6 @@ pub async fn count_download(
|
||||
}
|
||||
}
|
||||
|
||||
let dependent_on_version_id = resolve_download_attribution_version(
|
||||
&pool,
|
||||
&redis,
|
||||
meta.as_ref().and_then(|m| m.dependent_on),
|
||||
"dependent_on",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let download = Download {
|
||||
recorded: get_current_tenths_of_ms(),
|
||||
domain: url.host_str().unwrap_or_default().to_string(),
|
||||
@@ -297,7 +212,6 @@ pub async fn count_download(
|
||||
.and_then(|m| m.loader.as_ref())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default(),
|
||||
dependent_on_version_id,
|
||||
};
|
||||
trace!("added download {download:#?}");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::models::ids::{DBNotificationId, DBUserId};
|
||||
use crate::database::models::ids::DBUserId;
|
||||
use crate::database::models::notification_item::DBNotification;
|
||||
use crate::database::models::notification_item::NotificationBuilder;
|
||||
use crate::database::models::user_item::DBUser;
|
||||
@@ -64,12 +64,34 @@ pub async fn create(
|
||||
.insert_many(user_ids, &mut txn, &redis)
|
||||
.await?;
|
||||
|
||||
let notifications =
|
||||
get_site_exposed_notifications(¬ification_ids, &mut txn).await?;
|
||||
let notifications = DBNotification::get_many(¬ification_ids, &mut txn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Notification::from)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
broadcast_notifications(&redis, notifications).await;
|
||||
for notification in notifications {
|
||||
let notification_id = notification.id;
|
||||
let to_user = notification.user_id;
|
||||
if let Err(error) = broadcast_friends_message(
|
||||
&redis,
|
||||
RedisFriendsMessage::Notification {
|
||||
to_user,
|
||||
notification,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
?error,
|
||||
?notification_id,
|
||||
?to_user,
|
||||
"failed to broadcast realtime notification"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
}
|
||||
@@ -133,12 +155,37 @@ pub async fn create_email_sync(
|
||||
.insert_many_without_delivery(notification_user_ids, &mut txn, &redis)
|
||||
.await?;
|
||||
|
||||
let notifications =
|
||||
get_site_exposed_notifications(¬ification_ids, &mut txn).await?;
|
||||
let notifications = DBNotification::get_many(¬ification_ids, &mut txn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Notification::from)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
broadcast_notifications(&redis, notifications).await;
|
||||
for notification in notifications {
|
||||
let Notification {
|
||||
user_id: to_user,
|
||||
id: notification_id,
|
||||
..
|
||||
} = notification;
|
||||
if let Err(error) = broadcast_friends_message(
|
||||
&redis,
|
||||
RedisFriendsMessage::Notification {
|
||||
to_user,
|
||||
notification,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
?error,
|
||||
?notification_id,
|
||||
?to_user,
|
||||
"failed to broadcast realtime notification"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut email_txn = pool.begin().await?;
|
||||
|
||||
@@ -285,57 +332,3 @@ pub async fn send_custom_email(
|
||||
|
||||
Ok(HttpResponse::Accepted().finish())
|
||||
}
|
||||
|
||||
async fn get_site_exposed_notifications(
|
||||
notification_ids: &[DBNotificationId],
|
||||
txn: &mut crate::database::PgTransaction<'_>,
|
||||
) -> Result<Vec<Notification>, ApiError> {
|
||||
let raw_ids = notification_ids.iter().map(|x| x.0).collect::<Vec<_>>();
|
||||
let exposed_ids = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT n.id AS "id!"
|
||||
FROM notifications n
|
||||
INNER JOIN notifications_types nt ON nt.name = n.body ->> 'type'
|
||||
WHERE n.id = ANY($1::BIGINT[])
|
||||
AND nt.expose_in_site_notifications = TRUE
|
||||
"#,
|
||||
&raw_ids[..],
|
||||
)
|
||||
.fetch_all(&mut *txn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(DBNotificationId)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(DBNotification::get_many(&exposed_ids, txn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(Notification::from)
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn broadcast_notifications(
|
||||
redis: &RedisPool,
|
||||
notifications: Vec<Notification>,
|
||||
) {
|
||||
for notification in notifications {
|
||||
let notification_id = notification.id;
|
||||
let to_user = notification.user_id;
|
||||
if let Err(error) = broadcast_friends_message(
|
||||
redis,
|
||||
RedisFriendsMessage::Notification {
|
||||
to_user,
|
||||
notification,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
?error,
|
||||
?notification_id,
|
||||
?to_user,
|
||||
"failed to broadcast realtime notification"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,18 +29,13 @@ use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use ariadne::ids::base62_impl::{parse_base62, to_base62};
|
||||
use ariadne::ids::random_base62_rng;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::{Duration, Utc};
|
||||
use eyre::eyre;
|
||||
use hmac::{Hmac, Mac};
|
||||
use lettre::message::Mailbox;
|
||||
use rand::Rng;
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
use rand_chacha::rand_core::SeedableRng;
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -66,8 +61,7 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
.service(set_email)
|
||||
.service(verify_email)
|
||||
.service(subscribe_newsletter)
|
||||
.service(get_newsletter_subscription_status)
|
||||
.service(discord_community_link),
|
||||
.service(get_newsletter_subscription_status),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1375,97 +1369,6 @@ pub struct DeleteAuthProvider {
|
||||
pub provider: AuthProvider,
|
||||
}
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct DiscordCommunityLinkResponse {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DiscordCommunityHandoffPayload {
|
||||
v: u8,
|
||||
modrinth_user_id: String,
|
||||
discord_user_id: String,
|
||||
iat: i64,
|
||||
exp: i64,
|
||||
nonce: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
operation_id = "discordCommunityLink",
|
||||
responses(
|
||||
(status = 200, description = "Discord community bot handoff URL", body = DiscordCommunityLinkResponse),
|
||||
(status = 400, description = "Discord provider not linked"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
security(("bearer_auth" = ["SESSION_ACCESS"]))
|
||||
)]
|
||||
#[post("/discord-community-link")]
|
||||
pub async fn discord_community_link(
|
||||
req: HttpRequest,
|
||||
client: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<web::Json<DiscordCommunityLinkResponse>, ApiError> {
|
||||
if ENV.DISCORD_COMMUNITY_LINK_SECRET.is_empty()
|
||||
|| ENV.DISCORD_COMMUNITY_BOT_HANDOFF_URL.is_empty()
|
||||
{
|
||||
return Err(ApiError::Internal(eyre!(
|
||||
"discord community linking is not configured"
|
||||
)));
|
||||
}
|
||||
|
||||
let db_user = get_full_user_from_headers(
|
||||
&req,
|
||||
&**client,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::SESSION_ACCESS,
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let Some(discord_id) = db_user.discord_id else {
|
||||
return Err(ApiError::Request(eyre!("discord account is not linked")));
|
||||
};
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
let nonce = ChaCha20Rng::from_entropy()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(32)
|
||||
.map(char::from)
|
||||
.collect::<String>();
|
||||
|
||||
let payload = DiscordCommunityHandoffPayload {
|
||||
v: 1,
|
||||
modrinth_user_id: ariadne::ids::UserId::from(db_user.id).to_string(),
|
||||
discord_user_id: discord_id.to_string(),
|
||||
iat: now,
|
||||
exp: now + 600,
|
||||
nonce,
|
||||
};
|
||||
|
||||
let payload_json = serde_json::to_vec(&payload).wrap_internal_err(
|
||||
"failed to serialize discord community handoff payload",
|
||||
)?;
|
||||
let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json);
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(
|
||||
ENV.DISCORD_COMMUNITY_LINK_SECRET.as_bytes(),
|
||||
)
|
||||
.wrap_internal_err("failed to initialize discord community link hmac")?;
|
||||
mac.update(payload_b64.as_bytes());
|
||||
let sig = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
||||
|
||||
let url = format!(
|
||||
"{}?payload={}&sig={}",
|
||||
ENV.DISCORD_COMMUNITY_BOT_HANDOFF_URL, payload_b64, sig,
|
||||
);
|
||||
|
||||
Ok(web::Json(DiscordCommunityLinkResponse { url }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
operation_id = "deleteAuthProvider",
|
||||
|
||||
@@ -54,8 +54,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
.service(flows::set_email)
|
||||
.service(flows::verify_email)
|
||||
.service(flows::subscribe_newsletter)
|
||||
.service(flows::get_newsletter_subscription_status)
|
||||
.service(flows::discord_community_link),
|
||||
.service(flows::get_newsletter_subscription_status),
|
||||
);
|
||||
cfg.service(pats::get_pats);
|
||||
cfg.service(pats::create_pat);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
LazyLock,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
@@ -12,16 +12,9 @@ use regex::Regex;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
|
||||
|
||||
use crate::{
|
||||
database::{
|
||||
PgPool,
|
||||
models::{DBProjectId, DBVersion, DBVersionId},
|
||||
},
|
||||
models::{
|
||||
ids::{ProjectId, VersionId},
|
||||
v3::analytics::DownloadReason,
|
||||
},
|
||||
database::models::{DBProjectId, DBVersionId},
|
||||
models::{ids::VersionId, v3::analytics::DownloadReason},
|
||||
routes::ApiError,
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
use super::super::{
|
||||
@@ -46,8 +39,6 @@ pub enum ProjectDownloadsField {
|
||||
ProjectId,
|
||||
/// Version ID of this project.
|
||||
VersionId,
|
||||
/// Project ID that caused this project to be downloaded.
|
||||
DependentProjectId,
|
||||
/// Referrer domain which linked to this project.
|
||||
Domain,
|
||||
/// Normalized user agent used to download this project.
|
||||
@@ -72,9 +63,6 @@ pub struct ProjectDownloadsFilters {
|
||||
/// Version IDs to include.
|
||||
#[serde(default)]
|
||||
pub version_id: Vec<VersionId>,
|
||||
/// Dependent project IDs to include.
|
||||
#[serde(default)]
|
||||
pub dependent_project_id: Vec<ProjectId>,
|
||||
/// Referrer domains to include.
|
||||
#[serde(default)]
|
||||
pub domain: Vec<String>,
|
||||
@@ -110,9 +98,6 @@ pub struct ProjectDownloads {
|
||||
/// [`ProjectDownloadsField::VersionId`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) version_id: Option<VersionId>,
|
||||
/// [`ProjectDownloadsField::DependentProjectId`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) dependent_project_id: Option<ProjectId>,
|
||||
/// [`ProjectDownloadsField::Monetized`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) monetized: Option<bool>,
|
||||
@@ -190,7 +175,6 @@ struct DownloadRow {
|
||||
domain: String,
|
||||
user_agent: String,
|
||||
version_id: DBVersionId,
|
||||
dependent_on_version_id: DBVersionId,
|
||||
monetized: i8,
|
||||
country: String,
|
||||
reason: String,
|
||||
@@ -204,7 +188,6 @@ const DOWNLOADS: &str = {
|
||||
const USE_DOMAIN: &str = "{use_domain: Bool}";
|
||||
const USE_USER_AGENT: &str = "{use_user_agent: Bool}";
|
||||
const USE_VERSION_ID: &str = "{use_version_id: Bool}";
|
||||
const USE_DEPENDENT_PROJECT_ID: &str = "{use_dependent_project_id: Bool}";
|
||||
const USE_MONETIZED: &str = "{use_monetized: Bool}";
|
||||
const USE_COUNTRY: &str = "{use_country: Bool}";
|
||||
const USE_REASON: &str = "{use_reason: Bool}";
|
||||
@@ -212,8 +195,6 @@ const DOWNLOADS: &str = {
|
||||
const USE_LOADER: &str = "{use_loader: Bool}";
|
||||
const FILTER_DOMAIN: &str = "filter_domain";
|
||||
const FILTER_VERSION_ID: &str = "filter_version_id";
|
||||
const FILTER_DEPENDENT_ON_VERSION_ID: &str =
|
||||
"filter_dependent_on_version_id";
|
||||
const FILTER_MONETIZED: &str = "{filter_monetized: UInt8}";
|
||||
const FILTER_COUNTRY: &str = "filter_country";
|
||||
const FILTER_REASON: &str = "filter_reason";
|
||||
@@ -225,7 +206,6 @@ const DOWNLOADS: &str = {
|
||||
? AS {PROJECT_IDS},
|
||||
? AS {FILTER_DOMAIN},
|
||||
? AS {FILTER_VERSION_ID},
|
||||
? AS {FILTER_DEPENDENT_ON_VERSION_ID},
|
||||
? AS {FILTER_COUNTRY},
|
||||
? AS {FILTER_REASON},
|
||||
? AS {FILTER_GAME_VERSION},
|
||||
@@ -237,7 +217,6 @@ const DOWNLOADS: &str = {
|
||||
if({USE_DOMAIN}, domain, '') AS domain,
|
||||
if({USE_USER_AGENT}, user_agent, '') AS user_agent,
|
||||
if({USE_VERSION_ID}, version_id, 0) AS version_id,
|
||||
if({USE_DEPENDENT_PROJECT_ID}, dependent_on_version_id, 0) AS dependent_on_version_id,
|
||||
if({USE_MONETIZED}, CAST(user_id != 0 AS Int8), -1) AS monetized,
|
||||
if({USE_COUNTRY}, country, '') AS country,
|
||||
if({USE_REASON}, reason, '') AS reason,
|
||||
@@ -254,13 +233,12 @@ const DOWNLOADS: &str = {
|
||||
AND downloads.project_id IN {PROJECT_IDS}
|
||||
AND (empty({FILTER_DOMAIN}) OR downloads.domain IN {FILTER_DOMAIN})
|
||||
AND (empty({FILTER_VERSION_ID}) OR downloads.version_id IN {FILTER_VERSION_ID})
|
||||
AND (empty({FILTER_DEPENDENT_ON_VERSION_ID}) OR downloads.dependent_on_version_id IN {FILTER_DEPENDENT_ON_VERSION_ID})
|
||||
AND ({FILTER_MONETIZED} = 2 OR CAST(downloads.user_id != 0 AS UInt8) = {FILTER_MONETIZED})
|
||||
AND (empty({FILTER_COUNTRY}) OR downloads.country IN {FILTER_COUNTRY})
|
||||
AND (empty({FILTER_REASON}) OR downloads.reason IN {FILTER_REASON})
|
||||
AND (empty({FILTER_GAME_VERSION}) OR downloads.game_version IN {FILTER_GAME_VERSION})
|
||||
AND (empty({FILTER_LOADER}) OR downloads.loader IN {FILTER_LOADER})
|
||||
GROUP BY bucket, source_project_id, project_id, domain, user_agent, version_id, dependent_on_version_id, monetized, country, reason, game_version, loader"
|
||||
GROUP BY bucket, source_project_id, project_id, domain, user_agent, version_id, monetized, country, reason, game_version, loader"
|
||||
)
|
||||
};
|
||||
|
||||
@@ -271,7 +249,6 @@ struct DownloadBucket {
|
||||
domain: Option<String>,
|
||||
user_agent: Option<DownloadSource>,
|
||||
version_id: Option<DBVersionId>,
|
||||
dependent_project_id: Option<DBProjectId>,
|
||||
monetized: Option<bool>,
|
||||
country: Option<String>,
|
||||
reason: Option<DownloadReason>,
|
||||
@@ -279,79 +256,12 @@ struct DownloadBucket {
|
||||
loader: Option<String>,
|
||||
}
|
||||
|
||||
async fn fetch_dependent_on_version_filter(
|
||||
metrics: &Metrics<ProjectDownloadsField, ProjectDownloadsFilters>,
|
||||
pool: &PgPool,
|
||||
) -> Result<Vec<VersionId>, ApiError> {
|
||||
if metrics.filter_by.dependent_project_id.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let project_ids = metrics
|
||||
.filter_by
|
||||
.dependent_project_id
|
||||
.iter()
|
||||
.map(|id| DBProjectId::from(*id).0)
|
||||
.collect::<Vec<_>>();
|
||||
let versions = sqlx::query!(
|
||||
"
|
||||
SELECT id FROM versions
|
||||
WHERE mod_id = ANY($1)
|
||||
",
|
||||
&project_ids
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch dependent project versions")?;
|
||||
|
||||
Ok(versions
|
||||
.into_iter()
|
||||
.map(|version| DBVersionId(version.id).into())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn fetch_dependent_version_projects(
|
||||
rows: &[DownloadRow],
|
||||
cx: &QueryClickhouseContext<'_>,
|
||||
) -> Result<HashMap<DBVersionId, DBProjectId>, ApiError> {
|
||||
let dependent_on_version_ids = rows
|
||||
.iter()
|
||||
.filter_map(|row| {
|
||||
(row.dependent_on_version_id.0 != 0)
|
||||
.then_some(row.dependent_on_version_id)
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
if dependent_on_version_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let dependent_on_version_ids =
|
||||
dependent_on_version_ids.into_iter().collect::<Vec<_>>();
|
||||
let versions =
|
||||
DBVersion::get_many(&dependent_on_version_ids, cx.pool, cx.redis)
|
||||
.await?;
|
||||
|
||||
Ok(versions
|
||||
.into_iter()
|
||||
.map(|version| (version.inner.id, version.inner.project_id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch(
|
||||
cx: &mut QueryClickhouseContext<'_>,
|
||||
metrics: &Metrics<ProjectDownloadsField, ProjectDownloadsFilters>,
|
||||
) -> Result<(), ApiError> {
|
||||
use ProjectDownloadsField as F;
|
||||
let uses = |field| metrics.bucket_by.contains(&field);
|
||||
let dependent_on_version_filter =
|
||||
fetch_dependent_on_version_filter(metrics, cx.pool).await?;
|
||||
if !metrics.filter_by.dependent_project_id.is_empty()
|
||||
&& dependent_on_version_filter.is_empty()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let use_columns = &[
|
||||
("use_project_id", uses(F::ProjectId)),
|
||||
("use_domain", uses(F::Domain)),
|
||||
@@ -360,7 +270,6 @@ pub(crate) async fn fetch(
|
||||
uses(F::UserAgent) || !metrics.filter_by.user_agent.is_empty(),
|
||||
),
|
||||
("use_version_id", uses(F::VersionId)),
|
||||
("use_dependent_project_id", uses(F::DependentProjectId)),
|
||||
("use_monetized", uses(F::Monetized)),
|
||||
("use_country", uses(F::Country)),
|
||||
("use_reason", uses(F::Reason)),
|
||||
@@ -381,7 +290,6 @@ pub(crate) async fn fetch(
|
||||
for filter_param in [
|
||||
ClickhouseFilterParam::String(&metrics.filter_by.domain),
|
||||
ClickhouseFilterParam::VersionId(&metrics.filter_by.version_id),
|
||||
ClickhouseFilterParam::VersionId(&dependent_on_version_filter),
|
||||
ClickhouseFilterParam::Bool(
|
||||
"filter_monetized",
|
||||
&metrics.filter_by.monetized,
|
||||
@@ -400,17 +308,9 @@ pub(crate) async fn fetch(
|
||||
.any(|(column_name, used)| *column_name == name && *used)
|
||||
};
|
||||
let mut cursor = query.fetch::<DownloadRow>()?;
|
||||
let mut rows = Vec::new();
|
||||
|
||||
while let Some(row) = cursor.next().await? {
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
let dependent_version_projects =
|
||||
fetch_dependent_version_projects(&rows, cx).await?;
|
||||
let mut buckets = HashMap::<DownloadBucket, u64>::new();
|
||||
|
||||
for row in rows {
|
||||
while let Some(row) = cursor.next().await? {
|
||||
let normalized_source = normalize_download_source(&row.user_agent);
|
||||
if !metrics.filter_by.user_agent.is_empty()
|
||||
&& !normalized_source.as_ref().is_some_and(|source| {
|
||||
@@ -428,15 +328,6 @@ pub(crate) async fn fetch(
|
||||
.then_some(normalized_source)
|
||||
.flatten(),
|
||||
version_id: uses_column("use_version_id").then_some(row.version_id),
|
||||
dependent_project_id: if uses(F::DependentProjectId)
|
||||
&& row.dependent_on_version_id.0 != 0
|
||||
{
|
||||
dependent_version_projects
|
||||
.get(&row.dependent_on_version_id)
|
||||
.copied()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
monetized: if uses_column("use_monetized") {
|
||||
match row.monetized {
|
||||
0 => Some(false),
|
||||
@@ -491,9 +382,6 @@ pub(crate) async fn fetch(
|
||||
version_id: key
|
||||
.version_id
|
||||
.and_then(none_if_zero_version_id),
|
||||
dependent_project_id: key
|
||||
.dependent_project_id
|
||||
.map(Into::into),
|
||||
monetized: key.monetized,
|
||||
country: key.country,
|
||||
reason: key.reason,
|
||||
@@ -580,7 +468,6 @@ static DOWNLOAD_SOURCE_PATTERNS: LazyLock<Vec<(Regex, DownloadSourcePattern)>> =
|
||||
(r"^unsup", P::Named("unsup")),
|
||||
(r"nothub/mrpack-install", P::Named("mrpack-install")),
|
||||
(r"^(packwiz-installer|packwiz/)", P::Named("Packwiz")),
|
||||
(r"^mrpack4server", P::Named("mrpack4server")),
|
||||
(
|
||||
r"^(Mozilla/|Chrome/|Chromium/|Firefox/|Safari/|AppleWebKit/|Edg/|OPR/)",
|
||||
P::Website,
|
||||
|
||||
@@ -24,8 +24,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
AuthenticationError,
|
||||
checks::{filter_visible_project_ids, filter_visible_version_ids},
|
||||
AuthenticationError, checks::filter_visible_version_ids,
|
||||
get_user_from_headers,
|
||||
},
|
||||
database::{
|
||||
@@ -43,7 +42,7 @@ use crate::{
|
||||
projects::ProjectStatus,
|
||||
teams::ProjectPermissions,
|
||||
threads::MessageBody,
|
||||
v3::{analytics::DownloadReason, projects::Project},
|
||||
v3::analytics::DownloadReason,
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
routes::ApiError,
|
||||
@@ -128,9 +127,6 @@ pub struct GetResponse {
|
||||
/// time interval of metrics collection. The number of slices is determined
|
||||
/// by [`GetRequest::time_range`].
|
||||
pub metrics: Vec<TimeSlice>,
|
||||
/// Project metadata for projects referenced in the response metrics.
|
||||
#[serde(default)]
|
||||
pub projects: HashMap<ProjectId, Project>,
|
||||
/// List of events associated with projects that were requested.
|
||||
pub project_events: Vec<ProjectAnalyticsEvent>,
|
||||
}
|
||||
@@ -322,8 +318,6 @@ pub async fn fetch_analytics(
|
||||
|
||||
let mut query_clickhouse_cx = QueryClickhouseContext {
|
||||
clickhouse: &clickhouse,
|
||||
pool: &pool,
|
||||
redis: &redis,
|
||||
req: &req,
|
||||
time_slices: &mut time_slices,
|
||||
project_ids: &project_ids,
|
||||
@@ -398,12 +392,8 @@ pub async fn fetch_analytics(
|
||||
.await?;
|
||||
}
|
||||
|
||||
let projects =
|
||||
fetch_response_projects(&mut time_slices, &user, &pool, &redis).await?;
|
||||
|
||||
Ok(web::Json(GetResponse {
|
||||
metrics: time_slices,
|
||||
projects,
|
||||
project_events,
|
||||
}))
|
||||
}
|
||||
@@ -472,88 +462,6 @@ pub(crate) fn normalize_loader_for_project(
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_response_projects(
|
||||
time_slices: &mut [TimeSlice],
|
||||
user: &crate::models::users::User,
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
) -> Result<HashMap<ProjectId, Project>, ApiError> {
|
||||
let mut project_ids = HashSet::<DBProjectId>::new();
|
||||
|
||||
for time_slice in &*time_slices {
|
||||
for data in &time_slice.0 {
|
||||
let AnalyticsData::Project(project) = data else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let source_project_id = DBProjectId::from(project.source_project);
|
||||
if source_project_id.0 != 0 {
|
||||
project_ids.insert(source_project_id);
|
||||
}
|
||||
if let ProjectMetrics::Downloads(downloads) = &project.metrics
|
||||
&& let Some(dependent_project_id) =
|
||||
downloads.dependent_project_id
|
||||
{
|
||||
project_ids.insert(dependent_project_id.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let project_ids = project_ids.into_iter().collect::<Vec<_>>();
|
||||
let projects = DBProject::get_many_ids(&project_ids, pool, redis).await?;
|
||||
let visible_project_ids = filter_visible_project_ids(
|
||||
projects.iter().map(|project| &project.inner).collect(),
|
||||
&Some(user.clone()),
|
||||
pool,
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
filter_response_project_ids(time_slices, &visible_project_ids);
|
||||
|
||||
Ok(projects
|
||||
.into_iter()
|
||||
.filter(|project| visible_project_ids.contains(&project.inner.id))
|
||||
.map(|project| {
|
||||
let project_id = project.inner.id.into();
|
||||
(project_id, Project::from(project))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn filter_response_project_ids(
|
||||
time_slices: &mut [TimeSlice],
|
||||
visible_project_ids: &HashSet<DBProjectId>,
|
||||
) {
|
||||
for time_slice in time_slices {
|
||||
time_slice.0.retain_mut(|data| {
|
||||
let AnalyticsData::Project(project) = data else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let source_project_id = DBProjectId::from(project.source_project);
|
||||
if source_project_id.0 != 0
|
||||
&& !visible_project_ids.contains(&source_project_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if let ProjectMetrics::Downloads(downloads) = &mut project.metrics
|
||||
&& let Some(dependent_project_id) =
|
||||
downloads.dependent_project_id
|
||||
&& !visible_project_ids
|
||||
.contains(&DBProjectId::from(dependent_project_id))
|
||||
{
|
||||
downloads.dependent_project_id = None;
|
||||
}
|
||||
|
||||
true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_project_status_change_events(
|
||||
project_ids: &[DBProjectId],
|
||||
time_range: &TimeRange,
|
||||
@@ -608,8 +516,6 @@ async fn fetch_project_status_change_events(
|
||||
|
||||
pub(crate) struct QueryClickhouseContext<'a> {
|
||||
pub(crate) clickhouse: &'a clickhouse::Client,
|
||||
pub(crate) pool: &'a PgPool,
|
||||
pub(crate) redis: &'a RedisPool,
|
||||
pub(crate) req: &'a GetRequest,
|
||||
pub(crate) time_slices: &'a mut [TimeSlice],
|
||||
pub(crate) project_ids: &'a [DBProjectId],
|
||||
@@ -969,7 +875,6 @@ mod tests {
|
||||
}),
|
||||
})]),
|
||||
],
|
||||
projects: HashMap::new(),
|
||||
project_events: vec![],
|
||||
};
|
||||
let target = json!({
|
||||
@@ -996,7 +901,6 @@ mod tests {
|
||||
}
|
||||
]
|
||||
],
|
||||
"projects": {},
|
||||
"project_events": []
|
||||
});
|
||||
|
||||
|
||||
@@ -325,16 +325,16 @@ impl TypesenseClient {
|
||||
filter_by: &str,
|
||||
) -> Result<()> {
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
&format!(
|
||||
.request(
|
||||
Method::DELETE,
|
||||
&format!(
|
||||
"/collections/{collection}/documents?filter_by={}&batch_size=1000",
|
||||
urlencoding::encode(filter_by)
|
||||
),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.wrap_err("failed to DELETE Typesense documents by filter")?;
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.wrap_err("failed to DELETE Typesense documents by filter")?;
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -478,20 +478,6 @@ impl SearchField {
|
||||
sort: false,
|
||||
optional: true,
|
||||
},
|
||||
SearchField::DependencyProjectIds => TypesenseFieldSpec {
|
||||
path: "dependency_project_ids",
|
||||
ty: "string[]",
|
||||
facet: true,
|
||||
sort: false,
|
||||
optional: true,
|
||||
},
|
||||
SearchField::CompatibleDependencyProjectIds => TypesenseFieldSpec {
|
||||
path: "compatible_dependency_project_ids",
|
||||
ty: "string[]",
|
||||
facet: true,
|
||||
sort: false,
|
||||
optional: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -540,7 +526,6 @@ impl Typesense {
|
||||
json!({"name": "minecraft_java_server.verified_plays_2w", "type": "int64", "sort": true, "optional": true}),
|
||||
json!({"name": "minecraft_java_server.is_online", "type": "bool", "sort": true, "optional": true}),
|
||||
json!({"name": "minecraft_java_server.ping.data.players_online", "type": "int32", "sort": true, "optional": true}),
|
||||
json!({"name": "dependencies", "type": "object[]", "optional": true}),
|
||||
];
|
||||
fields.extend(TYPESENSE_SEARCH_FIELDS.iter().cloned());
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ use crate::database::models::{
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::exp;
|
||||
use crate::models::ids::ProjectId;
|
||||
use crate::models::projects::{DependencyType, from_duplicate_version_fields};
|
||||
use crate::models::projects::from_duplicate_version_fields;
|
||||
use crate::models::v2::projects::LegacyProject;
|
||||
use crate::routes::v2_reroute;
|
||||
use crate::search::{SearchProjectDependency, UploadSearchProject};
|
||||
use crate::search::UploadSearchProject;
|
||||
use crate::util::error::Context;
|
||||
|
||||
fn normalize_for_search(s: &str) -> String {
|
||||
@@ -65,12 +65,6 @@ pub async fn index_local(
|
||||
components: exp::ProjectSerial,
|
||||
}
|
||||
|
||||
let searchable_statuses =
|
||||
crate::models::projects::ProjectStatus::iterator()
|
||||
.filter(|x| x.is_searchable())
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
let db_projects = sqlx::query!(
|
||||
r#"
|
||||
SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,
|
||||
@@ -82,7 +76,10 @@ pub async fn index_local(
|
||||
ORDER BY m.id ASC
|
||||
LIMIT $2;
|
||||
"#,
|
||||
&searchable_statuses,
|
||||
&*crate::models::projects::ProjectStatus::iterator()
|
||||
.filter(|x| x.is_searchable())
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
limit,
|
||||
cursor,
|
||||
)
|
||||
@@ -121,54 +118,6 @@ pub async fn index_local(
|
||||
return Ok((vec![], i64::MAX));
|
||||
};
|
||||
|
||||
info!("Indexing local dependencies!");
|
||||
|
||||
let dependencies: DashMap<DBProjectId, Vec<SearchProjectDependency>> =
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT DISTINCT v.mod_id dependent_project_id,
|
||||
d.mod_dependency_id dependency_project_id,
|
||||
d.dependency_type dependency_type,
|
||||
m.name dependency_name,
|
||||
m.slug dependency_slug,
|
||||
m.icon_url dependency_icon_url
|
||||
FROM versions v
|
||||
INNER JOIN dependencies d ON d.dependent_id = v.id
|
||||
INNER JOIN mods m ON m.id = d.mod_dependency_id
|
||||
WHERE v.mod_id = ANY($1)
|
||||
AND d.mod_dependency_id IS NOT NULL
|
||||
AND m.status = ANY($2)
|
||||
",
|
||||
&project_ids,
|
||||
&searchable_statuses,
|
||||
)
|
||||
.fetch(pool)
|
||||
.try_fold(
|
||||
DashMap::new(),
|
||||
|acc: DashMap<DBProjectId, Vec<SearchProjectDependency>>, m| {
|
||||
if let Some(dependency_project_id) = m.dependency_project_id {
|
||||
acc.entry(DBProjectId(m.dependent_project_id))
|
||||
.or_default()
|
||||
.push(SearchProjectDependency {
|
||||
project_id: ProjectId::from(DBProjectId(
|
||||
dependency_project_id,
|
||||
))
|
||||
.to_string(),
|
||||
dependency_type: DependencyType::from_string(
|
||||
&m.dependency_type,
|
||||
),
|
||||
name: m.dependency_name,
|
||||
slug: m.dependency_slug,
|
||||
icon_url: m.dependency_icon_url,
|
||||
});
|
||||
}
|
||||
|
||||
async move { Ok(acc) }
|
||||
},
|
||||
)
|
||||
.await
|
||||
.wrap_err("failed to fetch project dependencies")?;
|
||||
|
||||
struct PartialGallery {
|
||||
url: String,
|
||||
featured: bool,
|
||||
@@ -397,26 +346,6 @@ pub async fn index_local(
|
||||
} else {
|
||||
(vec![], vec![])
|
||||
};
|
||||
let dependencies = dependencies
|
||||
.get(&project.id)
|
||||
.map(|x| x.clone())
|
||||
.unwrap_or_default();
|
||||
let dependency_project_ids = dependencies
|
||||
.iter()
|
||||
.map(|dependency| dependency.project_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let compatible_dependency_project_ids = dependencies
|
||||
.iter()
|
||||
.filter(|dependency| {
|
||||
matches!(
|
||||
dependency.dependency_type,
|
||||
DependencyType::Required
|
||||
| DependencyType::Optional
|
||||
| DependencyType::Embedded
|
||||
)
|
||||
})
|
||||
.map(|dependency| dependency.project_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(versions) = versions.remove(&project.id) {
|
||||
// Aggregated project loader fields
|
||||
@@ -557,10 +486,6 @@ pub async fn index_local(
|
||||
featured_gallery: featured_gallery.clone(),
|
||||
open_source,
|
||||
color: project.color.map(|x| x as u32),
|
||||
dependency_project_ids: dependency_project_ids.clone(),
|
||||
compatible_dependency_project_ids:
|
||||
compatible_dependency_project_ids.clone(),
|
||||
dependencies: dependencies.clone(),
|
||||
loader_fields,
|
||||
project_loader_fields: project_loader_fields.clone(),
|
||||
// 'loaders' is aggregate of all versions' loaders
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::database::redis::RedisPool;
|
||||
use crate::models::exp;
|
||||
use crate::models::exp::minecraft::JavaServerPing;
|
||||
use crate::models::ids::{ProjectId, VersionId};
|
||||
use crate::models::projects::DependencyType;
|
||||
use crate::queue::server_ping;
|
||||
use crate::routes::ApiError;
|
||||
use crate::{database::PgPool, env::ENV};
|
||||
@@ -196,8 +195,6 @@ pub enum SearchField {
|
||||
MinecraftJavaServerContentKind,
|
||||
MinecraftJavaServerContentSupportedGameVersions,
|
||||
MinecraftJavaServerPingData,
|
||||
DependencyProjectIds,
|
||||
CompatibleDependencyProjectIds,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -251,12 +248,6 @@ pub struct UploadSearchProject {
|
||||
pub version_published_timestamp: i64,
|
||||
pub open_source: bool,
|
||||
pub color: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub dependency_project_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub compatible_dependency_project_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub dependencies: Vec<SearchProjectDependency>,
|
||||
|
||||
// Hidden fields to get the Project model out of the search results.
|
||||
pub loaders: Vec<String>, // Search uses loaders as categories- this is purely for the Project model.
|
||||
@@ -268,15 +259,6 @@ pub struct UploadSearchProject {
|
||||
pub loader_fields: HashMap<String, Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct SearchProjectDependency {
|
||||
pub project_id: String,
|
||||
pub dependency_type: DependencyType,
|
||||
pub name: String,
|
||||
pub slug: Option<String>,
|
||||
pub icon_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct SearchResults {
|
||||
pub hits: Vec<ResultSearchProject>,
|
||||
@@ -313,12 +295,6 @@ pub struct ResultSearchProject {
|
||||
pub gallery: Vec<String>,
|
||||
pub featured_gallery: Option<String>,
|
||||
pub color: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub dependency_project_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub compatible_dependency_project_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub dependencies: Vec<SearchProjectDependency>,
|
||||
|
||||
// Hidden fields to get the Project model out of the search results.
|
||||
pub loaders: Vec<String>, // Search uses loaders as categories- this is purely for the Project model.
|
||||
@@ -356,10 +332,6 @@ impl From<UploadSearchProject> for ResultSearchProject {
|
||||
gallery: source.gallery,
|
||||
featured_gallery: source.featured_gallery,
|
||||
color: source.color,
|
||||
dependency_project_ids: source.dependency_project_ids,
|
||||
compatible_dependency_project_ids: source
|
||||
.compatible_dependency_project_ids,
|
||||
dependencies: source.dependencies,
|
||||
loaders: source.loaders,
|
||||
project_loader_fields: source.project_loader_fields,
|
||||
components: source.components,
|
||||
|
||||
@@ -214,31 +214,12 @@ pub async fn setup_search_projects(
|
||||
USER_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
let project_1 = api
|
||||
.get_project_deserialized_common(
|
||||
&format!("{test_name}-searchable-project-1"),
|
||||
USER_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
let modify_json = serde_json::from_value(json!([
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/dependencies",
|
||||
"value": [
|
||||
{
|
||||
"project_id": project_1.id,
|
||||
"dependency_type": "required"
|
||||
}
|
||||
]
|
||||
}
|
||||
]))
|
||||
.unwrap();
|
||||
api.add_public_version(
|
||||
project_7.id,
|
||||
"1.0.0",
|
||||
TestFile::build_random_jar(),
|
||||
None,
|
||||
Some(modify_json),
|
||||
None,
|
||||
USER_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -4,12 +4,11 @@ use common::database::*;
|
||||
|
||||
use common::dummy_data::DUMMY_CATEGORIES;
|
||||
|
||||
use ariadne::ids::base62_impl::{parse_base62, to_base62};
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use common::environment::TestEnvironment;
|
||||
use common::environment::with_test_environment;
|
||||
use common::search::setup_search_projects;
|
||||
use futures::stream::StreamExt;
|
||||
use labrinth::models::projects::DependencyType;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::api_common::Api;
|
||||
@@ -30,12 +29,6 @@ async fn search_projects() {
|
||||
|
||||
let api = &test_env.api;
|
||||
let test_name = test_env.db.database_name.clone();
|
||||
let dependency_project_id = id_conversion
|
||||
.iter()
|
||||
.find_map(|(project_id, test_id)| {
|
||||
(*test_id == 1).then_some(to_base62(*project_id))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Pairs of:
|
||||
// 1. vec of search facets
|
||||
@@ -90,18 +83,6 @@ async fn search_projects() {
|
||||
json!([["categories:fabric"], ["project_types:modpack"]]),
|
||||
vec![4],
|
||||
),
|
||||
(
|
||||
json!([[format!(
|
||||
"dependency_project_ids:{dependency_project_id}"
|
||||
)]]),
|
||||
vec![7],
|
||||
),
|
||||
(
|
||||
json!([[format!(
|
||||
"compatible_dependency_project_ids:{dependency_project_id}"
|
||||
)]]),
|
||||
vec![7],
|
||||
),
|
||||
];
|
||||
// TODO: versions, game versions
|
||||
// Untested:
|
||||
@@ -142,46 +123,6 @@ async fn search_projects() {
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let projects = api
|
||||
.search_deserialized(
|
||||
Some(&format!("&{test_name}")),
|
||||
Some(json!([[format!(
|
||||
"dependency_project_ids:{dependency_project_id}"
|
||||
)]])),
|
||||
USER_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(projects.total_hits, 1);
|
||||
assert_eq!(projects.hits[0].dependency_project_ids.len(), 1);
|
||||
assert_eq!(
|
||||
projects.hits[0].dependency_project_ids[0],
|
||||
dependency_project_id
|
||||
);
|
||||
assert_eq!(
|
||||
projects.hits[0].compatible_dependency_project_ids.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
projects.hits[0].compatible_dependency_project_ids[0],
|
||||
dependency_project_id
|
||||
);
|
||||
assert_eq!(projects.hits[0].dependencies.len(), 1);
|
||||
assert_eq!(
|
||||
projects.hits[0].dependencies[0].project_id,
|
||||
dependency_project_id
|
||||
);
|
||||
assert_eq!(
|
||||
projects.hits[0].dependencies[0].dependency_type,
|
||||
DependencyType::Required
|
||||
);
|
||||
assert!(
|
||||
projects.hits[0].dependencies[0]
|
||||
.slug
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("searchable-project-1")
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -79,16 +79,6 @@ pub async fn search_users_escapes_wildcards_and_limits_results() {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO users (id, username, email, role)
|
||||
VALUES (2100, 'prefix_under_score', 'prefix_under_score@modrinth.com', 'developer')
|
||||
",
|
||||
)
|
||||
.execute(&*test_env.db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = test::TestRequest::get()
|
||||
.uri("/v3/users/search?query=prefix")
|
||||
.to_request();
|
||||
@@ -114,17 +104,6 @@ pub async fn search_users_escapes_wildcards_and_limits_results() {
|
||||
test::read_body_json(resp).await;
|
||||
assert!(users.is_empty());
|
||||
|
||||
let req = test::TestRequest::get()
|
||||
.uri("/v3/users/search?query=prefix_")
|
||||
.to_request();
|
||||
let resp = test_env.call(req).await;
|
||||
assert_status!(&resp, actix_http::StatusCode::OK);
|
||||
|
||||
let users: Vec<serde_json::Value> =
|
||||
test::read_body_json(resp).await;
|
||||
assert_eq!(users.len(), 1);
|
||||
assert_eq!(users[0]["username"], "prefix_under_score");
|
||||
|
||||
let req = test::TestRequest::get()
|
||||
.uri("/v3/users/search?query=%20%20")
|
||||
.to_request();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature'
|
||||
import { ModrinthApiError } from '../core/errors'
|
||||
import type { RequestContext } from '../types/request'
|
||||
import { getNodeBaseUrl } from '../utils/node-url'
|
||||
|
||||
/**
|
||||
* Node authentication credentials
|
||||
@@ -108,7 +107,7 @@ export class NodeAuthFeature extends AbstractFeature {
|
||||
}
|
||||
|
||||
private applyAuth(context: RequestContext, auth: NodeAuth): void {
|
||||
const baseUrl = getNodeBaseUrl(auth.url)
|
||||
const baseUrl = `https://${auth.url.replace(/\/modrinth\/v\d+\/fs\/?$/, '')}`
|
||||
context.url = this.buildUrl(context.path, baseUrl, context.options.version)
|
||||
|
||||
context.options.headers = {
|
||||
|
||||
@@ -50,5 +50,4 @@ export {
|
||||
parseSyncEventData,
|
||||
SseParser,
|
||||
} from './utils/sse'
|
||||
export { getNodeWebSocketUrl } from './utils/node-url'
|
||||
export type { Override, RawDecimal } from './utils/types'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { UploadHandle, UploadProgress } from '../../../types/upload'
|
||||
import { getNodeBaseUrl } from '../../../utils/node-url'
|
||||
import type { Archon } from '../../archon/types'
|
||||
import type { Kyros } from '../types'
|
||||
|
||||
@@ -12,7 +11,7 @@ export class KyrosFilesV0Module extends AbstractModule {
|
||||
}
|
||||
|
||||
private getNodeBaseUrl(auth: NodeFsAuth): string {
|
||||
return getNodeBaseUrl(auth.url)
|
||||
return `https://${auth.url.replace(/\/modrinth\/v\d+\/fs\/?$/, '')}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,18 +29,4 @@ export class LabrinthAuthInternalModule extends AbstractModule {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signed Discord community bot handoff URL
|
||||
*/
|
||||
public async createDiscordCommunityLink(): Promise<Labrinth.Auth.Internal.DiscordCommunityLinkResponse> {
|
||||
return this.client.request<Labrinth.Auth.Internal.DiscordCommunityLinkResponse>(
|
||||
'/auth/discord-community-link',
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,10 +510,6 @@ export namespace Labrinth {
|
||||
export type SubscriptionStatus = {
|
||||
subscribed: boolean
|
||||
}
|
||||
|
||||
export type DiscordCommunityLinkResponse = {
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
export namespace v2 {
|
||||
|
||||
@@ -2,7 +2,6 @@ import mitt from 'mitt'
|
||||
|
||||
import { AbstractWebSocketClient, type WebSocketConnection } from '../core/abstract-websocket'
|
||||
import type { Archon } from '../modules/archon/types'
|
||||
import { getNodeWebSocketUrl } from '../utils/node-url'
|
||||
|
||||
type WSEventMap = {
|
||||
[K in Archon.Websocket.v0.WSEvent as `${string}:${K['event']}`]: K
|
||||
@@ -20,7 +19,7 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const ws = new WebSocket(getNodeWebSocketUrl(auth.url))
|
||||
const ws = new WebSocket(`wss://${auth.url}`)
|
||||
|
||||
const connection: WebSocketConnection = {
|
||||
serverId,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
const NODE_FS_PATH_REGEX = /\/modrinth\/v\d+\/fs\/?$/
|
||||
const HTTP_SCHEME_REGEX = /^https?:\/\//i
|
||||
const WS_SCHEME_REGEX = /^wss?:\/\//i
|
||||
const HTTP_SECURE_SCHEME_REGEX = /^https:\/\//i
|
||||
const HTTP_INSECURE_SCHEME_REGEX = /^http:\/\//i
|
||||
|
||||
export function getNodeBaseUrl(url: string): string {
|
||||
const baseUrl = url.replace(NODE_FS_PATH_REGEX, '')
|
||||
return HTTP_SCHEME_REGEX.test(baseUrl) ? baseUrl : `https://${baseUrl}`
|
||||
}
|
||||
|
||||
export function getNodeWebSocketUrl(url: string): string {
|
||||
if (WS_SCHEME_REGEX.test(url)) return url
|
||||
if (HTTP_SECURE_SCHEME_REGEX.test(url)) return url.replace(HTTP_SECURE_SCHEME_REGEX, 'wss://')
|
||||
if (HTTP_INSECURE_SCHEME_REGEX.test(url)) return url.replace(HTTP_INSECURE_SCHEME_REGEX, 'ws://')
|
||||
|
||||
return `wss://${url}`
|
||||
}
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE custom_minecraft_skins SET display_order = display_order + 1 WHERE minecraft_user_uuid = ? AND display_order >= ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "11875ae76d8a61099dff08e9792fd6f231ecffd45315ed2b76253d02bbd531ff"
|
||||
}
|
||||
Generated
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "display_order",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "18f04a0f6c262995b5f1eee10c2c5a396443ead9a9e295f6eea0986e40d65449"
|
||||
}
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE custom_minecraft_skins SET display_order = display_order + 1 WHERE minecraft_user_uuid = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3538a56e37f456ca57998bd4753da27bff30801646436358acf57eb4cdfa25ad"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4c8063f9ce2fd7deec9b69e0b2c1055fe47287d7f99be41215c25c1019d439b9"
|
||||
}
|
||||
Generated
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT texture_key FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY display_order ASC, rowid ASC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "texture_key",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "69c5a93676809ab6480fc41a2e6e34b16057db8b0eab08a9d8b8dce961c6f81c"
|
||||
}
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id, display_order) VALUES (?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9a9b8b5c0b646b841b73461980b37ad3b03ecf99d1484c402694799dc08271f5"
|
||||
}
|
||||
+3
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
"query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -17,11 +17,6 @@
|
||||
"name": "cape_id: Hyphenated",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_order",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -30,9 +25,8 @@
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a1217622558d50ee18e7ba0d85e991032037aaccfeca64dc076f0dcc826c108a"
|
||||
"hash": "a0b0ff0ae4b88d5df9d15d3427ab4e9a6ff21cffdc9c2f3d6860e245949d313d"
|
||||
}
|
||||
+3
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY display_order ASC, rowid ASC LIMIT ? OFFSET ?",
|
||||
"query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? ORDER BY rowid ASC LIMIT ? OFFSET ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -17,11 +17,6 @@
|
||||
"name": "cape_id: Hyphenated",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "display_order",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -30,9 +25,8 @@
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "57df2984dd65b408473ed1ae86b0215f46946f9b9043d4b037e3a4d5b27e0373"
|
||||
"hash": "aae88809ada53e13441352e315f68169cfd8226b57bacd8c270d7777fc6883ac"
|
||||
}
|
||||
Generated
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "display_order!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "bab7f687a8397975747cfe194a0e2cbc2e701584d8b3394a1aa686e3ff4d47f5"
|
||||
}
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE custom_minecraft_skins SET display_order = ? WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d68c41fb2cb182aa8fc23422c55bd5b728ba93d9e3bb6f3db57ac1c83574d508"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
ALTER TABLE custom_minecraft_skins
|
||||
ADD COLUMN display_order INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE custom_minecraft_skins
|
||||
SET display_order = (
|
||||
SELECT COUNT(*)
|
||||
FROM custom_minecraft_skins AS previous
|
||||
WHERE previous.minecraft_user_uuid = custom_minecraft_skins.minecraft_user_uuid
|
||||
AND previous.rowid <= custom_minecraft_skins.rowid
|
||||
) - 1;
|
||||
|
||||
CREATE INDEX custom_minecraft_skins_user_display_order
|
||||
ON custom_minecraft_skins (minecraft_user_uuid, display_order);
|
||||
@@ -65,9 +65,7 @@ use crate::{
|
||||
ErrorKind, State,
|
||||
state::{
|
||||
MinecraftCharacterExpressionState, MinecraftProfile,
|
||||
minecraft_skins::{
|
||||
CustomMinecraftSkin, CustomMinecraftSkinInsertPosition, mojang_api,
|
||||
},
|
||||
minecraft_skins::{CustomMinecraftSkin, mojang_api},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -270,11 +268,12 @@ pub async fn get_available_capes() -> crate::Result<Vec<Cape>> {
|
||||
.await?
|
||||
.ok_or(ErrorKind::NoCredentialsError)?;
|
||||
|
||||
let Some(profile) = selected_credentials.online_profile_fresh().await
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let profile = selected_credentials
|
||||
.online_profile_fresh()
|
||||
.await
|
||||
.ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable {
|
||||
user_name: selected_credentials.offline_profile.name.clone(),
|
||||
})?;
|
||||
let pending_skin_change = pending_effective_skin_change(profile.id).await;
|
||||
let pending_cape_id = pending_skin_change
|
||||
.as_ref()
|
||||
@@ -310,22 +309,16 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
.await?
|
||||
.ok_or(ErrorKind::NoCredentialsError)?;
|
||||
|
||||
let online_profile = selected_credentials.online_profile_fresh().await;
|
||||
let profile_id = online_profile
|
||||
.as_ref()
|
||||
.map_or(selected_credentials.offline_profile.id, |profile| {
|
||||
profile.id
|
||||
});
|
||||
let profile = selected_credentials
|
||||
.online_profile_fresh()
|
||||
.await
|
||||
.ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable {
|
||||
user_name: selected_credentials.offline_profile.name.clone(),
|
||||
})?;
|
||||
|
||||
let current_skin = online_profile
|
||||
.as_ref()
|
||||
.map(|profile| profile.current_skin())
|
||||
.transpose()?;
|
||||
let current_cape_id = online_profile
|
||||
.as_ref()
|
||||
.and_then(|profile| profile.current_cape())
|
||||
.map(|cape| cape.id);
|
||||
let pending_skin_change = pending_effective_skin_change(profile_id).await;
|
||||
let current_skin = profile.current_skin()?;
|
||||
let current_cape_id = profile.current_cape().map(|cape| cape.id);
|
||||
let pending_skin_change = pending_effective_skin_change(profile.id).await;
|
||||
let pending_unequip = pending_skin_change
|
||||
.as_ref()
|
||||
.is_some_and(PendingEffectiveSkinChange::is_unequip);
|
||||
@@ -333,15 +326,16 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
.as_ref()
|
||||
.and_then(PendingEffectiveSkinChange::skin);
|
||||
|
||||
let fallback_default_skin = get_fallback_default_skin()?;
|
||||
let fallback_default_skin = assets::DEFAULT_SKINS.first();
|
||||
let current_skin_texture_key = pending_skin.as_ref().map_or_else(
|
||||
|| {
|
||||
if pending_unequip {
|
||||
Arc::clone(&fallback_default_skin.texture_key)
|
||||
} else if let Some(current_skin) = current_skin {
|
||||
current_skin.texture_key()
|
||||
fallback_default_skin.map_or_else(
|
||||
|| current_skin.texture_key(),
|
||||
|skin| Arc::clone(&skin.texture_key),
|
||||
)
|
||||
} else {
|
||||
Arc::clone(&fallback_default_skin.texture_key)
|
||||
current_skin.texture_key()
|
||||
}
|
||||
},
|
||||
|skin| skin.texture_key.clone(),
|
||||
@@ -349,11 +343,10 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
let current_skin_variant = pending_skin.as_ref().map_or_else(
|
||||
|| {
|
||||
if pending_unequip {
|
||||
fallback_default_skin.variant
|
||||
} else if let Some(current_skin) = current_skin {
|
||||
current_skin.variant
|
||||
fallback_default_skin
|
||||
.map_or(current_skin.variant, |skin| skin.variant)
|
||||
} else {
|
||||
fallback_default_skin.variant
|
||||
current_skin.variant
|
||||
}
|
||||
},
|
||||
|skin| skin.variant,
|
||||
@@ -361,10 +354,8 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
let current_cape_id = pending_skin.as_ref().map_or(
|
||||
if pending_unequip {
|
||||
None
|
||||
} else if current_skin.is_some() {
|
||||
current_cape_id
|
||||
} else {
|
||||
None
|
||||
current_cape_id
|
||||
},
|
||||
|skin| skin.cape_id,
|
||||
);
|
||||
@@ -373,44 +364,42 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
let mut custom_skins = Vec::new();
|
||||
let mut saved_default_skins = Vec::new();
|
||||
|
||||
for mut custom_skin in CustomMinecraftSkin::get_all(profile_id, &state.pool)
|
||||
for mut custom_skin in CustomMinecraftSkin::get_all(profile.id, &state.pool)
|
||||
.await?
|
||||
.collect::<Vec<_>>()
|
||||
.await
|
||||
{
|
||||
let is_saved_default_skin =
|
||||
is_bundled_skin(&custom_skin.texture_key, custom_skin.variant);
|
||||
let current_skin_sync =
|
||||
if pending_skin.is_some() || current_skin.is_none() {
|
||||
SavedSkinSync {
|
||||
is_current_skin: custom_skin.texture_key
|
||||
== current_skin_texture_key.as_ref()
|
||||
&& custom_skin.variant == current_skin_variant
|
||||
&& custom_skin.cape_id == current_cape_id,
|
||||
settings_changed: false,
|
||||
}
|
||||
} else {
|
||||
sync_saved_skin_with_current_profile(
|
||||
&mut custom_skin,
|
||||
¤t_skin_texture_key,
|
||||
current_skin_variant,
|
||||
current_cape_id,
|
||||
)
|
||||
};
|
||||
let current_skin_sync = if pending_skin.is_some() {
|
||||
SavedSkinSync {
|
||||
is_current_skin: custom_skin.texture_key
|
||||
== current_skin_texture_key.as_ref()
|
||||
&& custom_skin.variant == current_skin_variant
|
||||
&& custom_skin.cape_id == current_cape_id,
|
||||
settings_changed: false,
|
||||
}
|
||||
} else {
|
||||
sync_saved_skin_with_current_profile(
|
||||
&mut custom_skin,
|
||||
¤t_skin_texture_key,
|
||||
current_skin_variant,
|
||||
current_cape_id,
|
||||
)
|
||||
};
|
||||
|
||||
let synced_texture_blob = if current_skin_sync.settings_changed {
|
||||
let texture_blob = custom_skin.texture_blob(&state.pool).await?;
|
||||
|
||||
if is_saved_default_skin && custom_skin.cape_id.is_none() {
|
||||
custom_skin.remove(profile_id, &state.pool).await?;
|
||||
custom_skin.remove(profile.id, &state.pool).await?;
|
||||
} else {
|
||||
CustomMinecraftSkin::add(
|
||||
profile_id,
|
||||
profile.id,
|
||||
&custom_skin.texture_key,
|
||||
&texture_blob,
|
||||
custom_skin.variant,
|
||||
custom_skin.cape_id,
|
||||
CustomMinecraftSkinInsertPosition::Bottom,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
@@ -456,6 +445,7 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
});
|
||||
}
|
||||
|
||||
custom_skins.sort_by(|a, b| a.texture.as_str().cmp(b.texture.as_str()));
|
||||
available_skins.extend(custom_skins);
|
||||
|
||||
for default_skin in assets::DEFAULT_SKINS.iter() {
|
||||
@@ -493,7 +483,7 @@ pub async fn get_available_skins() -> crate::Result<Vec<Skin>> {
|
||||
if let Some(mut skin) = pending_skin {
|
||||
skin.is_equipped = true;
|
||||
available_skins.push(skin);
|
||||
} else if let Some(current_skin) = current_skin {
|
||||
} else {
|
||||
available_skins.push(Skin {
|
||||
texture_key: current_skin_texture_key,
|
||||
name: current_skin.name.as_deref().map(Arc::from),
|
||||
@@ -536,7 +526,6 @@ pub async fn add_and_equip_custom_skin(
|
||||
&texture_blob,
|
||||
variant,
|
||||
cape_id,
|
||||
CustomMinecraftSkinInsertPosition::Top,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
@@ -609,21 +598,6 @@ async fn add_and_equip_custom_skin_now(
|
||||
let equipped_skin = profile.current_skin()?;
|
||||
let equipped_skin_texture_key = equipped_skin.texture_key();
|
||||
let equipped_skin_variant = equipped_skin.variant;
|
||||
let insert_position = if local_texture_key
|
||||
!= equipped_skin_texture_key.as_ref()
|
||||
{
|
||||
CustomMinecraftSkin::get_by_texture(
|
||||
profile.id,
|
||||
local_texture_key,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.map_or(CustomMinecraftSkinInsertPosition::Top, |skin| {
|
||||
CustomMinecraftSkinInsertPosition::At(skin.display_order)
|
||||
})
|
||||
} else {
|
||||
CustomMinecraftSkinInsertPosition::Top
|
||||
};
|
||||
|
||||
let persistence_result = if cape_id.is_none()
|
||||
&& is_bundled_skin(&equipped_skin_texture_key, equipped_skin_variant)
|
||||
@@ -632,7 +606,6 @@ async fn add_and_equip_custom_skin_now(
|
||||
texture_key: equipped_skin_texture_key.to_string(),
|
||||
variant: equipped_skin_variant,
|
||||
cape_id: None,
|
||||
display_order: 0,
|
||||
}
|
||||
.remove(profile.id, &state.pool)
|
||||
.await
|
||||
@@ -643,7 +616,6 @@ async fn add_and_equip_custom_skin_now(
|
||||
&texture_blob,
|
||||
variant,
|
||||
cape_id,
|
||||
insert_position,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
@@ -659,7 +631,6 @@ async fn add_and_equip_custom_skin_now(
|
||||
texture_key: local_texture_key.to_string(),
|
||||
variant,
|
||||
cape_id,
|
||||
display_order: 0,
|
||||
}
|
||||
.remove(profile.id, &state.pool)
|
||||
.await?;
|
||||
@@ -762,22 +733,6 @@ async fn persist_equipped_skin(
|
||||
let equipped_skin_variant = equipped_skin.variant;
|
||||
let texture_key_changed =
|
||||
skin.texture_key.as_ref() != equipped_skin_texture_key.as_ref();
|
||||
let insert_position = if texture_key_changed {
|
||||
CustomMinecraftSkin::get_by_texture(
|
||||
profile.id,
|
||||
&skin.texture_key,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.map_or(
|
||||
CustomMinecraftSkinInsertPosition::Bottom,
|
||||
|saved_skin| {
|
||||
CustomMinecraftSkinInsertPosition::At(saved_skin.display_order)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
CustomMinecraftSkinInsertPosition::Bottom
|
||||
};
|
||||
|
||||
if skin.cape_id.is_none()
|
||||
&& is_bundled_skin(&equipped_skin_texture_key, equipped_skin_variant)
|
||||
@@ -786,7 +741,6 @@ async fn persist_equipped_skin(
|
||||
texture_key: equipped_skin_texture_key.to_string(),
|
||||
variant: equipped_skin_variant,
|
||||
cape_id: None,
|
||||
display_order: 0,
|
||||
}
|
||||
.remove(profile.id, &state.pool)
|
||||
.await?;
|
||||
@@ -798,7 +752,6 @@ async fn persist_equipped_skin(
|
||||
texture_blob,
|
||||
equipped_skin_variant,
|
||||
skin.cape_id,
|
||||
insert_position,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
@@ -809,7 +762,6 @@ async fn persist_equipped_skin(
|
||||
texture_key: skin.texture_key.to_string(),
|
||||
variant: skin.variant,
|
||||
cape_id: skin.cape_id,
|
||||
display_order: 0,
|
||||
}
|
||||
.remove(profile.id, &state.pool)
|
||||
.await?;
|
||||
@@ -836,7 +788,6 @@ pub async fn remove_custom_skin(skin: Skin) -> crate::Result<()> {
|
||||
texture_key: skin.texture_key.to_string(),
|
||||
variant: skin.variant,
|
||||
cape_id: skin.cape_id,
|
||||
display_order: 0,
|
||||
}
|
||||
.remove(selected_credentials.offline_profile.id, &state.pool)
|
||||
.await?;
|
||||
@@ -850,25 +801,6 @@ pub async fn remove_custom_skin(skin: Skin) -> crate::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persists the custom saved skin order for the currently selected Minecraft profile.
|
||||
#[tracing::instrument]
|
||||
pub async fn set_custom_skin_order(
|
||||
texture_keys: Vec<String>,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
|
||||
let selected_credentials = Credentials::get_default_credential(&state.pool)
|
||||
.await?
|
||||
.ok_or(ErrorKind::NoCredentialsError)?;
|
||||
|
||||
CustomMinecraftSkin::set_order(
|
||||
selected_credentials.offline_profile.id,
|
||||
&texture_keys,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Adds or updates a saved skin locally without applying it to Mojang.
|
||||
///
|
||||
/// This is used by the skin editor. If the edited skin is currently equipped, the caller should
|
||||
@@ -899,26 +831,12 @@ pub async fn save_custom_skin(
|
||||
Arc::clone(&skin.texture_key)
|
||||
};
|
||||
let cape_id = cape.map(|cape| cape.id);
|
||||
let insert_position = if replace_texture && old_texture_key != texture_key {
|
||||
CustomMinecraftSkin::get_by_texture(
|
||||
selected_credentials.offline_profile.id,
|
||||
&old_texture_key,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.map_or(CustomMinecraftSkinInsertPosition::Bottom, |skin| {
|
||||
CustomMinecraftSkinInsertPosition::At(skin.display_order)
|
||||
})
|
||||
} else {
|
||||
CustomMinecraftSkinInsertPosition::Bottom
|
||||
};
|
||||
|
||||
if cape_id.is_none() && is_bundled_skin(&texture_key, variant) {
|
||||
CustomMinecraftSkin {
|
||||
texture_key: texture_key.to_string(),
|
||||
variant,
|
||||
cape_id: None,
|
||||
display_order: 0,
|
||||
}
|
||||
.remove(selected_credentials.offline_profile.id, &state.pool)
|
||||
.await?;
|
||||
@@ -929,7 +847,6 @@ pub async fn save_custom_skin(
|
||||
&texture_blob,
|
||||
variant,
|
||||
cape_id,
|
||||
insert_position,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
@@ -940,7 +857,6 @@ pub async fn save_custom_skin(
|
||||
texture_key: old_texture_key.to_string(),
|
||||
variant: skin.variant,
|
||||
cape_id: skin.cape_id,
|
||||
display_order: 0,
|
||||
}
|
||||
.remove(selected_credentials.offline_profile.id, &state.pool)
|
||||
.await?;
|
||||
@@ -1286,7 +1202,6 @@ async fn preserve_current_profile_skin(
|
||||
&texture,
|
||||
current_skin.variant,
|
||||
current_cape_id,
|
||||
CustomMinecraftSkinInsertPosition::Bottom,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
@@ -1308,7 +1223,6 @@ async fn preserve_current_profile_skin(
|
||||
&texture,
|
||||
current_skin.variant,
|
||||
current_cape_id,
|
||||
CustomMinecraftSkinInsertPosition::Bottom,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
@@ -1333,25 +1247,6 @@ fn is_bundled_skin(texture_key: &str, variant: MinecraftSkinVariant) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn get_fallback_default_skin() -> crate::Result<&'static Skin> {
|
||||
assets::DEFAULT_SKINS
|
||||
.iter()
|
||||
.find(|skin| {
|
||||
skin.name.as_deref() == Some("Steve")
|
||||
&& skin.variant == MinecraftSkinVariant::Classic
|
||||
})
|
||||
.or_else(|| {
|
||||
assets::DEFAULT_SKINS
|
||||
.iter()
|
||||
.find(|skin| skin.name.as_deref() == Some("Steve"))
|
||||
})
|
||||
.or_else(|| assets::DEFAULT_SKINS.first())
|
||||
.ok_or_else(|| {
|
||||
ErrorKind::OtherError("No bundled default skins found".into())
|
||||
.as_error()
|
||||
})
|
||||
}
|
||||
|
||||
fn local_skin_texture_key(texture_blob: &[u8]) -> Arc<str> {
|
||||
Arc::from(format!("local-{:x}", sha2::Sha256::digest(texture_blob)))
|
||||
}
|
||||
|
||||
@@ -315,7 +315,6 @@ pub async fn generate_pack_from_version_id(
|
||||
reason,
|
||||
game_version: profile.game_version.clone(),
|
||||
loader: profile.loader.as_str().to_string(),
|
||||
dependent_on: Some(version_id.clone()),
|
||||
};
|
||||
|
||||
let file = fetch_advanced(
|
||||
|
||||
@@ -387,8 +387,8 @@ pub async fn install_zipped_mrpack_files(
|
||||
profile_path: profile_path.clone(),
|
||||
pack_name: pack.name.clone(),
|
||||
icon,
|
||||
pack_id: project_id.clone(),
|
||||
pack_version: version_id.clone(),
|
||||
pack_id: project_id,
|
||||
pack_version: version_id,
|
||||
},
|
||||
100.0,
|
||||
"Downloading modpack",
|
||||
@@ -409,7 +409,6 @@ pub async fn install_zipped_mrpack_files(
|
||||
reason,
|
||||
game_version: profile.game_version.clone(),
|
||||
loader: profile.loader.as_str().to_string(),
|
||||
dependent_on: version_id.clone(),
|
||||
};
|
||||
|
||||
let num_files = pack.files.len();
|
||||
|
||||
@@ -462,7 +462,6 @@ pub async fn update_project(
|
||||
profile_path,
|
||||
update_version,
|
||||
fetch::DownloadReason::Update,
|
||||
None,
|
||||
&state.pool,
|
||||
&state.fetch_semaphore,
|
||||
&state.io_semaphore,
|
||||
@@ -504,7 +503,6 @@ pub async fn add_project_from_version(
|
||||
profile_path: &str,
|
||||
version_id: &str,
|
||||
reason: fetch::DownloadReason,
|
||||
dependent_on_version_id: Option<String>,
|
||||
) -> crate::Result<String> {
|
||||
let state = State::get().await?;
|
||||
|
||||
@@ -512,7 +510,6 @@ pub async fn add_project_from_version(
|
||||
profile_path,
|
||||
version_id,
|
||||
reason,
|
||||
dependent_on_version_id,
|
||||
&state.pool,
|
||||
&state.fetch_semaphore,
|
||||
&state.io_semaphore,
|
||||
|
||||
@@ -888,7 +888,6 @@ async fn get_modpack_identifiers(
|
||||
reason: DownloadReason::Modpack,
|
||||
game_version: profile.game_version.clone(),
|
||||
loader: profile.loader.as_str().to_string(),
|
||||
dependent_on: Some(version_id.to_string()),
|
||||
};
|
||||
|
||||
let mrpack_bytes = fetch_mirrors(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use futures::{Stream, StreamExt, stream};
|
||||
use uuid::{Uuid, fmt::Hyphenated};
|
||||
|
||||
@@ -24,22 +22,12 @@ pub struct CustomMinecraftSkin {
|
||||
///
|
||||
/// If `None`, the skin is saved without a cape.
|
||||
pub cape_id: Option<Uuid>,
|
||||
/// The saved skin display order within this player's saved skins.
|
||||
pub display_order: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum CustomMinecraftSkinInsertPosition {
|
||||
Top,
|
||||
Bottom,
|
||||
At(i64),
|
||||
}
|
||||
|
||||
struct CustomMinecraftSkinRow {
|
||||
texture_key: String,
|
||||
variant: MinecraftSkinVariant,
|
||||
cape_id: Option<Hyphenated>,
|
||||
display_order: i64,
|
||||
}
|
||||
|
||||
impl CustomMinecraftSkin {
|
||||
@@ -49,7 +37,6 @@ impl CustomMinecraftSkin {
|
||||
texture: &[u8],
|
||||
variant: MinecraftSkinVariant,
|
||||
cape_id: Option<Uuid>,
|
||||
insert_position: CustomMinecraftSkinInsertPosition,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
@@ -57,51 +44,6 @@ impl CustomMinecraftSkin {
|
||||
|
||||
let mut transaction = db.begin().await?;
|
||||
|
||||
let existing_order = sqlx::query_scalar!(
|
||||
"SELECT display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
minecraft_user_id,
|
||||
texture_key
|
||||
)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let display_order = match existing_order {
|
||||
Some(display_order) => display_order,
|
||||
None => match insert_position {
|
||||
CustomMinecraftSkinInsertPosition::Top => {
|
||||
sqlx::query!(
|
||||
"UPDATE custom_minecraft_skins SET display_order = display_order + 1 WHERE minecraft_user_uuid = ?",
|
||||
minecraft_user_id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
0
|
||||
}
|
||||
CustomMinecraftSkinInsertPosition::Bottom => {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' \
|
||||
FROM custom_minecraft_skins WHERE minecraft_user_uuid = ?",
|
||||
minecraft_user_id
|
||||
)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?
|
||||
}
|
||||
CustomMinecraftSkinInsertPosition::At(display_order) => {
|
||||
sqlx::query!(
|
||||
"UPDATE custom_minecraft_skins SET display_order = display_order + 1 \
|
||||
WHERE minecraft_user_uuid = ? AND display_order >= ?",
|
||||
minecraft_user_id,
|
||||
display_order
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
display_order
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
minecraft_user_id,
|
||||
@@ -115,11 +57,11 @@ impl CustomMinecraftSkin {
|
||||
texture_key, texture
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id, display_order) VALUES (?, ?, ?, ?, ?)",
|
||||
minecraft_user_id, texture_key, variant, cape_id, display_order
|
||||
"INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)",
|
||||
minecraft_user_id, texture_key, variant, cape_id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
@@ -138,7 +80,7 @@ impl CustomMinecraftSkin {
|
||||
|
||||
sqlx::query_as!(
|
||||
CustomMinecraftSkinRow,
|
||||
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order \
|
||||
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \
|
||||
FROM custom_minecraft_skins \
|
||||
WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
minecraft_user_id,
|
||||
@@ -151,7 +93,6 @@ impl CustomMinecraftSkin {
|
||||
texture_key: row.texture_key,
|
||||
variant: row.variant,
|
||||
cape_id: row.cape_id.map(Uuid::from),
|
||||
display_order: row.display_order,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
@@ -166,10 +107,10 @@ impl CustomMinecraftSkin {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
|
||||
Ok(stream::iter(sqlx::query!(
|
||||
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order \
|
||||
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \
|
||||
FROM custom_minecraft_skins \
|
||||
WHERE minecraft_user_uuid = ? \
|
||||
ORDER BY display_order ASC, rowid ASC \
|
||||
ORDER BY rowid ASC \
|
||||
LIMIT ? OFFSET ?",
|
||||
minecraft_user_id, count, offset
|
||||
)
|
||||
@@ -179,7 +120,6 @@ impl CustomMinecraftSkin {
|
||||
texture_key: row.texture_key,
|
||||
variant: row.variant,
|
||||
cape_id: row.cape_id.map(Uuid::from),
|
||||
display_order: row.display_order,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -221,62 +161,4 @@ impl CustomMinecraftSkin {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_order(
|
||||
minecraft_user_id: Uuid,
|
||||
texture_keys: &[String],
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
let mut transaction = db.begin().await?;
|
||||
|
||||
let existing_rows = sqlx::query!(
|
||||
"SELECT texture_key FROM custom_minecraft_skins \
|
||||
WHERE minecraft_user_uuid = ? \
|
||||
ORDER BY display_order ASC, rowid ASC",
|
||||
minecraft_user_id
|
||||
)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let existing_keys = existing_rows
|
||||
.iter()
|
||||
.map(|row| row.texture_key.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
let mut seen_keys = HashSet::new();
|
||||
let mut ordered_keys = Vec::with_capacity(existing_rows.len());
|
||||
|
||||
for texture_key in texture_keys {
|
||||
if seen_keys.insert(texture_key.as_str())
|
||||
&& existing_keys.contains(texture_key.as_str())
|
||||
{
|
||||
ordered_keys.push(texture_key.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
for row in &existing_rows {
|
||||
if seen_keys.insert(row.texture_key.as_str()) {
|
||||
ordered_keys.push(row.texture_key.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
for (display_order, texture_key) in ordered_keys.into_iter().enumerate()
|
||||
{
|
||||
let display_order = display_order as i64;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE custom_minecraft_skins SET display_order = ? \
|
||||
WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
display_order,
|
||||
minecraft_user_id,
|
||||
texture_key
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1336,7 +1336,6 @@ impl Profile {
|
||||
profile_path: &str,
|
||||
version_id: &str,
|
||||
reason: util::fetch::DownloadReason,
|
||||
dependent_on_version_id: Option<String>,
|
||||
pool: &SqlitePool,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
io_semaphore: &IoSemaphore,
|
||||
@@ -1353,7 +1352,6 @@ impl Profile {
|
||||
reason,
|
||||
game_version: profile.game_version.clone(),
|
||||
loader: profile.loader.as_str().to_string(),
|
||||
dependent_on: dependent_on_version_id,
|
||||
};
|
||||
|
||||
let version =
|
||||
|
||||
@@ -35,7 +35,6 @@ pub struct DownloadMeta {
|
||||
pub reason: DownloadReason,
|
||||
pub game_version: String,
|
||||
pub loader: String,
|
||||
pub dependent_on: Option<String>,
|
||||
}
|
||||
|
||||
impl DownloadMeta {
|
||||
|
||||
@@ -135,7 +135,6 @@ import _GitGraphIcon from './icons/git-graph.svg?component'
|
||||
import _GlassesIcon from './icons/glasses.svg?component'
|
||||
import _GlobeIcon from './icons/globe.svg?component'
|
||||
import _GridIcon from './icons/grid.svg?component'
|
||||
import _GripVerticalIcon from './icons/grip-vertical.svg?component'
|
||||
import _HamburgerIcon from './icons/hamburger.svg?component'
|
||||
import _HammerIcon from './icons/hammer.svg?component'
|
||||
import _HandHelpingIcon from './icons/hand-helping.svg?component'
|
||||
@@ -190,7 +189,6 @@ import _MonitorSmartphoneIcon from './icons/monitor-smartphone.svg?component'
|
||||
import _MoonIcon from './icons/moon.svg?component'
|
||||
import _MoreHorizontalIcon from './icons/more-horizontal.svg?component'
|
||||
import _MoreVerticalIcon from './icons/more-vertical.svg?component'
|
||||
import _MoveIcon from './icons/move.svg?component'
|
||||
import _NewspaperIcon from './icons/newspaper.svg?component'
|
||||
import _NoSignalIcon from './icons/no-signal.svg?component'
|
||||
import _NotepadTextIcon from './icons/notepad-text.svg?component'
|
||||
@@ -417,7 +415,6 @@ import _UserSearchIcon from './icons/user-search.svg?component'
|
||||
import _UserXIcon from './icons/user-x.svg?component'
|
||||
import _UsersIcon from './icons/users.svg?component'
|
||||
import _VersionIcon from './icons/version.svg?component'
|
||||
import _VideoIcon from './icons/video.svg?component'
|
||||
import _WikiIcon from './icons/wiki.svg?component'
|
||||
import _WindowIcon from './icons/window.svg?component'
|
||||
import _WorldIcon from './icons/world.svg?component'
|
||||
@@ -558,7 +555,6 @@ export const GitGraphIcon = _GitGraphIcon
|
||||
export const GlassesIcon = _GlassesIcon
|
||||
export const GlobeIcon = _GlobeIcon
|
||||
export const GridIcon = _GridIcon
|
||||
export const GripVerticalIcon = _GripVerticalIcon
|
||||
export const HamburgerIcon = _HamburgerIcon
|
||||
export const HammerIcon = _HammerIcon
|
||||
export const HandHelpingIcon = _HandHelpingIcon
|
||||
@@ -613,7 +609,6 @@ export const MonitorSmartphoneIcon = _MonitorSmartphoneIcon
|
||||
export const MoonIcon = _MoonIcon
|
||||
export const MoreHorizontalIcon = _MoreHorizontalIcon
|
||||
export const MoreVerticalIcon = _MoreVerticalIcon
|
||||
export const MoveIcon = _MoveIcon
|
||||
export const NewspaperIcon = _NewspaperIcon
|
||||
export const NoSignalIcon = _NoSignalIcon
|
||||
export const NotepadTextIcon = _NotepadTextIcon
|
||||
@@ -840,7 +835,6 @@ export const UserSearchIcon = _UserSearchIcon
|
||||
export const UserXIcon = _UserXIcon
|
||||
export const UsersIcon = _UsersIcon
|
||||
export const VersionIcon = _VersionIcon
|
||||
export const VideoIcon = _VideoIcon
|
||||
export const WikiIcon = _WikiIcon
|
||||
export const WindowIcon = _WindowIcon
|
||||
export const WorldIcon = _WorldIcon
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-grip-vertical"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="9" cy="12" r="1" />
|
||||
<circle cx="9" cy="5" r="1" />
|
||||
<circle cx="9" cy="19" r="1" />
|
||||
<circle cx="15" cy="12" r="1" />
|
||||
<circle cx="15" cy="5" r="1" />
|
||||
<circle cx="15" cy="19" r="1" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 498 B |
@@ -1,20 +0,0 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-move"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 2v20" />
|
||||
<path d="m15 19-3 3-3-3" />
|
||||
<path d="m19 9 3 3-3 3" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="m5 9-3 3 3 3" />
|
||||
<path d="m9 5 3-3 3 3" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 447 B |
@@ -1,16 +0,0 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-video"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
|
||||
<rect x="2" y="6" width="14" height="12" rx="2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 427 B |
@@ -1,100 +0,0 @@
|
||||
---
|
||||
title: Modrinth joins Spark Universe
|
||||
summary: The next chapter. What it means and why we think it’s right for Modrinth.
|
||||
date: 2026-06-15T07:00:00-07:00
|
||||
authors: ['MpxzqsyW']
|
||||
---
|
||||
|
||||
### Hey everyone, Jai here!
|
||||
|
||||
I know this news comes as a surprise, so I wanted to talk through why we think it’s right for Modrinth and what it means for us.
|
||||
|
||||
<div id="spark-live-widget"></div>
|
||||
|
||||
### My journey with Modrinth
|
||||
|
||||
I made my first Minecraft mod when I was nine. It was a little tech mod called [Mine-Tech](https://www.minecraftforum.net/forums/mapping-and-modding-java-edition/minecraft-mods/2643681-mine-tech#c1) that I uploaded to the Minecraft Forums. I got into modding because I watched PopularMMOs after school every day and wanted to make something cool enough to show up in one of his videos. That never happened, but it's what got me coding.
|
||||
|
||||
I spent most of my teenage years bouncing around Minecraft: working on servers, joining modding teams, making a dinosaur mod called [Prehistoric Eclipse](https://www.curseforge.com/minecraft/mc-mods/prehistoric-eclipse) that somehow got hundreds of thousands of downloads. Minecraft modding is genuinely how I learned to program.
|
||||
|
||||
In 2020, COVID hit and I found myself with a lot of time on my hands. The platforms were still bad. Search was broken, creators were an afterthought, and nothing had really improved in years. So I started building a search engine that indexed mods from other platforms. They quickly shut us down for scraping (fair enough), and at that point I asked myself, “why not just build the whole platform from scratch?”
|
||||
|
||||
I open-sourced it, and people actually showed up to help build it with me. That's how Modrinth really started, as a thing I was building that other people wanted to exist too.
|
||||
|
||||
Over the next year and a half, I kept working on it, mostly during school, sometimes instead of school. Listening to creators, shipping features, fixing things that broke. By 2022 we had a million monthly visitors and my hobby project was taking over my life. I dropped out of high school to work on Modrinth full time. I raised money, hired a team, and moved to New York. We launched creator monetization, the Modrinth App, analytics, and more.
|
||||
|
||||
The site was growing like crazy, but we also accumulated a lot of technical debt and made a lot of mistakes along the way. In early 2024, I [returned $800k to our investors](/news/article/capital-return/) because the venture path wasn't right for us. That was painful, but the right thing to do.
|
||||
|
||||
### What brought me here
|
||||
|
||||
Since then, I decided to go to college. I initially thought I could keep running Modrinth day-to-day, but school quickly started to absorb my life, and I found myself wanting to spend more time learning in classes and being involved in the campus community than doing things for Modrinth.
|
||||
|
||||
I hired Josh to help manage the team and as he excelled at every responsibility I gave him (and honestly did some things way better than I ever could), he quickly evolved into running the entire team day-to-day.
|
||||
|
||||
Through his leadership, we shipped new creator withdrawal options, server projects, and many app improvements and fixes. We’ve also taken Modrinth Hosting fully in-house, bringing more stability to the platform and expanding into new regions around the world.
|
||||
|
||||
But, there were still problems, mostly stemming from me not being committed or involved. I would take forever to do basic responsibilities, from hiring new content moderators and updating our very outdated legal documents, to giving feedback on new features we were releasing. I felt like I was doing a disservice to the community and the team by not being a present leader.
|
||||
|
||||
On top of that, Modrinth has been struggling from a lack of resources in ways that are starting to show. Content review wait times, website instability, API tech debt, and an increasing amount of bugs we hardly have time to keep up with. Things that we simply need more attention and resources to fix.
|
||||
|
||||
I started looking for someone new to support Josh and the team.
|
||||
|
||||
It wasn’t hard to find someone who wanted to buy Modrinth, but I was not interested in selling to some company that doesn’t care about the game and the community as much as we do. That’s what led me to Raf and Flo, the founders of [Spark Universe](https://sparkuniverse.com/).
|
||||
|
||||
### Why Spark?
|
||||
|
||||
I know what some of you are thinking. Spark Universe, are they really the right people for Modrinth and do they actually care about this community?
|
||||
|
||||
I first met Raf and Flo in 2023. We were talking about integrating a mod browser into Essential Mod. They asked how they could contribute to creator payouts for every mod downloaded through their integration. Nobody had ever asked that question before, not even once, and that really stuck with me.
|
||||
|
||||
Across everything that Spark has made, you can tell that a lot of skill and craft was involved. I've always looked up to their design ethos and consistent quality in everything they make.
|
||||
|
||||
On Bedrock, Spark has created amazing content like [RealismCraft](https://sparkuniverse.com/project/realism-craft) and the recently announced [Aether Legends](https://aetherlegends.gg/) in partnership with the original mod authors. On Java, Spark have built the [Essential Mod](https://modrinth.com/mod/essential), which makes playing with friends easier, and introduced free peer-to-peer hosting to the community over 4 years ago.
|
||||
|
||||
I know some people really don't like Essential. And while I think it's very cool what they’ve brought to Minecraft, and how they’ve built a modding team that pays all of its team livable salaries, it's fine if you still don't like them after this. Modrinth will stay an independent team and project, and there are no plans to ever merge the two.
|
||||
|
||||
When I visited the Spark team last year, I met over 50 people in person and saw for myself how much passion they have for creating in Minecraft. These people have done it all: Builds, mini-games, servers, texture packs, add-ons, mods, YT shows, animations, and more. Many of them have been creating in Minecraft for over 10 years.
|
||||
|
||||
If Modrinth was ever going to have a new home, it had to be with people who get it because they've lived it.
|
||||
|
||||
### I know this is hard to hear
|
||||
|
||||
I know some of you will find this news disappointing: I understand. Modrinth is the independent community-first alternative and joining another company sounds scary.
|
||||
|
||||
I truly believe that this was the best thing to do, but I’m not asking for your blind trust. Watch what happens, hold us accountable, and give the team at Spark Universe the chance to show you who they are. They know they have to earn your trust. They’re ready to do that work.
|
||||
|
||||
### What's not changing
|
||||
|
||||
It is a cliche that companies, upon acquisition, will say that nothing is going to change. And it always does. Modrinth _will_ change, because it's not perfect. But our mission and values will not.
|
||||
|
||||
- **Our mission stays the same.** A transparent, creator-first modding platform. We are building the best place to share and grow your mods.
|
||||
|
||||
- **Modrinth stays open source.** Our code is available for anyone to use, adapt, and contribute to. It’s core to our mission and Spark will not be changing that.
|
||||
|
||||
- **We are not merging with Essential.** Modrinth and Essential have unique areas of focus and independent teams. Essential will never be automatically installed to your instances or favored when browsing for mods.
|
||||
|
||||
- **The team isn't going anywhere.** Modrinth continues to be built by the same people. Josh stays leading the team. Raf and Flo are here to support our team, not to have someone from Spark run Modrinth.
|
||||
|
||||
- **I'll still be around too.** I will continue to help the team with making Modrinth better. I'm still in all of our team channels, giving input on various things, and helping behind the scenes to make Modrinth better and better!
|
||||
|
||||
### How Spark is helping
|
||||
|
||||
The acquisition closed back in February. Raf and Flo have spent a lot of time getting more familiar with how the Modrinth team and the platform operate, and have been helping us already.
|
||||
|
||||
- **Growing the team.** Modrinth has been growing so much that we’ve definitely felt stretch thin. Spark is helping us expand the team in content review, support and engineering so we can resolve some of these growing pains.
|
||||
|
||||
- **Supporting team members.** Currently a lot of the team are contractors because employing a global and remote team is really hard. Spark will help us offer employment and benefits to the team! Everyone has poured a lot of love into Modrinth so its awesome to support them further.
|
||||
|
||||
- **Continuing our vision.** Spark is here to help us do more of what we’re already doing. Building a creator-first modding platform together with the community. We want to be the best place to share and grow your mods.
|
||||
|
||||
### Thank you everyone!
|
||||
|
||||
I've been working on Modrinth since I was thirteen. It started as a side project and turned into something that tens of millions of people use. That still feels surreal.
|
||||
|
||||
Thank you to every creator who has published their work here. Thank you to all the players who find their mods on Modrinth! To everyone who's [contributed code](https://github.com/modrinth/code/graphs/contributors?all=1), reported bugs, translated the site, shared Modrinth with friends, or just hung out in the Discord: This has always been a community project and it always will be.
|
||||
|
||||
Watch, stay involved, and keep holding us to the standard you deserve.
|
||||
|
||||
— Jai, Founder of Modrinth
|
||||
|
||||
<div id="spark-live-widget-embed"></div>
|
||||
@@ -10,31 +10,6 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2026-06-11T19:05:19+00:00`,
|
||||
product: 'app',
|
||||
version: '0.14.6',
|
||||
body: `## Added
|
||||
- Added the ability to reorder saved skins in the skin selector by dragging and dropping them.
|
||||
- Added recovery steps for more Microsoft sign-in and Xbox authentication errors.
|
||||
|
||||
## Changed
|
||||
- Adding a skin to the skin selector will no longer automatically apply it to your Minecraft account.
|
||||
|
||||
## Fixed
|
||||
- Fixed issue where the theme would not change automatically if set to "System theme" and the system theme changes.
|
||||
- Fixed rate limit issue when adding a skin in the skin selector.
|
||||
- Fixed issue with the Skin selector appearing in a broken state when the Minecraft api is unavailable. Now it appears in a read only state, showing the last selected skin or the default skin if it is unable to determine the last selected skin.
|
||||
- Fixed issue where skins which had translucency in the outer layer did not correctly render.
|
||||
- Fixed issue in Skin selector where the Edit button was not available on skins included within Modrinth App - meaning you could not change the cape of the skin without first applying it.
|
||||
- Fixed loading state incorrectly showing briefly in the Skins selector when a saved skin is deleted.`,
|
||||
},
|
||||
{
|
||||
date: `2026-06-11T19:05:19+00:00`,
|
||||
product: 'web',
|
||||
body: `## Fixed
|
||||
- Datepicker dropdown can overflow and get cut off by page since it shows below instead of above the input.`,
|
||||
},
|
||||
{
|
||||
date: `2026-06-08T22:54:32+00:00`,
|
||||
product: 'web',
|
||||
|
||||
@@ -14,7 +14,6 @@ import { article as design_refresh } from "./design_refresh";
|
||||
import { article as download_adjustment } from "./download_adjustment";
|
||||
import { article as free_server_medal } from "./free_server_medal";
|
||||
import { article as introducing_server_projects } from "./introducing_server_projects";
|
||||
import { article as joining_spark_universe } from "./joining_spark_universe";
|
||||
import { article as knossos_v2_1_0 } from "./knossos_v2_1_0";
|
||||
import { article as licensing_guide } from "./licensing_guide";
|
||||
import { article as modpack_changes } from "./modpack_changes";
|
||||
@@ -57,7 +56,6 @@ export const articles = [
|
||||
download_adjustment,
|
||||
free_server_medal,
|
||||
introducing_server_projects,
|
||||
joining_spark_universe,
|
||||
knossos_v2_1_0,
|
||||
licensing_guide,
|
||||
modpack_changes,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,12 +0,0 @@
|
||||
// AUTO-GENERATED FILE - DO NOT EDIT
|
||||
export const article = {
|
||||
html: () => import(`./joining_spark_universe.content`).then(m => m.html),
|
||||
title: "Modrinth joins Spark Universe",
|
||||
summary: "The next chapter. What it means and why we think it’s right for Modrinth.",
|
||||
date: "2026-06-15T14:00:00.000Z",
|
||||
slug: "joining-spark-universe",
|
||||
authors: ["MpxzqsyW"],
|
||||
unlisted: false,
|
||||
thumbnail: true,
|
||||
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB |
@@ -35,7 +35,7 @@
|
||||
<button
|
||||
v-if="hasClearButton"
|
||||
type="button"
|
||||
class="absolute right-0.5 top-px z-[1] touch-manipulation cursor-pointer select-none border-none bg-transparent p-2 text-secondary transition-colors hover:text-contrast"
|
||||
class="absolute right-0.5 z-[1] touch-manipulation cursor-pointer select-none border-none bg-transparent p-2 text-secondary transition-colors hover:text-contrast"
|
||||
aria-label="Clear date"
|
||||
@click.stop="clearValue"
|
||||
>
|
||||
@@ -161,7 +161,7 @@ const props = withDefaults(
|
||||
mode: 'single',
|
||||
showMonths: 1,
|
||||
time24hr: false,
|
||||
clearable: false,
|
||||
clearable: true,
|
||||
placeholder: 'Enter date',
|
||||
showIcon: true,
|
||||
showToday: false,
|
||||
@@ -199,7 +199,6 @@ let originalInputFocus: HTMLInputElement['focus'] | null = null
|
||||
let suppressNextInputFocusScroll = false
|
||||
const calendarBaseClass = 'modrinth-date-picker-calendar'
|
||||
const twoCalendarClass = 'has-two-calendars'
|
||||
const calendarPositionGap = 2
|
||||
const calendarStateClasses = [
|
||||
'calendar-only',
|
||||
'show-today',
|
||||
@@ -1085,83 +1084,6 @@ function syncInputFocusScrollSuppression() {
|
||||
inputFocusScrollSuppressionTarget = target
|
||||
}
|
||||
|
||||
function setCalendarPositionClass(container: HTMLElement, className: string, isEnabled: boolean) {
|
||||
container.classList.toggle(className, isEnabled)
|
||||
}
|
||||
|
||||
function getCalendarPositionParts() {
|
||||
const parts = props.position.split(' ')
|
||||
return {
|
||||
vertical: parts[0] ?? 'auto',
|
||||
horizontal: parts[1] ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function getCalendarHeight(container: HTMLElement) {
|
||||
const height = container.getBoundingClientRect().height
|
||||
if (height > 0) return height
|
||||
|
||||
return Array.from(container.children).reduce(
|
||||
(total, child) => total + (child instanceof HTMLElement ? child.offsetHeight : 0),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
function positionCalendar(instance: Instance, customPositionElement?: HTMLElement) {
|
||||
const container = instance.calendarContainer
|
||||
const positionElement = customPositionElement ?? instance._positionElement
|
||||
if (!container || !positionElement) return
|
||||
|
||||
const calendarHeight = getCalendarHeight(container)
|
||||
const calendarWidth = container.offsetWidth
|
||||
const { vertical, horizontal } = getCalendarPositionParts()
|
||||
const inputBounds = positionElement.getBoundingClientRect()
|
||||
const distanceFromBottom = window.innerHeight - inputBounds.bottom
|
||||
const showOnTop =
|
||||
vertical === 'above' ||
|
||||
(vertical !== 'below' &&
|
||||
distanceFromBottom < calendarHeight &&
|
||||
inputBounds.top > calendarHeight)
|
||||
|
||||
const top =
|
||||
window.pageYOffset +
|
||||
inputBounds.top +
|
||||
(showOnTop
|
||||
? -calendarHeight - calendarPositionGap
|
||||
: positionElement.offsetHeight + calendarPositionGap)
|
||||
let left = window.pageXOffset + inputBounds.left
|
||||
let isCenter = false
|
||||
let isRight = false
|
||||
|
||||
if (horizontal === 'center') {
|
||||
left -= (calendarWidth - inputBounds.width) / 2
|
||||
isCenter = true
|
||||
} else if (horizontal === 'right') {
|
||||
left -= calendarWidth - inputBounds.width
|
||||
isRight = true
|
||||
}
|
||||
|
||||
const viewportLeft = window.pageXOffset
|
||||
const viewportRight = viewportLeft + document.documentElement.clientWidth
|
||||
const isOverflowingRight = left + calendarWidth > viewportRight
|
||||
const clampedLeft = Math.min(
|
||||
Math.max(viewportLeft, left),
|
||||
Math.max(viewportLeft, viewportRight - calendarWidth),
|
||||
)
|
||||
|
||||
setCalendarPositionClass(container, 'arrowTop', !showOnTop)
|
||||
setCalendarPositionClass(container, 'arrowBottom', showOnTop)
|
||||
setCalendarPositionClass(container, 'arrowLeft', !isCenter && !isRight)
|
||||
setCalendarPositionClass(container, 'arrowCenter', isCenter)
|
||||
setCalendarPositionClass(container, 'arrowRight', isRight)
|
||||
setCalendarPositionClass(container, 'rightMost', isOverflowingRight)
|
||||
setCalendarPositionClass(container, 'centerMost', false)
|
||||
|
||||
container.style.top = `${top}px`
|
||||
container.style.left = `${clampedLeft}px`
|
||||
container.style.right = 'auto'
|
||||
}
|
||||
|
||||
const resolvedDateFormat = computed(
|
||||
() => props.dateFormat ?? (props.enableTime ? 'Y-m-d H:i' : 'Y-m-d'),
|
||||
)
|
||||
@@ -1182,7 +1104,12 @@ const selectedDates = computed(() => {
|
||||
})
|
||||
|
||||
const hasClearButton = computed(
|
||||
() => !props.calendarOnly && props.clearable && !props.disabled && selectedDates.value.length > 0,
|
||||
() =>
|
||||
!props.calendarOnly &&
|
||||
props.clearable &&
|
||||
!props.disabled &&
|
||||
!props.readonly &&
|
||||
selectedDates.value.length > 0,
|
||||
)
|
||||
|
||||
const inputClasses = computed(() => [
|
||||
@@ -1216,7 +1143,6 @@ watch(
|
||||
props.calendarOnly,
|
||||
props.closeOnSelect,
|
||||
props.position,
|
||||
props.clearable,
|
||||
],
|
||||
() => {
|
||||
if (!picker.value) return
|
||||
@@ -1473,7 +1399,7 @@ function flatpickrOptions(): Options {
|
||||
mode: props.mode,
|
||||
noCalendar: false,
|
||||
nextArrow: chevronRightIcon,
|
||||
position: positionCalendar,
|
||||
position: props.position,
|
||||
prevArrow: chevronLeftIcon,
|
||||
showMonths: resolvedShowMonths.value,
|
||||
static: false,
|
||||
@@ -1554,7 +1480,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.arrowBottom) {
|
||||
margin-top: -0.5rem;
|
||||
margin-top: -2.5rem;
|
||||
}
|
||||
|
||||
.modrinth-date-picker.calendar-only {
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type Component, computed } from 'vue'
|
||||
|
||||
import SparkLiveWidget from './SparkLiveWidget.vue'
|
||||
import SparkLiveWidgetEmbed from './SparkLiveWidgetEmbed.vue'
|
||||
|
||||
const ARTICLE_WIDGETS: Record<string, Component> = {
|
||||
'spark-live-widget': SparkLiveWidget,
|
||||
'spark-live-widget-embed': SparkLiveWidgetEmbed,
|
||||
}
|
||||
|
||||
type ArticleBodyPart = { type: 'html'; content: string } | { type: 'widget'; id: string }
|
||||
|
||||
function parseArticleHtml(html: string): ArticleBodyPart[] {
|
||||
const widgetIds = Object.keys(ARTICLE_WIDGETS)
|
||||
if (widgetIds.length === 0) {
|
||||
return [{ type: 'html', content: html }]
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`<div id="(${widgetIds.join('|')})"></div>`, 'g')
|
||||
const parts: ArticleBodyPart[] = []
|
||||
let lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = pattern.exec(html)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: 'html', content: html.slice(lastIndex, match.index) })
|
||||
}
|
||||
parts.push({ type: 'widget', id: match[1] })
|
||||
lastIndex = pattern.lastIndex
|
||||
}
|
||||
|
||||
if (lastIndex < html.length) {
|
||||
parts.push({ type: 'html', content: html.slice(lastIndex) })
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : [{ type: 'html', content: html }]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
html: string
|
||||
}>()
|
||||
|
||||
const parts = computed(() => parseArticleHtml(props.html))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="parts.length === 1 && parts[0].type === 'html'"
|
||||
class="markdown-body"
|
||||
v-html="parts[0]?.content"
|
||||
/>
|
||||
<div v-else class="markdown-body">
|
||||
<template v-for="(part, index) in parts" :key="index">
|
||||
<div v-if="part.type === 'html'" v-html="part.content" />
|
||||
<component :is="ARTICLE_WIDGETS[part.id]" v-else />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,63 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { VideoIcon } from '@modrinth/assets'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
embed?: boolean
|
||||
}>(),
|
||||
{
|
||||
embed: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-2xl border border-solid border-surface-4 bg-surface-3 overflow-hidden">
|
||||
<div class="p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<VideoIcon class="size-6 text-brand" />
|
||||
<span class="text-contrast font-medium">
|
||||
We're hosting a live chat with Spark later today to answer all of your questions!
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
Ask questions for us to answer in our
|
||||
<a
|
||||
href="https://discord.modrinth.com"
|
||||
target="_blank"
|
||||
class="text-brand font-semibold hover:underline"
|
||||
>Discord server</a
|
||||
>.
|
||||
</span>
|
||||
<span
|
||||
>Tune in live on June 15, 11am PST / 2pm EST / 7pm BST / 8pm CEST over on our
|
||||
<a
|
||||
href="https://www.youtube.com/live/p1Dg-fud0TQ"
|
||||
target="_blank"
|
||||
class="text-brand font-semibold hover:underline"
|
||||
>YouTube channel</a
|
||||
>.</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="embed"
|
||||
style="left: 0; width: 100%; height: 0; position: relative; padding-bottom: 56.25%"
|
||||
>
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/p1Dg-fud0TQ"
|
||||
style="top: 0; left: 0; width: 100%; height: 100%; position: absolute; border: 0"
|
||||
allowfullscreen
|
||||
scrolling="no"
|
||||
allow="
|
||||
accelerometer *;
|
||||
clipboard-write *;
|
||||
encrypted-media *;
|
||||
gyroscope *;
|
||||
picture-in-picture *;
|
||||
web-share *;
|
||||
"
|
||||
referrerpolicy="strict-origin"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user