Compare commits

..
Author SHA1 Message Date
tdgao afced420d0 refactor: pnpm prepr 2026-06-07 16:49:01 -07:00
tdgao dff90d0ff9 feat: disable playtime statcard for purely plugin projects 2026-06-07 16:45:54 -07:00
tdgao c1cd2c295e fix: show decimal in playtime statcard if under 1 hrs 2026-06-07 16:45:23 -07:00
tdgao bac27e82df fix: sum rounded value instead of raw value for tooltip total if it's under 1 2026-06-07 16:41:35 -07:00
tdgao 4cb70c13fa feat: fix revenue rounding for values under 1 and show full values for all statcards with tooltip 2026-06-07 15:14:01 -07:00
tdgao 1cf39d9446 feat: add "Your projects" preset selection 2026-06-07 14:55:23 -07:00
tdgao 791f6ee0e0 feat: add button to view user analytics 2026-06-07 14:14:20 -07:00
Calum H.andGitHub 7366c32df3 feat: incompat modal improvement (#6256)
* feat: incompat modal improvement

* feat: use ContentUpdaterModal and remove IncompatibilityWarningModal

* fix: lint

* fix: lint
2026-06-05 15:56:05 +00:00
707e219ff8 feat: use multi select for moderation reports (#6312)
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-06-05 14:55:05 +00:00
Calum H.andGitHub dfe12d4ecb feat: server access post release QA (#6316)
* fix: clicking users in table in app takes you to blank page instead of website

* fix: wrong loader icon on server panel

* fix: surface var misalignment

* fix: password managers still detecting username field as something to autofill

* feat: show users on backupitem components

* feat: seperators for filter sections

* fix: lint + change remove -> revoke

* fix: copy

* feat: align copy
2026-06-05 14:54:27 +00:00
Truman GaoandGitHub c653228fe7 fix: malformed versions causing versions list page to crash (#6315) 2026-06-05 09:49:59 +00:00
Prospector 352a196795 update blog date 2026-06-04 11:56:52 -07:00
Calum H.andGitHub cef9b1efe5 fix: release workflow (#6311) 2026-06-04 09:31:41 -07:00
Calum H.andGitHub 7d6c54cff9 feat: changelog (#6310) 2026-06-04 18:01:38 +02:00
bd97ace974 feat: hosting access tab (#5995)
* feat: implement access tab with dummy data

* fix: spacing

* feat: qa

* feat: implement backend

* qa: qa pass

* feat: fix user "search"

* fix: lint

* feat: change to bitfield

* feat: fix fields

* fix: lint

* fix: lint

* feat: hook up api

* feat: fix permissions

* feat: audit log table event start

* feat: better mobile mode for audit log table

* feat: i18n

* feat: qa

* feat: enforce permissions

* feat: email template start

* feat: qa

* fix: tooltip bug

* feat: qa

* impl: sse support in api-client

* feat: sse impl

* fix: desync path

* feat: time frame picker from analytics

* feat: QA

* fix: spacing

* fix: permisison audit log entries

* fix: hosting manage page shared server detection

* fix: lint

* feat: qa + lint

* feat: audit log table sort by time

* feat: finish frontend panel stuff

* fix: lint

* fix: backend alignment

* fix: lint

* fix: supress friend errors

* feat: qa

* fix: qa

* fix: lint

* fix: utils barrel

* fix: safari cookies in dev

* fix: pin nuxt

* feat: fixes + notif fix

* fix: notifications

* feat: qa

* fix: notification sync not happening immediately

* fix: qa

* fix: qa

* feat: qa

* blog + prepr

* feat: toast shit

* blog images

* thumbnail update one last time

* prepr

* feat: use reinvite route

* update images

* fix: reinvite stuff

* fix: lint

* fix: alignment of save bar

* fix: notif sizing

* fix: split up access

* fix: lint

* fix: lint

* fix: link

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-06-04 15:58:01 +00:00
58ad58f958 feat: user search (#6302)
* feat: user search

* refactor: use sqlx macro

* refactor: better name for escaped query

* style: cleanup unused import

* chore: update sqlx query cache

* fix: tests

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
2026-06-03 19:38:24 +00:00
Prospector d907083d83 changelog 2026-06-03 11:38:23 -07:00
Truman GaoandGitHub 8371ff641a fix: analytics post release bugs (#6291)
* fix: previous period data was included in the table

* fix: revenue displaying stale data when viewing it from different metric and grouped by 6 hour or 1 hour

* fix: remove staletime on analytics query so switching tabs does not refersh query

* feat: add monetization alert

* fix-small: missing space in tooltip

* fix: incorrect y-axis formatting for trailing decimal 0s

* fix: switching tabs resets table series selection due to other refetches

* fix: always show month first in chart tooltip

* fix: change all time start date to be project published date

* fix: increase length on project name column

* fix: unknown download source data points not showing for download source breakdown

* fix: double unknown for loader

* fix: no data on country labeling incorrectly as "Unknown" instead of "Other"

* fix: date picker number inputs showing arrows

* fix: stat card showing enormous percentage for prev period by switching it to absolute value difference after 1000%

* fix: decimal values for playtime being rounded badly, resulting in 0.04 becoming 0.0

* fix: chips having stroke

* refactor: pnpm prepr

* fix: spacing in annoucement link

* fix: legend scroll shadow on top of event tooltip
2026-06-03 18:27:31 +00:00
Prospector b1cd16f966 update ads.txt 2026-06-03 10:23:11 -07:00
Prospector 40a06921ea changelog 2026-06-03 07:00:46 -07:00
Aaron CorreaandGitHub a7dc063e08 fix: use correct organization route parameter (#6294) 2026-06-03 13:41:02 +00:00
ThatGravyBoatandGitHub 64b61d8fd0 chore: misc moderation changes (#6296)
* feat: report filter by target, issue type, project type

* fix: reply modal showing up for staff

* chore: change minimum class source from 10 to 2
2026-06-03 13:14:17 +00:00
5e7d4cc838 fix: notifs not live (#6299)
* fix: notifs not live

* Update apps/labrinth/src/routes/internal/external_notifications.rs

Co-authored-by: Sychic <47618543+Sychic@users.noreply.github.com>
Signed-off-by: Calum H. <hendersoncal117@gmail.com>

* fix: fmt

---------

Signed-off-by: Calum H. <hendersoncal117@gmail.com>
Co-authored-by: Sychic <47618543+Sychic@users.noreply.github.com>
2026-06-03 13:00:12 +00:00
ProspectorandGitHub a0c80b13a4 fix: no mention of Australia on hosting landing page (#6288) 2026-06-03 02:25:16 +00:00
Calum H.andGitHub 3c051f5b1d feat: add notifs onto friends ws temporarily (#6290)
* feat: add notifs onto friends ws temporarily

* fix: lint + styling

* fix: regressions
2026-06-02 19:47:37 +00:00
Calum H.andGitHub 940a796ba5 chore: changelog (#6286) 2026-06-02 18:39:29 +02:00
6ee5e4df19 feat: access labrinth backend (#6284)
* feat: redirect `/hosting` to archon

* feat: server invite notification type

* feat: direct email notification endpoint

* feat: revoke notification endpoint

* feat: specify users to remove notifications from

* refactor: insert notifications before sending emails

* refactor: rename endpoint

* refactor: remove archon redirect

* style: mark field unused

* feat: dedup external notifications

* feat: add server invite email templates

* style: remove unnecessary format

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
2026-06-02 16:34:04 +00:00
aecsocketandGitHub d61397097c Tilitfy webhook changes (#6267)
* Tilitfy webhook changes

* add env

* fix
2026-06-02 15:14:02 +02:00
Calum H.andGitHub cfe45b368c fix: memory issues when importing giant mrpack files (#6278)
* feat: dont load mrpacks into memory if they are local imports

* fix: frontend
2026-06-02 12:59:41 +00:00
306 changed files with 18616 additions and 2650 deletions
+4 -1
View File
@@ -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: |
+1 -1
View File
@@ -9,7 +9,7 @@
"files.insertFinalNewline": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "always"
"source.organizeImports": "never"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[vue]": {
+1 -1
View File
@@ -64,7 +64,7 @@ The website and app `prepr` commands
Each project may have its own `CLAUDE.md` with detailed instructions:
- [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API
- [`apps/labrinth/AGENTS.md`](apps/labrinth/AGENTS.md) — Backend API
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
## Code Guidelines
+115 -3
View File
@@ -36,6 +36,7 @@ import {
ButtonStyled,
commonMessages,
ContentInstallModal,
ContentUpdaterModal,
CreationFlowModal,
defineMessages,
I18nDebugPanel,
@@ -75,7 +76,6 @@ import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import ErrorModal from '@/components/ui/ErrorModal.vue'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
@@ -95,7 +95,7 @@ import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { create_profile_and_install_from_file } from '@/helpers/pack'
import { list } from '@/helpers/profile.js'
@@ -241,6 +241,7 @@ const {
const news = ref([])
const availableSurvey = ref(false)
const displayedServerInviteNotifications = new Set()
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
@@ -611,6 +612,16 @@ const {
handleModpackDuplicateCreateAnyway: handleContentInstallModpackDuplicateCreateAnyway,
handleModpackDuplicateGoToInstance: handleContentInstallModpackDuplicateGoToInstance,
setIncompatibilityWarningModal: setContentIncompatibilityWarningModal,
incompatibilityWarningVersions: contentInstallIncompatibilityWarningVersions,
incompatibilityWarningCurrentGameVersion: contentInstallIncompatibilityWarningCurrentGameVersion,
incompatibilityWarningCurrentLoader: contentInstallIncompatibilityWarningCurrentLoader,
incompatibilityWarningProjectType: contentInstallIncompatibilityWarningProjectType,
incompatibilityWarningProjectIconUrl: contentInstallIncompatibilityWarningProjectIconUrl,
incompatibilityWarningProjectName: contentInstallIncompatibilityWarningProjectName,
incompatibilityWarningMessage: contentInstallIncompatibilityWarningMessage,
incompatibilityWarningInstalling: contentInstallIncompatibilityWarningInstalling,
handleIncompatibilityWarningInstall: handleContentInstallIncompatibilityWarningInstall,
handleIncompatibilityWarningCancel: handleContentInstallIncompatibilityWarningCancel,
} = contentInstall
const serverInstall = createServerInstall({ router, handleError, popupNotificationManager })
@@ -632,6 +643,12 @@ const updateToPlayModal = ref()
const modrinthLoginFlowWaitModal = ref()
watch(incompatibilityWarningModal, (modal) => {
if (modal) {
setContentIncompatibilityWarningModal(modal)
}
})
setupAuthProvider(credentials, async (_redirectPath) => {
await signIn()
})
@@ -752,6 +769,86 @@ const accounts = ref(null)
provide('accountsCard', accounts)
command_listener(handleCommand)
notification_listener(handleLiveNotification)
async function markLiveNotificationRead(notification) {
try {
await tauriApiClient.labrinth.notifications_v2.markAsRead(notification.id)
} catch (error) {
if (error instanceof ModrinthApiError && error.statusCode === 404) {
console.warn(`notification ${notification.id} could not be marked as read`, error)
return
}
throw error
}
}
async function respondToServerInvite(notification, action) {
const serverId = notification.body?.server_id
if (typeof serverId !== 'string') {
throw new Error('Missing server ID for invite notification.')
}
await tauriApiClient.request(`/servers/${serverId}/invites/${action}`, {
api: 'archon',
version: 1,
method: 'POST',
})
await markLiveNotificationRead(notification)
return serverId
}
async function acceptServerInviteNotification(notification) {
try {
const serverId = await respondToServerInvite(notification, 'accept')
await router.push(`/hosting/manage/${encodeURIComponent(serverId)}`)
queryClient.invalidateQueries({ queryKey: ['servers'] })
} catch (error) {
handleError(error)
}
}
async function declineServerInviteNotification(notification) {
try {
await respondToServerInvite(notification, 'decline')
} catch (error) {
handleError(error)
}
}
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
displayedServerInviteNotifications.add(notification.id)
const serverName =
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
const inviterId = notification.body.invited_by
const invitedBy =
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
addPopupNotification({
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),
},
})
}
async function handleCommand(e) {
if (!e) return
@@ -1550,7 +1647,22 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@go-to-instance="handleModpackDuplicateGoToInstance"
/>
<AddServerToInstanceModal ref="addServerToInstanceModal" />
<IncompatibilityWarningModal ref="incompatibilityWarningModal" />
<ContentUpdaterModal
ref="incompatibilityWarningModal"
mode="incompatibility-warning"
:versions="contentInstallIncompatibilityWarningVersions"
:current-game-version="contentInstallIncompatibilityWarningCurrentGameVersion"
:current-loader="contentInstallIncompatibilityWarningCurrentLoader"
current-version-id=""
:is-app="true"
:project-type="contentInstallIncompatibilityWarningProjectType"
:project-icon-url="contentInstallIncompatibilityWarningProjectIconUrl"
:project-name="contentInstallIncompatibilityWarningProjectName"
:warning="contentInstallIncompatibilityWarningMessage"
:action-loading="contentInstallIncompatibilityWarningInstalling"
@update="handleContentInstallIncompatibilityWarningInstall"
@cancel="handleContentInstallIncompatibilityWarningCancel"
/>
<ModpackAlreadyInstalledModal
ref="contentInstallModpackAlreadyInstalledModal"
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
@@ -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 ?? ''}`
@@ -1,185 +0,0 @@
<template>
<ModalWrapper ref="incompatibleModal" header="Incompatibility warning" :on-hide="onInstall">
<div class="modal-body">
<p>
This {{ versions?.length > 0 ? 'project' : 'version' }} is not compatible with the instance
you're trying to install it on. Are you sure you want to continue? Dependencies will not be
installed.
</p>
<table>
<thead>
<tr class="header">
<th>{{ instance?.name }}</th>
<th>{{ project.title }}</th>
</tr>
</thead>
<tbody>
<tr class="content">
<td class="data">{{ instance?.loader }} {{ instance?.game_version }}</td>
<td>
<Combobox
v-if="versions?.length > 1"
v-model="selectedVersionId"
:options="versionOptions"
:searchable="true"
placeholder="Select version"
force-direction="up"
:max-height="150"
/>
<span v-else>
<span>{{ selectedVersionLabel }}</span>
</span>
</td>
</tr>
</tbody>
</table>
<div class="button-group">
<ButtonStyled type="outlined">
<button @click="() => incompatibleModal.hide()"><XIcon />Cancel</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="installing" @click="install()">
<DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}
</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
</template>
<script setup>
import { DownloadIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
formatLoader,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { add_project_from_version as installMod } from '@/helpers/profile'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const instance = ref(null)
const project = ref(null)
const versions = ref(null)
const selectedVersion = ref(null)
const incompatibleModal = ref(null)
const installing = ref(false)
const onInstall = ref(() => {})
const selectedVersionLabel = computed(() => {
if (!selectedVersion.value) return ''
return `${selectedVersion.value.name} (${selectedVersion.value.loaders
.map((name) => formatLoader(formatMessage, name))
.join(', ')} - ${selectedVersion.value.game_versions.join(', ')})`
})
const versionOptions = computed(() =>
(versions.value ?? []).map((version) => ({
value: version.id,
label: `${version.name} (${version.loaders
.map((name) => formatLoader(formatMessage, name))
.join(', ')} - ${version.game_versions.join(', ')})`,
})),
)
const selectedVersionId = computed({
get: () => selectedVersion.value?.id ?? null,
set: (value) => {
if (!value) return
selectedVersion.value = (versions.value ?? []).find((version) => version.id === value) ?? null
},
})
defineExpose({
show: (instanceVal, projectVal, projectVersions, selected, callback) => {
instance.value = instanceVal
versions.value = projectVersions ?? []
selectedVersion.value = selected ?? projectVersions?.[0] ?? null
project.value = projectVal
onInstall.value = callback
installing.value = false
incompatibleModal.value.show()
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
},
})
const install = async () => {
installing.value = true
await installMod(instance.value.path, selectedVersion.value.id, 'standalone').catch(handleError)
installing.value = false
onInstall.value(selectedVersion.value.id)
incompatibleModal.value.hide()
trackEvent('ProjectInstall', {
loader: instance.value.loader,
game_version: instance.value.game_version,
id: project.value,
version_id: selectedVersion.value.id,
project_type: project.value.project_type,
title: project.value.title,
source: 'ProjectIncompatibilityWarningModal',
})
}
</script>
<style lang="scss" scoped>
.data {
text-transform: capitalize;
}
table {
width: 100%;
border-radius: var(--radius-lg);
border-collapse: collapse;
box-shadow: 0 0 0 1px var(--color-button-bg);
}
th {
text-align: left;
padding: 1rem;
background-color: var(--color-bg);
overflow: hidden;
border-bottom: 1px solid var(--color-button-bg);
}
th:first-child {
border-top-left-radius: var(--radius-lg);
border-right: 1px solid var(--color-button-bg);
}
th:last-child {
border-top-right-radius: var(--radius-lg);
}
td {
padding: 1rem;
}
td:first-child {
border-right: 1px solid var(--color-button-bg);
}
.button-group {
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>
@@ -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(() => ({
+4
View File
@@ -98,6 +98,10 @@ export async function friend_listener(callback) {
return await listen('friend', (event) => callback(event.payload))
}
export async function notification_listener(callback) {
return await listen('notification', (event) => callback(event.payload))
}
/// Payload for the 'log' event
/*
LogPayload {
+1
View File
@@ -10,6 +10,7 @@ export interface LoadingBarType {
version?: string
profile_path?: string
pack_name?: string
icon?: string | null
}
export interface LoadingBar {
@@ -149,6 +149,9 @@
"app.browse.server.installing": {
"message": "Installing"
},
"app.content-install.no-compatible-versions": {
"message": "No available versions match {compatibilityLabel}. Select a version to install anyway. Dependencies will not be installed automatically."
},
"app.creation-modal.installing-modpack.description": {
"message": "{fileName}"
},
@@ -0,0 +1,38 @@
<script setup lang="ts">
import {
injectModrinthClient,
injectModrinthServerContext,
ServersManageAccessPage,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
const client = injectModrinthClient()
const { serverId } = injectModrinthServerContext()
const queryClient = useQueryClient()
try {
await Promise.all([
queryClient.ensureQueryData({
queryKey: ['servers', 'users', 'v1', serverId],
queryFn: () => client.archon.server_users_v1.list(serverId),
staleTime: 30_000,
}),
queryClient.ensureQueryData({
queryKey: ['servers', 'v1', 'detail', serverId],
queryFn: () => client.archon.servers_v1.get(serverId),
staleTime: 30_000,
}),
])
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
function userProfileLink(username: string) {
return () => openUrl(`https://modrinth.com/user/${encodeURIComponent(username)}`)
}
</script>
<template>
<ServersManageAccessPage :user-profile-link="userProfileLink" />
</template>
@@ -1,7 +1,8 @@
import Access from './Access.vue'
import Backups from './Backups.vue'
import Content from './Content.vue'
import Files from './Files.vue'
import Index from './Index.vue'
import Overview from './Overview.vue'
export { Backups, Content, Files, Index, Overview }
export { Access, Backups, Content, Files, Index, Overview }
@@ -1,6 +1,6 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ContentInstallInstance, ContentInstallProjectInfo, ContentItem } from '@modrinth/ui'
import { createContext } from '@modrinth/ui'
import { createContext, defineMessage, useVIntl } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
@@ -34,7 +34,7 @@ import {
} from '@/store/install.js'
interface ModalRef {
show: () => void
show: (initialVersionId?: string) => void
hide: () => void
}
@@ -42,19 +42,14 @@ interface ModpackAlreadyInstalledModalRef {
show: (instanceName: string, instancePath: string) => void
}
interface IncompatibilityWarningModalRef {
show: (
instance: GameInstance,
project: Labrinth.Projects.v2.Project,
versions: Labrinth.Versions.v2.Version[],
version: Labrinth.Versions.v2.Version,
callback: (versionId?: string) => void,
) => void
}
const LOADER_ORDER = ['vanilla', 'fabric', 'quilt', 'neoforge', 'forge']
const SUPPORTED_LOADERS: Set<string> = new Set(['vanilla', 'forge', 'fabric', 'quilt', 'neoforge'])
const VANILLA_COMPATIBLE_LOADERS: Set<string> = new Set(['minecraft', 'datapack'])
const noCompatibleVersionsMessage = defineMessage({
id: 'app.content-install.no-compatible-versions',
defaultMessage:
'No available versions match {compatibilityLabel}. Select a version to install anyway. Dependencies will not be installed automatically.',
})
function sortLoaders(loaders: string[]): string[] {
return loaders.slice().sort((a, b) => {
@@ -91,7 +86,17 @@ export interface ContentInstallContext {
setModpackAlreadyInstalledModal: (ref: ModpackAlreadyInstalledModalRef) => void
handleModpackDuplicateCreateAnyway: () => Promise<void>
handleModpackDuplicateGoToInstance: (instancePath: string) => void
setIncompatibilityWarningModal: (ref: IncompatibilityWarningModalRef) => void
setIncompatibilityWarningModal: (ref: ModalRef) => void
incompatibilityWarningVersions: Ref<Labrinth.Versions.v2.Version[]>
incompatibilityWarningCurrentGameVersion: Ref<string>
incompatibilityWarningCurrentLoader: Ref<string>
incompatibilityWarningProjectType: Ref<string | undefined>
incompatibilityWarningProjectIconUrl: Ref<string | undefined>
incompatibilityWarningProjectName: Ref<string | undefined>
incompatibilityWarningMessage: Ref<string | undefined>
incompatibilityWarningInstalling: Ref<boolean>
handleIncompatibilityWarningInstall: (version: Labrinth.Versions.v2.Version) => Promise<void>
handleIncompatibilityWarningCancel: () => void
install: (
projectId: string,
versionId?: string | null,
@@ -113,6 +118,7 @@ export function createContentInstall(opts: {
router: Router
handleError: (err: unknown) => void
}): ContentInstallContext {
const { formatMessage } = useVIntl()
const instances = ref<ContentInstallInstance[]>([])
const compatibleLoaders = ref<string[]>([])
const gameVersions = ref<string[]>([])
@@ -124,6 +130,14 @@ export function createContentInstall(opts: {
const projectInfo = ref<ContentInstallProjectInfo | null>(null)
const installingItems = ref<Map<string, ContentItem[]>>(new Map())
const incompatibilityWarningVersions = ref<Labrinth.Versions.v2.Version[]>([])
const incompatibilityWarningCurrentGameVersion = ref('')
const incompatibilityWarningCurrentLoader = ref('')
const incompatibilityWarningProjectType = ref<string | undefined>(undefined)
const incompatibilityWarningProjectIconUrl = ref<string | undefined>(undefined)
const incompatibilityWarningProjectName = ref<string | undefined>(undefined)
const incompatibilityWarningMessage = ref<string | undefined>(undefined)
const incompatibilityWarningInstalling = ref(false)
function addInstallingItem(
instancePath: string,
@@ -239,11 +253,15 @@ export function createContentInstall(opts: {
let modalRef: ModalRef | null = null
let modpackAlreadyInstalledModalRef: ModpackAlreadyInstalledModalRef | null = null
let incompatibilityWarningModalRef: IncompatibilityWarningModalRef | null = null
let incompatibilityWarningModalRef: ModalRef | null = null
let currentProject: Labrinth.Projects.v2.Project | null = null
let currentVersions: Labrinth.Versions.v2.Version[] = []
let currentCallback: (versionId?: string) => void = () => {}
let profileMap: Record<string, GameInstance> = {}
let incompatibilityWarningInstance: GameInstance | null = null
let incompatibilityWarningProject: Labrinth.Projects.v2.Project | null = null
let incompatibilityWarningCallback: (versionId?: string) => void = () => {}
let incompatibilityWarningInstalled = false
let pendingModpackInstall: {
project: Labrinth.Projects.v2.Project
@@ -410,15 +428,35 @@ export function createContentInstall(opts: {
async function handleInstallToInstance(instance: ContentInstallInstance) {
const profile = profileMap[instance.id]
const storeInstance = instances.value.find((i) => i.id === instance.id)
if (storeInstance) storeInstance.installing = true
if (!currentProject || !profile) {
opts.handleError('No project or instance found')
return
}
const version = findPreferredVersion(currentVersions, currentProject, profile)
if (!version) {
if (storeInstance) storeInstance.installing = false
opts.handleError('No compatible version found')
if (currentVersions.length > 0 && incompatibilityWarningModalRef) {
const onIncompatibleInstall = (versionId?: string) => {
if (versionId && storeInstance) {
storeInstance.installed = true
}
currentCallback(versionId)
}
await showIncompatibilityWarning(
profile,
currentProject,
currentVersions,
currentVersions[0],
onIncompatibleInstall,
)
} else {
opts.handleError('No version found')
}
return
}
if (storeInstance) storeInstance.installing = true
const installedProjectIds: string[] = []
if (currentProject) {
addInstallingItem(instance.id, currentProject, version)
@@ -458,6 +496,73 @@ export function createContentInstall(opts: {
}
}
async function showIncompatibilityWarning(
instance: GameInstance,
project: Labrinth.Projects.v2.Project,
versions: Labrinth.Versions.v2.Version[],
version: Labrinth.Versions.v2.Version,
callback: (versionId?: string) => void,
) {
incompatibilityWarningInstance = instance
incompatibilityWarningProject = project
incompatibilityWarningCallback = callback
incompatibilityWarningInstalled = false
incompatibilityWarningInstalling.value = false
incompatibilityWarningVersions.value = versions
incompatibilityWarningCurrentGameVersion.value = instance.game_version ?? ''
incompatibilityWarningCurrentLoader.value = instance.loader ?? ''
incompatibilityWarningProjectType.value = project.project_type
incompatibilityWarningProjectIconUrl.value = project.icon_url ?? undefined
incompatibilityWarningProjectName.value = project.title
const compatibilityLabel =
project.project_type === 'resourcepack' || project.project_type === 'datapack'
? (instance.game_version ?? '')
: `${instance.loader ?? ''} ${instance.game_version ?? ''}`.trim()
incompatibilityWarningMessage.value = formatMessage(noCompatibleVersionsMessage, {
compatibilityLabel,
})
await nextTick()
incompatibilityWarningModalRef?.show(version.id)
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
}
async function handleIncompatibilityWarningInstall(version: Labrinth.Versions.v2.Version) {
if (!incompatibilityWarningInstance || !incompatibilityWarningProject) return
incompatibilityWarningInstalling.value = true
try {
await add_project_from_version(incompatibilityWarningInstance.path, version.id, 'standalone')
} catch (err) {
opts.handleError(err)
incompatibilityWarningInstalling.value = false
return
}
incompatibilityWarningInstalling.value = false
incompatibilityWarningInstalled = true
incompatibilityWarningCallback(version.id)
incompatibilityWarningModalRef?.hide()
trackEvent('ProjectInstall', {
loader: incompatibilityWarningInstance.loader,
game_version: incompatibilityWarningInstance.game_version,
id: incompatibilityWarningProject.id,
version_id: version.id,
project_type: incompatibilityWarningProject.project_type,
title: incompatibilityWarningProject.title,
source: 'ProjectIncompatibilityWarningModal',
})
}
function handleIncompatibilityWarningCancel() {
if (!incompatibilityWarningInstalled) {
incompatibilityWarningCallback()
}
incompatibilityWarningInstalled = false
}
async function handleCreateAndInstall(data: {
name: string
iconPath: string | null
@@ -614,7 +719,7 @@ export function createContentInstall(opts: {
removeInstallingItems(instancePath, installedProjectIds)
}
} else {
incompatibilityWarningModalRef?.show(instance, project, projectVersions, version, callback)
await showIncompatibilityWarning(instance, project, projectVersions, version, callback)
}
} else {
let versions = (
@@ -668,9 +773,19 @@ export function createContentInstall(opts: {
pendingModpackInstall = null
opts.router.push(`/instance/${encodeURIComponent(instancePath)}`)
},
setIncompatibilityWarningModal(ref: IncompatibilityWarningModalRef) {
setIncompatibilityWarningModal(ref: ModalRef) {
incompatibilityWarningModalRef = ref
},
incompatibilityWarningVersions,
incompatibilityWarningCurrentGameVersion,
incompatibilityWarningCurrentLoader,
incompatibilityWarningProjectType,
incompatibilityWarningProjectIconUrl,
incompatibilityWarningProjectName,
incompatibilityWarningMessage,
incompatibilityWarningInstalling,
handleIncompatibilityWarningInstall,
handleIncompatibilityWarningCancel,
install,
installingItems,
}
+8
View File
@@ -73,6 +73,14 @@ export default new createRouter({
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'access',
name: 'ServerManageAccess',
component: Hosting.Access,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
],
},
{
+6
View File
@@ -42,6 +42,12 @@
{
"url": "https://api.purpurmc.org/*"
},
{
"url": "http://localhost:8000/*"
},
{
"url": "http://127.0.0.1:8000/*"
},
{
"url": "http://*.taila228c5.ts.net/*"
},
-1
View File
@@ -40,4 +40,3 @@ These composables are deprecated and should not be used in new code:
- **`useAsyncData`** - we use tanstack, not nuxt's built in async data utility.
- **`useBaseFetch`** (`src/composables/fetch.js`) — legacy Labrinth fetch wrapper. Use `client.labrinth.*` modules instead.
- **`useServersFetch`** (`src/composables/servers/servers-fetch.ts`) — legacy Archon fetch wrapper with manual retry/circuit-breaker. Use `client.archon.*` modules instead — refer to the `packages/api-client/CLAUDE.md` for more information.
+1
View File
@@ -224,6 +224,7 @@ export default defineNuxtConfig({
globalThis.INTERCOM_APP_ID ||
'ykeritl9',
production: isProduction(),
cookieSecure: isProduction(),
buildEnv: process.env.BUILD_ENV,
preview: process.env.PREVIEW === 'true',
featureFlagOverrides: getFeatureFlagOverrides(),
+1 -1
View File
@@ -26,7 +26,7 @@
"@types/semver": "^7.7.1",
"autoprefixer": "^10.4.19",
"glob": "^10.2.7",
"nuxt": "^3.20.2",
"nuxt": "=3.20.2",
"postcss": "^8.4.39",
"prettier-plugin-tailwindcss": "^0.6.5",
"sass": "^1.58.0",
@@ -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>
@@ -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" />
@@ -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}`
}
@@ -168,6 +168,14 @@ const {
onRangeSelected: (start, end, groupBy) => emit('range-select', start, end, groupBy),
})
function getTooltipTotalMetricValue(value: number): number {
if (props.activeStat === 'revenue' && Math.abs(value) < 1) {
return Math.round(value * 100) / 100
}
return value
}
const hoverTotalValue = computed(() => {
if (hoverState.sliceIndex === null) return 0
const sliceIndex = hoverState.sliceIndex
@@ -176,7 +184,7 @@ const hoverTotalValue = computed(() => {
return props.currentLegendEntries.reduce((sum, legendEntry) => {
if (legendEntry.hidden) return sum
const dataset = props.chartDatasetById.get(legendEntry.id)
return sum + (dataset?.data[sliceIndex] ?? 0)
return sum + getTooltipTotalMetricValue(dataset?.data[sliceIndex] ?? 0)
}, 0)
})
@@ -40,7 +40,6 @@ export function useAnalyticsChartEvents(
placeholderData: [],
refetchOnMount: 'always',
retry: false,
staleTime: 0,
})
const localAnalyticsChartEvents = computed(() => analyticsEvents.value ?? [])
@@ -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(
@@ -39,6 +39,14 @@ export const analyticsMessages = defineMessages({
id: 'analytics.project.all',
defaultMessage: 'All projects',
},
yourProjects: {
id: 'analytics.project.your',
defaultMessage: 'Your projects',
},
userProjects: {
id: 'analytics.project.user',
defaultMessage: "{username}'s projects",
},
selectProjects: {
id: 'analytics.project.select',
defaultMessage: 'Select projects',
@@ -224,6 +232,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}',
@@ -146,6 +146,7 @@ const QUERY_KEY_TABLE_SORT = 'a_table_sort'
const QUERY_KEY_TABLE_SORT_DIRECTION = 'a_table_sort_direction'
const QUERY_KEY_LEGACY_GRAPH_TOP_BREAKDOWN_FILTER = 'a_top_breakdown'
const QUERY_KEY_LEGACY_GRAPH_LEGEND_EXPANSION = 'a_legend_expanded'
const PROJECT_SELECTION_ALL_QUERY_VALUE = 'all'
const URL_FILTER_CATEGORIES: Exclude<AnalyticsQueryFilterCategory, 'project'>[] = [
'project_status',
@@ -405,9 +406,10 @@ export function buildDefaultAnalyticsGraphState(
export function buildDefaultAnalyticsQueryBuilderState(
availableProjectIds: string[],
defaultProjectIds: string[] = availableProjectIds,
): AnalyticsQueryBuilderState {
return {
selectedProjectIds: [...availableProjectIds],
selectedProjectIds: [...defaultProjectIds],
selectedTimeframeMode: DEFAULT_TIMEFRAME_MODE,
selectedTimeframe: DEFAULT_TIMEFRAME_PRESET,
selectedLastTimeframeAmount: DEFAULT_LAST_TIMEFRAME_AMOUNT,
@@ -415,7 +417,7 @@ export function buildDefaultAnalyticsQueryBuilderState(
selectedCustomTimeframeStartDate: getDefaultCustomStartDate(),
selectedCustomTimeframeEndDate: getDefaultCustomEndDate(),
selectedGroupBy: DEFAULT_GROUP_BY_PRESET,
selectedBreakdowns: getDefaultAnalyticsBreakdownPresets(availableProjectIds),
selectedBreakdowns: getDefaultAnalyticsBreakdownPresets(defaultProjectIds),
selectedFilters: buildEmptySelectedFilters(),
}
}
@@ -475,12 +477,16 @@ export function getAnalyticsBreakdownPresetForProjectSelection(
export function isAnalyticsQueryBuilderStateDefault(
state: AnalyticsQueryBuilderState,
availableProjectIds: string[],
defaultProjectIds: string[] = availableProjectIds,
): boolean {
const defaultState = buildDefaultAnalyticsQueryBuilderState(availableProjectIds)
const defaultState = buildDefaultAnalyticsQueryBuilderState(
availableProjectIds,
defaultProjectIds,
)
const areDefaultProjectsSelected =
availableProjectIds.length === 0
defaultProjectIds.length === 0
? state.selectedProjectIds.length === 0
: areAllProjectsSelected(state.selectedProjectIds, availableProjectIds)
: areAllProjectsSelected(state.selectedProjectIds, defaultProjectIds)
return (
areDefaultProjectsSelected &&
@@ -666,13 +672,19 @@ export function readAnalyticsTableSortState(
export function readAnalyticsQueryBuilderState(
query: LocationQuery,
availableProjectIds: string[],
defaultProjectIds: string[] = availableProjectIds,
): AnalyticsQueryBuilderState {
const defaultState = buildDefaultAnalyticsQueryBuilderState(availableProjectIds)
const defaultState = buildDefaultAnalyticsQueryBuilderState(
availableProjectIds,
defaultProjectIds,
)
const selectedProjectIdsFromQuery = parseListQueryValue(query[QUERY_KEY_PROJECT_IDS])
const selectedProjectIds =
selectedProjectIdsFromQuery.length > 0
? selectedProjectIdsFromQuery
: defaultState.selectedProjectIds
let selectedProjectIds = defaultState.selectedProjectIds
if (selectedProjectIdsFromQuery.includes(PROJECT_SELECTION_ALL_QUERY_VALUE)) {
selectedProjectIds = [...availableProjectIds]
} else if (selectedProjectIdsFromQuery.length > 0) {
selectedProjectIds = selectedProjectIdsFromQuery
}
const selectedFilters = buildEmptySelectedFilters()
for (const category of URL_FILTER_CATEGORIES) {
@@ -779,14 +791,17 @@ export function buildAnalyticsQueryBuilderRouteQuery(
state: AnalyticsQueryBuilderState,
availableProjectIds: string[],
graphState?: AnalyticsGraphState,
defaultProjectIds: string[] = availableProjectIds,
): MutableRouteQuery {
const nextRouteQuery = {
...currentRouteQuery,
} as MutableRouteQuery
const projectIdsQueryValue = areAllProjectsSelected(state.selectedProjectIds, availableProjectIds)
const projectIdsQueryValue = areAllProjectsSelected(state.selectedProjectIds, defaultProjectIds)
? undefined
: serializeListQueryValue(state.selectedProjectIds)
: areAllProjectsSelected(state.selectedProjectIds, availableProjectIds)
? PROJECT_SELECTION_ALL_QUERY_VALUE
: serializeListQueryValue(state.selectedProjectIds)
const isCustomTimeframeMode =
state.selectedTimeframeMode === 'custom_range' ||
state.selectedTimeframeMode === 'custom_datetime_range'
@@ -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%',
})
}
@@ -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,
}),
})
}
@@ -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))
@@ -69,6 +69,29 @@
<template v-if="hasProjectOptions" #top>
<div>
<button
v-if="showProjectPresets"
type="button"
class="flex w-full cursor-pointer items-center gap-1.5 border-0 bg-surface-4 px-4 py-3 text-left shadow-none transition-all duration-150 hover:brightness-[115%] focus:brightness-[115%]"
:aria-selected="isUserProjectsOptionSelected"
:class="isUserProjectsOptionSelected ? 'text-contrast' : 'text-primary'"
role="option"
@click="selectUserProjectsMode"
@keydown.enter.stop
@keydown.space.stop
>
<LayersIcon
class="h-5 w-5 shrink-0 text-primary"
:class="isUserProjectsOptionSelected ? 'text-contrast' : 'text-primary'"
/>
<span class="min-w-0 flex-1 font-semibold leading-tight">
{{ userProjectsLabel }}
</span>
<span class="flex shrink-0 items-center justify-center text-brand">
<CheckIcon v-if="isUserProjectsOptionSelected" aria-hidden="true" class="size-5" />
</span>
</button>
<button
v-if="!showProjectPresets || showAllProjectsPreset"
type="button"
class="flex w-full cursor-pointer items-center gap-1.5 border-0 bg-surface-4 px-4 py-3 text-left shadow-none transition-all duration-150 hover:brightness-[115%] focus:brightness-[115%]"
:aria-selected="isAllProjectsOptionSelected"
@@ -210,7 +233,11 @@
decoding="async"
/>
<LayersIcon
v-else-if="isAllProjectsOptionSelected || areAllProjectsSelected"
v-else-if="
isUserProjectsOptionSelected ||
isAllProjectsOptionSelected ||
areAllProjectRowsSelected
"
class="size-5 shrink-0 text-primary"
/>
<BoxIcon v-else class="size-5 shrink-0 text-primary" />
@@ -253,6 +280,33 @@
<template v-if="hasProjectOptions" #top>
<div>
<button
v-if="showProjectPresets"
type="button"
class="flex w-full cursor-pointer items-center gap-2 border-0 bg-surface-4 px-4 py-3 text-left shadow-none transition-all duration-150 hover:brightness-[115%] focus:brightness-[115%]"
:aria-selected="isUserProjectsOptionSelected"
:class="isUserProjectsOptionSelected ? 'text-contrast' : 'text-primary'"
role="option"
@click="selectUserProjectsMode"
@keydown.enter.stop
@keydown.space.stop
>
<LayersIcon
class="h-5 w-5 shrink-0 text-primary"
:class="isUserProjectsOptionSelected ? 'text-contrast' : 'text-primary'"
/>
<span class="min-w-0 flex-1 font-semibold leading-tight">
{{ userProjectsLabel }}
</span>
<span class="flex shrink-0 items-center justify-center text-brand">
<CheckIcon
v-if="isUserProjectsOptionSelected"
aria-hidden="true"
class="size-5"
/>
</span>
</button>
<button
v-if="!showProjectPresets || showAllProjectsPreset"
type="button"
class="flex w-full cursor-pointer items-center gap-2 border-0 bg-surface-4 px-4 py-3 text-left shadow-none transition-all duration-150 hover:brightness-[115%] focus:brightness-[115%]"
:aria-selected="isAllProjectsOptionSelected"
@@ -449,11 +503,17 @@ const QUERY_BUILDER_DROPDOWN_MIN_WIDTH = '12rem'
const analyticsQueryChipTriggerClass = 'h-10 '
const analyticsQueryAddFilterButtonClass = '!h-10 max-w-full !w-max !px-3.5 flex !gap-2'
const projectOptionCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
type ProjectSelectionPreset = 'user' | 'all'
const {
hasProjectContext,
projectGroups,
projects,
dashboardUserProjectIds,
dashboardOrganizationProjectIds,
defaultProjectIds,
isUsingDashboardUserOverride,
dashboardProjectUserName,
selectedProjectIds,
selectedTimeframeMode,
selectedTimeframe,
@@ -467,7 +527,7 @@ const {
activeStat,
showPreviousPeriod,
projectStatusById,
projectDownloadsById,
availableProjectDownloadsById,
queryResetToken,
refreshAnalyticsQuery,
setFetchRequest,
@@ -535,12 +595,25 @@ const projectSelectOptions = computed<MultiSelectItem<string>[]>(() => {
const allProjectIds = computed(() => projectOptions.value.map((project) => project.value))
const hasProjectOptions = computed(() => projectOptions.value.length > 0)
const userProjectIds = computed(() =>
dashboardOrganizationProjectIds.value.length > 0
? dashboardUserProjectIds.value
: defaultProjectIds.value,
)
const showProjectPresets = computed(
() =>
hasProjectOptions.value &&
dashboardUserProjectIds.value.length > 0 &&
dashboardOrganizationProjectIds.value.length > 0,
)
const showAllProjectsPreset = computed(() => dashboardOrganizationProjectIds.value.length > 0)
const noProjectsMessage = computed(() =>
hasProjectContext.value
? formatMessage(analyticsMessages.noDataAvailableForAnalytics)
: formatMessage(analyticsMessages.noProjectsAvailable),
)
const isProjectSelectOpen = ref(false)
const draftProjectSelectionPreset = ref<ProjectSelectionPreset | null>(null)
const draftSelectedProjectIds = ref<string[]>([...selectedProjectIds.value])
const projectDownloadsThreshold = ref<number | null>(null)
const projectDownloadsThresholdProjectIds = ref<string[] | null>(null)
@@ -558,15 +631,48 @@ function normalizeProjectSelection(projectIds: string[]) {
return projectIds.length > 0 ? [...projectIds] : [...allProjectIds.value]
}
function getProjectSelectionPreset(projectIds: string[]): ProjectSelectionPreset | null {
if (!showProjectPresets.value) {
return null
}
if (isSameProjectSelection(projectIds, userProjectIds.value)) {
return 'user'
}
if (isSameProjectSelection(projectIds, allProjectIds.value)) {
return 'all'
}
return null
}
function setDraftProjectSelection(projectIds: string[]) {
const preset = getProjectSelectionPreset(projectIds)
draftProjectSelectionPreset.value = preset
if (preset) {
draftSelectedProjectIds.value = []
return
}
draftSelectedProjectIds.value = isSameProjectSelection(projectIds, allProjectIds.value)
? []
: [...projectIds]
}
watch(selectedProjectIds, (nextSelectedProjectIds) => {
if (isProjectSelectOpen.value) {
return
}
draftSelectedProjectIds.value = [...nextSelectedProjectIds]
setDraftProjectSelection(nextSelectedProjectIds)
})
watch(draftSelectedProjectIds, (nextSelectedProjectIds) => {
if (draftProjectSelectionPreset.value && nextSelectedProjectIds.length > 0) {
draftProjectSelectionPreset.value = null
}
if (projectDownloadsThreshold.value === null) {
return
}
@@ -587,25 +693,40 @@ watch(queryResetToken, () => {
isBreakdownSelectOpen.value = false
draftSelectedBreakdowns.value = [...selectedBreakdowns.value]
clearProjectDownloadsThreshold()
draftSelectedProjectIds.value = isSameProjectSelection(
selectedProjectIds.value,
allProjectIds.value,
)
? []
: [...selectedProjectIds.value]
setDraftProjectSelection(selectedProjectIds.value)
})
const areAllProjectsSelected = computed(() => {
const areAllProjectRowsSelected = computed(() => {
return isSameProjectSelection(draftSelectedProjectIds.value, allProjectIds.value)
})
const isAllProjectsOptionSelected = computed(() => draftSelectedProjectIds.value.length === 0)
const isAllProjectsOptionSelected = computed(() =>
showProjectPresets.value
? draftProjectSelectionPreset.value === 'all'
: draftSelectedProjectIds.value.length === 0,
)
const isUserProjectsOptionSelected = computed(() => {
return showProjectPresets.value && draftProjectSelectionPreset.value === 'user'
})
const userProjectsLabel = computed(() => {
if (isUsingDashboardUserOverride.value) {
return formatMessage(analyticsMessages.userProjects, {
username: dashboardProjectUserName.value,
})
}
return formatMessage(analyticsMessages.yourProjects)
})
const selectedProjectLabel = computed(() => {
if (!hasProjectOptions.value) {
return noProjectsMessage.value
}
if (isAllProjectsOptionSelected.value || areAllProjectsSelected.value) {
if (isUserProjectsOptionSelected.value) {
return userProjectsLabel.value
}
if (isAllProjectsOptionSelected.value || areAllProjectRowsSelected.value) {
return formatMessage(analyticsMessages.allProjects)
}
@@ -623,8 +744,9 @@ const selectedProjectLabel = computed(() => {
const selectedProjectIconUrl = computed(() => {
if (
isUserProjectsOptionSelected.value ||
isAllProjectsOptionSelected.value ||
areAllProjectsSelected.value ||
areAllProjectRowsSelected.value ||
draftSelectedProjectIds.value.length !== 1
) {
return undefined
@@ -639,12 +761,7 @@ function getProjectIconUrl(projectId: string): string | undefined {
function handleProjectSelectOpen() {
isProjectSelectOpen.value = true
draftSelectedProjectIds.value = isSameProjectSelection(
selectedProjectIds.value,
allProjectIds.value,
)
? []
: [...selectedProjectIds.value]
setDraftProjectSelection(selectedProjectIds.value)
}
function handleProjectSelectClose(
@@ -657,9 +774,14 @@ function handleProjectSelectClose(
function commitDraftSelectedProjects(
nextSelectedProjectIds: string[] = draftSelectedProjectIds.value,
) {
const nextProjectIds = normalizeProjectSelection(nextSelectedProjectIds)
const nextProjectIds =
draftProjectSelectionPreset.value === 'user'
? [...userProjectIds.value]
: draftProjectSelectionPreset.value === 'all'
? [...allProjectIds.value]
: normalizeProjectSelection(nextSelectedProjectIds)
draftSelectedProjectIds.value = [...nextProjectIds]
setDraftProjectSelection(nextProjectIds)
if (!isSameProjectSelection(selectedProjectIds.value, nextProjectIds)) {
if (isSameProjectSelection(nextProjectIds, allProjectIds.value)) {
showPreviousPeriod.value = false
@@ -670,6 +792,17 @@ function commitDraftSelectedProjects(
function selectAllProjectsMode() {
clearProjectDownloadsThreshold()
if (showProjectPresets.value) {
draftProjectSelectionPreset.value = 'all'
} else {
draftProjectSelectionPreset.value = null
}
draftSelectedProjectIds.value = []
}
function selectUserProjectsMode() {
clearProjectDownloadsThreshold()
draftProjectSelectionPreset.value = 'user'
draftSelectedProjectIds.value = []
}
@@ -753,9 +886,10 @@ function applyProjectDownloadsThreshold(threshold: number | null) {
}
const projectIds = projects.value
.filter((project) => (projectDownloadsById.value.get(project.id) ?? 0) > threshold)
.filter((project) => (availableProjectDownloadsById.value.get(project.id) ?? 0) > threshold)
.map((project) => project.id)
draftProjectSelectionPreset.value = null
projectDownloadsThresholdProjectIds.value = projectIds
draftSelectedProjectIds.value = projectIds
}
@@ -894,12 +1028,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 +1287,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,
}),
)
@@ -38,7 +38,8 @@
<div class="flex flex-col gap-2.5">
<div
class="text-2xl font-semibold leading-none md:text-4xl"
v-tooltip="!disabled ? statTooltip : undefined"
class="w-fit text-2xl font-semibold leading-none md:text-4xl"
:class="{
'text-primary': disabled,
'text-contrast': !disabled,
@@ -114,6 +115,7 @@ import { analyticsStatCardMessages } from '../analytics-messages'
const props = defineProps<{
label: string
statLabel: string
statTooltip?: string
vsPrevPeriodPercent: string | null
icon: string
active?: boolean
@@ -1,21 +1,46 @@
<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"
:stat-tooltip="card.statTooltip"
: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,
@@ -23,12 +48,16 @@ import {
} from '~/providers/analytics/analytics'
import { analyticsStatCardMessages, formatAnalyticsStatLabel } from '../analytics-messages.ts'
import { formatAnalyticsTableFullPlaytime } from '../analytics-table/analytics-table-formatting.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 +65,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(
() =>
@@ -45,6 +79,38 @@ const compactNumberFormatter = computed(
}),
)
const underDollarRevenueFormatter = computed(
() =>
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
)
const preciseRevenueFormatter = computed(
() =>
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 5,
maximumFractionDigits: 5,
}),
)
const tooltipRevenueFormatter = computed(
() =>
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
)
const underHourPlaytimeFormatter = computed(
() =>
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
)
function formatStatNumber(value: number): string {
const rounded = Math.round(value)
@@ -55,18 +121,120 @@ function formatStatNumber(value: number): string {
return formatNumber(rounded)
}
function formatFullStatNumber(value: number): string {
return formatNumber(Math.round(value))
}
function formatRevenueNumber(value: number): string {
if (Math.abs(value) > 0 && Math.abs(value) < 1) {
return underDollarRevenueFormatter.value.format(value)
}
return formatStatNumber(value)
}
function formatRevenueValue(value: number): string {
return formatMessage(analyticsStatCardMessages.revenueValue, {
value: formatRevenueNumber(value),
})
}
function formatPreciseRevenueValue(value: number): string {
return formatMessage(analyticsStatCardMessages.revenueValue, {
value:
Math.abs(value) < 1
? preciseRevenueFormatter.value.format(value)
: tooltipRevenueFormatter.value.format(value),
})
}
function formatPlaytimeTooltip(value: number): string {
return formatAnalyticsTableFullPlaytime(value, formatMessage)
}
function formatPlaytimeNumber(value: number): string {
if (Math.abs(value) > 0 && Math.abs(value) < 1) {
return underHourPlaytimeFormatter.value.format(value)
}
return formatStatNumber(value)
}
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: formatRevenueNumber(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<
@@ -74,6 +242,7 @@ const statCards = computed<
key: AnalyticsDashboardStat
label: string
statLabel: string
statTooltip?: string
vsPrevPeriodPercent: string | null
icon: string
disabled: boolean
@@ -83,7 +252,13 @@ const statCards = computed<
key: 'views',
label: formatAnalyticsStatLabel('views', formatMessage),
statLabel: formatStatNumber(currentTotals.value.views),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.views),
statTooltip: formatFullStatNumber(currentTotals.value.views),
vsPrevPeriodPercent: formatPreviousPeriodComparison(
'views',
percentChanges.value.views,
currentTotals.value.views,
previousTotals.value.views,
),
icon: 'eye',
disabled: !isAnalyticsDashboardStatRelevant('views', selectedBreakdowns.value),
},
@@ -91,17 +266,27 @@ const statCards = computed<
key: 'downloads',
label: formatAnalyticsStatLabel('downloads', formatMessage),
statLabel: formatStatNumber(currentTotals.value.downloads),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.downloads),
statTooltip: formatFullStatNumber(currentTotals.value.downloads),
vsPrevPeriodPercent: formatPreviousPeriodComparison(
'downloads',
percentChanges.value.downloads,
currentTotals.value.downloads,
previousTotals.value.downloads,
),
icon: 'download',
disabled: !isAnalyticsDashboardStatRelevant('downloads', selectedBreakdowns.value),
},
{
key: 'revenue',
label: formatAnalyticsStatLabel('revenue', formatMessage),
statLabel: formatMessage(analyticsStatCardMessages.revenueValue, {
value: formatStatNumber(currentTotals.value.revenue),
}),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.revenue),
statLabel: formatRevenueValue(currentTotals.value.revenue),
statTooltip: formatPreciseRevenueValue(currentTotals.value.revenue),
vsPrevPeriodPercent: formatPreviousPeriodComparison(
'revenue',
percentChanges.value.revenue,
currentTotals.value.revenue,
previousTotals.value.revenue,
),
icon: 'dollar',
disabled: !isAnalyticsDashboardStatRelevant('revenue', selectedBreakdowns.value),
},
@@ -109,9 +294,15 @@ const statCards = computed<
key: 'playtime',
label: formatAnalyticsStatLabel('playtime', formatMessage),
statLabel: formatMessage(analyticsStatCardMessages.playtimeHours, {
hours: formatStatNumber(currentTotals.value.playtime / 3600),
hours: formatPlaytimeNumber(currentTotals.value.playtime / 3600),
}),
vsPrevPeriodPercent: formatPreviousPeriodPercent(percentChanges.value.playtime),
statTooltip: formatPlaytimeTooltip(currentTotals.value.playtime),
vsPrevPeriodPercent: formatPreviousPeriodComparison(
'playtime',
percentChanges.value.playtime,
currentTotals.value.playtime,
previousTotals.value.playtime,
),
icon: 'clock',
disabled: !isAnalyticsDashboardStatRelevant('playtime', selectedBreakdowns.value),
},
@@ -53,6 +53,7 @@ export interface UseAnalyticsRouteSyncOptions {
queryBuilder: AnalyticsQueryBuilderRefs
graph: AnalyticsGraphRefs
availableProjectIds: Ref<string[]>
defaultProjectIds: Ref<string[]>
sanitizeSelectedFilters: (
breakdowns: readonly AnalyticsBreakdownPreset[],
filters: AnalyticsSelectedFilters,
@@ -60,7 +61,8 @@ export interface UseAnalyticsRouteSyncOptions {
}
export function useAnalyticsRouteSync(options: UseAnalyticsRouteSyncOptions) {
const { queryBuilder, graph, availableProjectIds, sanitizeSelectedFilters } = options
const { queryBuilder, graph, availableProjectIds, defaultProjectIds, sanitizeSelectedFilters } =
options
const route = useRoute()
const router = useRouter()
@@ -116,6 +118,7 @@ export function useAnalyticsRouteSync(options: UseAnalyticsRouteSyncOptions) {
getSelectedAnalyticsQueryBuilderState(),
availableProjectIds.value,
getSelectedAnalyticsGraphState(),
defaultProjectIds.value,
)
const hasAnalyticsQueryChange = hasAnalyticsQueryBuilderRouteChange(route.query, nextRouteQuery)
@@ -144,7 +147,11 @@ export function useAnalyticsRouteSync(options: UseAnalyticsRouteSyncOptions) {
}
function applyRouteQueryToState(nextQuery: LocationQuery) {
const nextQueryState = readAnalyticsQueryBuilderState(nextQuery, availableProjectIds.value)
const nextQueryState = readAnalyticsQueryBuilderState(
nextQuery,
availableProjectIds.value,
defaultProjectIds.value,
)
const availableProjectIdSet = new Set(availableProjectIds.value)
const nextSelectedProjectIds = nextQueryState.selectedProjectIds.filter((projectId) =>
availableProjectIdSet.has(projectId),
@@ -1,244 +1,284 @@
<template>
<div
class="notification"
:class="{
'has-body': hasBody,
compact: compact,
read: notification.read,
}"
:class="
type === 'server_invite'
? { read: notification.read }
: {
notification: true,
'has-body': hasBody,
compact: compact,
read: notification.read,
}
"
>
<nuxt-link
v-if="!type"
:to="notification.link"
class="notification__icon backed-svg"
:class="{ raised: raised }"
>
<BellIcon />
</nuxt-link>
<DoubleIcon v-else class="notification__icon">
<template #primary>
<nuxt-link v-if="project" :to="getProjectLink(project)" tabindex="-1">
<Avatar size="xs" :src="project.icon_url" :raised="raised" no-shadow />
</nuxt-link>
<nuxt-link
v-else-if="organization"
:to="`/organization/${organization.slug}`"
tabindex="-1"
>
<Avatar size="xs" :src="organization.icon_url" :raised="raised" no-shadow />
</nuxt-link>
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
</nuxt-link>
<Avatar v-else size="xs" :raised="raised" no-shadow />
</template>
<template #secondary>
<ScaleIcon
v-if="type === 'moderator_message' || type === 'status_change'"
class="moderation-color"
/>
<UserPlusIcon v-else-if="type === 'team_invite' && project" class="creator-color" />
<UserPlusIcon
v-else-if="type === 'organization_invite' && organization"
class="creator-color"
/>
<VersionIcon v-else-if="type === 'project_update' && project && version" />
<BellIcon v-else />
</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">&nbsp;</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">&nbsp;</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>
<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">
<template v-if="type === 'server_invite'">
<div class="flex flex-col gap-4">
<ModrinthServersIcon class="h-auto w-56 max-w-full text-[var(--color-heading)]" />
<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"
class="flex flex-wrap items-center gap-x-1.5 gap-y-2 text-lg leading-tight text-[var(--color-heading)]"
>
<VersionIcon />
<nuxt-link
:to="getVersionLink(notif.extra_data.project, notif.extra_data.version)"
class="text-link"
v-if="invitedBy"
:to="`/user/${invitedBy.username}`"
class="inline-flex items-center font-bold text-[var(--color-heading)] hover:underline"
>
{{ 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="mr-1.5 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>
<span>{{ invitedBy.username }}</span>
</nuxt-link>
<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>
<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()
}
"
>
<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"
<div
v-if="!notification.read"
class="flex flex-wrap items-center gap-3"
:class="{ 'gap-2': compact }"
>
<ButtonStyled color="brand">
<button @click="performActionByTitle(notification, 'Accept')">
<CheckIcon />
Accept
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="performActionByTitle(notification, 'Deny')">
<XIcon />
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)]"
>
<CheckCircleIcon /> Read
</span>
<span v-tooltip="formatDateTime(notification.created)" class="inline-flex items-center">
<CalendarIcon class="mr-1" /> Received
{{ formatRelativeTime(notification.created) }}
</span>
<CopyCode v-if="flags.developerMode" :text="notification.id" />
</div>
</div>
</template>
<template v-else>
<nuxt-link
v-if="!type"
:to="notification.link"
class="notification__icon backed-svg"
:class="{ raised: raised }"
>
<BellIcon />
</nuxt-link>
<DoubleIcon v-else class="notification__icon">
<template #primary>
<nuxt-link v-if="project" :to="getProjectLink(project)" tabindex="-1">
<Avatar size="xs" :src="project.icon_url" :raised="raised" no-shadow />
</nuxt-link>
<nuxt-link
v-else-if="organization"
:to="`/organization/${organization.slug}`"
tabindex="-1"
>
<Avatar size="xs" :src="organization.icon_url" :raised="raised" no-shadow />
</nuxt-link>
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
</nuxt-link>
<Avatar v-else size="xs" :raised="raised" no-shadow />
</template>
<template #secondary>
<ScaleIcon
v-if="type === 'moderator_message' || type === 'status_change'"
class="moderation-color"
/>
<UserPlusIcon v-else-if="type === 'team_invite' && project" class="creator-color" />
<UserPlusIcon
v-else-if="type === 'organization_invite' && organization"
class="creator-color"
/>
<VersionIcon v-else-if="type === 'project_update' && project && version" />
<BellIcon v-else />
</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">&nbsp;</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">&nbsp;</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>
<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 />
<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)
@@ -247,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)
@@ -260,41 +300,79 @@
"
>
<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>
@@ -328,11 +406,13 @@ import { markAsRead } from '~/helpers/platform-notifications'
import { getProjectLink, getVersionLink } from '~/helpers/projects'
import { acceptTeamInvite, removeSelfFromTeam } from '~/helpers/teams'
import ModrinthServersIcon from '../brand/ModrinthServersIcon.vue'
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',
@@ -415,9 +495,29 @@ async function performAction(notification, actionIndex) {
await read()
if (actionIndex !== null) {
await useBaseFetch(`${notification.actions[actionIndex].action_route[1]}`, {
method: notification.actions[actionIndex].action_route[0].toUpperCase(),
})
const action = notification.actions[actionIndex]
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(`/servers/${serverId}/invites/${inviteAction}`, {
api: 'archon',
version: 1,
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(),
})
}
}
} catch (err) {
addNotification({
@@ -429,6 +529,20 @@ async function performAction(notification, actionIndex) {
stopLoading()
}
function performActionByTitle(notification, title) {
const actionIndex = notification.actions.findIndex((action) => action.title === title)
if (actionIndex === -1) {
addNotification({
title: 'An error occurred',
text: `Missing ${title.toLowerCase()} action for notification.`,
type: 'error',
})
return
}
return performAction(notification, actionIndex)
}
function getMessages() {
const messages = []
if (props.notification.body.message_id) {
@@ -1,20 +1,22 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { PlusIcon, XIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
injectModrinthClient,
injectNotificationManager,
NewModal,
ServerNotice,
StyledInput,
TagItem,
} from '@modrinth/ui'
import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
import { ref } from 'vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
type ServerNoticeType = Archon.Notices.v0.ListedNotice
const modal = ref<InstanceType<typeof NewModal>>()
@@ -32,28 +34,23 @@ const assignedNodes = computed(() => assigned.value.filter((n) => n.kind === 'no
const inputField = ref('')
async function refresh() {
await useServersFetch('notices').then((res) => {
const notices = res as ServerNoticeType[]
assigned.value = notices.find((n) => n.id === notice.value?.id)?.assigned ?? []
})
const notices = await client.archon.notices_v0.list()
assigned.value = notices.find((n) => n.id === notice.value?.id)?.assigned ?? []
}
async function assign(server: boolean = true) {
const input = inputField.value.trim()
if (input !== '' && notice.value) {
await useServersFetch(
`notices/${notice.value.id}/assign?${server ? 'server' : 'node'}=${input}`,
{
method: 'PUT',
},
).catch((err) => {
addNotification({
title: 'Error assigning notice',
text: err,
type: 'error',
await client.archon.notices_v0
.assign(notice.value.id, server ? { server: input } : { node: input })
.catch((err) => {
addNotification({
title: 'Error assigning notice',
text: err,
type: 'error',
})
})
})
} else {
addNotification({
title: 'Error assigning notice',
@@ -84,18 +81,15 @@ async function unassignDetect() {
async function unassign(id: string, server: boolean = true) {
if (notice.value) {
await useServersFetch(
`notices/${notice.value.id}/unassign?${server ? 'server' : 'node'}=${id}`,
{
method: 'PUT',
},
).catch((err) => {
addNotification({
title: 'Error unassigning notice',
text: err,
type: 'error',
await client.archon.notices_v0
.unassign(notice.value.id, server ? { server: id } : { node: id })
.catch((err) => {
addNotification({
title: 'Error unassigning notice',
text: err,
type: 'error',
})
})
})
}
await refresh()
}
@@ -125,7 +119,7 @@ defineExpose({ show, hide })
:level="notice.level"
:message="notice.message"
:dismissable="notice.dismissable"
:title="notice.title"
:title="notice.title ?? undefined"
preview
/>
<div class="flex flex-col gap-2">
@@ -133,6 +133,7 @@ import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
injectModrinthClient,
injectNotificationManager,
NewModal,
StyledInput,
@@ -143,9 +144,9 @@ import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
import { computed, ref } from 'vue'
import { useBaseFetch } from '#imports'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const modal = ref<InstanceType<typeof NewModal>>()
@@ -205,12 +206,12 @@ const applyDisabled = computed(() => {
async function ensureOverview() {
if (regions.value.length || nodeHostnames.value.length) return
try {
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
regions.value = (data.regions || []).map((r: any) => ({
const data = await client.archon.nodes_internal.overview()
regions.value = data.regions.map((r) => ({
value: r.key,
label: `${r.display_name} (${r.key})`,
}))
nodeHostnames.value = data.node_hostnames || []
nodeHostnames.value = data.node_hostnames
if (!selectedRegion.value && regions.value.length) selectedRegion.value = regions.value[0].value
} catch (err) {
addNotification({ title: 'Failed to load nodes overview', text: String(err), type: 'error' })
@@ -198,6 +198,7 @@ import {
ButtonStyled,
Chips,
Combobox,
injectModrinthClient,
injectNotificationManager,
NewModal,
StyledInput,
@@ -207,13 +208,12 @@ import {
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const emit = defineEmits<{
success: []
}>()
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const modal = ref<InstanceType<typeof NewModal>>()
@@ -341,12 +341,12 @@ const submitDisabled = computed(() => {
async function ensureOverview() {
if (regions.value.length || nodeHostnames.value.length) return
try {
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
regions.value = (data.regions || []).map((r: any) => ({
const data = await client.archon.nodes_internal.overview()
regions.value = data.regions.map((r) => ({
value: r.key,
label: `${r.display_name} (${r.key})`,
}))
nodeHostnames.value = data.node_hostnames || []
nodeHostnames.value = data.node_hostnames
if (!selectedRegion.value && regions.value.length) {
selectedRegion.value = regions.value[0].value
}
@@ -364,30 +364,22 @@ async function submit() {
scheduleOption.value === 'now' ? undefined : dayjs(scheduledDate.value).toISOString()
if (mode.value === 'servers') {
await useServersFetch('/transfers/schedule/servers', {
version: 'internal',
method: 'POST',
body: {
server_ids: parsedServerIds.value,
scheduled_at: scheduledAt,
target_region: selectedRegion.value || undefined,
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
reason: reason.value.trim(),
},
await client.archon.transfers_internal.scheduleServers({
server_ids: parsedServerIds.value,
scheduled_at: scheduledAt,
target_region: selectedRegion.value || undefined,
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
reason: reason.value.trim(),
})
} else {
await useServersFetch('/transfers/schedule/nodes', {
version: 'internal',
method: 'POST',
body: {
node_hostnames: selectedNodes.value.slice(),
scheduled_at: scheduledAt,
target_region: selectedRegion.value || undefined,
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
reason: reason.value.trim(),
cordon_nodes: cordonNodes.value,
tag_nodes: tagNodes.value.trim() || undefined,
},
await client.archon.transfers_internal.scheduleNodes({
node_hostnames: selectedNodes.value.slice(),
scheduled_at: scheduledAt,
target_region: selectedRegion.value || undefined,
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
reason: reason.value.trim(),
cordon_nodes: cordonNodes.value,
tag_nodes: tagNodes.value.trim() || undefined,
})
}
@@ -48,7 +48,12 @@
>{{ isIncome ? '' : '-' }}{{ formatMoney(transaction.amount) }}</span
>
<template v-if="transaction.type === 'withdrawal' && transaction.status === 'in-transit'">
<Tooltip theme="dismissable-prompt" :triggers="['hover', 'focus']" no-auto-focus>
<Tooltip
theme="dismissable-prompt"
class="inline-flex shrink-0"
:triggers="['hover', 'focus']"
no-auto-focus
>
<span class="my-auto align-middle"
><ButtonStyled circular type="outlined" size="small">
<button class="align-middle" @click="cancelPayout">
@@ -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())
"
+2 -1
View File
@@ -1,8 +1,9 @@
export const useAffiliates = () => {
const config = useRuntimeConfig()
const affiliateCookie = useCookie('mrs_afl', {
maxAge: 60 * 60 * 24 * 7, // 7 days
sameSite: 'lax',
secure: true,
secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
})
+2 -1
View File
@@ -30,10 +30,11 @@ export const initAuth = async (oldToken = null) => {
}
const route = useRoute()
const config = useRuntimeConfig()
const authCookie = useCookie('auth-token', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: true,
secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
})
+12 -9
View File
@@ -54,6 +54,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showViewProdRouteBanner: false,
showModeratorProjectMemberUi: false,
showModeratorPrivateMessageHighlight: true,
archonApiStaging: false,
showHostingAccessInstanceAuditLog: false,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
@@ -64,19 +66,20 @@ export type AllFeatureFlags = {
export type PartialFeatureFlags = Partial<AllFeatureFlags>
const COOKIE_OPTIONS = {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: true,
httpOnly: false,
path: '/',
} satisfies CookieOptions<PartialFeatureFlags>
const getCookieOptions = () =>
({
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: useRuntimeConfig().public.cookieSecure,
httpOnly: false,
path: '/',
}) satisfies CookieOptions<PartialFeatureFlags>
export const useFeatureFlags = () =>
useState<AllFeatureFlags>('featureFlags', () => {
const config = useRuntimeConfig()
const savedFlags = useCookie<PartialFeatureFlags>('featureFlags', COOKIE_OPTIONS)
const savedFlags = useCookie<PartialFeatureFlags>('featureFlags', getCookieOptions())
if (!savedFlags.value) {
savedFlags.value = {}
@@ -106,6 +109,6 @@ export const useFeatureFlags = () =>
export const saveFeatureFlags = () => {
const flags = useFeatureFlags()
const cookie = useCookie<PartialFeatureFlags>('featureFlags', COOKIE_OPTIONS)
const cookie = useCookie<PartialFeatureFlags>('featureFlags', getCookieOptions())
cookie.value = flags.value
}
@@ -1,264 +0,0 @@
/**
* @deprecated Use `@modrinth/api-client` via `injectModrinthClient()` instead.
* The api-client's archon modules (`client.archon.servers_v0`, etc.) handle auth,
* retry, and circuit breaking automatically. This composable is kept for legacy
* code that hasn't been migrated yet.
*/
import { PANEL_VERSION } from '@modrinth/api-client'
import type { V1ErrorInfo } from '@modrinth/utils'
import { ModrinthServerError, ModrinthServersFetchError } from '@modrinth/utils'
import { $fetch, FetchError } from 'ofetch'
export interface ServersFetchOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
contentType?: string
body?: Record<string, any>
version?: number | 'internal'
override?: {
url?: string
token?: string
}
retry?: number | boolean
bypassAuth?: boolean
}
export async function useServersFetch<T>(
path: string,
options: ServersFetchOptions = {},
module?: string,
errorContext?: string,
): Promise<T> {
const config = useRuntimeConfig()
const auth = await useAuth()
const authToken = auth.value?.token
if (!authToken && !options.bypassAuth) {
const error = new ModrinthServersFetchError(
'[Modrinth Hosting] Cannot fetch without auth',
10000,
)
throw new ModrinthServerError('Missing auth token', 401, error, module, undefined, undefined)
}
const {
method = 'GET',
contentType = 'application/json',
body,
version = 0,
override,
retry = method === 'GET' ? 3 : 0,
} = options
const circuitBreakerKey = `${module || 'default'}_${path}`
const failureCount = useState<number>(`fetch_failures_${circuitBreakerKey}`, () => 0)
const lastFailureTime = useState<number>(`last_failure_${circuitBreakerKey}`, () => 0)
const now = Date.now()
if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) {
const error = new ModrinthServersFetchError(
'[Modrinth Hosting] Circuit breaker open - too many recent failures',
503,
)
throw new ModrinthServerError(
'Service temporarily unavailable',
503,
error,
module,
undefined,
undefined,
)
}
if (now - lastFailureTime.value > 30000) {
failureCount.value = 0
}
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
/\/$/,
'',
)
if (!base) {
const error = new ModrinthServersFetchError(
'[Modrinth Hosting] Cannot fetch without base url. Make sure to set a PYRO_BASE_URL in environment variables',
10001,
)
throw new ModrinthServerError(
'Configuration error: Missing PYRO_BASE_URL',
500,
error,
module,
undefined,
undefined,
)
}
const versionString = `v${version}`
let newOverrideUrl = override?.url
if (newOverrideUrl && newOverrideUrl.includes('v0') && version !== 0) {
newOverrideUrl = newOverrideUrl.replace('v0', versionString)
}
const fullUrl = newOverrideUrl
? `https://${newOverrideUrl}/${path.replace(/^\//, '')}`
: version === 0
? `${base}/modrinth/v${version}/${path.replace(/^\//, '')}`
: version === 'internal'
? `${base}/_internal/${path.replace(/^\//, '')}`
: `${base}/v${version}/${path.replace(/^\//, '')}`
const headers: Record<string, string> = {
'User-Agent': 'Modrinth/1.0 (https://modrinth.com)',
'X-Archon-Request': 'true',
'X-Panel-Version': String(PANEL_VERSION),
Vary: 'Accept, Origin',
}
if (!options.bypassAuth) {
headers.Authorization = `Bearer ${override?.token ?? authToken}`
headers['Access-Control-Allow-Headers'] = 'Authorization'
}
if (contentType !== 'none') {
headers['Content-Type'] = contentType
}
if (import.meta.client && typeof window !== 'undefined') {
headers.Origin = window.location.origin
}
let attempts = 0
const maxAttempts = (typeof retry === 'boolean' ? (retry ? 3 : 1) : retry) + 1
let lastError: Error | null = null
while (attempts < maxAttempts) {
try {
const response = await $fetch<T>(fullUrl, {
method,
headers,
body:
body && contentType === 'application/json' ? JSON.stringify(body) : (body ?? undefined),
timeout: 10000,
})
failureCount.value = 0
return response
} catch (error) {
lastError = error as Error
attempts++
if (error instanceof FetchError) {
const statusCode = error.response?.status
const statusText = error.response?.statusText || 'Unknown error'
if (statusCode && statusCode >= 500) {
failureCount.value++
lastFailureTime.value = now
}
let v1Error: V1ErrorInfo | undefined
if (error.data?.error && error.data?.description) {
v1Error = {
context: errorContext,
...error.data,
}
}
const errorMessages: { [key: number]: string } = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
408: 'Request Timeout',
429: "You're making requests too quickly. Please wait a moment and try again.",
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
}
const message =
statusCode && statusCode in errorMessages
? errorMessages[statusCode]
: `HTTP Error: ${statusCode || 'unknown'} ${statusText}`
const isRetryable = statusCode ? [408, 429].includes(statusCode) : false
const is5xxRetryable =
statusCode && statusCode >= 500 && statusCode < 600 && method === 'GET' && attempts === 1
if (!(isRetryable || is5xxRetryable) || attempts >= maxAttempts) {
console.error('Fetch error:', error)
const fetchError = new ModrinthServersFetchError(
`[Modrinth Hosting] ${error.message}`,
statusCode,
error,
)
throw new ModrinthServerError(
`[Modrinth Hosting] ${message}`,
statusCode,
fetchError,
module,
v1Error,
error.data,
)
}
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000
const delay = Math.min(baseDelay * Math.pow(2, attempts - 1) + Math.random() * 1000, 15000)
console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`)
await new Promise((resolve) => setTimeout(resolve, delay))
continue
}
console.error('Unexpected fetch error:', error)
const fetchError = new ModrinthServersFetchError(
'[Modrinth Hosting] An unexpected error occurred during the fetch operation.',
undefined,
error as Error,
)
throw new ModrinthServerError(
'Unexpected error during fetch operation',
undefined,
fetchError,
module,
undefined,
undefined,
)
}
}
console.error('All retry attempts failed:', lastError)
if (lastError instanceof FetchError) {
const statusCode = lastError.response?.status
const pyroError = new ModrinthServersFetchError(
'Maximum retry attempts reached',
statusCode,
lastError,
)
throw new ModrinthServerError(
'Maximum retry attempts reached',
statusCode,
pyroError,
module,
undefined,
lastError.data,
)
}
const fetchError = new ModrinthServersFetchError(
'Maximum retry attempts reached',
undefined,
lastError || undefined,
)
throw new ModrinthServerError(
'Maximum retry attempts reached',
undefined,
fetchError,
module,
undefined,
undefined,
)
}
@@ -17,7 +17,6 @@ export type FilterSelection = {
const cookieDefaults = {
maxAge: TEN_MINUTES,
sameSite: 'lax' as const,
secure: true,
path: '/',
httpOnly: false,
}
@@ -52,13 +51,19 @@ function newFilterSelection(
}
export function useCdnDownloadContext() {
const filterGameVersionCookie = useCookie<string | null>('mr_download_filter_game_version', {
const config = useRuntimeConfig()
const cookieOptions = {
...cookieDefaults,
secure: config.public.cookieSecure,
}
const filterGameVersionCookie = useCookie<string | null>('mr_download_filter_game_version', {
...cookieOptions,
default: () => null,
})
const filterLoaderCookie = useCookie<string | null>('mr_download_filter_loader', {
...cookieDefaults,
...cookieOptions,
default: () => null,
})
+3 -1
View File
@@ -14,6 +14,7 @@ import {
import type { Ref } from 'vue'
import { useFeatureFlags } from '~/composables/featureFlags.ts'
import { withStagingArchonBaseUrl } from '~/helpers/archon.ts'
async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
try {
@@ -37,7 +38,8 @@ export function createModrinthClient(
const clientConfig: NuxtClientConfig = {
labrinthBaseUrl: config.apiBaseUrl,
archonBaseUrl: config.archonBaseUrl,
archonBaseUrl: () =>
withStagingArchonBaseUrl(config.archonBaseUrl, flags.value.archonApiStaging),
archonSentryCapture: () => flags.value.archonSentryCapture,
rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
features: [
+12
View File
@@ -0,0 +1,12 @@
export const STAGING_ARCHON_BASE_URL = 'https://staging-archon.modrinth.com/'
export function withStagingArchonBaseUrl(
baseUrl: string,
useStaging = useFeatureFlags().value.archonApiStaging,
) {
if (!useStaging) {
return baseUrl
}
return STAGING_ARCHON_BASE_URL
}
+20 -2
View File
@@ -350,6 +350,12 @@
"analytics.project.select": {
"message": "Select projects"
},
"analytics.project.user": {
"message": "{username}'s projects"
},
"analytics.project.your": {
"message": "Your projects"
},
"analytics.query.filter.add": {
"message": "Add filter"
},
@@ -368,6 +374,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"
},
@@ -1944,7 +1959,7 @@
"message": "Frog"
},
"hosting-marketing.available-locations": {
"message": "Available in North America, Europe, and Southeast Asia for wide coverage."
"message": "Available in North America, Europe, Southeast Asia, and Australia for wide coverage."
},
"hosting-marketing.billing.monthly": {
"message": "Pay monthly"
@@ -2007,7 +2022,7 @@
"message": "Where are Modrinth Hosting servers located? Can I choose a region?"
},
"hosting-marketing.faq.location.answer": {
"message": "We have servers available across North America, Europe, and Southeast Asia at the moment that you can choose upon purchase. More regions to come in the future! If you'd like to switch your region, please contact support."
"message": "We have servers available across North America, Europe, Southeast Asia, and Australia at the moment that you can choose upon purchase. More regions to come in the future! If you'd like to switch your region, please contact support."
},
"hosting-marketing.faq.versions-loaders": {
"message": "What Minecraft versions and loaders can be used?"
@@ -2837,6 +2852,9 @@
"profile.bio.fallback.user": {
"message": "A Modrinth user."
},
"profile.button.analytics": {
"message": "View user analytics"
},
"profile.button.billing": {
"message": "Manage user billing"
},
+10 -4
View File
@@ -1874,12 +1874,18 @@ const isSettings = computed(() => route.name.startsWith('type-project-settings')
// Transform versionsV3 to be same shape as versionsV2 for compatibility in project pages
const versionsRaw = computed(() => {
return (versionsV3.value ?? []).map((v) => {
const isModpack = v.project_types?.includes('modpack')
return (versionsV3.value ?? []).map((version) => {
const files = Array.isArray(version.files) ? version.files : []
const gameVersions = Array.isArray(version.game_versions) ? version.game_versions : []
const loaders = Array.isArray(version.loaders) ? version.loaders : []
const isModpack = version.project_types?.includes('modpack')
const mrpackLoaders = Array.isArray(version.mrpack_loaders) ? version.mrpack_loaders : []
return {
...v,
loaders: isModpack && v.mrpack_loaders ? v.mrpack_loaders : v.loaders,
...version,
files,
game_versions: gameVersions,
loaders: isModpack && mrpackLoaders.length ? mrpackLoaders : loaders,
}
})
})
@@ -53,11 +53,11 @@
{{ formatDate(version.date_published) }}</span
>
</div>
<ButtonStyled color="brand" type="transparent">
<ButtonStyled v-if="getPrimaryFile(version)" color="brand" type="transparent">
<a
class="ml-auto"
:href="createDownloadUrl(version)"
:download="getPrimaryFile(version).filename"
:download="getPrimaryFile(version)?.filename"
:title="`Download ${version.name}`"
>
<DownloadIcon aria-hidden="true" />
@@ -145,10 +145,10 @@ const filteredVersions = computed(() => {
(projectVersion) =>
(selectedGameVersions.length === 0 ||
selectedGameVersions.some((gameVersion) =>
projectVersion.game_versions.includes(gameVersion),
getVersionGameVersions(projectVersion).includes(gameVersion),
)) &&
(selectedLoaders.length === 0 ||
selectedLoaders.some((loader) => projectVersion.loaders.includes(loader))) &&
selectedLoaders.some((loader) => getVersionLoaders(projectVersion).includes(loader))) &&
(selectedVersionTypes.length === 0 ||
selectedVersionTypes.includes(projectVersion.version_type)),
)
@@ -233,14 +233,25 @@ watch(
)
function getPrimaryFile(version) {
return version.files.find((x) => x.primary) || version.files[0]
return version.files?.find((x) => x.primary) || version.files?.[0]
}
function createDownloadUrl(version) {
return createProjectDownloadUrl(getPrimaryFile(version).url, {
const file = getPrimaryFile(version)
if (!file?.url) return undefined
return createProjectDownloadUrl(file.url, {
reason: cdnDownloadReason.value,
})
}
function getVersionGameVersions(version) {
return Array.isArray(version.game_versions) ? version.game_versions : []
}
function getVersionLoaders(version) {
return Array.isArray(version.loaders) ? version.loaders : []
}
</script>
<style lang="scss">
@@ -111,10 +111,11 @@
color: 'primary',
hoverFilled: true,
link: createDownloadUrl(version),
download: getPrimaryFile(version).filename,
download: getPrimaryFile(version)?.filename,
action: () => {
emit('onDownload')
},
shown: !!getPrimaryFile(version),
},
{
id: 'new-tab',
@@ -402,7 +403,7 @@ const emit = defineEmits(['onDownload'])
const baseDropdownId = useId()
function getPrimaryFile(version: Labrinth.Versions.v3.Version) {
return version.files.find((x) => x.primary) || version.files[0]
return version.files?.find((x) => x.primary) || version.files?.[0]
}
watch(
@@ -417,7 +418,10 @@ watch(
)
function createDownloadUrl(version: Labrinth.Versions.v3.Version) {
return createProjectDownloadUrl(getPrimaryFile(version).url, {
const file = getPrimaryFile(version)
if (!file?.url) return undefined
return createProjectDownloadUrl(file.url, {
reason: cdnDownloadReason.value,
})
}
@@ -43,11 +43,11 @@
:open-modal="currentMember ? () => handleOpenCreateVersionModal() : undefined"
>
<template #actions="{ version }">
<ButtonStyled circular type="transparent">
<ButtonStyled v-if="getPrimaryFile(version)" circular type="transparent">
<a
v-tooltip="`Download`"
:href="createDownloadUrl(version)"
:download="getPrimaryFile(version).filename"
:download="getPrimaryFile(version)?.filename"
class="hover:!bg-button-bg [&>svg]:!text-green"
aria-label="Download"
@click="emit('onDownload')"
@@ -102,10 +102,11 @@
color: 'primary',
hoverFilled: true,
link: createDownloadUrl(version),
download: getPrimaryFile(version).filename,
download: getPrimaryFile(version)?.filename,
action: () => {
emit('onDownload')
},
shown: !!getPrimaryFile(version),
},
{
id: 'new-tab',
@@ -318,7 +319,7 @@ const emit = defineEmits(['onDownload', 'deleteVersion'])
const baseDropdownId = useId()
function getPrimaryFile(version) {
return version.files.find((x) => x.primary) || version.files[0]
return version.files?.find((x) => x.primary) || version.files?.[0]
}
watch(
@@ -333,7 +334,10 @@ watch(
)
function createDownloadUrl(version) {
return createProjectDownloadUrl(getPrimaryFile(version).url, {
const file = getPrimaryFile(version)
if (!file?.url) return undefined
return createProjectDownloadUrl(file.url, {
reason: cdnDownloadReason.value,
})
}
+6 -2
View File
@@ -23,6 +23,10 @@ function copy(id: string) {
navigator.clipboard?.writeText(`/_internal/templates/email/${id}`).catch(() => {})
}
function uncachedPreviewUrl(id: string) {
return `/_internal/templates/email/${id}?preview=${Date.now()}`
}
const previewModal = ref<{ hide: () => void; show: () => void } | null>(null)
const previewTemplate = ref<string | null>(null)
const previewLoading = ref(false)
@@ -73,7 +77,7 @@ async function openPreview(id: string, event?: MouseEvent) {
variableValues.value = {}
try {
const response = await fetch(`/_internal/templates/email/${id}`)
const response = await fetch(uncachedPreviewUrl(id), { cache: 'no-store' })
previewHtml.value = await response.text()
if (!response.ok) {
@@ -103,7 +107,7 @@ function openPopupPreview(id: string, offset = 0) {
const left = window.screenX + (window.outerWidth - width) / 2 + ((offset * 28) % 320)
const top = window.screenY + (window.outerHeight - height) / 2 + ((offset * 28) % 320)
window.open(
`/_internal/templates/email/${id}`,
uncachedPreviewUrl(id),
`email-${id}`,
`popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,menubar=no,toolbar=no,location=no,status=no`,
)
@@ -218,7 +218,7 @@
:level="notice.level"
:message="notice.message"
:dismissable="notice.dismissable"
:title="notice.title"
:title="notice.title ?? undefined"
preview
/>
<div class="mt-4 flex items-center gap-2">
@@ -260,6 +260,7 @@
</div>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { EditIcon, PlusIcon, SaveIcon, SettingsIcon, TrashIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
@@ -267,6 +268,7 @@ import {
commonMessages,
CopyCode,
defineMessages,
injectModrinthClient,
injectNotificationManager,
NewModal,
ServerNotice,
@@ -278,14 +280,13 @@ import {
useVIntl,
} from '@modrinth/ui'
import { NOTICE_LEVELS } from '@modrinth/ui/src/utils/notices.ts'
import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import AssignNoticeModal from '~/components/ui/admin/AssignNoticeModal.vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
@@ -297,6 +298,8 @@ const formatDateTimeShortMonth = useFormatDateTime({
dateStyle: 'medium',
})
type ServerNoticeType = Archon.Notices.v0.ListedNotice
const notices = ref<ServerNoticeType[]>([])
const createNoticeModal = ref<InstanceType<typeof NewModal>>()
const assignNoticeModal = ref<InstanceType<typeof AssignNoticeModal>>()
@@ -304,16 +307,14 @@ const assignNoticeModal = ref<InstanceType<typeof AssignNoticeModal>>()
await refreshNotices()
async function refreshNotices() {
await useServersFetch('notices').then((res) => {
notices.value = res as ServerNoticeType[]
notices.value.sort((a, b) => {
const dateDiff = dayjs(b.announce_at).diff(dayjs(a.announce_at))
if (dateDiff === 0) {
return b.id - a.id
}
notices.value = await client.archon.notices_v0.list()
notices.value.sort((a, b) => {
const dateDiff = dayjs(b.announce_at).diff(dayjs(a.announce_at))
if (dateDiff === 0) {
return b.id - a.id
}
return dateDiff
})
return dateDiff
})
}
@@ -347,7 +348,7 @@ function startEditing(notice: ServerNoticeType, assignments: boolean = false) {
newNoticeLevel.value = levelOptions.find((x) => x.id === notice.level) ?? levelOptions[0]
newNoticeDismissable.value = notice.dismissable
newNoticeMessage.value = notice.message
newNoticeTitle.value = notice.title
newNoticeTitle.value = notice.title ?? undefined
newNoticeScheduledDate.value = dayjs(notice.announce_at).format(DATE_TIME_FORMAT)
newNoticeExpiresDate.value = notice.expires
? dayjs(notice.expires).format(DATE_TIME_FORMAT)
@@ -361,9 +362,8 @@ function startEditing(notice: ServerNoticeType, assignments: boolean = false) {
}
async function deleteNotice(notice: ServerNoticeType) {
await useServersFetch(`notices/${notice.id}`, {
method: 'DELETE',
})
await client.archon.notices_v0
.delete(notice.id)
.then(() => {
addNotification({
title: `Successfully deleted notice #${notice.id}`,
@@ -412,9 +412,10 @@ async function saveChanges() {
return
}
await useServersFetch(`notices/${editingNotice.value?.id}`, {
method: 'PATCH',
body: {
if (!editingNotice.value) return
await client.archon.notices_v0
.update(editingNotice.value.id, {
message: newNoticeMessage.value,
title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
level: newNoticeLevel.value.id,
@@ -425,14 +426,14 @@ async function saveChanges() {
expires: newNoticeExpiresDate.value
? dayjs(newNoticeExpiresDate.value).toISOString()
: undefined,
},
}).catch((err) => {
addNotification({
title: 'Error saving changes to notice',
text: err,
type: 'error',
})
})
.catch((err) => {
addNotification({
title: 'Error saving changes to notice',
text: err,
type: 'error',
})
})
await refreshNotices()
createNoticeModal.value?.hide()
}
@@ -442,9 +443,8 @@ async function createNotice() {
return
}
await useServersFetch('notices', {
method: 'POST',
body: {
await client.archon.notices_v0
.create({
message: newNoticeMessage.value,
title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
level: newNoticeLevel.value.id,
@@ -455,14 +455,14 @@ async function createNotice() {
expires: newNoticeExpiresDate.value
? dayjs(newNoticeExpiresDate.value).toISOString()
: undefined,
},
}).catch((err) => {
addNotification({
title: 'Error creating notice',
text: err,
type: 'error',
})
})
.catch((err) => {
addNotification({
title: 'Error creating notice',
text: err,
type: 'error',
})
})
await refreshNotices()
createNoticeModal.value?.hide()
}
@@ -108,11 +108,13 @@
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { PlusIcon, XCircleIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
ConfirmModal,
injectModrinthClient,
injectNotificationManager,
Pagination,
TagItem,
@@ -124,38 +126,16 @@ import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import TransferModal from '~/components/ui/admin/TransferModal.vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
// Types
interface ProvisionOptions {
region?: string | null
node_tags?: string[]
}
interface TransferBatch {
id: number
created_by: string
created_at: string
reason: string | null
scheduled_at: string
cancelled: boolean
log_count: number
provision_options: ProvisionOptions
}
interface HistoryResponse {
batches: TransferBatch[]
total: number
page: number
page_size: number
}
type TransferBatch = Archon.Transfers.Internal.TransferLogBatchEntry
const transferModal = ref<InstanceType<typeof TransferModal>>()
const cancelModal = ref<InstanceType<typeof ConfirmModal>>()
@@ -178,10 +158,10 @@ async function refreshHistory() {
loading.value = true
error.value = null
try {
const data = await useServersFetch<HistoryResponse>(
`/transfers/history?page=${currentPage.value}&page_size=${pageSize}`,
{ version: 'internal' },
)
const data = await client.archon.transfers_internal.history({
page: currentPage.value,
page_size: pageSize,
})
batches.value = data.batches || []
total.value = data.total || 0
@@ -283,12 +263,8 @@ function showCancelModal(batchId: number) {
async function confirmCancel() {
if (!cancellingBatchId.value) return
try {
await useServersFetch('/transfers/cancel', {
version: 'internal',
method: 'POST',
body: {
batch_ids: [cancellingBatchId.value],
},
await client.archon.transfers_internal.cancel({
batch_ids: [cancellingBatchId.value],
})
addNotification({
title: 'Transfer cancelled',
@@ -72,6 +72,7 @@
}}
<Tooltip
theme="dismissable-prompt"
class="inline-flex shrink-0"
:triggers="['hover', 'focus']"
no-auto-focus
:aria-id="`${baseId}-date-segment-tooltip-${i}`"
@@ -106,6 +107,7 @@
{{ formatMessage(messages.processing) }}
<Tooltip
theme="dismissable-prompt"
class="inline-flex shrink-0"
:triggers="['hover', 'focus']"
no-auto-focus
:aria-id="`${baseId}-processing-tooltip`"
+3 -2
View File
@@ -868,7 +868,7 @@ const messages = defineMessages({
faqLocationAnswer: {
id: 'hosting-marketing.faq.location.answer',
defaultMessage:
"We have servers available across North America, Europe, and Southeast Asia at the moment that you can choose upon purchase. More regions to come in the future! If you'd like to switch your region, please contact support.",
"We have servers available across North America, Europe, Southeast Asia, and Australia at the moment that you can choose upon purchase. More regions to come in the future! If you'd like to switch your region, please contact support.",
},
faqIncreaseStorage: {
id: 'hosting-marketing.faq.increase-storage',
@@ -921,7 +921,8 @@ const messages = defineMessages({
},
availableLocations: {
id: 'hosting-marketing.available-locations',
defaultMessage: 'Available in North America, Europe, and Southeast Asia for wide coverage.',
defaultMessage:
'Available in North America, Europe, Southeast Asia, and Australia for wide coverage.',
},
payMonthly: {
id: 'hosting-marketing.billing.monthly',
@@ -11,6 +11,7 @@
:auth-user="authUser"
:navigate-to-billing="() => router.push('/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
constrain-width
:browse-modpacks="
({ serverId: sid, worldId: wid, from }) => {
navigateTo({
@@ -0,0 +1,82 @@
<script setup lang="ts">
import {
injectModrinthClient,
injectModrinthServerContext,
ServersManageAccessPage,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const client = injectModrinthClient()
const { server, serverId } = injectModrinthServerContext()
const queryClient = useQueryClient()
const flags = useFeatureFlags()
const ACTION_LOG_PAGE_SIZE = 200
const ACTION_LOG_SORT_DIRECTION = 'desc'
const actionLogDateFilter = defaultActionLogDateFilter()
await Promise.allSettled([
queryClient.ensureQueryData({
queryKey: ['servers', 'users', 'v1', serverId],
queryFn: () => client.archon.server_users_v1.list(serverId),
staleTime: 30_000,
}),
queryClient.ensureQueryData({
queryKey: ['servers', 'v1', 'detail', serverId],
queryFn: () => client.archon.servers_v1.get(serverId),
staleTime: 30_000,
}),
queryClient.prefetchInfiniteQuery({
queryKey: [
'servers',
'action-log',
'v1',
'infinite',
serverId,
null,
actionLogDateFilter.min_datetime,
actionLogDateFilter.max_datetime,
ACTION_LOG_SORT_DIRECTION,
],
queryFn: ({ pageParam = 0 }) => {
const offset = typeof pageParam === 'number' ? pageParam : 0
return client.archon.actions_v1.list(serverId, {
limit: ACTION_LOG_PAGE_SIZE,
offset,
order: ACTION_LOG_SORT_DIRECTION,
...actionLogDateFilter,
})
},
getNextPageParam: (lastPage) =>
typeof lastPage.next_offset === 'number' ? lastPage.next_offset : undefined,
initialPageParam: 0,
staleTime: 30_000,
}),
])
useHead({
title: computed(() => `Access - ${server.value?.name ?? 'Server'} - Modrinth`),
})
function defaultActionLogDateFilter() {
const endDate = new Date()
const startDate = new Date(endDate)
startDate.setDate(startDate.getDate() - 6)
return {
min_datetime: startOfDay(startDate).toISOString(),
max_datetime: endOfDay(endDate).toISOString(),
}
}
function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
function endOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999)
}
</script>
<template>
<ServersManageAccessPage :show-audit-log-instances="flags.showHostingAccessInstanceAuditLog" />
</template>
@@ -8,54 +8,169 @@
autocomplete="off"
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
clearable
wrapper-class="flex-1 lg:max-w-52"
input-class="h-[40px]"
wrapper-class="flex-1"
input-class="h-[40px] w-full"
@input="goToPage(1)"
/>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<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>
<MultiSelect
v-model="currentReporterOrProject"
:options="reporterOrProjectOptions"
:max-height="500"
dropdown-min-width="360px"
no-options-message="no options found"
:searchable="reporterOrProjectOptions.length > 6"
:max-tag-rows="1"
fit-content
checkbox-position="right"
show-selection-actions
should-show-select-all
@update:model-value="goToPage(1)"
>
<template #input-content="{ isOpen, openDirection }">
<div class="flex min-h-7 min-w-0 max-w-full flex-1 items-center gap-1.5 pr-1">
<LayersIcon class="size-5 shrink-0 text-primary" />
<span class="min-w-0 flex-1 truncate px-0.5 font-semibold text-primary">
{{
currentReporterOrProject.length === 0
? 'All Reports'
: `${currentReporterOrProject.length} selected`
}}
</span>
<ChevronLeftIcon
class="size-5 shrink-0 text-primary transition-transform duration-150"
:class="
isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'
"
/>
</div>
</template>
<template #top>
<div>
<button
type="button"
class="flex w-full cursor-pointer items-center gap-1.5 border-0 bg-surface-4 px-4 py-3 text-left shadow-none transition-all duration-150 hover:brightness-[115%] focus:brightness-[115%]"
:aria-selected="currentReporterOrProject.length === 0"
:class="currentReporterOrProject.length === 0 ? 'text-contrast' : 'text-primary'"
role="option"
@click="
() => {
currentReporterOrProject = []
goToPage(1)
}
"
@keydown.enter.stop
@keydown.space.stop
>
<LayersIcon
class="h-5 w-5 shrink-0 text-primary"
:class="currentReporterOrProject.length === 0 ? 'text-contrast' : 'text-primary'"
/>
<span class="min-w-0 flex-1 font-semibold leading-tight">All Reports</span>
<span class="flex shrink-0 items-center justify-center text-brand">
<CheckIcon
v-if="currentReporterOrProject.length === 0"
aria-hidden="true"
class="size-5"
/>
</span>
</button>
</div>
</template>
</MultiSelect>
<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)"
@select="goToPage(1)"
/>
</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)"
@select="goToPage(1)"
/>
</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)"
@select="goToPage(1)"
/>
</div>
</div>
</template>
</FloatingPanel>
</div>
</div>
<div v-if="totalPages > 1" class="flex items-center justify-between">
<div>
Showing
{{ itemsPerPage * (currentPage - 1) + 1 }}
{{ itemsPerPage * (currentPage - 1) + Math.min(itemsPerPage, paginatedReports.length) }}
of {{ sortedReports.length }} reports
</div>
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
@@ -72,17 +187,30 @@
</template>
<script setup lang="ts">
import { ListFilterIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
import type { Labrinth } from '@modrinth/api-client'
import {
BlendIcon,
CheckIcon,
ChevronLeftIcon,
LayersIcon,
ListFilterIcon,
SearchIcon,
SortAscIcon,
SortDescIcon,
} from '@modrinth/assets'
import type { ExtendedReport } from '@modrinth/moderation'
import {
Combobox,
type ComboboxOption,
commonMessages,
FloatingPanel,
MultiSelect,
type MultiSelectItem,
Pagination,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import type { Report } from '@modrinth/utils'
import type { Report, User } from '@modrinth/utils'
import Fuse from 'fuse.js'
import ReportCard from '~/components/ui/moderation/ModerationReportCard.vue'
@@ -93,6 +221,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 +297,112 @@ 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 }))]
})
type ReportedType<T> = T & { report_item_count: number }
const currentReporterOrProject = ref<string[]>([])
const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
if (!allReports.value) return []
const options: MultiSelectItem<string>[] = []
const uniqueProjectIds: { [id: string]: ReportedType<Labrinth.Projects.v2.Project> } = {}
const uniqueReporterIds: { [id: string]: ReportedType<User> } = {}
for (const report of filteredReports.value) {
if (report.project)
uniqueProjectIds[report.project.id] = {
...report.project,
report_item_count: (uniqueProjectIds[report.project.id]?.report_item_count || 0) + 1,
}
if (report.reporter_user)
uniqueReporterIds[report.reporter_user.id] = {
...report.reporter_user,
report_item_count: (uniqueReporterIds[report.reporter_user.id]?.report_item_count || 0) + 1,
}
}
if (Object.keys(uniqueProjectIds).length !== 0) {
options.push({ type: 'section-header', label: 'Projects' })
Object.values(uniqueProjectIds)
.sort((a, b) =>
a.report_item_count === b.report_item_count
? a.title.localeCompare(b.title)
: b.report_item_count - a.report_item_count,
)
.forEach((project) => {
options.push({
value: `project/${project.id}`,
label: `${project.title} (${project.report_item_count})`,
icon: project.icon_url ? h('img', { src: project.icon_url }) : undefined,
})
})
}
options.push({ type: 'section-header', label: 'Reporters' })
Object.values(uniqueReporterIds)
.sort((a, b) =>
a.report_item_count === b.report_item_count
? a.username.localeCompare(b.username)
: b.report_item_count - a.report_item_count,
)
.forEach((reporter) => {
options.push({
value: `reporter/${reporter.id}`,
label: `${reporter.username} (${reporter.report_item_count})`,
icon: reporter.avatar_url ? h('img', { src: reporter.avatar_url }) : undefined,
})
})
return options
})
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 +466,80 @@ 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 reporterOrProjectFilter = currentReporterOrProject.value
const filtered =
reporterOrProjectFilter.length === 0
? [...filteredReports.value]
: filteredReports.value.filter((report) => {
const reporterOrProjectFilterLookup = new Set(reporterOrProjectFilter)
const reporterValue = report.reporter_user ? `reporter/${report.reporter_user.id}` : null
const projectValue = report.project ? `project/${report.project.id}` : null
return (
(reporterValue && reporterOrProjectFilterLookup.has(reporterValue)) ||
(projectValue && reporterOrProjectFilterLookup.has(projectValue))
)
})
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 +549,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
+18
View File
@@ -263,6 +263,15 @@
action: () => $refs.userDetailsModal.show(),
shown: auth.user && isStaff(auth.user),
},
{
id: 'open-analytics',
action: () =>
navigateTo({
path: '/dashboard/analytics',
query: { user: user.username || user.id },
}),
shown: auth.user && isAdmin(auth.user),
},
{
id: 'edit-role',
action: () => openRoleEditModal(),
@@ -297,6 +306,10 @@
<InfoIcon aria-hidden="true" />
{{ formatMessage(messages.infoButton) }}
</template>
<template #open-analytics>
<ChartIcon aria-hidden="true" />
{{ formatMessage(messages.analyticsButton) }}
</template>
<template #toggle-affiliate>
<AffiliateIcon aria-hidden="true" />
{{
@@ -500,6 +513,7 @@ import {
BadgeCheckIcon,
BoxIcon,
CalendarIcon,
ChartIcon,
CheckIcon,
ClipboardCopyIcon,
CurrencyIcon,
@@ -692,6 +706,10 @@ const messages = defineMessages({
id: 'profile.button.info',
defaultMessage: 'View user details',
},
analyticsButton: {
id: 'profile.button.analytics',
defaultMessage: 'View user analytics',
},
setAffiliateButton: {
id: 'profile.button.set-affiliate',
defaultMessage: 'Set as affiliate',
+2 -1
View File
@@ -27,10 +27,11 @@ export interface Cosmetics {
export default defineNuxtPlugin({
name: 'cosmetics',
setup() {
const config = useRuntimeConfig()
const cosmetics = useCookie<Cosmetics>('cosmetics', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: true,
secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
default: () => ({
@@ -8,10 +8,11 @@ interface ThemeSettings {
export function useThemeSettings(getDefaultTheme?: () => Theme) {
getDefaultTheme ??= () => 'dark'
const config = useRuntimeConfig()
const $settings = useCookie<ThemeSettings>('color-mode', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: true,
secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
})
@@ -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)
}
}
@@ -11,9 +11,29 @@ import type {
} from './analytics-types'
const MINECRAFT_JAVA_SERVER_PROJECT_TYPE = 'minecraft_java_server'
const PLUGIN_PROJECT_TYPE = 'plugin'
export const UNKNOWN_ORGANIZATION_NAME = 'Organization'
function getProjectTypes(project: ProjectTypeMetadata): string[] {
const projectTypes = new Set<string>()
const projectType = project.project_type?.trim()
if (projectType) {
projectTypes.add(projectType)
}
for (const types of [project.project_types, project.projectTypes]) {
for (const type of types ?? []) {
const projectType = type.trim()
if (projectType) {
projectTypes.add(projectType)
}
}
}
return [...projectTypes]
}
function isServerProject(project: ProjectTypeMetadata): boolean {
if (project.project_type === MINECRAFT_JAVA_SERVER_PROJECT_TYPE) {
return true
@@ -28,6 +48,11 @@ export function isAnalyticsEligibleProject(
return !isServerProject(project) && getProjectStatusFilterValue(project.status) !== 'draft'
}
export function isPluginProject(project: ProjectTypeMetadata): boolean {
const projectTypes = getProjectTypes(project)
return projectTypes.length > 0 && projectTypes.every((type) => type === PLUGIN_PROJECT_TYPE)
}
export function getSingleQueryValue(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
@@ -46,6 +71,8 @@ export function toAnalyticsDashboardProject(
iconUrl: project.icon_url ?? undefined,
downloads: project.downloads ?? 0,
status: getProjectStatusFilterValue(project.status),
publishedAt: project.published ?? undefined,
projectTypes: getProjectTypes(project),
}
}
@@ -94,6 +94,7 @@ export type MutableRouteQuery = Record<
export type ProjectTypeMetadata = {
project_type?: string | null
project_types?: readonly string[] | null
projectTypes?: readonly string[] | null
}
export type AnalyticsProjectFetchRequest = Labrinth.Analytics.v3.FetchRequest & {
@@ -108,6 +109,7 @@ export type AnalyticsDashboardProjectSource = ProjectTypeMetadata & {
icon_url?: string | null
downloads?: number | null
status?: string | null
published?: string | null
}
export type AnalyticsProjectVersionSource = {
@@ -121,6 +123,8 @@ export interface AnalyticsDashboardProject {
iconUrl?: string
downloads: number
status: ProjectStatusFilterValue
publishedAt?: string
projectTypes: string[]
}
export interface AnalyticsDashboardProjectGroup {
@@ -36,6 +36,7 @@ import type { OrganizationContext } from '../organization-context'
import {
addVersionIdsFromTimeSlices,
addVersionProjectNamesFromTimeSlices,
ANALYTICS_START_TIME,
areAnalyticsFetchRequestsEqual,
buildAnalyticsCurrentTimeSlicesQueryKey,
buildAnalyticsFacetsRequest,
@@ -68,10 +69,12 @@ import {
sortStringValues,
} from './analytics-filter-utils'
import {
getProjectIdsMatchingStatusFilter,
getProjectOrganizationId,
getSingleQueryValue,
getUniqueAnalyticsDashboardProjects,
isAnalyticsEligibleProject,
isPluginProject,
toAnalyticsDashboardProject,
UNKNOWN_ORGANIZATION_NAME,
} from './analytics-project-utils'
@@ -131,6 +134,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') {
@@ -156,6 +170,11 @@ export interface AnalyticsDashboardContextValue {
hasProjectContext: ComputedRef<boolean>
projectGroups: ComputedRef<AnalyticsDashboardProjectGroup[]>
projects: ComputedRef<AnalyticsDashboardProject[]>
dashboardUserProjectIds: ComputedRef<string[]>
dashboardOrganizationProjectIds: ComputedRef<string[]>
defaultProjectIds: ComputedRef<string[]>
isUsingDashboardUserOverride: ComputedRef<boolean>
dashboardProjectUserName: ComputedRef<string>
selectedProjectIds: Ref<string[]>
selectedTimeframeMode: Ref<AnalyticsTimeframeMode>
selectedTimeframe: Ref<AnalyticsTimeframePreset>
@@ -164,6 +183,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>
@@ -184,6 +204,7 @@ export interface AnalyticsDashboardContextValue {
versionProjectIconUrlsById: ComputedRef<Map<string, string>>
projectStatusById: ComputedRef<Map<string, ProjectStatusFilterValue>>
availableProjectStatuses: ComputedRef<ProjectStatusFilterValue[]>
availableProjectDownloadsById: ComputedRef<Map<string, number>>
projectDownloadsById: ComputedRef<Map<string, number>>
projectVersionDownloadsById: ComputedRef<Map<string, number>>
gameVersionDownloadsByVersion: ComputedRef<Map<string, number>>
@@ -368,6 +389,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 +429,7 @@ export function createAnalyticsDashboardContext(
}
},
enabled: shouldFetchDashboardAllProjects,
refetchOnWindowFocus: false,
})
const areProjectsLoaded = computed(() => {
@@ -503,6 +526,45 @@ export function createAnalyticsDashboardContext(
)
const availableProjectIds = computed(() => projects.value.map((project) => project.id))
const dashboardUserProjectIds = computed(() => {
if (!shouldFetchDashboardAllProjects.value) {
return [...availableProjectIds.value]
}
const response = dashboardAllProjects.value
if (!response) {
return []
}
const availableProjectIdSet = new Set(availableProjectIds.value)
return response.projects
.filter(
(project) => !getProjectOrganizationId(project) && availableProjectIdSet.has(project.id),
)
.map((project) => project.id)
})
const dashboardOrganizationProjectIds = computed(() => {
if (!shouldFetchDashboardAllProjects.value) {
return []
}
const response = dashboardAllProjects.value
if (!response) {
return []
}
const availableProjectIdSet = new Set(availableProjectIds.value)
return response.projects
.filter(
(project) => getProjectOrganizationId(project) && availableProjectIdSet.has(project.id),
)
.map((project) => project.id)
})
const defaultProjectIds = computed(() =>
dashboardOrganizationProjectIds.value.length > 0 && dashboardUserProjectIds.value.length > 0
? dashboardUserProjectIds.value
: availableProjectIds.value,
)
const projectNamesById = computed(
() => new Map(projects.value.map((project) => [project.id, project.name])),
)
@@ -521,6 +583,7 @@ export function createAnalyticsDashboardContext(
const presentStatuses = new Set(projects.value.map((project) => project.status))
return PROJECT_STATUS_FILTER_VALUES.filter((status) => presentStatuses.has(status))
})
const sortedAvailableProjectIds = computed(() => sortStringValues(availableProjectIds.value))
const sortedSelectedProjectIds = computed(() => sortStringValues(selectedProjectIds.value))
const filterOptionProjectSources = computed<AnalyticsProjectVersionSource[] | null>(() => {
if (hasProjectContext.value && options.projectPageContext) {
@@ -547,6 +610,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),
)
@@ -569,6 +689,7 @@ export function createAnalyticsDashboardContext(
selectedFilters: selectedFilters.value,
},
availableProjectIds.value,
defaultProjectIds.value,
)
const isGraphDefault = isAnalyticsGraphStateDefault(
{
@@ -598,19 +719,38 @@ export function createAnalyticsDashboardContext(
customStartDate: selectedCustomTimeframeStartDate.value,
customEndDate: selectedCustomTimeframeEndDate.value,
nowTimestamp: queryRefreshTimestamp.value,
allTimeStartTimestamp: analyticsAllTimeStartDate.value.getTime(),
}) > REVENUE_MIN_TIMEFRAME_MS,
)
const isPlaytimeAvailableForProjectSelection = computed(() => {
const selectedProjectIdSet = new Set(selectedProjectIds.value)
const selectedProjects = projects.value.filter((project) =>
selectedProjectIdSet.has(project.id),
)
return (
selectedProjects.length === 0 || selectedProjects.some((project) => !isPluginProject(project))
)
})
function isAnalyticsDashboardStatAvailableForTimeframe(stat: AnalyticsDashboardStat): boolean {
return stat !== 'revenue' || isRevenueTimeframeAvailable.value
}
function isAnalyticsDashboardStatAvailableForProjectSelection(
stat: AnalyticsDashboardStat,
): boolean {
return stat !== 'playtime' || isPlaytimeAvailableForProjectSelection.value
}
function getRelevantAnalyticsDashboardStats(
breakdowns: readonly AnalyticsBreakdownPreset[],
filters: AnalyticsSelectedFilters = selectedFilters.value,
): readonly AnalyticsDashboardStat[] {
return getEnabledAnalyticsStatsForState(breakdowns, filters).filter((stat) =>
isAnalyticsDashboardStatAvailableForTimeframe(stat),
return getEnabledAnalyticsStatsForState(breakdowns, filters).filter(
(stat) =>
isAnalyticsDashboardStatAvailableForTimeframe(stat) &&
isAnalyticsDashboardStatAvailableForProjectSelection(stat),
)
}
@@ -667,6 +807,7 @@ export function createAnalyticsDashboardContext(
selectedGraphDatasetIds,
},
availableProjectIds,
defaultProjectIds,
sanitizeSelectedFilters: sanitizeAnalyticsSelectedFiltersForContext,
})
@@ -694,7 +835,13 @@ export function createAnalyticsDashboardContext(
}
watch(
[selectedBreakdowns, selectedFilters, activeStat, isRevenueTimeframeAvailable],
[
selectedBreakdowns,
selectedFilters,
activeStat,
isRevenueTimeframeAvailable,
isPlaytimeAvailableForProjectSelection,
],
([nextBreakdowns, nextFilters, nextActiveStat]) => {
if (isAnalyticsDashboardStatRelevant(nextActiveStat, nextBreakdowns, nextFilters)) {
return
@@ -772,9 +919,10 @@ export function createAnalyticsDashboardContext(
return
}
const availableProjectIds = new Set(nextProjects.map((project) => project.id))
const nextAvailableProjectIds = nextProjects.map((project) => project.id)
const availableProjectIds = new Set(nextAvailableProjectIds)
if (!hasExplicitProjectSelectionQuery.value) {
const nextSelectedProjectIds = nextProjects.map((project) => project.id)
const nextSelectedProjectIds = [...defaultProjectIds.value]
syncSelectedBreakdownsForProjectSelection(nextSelectedProjectIds)
syncProjectEventsVisibilityForProjectSelection(nextSelectedProjectIds)
if (!areStringArraysEqual(selectedProjectIds.value, nextSelectedProjectIds)) {
@@ -784,9 +932,14 @@ export function createAnalyticsDashboardContext(
return
}
const retainedSelection = selectedProjectIds.value.filter((id) => availableProjectIds.has(id))
const queryProjectSelection = readAnalyticsQueryBuilderState(
route.query,
nextAvailableProjectIds,
defaultProjectIds.value,
).selectedProjectIds
const retainedSelection = queryProjectSelection.filter((id) => availableProjectIds.has(id))
const nextSelectedProjectIds =
retainedSelection.length > 0 ? retainedSelection : nextProjects.map((project) => project.id)
retainedSelection.length > 0 ? retainedSelection : [...defaultProjectIds.value]
syncSelectedBreakdownsForProjectSelection(nextSelectedProjectIds)
syncProjectEventsVisibilityForProjectSelection(nextSelectedProjectIds)
@@ -842,6 +995,7 @@ export function createAnalyticsDashboardContext(
selectedBreakdowns,
selectedFilters,
availableProjectIds,
defaultProjectIds,
],
() => {
syncQueryBuilderRouteQuery()
@@ -867,7 +1021,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 +1065,7 @@ export function createAnalyticsDashboardContext(
)
},
enabled: computed(() => isAnalyticsFetchRequestReady(analyticsTimeSlicesFetchRequest.value)),
refetchOnWindowFocus: false,
gcTime: ANALYTICS_TIME_SLICES_GC_TIME_MS,
})
watch(currentAnalyticsError, (error) => {
@@ -956,7 +1121,10 @@ export function createAnalyticsDashboardContext(
}
const dailyFetchRequest = buildDailyAnalyticsFetchRequest(fetchRequest.value)
return buildComparisonFetchRequest(dailyFetchRequest) ?? dailyFetchRequest
return (
buildComparisonFetchRequest(dailyFetchRequest, analyticsComparisonStartTime.value) ??
dailyFetchRequest
)
})
watch(
@@ -984,7 +1152,7 @@ export function createAnalyticsDashboardContext(
nextQueryRefreshTimestamp,
),
queryFn: () =>
fetchAnalyticsTimeSlices(nextFetchRequest, (request) =>
fetchAnalyticsData(nextFetchRequest, (request) =>
client.labrinth.analytics_v3.fetch(request),
),
gcTime: ANALYTICS_PREFETCH_GC_TIME_MS,
@@ -1002,6 +1170,19 @@ export function createAnalyticsDashboardContext(
return buildAnalyticsFacetsRequest(sortedSelectedProjectIds.value, nextFetchRequest.time_range)
})
const availableProjectDownloadCountRequest = computed<Labrinth.Analytics.v3.FetchRequest | null>(
() => {
const nextFetchRequest = fetchRequest.value
if (!nextFetchRequest || sortedAvailableProjectIds.value.length === 0) {
return null
}
return buildAnalyticsFacetsRequest(
sortedAvailableProjectIds.value,
nextFetchRequest.time_range,
)
},
)
const {
data: analyticsFacetsData,
@@ -1057,6 +1238,37 @@ export function createAnalyticsDashboardContext(
isAnalyticsFetchRequestReady(analyticsFacetsRequest.value),
),
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
refetchOnWindowFocus: false,
})
const { data: availableProjectDownloadCountTimeSlices } = useQuery({
queryKey: computed(() => [
'analytics',
'dashboard',
analyticsQueryUserId.value,
'filter-options',
'available-project-download-counts',
availableProjectDownloadCountRequest.value,
queryRefreshTimestamp.value,
]),
queryFn: () => {
const nextRequest = availableProjectDownloadCountRequest.value
if (!isAnalyticsFetchRequestReady(nextRequest)) {
return []
}
return fetchAnalyticsTimeSlices(nextRequest, (request) =>
client.labrinth.analytics_v3.fetch(request),
)
},
enabled: computed(
() =>
hasCompletedAnalyticsLoading.value &&
isAnalyticsFetchRequestReady(availableProjectDownloadCountRequest.value),
),
placeholderData: [],
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
refetchOnWindowFocus: false,
})
const { data: analyticsDownloadCountTimeSlices } = useQuery({
@@ -1086,30 +1298,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 +1404,7 @@ export function createAnalyticsDashboardContext(
const splitTimeSlices = splitAnalyticsTimeSlices(
nextAnalyticsData.metrics,
fetchRequest.value,
analyticsComparisonStartTime.value,
)
timeSlices.value = splitTimeSlices.currentTimeSlices
previousTimeSlices.value = splitTimeSlices.previousTimeSlices
@@ -1269,6 +1461,7 @@ export function createAnalyticsDashboardContext(
enabled: computed(() => analyticsVersionIds.value.length > 0),
placeholderData: [],
gcTime: ANALYTICS_FILTER_OPTIONS_GC_TIME_MS,
refetchOnWindowFocus: false,
})
const allVersionMetadata = computed(() => {
@@ -1320,6 +1513,9 @@ export function createAnalyticsDashboardContext(
const countTimeSlices = analyticsDownloadCountTimeSlices.value ?? []
return countTimeSlices.length > 0 ? countTimeSlices : timeSlices.value
})
const availableProjectDownloadsById = computed(() =>
getProjectDownloadsByIdFromTimeSlices(availableProjectDownloadCountTimeSlices.value ?? []),
)
const projectDownloadsById = computed(() =>
getProjectDownloadsByIdFromTimeSlices(downloadCountTimeSlices.value),
)
@@ -1414,7 +1610,10 @@ export function createAnalyticsDashboardContext(
return
}
const defaultQueryState = buildDefaultAnalyticsQueryBuilderState(availableProjectIds.value)
const defaultQueryState = buildDefaultAnalyticsQueryBuilderState(
availableProjectIds.value,
defaultProjectIds.value,
)
const defaultGraphState = buildDefaultAnalyticsGraphState(defaultQueryState.selectedProjectIds)
selectedProjectIds.value = defaultQueryState.selectedProjectIds
@@ -1480,6 +1679,11 @@ export function createAnalyticsDashboardContext(
hasProjectContext,
projectGroups,
projects,
dashboardUserProjectIds,
dashboardOrganizationProjectIds,
defaultProjectIds,
isUsingDashboardUserOverride,
dashboardProjectUserName: effectiveUsername,
selectedProjectIds,
selectedTimeframeMode,
selectedTimeframe,
@@ -1488,6 +1692,7 @@ export function createAnalyticsDashboardContext(
selectedCustomTimeframeStartDate,
selectedCustomTimeframeEndDate,
selectedGroupBy,
analyticsAllTimeStartDate,
selectedBreakdowns,
selectedFilters,
queryRefreshTimestamp,
@@ -1508,6 +1713,7 @@ export function createAnalyticsDashboardContext(
versionProjectIconUrlsById,
projectStatusById,
availableProjectStatuses,
availableProjectDownloadsById,
projectDownloadsById,
projectVersionDownloadsById,
gameVersionDownloadsByVersion,
+5 -1
View File
@@ -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 whats changed.",
"thumbnail": "https://modrinth.com/news/article/server-access/thumbnail.webp",
"date": "2026-06-04T15:59:11.000Z",
"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.",
+9 -1
View File
@@ -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 whats 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>&lt;![CDATA[&lt;p&gt;Hey everyone,&lt;/p&gt;&lt;p&gt;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 were excited to finally get it out.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;/news/article/server-access/server-access.webp&quot; alt=&quot;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.&quot;&gt;&lt;/p&gt;&lt;h2&gt;TL;DR&lt;/h2&gt;&lt;ul&gt;&lt;li&gt;Add users to your server&lt;/li&gt;&lt;li&gt;Set permission roles&lt;/li&gt;&lt;li&gt;View activity log&lt;/li&gt;&lt;/ul&gt;&lt;h2&gt;Invite your friends&lt;/h2&gt;&lt;p&gt;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 theyll receive an invite by email or as a notification in the app if theyre signed in.&lt;/p&gt;&lt;p&gt;Alongside this release, weve also improved state syncing between the website panel, app, and other users, so everything should stay up to date in real time.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;/news/article/server-access/add-user-modal.webp&quot; alt=&quot;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.&quot;&gt;&lt;/p&gt;&lt;h2&gt;Permission roles&lt;/h2&gt;&lt;p&gt;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:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Owner:&lt;/strong&gt; Full access to the server including billing (you)&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Editor:&lt;/strong&gt; Manage content, files, backups, settings, and more&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Limited:&lt;/strong&gt; Start, stop, and view the server without making changes&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;You can find a full permission breakdown below:&lt;/p&gt;&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Permission&lt;/th&gt;&lt;th&gt;Owner&lt;/th&gt;&lt;th&gt;Editor&lt;/th&gt;&lt;th&gt;Limited&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Start / stop server&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Execute commands&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Edit settings&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Edit installation&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Manage content&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Manage files&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Create &amp;amp; restore backups&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Invite users&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Reset server&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Manage billing&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;h2&gt;See what changed&lt;/h2&gt;&lt;p&gt;Along with adding users, weve 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.&lt;/p&gt;&lt;p&gt;You can select a time timeframe and filter by user or action type if youre looking for something specific.&lt;/p&gt;&lt;p&gt;&lt;img src=&quot;/news/article/server-access/activity-log.webp&quot; alt=&quot;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.&quot;&gt;&lt;/p&gt;&lt;p&gt;&lt;/p&gt;&lt;p&gt;Thank you for your continued support! 💚&lt;/p&gt;]]&gt;</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>
@@ -32,6 +32,10 @@ export default {
'project-invited': () => import('./project/ProjectInvited.vue'),
'project-transferred': () => import('./project/ProjectTransferred.vue'),
// Server
'server-invited': () => import('./server/ServerInvited.vue'),
'server-invited-no-account': () => import('./server/ServerInvitedNoAccount.vue'),
// Organizations
'organization-invited': () => import('./organization/OrganizationInvited.vue'),
} as Record<string, () => Promise<{ default: Component }>>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { Button, Column, Heading, Link as VLink, Row, Section, Text } from '@vue-email/components'
import StyledEmail from '../shared/StyledEmail.vue'
</script>
<template>
<StyledEmail
title="You've been invited to a server"
:manual-links="[
{ link: 'https://modrinth.com/dashboard/notifications', label: 'Notification dashboard' },
{ link: 'https://support.modrinth.com', label: 'Support Portal' },
]"
>
<Heading as="h1" class="mb-2 text-2xl font-bold">You've been invited to a server</Heading>
<Text class="text-base">Hi <span class="no-auto-link">{user.name}</span>,</Text>
<Text class="text-base">
Modrinth user
<b
><VLink href="https://modrinth.com/user/{inviter.name}" class="text-green underline">
{inviter.name}
</VLink></b
>
has invited you to help manage
<b>{server.name}</b>
on Modrinth Hosting.
</Text>
<Section class="bg-bg-super mb-4 mt-4 rounded-lg border border-divider pb-4 pl-4 pr-4 pt-4">
<Text class="m-0 text-base">
You have been invited with the <b>{server.role}</b> role permission.
</Text>
</Section>
<Button
href="https://modrinth.com/dashboard/notifications"
target="_blank"
class="text-accentContrast inline-block rounded-[12px] bg-brand pb-3 pl-4 pr-4 pt-3 text-[14px] font-bold"
>
Review invitation
</Button>
<VLink href="https://modrinth.com/dashboard/notifications">
<Text class="text-muted mt-2 break-words text-xs font-bold"
>https://modrinth.com/dashboard/notifications</Text
>
</VLink>
<Text class="text-base">
To accept or reject this invitation, open your Modrinth notifications and review the invite.
If you were not expecting this invitation, contact the server owner or reach out to Modrinth
Support
<VLink href="https://support.modrinth.com" class="text-green underline">
through the Support Portal</VLink
>.
</Text>
<Section class="border-0 border-t border-solid border-divider pt-4">
<Text class="mb-3 mt-0 text-base font-bold">What does my role mean?</Text>
<Row>
<Column class="pr-2 align-top">
<Section class="bg-bg-super rounded-lg border border-divider pb-3 pl-3 pr-3 pt-3">
<Text class="m-0 text-xs font-bold uppercase">Editor</Text>
<Text class="mb-0 mt-2 text-base">
Manage instance content, files, backups, and other settings.
</Text>
</Section>
</Column>
<Column class="pl-2 align-top">
<Section class="bg-bg-super rounded-lg border border-divider pb-3 pl-3 pr-3 pt-3">
<Text class="m-0 text-xs font-bold uppercase">Viewer</Text>
<Text class="mb-0 mt-2 text-base">
Start, stop, and view the server without making changes.
</Text>
</Section>
</Column>
</Row>
</Section>
</StyledEmail>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { Button, Column, Heading, Link as VLink, Row, Section, Text } from '@vue-email/components'
import StyledEmail from '../shared/StyledEmail.vue'
</script>
<template>
<StyledEmail
title="You've been invited to a server"
:manual-links="[
{ link: '{serverinvite.url}', label: 'Create account and review invitation' },
{ link: 'https://support.modrinth.com', label: 'Support Portal' },
]"
>
<Heading as="h1" class="mb-2 text-2xl font-bold">You've been invited to a server</Heading>
<Text class="text-base">Hi,</Text>
<Text class="text-base">
Modrinth user
<b
><VLink href="https://modrinth.com/user/{inviter.name}" class="text-green underline">
{inviter.name}
</VLink></b
>
has invited you to help manage
<b>{server.name}</b>
on Modrinth Hosting.
</Text>
<Section class="bg-bg-super mb-4 mt-4 rounded-lg border border-divider pb-4 pl-4 pr-4 pt-4">
<Text class="m-0 text-base">
You have been invited with the <b>{server.role}</b> role permission.
</Text>
</Section>
<Button
href="{serverinvite.url}"
target="_blank"
class="text-accentContrast inline-block rounded-[12px] bg-brand pb-3 pl-4 pr-4 pt-3 text-[14px] font-bold"
>
Create account
</Button>
<VLink href="{serverinvite.url}">
<Text class="text-muted mt-2 break-words text-xs font-bold">{serverinvite.url}</Text>
</VLink>
<Text class="text-base">
To accept or reject this invitation, create a Modrinth account and review the invite from your
notifications dashboard. If you were not expecting this invitation, contact the server owner
or reach out to Modrinth Support
<VLink href="https://support.modrinth.com" class="text-green underline">
through the Support Portal</VLink
>.
</Text>
<Section class="border-0 border-t border-solid border-divider pt-4">
<Text class="mb-3 mt-0 text-base font-bold">What does my role let me do?</Text>
<Row>
<Column class="pr-2 align-top">
<Section class="bg-bg-super rounded-lg border border-divider pb-3 pl-3 pr-3 pt-3">
<Text class="m-0 text-xs font-bold uppercase">Editor</Text>
<Text class="mb-0 mt-2 text-base">
Manage instance content, files, backups, and other settings.
</Text>
</Section>
</Column>
<Column class="pl-2 align-top">
<Section class="bg-bg-super rounded-lg border border-divider pb-3 pl-3 pr-3 pt-3">
<Text class="m-0 text-xs font-bold uppercase">Viewer</Text>
<Text class="mb-0 mt-2 text-base">
Start, stop, and view the server without making changes.
</Text>
</Section>
</Column>
</Row>
</Section>
</StyledEmail>
</template>
+1
View File
@@ -104,6 +104,7 @@ TREMENDOUS_PRIVATE_KEY=none
TREMENDOUS_CAMPAIGN_ID=none
TILTIFY_CLIENT_ID=
TILTIFY_CLIENT_SECRET=
TILTIFY_WEBHOOK_SIGNING_KEY=
TILTIFY_PRIDE_26_CAMPAIGN_ID=
HCAPTCHA_SECRET=none
+1
View File
@@ -125,6 +125,7 @@ TREMENDOUS_PRIVATE_KEY=none
TREMENDOUS_CAMPAIGN_ID=none
TILTIFY_CLIENT_ID=
TILTIFY_CLIENT_SECRET=
TILTIFY_WEBHOOK_SIGNING_KEY=
TILTIFY_PRIDE_26_CAMPAIGN_ID=
HCAPTCHA_SECRET=none
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id\n FROM notifications\n WHERE body @> $1::jsonb\n AND user_id = ANY($2::bigint[])\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Jsonb",
"Int8Array"
]
},
"nullable": [
false
]
},
"hash": "147f31c59219c5485531914897618427375b203777a68fd6ece1a50feaf41df9"
}
@@ -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"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT user_id\n FROM notifications\n WHERE user_id = ANY($1::bigint[]) AND body = $2::jsonb\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8Array",
"Jsonb"
]
},
"nullable": [
false
]
},
"hash": "a29d71466a0842f634d0a068a45951273c087bc112a9848e9bdda37c7fe61747"
}
@@ -0,0 +1,28 @@
INSERT INTO notifications_types
(name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications)
VALUES ('server_invite', 1, FALSE, TRUE);
INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
VALUES (NULL, 'email', 'server_invite', FALSE);
INSERT INTO notifications_templates
(channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
VALUES
(
'email',
'server_invite',
'You''ve been invited to a server',
'https://modrinth.com/_internal/templates/email/server-invited',
CONCAT(
'Hi {user.name},',
CHR(10),
CHR(10),
'Modrinth user {inviter.name} has invited you to help manage {server.name} on Modrinth Hosting with the {server.role} role.',
CHR(10),
CHR(10),
'To accept or reject this invitation, open your Modrinth notifications: https://modrinth.com/dashboard/notifications',
CHR(10),
CHR(10),
'If you were not expecting this invitation, contact the server owner or reach out to Modrinth Support at https://support.modrinth.com'
)
);
@@ -0,0 +1,2 @@
CREATE INDEX users_lowercase_username_pattern
ON users (LOWER(username) text_pattern_ops);
@@ -40,7 +40,8 @@ impl NotificationBuilder {
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<(), DatabaseError> {
self.insert_many(vec![user], transaction, redis).await
self.insert_many(vec![user], transaction, redis).await?;
Ok(())
}
pub async fn insert_many_payout_notifications(
@@ -129,12 +130,11 @@ impl NotificationBuilder {
Ok(())
}
pub async fn insert_many(
async fn insert_many_records(
&self,
users: Vec<DBUserId>,
users: &[DBUserId],
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<(), DatabaseError> {
) -> Result<Vec<DBNotificationId>, DatabaseError> {
let notification_ids =
generate_many_notification_ids(users.len(), &mut *transaction)
.await?;
@@ -146,7 +146,7 @@ impl NotificationBuilder {
.collect::<Vec<_>>();
let users_raw_ids = users.iter().map(|x| x.0).collect::<Vec<_>>();
let notification_ids =
let notification_ids_raw =
notification_ids.iter().map(|x| x.0).collect::<Vec<_>>();
sqlx::query!(
@@ -156,13 +156,29 @@ impl NotificationBuilder {
)
SELECT * FROM UNNEST($1::bigint[], $2::bigint[], $3::jsonb[])
",
&notification_ids[..],
&notification_ids_raw[..],
&users_raw_ids[..],
&bodies[..],
)
.execute(&mut *transaction)
.await?;
Ok(notification_ids)
}
pub async fn insert_many(
&self,
users: Vec<DBUserId>,
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<Vec<DBNotificationId>, DatabaseError> {
let notification_ids =
self.insert_many_records(&users, transaction).await?;
let users_raw_ids = users.iter().map(|x| x.0).collect::<Vec<_>>();
let notification_ids_raw =
notification_ids.iter().map(|x| x.0).collect::<Vec<_>>();
let notification_types = notification_ids
.iter()
.map(|_| self.body.notification_type().as_str())
@@ -171,14 +187,28 @@ impl NotificationBuilder {
NotificationBuilder::insert_many_deliveries(
transaction,
redis,
&notification_ids,
&notification_ids_raw,
&users_raw_ids,
&notification_types,
&users,
)
.await?;
Ok(())
Ok(notification_ids)
}
/// Like [`insert_many`], but skips queuing deliveries so the caller can
/// manually send the notifications.
pub async fn insert_many_without_delivery(
&self,
users: Vec<DBUserId>,
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<Vec<DBNotificationId>, DatabaseError> {
let notification_ids =
self.insert_many_records(&users, transaction).await?;
DBNotification::clear_user_notifications_cache(&users, redis).await?;
Ok(notification_ids)
}
pub async fn insert_many_deliveries(
@@ -571,6 +601,38 @@ impl DBNotification {
Ok(Some(()))
}
pub async fn remove_many_matching_body(
body_filter: &serde_json::Value,
users: &[DBUserId],
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<usize, DatabaseError> {
let user_ids = users.iter().map(|x| x.0).collect::<Vec<i64>>();
let ids = sqlx::query!(
"
SELECT id
FROM notifications
WHERE body @> $1::jsonb
AND user_id = ANY($2::bigint[])
",
body_filter,
&user_ids
)
.fetch(&mut *transaction)
.map_ok(|x| DBNotificationId(x.id))
.try_collect::<Vec<_>>()
.await?;
if ids.is_empty() {
return Ok(0);
}
Self::remove_many(&ids, transaction, redis).await?;
Ok(ids.len())
}
pub async fn clear_user_notifications_cache(
user_ids: impl IntoIterator<Item = &DBUserId>,
redis: &RedisPool,
@@ -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
}
+1
View File
@@ -298,6 +298,7 @@ vars! {
TILTIFY_CLIENT_ID: String = "";
TILTIFY_CLIENT_SECRET: String = "";
TILTIFY_PRIDE_26_CAMPAIGN_ID: String = "";
TILTIFY_WEBHOOK_SIGNING_KEY: String = "";
// server pinging
SERVER_PING_MAX_CONCURRENT: usize = 16usize;
@@ -11,6 +11,7 @@ use crate::models::{
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, Deserialize)]
pub struct LegacyNotification {
@@ -66,6 +67,12 @@ pub enum LegacyNotificationBody {
team_id: TeamId,
role: String,
},
ServerInvite {
server_id: Uuid,
server_name: String,
invited_by: UserId,
role: String,
},
StatusChange {
project_id: ProjectId,
old_status: ProjectStatus,
@@ -166,6 +173,9 @@ impl LegacyNotification {
NotificationBody::OrganizationInvite { .. } => {
Some("organization_invite".to_string())
}
NotificationBody::ServerInvite { .. } => {
Some("server_invite".to_string())
}
NotificationBody::StatusChange { .. } => {
Some("status_change".to_string())
}
@@ -269,6 +279,17 @@ impl LegacyNotification {
team_id,
role,
},
NotificationBody::ServerInvite {
server_id,
server_name,
invited_by,
role,
} => LegacyNotificationBody::ServerInvite {
server_id,
server_name,
invited_by,
role,
},
NotificationBody::StatusChange {
project_id,
old_status,
@@ -12,6 +12,7 @@ use crate::routes::ApiError;
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, Deserialize)]
pub struct Notification {
@@ -34,6 +35,7 @@ pub enum NotificationType {
ProjectUpdate,
TeamInvite,
OrganizationInvite,
ServerInvite,
StatusChange,
ModeratorMessage,
LegacyMarkdown,
@@ -67,6 +69,7 @@ impl NotificationType {
NotificationType::ProjectUpdate => "project_update",
NotificationType::TeamInvite => "team_invite",
NotificationType::OrganizationInvite => "organization_invite",
NotificationType::ServerInvite => "server_invite",
NotificationType::StatusChange => "status_change",
NotificationType::ModeratorMessage => "moderator_message",
NotificationType::LegacyMarkdown => "legacy_markdown",
@@ -104,6 +107,7 @@ impl NotificationType {
"project_update" => NotificationType::ProjectUpdate,
"team_invite" => NotificationType::TeamInvite,
"organization_invite" => NotificationType::OrganizationInvite,
"server_invite" => NotificationType::ServerInvite,
"status_change" => NotificationType::StatusChange,
"moderator_message" => NotificationType::ModeratorMessage,
"legacy_markdown" => NotificationType::LegacyMarkdown,
@@ -156,6 +160,12 @@ pub enum NotificationBody {
team_id: TeamId,
role: String,
},
ServerInvite {
server_id: Uuid,
server_name: String,
invited_by: UserId,
role: String,
},
StatusChange {
project_id: ProjectId,
old_status: ProjectStatus,
@@ -267,6 +277,9 @@ impl NotificationBody {
NotificationBody::OrganizationInvite { .. } => {
NotificationType::OrganizationInvite
}
NotificationBody::ServerInvite { .. } => {
NotificationType::ServerInvite
}
NotificationBody::StatusChange { .. } => {
NotificationType::StatusChange
}
@@ -418,6 +431,34 @@ impl From<DBNotification> for Notification {
},
],
),
NotificationBody::ServerInvite {
server_id: _,
server_name,
role,
..
} => (
"You have been invited to join a server!".to_string(),
format!(
"An invite has been sent for you to be {role} of {server_name}"
),
"#".to_string(),
vec![
NotificationAction {
name: "Accept".to_string(),
action_route: (
"POST".to_string(),
String::new(),
),
},
NotificationAction {
name: "Deny".to_string(),
action_route: (
"POST".to_string(),
String::new(),
),
},
],
),
NotificationBody::StatusChange {
old_status,
new_status,
+20 -1
View File
@@ -73,6 +73,13 @@ pub struct User {
pub github_id: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SearchUser {
pub id: UserId,
pub username: String,
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserCampaigns {
pub pride_26: Option<Pride26CampaignDonation>,
@@ -87,7 +94,9 @@ pub struct UserPayoutData {
pub balance: Decimal,
}
use crate::database::models::user_item::{DBUser, Pride26CampaignDonation};
use crate::database::models::user_item::{
DBSearchUser, DBUser, Pride26CampaignDonation,
};
impl From<DBUser> for User {
fn from(data: DBUser) -> Self {
@@ -116,6 +125,16 @@ impl From<DBUser> for User {
}
}
impl From<DBSearchUser> for SearchUser {
fn from(data: DBSearchUser) -> Self {
Self {
id: data.id.into(),
username: data.username,
avatar_url: data.avatar_url,
}
}
}
impl User {
pub fn from_full(db_user: DBUser) -> Self {
let mut auth_providers = Vec::new();
@@ -61,6 +61,10 @@ const ORGINVITE_INVITER_NAME: &str = "organizationinvite.inviter.name";
const ORGINVITE_ORG_NAME: &str = "organizationinvite.organization.name";
const ORGINVITE_ROLE_NAME: &str = "organizationinvite.role.name";
const SERVERINVITE_INVITER_NAME: &str = "inviter.name";
const SERVERINVITE_SERVER_NAME: &str = "server.name";
const SERVERINVITE_ROLE_NAME: &str = "server.role";
const STATUSCHANGE_PROJECT_NAME: &str = "statuschange.project.name";
const STATUSCHANGE_OLD_STATUS: &str = "statuschange.old.status";
const STATUSCHANGE_NEW_STATUS: &str = "statuschange.new.status";
@@ -735,6 +739,27 @@ async fn collect_template_variables(
title: title.to_string(),
}),
NotificationBody::ServerInvite {
server_name,
invited_by,
role,
..
} => {
let inviter = DBUser::get_id(
DBUserId(invited_by.0 as i64),
&mut *exec,
redis,
)
.await?
.ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?;
map.insert(SERVERINVITE_INVITER_NAME, inviter.username);
map.insert(SERVERINVITE_SERVER_NAME, server_name.clone());
map.insert(SERVERINVITE_ROLE_NAME, role.clone());
Ok(EmailTemplate::Static(map))
}
NotificationBody::ProjectUpdate { .. }
| NotificationBody::ModeratorMessage { .. }
| NotificationBody::LegacyMarkdown { .. }
+58 -3
View File
@@ -1,9 +1,12 @@
use actix_web::{get, post, web};
use chrono::{DateTime, Utc};
use actix_web::{HttpRequest, get, post, web};
use base64::Engine;
use chrono::{DateTime, Duration, Utc};
use eyre::eyre;
use hmac::{Hmac, Mac};
use reqwest::Method;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::HashSet;
use tracing::{info, warn};
use uuid::Uuid;
@@ -134,11 +137,17 @@ impl CampaignDonation {
#[utoipa::path]
#[post("/webhook")]
pub async fn tiltify_webhook(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
payouts_queue: web::Data<PayoutsQueue>,
web::Json(raw_payload): web::Json<serde_json::Value>,
body: String,
) -> Result<(), ApiError> {
verify_tiltify_webhook_signature(&req, &body)?;
let raw_payload = serde_json::from_str::<serde_json::Value>(&body)
.wrap_internal_err_with(|| eyre!("invalid Tiltify webhook JSON"))?;
// deserialize the JSON in the request handler, not in the params,
// since if the JSON fails to deserialize then it's *our* fault,
// not the caller's.
@@ -237,6 +246,52 @@ pub async fn tiltify_webhook(
Ok(())
}
fn verify_tiltify_webhook_signature(
req: &HttpRequest,
body: &str,
) -> Result<(), ApiError> {
let signature = req
.headers()
.get("X-Tiltify-Signature")
.and_then(|x| x.to_str().ok())
.wrap_request_err("missing Tiltify webhook signature")?;
let signature = base64::engine::general_purpose::STANDARD
.decode(signature)
.wrap_request_err("invalid Tiltify webhook signature")?;
let timestamp = req
.headers()
.get("X-Tiltify-Timestamp")
.and_then(|x| x.to_str().ok())
.wrap_request_err("missing Tiltify webhook timestamp")?;
let parsed_timestamp = DateTime::parse_from_rfc3339(timestamp)
.wrap_request_err("invalid Tiltify webhook timestamp")?;
let parsed_timestamp = parsed_timestamp.with_timezone(&Utc);
let age = Utc::now().signed_duration_since(parsed_timestamp);
if age < -Duration::minutes(1) || age > Duration::minutes(1) {
return Err(ApiError::Request(eyre!(
"expired Tiltify webhook timestamp",
)));
}
if ENV.TILTIFY_WEBHOOK_SIGNING_KEY.is_empty() {
return Err(ApiError::Internal(eyre!(
"TILTIFY_WEBHOOK_SIGNING_KEY must be set"
)));
}
let mut mac: Hmac<Sha256> =
Hmac::new_from_slice(ENV.TILTIFY_WEBHOOK_SIGNING_KEY.as_bytes())
.wrap_internal_err("initializing Tiltify webhook HMAC")?;
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(body.as_bytes());
mac.verify_slice(&signature)
.wrap_request_err("invalid Tiltify webhook signature")?;
Ok(())
}
#[utoipa::path]
#[get("/pride-26")]
pub async fn pride_26(
@@ -1,23 +1,36 @@
use crate::auth::get_user_from_headers;
use crate::database::PgPool;
use crate::database::models::ids::DBUserId;
use crate::database::models::notification_item::DBNotification;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::user_item::DBUser;
use crate::database::redis::RedisPool;
use crate::models::users::Role;
use crate::models::v3::notifications::NotificationBody;
use crate::models::v3::notifications::{
Notification, NotificationBody, NotificationDeliveryStatus,
};
use crate::models::v3::pats::Scopes;
use crate::queue::email::EmailQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::routes::internal::statuses::broadcast_friends_message;
use crate::sync::friends::RedisFriendsMessage;
use crate::util::guards::external_notification_key_guard;
use actix_web::HttpRequest;
use actix_web::http::StatusCode;
use actix_web::web;
use actix_web::{HttpResponse, post};
use actix_web::{
CustomizeResponder, HttpRequest, HttpResponse, Responder, delete, post,
};
use ariadne::ids::UserId;
use eyre::eyre;
use lettre::message::Mailbox;
use serde::Deserialize;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(create).service(send_custom_email);
cfg.service(create)
.service(create_email_sync)
.service(remove)
.service(send_custom_email);
}
#[derive(Deserialize)]
@@ -47,15 +60,218 @@ pub async fn create(
));
}
NotificationBuilder { body }
let notification_ids = NotificationBuilder { body }
.insert_many(user_ids, &mut txn, &redis)
.await?;
let notifications = DBNotification::get_many(&notification_ids, &mut txn)
.await?
.into_iter()
.map(Notification::from)
.collect::<Vec<_>>();
txn.commit().await?;
for notification in notifications {
let notification_id = notification.id;
let to_user = notification.user_id;
if let Err(error) = broadcast_friends_message(
&redis,
RedisFriendsMessage::Notification {
to_user,
notification,
},
)
.await
{
tracing::warn!(
?error,
?notification_id,
?to_user,
"failed to broadcast realtime notification"
);
}
}
Ok(HttpResponse::Accepted().finish())
}
/// Inserts notifications for all users and tries to send emails immediately.
///
/// Responds with the user IDs that could not be emailed:
/// - `200` if every recipient was emailed (empty list)
/// - `207` if some recipients could not be emailed (list of failed IDs)
#[post(
"external_notifications/email-sync",
guard = "external_notification_key_guard"
)]
pub async fn create_email_sync(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
email_queue: web::Data<EmailQueue>,
create_notification: web::Json<CreateNotification>,
) -> Result<CustomizeResponder<web::Json<Vec<UserId>>>, ApiError> {
let CreateNotification { body, user_ids } =
create_notification.into_inner();
let raw_user_ids = user_ids.iter().map(|x| x.0 as i64).collect::<Vec<_>>();
let user_ids = raw_user_ids
.iter()
.map(|x| DBUserId(*x))
.collect::<Vec<_>>();
let mut txn = pool.begin().await?;
if !DBUser::exists_many(&user_ids, &mut txn).await? {
return Err(ApiError::InvalidInput(
"One of the specified users do not exist.".to_owned(),
));
}
// Skip users who already have an identical notification
let body_value = serde_json::value::to_value(&body)?;
let already_notified = sqlx::query!(
"
SELECT DISTINCT user_id
FROM notifications
WHERE user_id = ANY($1::bigint[]) AND body = $2::jsonb
",
&raw_user_ids[..],
body_value,
)
.fetch_all(&mut txn)
.await?
.into_iter()
.map(|row| DBUserId(row.user_id))
.collect::<std::collections::HashSet<_>>();
let notification_user_ids = user_ids
.clone()
.into_iter()
.filter(|id| !already_notified.contains(id))
.collect::<Vec<_>>();
let notification_ids = NotificationBuilder { body: body.clone() }
.insert_many_without_delivery(notification_user_ids, &mut txn, &redis)
.await?;
let notifications = DBNotification::get_many(&notification_ids, &mut txn)
.await?
.into_iter()
.map(Notification::from)
.collect::<Vec<_>>();
txn.commit().await?;
for notification in notifications {
let Notification {
user_id: to_user,
id: notification_id,
..
} = notification;
if let Err(error) = broadcast_friends_message(
&redis,
RedisFriendsMessage::Notification {
to_user,
notification,
},
)
.await
{
tracing::warn!(
?error,
?notification_id,
?to_user,
"failed to broadcast realtime notification"
);
}
}
let mut email_txn = pool.begin().await?;
let mut failed = Vec::new();
for user_id in &user_ids {
let Some(user) =
DBUser::get_id(*user_id, &mut email_txn, &redis).await?
else {
failed.push(UserId(user_id.0 as u64));
continue;
};
let delivered = match user
.email
.and_then(|email| email.parse::<Mailbox>().ok())
{
Some(mailbox) => {
email_queue
.send_one(&mut email_txn, body.clone(), *user_id, mailbox)
.await?
== NotificationDeliveryStatus::Delivered
}
None => false,
};
if !delivered {
failed.push(UserId(user_id.0 as u64));
}
}
let status = if failed.is_empty() {
StatusCode::OK
} else {
StatusCode::MULTI_STATUS
};
Ok(web::Json(failed).customize().with_status(status))
}
#[derive(Deserialize)]
struct NotificationFilter {
pub user_ids: Vec<UserId>,
#[serde(flatten)]
pub body: serde_json::Map<String, serde_json::Value>,
}
#[delete("external_notifications", guard = "external_notification_key_guard")]
pub async fn remove(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
notification_filter: web::Json<NotificationFilter>,
) -> Result<HttpResponse, ApiError> {
let NotificationFilter { user_ids, body } =
notification_filter.into_inner();
if user_ids.is_empty() {
return Err(ApiError::Request(eyre!(
"at least one user must be provided to remove notifications from"
)));
}
if body.is_empty() {
return Err(ApiError::Request(eyre!(
"at least one `body` field must be provided to match notifications"
)));
}
let filters = serde_json::Value::Object(body);
let user_ids = user_ids
.into_iter()
.map(|x| DBUserId(x.0 as i64))
.collect::<Vec<_>>();
let mut txn = pool.begin().await?;
DBNotification::remove_many_matching_body(
&filters, &user_ids, &mut txn, &redis,
)
.await?;
txn.commit().await?;
Ok(HttpResponse::NoContent().finish())
}
#[derive(Deserialize)]
struct SendEmail {
pub users: Vec<UserId>,
@@ -2,7 +2,9 @@ use crate::auth::AuthenticationError;
use crate::auth::validate::get_user_record_from_bearer_token;
use crate::database::PgPool;
use crate::database::models::friend_item::DBFriend;
use crate::database::models::notification_item::DBNotification;
use crate::database::redis::RedisPool;
use crate::models::notifications::{Notification, NotificationBody};
use crate::models::pats::Scopes;
use crate::models::users::User;
use crate::queue::session::AuthQueue;
@@ -42,6 +44,7 @@ struct LauncherHeartbeatInit {
code: String,
}
// TODO: Move launcher-specific tunnel traffic to a proper launcher websocket endpoint.
#[get("launcher_socket")]
pub async fn ws_init(
req: HttpRequest,
@@ -127,6 +130,26 @@ pub async fn ws_init(
)?)
.await;
let unread_server_invites = DBNotification::get_many_user_exposed_on_site(
user_id.into(),
&**pool,
&redis,
)
.await?
.into_iter()
.filter(|notification| {
!notification.read
&& matches!(
&notification.body,
NotificationBody::ServerInvite { .. }
)
})
.map(Notification::from);
for notification in unread_server_invites {
let _ = session.text(serde_json::to_string(&notification)?).await;
}
let db = db.clone();
let socket_id = db.next_socket_id.fetch_add(1, Ordering::Relaxed);
db.sockets
@@ -449,6 +472,25 @@ pub async fn send_message_to_user(
Ok(())
}
pub async fn send_notification_to_user(
db: &ActiveSockets,
user: UserId,
notification: &Notification,
) -> Result<(), crate::database::models::DatabaseError> {
let message = serde_json::to_string(notification)?;
if let Some(socket_ids) = db.sockets_by_user_id.get(&user) {
for socket_id in socket_ids.iter() {
if let Some(socket) = db.sockets.get(&socket_id) {
let mut socket = socket.socket.clone();
let _ = socket.text(message.clone()).await;
}
}
}
Ok(())
}
pub async fn close_socket(
id: SocketId,
pool: &PgPool,
+21
View File
@@ -39,6 +39,7 @@ use validator::Validate;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.route("user", web::get().to(user_auth_get));
cfg.route("users", web::get().to(users_get));
cfg.route("users/search", web::get().to(users_search));
cfg.route("user_email", web::get().to(admin_user_email));
cfg.service(
@@ -327,6 +328,26 @@ pub struct UserIds {
pub ids: String,
}
#[derive(Deserialize)]
pub struct UserSearchQuery {
pub query: String,
}
pub async fn users_search(
web::Query(query): web::Query<UserSearchQuery>,
pool: web::Data<PgPool>,
) -> Result<web::Json<Vec<crate::models::users::SearchUser>>, ApiError> {
let query = query.query.trim();
let users = DBUser::search(query, &**pool)
.await
.wrap_internal_err("failed to search users")?
.into_iter()
.map(Into::into)
.collect();
Ok(web::Json(users))
}
pub async fn users_get(
req: HttpRequest,
web::Query(ids): web::Query<UserIds>,
+29 -5
View File
@@ -1,7 +1,8 @@
use crate::database::PgPool;
use crate::models::notifications::Notification;
use crate::queue::socket::ActiveSockets;
use crate::routes::internal::statuses::{
broadcast_to_local_friends, send_message_to_user,
broadcast_to_local_friends, send_message_to_user, send_notification_to_user,
};
use actix_web::web::Data;
use ariadne::ids::UserId;
@@ -14,12 +15,23 @@ use tokio_stream::StreamExt;
pub const FRIENDS_CHANNEL_NAME: &str = "friends";
#[derive(Debug, Serialize, Deserialize)]
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RedisFriendsMessage {
StatusUpdate { status: UserStatus },
UserOffline { user: UserId },
DirectStatusUpdate { to_user: UserId, status: UserStatus },
StatusUpdate {
status: UserStatus,
},
UserOffline {
user: UserId,
},
DirectStatusUpdate {
to_user: UserId,
status: UserStatus,
},
Notification {
to_user: UserId,
notification: Notification,
},
}
impl ToRedisArgs for RedisFriendsMessage {
@@ -80,6 +92,18 @@ pub async fn handle_pubsub(
.await;
}
Ok(RedisFriendsMessage::Notification {
to_user,
notification,
}) => {
let _ = send_notification_to_user(
&sockets,
to_user,
&notification,
)
.await;
}
Err(_) => {}
}
});

Some files were not shown because too many files have changed in this diff Show More