mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b70e23917d | ||
|
|
7d6c54cff9 | ||
|
|
bd97ace974 | ||
|
|
58ad58f958 | ||
|
|
d907083d83 | ||
|
|
8371ff641a | ||
|
|
b1cd16f966 | ||
|
|
40a06921ea | ||
|
|
a7dc063e08 | ||
|
|
64b61d8fd0 | ||
|
|
5e7d4cc838 |
@@ -57,8 +57,11 @@ jobs:
|
||||
|
||||
- name: Extract app changelog
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ env.VERSION_TAG }}
|
||||
run: npx --yes tsx scripts/build-theseus-release-notes.ts
|
||||
run: |
|
||||
LAST_GITHUB_RELEASE_PUBLISHED_AT=$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.published_at // ""' 2>/dev/null || true)
|
||||
LAST_GITHUB_RELEASE_PUBLISHED_AT="$LAST_GITHUB_RELEASE_PUBLISHED_AT" npx --yes tsx scripts/build-theseus-release-notes.ts
|
||||
|
||||
- name: Generate version manifest
|
||||
run: |
|
||||
|
||||
@@ -241,7 +241,6 @@ bool_to_int_with_if = "warn"
|
||||
borrow_as_ptr = "warn"
|
||||
cfg_not_test = "warn"
|
||||
clear_with_drain = "warn"
|
||||
type_complexity = "allow"
|
||||
cloned_instead_of_copied = "warn"
|
||||
collection_is_never_read = "warn"
|
||||
dbg_macro = "warn"
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import {
|
||||
ArrowBigUpDashIcon,
|
||||
ChangeSkinIcon,
|
||||
CheckIcon,
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
@@ -41,7 +40,6 @@ import {
|
||||
defineMessages,
|
||||
I18nDebugPanel,
|
||||
LoadingBar,
|
||||
ModrinthHostingLogo,
|
||||
NewsArticleCard,
|
||||
NotificationPanel,
|
||||
OverflowMenu,
|
||||
@@ -86,7 +84,6 @@ import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
|
||||
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import ServerInvitePopupBody from '@/components/ui/notifications/ServerInvitePopupBody.vue'
|
||||
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
|
||||
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
@@ -804,6 +801,11 @@ async function declineServerInviteNotification(notification) {
|
||||
}
|
||||
}
|
||||
|
||||
function openServerInviteInviterProfile(inviterName) {
|
||||
if (!inviterName) return
|
||||
openUrl(`${config.siteUrl}/user/${encodeURIComponent(inviterName)}`)
|
||||
}
|
||||
|
||||
async function handleLiveNotification(notification) {
|
||||
if (notification?.body?.type !== 'server_invite' || notification.read) return
|
||||
if (displayedServerInviteNotifications.has(notification.id)) return
|
||||
@@ -817,30 +819,17 @@ async function handleLiveNotification(notification) {
|
||||
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
|
||||
|
||||
addPopupNotification({
|
||||
title: 'Modrinth Hosting',
|
||||
titleLogo: ModrinthHostingLogo,
|
||||
bodyComponent: ServerInvitePopupBody,
|
||||
bodyProps: {
|
||||
inviterName: invitedBy?.username ?? null,
|
||||
inviterAvatarUrl: invitedBy?.avatar_url ?? null,
|
||||
serverName,
|
||||
},
|
||||
type: 'info',
|
||||
buttons: [
|
||||
{
|
||||
label: 'Accept',
|
||||
action: () => acceptServerInviteNotification(notification),
|
||||
icon: CheckIcon,
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
label: 'Decline',
|
||||
action: () => declineServerInviteNotification(notification),
|
||||
icon: XIcon,
|
||||
color: 'red',
|
||||
},
|
||||
],
|
||||
title: serverName,
|
||||
autoCloseMs: null,
|
||||
toast: {
|
||||
type: 'server-invite',
|
||||
actorName: invitedBy?.username ?? null,
|
||||
actorAvatarUrl: invitedBy?.avatar_url ?? null,
|
||||
entityName: serverName,
|
||||
onAccept: () => acceptServerInviteNotification(notification),
|
||||
onDecline: () => declineServerInviteNotification(notification),
|
||||
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ import {
|
||||
type PopupNotificationProgressItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -284,6 +285,7 @@ function goToTerminal(path?: string) {
|
||||
}
|
||||
|
||||
const currentLoadingBars = ref<LoadingBar[]>([])
|
||||
const currentLoadingBarIconUrls = ref<Record<string, string | null>>({})
|
||||
const notificationId = ref<string | number | null>(null)
|
||||
const dismissed = ref(false)
|
||||
|
||||
@@ -303,6 +305,16 @@ function getLoadingText(loadingBar: LoadingBar): string {
|
||||
return loadingBar.message ? `${percent}% ${loadingBar.message}` : `${percent}%`
|
||||
}
|
||||
|
||||
function getDisplayIconUrl(icon: string | null | undefined): string | null {
|
||||
if (!icon) {
|
||||
return null
|
||||
}
|
||||
if (/^(https?:|data:|blob:|asset:|tauri:)/.test(icon)) {
|
||||
return icon
|
||||
}
|
||||
return convertFileSrc(icon)
|
||||
}
|
||||
|
||||
function getNotification(): PopupNotification | null {
|
||||
if (!notificationId.value) {
|
||||
return null
|
||||
@@ -326,6 +338,7 @@ function buildDownloadItems(): PopupNotificationProgressItem[] {
|
||||
id: getLoadingBarKey(bar),
|
||||
title: bar.title ?? '',
|
||||
text: getLoadingText(bar),
|
||||
iconUrl: currentLoadingBarIconUrls.value[getLoadingBarKey(bar)] ?? null,
|
||||
progress: getLoadingProgress(bar),
|
||||
waiting: !bar.total || bar.total <= 0,
|
||||
}))
|
||||
@@ -400,6 +413,32 @@ async function refreshLoadingBars() {
|
||||
.map(formatLoadingBars)
|
||||
.filter((bar) => bar?.bar_type?.type !== 'launcher_update')
|
||||
|
||||
const profilePaths = Array.from(
|
||||
new Set(
|
||||
currentLoadingBars.value
|
||||
.map((bar) => bar.bar_type?.profile_path)
|
||||
.filter((path): path is string => !!path),
|
||||
),
|
||||
)
|
||||
const profiles = profilePaths.length
|
||||
? await getInstances(profilePaths).catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})
|
||||
: []
|
||||
const profileIconUrls = new Map(
|
||||
profiles.map((profile) => [profile.path, getDisplayIconUrl(profile.icon_path)]),
|
||||
)
|
||||
currentLoadingBarIconUrls.value = Object.fromEntries(
|
||||
currentLoadingBars.value.map((bar) => {
|
||||
const barIconUrl = getDisplayIconUrl(bar.bar_type?.icon)
|
||||
const profileIconUrl = bar.bar_type?.profile_path
|
||||
? profileIconUrls.get(bar.bar_type.profile_path)
|
||||
: null
|
||||
return [getLoadingBarKey(bar), barIconUrl ?? profileIconUrl ?? null]
|
||||
}),
|
||||
)
|
||||
|
||||
currentLoadingBars.value.sort((a, b) => {
|
||||
const aKey = `${a.loading_bar_uuid ?? a.id ?? ''}`
|
||||
const bKey = `${b.loading_bar_uuid ?? b.id ?? ''}`
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
InstallationSettingsLayout,
|
||||
provideAppBackup,
|
||||
provideInstallationSettings,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
|
||||
@@ -34,9 +35,17 @@ import type { Manifest } from '../../../helpers/types'
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
const debug = useDebugLogger('AppInstallationSettings')
|
||||
|
||||
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
|
||||
|
||||
debug('metadata load: start', {
|
||||
instancePath: instance.value.path,
|
||||
loader: instance.value.loader,
|
||||
gameVersion: instance.value.game_version,
|
||||
installStage: instance.value.install_stage,
|
||||
})
|
||||
|
||||
const [
|
||||
fabric_versions,
|
||||
forge_versions,
|
||||
@@ -72,6 +81,15 @@ const [
|
||||
.catch(handleError),
|
||||
])
|
||||
|
||||
debug('metadata load: done', {
|
||||
hasFabricManifest: !!fabric_versions?.value,
|
||||
hasForgeManifest: !!forge_versions?.value,
|
||||
hasQuiltManifest: !!quilt_versions?.value,
|
||||
hasNeoforgeManifest: !!neoforge_versions?.value,
|
||||
gameVersions: all_game_versions?.value?.length ?? 0,
|
||||
availablePlatforms: loaders?.value?.map((loader) => loader.name) ?? [],
|
||||
})
|
||||
|
||||
const { data: modpackInfo } = useQuery({
|
||||
queryKey: computed(() => ['linkedModpackInfo', instance.value.path]),
|
||||
queryFn: () => get_linked_modpack_info(instance.value.path, 'must_revalidate'),
|
||||
@@ -95,11 +113,21 @@ function getManifest(loader: string) {
|
||||
quilt: quilt_versions,
|
||||
neoforge: neoforge_versions,
|
||||
}
|
||||
return map[loader]
|
||||
const manifest = map[loader]
|
||||
debug('getManifest:', {
|
||||
loader,
|
||||
hasManifest: !!manifest?.value,
|
||||
gameVersions: manifest?.value?.gameVersions?.length ?? 0,
|
||||
})
|
||||
return manifest
|
||||
}
|
||||
|
||||
provideAppBackup({
|
||||
async createBackup() {
|
||||
debug('createBackup: start', {
|
||||
instancePath: instance.value.path,
|
||||
instanceName: instance.value.name,
|
||||
})
|
||||
const allProfiles = await list()
|
||||
const prefix = `${instance.value.name} - Backup #`
|
||||
const existingNums = allProfiles
|
||||
@@ -109,6 +137,7 @@ provideAppBackup({
|
||||
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
|
||||
const newPath = await duplicate(instance.value.path)
|
||||
await edit(newPath, { name: `${prefix}${nextNum}` })
|
||||
debug('createBackup: done', { newPath, backupName: `${prefix}${nextNum}` })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -165,32 +194,72 @@ provideInstallationSettings({
|
||||
const manifest = getManifest(loader)
|
||||
return !!manifest?.value?.gameVersions?.some((x) => item.version === x.id)
|
||||
})
|
||||
return (showSnapshots ? filtered : filtered.filter((x) => x.version_type === 'release')).map(
|
||||
(x) => ({ value: x.version, label: x.version }),
|
||||
)
|
||||
const result = (
|
||||
showSnapshots ? filtered : filtered.filter((x) => x.version_type === 'release')
|
||||
).map((x) => ({ value: x.version, label: x.version }))
|
||||
debug('resolveGameVersions:', {
|
||||
loader,
|
||||
showSnapshots,
|
||||
totalVersions: versions.length,
|
||||
filteredVersions: filtered.length,
|
||||
resultVersions: result.length,
|
||||
})
|
||||
return result
|
||||
},
|
||||
|
||||
resolveLoaderVersions(loader, gameVersion) {
|
||||
if (loader === 'vanilla' || !gameVersion) return []
|
||||
const manifest = getManifest(loader)
|
||||
if (!manifest?.value) return []
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
return manifest.value.gameVersions[0]?.loaders ?? []
|
||||
if (loader === 'vanilla' || !gameVersion) {
|
||||
debug('resolveLoaderVersions: skipped', { loader, gameVersion })
|
||||
return []
|
||||
}
|
||||
return manifest.value.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
|
||||
const manifest = getManifest(loader)
|
||||
if (!manifest?.value) {
|
||||
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
|
||||
return []
|
||||
}
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
const result = manifest.value.gameVersions[0]?.loaders ?? []
|
||||
debug('resolveLoaderVersions: fabric/quilt result', {
|
||||
loader,
|
||||
gameVersion,
|
||||
count: result.length,
|
||||
})
|
||||
return result
|
||||
}
|
||||
const result =
|
||||
manifest.value.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
|
||||
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
|
||||
return result
|
||||
},
|
||||
|
||||
resolveHasSnapshots(loader) {
|
||||
const versions = all_game_versions?.value ?? []
|
||||
if (loader === 'vanilla') return versions.some((x) => x.version_type !== 'release')
|
||||
if (loader === 'vanilla') {
|
||||
const result = versions.some((x) => x.version_type !== 'release')
|
||||
debug('resolveHasSnapshots: vanilla', { loader, result })
|
||||
return result
|
||||
}
|
||||
const manifest = getManifest(loader)
|
||||
const supported = versions.filter(
|
||||
(item) => !!manifest?.value?.gameVersions?.some((x) => item.version === x.id),
|
||||
)
|
||||
return supported.some((x) => x.version_type !== 'release')
|
||||
const result = supported.some((x) => x.version_type !== 'release')
|
||||
debug('resolveHasSnapshots:', {
|
||||
loader,
|
||||
totalVersions: versions.length,
|
||||
supportedVersions: supported.length,
|
||||
result,
|
||||
})
|
||||
return result
|
||||
},
|
||||
|
||||
async save(platform, gameVersion, loaderVersionId) {
|
||||
debug('save: called', {
|
||||
instancePath: instance.value.path,
|
||||
platform,
|
||||
gameVersion,
|
||||
loaderVersionId,
|
||||
})
|
||||
const editProfile: Record<string, string | undefined> = {
|
||||
loader: platform,
|
||||
game_version: gameVersion,
|
||||
@@ -199,17 +268,21 @@ provideInstallationSettings({
|
||||
editProfile.loader_version = loaderVersionId
|
||||
}
|
||||
await edit(instance.value.path, editProfile).catch(handleError)
|
||||
debug('save: edit complete', { editProfile })
|
||||
},
|
||||
|
||||
afterSave: async () => {
|
||||
debug('afterSave: installing', { instancePath: instance.value.path })
|
||||
await install(instance.value.path, false).catch(handleError)
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('afterSave: done')
|
||||
},
|
||||
|
||||
async repair() {
|
||||
debug('repair: called', { instancePath: instance.value.path })
|
||||
repairing.value = true
|
||||
await install(instance.value.path, true).catch(handleError)
|
||||
repairing.value = false
|
||||
@@ -217,9 +290,11 @@ provideInstallationSettings({
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('repair: done')
|
||||
},
|
||||
|
||||
async reinstallModpack() {
|
||||
debug('reinstallModpack: called', { instancePath: instance.value.path })
|
||||
reinstalling.value = true
|
||||
await update_repair_modrinth(instance.value.path).catch(handleError)
|
||||
reinstalling.value = false
|
||||
@@ -227,9 +302,11 @@ provideInstallationSettings({
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('reinstallModpack: done')
|
||||
},
|
||||
|
||||
async unlinkModpack() {
|
||||
debug('unlinkModpack: called', { instancePath: instance.value.path })
|
||||
await edit(instance.value.path, {
|
||||
linked_data: null as unknown as undefined,
|
||||
})
|
||||
@@ -237,27 +314,38 @@ provideInstallationSettings({
|
||||
queryKey: ['linkedModpackInfo', instance.value.path],
|
||||
})
|
||||
onUnlinked()
|
||||
debug('unlinkModpack: done')
|
||||
},
|
||||
|
||||
getCachedModpackVersions: () => null,
|
||||
async fetchModpackVersions() {
|
||||
debug('fetchModpackVersions: called', {
|
||||
projectId: instance.value.linked_data?.project_id,
|
||||
})
|
||||
const versions = await get_project_versions(instance.value.linked_data!.project_id!).catch(
|
||||
handleError,
|
||||
)
|
||||
debug('fetchModpackVersions: done', { count: versions?.length ?? 0 })
|
||||
return (versions ?? []) as Labrinth.Versions.v2.Version[]
|
||||
},
|
||||
|
||||
async getVersionChangelog(versionId: string) {
|
||||
debug('getVersionChangelog: called', { versionId })
|
||||
return (await get_version(versionId, 'must_revalidate').catch(
|
||||
() => null,
|
||||
)) as Labrinth.Versions.v2.Version | null
|
||||
},
|
||||
|
||||
async onModpackVersionConfirm(version) {
|
||||
debug('onModpackVersionConfirm: called', {
|
||||
versionId: version.id,
|
||||
instancePath: instance.value.path,
|
||||
})
|
||||
await update_managed_modrinth_version(instance.value.path, version.id)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.value.path],
|
||||
})
|
||||
debug('onModpackVersionConfirm: done')
|
||||
},
|
||||
|
||||
updaterModalProps: computed(() => ({
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-x-1.5 gap-y-1 leading-snug text-primary">
|
||||
<button
|
||||
v-if="inviterName"
|
||||
type="button"
|
||||
class="inline-flex min-w-0 items-center border-0 bg-transparent p-0 font-semibold text-contrast hover:underline"
|
||||
@click="openInviterProfile(inviterName)"
|
||||
>
|
||||
<Avatar
|
||||
:src="inviterAvatarUrl"
|
||||
:alt="inviterName"
|
||||
circle
|
||||
size="xxs"
|
||||
no-shadow
|
||||
class="mr-1.5 inline-flex"
|
||||
/>
|
||||
<span>{{ inviterName }}</span>
|
||||
</button>
|
||||
<span>
|
||||
<span v-if="inviterName" class="whitespace-nowrap">has invited you to manage</span>
|
||||
<span v-else class="whitespace-nowrap">You have been invited to manage</span>
|
||||
<span class="font-semibold text-contrast ml-1">{{ serverName }}</span>
|
||||
<span>.</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Avatar } from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
|
||||
import { config } from '@/config'
|
||||
|
||||
defineProps({
|
||||
inviterName: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
inviterAvatarUrl: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
serverName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
function openInviterProfile(username) {
|
||||
openUrl(`${config.siteUrl}/user/${encodeURIComponent(username)}`)
|
||||
}
|
||||
</script>
|
||||
@@ -10,6 +10,7 @@ export interface LoadingBarType {
|
||||
version?: string
|
||||
profile_path?: string
|
||||
pack_name?: string
|
||||
icon?: string | null
|
||||
}
|
||||
|
||||
export interface LoadingBar {
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
>
|
||||
<div
|
||||
v-if="showLegendTopFade"
|
||||
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-5 bg-gradient-to-b from-surface-3 to-transparent"
|
||||
class="z-1 pointer-events-none absolute left-0 right-0 top-0 h-5 bg-gradient-to-b from-surface-3 to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
>
|
||||
<div
|
||||
v-if="showLegendBottomFade"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-5 bg-gradient-to-t from-surface-3 to-transparent"
|
||||
class="z-1 pointer-events-none absolute bottom-0 left-0 right-0 h-5 bg-gradient-to-t from-surface-3 to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -132,7 +132,7 @@
|
||||
:href="event.announcement_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-1.5 inline-flex items-center gap-1 text-sm font-medium text-primary underline !transition-all hover:text-contrast"
|
||||
class="my-0.5 inline-flex items-center gap-1 text-xs font-medium text-primary underline !transition-all hover:text-contrast"
|
||||
>
|
||||
{{ formatMessage(analyticsChartMessages.seeAnnouncement) }}
|
||||
<ExternalIcon class="size-3.5" aria-hidden="true" />
|
||||
|
||||
+8
-7
@@ -18,7 +18,7 @@
|
||||
({{ durationLabel }})
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="previousRangeLabel" class="min-w-0 truncate text-xs text-primary">
|
||||
<span v-if="previousRangeLabel" class="min-w-0 space-x-1 truncate text-xs text-primary">
|
||||
<span class="font-medium">{{ previousRangeLabel }}</span>
|
||||
<span class="font-normal text-secondary">
|
||||
{{ formatMessage(analyticsChartMessages.previousPeriodShort) }}
|
||||
@@ -197,6 +197,7 @@ function getEntryAriaLabel(entry: AnalyticsChartTooltipEntry) {
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000
|
||||
const ONE_MINUTE_MS = 60 * 1000
|
||||
const DATE_LOCALE = 'en-US'
|
||||
|
||||
function formatRangeLabel(
|
||||
start: Date,
|
||||
@@ -225,13 +226,13 @@ function formatRangeLabel(
|
||||
}
|
||||
|
||||
if (includeTime) {
|
||||
const startLabel = new Intl.DateTimeFormat(undefined, startOptions).format(start)
|
||||
const endLabel = new Intl.DateTimeFormat(undefined, timeOptions).format(end)
|
||||
const startLabel = new Intl.DateTimeFormat(DATE_LOCALE, startOptions).format(start)
|
||||
const endLabel = new Intl.DateTimeFormat(DATE_LOCALE, timeOptions).format(end)
|
||||
const range = `${startLabel}–${endLabel}`
|
||||
|
||||
if (!showTrailingYear) return range
|
||||
|
||||
const yearLabel = new Intl.DateTimeFormat(undefined, { year: 'numeric' }).format(end)
|
||||
const yearLabel = new Intl.DateTimeFormat(DATE_LOCALE, { year: 'numeric' }).format(end)
|
||||
return `${range}, ${yearLabel}`
|
||||
}
|
||||
|
||||
@@ -244,13 +245,13 @@ function formatRangeLabel(
|
||||
endOptions = { day: 'numeric' }
|
||||
}
|
||||
|
||||
const startLabel = new Intl.DateTimeFormat(undefined, startOptions).format(start)
|
||||
const endLabel = new Intl.DateTimeFormat(undefined, endOptions).format(end)
|
||||
const startLabel = new Intl.DateTimeFormat(DATE_LOCALE, startOptions).format(start)
|
||||
const endLabel = new Intl.DateTimeFormat(DATE_LOCALE, endOptions).format(end)
|
||||
const range = `${startLabel}–${endLabel}`
|
||||
|
||||
if (!showTrailingYear) return range
|
||||
|
||||
const yearLabel = new Intl.DateTimeFormat(undefined, { year: 'numeric' }).format(end)
|
||||
const yearLabel = new Intl.DateTimeFormat(DATE_LOCALE, { year: 'numeric' }).format(end)
|
||||
return `${range}, ${yearLabel}`
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -40,7 +40,6 @@ export function useAnalyticsChartEvents(
|
||||
placeholderData: [],
|
||||
refetchOnMount: 'always',
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const localAnalyticsChartEvents = computed(() => analyticsEvents.value ?? [])
|
||||
|
||||
+11
-4
@@ -109,7 +109,7 @@ function getRegionDisplayNames(locale: string): Intl.DisplayNames | null {
|
||||
function formatCountryCode(countryCode: string, formatMessage: FormatMessage): string {
|
||||
const normalized = countryCode.trim().toUpperCase()
|
||||
if (normalized === OTHER_COUNTRY_CODE) {
|
||||
return formatMessage(analyticsMessages.unknown)
|
||||
return formatMessage(analyticsMessages.other)
|
||||
}
|
||||
|
||||
if (!REGION_CODE_PATTERN.test(normalized)) {
|
||||
@@ -146,6 +146,9 @@ export function formatBreakdownLabel(
|
||||
normalizedLowercaseValue === 'other' ||
|
||||
normalizedLowercaseValue === 'unknown'
|
||||
) {
|
||||
if (selectedBreakdown === 'country') {
|
||||
return formatMessage(analyticsMessages.other)
|
||||
}
|
||||
return formatMessage(analyticsMessages.unknown)
|
||||
}
|
||||
if (selectedBreakdown === 'country') {
|
||||
@@ -753,7 +756,7 @@ export function formatMetricValue(
|
||||
case 'playtime': {
|
||||
const hours = value / 3600
|
||||
return formatMessage(analyticsStatCardMessages.playtimeHours, {
|
||||
hours: hours.toFixed(1),
|
||||
hours: Math.abs(hours) < 1 ? hours.toFixed(2) : hours.toFixed(1),
|
||||
})
|
||||
}
|
||||
case 'views':
|
||||
@@ -770,7 +773,11 @@ function formatSmallAxisNumber(value: number): string {
|
||||
}
|
||||
|
||||
const formattedValue = Math.abs(value) < 1 ? value.toFixed(2) : value.toFixed(1)
|
||||
return formattedValue.replace(/\.?0+$/, '')
|
||||
return trimTrailingFractionZeros(formattedValue)
|
||||
}
|
||||
|
||||
function trimTrailingFractionZeros(value: string): string {
|
||||
return value.replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')
|
||||
}
|
||||
|
||||
const COMPACT_AXIS_UNITS = [
|
||||
@@ -814,7 +821,7 @@ function formatCompactAxisValue(value: number): string {
|
||||
return String(truncatedValue)
|
||||
}
|
||||
|
||||
return roundedValue.toFixed(fractionDigitCount).replace(/\.?0+$/, '')
|
||||
return trimTrailingFractionZeros(roundedValue.toFixed(fractionDigitCount))
|
||||
}
|
||||
|
||||
export function formatAxisValue(
|
||||
|
||||
@@ -224,6 +224,19 @@ export const analyticsGraphTitleMessages = defineMessages({
|
||||
})
|
||||
|
||||
export const analyticsStatCardMessages = defineMessages({
|
||||
monetizationBannerTitle: {
|
||||
id: 'analytics.stat.monetization-banner.title',
|
||||
defaultMessage: 'How does monetization work?',
|
||||
},
|
||||
monetizationBannerBody: {
|
||||
id: 'analytics.stat.monetization-banner.body',
|
||||
defaultMessage:
|
||||
'Only views and downloads made through Modrinth are eligible for monetization and must pass fraud-prevention filtering. Modrinth App downloads also require the user to be logged in. Because all projects have a similar ratio of monetized downloads, your revenue would not meaningfully change if all downloads were counted.',
|
||||
},
|
||||
monetizationBannerLearnMore: {
|
||||
id: 'analytics.stat.monetization-banner.learn-more',
|
||||
defaultMessage: 'Learn more',
|
||||
},
|
||||
revenueValue: {
|
||||
id: 'analytics.stat.revenue-value',
|
||||
defaultMessage: '${value}',
|
||||
|
||||
+2
@@ -66,6 +66,7 @@ export function buildAnalyticsTableColumns({
|
||||
key: getAnalyticsTableBreakdownColumnKey(breakdown),
|
||||
label: getAnalyticsTableBreakdownColumnLabel(breakdown, formatMessage),
|
||||
enableSorting: true,
|
||||
width: breakdown === 'project' ? '25%' : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -75,6 +76,7 @@ export function buildAnalyticsTableColumns({
|
||||
key: 'project',
|
||||
label: formatAnalyticsBreakdownLabel('project', formatMessage),
|
||||
enableSorting: true,
|
||||
width: '25%',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+5
-3
@@ -40,10 +40,12 @@ export function formatAnalyticsTableCompactPlaytime(
|
||||
formatMessage: FormatMessage,
|
||||
): string {
|
||||
const totalSeconds = Math.max(0, Math.round(value))
|
||||
const hours = totalSeconds / SECONDS_PER_HOUR
|
||||
const fractionDigits = hours < 1 ? 2 : 1
|
||||
return formatMessage(analyticsStatCardMessages.playtimeHours, {
|
||||
hours: (totalSeconds / SECONDS_PER_HOUR).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
hours: hours.toLocaleString(undefined, {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
+17
-1
@@ -12,6 +12,7 @@ import {
|
||||
getSliceCount,
|
||||
} from '../analytics-chart/analytics-chart-utils'
|
||||
import type { FormatMessage } from '../analytics-messages'
|
||||
import { analyticsMessages } from '../analytics-messages'
|
||||
import {
|
||||
ALL_BREAKDOWN_VALUE,
|
||||
COMBINED_BREAKDOWN_LABEL_SEPARATOR,
|
||||
@@ -64,6 +65,8 @@ export function buildAnalyticsTableRows({
|
||||
|
||||
const timeRange = fetchRequest.time_range
|
||||
const sliceCount = getSliceCount(timeRange, timeSlices.length)
|
||||
const currentTimeSlices =
|
||||
timeSlices.length > sliceCount ? timeSlices.slice(timeSlices.length - sliceCount) : timeSlices
|
||||
const includeDate = mode === 'date_breakdown'
|
||||
const breakdownDisplayValues = new Map<string, string>()
|
||||
const projectDisplayValues = new Map<string, string>()
|
||||
@@ -119,9 +122,22 @@ export function buildAnalyticsTableRows({
|
||||
}
|
||||
|
||||
function getCombinedBreakdownDisplay(displays: AnalyticsTableBreakdownDisplayValues) {
|
||||
const unknownBreakdownLabel = formatMessage(analyticsMessages.unknown)
|
||||
let hasUnknownBreakdownLabel = false
|
||||
|
||||
return selectedBreakdowns
|
||||
.map((breakdown) => displays[breakdown])
|
||||
.filter((displayValue): displayValue is string => Boolean(displayValue))
|
||||
.filter((displayValue) => {
|
||||
if (displayValue !== unknownBreakdownLabel) {
|
||||
return true
|
||||
}
|
||||
if (hasUnknownBreakdownLabel) {
|
||||
return false
|
||||
}
|
||||
hasUnknownBreakdownLabel = true
|
||||
return true
|
||||
})
|
||||
.join(COMBINED_BREAKDOWN_LABEL_SEPARATOR)
|
||||
}
|
||||
|
||||
@@ -184,7 +200,7 @@ export function buildAnalyticsTableRows({
|
||||
}
|
||||
}
|
||||
|
||||
timeSlices.forEach((slice, sliceIndex) => {
|
||||
currentTimeSlices.forEach((slice, sliceIndex) => {
|
||||
const bucketLabel = includeDate ? getBucketLabel(sliceIndex) : undefined
|
||||
|
||||
for (const point of slice) {
|
||||
|
||||
@@ -30,9 +30,10 @@ export function getAnalyticsBreakdownValue(
|
||||
case 'user_agent': {
|
||||
const downloadSource = normalizeBreakdownValue(
|
||||
'user_agent' in point ? point.user_agent : undefined,
|
||||
UNKNOWN_BREAKDOWN_VALUE,
|
||||
)
|
||||
return downloadSource === ALL_BREAKDOWN_VALUE
|
||||
? ALL_BREAKDOWN_VALUE
|
||||
return downloadSource === UNKNOWN_BREAKDOWN_VALUE
|
||||
? UNKNOWN_BREAKDOWN_VALUE
|
||||
: getDownloadSourceLabel(downloadSource, formatMessage)
|
||||
}
|
||||
case 'download_reason':
|
||||
@@ -98,5 +99,12 @@ function normalizeBreakdownValue(
|
||||
fallback = ALL_BREAKDOWN_VALUE,
|
||||
): string {
|
||||
const normalized = value?.trim()
|
||||
const normalizedLowercase = normalized?.toLowerCase()
|
||||
if (
|
||||
fallback === UNKNOWN_BREAKDOWN_VALUE &&
|
||||
(normalizedLowercase === 'unknown' || normalizedLowercase === 'other')
|
||||
) {
|
||||
return fallback
|
||||
}
|
||||
return normalized && normalized.length > 0 ? normalized : fallback
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ const {
|
||||
selectedCustomTimeframeEndDate,
|
||||
selectedGroupBy,
|
||||
queryRefreshTimestamp,
|
||||
analyticsAllTimeStartDate,
|
||||
refreshAnalyticsQuery,
|
||||
} = injectAnalyticsDashboardContext()
|
||||
|
||||
@@ -107,6 +108,7 @@ function handleTimeframeDraftChange(selection: TimeFramePickerSelection) {
|
||||
customStartDate: selection.customStartDate,
|
||||
customEndDate: selection.customEndDate,
|
||||
nowTimestamp: queryRefreshTimestamp.value,
|
||||
allTimeStartDate: analyticsAllTimeStartDate.value,
|
||||
})
|
||||
const { start, end } = ensureMinimumTimeRange(range.start, range.end)
|
||||
const durationMinutes = Math.max(1, Math.floor((end.getTime() - start.getTime()) / 60000))
|
||||
|
||||
@@ -894,12 +894,6 @@ function isRevenueHourlyGroupBy(groupBy: AnalyticsGroupByPreset): boolean {
|
||||
return groupBy === '1h' || groupBy === '6h'
|
||||
}
|
||||
|
||||
function getAllTimeYearGroupStart(end: Date): Date {
|
||||
const start = new Date(end)
|
||||
start.setFullYear(2021)
|
||||
return start
|
||||
}
|
||||
|
||||
const groupByOptions = computed<ComboboxOption<AnalyticsGroupByPreset>[]>(() => {
|
||||
const timeframeMinutes = selectedTimeframeDurationMinutes.value
|
||||
const options = groupByPresetOptions.map((option) => {
|
||||
@@ -1159,13 +1153,7 @@ function buildMetricFilters(
|
||||
|
||||
const fetchRequest = computed<Labrinth.Analytics.v3.FetchRequest>(() => {
|
||||
const rawRange = selectedTimeRange.value
|
||||
const rawStart =
|
||||
selectedTimeframeMode.value === 'preset' &&
|
||||
selectedTimeframe.value === 'all_time' &&
|
||||
selectedGroupBy.value === 'year'
|
||||
? getAllTimeYearGroupStart(rawRange.end)
|
||||
: rawRange.start
|
||||
const { start, end } = ensureMinimumTimeRange(rawStart, rawRange.end)
|
||||
const { start, end } = ensureMinimumTimeRange(rawRange.start, rawRange.end)
|
||||
|
||||
const groupByMs = getAnalyticsGroupByPresetMinutes(selectedGroupBy.value) * 60 * 1000
|
||||
const desiredSlices = Math.max(1, Math.floor((end.getTime() - start.getTime()) / groupByMs))
|
||||
|
||||
@@ -84,6 +84,7 @@ function subtractCalendarMonths(date: Date, months: number): Date {
|
||||
export function getTimeRangeForPreset(
|
||||
preset: AnalyticsTimeframePreset,
|
||||
nowTimestamp: number,
|
||||
allTimeStartDate: Date = new Date(Date.UTC(2023, 0, 1, 0, 0, 0, 0)),
|
||||
): AnalyticsTimeRange {
|
||||
const now = getRoundedNow(nowTimestamp)
|
||||
const end = new Date(now)
|
||||
@@ -130,7 +131,7 @@ export function getTimeRangeForPreset(
|
||||
}
|
||||
case 'all_time':
|
||||
return {
|
||||
start: new Date(Date.UTC(2023, 0, 1, 0, 0, 0, 0)),
|
||||
start: new Date(allTimeStartDate),
|
||||
end,
|
||||
}
|
||||
default:
|
||||
@@ -193,6 +194,7 @@ export function getAnalyticsTimeRange({
|
||||
customStartDate,
|
||||
customEndDate,
|
||||
nowTimestamp,
|
||||
allTimeStartDate,
|
||||
}: {
|
||||
mode: AnalyticsTimeframeMode
|
||||
preset: AnalyticsTimeframePreset
|
||||
@@ -201,6 +203,7 @@ export function getAnalyticsTimeRange({
|
||||
customStartDate: string
|
||||
customEndDate: string
|
||||
nowTimestamp: number
|
||||
allTimeStartDate?: Date
|
||||
}): AnalyticsTimeRange {
|
||||
switch (mode) {
|
||||
case 'last':
|
||||
@@ -211,7 +214,7 @@ export function getAnalyticsTimeRange({
|
||||
return getTimeRangeForCustomDateTimeRange(customStartDate, customEndDate)
|
||||
case 'preset':
|
||||
default:
|
||||
return getTimeRangeForPreset(preset, nowTimestamp)
|
||||
return getTimeRangeForPreset(preset, nowTimestamp, allTimeStartDate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +272,7 @@ export function useSelectedAnalyticsTimeRange() {
|
||||
selectedCustomTimeframeStartDate,
|
||||
selectedCustomTimeframeEndDate,
|
||||
queryRefreshTimestamp,
|
||||
analyticsAllTimeStartDate,
|
||||
} = injectAnalyticsDashboardContext()
|
||||
|
||||
const selectedTimeRange = computed(() =>
|
||||
@@ -280,6 +284,7 @@ export function useSelectedAnalyticsTimeRange() {
|
||||
customStartDate: selectedCustomTimeframeStartDate.value,
|
||||
customEndDate: selectedCustomTimeframeEndDate.value,
|
||||
nowTimestamp: queryRefreshTimestamp.value,
|
||||
allTimeStartDate: analyticsAllTimeStartDate.value,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,21 +1,45 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatCard
|
||||
v-for="card in statCards"
|
||||
:key="card.key"
|
||||
:label="card.label"
|
||||
:stat-label="card.statLabel"
|
||||
:vs-prev-period-percent="card.vsPrevPeriodPercent"
|
||||
:icon="card.icon"
|
||||
:active="activeStat === card.key"
|
||||
:disabled="card.disabled"
|
||||
@click="setActiveStat(card.key)"
|
||||
/>
|
||||
<div class="flex w-full flex-col gap-3">
|
||||
<Admonition
|
||||
v-if="showMonetizationBanner"
|
||||
type="info"
|
||||
:header="formatMessage(analyticsStatCardMessages.monetizationBannerTitle)"
|
||||
show-actions-underneath
|
||||
dismissible
|
||||
@dismiss="dismissMonetizationBanner"
|
||||
>
|
||||
<div class="text-primary">
|
||||
{{ formatMessage(analyticsStatCardMessages.monetizationBannerBody) }}
|
||||
</div>
|
||||
<template #actions>
|
||||
<ButtonStyled color="blue">
|
||||
<a href="https://modrinth.com/legal/cmp-info" target="_blank" class="w-fit !px-4">
|
||||
{{ formatMessage(analyticsStatCardMessages.monetizationBannerLearnMore) }}
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<StatCard
|
||||
v-for="card in statCards"
|
||||
:key="card.key"
|
||||
:label="card.label"
|
||||
:stat-label="card.statLabel"
|
||||
:vs-prev-period-percent="card.vsPrevPeriodPercent"
|
||||
:icon="card.icon"
|
||||
:active="activeStat === card.key"
|
||||
:disabled="card.disabled"
|
||||
@click="setActiveStat(card.key)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFormatNumber, useVIntl } from '@modrinth/ui'
|
||||
import { RightArrowIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled, useFormatNumber, useVIntl } from '@modrinth/ui'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
|
||||
import {
|
||||
type AnalyticsDashboardStat,
|
||||
@@ -25,10 +49,13 @@ import {
|
||||
import { analyticsStatCardMessages, formatAnalyticsStatLabel } from '../analytics-messages.ts'
|
||||
import StatCard from './StatCard.vue'
|
||||
|
||||
const MONETIZATION_BANNER_DISMISSED_KEY = 'analytics-monetization-banner-dismissed'
|
||||
|
||||
const {
|
||||
activeStat,
|
||||
setActiveStat,
|
||||
currentTotals,
|
||||
previousTotals,
|
||||
percentChanges,
|
||||
hasPreviousPeriodComparison,
|
||||
selectedBreakdowns,
|
||||
@@ -36,6 +63,11 @@ const {
|
||||
} = injectAnalyticsDashboardContext()
|
||||
const formatNumber = useFormatNumber()
|
||||
const { formatMessage } = useVIntl()
|
||||
const monetizationBannerDismissed = useLocalStorage(MONETIZATION_BANNER_DISMISSED_KEY, false)
|
||||
const showMonetizationBanner = computed(
|
||||
() => selectedBreakdowns.value.includes('monetization') && !monetizationBannerDismissed.value,
|
||||
)
|
||||
const MAX_PREVIOUS_PERIOD_PERCENT_DISPLAY = 1000
|
||||
|
||||
const compactNumberFormatter = computed(
|
||||
() =>
|
||||
@@ -57,16 +89,79 @@ function formatStatNumber(value: number): string {
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
const rounded = Math.round(value * 10) / 10
|
||||
if (rounded === 0) {
|
||||
return '0%'
|
||||
}
|
||||
|
||||
const signPrefix = rounded > 0 ? '+' : ''
|
||||
return `${signPrefix}${rounded.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatPreviousPeriodPercent(value: number): string | null {
|
||||
function formatSignedStatNumber(value: number): string {
|
||||
const signPrefix = value > 0 ? '+' : ''
|
||||
return `${signPrefix}${formatStatNumber(value)}`
|
||||
}
|
||||
|
||||
function formatSignedRevenue(value: number): string {
|
||||
const signPrefix = value > 0 ? '+' : value < 0 ? '-' : ''
|
||||
return `${signPrefix}${formatMessage(analyticsStatCardMessages.revenueValue, {
|
||||
value: formatStatNumber(Math.abs(value)),
|
||||
})}`
|
||||
}
|
||||
|
||||
function formatSignedPlaytimeHours(value: number): string {
|
||||
const rounded = Math.round(value * 10) / 10
|
||||
if (rounded === 0) {
|
||||
return '0'
|
||||
}
|
||||
|
||||
if (Math.abs(rounded) >= 1000) {
|
||||
const signPrefix = rounded > 0 ? '+' : ''
|
||||
return `${signPrefix}${compactNumberFormatter.value.format(rounded)}`
|
||||
}
|
||||
|
||||
const signPrefix = rounded > 0 ? '+' : ''
|
||||
return `${signPrefix}${rounded.toFixed(1)}`
|
||||
}
|
||||
|
||||
function formatSignedPlaytime(value: number): string {
|
||||
return formatMessage(analyticsStatCardMessages.playtimeHours, {
|
||||
hours: formatSignedPlaytimeHours(value / 3600),
|
||||
})
|
||||
}
|
||||
|
||||
function formatPreviousPeriodComparison(
|
||||
stat: AnalyticsDashboardStat,
|
||||
percentChange: number,
|
||||
currentValue: number,
|
||||
previousValue: number,
|
||||
): string | null {
|
||||
if (!hasPreviousPeriodComparison.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return formatPercent(value)
|
||||
const delta = currentValue - previousValue
|
||||
if (previousValue === 0 && currentValue === 0) {
|
||||
return formatPercent(percentChange)
|
||||
}
|
||||
|
||||
if (previousValue !== 0 && Math.abs(percentChange) <= MAX_PREVIOUS_PERIOD_PERCENT_DISPLAY) {
|
||||
return formatPercent(percentChange)
|
||||
}
|
||||
|
||||
switch (stat) {
|
||||
case 'revenue':
|
||||
return formatSignedRevenue(delta)
|
||||
case 'playtime':
|
||||
return formatSignedPlaytime(delta)
|
||||
case 'views':
|
||||
case 'downloads':
|
||||
return formatSignedStatNumber(delta)
|
||||
}
|
||||
}
|
||||
|
||||
function dismissMonetizationBanner() {
|
||||
monetizationBannerDismissed.value = true
|
||||
}
|
||||
|
||||
const statCards = computed<
|
||||
@@ -83,7 +178,12 @@ const statCards = computed<
|
||||
key: 'views',
|
||||
label: formatAnalyticsStatLabel('views', formatMessage),
|
||||
statLabel: formatStatNumber(currentTotals.value.views),
|
||||
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.views),
|
||||
vsPrevPeriodPercent: formatPreviousPeriodComparison(
|
||||
'views',
|
||||
percentChanges.value.views,
|
||||
currentTotals.value.views,
|
||||
previousTotals.value.views,
|
||||
),
|
||||
icon: 'eye',
|
||||
disabled: !isAnalyticsDashboardStatRelevant('views', selectedBreakdowns.value),
|
||||
},
|
||||
@@ -91,7 +191,12 @@ const statCards = computed<
|
||||
key: 'downloads',
|
||||
label: formatAnalyticsStatLabel('downloads', formatMessage),
|
||||
statLabel: formatStatNumber(currentTotals.value.downloads),
|
||||
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.downloads),
|
||||
vsPrevPeriodPercent: formatPreviousPeriodComparison(
|
||||
'downloads',
|
||||
percentChanges.value.downloads,
|
||||
currentTotals.value.downloads,
|
||||
previousTotals.value.downloads,
|
||||
),
|
||||
icon: 'download',
|
||||
disabled: !isAnalyticsDashboardStatRelevant('downloads', selectedBreakdowns.value),
|
||||
},
|
||||
@@ -101,7 +206,12 @@ const statCards = computed<
|
||||
statLabel: formatMessage(analyticsStatCardMessages.revenueValue, {
|
||||
value: formatStatNumber(currentTotals.value.revenue),
|
||||
}),
|
||||
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.revenue),
|
||||
vsPrevPeriodPercent: formatPreviousPeriodComparison(
|
||||
'revenue',
|
||||
percentChanges.value.revenue,
|
||||
currentTotals.value.revenue,
|
||||
previousTotals.value.revenue,
|
||||
),
|
||||
icon: 'dollar',
|
||||
disabled: !isAnalyticsDashboardStatRelevant('revenue', selectedBreakdowns.value),
|
||||
},
|
||||
@@ -111,7 +221,12 @@ const statCards = computed<
|
||||
statLabel: formatMessage(analyticsStatCardMessages.playtimeHours, {
|
||||
hours: formatStatNumber(currentTotals.value.playtime / 3600),
|
||||
}),
|
||||
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.playtime),
|
||||
vsPrevPeriodPercent: formatPreviousPeriodComparison(
|
||||
'playtime',
|
||||
percentChanges.value.playtime,
|
||||
currentTotals.value.playtime,
|
||||
previousTotals.value.playtime,
|
||||
),
|
||||
icon: 'clock',
|
||||
disabled: !isAnalyticsDashboardStatRelevant('playtime', selectedBreakdowns.value),
|
||||
},
|
||||
|
||||
@@ -32,41 +32,40 @@
|
||||
/>
|
||||
<span>{{ invitedBy.username }}</span>
|
||||
</nuxt-link>
|
||||
<span v-if="invitedBy">has invited you to join</span>
|
||||
<span v-else>You have been invited to join</span>
|
||||
<span class="font-bold text-[var(--color-heading)]">
|
||||
{{ notification.body.server_name }}
|
||||
</span>
|
||||
<span>.</span>
|
||||
<span v-if="invitedBy">has invited you to manage</span>
|
||||
<span v-else>You have been invited to manage</span>
|
||||
<span
|
||||
><strong class="font-bold text-[var(--color-heading)]">{{
|
||||
notification.body.server_name
|
||||
}}</strong
|
||||
>.</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="!notification.read"
|
||||
class="flex flex-wrap items-center gap-3"
|
||||
:class="{ 'gap-2': compact }"
|
||||
>
|
||||
<ButtonStyled :circular="compact" color="brand" :type="compact ? 'transparent' : null">
|
||||
<button
|
||||
v-tooltip="compact ? 'Accept' : null"
|
||||
@click="performActionByTitle(notification, 'Accept')"
|
||||
>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="performActionByTitle(notification, 'Accept')">
|
||||
<CheckIcon />
|
||||
<span v-if="!compact">Accept</span>
|
||||
Accept
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled :circular="compact" color="red" :type="compact ? 'transparent' : null">
|
||||
<button
|
||||
v-tooltip="compact ? 'Decline' : null"
|
||||
@click="performActionByTitle(notification, 'Deny')"
|
||||
>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="performActionByTitle(notification, 'Deny')">
|
||||
<XIcon />
|
||||
<span v-if="!compact">Decline</span>
|
||||
Decline
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div
|
||||
class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<span v-if="notification.read" class="inline-flex items-center font-bold text-[var(--color-text)]">
|
||||
<span
|
||||
v-if="notification.read"
|
||||
class="inline-flex items-center font-bold text-[var(--color-text)]"
|
||||
>
|
||||
<CheckCircleIcon /> Read
|
||||
</span>
|
||||
<span v-tooltip="formatDateTime(notification.created)" class="inline-flex items-center">
|
||||
@@ -118,198 +117,168 @@
|
||||
</template>
|
||||
</DoubleIcon>
|
||||
<div class="notification__title">
|
||||
<template v-if="type === 'project_update' && project && version">
|
||||
A project you follow,
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">{{ project.title }}</nuxt-link>
|
||||
, has been updated:
|
||||
</template>
|
||||
<template v-else-if="type === 'team_invite' && project">
|
||||
<nuxt-link
|
||||
:to="`/user/${invitedBy.username}`"
|
||||
class="iconified-link title-link inline-flex"
|
||||
>
|
||||
<Avatar
|
||||
:src="invitedBy.avatar_url"
|
||||
circle
|
||||
size="xxs"
|
||||
no-shadow
|
||||
:raised="raised"
|
||||
class="inline-flex"
|
||||
/>
|
||||
<span class="space"> </span>
|
||||
<span>{{ invitedBy.username }}</span>
|
||||
</nuxt-link>
|
||||
<span>
|
||||
has invited you to join
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">
|
||||
{{ project.title }} </nuxt-link
|
||||
>.
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="type === 'organization_invite' && organization">
|
||||
<nuxt-link
|
||||
:to="`/user/${invitedBy.username}`"
|
||||
class="iconified-link title-link inline-flex"
|
||||
>
|
||||
<Avatar
|
||||
:src="invitedBy.avatar_url"
|
||||
circle
|
||||
size="xxs"
|
||||
no-shadow
|
||||
:raised="raised"
|
||||
class="inline-flex"
|
||||
/>
|
||||
<span class="space"> </span>
|
||||
<span>{{ invitedBy.username }}</span>
|
||||
</nuxt-link>
|
||||
<span>
|
||||
has invited you to join
|
||||
<nuxt-link :to="`/organization/${organization.slug}`" class="title-link">
|
||||
{{ organization.name }} </nuxt-link
|
||||
>.
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="type === 'status_change' && project">
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">
|
||||
{{ project.title }}
|
||||
</nuxt-link>
|
||||
<template v-if="tags.rejectedStatuses.includes(notification.body.new_status)">
|
||||
has been
|
||||
<ProjectStatusBadge :status="notification.body.new_status" />
|
||||
<template v-if="type === 'project_update' && project && version">
|
||||
A project you follow,
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">{{
|
||||
project.title
|
||||
}}</nuxt-link>
|
||||
, has been updated:
|
||||
</template>
|
||||
<template v-else>
|
||||
updated from
|
||||
<ProjectStatusBadge :status="notification.body.old_status" />
|
||||
to
|
||||
<ProjectStatusBadge :status="notification.body.new_status" />
|
||||
</template>
|
||||
by the moderators.
|
||||
</template>
|
||||
<template v-else-if="type === 'moderator_message' && thread && project && !report">
|
||||
Your project,
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">{{ project.title }}</nuxt-link>
|
||||
, has received
|
||||
<template v-if="notification.grouped_notifs"> messages</template>
|
||||
<template v-else>a message</template>
|
||||
from the moderators.
|
||||
</template>
|
||||
<template v-else-if="type === 'moderator_message' && thread && report">
|
||||
A moderator replied to your report of
|
||||
<template v-if="version">
|
||||
version
|
||||
<nuxt-link :to="getVersionLink(project, version)" class="title-link">
|
||||
{{ version.name }}
|
||||
</nuxt-link>
|
||||
of project
|
||||
</template>
|
||||
<nuxt-link v-if="project" :to="getProjectLink(project)" class="title-link">
|
||||
{{ project.title }}
|
||||
</nuxt-link>
|
||||
<nuxt-link v-else-if="user" :to="getUserLink(user)" class="title-link">
|
||||
{{ user.username }}
|
||||
</nuxt-link>
|
||||
.
|
||||
</template>
|
||||
<nuxt-link v-else :to="notification.link" class="title-link">
|
||||
<span v-html="renderString(notification.title)" />
|
||||
</nuxt-link>
|
||||
<!-- <span v-else class="known-errors">Error reading notification.</span>-->
|
||||
</div>
|
||||
<div v-if="hasBody" class="notification__body">
|
||||
<ThreadSummary
|
||||
v-if="type === 'moderator_message' && thread"
|
||||
:thread="thread"
|
||||
:link="threadLink"
|
||||
:raised="raised"
|
||||
:messages="getMessages()"
|
||||
class="thread-summary"
|
||||
:auth="auth"
|
||||
/>
|
||||
<div v-else-if="type === 'project_update'" class="version-list">
|
||||
<div
|
||||
v-for="notif in (notification.grouped_notifs
|
||||
? [notification, ...notification.grouped_notifs]
|
||||
: [notification]
|
||||
).filter((x) => x.extra_data.version)"
|
||||
:key="notif.id"
|
||||
class="version-link"
|
||||
>
|
||||
<VersionIcon />
|
||||
<template v-else-if="type === 'team_invite' && project">
|
||||
<nuxt-link
|
||||
:to="getVersionLink(notif.extra_data.project, notif.extra_data.version)"
|
||||
class="text-link"
|
||||
:to="`/user/${invitedBy.username}`"
|
||||
class="iconified-link title-link inline-flex"
|
||||
>
|
||||
{{ notif.extra_data.version.name }}
|
||||
</nuxt-link>
|
||||
<span class="version-info">
|
||||
for
|
||||
<Categories
|
||||
:categories="getLoaderCategories(notif.extra_data.version)"
|
||||
:type="notif.extra_data.project.project_type"
|
||||
class="categories"
|
||||
<Avatar
|
||||
:src="invitedBy.avatar_url"
|
||||
circle
|
||||
size="xxs"
|
||||
no-shadow
|
||||
:raised="raised"
|
||||
class="inline-flex"
|
||||
/>
|
||||
{{ $formatVersion(notif.extra_data.version.game_versions) }}
|
||||
<span v-tooltip="formatDateTime(notif.extra_data.version.date_published)" class="date">
|
||||
{{ formatRelativeTime(notif.extra_data.version.date_published) }}
|
||||
</span>
|
||||
<span class="space"> </span>
|
||||
<span>{{ invitedBy.username }}</span>
|
||||
</nuxt-link>
|
||||
<span>
|
||||
has invited you to join
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">
|
||||
{{ project.title }} </nuxt-link
|
||||
>.
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="type === 'organization_invite' && organization">
|
||||
<nuxt-link
|
||||
:to="`/user/${invitedBy.username}`"
|
||||
class="iconified-link title-link inline-flex"
|
||||
>
|
||||
<Avatar
|
||||
:src="invitedBy.avatar_url"
|
||||
circle
|
||||
size="xxs"
|
||||
no-shadow
|
||||
:raised="raised"
|
||||
class="inline-flex"
|
||||
/>
|
||||
<span class="space"> </span>
|
||||
<span>{{ invitedBy.username }}</span>
|
||||
</nuxt-link>
|
||||
<span>
|
||||
has invited you to join
|
||||
<nuxt-link :to="`/organization/${organization.slug}`" class="title-link">
|
||||
{{ organization.name }} </nuxt-link
|
||||
>.
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="type === 'status_change' && project">
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">
|
||||
{{ project.title }}
|
||||
</nuxt-link>
|
||||
<template v-if="tags.rejectedStatuses.includes(notification.body.new_status)">
|
||||
has been
|
||||
<ProjectStatusBadge :status="notification.body.new_status" />
|
||||
</template>
|
||||
<template v-else>
|
||||
updated from
|
||||
<ProjectStatusBadge :status="notification.body.old_status" />
|
||||
to
|
||||
<ProjectStatusBadge :status="notification.body.new_status" />
|
||||
</template>
|
||||
by the moderators.
|
||||
</template>
|
||||
<template v-else-if="type === 'moderator_message' && thread && project && !report">
|
||||
Your project,
|
||||
<nuxt-link :to="getProjectLink(project)" class="title-link">{{
|
||||
project.title
|
||||
}}</nuxt-link>
|
||||
, has received
|
||||
<template v-if="notification.grouped_notifs"> messages</template>
|
||||
<template v-else>a message</template>
|
||||
from the moderators.
|
||||
</template>
|
||||
<template v-else-if="type === 'moderator_message' && thread && report">
|
||||
A moderator replied to your report of
|
||||
<template v-if="version">
|
||||
version
|
||||
<nuxt-link :to="getVersionLink(project, version)" class="title-link">
|
||||
{{ version.name }}
|
||||
</nuxt-link>
|
||||
of project
|
||||
</template>
|
||||
<nuxt-link v-if="project" :to="getProjectLink(project)" class="title-link">
|
||||
{{ project.title }}
|
||||
</nuxt-link>
|
||||
<nuxt-link v-else-if="user" :to="getUserLink(user)" class="title-link">
|
||||
{{ user.username }}
|
||||
</nuxt-link>
|
||||
.
|
||||
</template>
|
||||
<nuxt-link v-else :to="notification.link" class="title-link">
|
||||
<span v-html="renderString(notification.title)" />
|
||||
</nuxt-link>
|
||||
<!-- <span v-else class="known-errors">Error reading notification.</span>-->
|
||||
</div>
|
||||
<template v-else>
|
||||
{{ notification.text }}
|
||||
</template>
|
||||
</div>
|
||||
<span class="notification__date">
|
||||
<span v-if="notification.read" class="read-badge inline-flex">
|
||||
<CheckCircleIcon /> Read
|
||||
</span>
|
||||
<span v-tooltip="formatDateTime(notification.created)" class="inline-flex">
|
||||
<CalendarIcon class="mr-1" /> Received
|
||||
{{ formatRelativeTime(notification.created) }}
|
||||
</span>
|
||||
</span>
|
||||
<div v-if="compact" class="notification__actions">
|
||||
<template v-if="type === 'team_invite' || type === 'organization_invite'">
|
||||
<ButtonStyled circular color="brand" type="transparent">
|
||||
<button
|
||||
v-tooltip="`Accept`"
|
||||
@click="
|
||||
() => {
|
||||
acceptTeamInvite(notification.body.team_id)
|
||||
read()
|
||||
}
|
||||
"
|
||||
<div v-if="hasBody" class="notification__body">
|
||||
<ThreadSummary
|
||||
v-if="type === 'moderator_message' && thread"
|
||||
:thread="thread"
|
||||
:link="threadLink"
|
||||
:raised="raised"
|
||||
:messages="getMessages()"
|
||||
class="thread-summary"
|
||||
:auth="auth"
|
||||
/>
|
||||
<div v-else-if="type === 'project_update'" class="version-list">
|
||||
<div
|
||||
v-for="notif in (notification.grouped_notifs
|
||||
? [notification, ...notification.grouped_notifs]
|
||||
: [notification]
|
||||
).filter((x) => x.extra_data.version)"
|
||||
:key="notif.id"
|
||||
class="version-link"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular color="red" type="transparent">
|
||||
<button
|
||||
v-tooltip="`Decline`"
|
||||
@click="
|
||||
() => {
|
||||
removeSelfFromTeam(notification.body.team_id)
|
||||
read()
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<ButtonStyled v-else-if="!notification.read" circular type="transparent">
|
||||
<button v-tooltip="`Mark as read`" @click="read()">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-else class="notification__actions">
|
||||
<div v-if="type !== null" class="input-group">
|
||||
<template
|
||||
v-if="(type === 'team_invite' || type === 'organization_invite') && !notification.read"
|
||||
>
|
||||
<ButtonStyled color="brand">
|
||||
<VersionIcon />
|
||||
<nuxt-link
|
||||
:to="getVersionLink(notif.extra_data.project, notif.extra_data.version)"
|
||||
class="text-link"
|
||||
>
|
||||
{{ notif.extra_data.version.name }}
|
||||
</nuxt-link>
|
||||
<span class="version-info">
|
||||
for
|
||||
<Categories
|
||||
:categories="getLoaderCategories(notif.extra_data.version)"
|
||||
:type="notif.extra_data.project.project_type"
|
||||
class="categories"
|
||||
/>
|
||||
{{ $formatVersion(notif.extra_data.version.game_versions) }}
|
||||
<span
|
||||
v-tooltip="formatDateTime(notif.extra_data.version.date_published)"
|
||||
class="date"
|
||||
>
|
||||
{{ formatRelativeTime(notif.extra_data.version.date_published) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
{{ notification.text }}
|
||||
</template>
|
||||
</div>
|
||||
<span class="notification__date">
|
||||
<span v-if="notification.read" class="read-badge inline-flex">
|
||||
<CheckCircleIcon /> Read
|
||||
</span>
|
||||
<span v-tooltip="formatDateTime(notification.created)" class="inline-flex">
|
||||
<CalendarIcon class="mr-1" /> Received
|
||||
{{ formatRelativeTime(notification.created) }}
|
||||
</span>
|
||||
</span>
|
||||
<div v-if="compact" class="notification__actions">
|
||||
<template v-if="type === 'team_invite' || type === 'organization_invite'">
|
||||
<ButtonStyled circular color="brand" type="transparent">
|
||||
<button
|
||||
v-tooltip="`Accept`"
|
||||
@click="
|
||||
() => {
|
||||
acceptTeamInvite(notification.body.team_id)
|
||||
@@ -318,11 +287,11 @@
|
||||
"
|
||||
>
|
||||
<CheckIcon />
|
||||
Accept
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<ButtonStyled circular color="red" type="transparent">
|
||||
<button
|
||||
v-tooltip="`Decline`"
|
||||
@click="
|
||||
() => {
|
||||
removeSelfFromTeam(notification.body.team_id)
|
||||
@@ -331,41 +300,78 @@
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
Decline
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<ButtonStyled v-else-if="!notification.read">
|
||||
<button @click="read()">
|
||||
<CheckIcon />
|
||||
Mark as read
|
||||
<ButtonStyled v-else-if="!notification.read" circular type="transparent">
|
||||
<button v-tooltip="`Mark as read`" @click="read()">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<CopyCode v-if="flags.developerMode" :text="notification.id" />
|
||||
</div>
|
||||
<div v-else class="input-group">
|
||||
<ButtonStyled v-if="notification.link && notification.link !== '#'">
|
||||
<nuxt-link :to="notification.link" target="_blank">
|
||||
<ExternalIcon />
|
||||
Open link
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-for="(action, actionIndex) in notification.actions" :key="actionIndex">
|
||||
<button @click="performAction(notification, actionIndex)">
|
||||
<CheckIcon v-if="action.title === 'Accept'" />
|
||||
<XIcon v-else-if="action.title === 'Deny'" />
|
||||
{{ action.title }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="notification.actions.length === 0 && !notification.read">
|
||||
<button @click="performAction(notification, null)">
|
||||
<CheckIcon />
|
||||
Mark as read
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<CopyCode v-if="flags.developerMode" :text="notification.id" />
|
||||
<div v-else class="notification__actions">
|
||||
<div v-if="type !== null" class="input-group">
|
||||
<template
|
||||
v-if="(type === 'team_invite' || type === 'organization_invite') && !notification.read"
|
||||
>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
@click="
|
||||
() => {
|
||||
acceptTeamInvite(notification.body.team_id)
|
||||
read()
|
||||
}
|
||||
"
|
||||
>
|
||||
<CheckIcon />
|
||||
Accept
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button
|
||||
@click="
|
||||
() => {
|
||||
removeSelfFromTeam(notification.body.team_id)
|
||||
read()
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
Decline
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<ButtonStyled v-else-if="!notification.read">
|
||||
<button @click="read()">
|
||||
<CheckIcon />
|
||||
Mark as read
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<CopyCode v-if="flags.developerMode" :text="notification.id" />
|
||||
</div>
|
||||
<div v-else class="input-group">
|
||||
<ButtonStyled v-if="notification.link && notification.link !== '#'">
|
||||
<nuxt-link :to="notification.link" target="_blank">
|
||||
<ExternalIcon />
|
||||
Open link
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-for="(action, actionIndex) in notification.actions" :key="actionIndex">
|
||||
<button @click="performAction(notification, actionIndex)">
|
||||
<CheckIcon v-if="action.title === 'Accept'" />
|
||||
<XIcon v-else-if="action.title === 'Deny'" />
|
||||
{{ action.title }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="notification.actions.length === 0 && !notification.read">
|
||||
<button @click="performAction(notification, null)">
|
||||
<CheckIcon />
|
||||
Mark as read
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<CopyCode v-if="flags.developerMode" :text="notification.id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -406,6 +412,7 @@ import ThreadSummary from './thread/ThreadSummary.vue'
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const emit = defineEmits(['update:notifications'])
|
||||
const router = useRouter()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const formatDateTime = useFormatDateTime({
|
||||
timeStyle: 'short',
|
||||
@@ -488,21 +495,25 @@ async function performAction(notification, actionIndex) {
|
||||
await read()
|
||||
|
||||
if (actionIndex !== null) {
|
||||
const [method, route] = notification.actions[actionIndex].action_route
|
||||
const action = notification.actions[actionIndex]
|
||||
|
||||
// TODO: fix all this jank shit
|
||||
if (route.startsWith('hosting/') || route.startsWith('/hosting/')) {
|
||||
const archonRoute = route
|
||||
.replace(/^\/?hosting/, '')
|
||||
.replace('/invite/accept', '/invites/accept')
|
||||
.replace('/invite/deny', '/invites/decline')
|
||||
if (type.value === 'server_invite') {
|
||||
const actionName = action.title.toLowerCase()
|
||||
const inviteAction = actionName === 'accept' ? 'accept' : 'decline'
|
||||
const serverId = notification.body.server_id
|
||||
|
||||
await client.request(archonRoute, {
|
||||
await client.request(`/servers/${serverId}/invites/${inviteAction}`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: method.toUpperCase(),
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (inviteAction === 'accept') {
|
||||
await router.push(`/hosting/manage/${encodeURIComponent(serverId)}`)
|
||||
}
|
||||
} else {
|
||||
const [method, route] = action.action_route
|
||||
|
||||
await useBaseFetch(route, {
|
||||
method: method.toUpperCase(),
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<ButtonStyled circular>
|
||||
<button :class="{ '[&>svg]:rotate-180': !collapsed }" @click="$emit('toggleCollapsed')">
|
||||
<button :class="!collapsed && '[&>svg]:rotate-180'" @click="$emit('toggleCollapsed')">
|
||||
<DropdownIcon class="duration-250 transition-transform ease-in-out" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
@@ -565,7 +565,7 @@ const expandedClasses = reactive<Set<string>>(new Set())
|
||||
const autoExpandedFileIds = reactive<Set<string>>(new Set())
|
||||
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
|
||||
const highlightedSourceCache = reactive<Map<string, { source: string; lines: string[] }>>(new Map())
|
||||
const LAZY_LOAD_CLASS_SOURCE_MINIMUM = 10
|
||||
const LAZY_LOAD_CLASS_SOURCE_MINIMUM = 2
|
||||
|
||||
interface ClassGroup {
|
||||
key: string
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
v-if="sortedMessages.length > 0"
|
||||
:disabled="!replyBody || isLoading"
|
||||
@click="
|
||||
isApproved(project)
|
||||
isApproved(project) && !isStaff(auth.user)
|
||||
? openReplyModal()
|
||||
: runBlockingAction('reply', () => sendReply())
|
||||
"
|
||||
@@ -169,7 +169,7 @@
|
||||
v-else
|
||||
:disabled="!replyBody || isLoading"
|
||||
@click="
|
||||
isApproved(project)
|
||||
isApproved(project) && !isStaff(auth.user)
|
||||
? openReplyModal()
|
||||
: runBlockingAction('send', () => sendReply())
|
||||
"
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="visibleQuickReplies.length > 0">
|
||||
<OverflowMenu :options="visibleQuickReplies">
|
||||
Quick reply
|
||||
Quick Reply
|
||||
<ChevronDownIcon />
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
|
||||
@@ -48,6 +48,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
useV1ContentTabAPI: true,
|
||||
labrinthApiCanary: false,
|
||||
dismissedExternalProjectsInfo: false,
|
||||
modpackPermissionsPage: false,
|
||||
showAllBanners: false,
|
||||
alwaysIgnoreErrorBanner: false,
|
||||
showViewProdRouteBanner: false,
|
||||
|
||||
@@ -368,6 +368,15 @@
|
||||
"analytics.stat.downloads": {
|
||||
"message": "Downloads"
|
||||
},
|
||||
"analytics.stat.monetization-banner.body": {
|
||||
"message": "Only views and downloads made through Modrinth are eligible for monetization and must pass fraud-prevention filtering. Modrinth App downloads also require the user to be logged in. Because all projects have a similar ratio of monetized downloads, your revenue would not meaningfully change if all downloads were counted."
|
||||
},
|
||||
"analytics.stat.monetization-banner.learn-more": {
|
||||
"message": "Learn more"
|
||||
},
|
||||
"analytics.stat.monetization-banner.title": {
|
||||
"message": "How does monetization work?"
|
||||
},
|
||||
"analytics.stat.playtime": {
|
||||
"message": "Playtime"
|
||||
},
|
||||
@@ -3263,6 +3272,9 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "If you need assistance or have additional inquiries, please visit the <help-center-link>Modrinth Help Center</help-center-link> and click the blue bubble to contact support."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Show member UI"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "This is a private conversation thread with the Modrinth moderators. They may message you with issues concerning this project."
|
||||
},
|
||||
@@ -3333,64 +3345,43 @@
|
||||
"message": "URL"
|
||||
},
|
||||
"project.settings.permissions.attention-needed.description.proj-approved": {
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files. Once completed, withheld versions will be automatically published."
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files and any withheld versions will be automatically published."
|
||||
},
|
||||
"project.settings.permissions.attention-needed.description.proj-draft": {
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files before submitting your project for review."
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files before you can submit your project for review."
|
||||
},
|
||||
"project.settings.permissions.attention-needed.title": {
|
||||
"message": "Unknown external content"
|
||||
},
|
||||
"project.settings.permissions.collapse-all": {
|
||||
"message": "Collapse all"
|
||||
"message": "Unknown embedded content"
|
||||
},
|
||||
"project.settings.permissions.completed.description": {
|
||||
"message": "All external content has permission information and attributions have been provided."
|
||||
"message": "All external content has attributions provided."
|
||||
},
|
||||
"project.settings.permissions.completed.title": {
|
||||
"message": "Permissions completed!"
|
||||
"message": "Attributions completed!"
|
||||
},
|
||||
"project.settings.permissions.empty-state.description": {
|
||||
"message": "None of your project's versions contain external content, so you don't need to worry about obtaining permissions."
|
||||
"message": "None of your versions contain external content, so you don't need to worry about obtaining permissions."
|
||||
},
|
||||
"project.settings.permissions.empty-state.heading": {
|
||||
"message": "You're all set!"
|
||||
},
|
||||
"project.settings.permissions.expand-all": {
|
||||
"message": "Expand all"
|
||||
},
|
||||
"project.settings.permissions.fail.description": {
|
||||
"message": "You may not have permission to redistribute some of the external content in your project. In order to publish on Modrinth, please remove this content or provide proof that you do have permission to use it."
|
||||
"message": "You don't have permission to redistribute some of the external content you've added. In order to publish on Modrinth, remove the infringing content."
|
||||
},
|
||||
"project.settings.permissions.fail.title": {
|
||||
"message": "Some content can't be included"
|
||||
},
|
||||
"project.settings.permissions.info-banner.description": {
|
||||
"message": "If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn more and get started!"
|
||||
"message": "If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn about how to do this properly!"
|
||||
},
|
||||
"project.settings.permissions.info-banner.title": {
|
||||
"message": "Learn about distribution permissions"
|
||||
"message": "Learn how attributions work"
|
||||
},
|
||||
"project.settings.permissions.learn-more": {
|
||||
"message": "Learn more"
|
||||
},
|
||||
"project.settings.permissions.no-results": {
|
||||
"message": "No external files match your search."
|
||||
},
|
||||
"project.settings.permissions.search-placeholder": {
|
||||
"message": "Search {count} {count, plural, one {project} other {projects}}..."
|
||||
},
|
||||
"project.settings.permissions.sort.most-files": {
|
||||
"message": "Most files"
|
||||
},
|
||||
"project.settings.permissions.sort.recently-edited": {
|
||||
"message": "Recently edited"
|
||||
},
|
||||
"project.settings.permissions.sort.rejected": {
|
||||
"message": "Rejected"
|
||||
},
|
||||
"project.settings.permissions.sort.status": {
|
||||
"message": "Status"
|
||||
"message": "Search {count} {count, plural, one {external project} other {external projects}}..."
|
||||
},
|
||||
"project.settings.title": {
|
||||
"message": "Settings"
|
||||
|
||||
@@ -72,9 +72,11 @@
|
||||
<h2 id="messages" class="m-0 text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.threadSectionTitle) }}
|
||||
</h2>
|
||||
<div v-if="isStaff(currentMember?.user)" class="flex items-center gap-2">
|
||||
<div v-if="currentMember?.staffOnly" class="flex items-center gap-2">
|
||||
<Toggle id="moderator-see-user-ui-toggle" v-model="moderatorSeeUserUi" small />
|
||||
<label for="moderator-see-user-ui-toggle"> Show member UI </label>
|
||||
<label for="moderator-see-user-ui-toggle">
|
||||
{{ formatMessage(messages.moderatorSeeUserUiToggle) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="userFacingUiVisible">
|
||||
@@ -149,9 +151,8 @@ import {
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { isStaff } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, type Ref, watch } from 'vue'
|
||||
|
||||
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
|
||||
import { getProjectLink, isApproved, isRejected, isUnderReview } from '~/helpers/projects.js'
|
||||
@@ -159,6 +160,7 @@ import { getProjectLink, isApproved, isRejected, isUnderReview } from '~/helpers
|
||||
const { formatMessage } = useVIntl()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
type ProjectPageMember = Labrinth.Projects.v3.TeamMember & { staffOnly?: boolean }
|
||||
type ModerationAdmonitionSection =
|
||||
| {
|
||||
type: 'paragraph'
|
||||
@@ -179,6 +181,10 @@ const messages = defineMessages({
|
||||
id: 'project.moderation.thread.title',
|
||||
defaultMessage: 'Moderation messages',
|
||||
},
|
||||
moderatorSeeUserUiToggle: {
|
||||
id: 'project.moderation.thread.moderator-see-user-ui-toggle',
|
||||
defaultMessage: 'Show member UI',
|
||||
},
|
||||
threadPrivateDescription: {
|
||||
id: 'project.moderation.thread.private-description',
|
||||
defaultMessage:
|
||||
@@ -207,10 +213,18 @@ const messages = defineMessages({
|
||||
})
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { projectV2: project, currentMember, invalidate, allMembers } = injectProjectPageContext()
|
||||
const {
|
||||
projectV2: project,
|
||||
currentMember: currentMemberRaw,
|
||||
invalidate,
|
||||
allMembers,
|
||||
} = injectProjectPageContext()
|
||||
const currentMember = currentMemberRaw as Ref<ProjectPageMember | null>
|
||||
|
||||
const canAccess = computed(() => !!currentMember.value)
|
||||
const userFacingUiVisible = computed(() => !!currentMember.value && moderatorSeeUserUi.value)
|
||||
const userFacingUiVisible = computed(
|
||||
() => !!currentMember.value && (!currentMember.value.staffOnly || moderatorSeeUserUi.value),
|
||||
)
|
||||
|
||||
const approvedAdmonitionMessage = computed<MessageDescriptor | null>(() => {
|
||||
switch (project.value?.status) {
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
commonMessages,
|
||||
commonProjectSettingsMessages,
|
||||
injectProjectPageContext,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { isStaff } from '@modrinth/utils'
|
||||
@@ -48,8 +47,10 @@ const navItems = computed(() => {
|
||||
projectV3.value?.project_types?.some((type) => ['mod', 'modpack'].includes(type)) &&
|
||||
isStaff(currentMember.value?.user)
|
||||
|
||||
const hasPermissionsPage = computed(() =>
|
||||
projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
|
||||
const hasPermissionsPage = computed(
|
||||
() =>
|
||||
flags.value.modpackPermissionsPage &&
|
||||
projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
|
||||
)
|
||||
|
||||
const items = [
|
||||
@@ -81,16 +82,16 @@ const navItems = computed(() => {
|
||||
label: formatMessage(commonProjectSettingsMessages.description),
|
||||
icon: AlignLeftIcon,
|
||||
},
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/versions`,
|
||||
label: formatMessage(commonProjectSettingsMessages.versions),
|
||||
icon: VersionIcon,
|
||||
},
|
||||
hasPermissionsPage.value && {
|
||||
link: `/${base}/settings/permissions`,
|
||||
label: formatMessage(commonProjectSettingsMessages.permissions),
|
||||
icon: SignatureIcon,
|
||||
},
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/versions`,
|
||||
label: formatMessage(commonProjectSettingsMessages.versions),
|
||||
icon: VersionIcon,
|
||||
},
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/license`,
|
||||
label: formatMessage(commonProjectSettingsMessages.license),
|
||||
@@ -143,16 +144,6 @@ watch(route, () => {
|
||||
const scrollY = scroll.y.value
|
||||
setTimeout(() => window.scrollTo(0, scrollY), 10)
|
||||
})
|
||||
|
||||
const moderatorSeeUserUi = computed<boolean>({
|
||||
get() {
|
||||
return flags.value.showModeratorProjectMemberUi
|
||||
},
|
||||
set(value: boolean) {
|
||||
flags.value.showModeratorProjectMemberUi = value
|
||||
saveFeatureFlags()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -176,10 +167,6 @@ const moderatorSeeUserUi = computed<boolean>({
|
||||
<div class="grid gap-4 lg:grid-cols-[1fr_3fr]">
|
||||
<div>
|
||||
<NavStack :items="navItems" />
|
||||
<div v-if="isStaff(currentMember?.user)" class="mt-4 flex items-center gap-2">
|
||||
<Toggle id="moderator-see-user-ui-toggle" v-model="moderatorSeeUserUi" small />
|
||||
<label for="moderator-see-user-ui-toggle"> Show member UI </label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<NuxtPage />
|
||||
|
||||
@@ -1,255 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
ArrowDown10Icon,
|
||||
ArrowDownWideNarrowIcon,
|
||||
ClockArrowDownIcon,
|
||||
FoldVerticalIcon,
|
||||
RightArrowIcon,
|
||||
SearchIcon,
|
||||
UnfoldVerticalIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { RightArrowIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
createAttributionGroupTitle,
|
||||
defineMessage,
|
||||
defineMessages,
|
||||
EmptyState,
|
||||
ExternalProjectPermissionsCard,
|
||||
injectModrinthClient,
|
||||
injectProjectPageContext,
|
||||
IntlFormatted,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { isStaff } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const auth = await useAuth()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
const isModerator = computed(() => {
|
||||
return isStaff(auth.value?.user) && !flags.value.showModeratorProjectMemberUi
|
||||
})
|
||||
import ExternalProjectPermissionsCard from '@modrinth/ui/src/components/external_files/ExternalProjectPermissionsCard.vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { projectV2: project } = injectProjectPageContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
type SortType = 'status' | 'most_files' | 'recently_edited' | 'rejected'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const currentSortType = ref<SortType>('status')
|
||||
const cardCollapsedById = ref<Record<string, boolean>>({})
|
||||
|
||||
const {
|
||||
data: attributionData,
|
||||
error: attributionError,
|
||||
isPending: pending,
|
||||
} = useQuery<Labrinth.Attribution.Internal.AttributionGroup[]>({
|
||||
queryKey: ['project-attribution', project.value.id],
|
||||
queryFn: () => labrinth.attribution_internal.listProjectAttribution(project.value.id),
|
||||
})
|
||||
|
||||
const sortRejectedLabel = defineMessage({
|
||||
id: 'project.settings.permissions.sort.rejected',
|
||||
defaultMessage: 'Rejected',
|
||||
})
|
||||
|
||||
const sortTypes = computed<ComboboxOption<SortType>[]>(() => {
|
||||
const options: ComboboxOption<SortType>[] = [
|
||||
{
|
||||
value: 'status',
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: 'project.settings.permissions.sort.status',
|
||||
defaultMessage: 'Status',
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'most_files',
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: 'project.settings.permissions.sort.most-files',
|
||||
defaultMessage: 'Most files',
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'recently_edited',
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: 'project.settings.permissions.sort.recently-edited',
|
||||
defaultMessage: 'Recently edited',
|
||||
}),
|
||||
),
|
||||
},
|
||||
]
|
||||
if (hasMixedModerationStatus.value) {
|
||||
options.push({
|
||||
value: 'rejected',
|
||||
label: formatMessage(sortRejectedLabel),
|
||||
})
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
const currentSortLabel = computed(() => {
|
||||
return sortTypes.value.find((option) => option.value === currentSortType.value)?.label ?? ''
|
||||
})
|
||||
|
||||
function isAttributed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
return !!group.attribution && !isNoPermission(group)
|
||||
}
|
||||
|
||||
function isNoPermission(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
return group.attribution?.kind === 'no_permission'
|
||||
}
|
||||
|
||||
function isModerationRejected(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
const kind = group.attribution?.moderation_status?.kind
|
||||
return kind === 'bad_proof' || kind === 'not_allowed'
|
||||
}
|
||||
|
||||
function rejectedSortRank(group: Labrinth.Attribution.Internal.AttributionGroup): number {
|
||||
if (isModerationRejected(group)) {
|
||||
return 0
|
||||
}
|
||||
if (group.attribution?.moderation_status?.kind === 'approved') {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
const hasMixedModerationStatus = computed(() => {
|
||||
const groups = attributionData.value ?? []
|
||||
let hasRejected = false
|
||||
let hasNonRejected = false
|
||||
for (const group of groups) {
|
||||
if (isModerationRejected(group)) {
|
||||
hasRejected = true
|
||||
} else {
|
||||
hasNonRejected = true
|
||||
}
|
||||
if (hasRejected && hasNonRejected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
watch(hasMixedModerationStatus, (mixed) => {
|
||||
if (!mixed && currentSortType.value === 'rejected') {
|
||||
currentSortType.value = 'status'
|
||||
}
|
||||
})
|
||||
|
||||
function statusSortRank(group: Labrinth.Attribution.Internal.AttributionGroup): number {
|
||||
if (isNoPermission(group)) {
|
||||
return 0
|
||||
} else if (!group.attribution) {
|
||||
return 1
|
||||
} else if (group.attribution?.kind === 'globally_allowed') {
|
||||
return 3
|
||||
} else {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
function alphabetSortKey(group: Labrinth.Attribution.Internal.AttributionGroup): string {
|
||||
return createAttributionGroupTitle(group, formatMessage)
|
||||
}
|
||||
|
||||
function compareAlphabetical(
|
||||
a: Labrinth.Attribution.Internal.AttributionGroup,
|
||||
b: Labrinth.Attribution.Internal.AttributionGroup,
|
||||
): number {
|
||||
return alphabetSortKey(a).localeCompare(alphabetSortKey(b), undefined, { sensitivity: 'base' })
|
||||
}
|
||||
|
||||
function attributedTimestamp(group: Labrinth.Attribution.Internal.AttributionGroup): number {
|
||||
if (!group.attributed_at) return Number.NEGATIVE_INFINITY
|
||||
const t = Date.parse(group.attributed_at)
|
||||
return Number.isNaN(t) ? Number.NEGATIVE_INFINITY : t
|
||||
}
|
||||
|
||||
function groupMatchesPermissionsSearch(
|
||||
group: Labrinth.Attribution.Internal.AttributionGroup,
|
||||
queryTrimmed: string,
|
||||
): boolean {
|
||||
const query = queryTrimmed.toLowerCase()
|
||||
if (group.flame_project?.title?.toLowerCase().includes(query)) {
|
||||
return true
|
||||
}
|
||||
return (group.files ?? []).some((file) => file.name.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
const groups = attributionData.value ?? []
|
||||
const queryTrimmed = searchQuery.value.trim()
|
||||
const filtered = queryTrimmed
|
||||
? groups.filter((group) => groupMatchesPermissionsSearch(group, queryTrimmed))
|
||||
: [...groups]
|
||||
const sortMode = currentSortType.value
|
||||
filtered.sort(compareAlphabetical)
|
||||
filtered.sort((a, b) => {
|
||||
if (sortMode === 'status') {
|
||||
return statusSortRank(a) - statusSortRank(b)
|
||||
}
|
||||
if (sortMode === 'most_files') {
|
||||
const ac = a.files?.length ?? 0
|
||||
const bc = b.files?.length ?? 0
|
||||
return bc - ac
|
||||
}
|
||||
if (sortMode === 'rejected') {
|
||||
return rejectedSortRank(a) - rejectedSortRank(b)
|
||||
}
|
||||
const at = attributedTimestamp(a)
|
||||
const bt = attributedTimestamp(b)
|
||||
return bt - at
|
||||
if (!flags.value.modpackPermissionsPage) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
})
|
||||
return filtered
|
||||
})
|
||||
}
|
||||
|
||||
const totalGroups = computed(() => attributionData.value?.length ?? 0)
|
||||
|
||||
const stats = computed(() => {
|
||||
const groups = attributionData.value ?? []
|
||||
let attributed = 0
|
||||
let pending = 0
|
||||
let noPermission = 0
|
||||
for (const group of groups) {
|
||||
if (isNoPermission(group)) {
|
||||
noPermission++
|
||||
} else if (isAttributed(group)) {
|
||||
attributed++
|
||||
} else {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
return { total: groups.length, attributed, pending, noPermission }
|
||||
})
|
||||
|
||||
const projectIsApproved = computed(() => project.value.status === 'approved')
|
||||
const externalFiles = ref([{}])
|
||||
const searchQuery = ref('')
|
||||
const currentSortType = ref('Oldest')
|
||||
|
||||
const sortTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Oldest', label: 'Oldest' },
|
||||
{ value: 'Newest', label: 'Newest' },
|
||||
]
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'project.settings.permissions.search-placeholder',
|
||||
defaultMessage: 'Search {count} {count, plural, one {project} other {projects}}...',
|
||||
defaultMessage:
|
||||
'Search {count} {count, plural, one {external project} other {external projects}}...',
|
||||
},
|
||||
infoBannerTitle: {
|
||||
id: 'project.settings.permissions.info-banner.title',
|
||||
defaultMessage: 'Learn about distribution permissions',
|
||||
defaultMessage: 'Learn how attributions work',
|
||||
},
|
||||
infoBannerDescription: {
|
||||
id: 'project.settings.permissions.info-banner.description',
|
||||
defaultMessage: `If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn more and get started!`,
|
||||
defaultMessage: `If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn about how to do this properly!`,
|
||||
},
|
||||
learnMore: {
|
||||
id: 'project.settings.permissions.learn-more',
|
||||
@@ -261,16 +57,15 @@ const messages = defineMessages({
|
||||
},
|
||||
emptyStateDescription: {
|
||||
id: 'project.settings.permissions.empty-state.description',
|
||||
defaultMessage: `None of your project's versions contain external content, so you don't need to worry about obtaining permissions.`,
|
||||
defaultMessage: `None of your versions contain external content, so you don't need to worry about obtaining permissions.`,
|
||||
},
|
||||
completedTitle: {
|
||||
id: 'project.settings.permissions.completed.title',
|
||||
defaultMessage: `Permissions completed!`,
|
||||
defaultMessage: `Attributions completed!`,
|
||||
},
|
||||
completedDescription: {
|
||||
id: 'project.settings.permissions.completed.description',
|
||||
defaultMessage:
|
||||
'All external content has permission information and attributions have been provided.',
|
||||
defaultMessage: 'All external content has attributions provided.',
|
||||
},
|
||||
failTitle: {
|
||||
id: 'project.settings.permissions.fail.title',
|
||||
@@ -278,144 +73,78 @@ const messages = defineMessages({
|
||||
},
|
||||
failDescription: {
|
||||
id: 'project.settings.permissions.fail.description',
|
||||
defaultMessage: `You may not have permission to redistribute some of the external content in your project. In order to publish on Modrinth, please remove this content or provide proof that you do have permission to use it.`,
|
||||
defaultMessage: `You don't have permission to redistribute some of the external content you've added. In order to publish on Modrinth, remove the infringing content.`,
|
||||
},
|
||||
attentionNeededTitle: {
|
||||
id: 'project.settings.permissions.attention-needed.title',
|
||||
defaultMessage: `Unknown external content`,
|
||||
defaultMessage: `Unknown embedded content`,
|
||||
},
|
||||
attentionNeededDescriptionApproved: {
|
||||
id: 'project.settings.permissions.attention-needed.description.proj-approved',
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files. Once completed, withheld versions will be automatically published.`,
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files and any withheld versions will be automatically published.`,
|
||||
},
|
||||
attentionNeededDescriptionDraft: {
|
||||
id: 'project.settings.permissions.attention-needed.description.proj-draft',
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files before submitting your project for review.`,
|
||||
},
|
||||
noResults: {
|
||||
id: 'project.settings.permissions.no-results',
|
||||
defaultMessage: 'No external files match your search.',
|
||||
},
|
||||
collapseAll: {
|
||||
id: 'project.settings.permissions.collapse-all',
|
||||
defaultMessage: 'Collapse all',
|
||||
},
|
||||
expandAll: {
|
||||
id: 'project.settings.permissions.expand-all',
|
||||
defaultMessage: 'Expand all',
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files before you can submit your project for review.`,
|
||||
},
|
||||
})
|
||||
|
||||
function defaultCardCollapsed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
return (
|
||||
(!isModerator.value && !!group.attribution) || group?.attribution?.kind === 'globally_allowed'
|
||||
)
|
||||
}
|
||||
|
||||
function getCardCollapsed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
return cardCollapsedById.value[group.id] ?? defaultCardCollapsed(group)
|
||||
}
|
||||
|
||||
function setCardCollapsed(groupId: string, collapsed: boolean) {
|
||||
cardCollapsedById.value = { ...cardCollapsedById.value, [groupId]: collapsed }
|
||||
}
|
||||
|
||||
const allCardsCollapsed = computed(() => {
|
||||
const groups = filteredGroups.value
|
||||
if (groups.length === 0) {
|
||||
return true
|
||||
}
|
||||
return groups.every((group) => getCardCollapsed(group))
|
||||
})
|
||||
|
||||
const expandCollapseAllLabel = computed(() =>
|
||||
formatMessage(allCardsCollapsed.value ? messages.expandAll : messages.collapseAll),
|
||||
)
|
||||
|
||||
function toggleAllCardsCollapsed() {
|
||||
const collapsed = !allCardsCollapsed.value
|
||||
const next = { ...cardCollapsedById.value }
|
||||
for (const group of filteredGroups.value) {
|
||||
next[group.id] = collapsed
|
||||
}
|
||||
cardCollapsedById.value = next
|
||||
}
|
||||
|
||||
function dismissInfoBanner() {
|
||||
flags.value.dismissedExternalProjectsInfo = true
|
||||
saveFeatureFlags()
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Admonition
|
||||
v-if="!flags.dismissedExternalProjectsInfo && !isModerator"
|
||||
type="info"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.infoBannerTitle)"
|
||||
dismissible
|
||||
@dismiss="dismissInfoBanner"
|
||||
>
|
||||
<IntlFormatted :message-id="messages.infoBannerDescription">
|
||||
<template #link="{ children }">
|
||||
<a class="text-link" target="_blank"> <component :is="() => children" /> </a>
|
||||
<template v-if="externalFiles.length > 0">
|
||||
<Admonition
|
||||
v-if="!flags.dismissedExternalProjectsInfo"
|
||||
type="info"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.infoBannerTitle)"
|
||||
dismissible
|
||||
@dismiss="dismissInfoBanner"
|
||||
>
|
||||
<IntlFormatted :message-id="messages.infoBannerDescription">
|
||||
<template #link="{ children }">
|
||||
<a class="text-link" target="_blank"> <component :is="() => children" /> </a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
<template #actions>
|
||||
<div class="flex">
|
||||
<ButtonStyled color="blue">
|
||||
<a> {{ formatMessage(messages.learnMore) }} <RightArrowIcon /> </a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
<template #actions>
|
||||
<div class="flex">
|
||||
<ButtonStyled color="blue">
|
||||
<a> {{ formatMessage(messages.learnMore) }} <RightArrowIcon /> </a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</Admonition>
|
||||
<template v-if="pending">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="h-[56px] w-full animate-pulse rounded-2xl bg-surface-3"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="totalGroups > 0">
|
||||
<template v-if="!isModerator">
|
||||
<Admonition
|
||||
v-if="stats.pending === 0 && stats.noPermission === 0"
|
||||
type="success"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.completedTitle)"
|
||||
:body="formatMessage(messages.completedDescription)"
|
||||
/>
|
||||
<Admonition
|
||||
v-else-if="stats.noPermission > 0"
|
||||
type="critical"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.failTitle)"
|
||||
:body="formatMessage(messages.failDescription)"
|
||||
/>
|
||||
<Admonition
|
||||
v-else-if="stats.pending > 0"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.attentionNeededTitle)"
|
||||
:body="
|
||||
formatMessage(
|
||||
projectIsApproved
|
||||
? messages.attentionNeededDescriptionApproved
|
||||
: messages.attentionNeededDescriptionDraft,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<div class="grid grid-cols-[1fr_auto_auto] gap-2">
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-if="true"
|
||||
type="success"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.completedTitle)"
|
||||
:body="formatMessage(messages.completedDescription)"
|
||||
/>
|
||||
<Admonition
|
||||
v-if="true"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.attentionNeededTitle)"
|
||||
:body="formatMessage(messages.attentionNeededDescriptionDraft)"
|
||||
/>
|
||||
<Admonition
|
||||
v-if="true"
|
||||
type="critical"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.failTitle)"
|
||||
:body="formatMessage(messages.failDescription)"
|
||||
/>
|
||||
<div class="grid grid-cols-[1fr_auto] gap-2">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
type="search"
|
||||
:placeholder="
|
||||
formatMessage(messages.searchPlaceholder, {
|
||||
count: totalGroups,
|
||||
count: externalFiles.length,
|
||||
})
|
||||
"
|
||||
:icon="SearchIcon"
|
||||
@@ -424,68 +153,27 @@ function dismissInfoBanner() {
|
||||
<div>
|
||||
<Combobox
|
||||
v-model="currentSortType"
|
||||
class="!w-full flex-grow sm:!w-[220px] sm:flex-grow-0"
|
||||
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
|
||||
:options="sortTypes"
|
||||
:placeholder="formatMessage(commonMessages.sortByLabel)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<ArrowDownWideNarrowIcon
|
||||
v-if="currentSortType === 'status'"
|
||||
<SortAscIcon
|
||||
v-if="currentSortType === 'Oldest'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<ArrowDown10Icon
|
||||
v-else-if="currentSortType === 'most_files'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<ClockArrowDownIcon
|
||||
v-else-if="currentSortType === 'recently_edited'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<ArrowDownWideNarrowIcon
|
||||
v-else-if="currentSortType === 'rejected'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<span class="truncate text-contrast">{{ currentSortLabel }}</span>
|
||||
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
|
||||
<span class="truncate text-contrast">{{ currentSortType }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
<ButtonStyled>
|
||||
<button type="button" class="!h-[40px]" @click="toggleAllCardsCollapsed">
|
||||
<UnfoldVerticalIcon
|
||||
v-if="allCardsCollapsed"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<FoldVerticalIcon v-else class="size-5 flex-shrink-0 text-secondary" />
|
||||
{{ expandCollapseAllLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col gap-3">
|
||||
<TransitionGroup name="list">
|
||||
<ExternalProjectPermissionsCard
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.id"
|
||||
:collapsed="getCardCollapsed(group)"
|
||||
:project-id="project.id"
|
||||
:group="group"
|
||||
:is-moderator="isModerator"
|
||||
@update:collapsed="setCardCollapsed(group.id, $event)"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="filteredGroups.length === 0"
|
||||
:heading="formatMessage(messages.noResults)"
|
||||
type="no-search-result"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<ExternalProjectPermissionsCard title="FTB Library" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="attributionError">
|
||||
<p v-if="attributionError" class="my-12 text-center text-red">
|
||||
{{ attributionError }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<EmptyState
|
||||
:heading="formatMessage(messages.emptyStateHeading)"
|
||||
@@ -494,18 +182,3 @@ function dismissInfoBanner() {
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<style scoped>
|
||||
.list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.list-move {
|
||||
transition: transform 200ms ease-in-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,22 +14,21 @@
|
||||
proceed-label="Delete"
|
||||
@proceed="deleteVersion()"
|
||||
/>
|
||||
|
||||
<Admonition
|
||||
v-if="withheldVersions.length > 0"
|
||||
v-if="flags.modpackPermissionsPage && withheldVersions.length > 0"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
:header="
|
||||
formatMessage(messages.withheldVersionsWarningTitle, {
|
||||
count: withheldVersions.length,
|
||||
version_name:
|
||||
withheldVersions.length === 1 ? withheldVersions[0].version_number : undefined,
|
||||
version_name: withheldVersions.length === 1 ? withheldVersions[0] : undefined,
|
||||
})
|
||||
"
|
||||
:body="
|
||||
formatMessage(messages.withheldVersionsWarningDescription, {
|
||||
count: withheldVersions.length,
|
||||
version_name:
|
||||
withheldVersions.length === 1 ? withheldVersions[0].version_number : undefined,
|
||||
version_name: withheldVersions.length === 1 ? withheldVersions[0] : undefined,
|
||||
})
|
||||
"
|
||||
>
|
||||
@@ -455,9 +454,7 @@ async function deleteVersion() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const withheldVersions = computed(() =>
|
||||
versions.value.filter((x) => x.files_missing_attribution?.length > 0),
|
||||
)
|
||||
const withheldVersions = computed(() => ['4.0.0'])
|
||||
|
||||
const messages = defineMessages({
|
||||
withheldVersionsWarningTitle: {
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-else class="input-group mt-2">
|
||||
<ButtonStyled v-if="primaryFile?.url" color="brand">
|
||||
<ButtonStyled v-if="primaryFile?.url && !currentMember" color="brand">
|
||||
<a
|
||||
v-tooltip="primaryFile.filename + ' (' + formatBytes(primaryFile.size) + ')'"
|
||||
:href="decoratedPrimaryFileUrl"
|
||||
@@ -159,44 +159,36 @@
|
||||
Report
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<template v-if="currentMember">
|
||||
<ButtonStyled v-if="version.files_missing_attribution?.length > 0" color="orange">
|
||||
<nuxt-link :to="`/project/${project.id}/settings/permissions`">
|
||||
Resolve missing permissions
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="handleOpenEditVersionModal(version.id, project.id, 'metadata')">
|
||||
<BoxIcon aria-hidden="true" />
|
||||
Edit metadata
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="handleOpenEditVersionModal(version.id, project.id, 'add-details')">
|
||||
<InfoIcon aria-hidden="true" />
|
||||
Edit details
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="handleOpenEditVersionModal(version.id, project.id, 'add-files')">
|
||||
<FileIcon aria-hidden="true" />
|
||||
Edit files
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
v-if="
|
||||
version.loaders.some((x: string) => tags.loaderData.dataPackLoaders.includes(x))
|
||||
"
|
||||
@click="modal_package_mod?.show()"
|
||||
>
|
||||
<BoxIcon aria-hidden="true" />
|
||||
Package as mod
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<ButtonStyled v-if="currentMember">
|
||||
<button @click="handleOpenEditVersionModal(version.id, project.id, 'metadata')">
|
||||
<BoxIcon aria-hidden="true" />
|
||||
Edit metadata
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="currentMember">
|
||||
<button @click="handleOpenEditVersionModal(version.id, project.id, 'add-details')">
|
||||
<InfoIcon aria-hidden="true" />
|
||||
Edit details
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="currentMember">
|
||||
<button @click="handleOpenEditVersionModal(version.id, project.id, 'add-files')">
|
||||
<FileIcon aria-hidden="true" />
|
||||
Edit files
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
v-if="
|
||||
currentMember &&
|
||||
version.loaders.some((x: string) => tags.loaderData.dataPackLoaders.includes(x))
|
||||
"
|
||||
@click="modal_package_mod?.show()"
|
||||
>
|
||||
<BoxIcon aria-hidden="true" />
|
||||
Package as mod
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-page__changelog universal-card">
|
||||
|
||||
@@ -185,7 +185,7 @@ function mapExternalProject(
|
||||
exceptions: project.exceptions,
|
||||
proof: project.proof,
|
||||
flame_project_id: project.flame_project_id,
|
||||
files: project.linked_files ?? [],
|
||||
files: project.linked_files,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,42 +17,81 @@
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
|
||||
<div
|
||||
class="flex flex-col items-stretch justify-end gap-2 sm:flex-row sm:items-center lg:flex-shrink-0"
|
||||
>
|
||||
<Combobox
|
||||
v-model="currentFilterType"
|
||||
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
||||
:options="filterTypes"
|
||||
v-model="currentMessageFilter"
|
||||
class="!w-full flex-grow sm:!w-[200px] sm:flex-grow-0"
|
||||
:options="messageFilterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
@select="goToPage(1)"
|
||||
>
|
||||
<template #selected>
|
||||
<template #selected="{ label: messageLabel }">
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
|
||||
<span class="truncate text-contrast"
|
||||
>{{ currentFilterType }} ({{ filteredReports.length }})</span
|
||||
>{{ messageLabel }} ({{ sortedReports.length }})</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
<Combobox
|
||||
v-model="currentSortType"
|
||||
v-model="currentSortTypeSorting"
|
||||
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
|
||||
:options="sortTypes"
|
||||
:placeholder="formatMessage(commonMessages.sortByLabel)"
|
||||
@select="goToPage(1)"
|
||||
>
|
||||
<template #selected>
|
||||
<template #selected="{ label: sortingLabel }">
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<SortAscIcon
|
||||
v-if="currentSortType === 'Oldest'"
|
||||
v-if="currentSortTypeSorting === 'oldest'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
|
||||
<span class="truncate text-contrast">{{ currentSortType }}</span>
|
||||
<span class="truncate text-contrast">{{ sortingLabel }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
<FloatingPanel button-class="!h-10 !shadow-none !text-contrast" :auto-focus="false">
|
||||
<BlendIcon class="size-5" /> Advanced filters
|
||||
<template #panel>
|
||||
<div class="flex min-w-64 flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold text-secondary">Report target</span>
|
||||
<Combobox
|
||||
v-model="currentReportTargetFilter"
|
||||
class="!w-full"
|
||||
:options="reportTargetFilterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-w-64 flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold text-secondary">Issue type</span>
|
||||
<Combobox
|
||||
v-model="currentReportIssueFilter"
|
||||
class="!w-full"
|
||||
:options="reportIssueFilterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold text-secondary">Project type</span>
|
||||
<Combobox
|
||||
v-model="currentProjectTypeFilter"
|
||||
class="!w-full"
|
||||
:options="projectTypeFilterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FloatingPanel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,12 +111,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ListFilterIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||
import { BlendIcon, ListFilterIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||
import type { ExtendedReport } from '@modrinth/moderation'
|
||||
import {
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
FloatingPanel,
|
||||
Pagination,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
@@ -93,6 +133,7 @@ useHead({ title: 'Reports queue - Modrinth' })
|
||||
const { formatMessage } = useVIntl()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = await useAuth()
|
||||
|
||||
const { data: allReports } = await useLazyAsyncData('new-moderation-reports', async () => {
|
||||
const startTime = performance.now()
|
||||
@@ -168,22 +209,54 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
const currentFilterType = ref('All')
|
||||
const filterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'All', label: 'All' },
|
||||
{ value: 'Unread', label: 'Unread' },
|
||||
{ value: 'Read', label: 'Read' },
|
||||
const currentSortTypeSorting = ref('oldest')
|
||||
const sortTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'oldest', label: 'Oldest' },
|
||||
{ value: 'newest', label: 'Newest' },
|
||||
]
|
||||
|
||||
const currentSortType = ref('Oldest')
|
||||
const sortTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Oldest', label: 'Oldest' },
|
||||
{ value: 'Newest', label: 'Newest' },
|
||||
const currentMessageFilter = ref('all')
|
||||
const messageFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'unread', label: 'Unread' },
|
||||
{ value: 'read', label: 'Read' },
|
||||
{ value: 'involved', label: 'Involved' },
|
||||
]
|
||||
|
||||
const currentProjectTypeFilter = ref('all')
|
||||
const projectTypeFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'all', label: 'All project types' },
|
||||
{ value: 'modpack', label: 'Modpacks' },
|
||||
{ value: 'mod', label: 'Mods' },
|
||||
{ value: 'resourcepack', label: 'Resource Packs' },
|
||||
{ value: 'datapack', label: 'Data Packs' },
|
||||
{ value: 'plugin', label: 'Plugins' },
|
||||
{ value: 'shader', label: 'Shaders' },
|
||||
{ value: 'minecraft_java_server', label: 'Servers' },
|
||||
]
|
||||
|
||||
const currentReportTargetFilter = ref('all')
|
||||
const reportTargetFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'project', label: 'Projects' },
|
||||
{ value: 'user', label: 'Users' },
|
||||
{ value: 'version', label: 'Versions' },
|
||||
]
|
||||
|
||||
const currentReportIssueFilter = ref('all')
|
||||
const reportIssueFilterTypes = computed<ComboboxOption<string>[]>(() => {
|
||||
const base: ComboboxOption<string>[] = [{ value: 'all', label: 'All' }]
|
||||
if (!allReports.value) return base
|
||||
|
||||
const issueTypes = new Set(allReports.value.map((report) => report.report_type))
|
||||
|
||||
const sortedTypes = Array.from(issueTypes).sort()
|
||||
return [...base, ...sortedTypes.map((type) => ({ value: type, label: type }))]
|
||||
})
|
||||
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = 15
|
||||
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage))
|
||||
const totalPages = computed(() => Math.ceil((sortedReports.value?.length || 0) / itemsPerPage))
|
||||
|
||||
const fuse = computed(() => {
|
||||
if (!allReports.value || allReports.value.length === 0) return null
|
||||
@@ -247,33 +320,68 @@ const baseFiltered = computed(() => {
|
||||
return query.value && searchResults.value ? searchResults.value : [...allReports.value]
|
||||
})
|
||||
|
||||
const typeFiltered = computed(() => {
|
||||
if (currentFilterType.value === 'All') return baseFiltered.value
|
||||
const filteredReports = computed(() => {
|
||||
const messageFilter = currentMessageFilter.value
|
||||
const projectTypeFilter = currentProjectTypeFilter.value
|
||||
const reportTargetFilter = currentReportTargetFilter.value
|
||||
const reportIssueFilter = currentReportIssueFilter.value
|
||||
|
||||
return baseFiltered.value.filter((report) => {
|
||||
if (
|
||||
messageFilter === 'all' &&
|
||||
projectTypeFilter === 'all' &&
|
||||
reportTargetFilter === 'all' &&
|
||||
reportIssueFilter === 'all'
|
||||
) {
|
||||
return baseFiltered.value
|
||||
}
|
||||
|
||||
const messageFilterPredicate = (report: ExtendedReport) => {
|
||||
const messages = report.thread?.messages || []
|
||||
|
||||
if (messages.length === 0) {
|
||||
return currentFilterType.value === 'Unread'
|
||||
}
|
||||
if (messageFilter === 'all') return true
|
||||
if (messages.length === 0) return messageFilter === 'Unread'
|
||||
if (!messages[messages.length - 1].author_id) return false
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (!lastMessage.author_id) return false
|
||||
if (messageFilter === 'involved') {
|
||||
const userId = (auth.value.user as any)?.id
|
||||
return userId && messages.some((message) => message.author_id === userId)
|
||||
}
|
||||
|
||||
const roleMap = memberRoleMap.value.get(report.id)
|
||||
if (!roleMap) return false
|
||||
|
||||
const authorRole = roleMap.get(lastMessage.author_id)
|
||||
const authorRole = roleMap.get(messages[messages.length - 1].author_id)
|
||||
const isModeratorMessage = authorRole === 'moderator' || authorRole === 'admin'
|
||||
|
||||
return currentFilterType.value === 'Read' ? isModeratorMessage : !isModeratorMessage
|
||||
return messageFilter === 'Read' ? isModeratorMessage : !isModeratorMessage
|
||||
}
|
||||
|
||||
const projectTypeFilterPredicate = (report: ExtendedReport) => {
|
||||
return projectTypeFilter === 'all' || report.project?.project_type === projectTypeFilter
|
||||
}
|
||||
|
||||
const reportTargetFilterPredicate = (report: ExtendedReport) => {
|
||||
return reportTargetFilter === 'all' || report.item_type === reportTargetFilter
|
||||
}
|
||||
|
||||
const reportIssueFilterPredicate = (report: ExtendedReport) => {
|
||||
return reportIssueFilter === 'all' || report.report_type === reportIssueFilter
|
||||
}
|
||||
|
||||
return baseFiltered.value.filter((report) => {
|
||||
return (
|
||||
messageFilterPredicate(report) &&
|
||||
projectTypeFilterPredicate(report) &&
|
||||
reportTargetFilterPredicate(report) &&
|
||||
reportIssueFilterPredicate(report)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const filteredReports = computed(() => {
|
||||
const filtered = [...typeFiltered.value]
|
||||
const sortedReports = computed(() => {
|
||||
const filtered = [...filteredReports.value]
|
||||
|
||||
if (currentSortType.value === 'Oldest') {
|
||||
if (currentSortTypeSorting.value === 'oldest') {
|
||||
filtered.sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime())
|
||||
} else {
|
||||
filtered.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
|
||||
@@ -283,10 +391,10 @@ const filteredReports = computed(() => {
|
||||
})
|
||||
|
||||
const paginatedReports = computed(() => {
|
||||
if (!filteredReports.value) return []
|
||||
if (!sortedReports.value) return []
|
||||
const start = (currentPage.value - 1) * itemsPerPage
|
||||
const end = start + itemsPerPage
|
||||
return filteredReports.value.slice(start, end)
|
||||
return sortedReports.value.slice(start, end)
|
||||
})
|
||||
|
||||
function goToPage(page: number) {
|
||||
|
||||
@@ -85,7 +85,7 @@ const showPreviewImage = (files) => {
|
||||
}
|
||||
}
|
||||
|
||||
const orgId = useRouteId('orgId')
|
||||
const orgId = useRouteId('organization')
|
||||
|
||||
const save = async () => {
|
||||
// Save field changes via useSavable
|
||||
|
||||
@@ -284,11 +284,11 @@ import {
|
||||
VersionIcon,
|
||||
XCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { defineMessage } from '@modrinth/ui'
|
||||
import {
|
||||
AutoLink,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessage,
|
||||
defineMessages,
|
||||
formatReportItemType,
|
||||
injectNotificationManager,
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
|
||||
const ANALYTICS_START_TIMESTAMP = '2023-01-01T00:00:00.000Z'
|
||||
export const ANALYTICS_START_DATE_INPUT_VALUE = ANALYTICS_START_TIMESTAMP.slice(0, 10)
|
||||
const ANALYTICS_START_TIME = new Date(ANALYTICS_START_TIMESTAMP).getTime()
|
||||
export const ANALYTICS_START_TIME = new Date(ANALYTICS_START_TIMESTAMP).getTime()
|
||||
export const REVENUE_MIN_TIMEFRAME_MS = 1 * 24 * 60 * 60 * 1000 // need at least 1 day in timeframe range to show revenue
|
||||
const ANALYTICS_DAY_MS = 24 * 60 * 60 * 1000
|
||||
const ANALYTICS_MAX_TIME_SLICES = 256 // controls granularity allowed in "group by" for timeframe ranges
|
||||
@@ -32,6 +32,7 @@ function isProjectAnalyticsPoint(
|
||||
|
||||
export function buildComparisonFetchRequest(
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
|
||||
minStartTime = ANALYTICS_START_TIME,
|
||||
): AnalyticsProjectFetchRequest | null {
|
||||
if (!isAnalyticsFetchRequestReady(fetchRequest)) {
|
||||
return null
|
||||
@@ -47,7 +48,7 @@ export function buildComparisonFetchRequest(
|
||||
|
||||
const previousStart = new Date(startTimestamp - duration)
|
||||
|
||||
if (previousStart.getTime() < ANALYTICS_START_TIME) {
|
||||
if (previousStart.getTime() < minStartTime) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -93,8 +94,12 @@ function getAnalyticsTimeSliceCount(
|
||||
export function splitAnalyticsTimeSlices(
|
||||
timeSlices: Labrinth.Analytics.v3.TimeSlice[],
|
||||
fetchRequest: Labrinth.Analytics.v3.FetchRequest | null,
|
||||
minStartTime = ANALYTICS_START_TIME,
|
||||
): AnalyticsTimeSliceSplit {
|
||||
if (!isAnalyticsFetchRequestReady(fetchRequest) || !buildComparisonFetchRequest(fetchRequest)) {
|
||||
if (
|
||||
!isAnalyticsFetchRequestReady(fetchRequest) ||
|
||||
!buildComparisonFetchRequest(fetchRequest, minStartTime)
|
||||
) {
|
||||
return {
|
||||
currentTimeSlices: timeSlices,
|
||||
previousTimeSlices: [],
|
||||
@@ -339,6 +344,7 @@ export function getAnalyticsTimeframeDurationMs({
|
||||
customStartDate,
|
||||
customEndDate,
|
||||
nowTimestamp,
|
||||
allTimeStartTimestamp = ANALYTICS_START_TIME,
|
||||
}: {
|
||||
mode: AnalyticsTimeframeMode
|
||||
preset: AnalyticsTimeframePreset
|
||||
@@ -347,6 +353,7 @@ export function getAnalyticsTimeframeDurationMs({
|
||||
customStartDate: string
|
||||
customEndDate: string
|
||||
nowTimestamp: number
|
||||
allTimeStartTimestamp?: number
|
||||
}): number {
|
||||
if (mode === 'preset') {
|
||||
switch (preset) {
|
||||
@@ -370,7 +377,7 @@ export function getAnalyticsTimeframeDurationMs({
|
||||
return now.getTime() - yearStart.getTime()
|
||||
}
|
||||
case 'all_time': {
|
||||
const allTimeDurationMs = nowTimestamp - ANALYTICS_START_TIME
|
||||
const allTimeDurationMs = nowTimestamp - allTimeStartTimestamp
|
||||
return Math.max(0, allTimeDurationMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export function toAnalyticsDashboardProject(
|
||||
iconUrl: project.icon_url ?? undefined,
|
||||
downloads: project.downloads ?? 0,
|
||||
status: getProjectStatusFilterValue(project.status),
|
||||
publishedAt: project.published ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ export type AnalyticsDashboardProjectSource = ProjectTypeMetadata & {
|
||||
icon_url?: string | null
|
||||
downloads?: number | null
|
||||
status?: string | null
|
||||
published?: string | null
|
||||
}
|
||||
|
||||
export type AnalyticsProjectVersionSource = {
|
||||
@@ -121,6 +122,7 @@ export interface AnalyticsDashboardProject {
|
||||
iconUrl?: string
|
||||
downloads: number
|
||||
status: ProjectStatusFilterValue
|
||||
publishedAt?: string
|
||||
}
|
||||
|
||||
export interface AnalyticsDashboardProjectGroup {
|
||||
|
||||
@@ -36,6 +36,7 @@ import type { OrganizationContext } from '../organization-context'
|
||||
import {
|
||||
addVersionIdsFromTimeSlices,
|
||||
addVersionProjectNamesFromTimeSlices,
|
||||
ANALYTICS_START_TIME,
|
||||
areAnalyticsFetchRequestsEqual,
|
||||
buildAnalyticsCurrentTimeSlicesQueryKey,
|
||||
buildAnalyticsFacetsRequest,
|
||||
@@ -68,6 +69,7 @@ import {
|
||||
sortStringValues,
|
||||
} from './analytics-filter-utils'
|
||||
import {
|
||||
getProjectIdsMatchingStatusFilter,
|
||||
getProjectOrganizationId,
|
||||
getSingleQueryValue,
|
||||
getUniqueAnalyticsDashboardProjects,
|
||||
@@ -131,6 +133,17 @@ const ANALYTICS_TIME_SLICES_GC_TIME_MS = 30 * 1000
|
||||
const ANALYTICS_PREFETCH_GC_TIME_MS = 15 * 1000
|
||||
const ANALYTICS_FILTER_OPTIONS_GC_TIME_MS = 60 * 1000
|
||||
const ANALYTICS_MOBILE_LAYOUT_QUERY = '(pointer: coarse), (max-width: 800px)'
|
||||
const ANALYTICS_ALL_TIME_START_OFFSET_MONTHS = 2
|
||||
|
||||
function subtractAnalyticsCalendarMonths(date: Date, months: number): Date {
|
||||
const nextDate = new Date(date)
|
||||
const day = nextDate.getDate()
|
||||
nextDate.setDate(1)
|
||||
nextDate.setMonth(nextDate.getMonth() - months)
|
||||
const daysInMonth = new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 0).getDate()
|
||||
nextDate.setDate(Math.min(day, daysInMonth))
|
||||
return nextDate
|
||||
}
|
||||
|
||||
function getAnalyticsFetchErrorMessage(error: unknown): string {
|
||||
if (error && typeof error === 'object') {
|
||||
@@ -164,6 +177,7 @@ export interface AnalyticsDashboardContextValue {
|
||||
selectedCustomTimeframeStartDate: Ref<string>
|
||||
selectedCustomTimeframeEndDate: Ref<string>
|
||||
selectedGroupBy: Ref<AnalyticsGroupByPreset>
|
||||
analyticsAllTimeStartDate: ComputedRef<Date>
|
||||
selectedBreakdowns: Ref<AnalyticsSelectedBreakdowns>
|
||||
selectedFilters: Ref<AnalyticsSelectedFilters>
|
||||
queryRefreshTimestamp: Ref<number>
|
||||
@@ -368,6 +382,7 @@ export function createAnalyticsDashboardContext(
|
||||
},
|
||||
enabled: computed(() => shouldFetchEffectiveUser.value && hasCompletedAnalyticsLoading.value),
|
||||
placeholderData: null,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
const effectiveUsername = computed(() => {
|
||||
if (effectiveUserId.value === options.auth.value.user?.id) {
|
||||
@@ -407,6 +422,7 @@ export function createAnalyticsDashboardContext(
|
||||
}
|
||||
},
|
||||
enabled: shouldFetchDashboardAllProjects,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const areProjectsLoaded = computed(() => {
|
||||
@@ -547,6 +563,63 @@ export function createAnalyticsDashboardContext(
|
||||
|
||||
return getAnalyticsVersionIdsFromProjects(projects, sortedSelectedProjectIds.value)
|
||||
})
|
||||
const { data: filterOptionProjectVersions, isFetched: hasFetchedFilterOptionProjectVersions } =
|
||||
useQuery({
|
||||
queryKey: computed(() => [
|
||||
'analytics',
|
||||
'dashboard',
|
||||
analyticsQueryUserId.value,
|
||||
'filter-options',
|
||||
'versions',
|
||||
filterOptionVersionIds.value,
|
||||
]),
|
||||
queryFn: () =>
|
||||
fetchAnalyticsVersionMetadataByIds(filterOptionVersionIds.value, (ids) =>
|
||||
client.labrinth.versions_v3.getVersions(ids),
|
||||
),
|
||||
enabled: computed(
|
||||
() =>
|
||||
filterOptionProjectSources.value !== null && sortedSelectedProjectIds.value.length > 0,
|
||||
),
|
||||
placeholderData: [],
|
||||
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const projectsById = computed(
|
||||
() => new Map(projects.value.map((project) => [project.id, project])),
|
||||
)
|
||||
const analyticsAllTimeStartDate = computed(() => {
|
||||
const fallbackStartDate = new Date(ANALYTICS_START_TIME)
|
||||
const filteredProjectIds = getProjectIdsMatchingStatusFilter(
|
||||
selectedProjectIds.value.length > 0 ? selectedProjectIds.value : availableProjectIds.value,
|
||||
projectStatusById.value,
|
||||
selectedFilters.value,
|
||||
)
|
||||
let startTime = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const projectId of filteredProjectIds) {
|
||||
const publishedAt = projectsById.value.get(projectId)?.publishedAt
|
||||
if (!publishedAt) {
|
||||
continue
|
||||
}
|
||||
|
||||
const projectStartTime = new Date(publishedAt).getTime()
|
||||
if (Number.isFinite(projectStartTime)) {
|
||||
startTime = Math.min(startTime, projectStartTime)
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(startTime)) {
|
||||
return fallbackStartDate
|
||||
}
|
||||
|
||||
const offsetStartDate = subtractAnalyticsCalendarMonths(
|
||||
new Date(startTime),
|
||||
ANALYTICS_ALL_TIME_START_OFFSET_MONTHS,
|
||||
)
|
||||
return new Date(Math.max(offsetStartDate.getTime(), ANALYTICS_START_TIME))
|
||||
})
|
||||
const hasExplicitProjectSelectionQuery = computed(() =>
|
||||
hasAnalyticsProjectSelectionQuery(route.query),
|
||||
)
|
||||
@@ -598,6 +671,7 @@ export function createAnalyticsDashboardContext(
|
||||
customStartDate: selectedCustomTimeframeStartDate.value,
|
||||
customEndDate: selectedCustomTimeframeEndDate.value,
|
||||
nowTimestamp: queryRefreshTimestamp.value,
|
||||
allTimeStartTimestamp: analyticsAllTimeStartDate.value.getTime(),
|
||||
}) > REVENUE_MIN_TIMEFRAME_MS,
|
||||
)
|
||||
|
||||
@@ -867,7 +941,17 @@ export function createAnalyticsDashboardContext(
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const comparisonFetchRequest = computed(() => buildComparisonFetchRequest(fetchRequest.value))
|
||||
const analyticsComparisonStartTime = computed(() => {
|
||||
if (selectedTimeframeMode.value === 'preset' && selectedTimeframe.value === 'all_time') {
|
||||
const fetchRequestStart = fetchRequest.value?.time_range.start
|
||||
return new Date(fetchRequestStart ?? analyticsAllTimeStartDate.value).getTime()
|
||||
}
|
||||
|
||||
return ANALYTICS_START_TIME
|
||||
})
|
||||
const comparisonFetchRequest = computed(() =>
|
||||
buildComparisonFetchRequest(fetchRequest.value, analyticsComparisonStartTime.value),
|
||||
)
|
||||
const analyticsTimeSlicesFetchRequest = computed(
|
||||
() => comparisonFetchRequest.value ?? fetchRequest.value,
|
||||
)
|
||||
@@ -901,6 +985,7 @@ export function createAnalyticsDashboardContext(
|
||||
)
|
||||
},
|
||||
enabled: computed(() => isAnalyticsFetchRequestReady(analyticsTimeSlicesFetchRequest.value)),
|
||||
refetchOnWindowFocus: false,
|
||||
gcTime: ANALYTICS_TIME_SLICES_GC_TIME_MS,
|
||||
})
|
||||
watch(currentAnalyticsError, (error) => {
|
||||
@@ -956,7 +1041,10 @@ export function createAnalyticsDashboardContext(
|
||||
}
|
||||
|
||||
const dailyFetchRequest = buildDailyAnalyticsFetchRequest(fetchRequest.value)
|
||||
return buildComparisonFetchRequest(dailyFetchRequest) ?? dailyFetchRequest
|
||||
return (
|
||||
buildComparisonFetchRequest(dailyFetchRequest, analyticsComparisonStartTime.value) ??
|
||||
dailyFetchRequest
|
||||
)
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -984,7 +1072,7 @@ export function createAnalyticsDashboardContext(
|
||||
nextQueryRefreshTimestamp,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchAnalyticsTimeSlices(nextFetchRequest, (request) =>
|
||||
fetchAnalyticsData(nextFetchRequest, (request) =>
|
||||
client.labrinth.analytics_v3.fetch(request),
|
||||
),
|
||||
gcTime: ANALYTICS_PREFETCH_GC_TIME_MS,
|
||||
@@ -1057,6 +1145,7 @@ export function createAnalyticsDashboardContext(
|
||||
isAnalyticsFetchRequestReady(analyticsFacetsRequest.value),
|
||||
),
|
||||
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const { data: analyticsDownloadCountTimeSlices } = useQuery({
|
||||
@@ -1086,30 +1175,9 @@ export function createAnalyticsDashboardContext(
|
||||
),
|
||||
placeholderData: [],
|
||||
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const { data: filterOptionProjectVersions, isFetched: hasFetchedFilterOptionProjectVersions } =
|
||||
useQuery({
|
||||
queryKey: computed(() => [
|
||||
'analytics',
|
||||
'dashboard',
|
||||
analyticsQueryUserId.value,
|
||||
'filter-options',
|
||||
'versions',
|
||||
filterOptionVersionIds.value,
|
||||
]),
|
||||
queryFn: () =>
|
||||
fetchAnalyticsVersionMetadataByIds(filterOptionVersionIds.value, (ids) =>
|
||||
client.labrinth.versions_v3.getVersions(ids),
|
||||
),
|
||||
enabled: computed(
|
||||
() =>
|
||||
filterOptionProjectSources.value !== null && sortedSelectedProjectIds.value.length > 0,
|
||||
),
|
||||
placeholderData: [],
|
||||
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
|
||||
})
|
||||
|
||||
const analyticsFacetsFilterOptionSummary = computed(() =>
|
||||
getAnalyticsFacetsFilterOptionSummary(analyticsFacetsData.value?.facets),
|
||||
)
|
||||
@@ -1213,6 +1281,7 @@ export function createAnalyticsDashboardContext(
|
||||
const splitTimeSlices = splitAnalyticsTimeSlices(
|
||||
nextAnalyticsData.metrics,
|
||||
fetchRequest.value,
|
||||
analyticsComparisonStartTime.value,
|
||||
)
|
||||
timeSlices.value = splitTimeSlices.currentTimeSlices
|
||||
previousTimeSlices.value = splitTimeSlices.previousTimeSlices
|
||||
@@ -1269,6 +1338,7 @@ export function createAnalyticsDashboardContext(
|
||||
enabled: computed(() => analyticsVersionIds.value.length > 0),
|
||||
placeholderData: [],
|
||||
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const allVersionMetadata = computed(() => {
|
||||
@@ -1488,6 +1558,7 @@ export function createAnalyticsDashboardContext(
|
||||
selectedCustomTimeframeStartDate,
|
||||
selectedCustomTimeframeEndDate,
|
||||
selectedGroupBy,
|
||||
analyticsAllTimeStartDate,
|
||||
selectedBreakdowns,
|
||||
selectedFilters,
|
||||
queryRefreshTimestamp,
|
||||
|
||||
@@ -522,4 +522,8 @@ mintegral.com, 10003, RESELLER
|
||||
blis.com, 33, RESELLER, 61453ae19a4b73f4
|
||||
|
||||
#Yahoo
|
||||
yahoo.com, 100033, DIRECT, e1a5b5b6e3255540
|
||||
yahoo.com, 100033, DIRECT, e1a5b5b6e3255540
|
||||
|
||||
#IndexExchange
|
||||
indexexchange.com, 184169, DIRECT, 50b1c356f2c5c8fc
|
||||
indexexchange.com, 198988, DIRECT, 50b1c356f2c5c8fc
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 348 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
@@ -1,5 +1,12 @@
|
||||
{
|
||||
"articles": [
|
||||
{
|
||||
"title": "Manage servers together",
|
||||
"summary": "Add other users to your server, assign roles, and track what’s changed.",
|
||||
"thumbnail": "https://modrinth.com/news/article/server-access/thumbnail.webp",
|
||||
"date": "2026-06-03T20:10:28.823Z",
|
||||
"link": "https://modrinth.com/news/article/server-access"
|
||||
},
|
||||
{
|
||||
"title": "Pride 2026 Fundraiser: Matching up to $10,000",
|
||||
"summary": "Celebrating our community and working together to make a difference.",
|
||||
|
||||
@@ -4,9 +4,17 @@
|
||||
<description><![CDATA[Keep up-to-date on the latest news from Modrinth.]]></description>
|
||||
<link>https://modrinth.com/news/</link>
|
||||
<generator>@modrinth/blog</generator>
|
||||
<lastBuildDate>Sun, 31 May 2026 23:20:41 GMT</lastBuildDate>
|
||||
<lastBuildDate>Wed, 03 Jun 2026 21:05:41 GMT</lastBuildDate>
|
||||
<atom:link href="https://modrinth.com/news/feed/rss.xml" rel="self" type="application/rss+xml"/>
|
||||
<language><![CDATA[en]]></language>
|
||||
<item>
|
||||
<title><![CDATA[Manage servers together]]></title>
|
||||
<description><![CDATA[Add other users to your server, assign roles, and track what’s changed.]]></description>
|
||||
<link>https://modrinth.com/news/article/server-access/</link>
|
||||
<guid isPermaLink="false">https://modrinth.com/news/article/server-access/</guid>
|
||||
<pubDate>Wed, 03 Jun 2026 20:10:28 GMT</pubDate>
|
||||
<content:encoded><![CDATA[<p>Hey everyone,</p><p>With this release, you can now give other users access to your server! This has been one of the most requested features for Modrinth Hosting and we’re excited to finally get it out.</p><p><img src="/news/article/server-access/server-access.webp" alt="The new Access tab in the Modrinth Hosting panel, featuring a list of invited users and their permissions, invite new users, and an activity log to see what changes are being made to your server and by whom."></p><h2>TL;DR</h2><ul><li>Add users to your server</li><li>Set permission roles</li><li>View activity log</li></ul><h2>Invite your friends</h2><p>You can now give other users access to your server so they can help manage content, start the server, and more. To invite someone, just enter their Modrinth username and they’ll receive an invite by email or as a notification in the app if they’re signed in.</p><p>Alongside this release, we’ve also improved state syncing between the website panel, app, and other users, so everything should stay up to date in real time.</p><p><img src="/news/article/server-access/add-user-modal.webp" alt="A pop-up modal for adding a user to your server. Search by Modrinth username, select their role (editor or limited), and an option to also send them a friend request."></p><h2>Permission roles</h2><p>When adding someone to your server, you can choose what level of access they have. There are three roles, with each role inheriting the permissions of the previous one:</p><ul><li><strong>Owner:</strong> Full access to the server including billing (you)</li><li><strong>Editor:</strong> Manage content, files, backups, settings, and more</li><li><strong>Limited:</strong> Start, stop, and view the server without making changes</li></ul><p>You can find a full permission breakdown below:</p><table><thead><tr><th>Permission</th><th>Owner</th><th>Editor</th><th>Limited</th></tr></thead><tbody><tr><td>Start / stop server</td><td>✅</td><td>✅</td><td>✅</td></tr><tr><td>Execute commands</td><td>✅</td><td>✅</td><td>❌</td></tr><tr><td>Edit settings</td><td>✅</td><td>✅</td><td>❌</td></tr><tr><td>Edit installation</td><td>✅</td><td>✅</td><td>❌</td></tr><tr><td>Manage content</td><td>✅</td><td>✅</td><td>❌</td></tr><tr><td>Manage files</td><td>✅</td><td>✅</td><td>❌</td></tr><tr><td>Create &amp; restore backups</td><td>✅</td><td>✅</td><td>❌</td></tr><tr><td>Invite users</td><td>✅</td><td>❌</td><td>❌</td></tr><tr><td>Reset server</td><td>✅</td><td>❌</td><td>❌</td></tr><tr><td>Manage billing</td><td>✅</td><td>❌</td><td>❌</td></tr></tbody></table><h2>See what changed</h2><p>Along with adding users, we’ve introduced an activity log. This is a chronological history of actions related to your server so you can see what changed, who changed it, and when it happened. Some actions are grouped together, like updating multiple projects at once, to keep things easier to read.</p><p>You can select a time timeframe and filter by user or action type if you’re looking for something specific.</p><p><img src="/news/article/server-access/activity-log.webp" alt="The activity log section of the Access tab, where you can see the user that performed an action on the left column, the action that was performed in the center, and the time it happened on the right."></p><p>—</p><p>Thank you for your continued support! 💚</p>]]></content:encoded>
|
||||
</item>
|
||||
<item>
|
||||
<title><![CDATA[Pride 2026 Fundraiser: Matching up to $10,000]]></title>
|
||||
<description><![CDATA[Celebrating our community and working together to make a difference.]]></description>
|
||||
|
||||
Generated
-32
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select\n fa.file_id as \"file_id: DBFileId\",\n f.url,\n v.mod_id as \"project_id: DBProjectId\"\n from file_scans fa\n inner join files f on f.id = fa.file_id\n inner join versions v on v.id = f.version_id\n where fa.attributions_scanned_at is null\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "file_id: DBFileId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "url",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "project_id: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0b06b60b7169a3f9ee66c7ec26f94982619e95a78ecb2d3ffa55774b098e0531"
|
||||
}
|
||||
Generated
-34
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select distinct f.version_id as \"version_id: DBVersionId\", f.id as \"file_id: DBFileId\",\n pag.flame_project\n from files f\n inner join attribution_enforced_versions aev on aev.id = f.version_id\n inner join override_file_sources ofs on ofs.file_id = f.id\n inner join project_attribution_files paf on paf.sha1 = ofs.sha1\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where f.version_id = ANY($1)\n and (\n pag.attribution is null\n or pag.attribution->>'kind' = 'no_permission'\n or coalesce(\n pag.attribution->'moderation_status'->>'kind',\n 'approved'\n ) != 'approved'\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "version_id: DBVersionId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "file_id: DBFileId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "flame_project",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "148ef260d2db9d31004be8227fba5b18600ad700fd5741553a5db810d08ae382"
|
||||
}
|
||||
Generated
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id)\n values ($1, $2, $3, $4)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1d27a83fb85c4640c3fc88fc6caa8209973ced0f88d8dbecdac349bbe70930a5"
|
||||
}
|
||||
Generated
-46
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect\n\t\t\tg.id as \"id: DBAttributionGroupId\",\n\t\t\tg.flame_project,\n\t\t\tg.attribution,\n\t\t\tg.attributed_at,\n\t\t\tg.attributed_by as \"attributed_by: i64\"\n\t\tfrom project_attribution_groups g\n\t\twhere g.project_id = $1\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id: DBAttributionGroupId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flame_project",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "attribution",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "attributed_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "attributed_by: i64",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1d96df0ac64801641f9af796d482d2964c1acf2d03e15b8f1397340d0c994af6"
|
||||
}
|
||||
Generated
-88
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n mef.sha1 hash,\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id\n WHERE mef.sha1 = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "hash",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "exceptions",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "proof",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inserted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "inserted_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "25d6977fa2db63f979fbe391f3475445fa92c9bbb5b034531f5e033a18c2a6a3"
|
||||
}
|
||||
Generated
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_files (group_id, name, sha1)\n select $1, unnest($2::text[]), unnest($3::bytea[])\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"TextArray",
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2e6bb84f45c0f8ba7bb9b09bf3def4322f727c7af8bd4be49dc4d7c487b925ed"
|
||||
}
|
||||
Generated
-40
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT mel.id, mel.flame_project_id, mel.status status, mel.link\n FROM moderation_external_licenses mel\n WHERE mel.flame_project_id = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "2f56a06b78a810936a77547ab5890943d6aa3e9143a8e2617d025be960880223"
|
||||
}
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM project_attribution_groups g\n WHERE NOT EXISTS (\n SELECT 1\n FROM project_attribution_files paf\n INNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1\n WHERE paf.group_id = g.id\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "301959b1ec20a4925f86188bc5e1c0cdd11f0d15004ce9f91fbeefcc3a27f8f4"
|
||||
}
|
||||
Generated
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select id as \"id: DBAttributionGroupId\", flame_project\n from project_attribution_groups\n where project_id = $1 and flame_project is not null\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id: DBAttributionGroupId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flame_project",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "424b975139004d2854d9365ef950984dd1a65e333bb80924b5883df5e7d3cad2"
|
||||
}
|
||||
Generated
-47
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\tselect\n\t\t\t\tpaf.group_id as \"group_id!\",\n\t\t\t\tpaf.name as \"name!\",\n\t\t\t\tconvert_from(paf.sha1, 'UTF8') as \"sha1!\",\n\t\t\t\tpaf.moderation_external_license_id,\n\t\t\t\tcoalesce(array_agg(distinct aev.id) filter (where aev.id is not null), '{}') as \"version_ids!: Vec<i64>\"\n\t\t\tfrom project_attribution_files paf\n\t\t\tleft join override_file_sources ofs on ofs.sha1 = paf.sha1\n\t\t\tleft join files f on f.id = ofs.file_id\n\t\t\tleft join versions v on v.id = f.version_id and v.mod_id = $2\n\t\t\tleft join attribution_enforced_versions aev on aev.id = v.id\n\t\t\twhere paf.group_id = ANY($1)\n\t\t\tgroup by paf.group_id, paf.name, paf.sha1, paf.moderation_external_license_id\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "group_id!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "sha1!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "moderation_external_license_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "version_ids!: Vec<i64>",
|
||||
"type_info": "Int8Array"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "48a83bc9de9bea73eac5c43e71f43fb8a1ac7eb67079d16e3de92f5ff6bf72b8"
|
||||
}
|
||||
Generated
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tupdate project_attribution_files\n\t\tset group_id = $1\n\t\twhere sha1 = $2\n\t\tand group_id in (\n\t\t\tselect id from project_attribution_groups where project_id = $3\n\t\t)\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4fb69c674dca723b6b2cb7bea07d51feb43b2714c1cd6a2987a6fe1f10e2579a"
|
||||
}
|
||||
Generated
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tupdate project_attribution_files\n\t\tset group_id = $1\n\t\twhere sha1 = $2 and group_id = $3\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5f7b9d628d3ff0addc12cfaab68d7cbcca08a89aedeb15ce5926ed87ed8a12c6"
|
||||
}
|
||||
Generated
-82
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id,\n title,\n status,\n link,\n exceptions,\n proof,\n flame_project_id,\n inserted_at,\n inserted_by,\n updated_at,\n updated_by\n FROM moderation_external_licenses\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "exceptions",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "proof",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "inserted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inserted_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5f847946ce63d3773b9a5822971ee7acaafeed018ce0540b2826bd878f213b87"
|
||||
}
|
||||
+6
-12
@@ -1,35 +1,30 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT d.id as dependency_id, dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type\n FROM dependencies d\n WHERE dependent_id = ANY($1)\n ",
|
||||
"query": "\n SELECT DISTINCT dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type\n FROM dependencies d\n WHERE dependent_id = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "dependency_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "version_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"ordinal": 1,
|
||||
"name": "dependency_project_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"ordinal": 2,
|
||||
"name": "dependency_version_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"ordinal": 3,
|
||||
"name": "file_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"ordinal": 4,
|
||||
"name": "dependency_type",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
@@ -40,7 +35,6 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
@@ -48,5 +42,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "16e13baf35118cda943abf259a0e24cfe13436844b7909e6502ec15a249fc856"
|
||||
"hash": "623881c24c12e77f6fc57669929be55a34800cd2269da29d555959164919c9a3"
|
||||
}
|
||||
Generated
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tinsert into project_attribution_groups (id, project_id)\n\t\tvalues ($1, $2)\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6320df0491556027ee64fd031797b252c2145ecf179e9ea9a8cedbdc8b7ed622"
|
||||
}
|
||||
Generated
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO file_scans (file_id)\n VALUES ($1)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "64c6abc464f2df37372875817ceb67b0e33786fe3dd27c052311356d43b1d601"
|
||||
}
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_licenses mel\n WHERE mel.flame_project_id = ANY($1)\n ORDER BY mel.id\n ",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_licenses mel\n WHERE ($1::text IS NULL OR mel.title ILIKE '%' || $1 || '%')\n AND ($2::integer IS NULL OR mel.flame_project_id = $2)\n ORDER BY mel.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -61,7 +61,8 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4Array"
|
||||
"Text",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
@@ -78,5 +79,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "03d85f360d4c603688d0662d24caee7b59985adba006fb531beb09f195582277"
|
||||
"hash": "6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57"
|
||||
}
|
||||
Generated
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect exists(\n\t\t\tselect 1 from project_attribution_groups where id = $1 and project_id = $2\n\t\t) as \"exists!\"\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "73f7542b4b6738c4baf1e2a3513645c3bf07a960b7b9fac529f23099675ec0cc"
|
||||
}
|
||||
Generated
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into file_scans (file_id, attributions_scanned_at)\n values ($1, now())\n on conflict (file_id) do update set attributions_scanned_at = now()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "80751dbca09a9dcb469a30fe5b8225e520baf50750a78875f582349610439a55"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n 'issues', (\n SELECT json_agg(\n to_jsonb(dri)\n || jsonb_build_object(\n -- TODO: replace with `json_array` in Postgres 16\n 'details', (\n SELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n )\n )\n FROM delphi_report_issues dri\n WHERE\n dri.report_id = dr.id\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n )\n ) AS \"data!: sqlx::types::Json<FileReport>\"\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE dr.id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "data!: sqlx::types::Json<FileReport>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8820a5985291c159c98371c9650092e3eba21c81e3b3386be779978aff30451a"
|
||||
}
|
||||
Generated
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"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 ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "avatar_url",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "8d0ae0da359ebd33801f2796c841b9b3cc1a59f7cdee756ac5ce1c459e69a531"
|
||||
}
|
||||
Generated
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM project_attribution_groups WHERE id=$1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8ffcb0c2bd82eaf64b143745adab359db6fcc448d6306292a7b345277a075883"
|
||||
}
|
||||
Generated
-82
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\t\tselect\n\t\t\t\t\tid,\n\t\t\t\t\ttitle,\n\t\t\t\t\tstatus,\n\t\t\t\t\tlink,\n\t\t\t\t\texceptions,\n\t\t\t\t\tproof,\n\t\t\t\t\tflame_project_id,\n\t\t\t\t\tinserted_at,\n\t\t\t\t\tinserted_by,\n\t\t\t\t\tupdated_at,\n\t\t\t\t\tupdated_by\n\t\t\t\tfrom moderation_external_licenses\n\t\t\t\twhere id = ANY($1)\n\t\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "exceptions",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "proof",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "inserted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inserted_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "944286604bdef4ee40f79af358d1b68cc93bfd2067619d9cc1d03013f73e5424"
|
||||
}
|
||||
Generated
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tupdate project_attribution_groups\n\t\tset attribution = $1, attributed_at = now(), attributed_by = $3\n\t\twhere id = $2\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "94920d66b27503c84744d41cb2b44dd213bb461596046488eaab0b62b0fe83c5"
|
||||
}
|
||||
Generated
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id)\n values ($1, $2, $3, $4)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "95750cd616b72347fb997c3b02a3d69f13be5993c1fd2e302761089c499fb015"
|
||||
}
|
||||
Generated
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n update file_scans\n set attributions_scanned_at = now\n from unnest($1::bigint[], $2::timestamptz[]) as u(id, now)\n where file_scans.file_id = u.id\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array",
|
||||
"TimestamptzArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "968904f577c2c696c6222e19cc145bce0e845f2ab9f8629b0789a4861bddb4dc"
|
||||
}
|
||||
+3
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_licenses mel\n WHERE ($1::text IS NULL OR mel.title ILIKE '%' || $1 || '%')\n AND (\n ($2::integer IS NULL AND $3::integer[] IS NULL)\n OR mel.flame_project_id = $2\n OR mel.flame_project_id = ANY($3)\n )\n ORDER BY mel.id\n ",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id\n WHERE mef.sha1 = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -61,9 +61,7 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4",
|
||||
"Int4Array"
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
@@ -80,5 +78,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0e9e528a008a9dd24acfabbffcfe8ee4fd48fbae7c6197bfbbf1923e6256658b"
|
||||
"hash": "99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4"
|
||||
}
|
||||
Generated
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_groups (id, project_id)\n values ($1, $2)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b471add519874a6364b7a9cdbfa3a8e6f5a8a1ac9a7bacbce378f7995eb48664"
|
||||
}
|
||||
Generated
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect paf.group_id\n\t\tfrom project_attribution_files paf\n\t\tinner join project_attribution_groups pag on pag.id = paf.group_id\n\t\twhere paf.sha1 = $1 and pag.project_id = $2\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "group_id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b782ed64e0df1e83623c8a8e55c00716f5338c85b6dfbb15c3465ff0cec339a7"
|
||||
}
|
||||
Generated
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect attribution\n\t\tfrom project_attribution_groups\n\t\twhere id = $1\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "attribution",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bb7c239e2b424557f260c56c01e15ab8b0ec04d76a55a50888d28e6c5d3948bc"
|
||||
}
|
||||
Generated
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_files (group_id, name, sha1)\n values ($1, $2, $3)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c39e1f0d820101ac1b19a3849254b03259f38f3de9ddca32e6189a7e015dd853"
|
||||
}
|
||||
Generated
-29
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect paf.group_id, paf.name from project_attribution_files paf\n\t\tinner join project_attribution_groups pag on pag.id = paf.group_id\n\t\twhere paf.sha1 = $1 and pag.project_id = $2\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "group_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ce9b408d416b4782ad501e14cb1ff006964818e81855bb299b269ffaa4c3f7fa"
|
||||
}
|
||||
Generated
-40
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\tselect id, name, version_number, date_published\n\t\t\tfrom versions\n\t\t\twhere id = ANY($1)\n\t\t\torder by date_published desc\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "version_number",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "date_published",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dac21bfc23fe361a6133e69892224351aefa03f14efec1be31a21441419e19b9"
|
||||
}
|
||||
Generated
-17
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_groups (id, project_id, attribution, flame_project)\n values ($1, $2, $3, $4)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Jsonb",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "df7652db8624e291447975b1d9b2151b1627316fbaaea4914607d66869670375"
|
||||
}
|
||||
Generated
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select paf.sha1 from project_attribution_files paf\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where pag.project_id = $1 and paf.sha1 = ANY($2)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "sha1",
|
||||
"type_info": "Bytea"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e4b58fe6e5d19ded48ab9e457b4e96fb51c8f74a7532bb9ab2e7a945b95bbe78"
|
||||
}
|
||||
Generated
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into override_file_sources (sha1, file_id)\n select unnest($1::bytea[]), $2\n on conflict do nothing\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"ByteaArray",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e7a0481729efa9cba6140effcdddff1a076eb7269d5b22860db3e3d1a651f6eb"
|
||||
}
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tdelete from project_attribution_groups g\n\t\twhere not exists (\n\t\t\tselect 1 from project_attribution_files f where f.group_id = g.id\n\t\t)\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ed027c5571c34ff4fd1ac50e96f257b0055c9497d2699927b34829490e7a2529"
|
||||
}
|
||||
Generated
-40
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select\n d.id as \"dependency_id!\",\n pag.attribution,\n pag.flame_project,\n pag.project_id as \"project_id: DBProjectId\"\n from dependencies d\n inner join files f on f.version_id = d.dependent_id\n inner join attribution_enforced_versions aev on aev.id = f.version_id\n inner join override_file_sources ofs on ofs.file_id = f.id\n inner join project_attribution_files paf on paf.sha1 = ofs.sha1\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where d.dependent_id = ANY($1)\n and d.dependency_file_name is not null\n and (\n pag.flame_project is not null\n or pag.attribution is not null\n )\n and split_part(paf.name, '/', -1) = d.dependency_file_name\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "dependency_id!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "attribution",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "flame_project",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "project_id: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f453824f96385cccb703848189f25076fd9c1578e417d899d959a19dd9f940c3"
|
||||
}
|
||||
Generated
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_groups (id, project_id, flame_project)\n values ($1, $2, $3)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f9131eb55490b96851ac3760e3199d2fd2dbb3d212cb2b8687dda8ba0fad2a80"
|
||||
}
|
||||
Generated
-40
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT encode(mef.sha1, 'escape') sha1, mel.id, mel.status status, mel.link\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mef.external_license_id = mel.id\n WHERE mef.sha1 = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "sha1",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f9b96c70c83f1bf0112243602843abae6ddc8851fc623ccf0a23f87567763485"
|
||||
}
|
||||
Generated
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t'issues', (\n\t\t\t\t\tSELECT coalesce(json_agg(\n\t\t\t\t\t\tto_jsonb(dri)\n\t\t\t\t\t\t|| jsonb_build_object(\n\t\t\t\t\t\t\t-- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t\t\t\t'details', (\n\t\t\t\t\t\t\t\tSELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n\t\t\t\t\t\t)\n\t\t\t\t\t), '[]'::json)\n\t\t\t\t\tFROM delphi_report_issues dri\n\t\t\t\t\tWHERE\n\t\t\t\t\t\tdri.report_id = dr.id\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n )\n ) AS \"data!: sqlx::types::Json<FileReport>\"\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE dr.id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "data!: sqlx::types::Json<FileReport>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fe4ff6ab40fe3dc3d474c0c23c9dba514c66ac30e573fc5f4e44ed7ff360d3d6"
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
# Labrinth
|
||||
|
||||
Labrinth is the backend API service for Modrinth, written in Rust.
|
||||
|
||||
## Code style
|
||||
|
||||
- When writing `sqlx` queries, NEVER use `query` directly. Always prefer using the `query!`, `query_as!`, `query_scalar!` macros.
|
||||
|
||||
## Pre-PR Checks
|
||||
|
||||
When the user refers to "perform[ing] pre-PR checks", do the following:
|
||||
|
||||
- Run `cargo clippy -p labrinth --all-targets` — there must be ZERO warnings, otherwise CI will fail
|
||||
- DO NOT run tests unless explicitly requested (they take a long time)
|
||||
- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare -- --tests`
|
||||
- NEVER run `cargo sqlx prepare --workspace`
|
||||
|
||||
## Testing
|
||||
|
||||
- Run `cargo test -p labrinth --all-targets` to test your changes — all tests must pass
|
||||
|
||||
## Local Services
|
||||
|
||||
- Read the root `docker-compose.yml` to see what running services are available while developing
|
||||
- Use `docker exec` to access these services
|
||||
|
||||
### Clickhouse
|
||||
|
||||
- Access: `docker exec labrinth-clickhouse clickhouse-client`
|
||||
- Database: `staging_ariadne`
|
||||
|
||||
### Postgres
|
||||
|
||||
- Access: `docker exec labrinth-postgres psql -U labrinth -d labrinth -c "<query>"`
|
||||
@@ -1,33 +0,0 @@
|
||||
create table file_scans (
|
||||
file_id bigint primary key references files(id),
|
||||
-- if a file..
|
||||
-- - does not have a row
|
||||
-- -> was created before attributions system
|
||||
-- - has a row, but `attributions_scanned_at = null`
|
||||
-- -> still needs to be scanned
|
||||
-- - has a row, and `attributions_scanned_at` is not null
|
||||
-- -> attributions have been scanned
|
||||
attributions_scanned_at timestamptz
|
||||
);
|
||||
|
||||
create table project_attribution_groups (
|
||||
id bigint primary key,
|
||||
project_id bigint not null references mods(id),
|
||||
flame_project jsonb,
|
||||
attribution jsonb,
|
||||
attributed_at timestamptz,
|
||||
attributed_by bigint references users(id)
|
||||
);
|
||||
create index on project_attribution_groups (project_id);
|
||||
|
||||
create table project_attribution_files (
|
||||
group_id bigint not null references project_attribution_groups(id),
|
||||
name text not null,
|
||||
sha1 bytea not null
|
||||
);
|
||||
|
||||
create table override_file_sources (
|
||||
sha1 bytea not null,
|
||||
file_id bigint not null references files(id),
|
||||
primary key (sha1, file_id)
|
||||
);
|
||||
@@ -1,19 +0,0 @@
|
||||
alter table file_scans
|
||||
drop constraint file_scans_file_id_fkey,
|
||||
add constraint file_scans_file_id_fkey
|
||||
foreign key (file_id) references files(id) on delete cascade;
|
||||
|
||||
alter table project_attribution_groups
|
||||
drop constraint project_attribution_groups_project_id_fkey,
|
||||
add constraint project_attribution_groups_project_id_fkey
|
||||
foreign key (project_id) references mods(id) on delete cascade;
|
||||
|
||||
alter table project_attribution_files
|
||||
drop constraint project_attribution_files_group_id_fkey,
|
||||
add constraint project_attribution_files_group_id_fkey
|
||||
foreign key (group_id) references project_attribution_groups(id) on delete cascade;
|
||||
|
||||
alter table override_file_sources
|
||||
drop constraint override_file_sources_file_id_fkey,
|
||||
add constraint override_file_sources_file_id_fkey
|
||||
foreign key (file_id) references files(id) on delete cascade;
|
||||
@@ -1,20 +0,0 @@
|
||||
alter table project_attribution_files
|
||||
add column moderation_external_license_id bigint references moderation_external_licenses(id);
|
||||
|
||||
create table version_attribution_exemptions (
|
||||
version_id bigint primary key references versions(id) on delete cascade
|
||||
);
|
||||
|
||||
create view attribution_enforced_versions as
|
||||
select v.id
|
||||
from versions v
|
||||
left join version_attribution_exemptions vae on vae.version_id = v.id
|
||||
where vae.version_id is null;
|
||||
|
||||
-- grandfathering migration:
|
||||
-- insert into version_attribution_exemptions (version_id)
|
||||
-- select v.id
|
||||
-- from versions v
|
||||
-- inner join mods m on m.id = v.mod_id
|
||||
-- where m.status in ('approved', 'unlisted', 'archived', 'private', 'scheduled', 'withheld')
|
||||
-- on conflict do nothing;
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX users_lowercase_username_pattern
|
||||
ON users (LOWER(username) text_pattern_ops);
|
||||
@@ -5,39 +5,11 @@ use crate::database::models::version_item::VersionQueryResult;
|
||||
use crate::database::models::{DBCollection, DBOrganization, DBTeamMember};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::database::{DBProject, DBVersion, models};
|
||||
use crate::models::ids::FileId;
|
||||
use crate::models::projects::{
|
||||
MissingAttributionFile, OverrideSource, Version,
|
||||
};
|
||||
use crate::models::users::User;
|
||||
use crate::queue::file_scan::{
|
||||
get_dependency_attributions, get_files_missing_attribution,
|
||||
};
|
||||
use crate::routes::ApiError;
|
||||
use futures::TryStreamExt;
|
||||
use itertools::Itertools;
|
||||
|
||||
pub async fn enrich_dependency_attributions(
|
||||
versions: &mut [VersionQueryResult],
|
||||
pool: &PgPool,
|
||||
) {
|
||||
let version_ids = versions.iter().map(|v| v.inner.id).collect::<Vec<_>>();
|
||||
let dep_attr = get_dependency_attributions(pool, &version_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
for version in versions {
|
||||
for dep in &mut version.dependencies {
|
||||
if let Some(attr) = dep_attr.get(&dep.id)
|
||||
&& (attr.attribution.flame_project.is_some()
|
||||
|| attr.attribution.resolution.is_some())
|
||||
{
|
||||
dep.attribution = Some(attr.attribution.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ValidateAuthorized {
|
||||
fn validate_authorized(
|
||||
&self,
|
||||
@@ -232,42 +204,7 @@ pub async fn filter_visible_versions(
|
||||
)
|
||||
.await?;
|
||||
versions.retain(|x| filtered_version_ids.contains(&x.inner.id));
|
||||
|
||||
let version_ids: Vec<_> = versions.iter().map(|v| v.inner.id).collect();
|
||||
let missing = get_files_missing_attribution(pool, &version_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
enrich_dependency_attributions(&mut versions, pool).await;
|
||||
|
||||
Ok(versions
|
||||
.into_iter()
|
||||
.map(|v| {
|
||||
let files_missing = missing
|
||||
.get(&v.inner.id)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.map(|(id, fp)| MissingAttributionFile {
|
||||
id: FileId(id.0 as u64),
|
||||
override_source: fp
|
||||
.as_ref()
|
||||
.map(|p| OverrideSource::Flame {
|
||||
id: p.id,
|
||||
title: p.title.clone(),
|
||||
url: p.url.clone(),
|
||||
icon_url: p.icon_url.clone(),
|
||||
})
|
||||
.or(Some(OverrideSource::Unknown)),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut version = Version::from(v);
|
||||
version.files_missing_attribution = files_missing;
|
||||
version
|
||||
})
|
||||
.collect())
|
||||
Ok(versions.into_iter().map(|x| x.into()).collect())
|
||||
}
|
||||
|
||||
impl ValidateAuthorized for models::DBOAuthClient {
|
||||
@@ -321,20 +258,13 @@ pub async fn filter_visible_version_ids(
|
||||
filter_enlisted_version_ids(versions.clone(), user_option, pool, redis)
|
||||
.await?;
|
||||
|
||||
let version_ids: Vec<_> = versions.iter().map(|v| v.id).collect();
|
||||
let withheld_versions = get_files_missing_attribution(pool, &version_ids)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Return versions that are not hidden, we are a mod of, or we are enlisted on the team of
|
||||
for version in versions {
|
||||
let is_withheld = withheld_versions.contains_key(&version.id);
|
||||
// We can see the version if:
|
||||
// - it's not hidden and we can see the project and it's not withheld for attribution
|
||||
// - it's not hidden and we can see the project
|
||||
// - we are a mod
|
||||
// - we are enlisted on the team of the mod
|
||||
if (!version.status.is_hidden()
|
||||
&& !is_withheld
|
||||
&& visible_project_ids.contains(&version.project_id))
|
||||
|| user_option.as_ref().is_some_and(|x| x.role.is_mod())
|
||||
|| enlisted_version_ids.contains(&version.id)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
use crate::database;
|
||||
use crate::database::PgPool;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::queue::analytics::cache::cache_analytics;
|
||||
use crate::queue::billing::{index_billing, index_subscriptions};
|
||||
use crate::queue::email::EmailQueue;
|
||||
use crate::queue::file_scan::scan_all_files;
|
||||
use crate::queue::payouts::{
|
||||
PayoutsQueue, index_payouts_notifications,
|
||||
insert_bank_balances_and_webhook, process_affiliate_payouts,
|
||||
@@ -36,10 +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,
|
||||
/// Finds files of versions which have not been scanned for attributions
|
||||
/// yet, extracts them to find file overrides, and finds any overrides which
|
||||
/// require attribution from the creator.
|
||||
ScanFiles,
|
||||
}
|
||||
|
||||
impl BackgroundTask {
|
||||
@@ -50,7 +44,6 @@ impl BackgroundTask {
|
||||
ro_pool: PgPool,
|
||||
redis_pool: RedisPool,
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
file_host: web::Data<dyn FileHost>,
|
||||
clickhouse: clickhouse::Client,
|
||||
stripe_client: stripe::Client,
|
||||
anrok_client: anrok::Client,
|
||||
@@ -97,7 +90,6 @@ impl BackgroundTask {
|
||||
PingMinecraftJavaServers => {
|
||||
ping_minecraft_java_servers(pool, redis_pool, clickhouse).await
|
||||
}
|
||||
ScanFiles => scan_all_files(&pool, &redis_pool, &**file_host).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
use super::DatabaseError;
|
||||
use crate::database::PgTransaction;
|
||||
use crate::models::ids::{
|
||||
AffiliateCodeId, AnalyticsEventId, AttributionGroupId, CampaignDonationId,
|
||||
ChargeId, CollectionId, FileId, ImageId, NotificationId,
|
||||
OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId,
|
||||
OAuthRedirectUriId, OrganizationId, PatId, PayoutId, ProductId,
|
||||
ProductPriceId, ProjectId, ReportId, SessionId, SharedInstanceId,
|
||||
SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId, ThreadMessageId,
|
||||
UserSubscriptionId, VersionId,
|
||||
AffiliateCodeId, AnalyticsEventId, CampaignDonationId, ChargeId,
|
||||
CollectionId, FileId, ImageId, NotificationId, OAuthAccessTokenId,
|
||||
OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId,
|
||||
OrganizationId, PatId, PayoutId, ProductId, ProductPriceId, ProjectId,
|
||||
ReportId, SessionId, SharedInstanceId, SharedInstanceVersionId, TeamId,
|
||||
TeamMemberId, ThreadId, ThreadMessageId, UserSubscriptionId, VersionId,
|
||||
};
|
||||
use ariadne::ids::base62_impl::to_base62;
|
||||
use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range};
|
||||
@@ -173,10 +172,6 @@ db_id_interface!(
|
||||
CollectionId,
|
||||
generator: generate_collection_id @ "collections",
|
||||
);
|
||||
db_id_interface!(
|
||||
AttributionGroupId,
|
||||
generator: generate_attribution_group_id @ "project_attribution_groups",
|
||||
);
|
||||
db_id_interface!(
|
||||
FileId,
|
||||
generator: generate_file_id @ "files",
|
||||
|
||||
@@ -6,7 +6,6 @@ use super::{DBUser, ids::*};
|
||||
use crate::database::models::DatabaseError;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::database::{PgTransaction, models};
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models::exp;
|
||||
use crate::models::ids::ProjectId;
|
||||
use crate::models::projects::{
|
||||
@@ -188,8 +187,6 @@ impl ProjectBuilder {
|
||||
pub async fn insert(
|
||||
self,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
redis: &RedisPool,
|
||||
file_host: &dyn FileHost,
|
||||
http: &reqwest::Client,
|
||||
) -> Result<DBProjectId, DatabaseError> {
|
||||
let project_struct = DBProject {
|
||||
@@ -238,7 +235,7 @@ impl ProjectBuilder {
|
||||
|
||||
for mut version in self.initial_versions {
|
||||
version.project_id = self.project_id;
|
||||
version.insert(transaction, redis, file_host, http).await?;
|
||||
version.insert(&mut *transaction, http).await?;
|
||||
}
|
||||
|
||||
LinkUrl::insert_many_projects(
|
||||
|
||||
@@ -57,6 +57,13 @@ pub struct DBUser {
|
||||
pub is_subscribed_to_newsletter: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||
pub struct DBSearchUser {
|
||||
pub id: DBUserId,
|
||||
pub username: String,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct Pride26CampaignDonation {
|
||||
pub last_donated_at: DateTime<Utc>,
|
||||
@@ -264,6 +271,44 @@ impl DBUser {
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
pub async fn search<'a, E>(
|
||||
query: &str,
|
||||
exec: E,
|
||||
) -> Result<Vec<DBSearchUser>, sqlx::Error>
|
||||
where
|
||||
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let lowercase_query = query.to_lowercase();
|
||||
let escaped_query = format!("{}%", escape_like(&lowercase_query));
|
||||
|
||||
let users = sqlx::query!(
|
||||
"
|
||||
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
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| DBSearchUser {
|
||||
id: DBUserId(row.id),
|
||||
username: row.username,
|
||||
avatar_url: row.avatar_url,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(users)
|
||||
}
|
||||
|
||||
pub async fn get_by_email<'a, E>(
|
||||
email: &str,
|
||||
exec: E,
|
||||
@@ -1044,3 +1089,14 @@ impl DBUser {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_like(query: &str) -> String {
|
||||
let mut escaped = String::with_capacity(query.len());
|
||||
for ch in query.chars() {
|
||||
if matches!(ch, '\\' | '%' | '_') {
|
||||
escaped.push('\\');
|
||||
}
|
||||
escaped.push(ch);
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
@@ -6,11 +6,8 @@ use crate::database::models::loader_fields::{
|
||||
QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::file_hosting::FileHost;
|
||||
use crate::models::exp;
|
||||
|
||||
use crate::models::projects::{FileType, VersionStatus};
|
||||
use crate::queue::file_scan::scan_file;
|
||||
use crate::routes::internal::delphi::DelphiRunParameters;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::{DashMap, DashSet};
|
||||
@@ -20,31 +17,10 @@ use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::iter;
|
||||
use tracing::error;
|
||||
|
||||
pub const VERSIONS_NAMESPACE: &str = "versions";
|
||||
const VERSION_FILES_NAMESPACE: &str = "versions_files";
|
||||
|
||||
pub async fn cleanup_empty_attribution_groups(
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
) -> Result<(), DatabaseError> {
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM project_attribution_groups g
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM project_attribution_files paf
|
||||
INNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1
|
||||
WHERE paf.group_id = g.id
|
||||
)
|
||||
",
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VersionBuilder {
|
||||
pub version_id: DBVersionId,
|
||||
@@ -158,10 +134,7 @@ impl VersionFileBuilder {
|
||||
pub async fn insert(
|
||||
self,
|
||||
version_id: DBVersionId,
|
||||
project_id: DBProjectId,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
redis: &RedisPool,
|
||||
file_host: &dyn FileHost,
|
||||
http: &reqwest::Client,
|
||||
) -> Result<DBFileId, DatabaseError> {
|
||||
let file_id = generate_file_id(&mut *transaction).await?;
|
||||
@@ -196,16 +169,6 @@ impl VersionFileBuilder {
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO file_scans (file_id)
|
||||
VALUES ($1)
|
||||
",
|
||||
file_id as DBFileId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if let Err(err) = crate::routes::internal::delphi::run(
|
||||
&mut *transaction,
|
||||
DelphiRunParameters {
|
||||
@@ -215,20 +178,7 @@ impl VersionFileBuilder {
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Error submitting new file to Delphi: {err:?}");
|
||||
}
|
||||
|
||||
if let Err(err) = scan_file(
|
||||
&mut *transaction,
|
||||
redis,
|
||||
file_host,
|
||||
project_id,
|
||||
file_id,
|
||||
&self.url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Error scanning new file {file_id:?}: {err:?}");
|
||||
tracing::error!("Error submitting new file to Delphi: {err}");
|
||||
}
|
||||
|
||||
Ok(file_id)
|
||||
@@ -245,8 +195,6 @@ impl VersionBuilder {
|
||||
pub async fn insert(
|
||||
self,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
redis: &RedisPool,
|
||||
file_host: &dyn FileHost,
|
||||
http: &reqwest::Client,
|
||||
) -> Result<DBVersionId, DatabaseError> {
|
||||
let version = DBVersion {
|
||||
@@ -288,15 +236,7 @@ impl VersionBuilder {
|
||||
} = self;
|
||||
|
||||
for file in files {
|
||||
file.insert(
|
||||
version_id,
|
||||
self.project_id,
|
||||
transaction,
|
||||
redis,
|
||||
file_host,
|
||||
http,
|
||||
)
|
||||
.await?;
|
||||
file.insert(version_id, transaction, http).await?;
|
||||
}
|
||||
|
||||
DependencyBuilder::insert_many(
|
||||
@@ -486,8 +426,6 @@ impl DBVersion {
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
cleanup_empty_attribution_groups(transaction).await?;
|
||||
|
||||
// Sync dependencies
|
||||
|
||||
let project_id = sqlx::query!(
|
||||
@@ -778,7 +716,7 @@ impl DBVersion {
|
||||
|
||||
let dependencies : DashMap<DBVersionId, Vec<DependencyQueryResult>> = sqlx::query!(
|
||||
"
|
||||
SELECT DISTINCT d.id as dependency_id, dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type
|
||||
SELECT DISTINCT dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type
|
||||
FROM dependencies d
|
||||
WHERE dependent_id = ANY($1)
|
||||
",
|
||||
@@ -786,12 +724,10 @@ impl DBVersion {
|
||||
).fetch(&mut exec)
|
||||
.try_fold(DashMap::new(), |acc : DashMap<_,Vec<DependencyQueryResult>>, m| {
|
||||
let dependency = DependencyQueryResult {
|
||||
id: m.dependency_id,
|
||||
project_id: m.dependency_project_id.map(DBProjectId),
|
||||
version_id: m.dependency_version_id.map(DBVersionId),
|
||||
file_name: m.file_name,
|
||||
dependency_type: m.dependency_type,
|
||||
attribution: None,
|
||||
};
|
||||
|
||||
acc.entry(DBVersionId(m.version_id))
|
||||
@@ -926,14 +862,14 @@ impl DBVersion {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_files_from_hash<'a, E>(
|
||||
pub async fn get_files_from_hash<'a, 'b, E>(
|
||||
algorithm: String,
|
||||
hashes: &[String],
|
||||
executor: E,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Vec<DBFile>, DatabaseError>
|
||||
where
|
||||
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
|
||||
E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
let val = redis.get_cached_keys(
|
||||
VERSION_FILES_NAMESPACE,
|
||||
@@ -1041,12 +977,10 @@ pub struct VersionQueryResult {
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct DependencyQueryResult {
|
||||
pub id: i32,
|
||||
pub project_id: Option<DBProjectId>,
|
||||
pub version_id: Option<DBVersionId>,
|
||||
pub file_name: Option<String>,
|
||||
pub dependency_type: String,
|
||||
pub attribution: Option<crate::models::projects::DependencyAttribution>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user