Merge remote-tracking branch 'origin/main' into cal/post-release-shared-instances

# Conflicts:
#	apps/app-frontend/src/pages/instance/share/index.vue
#	apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue
This commit is contained in:
Calum H. (IMB11)
2026-07-28 13:31:28 +01:00
118 changed files with 3191 additions and 4716 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ jobs:
REDIS_CONNECTION_TYPE: multiplexed
REDIS_URL: redis://127.0.0.1:7000,redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003,redis://127.0.0.1:7004,redis://127.0.0.1:7005
# Avoid stack overflows in tests
RUST_MIN_STACK: 67108864
RUST_MIN_STACK: 134217728
steps:
- name: Check out code
+8 -4
View File
@@ -115,6 +115,7 @@ import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
import { hasActivePride26Midas, hasMidasBadge } from '@/helpers/user-campaigns.ts'
import { parse_modrinth_user_link } from '@/helpers/users'
import {
areUpdatesEnabled,
enqueueUpdateForInstallation,
@@ -265,7 +266,7 @@ providePageContext({
themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
),
},
openExternalUrl: (url) => openUrl(url),
openExternalUrl: (url) => void openUrl(url),
})
provideModalBehavior({
noblur: computed(() => !themeStore.advancedRendering),
@@ -966,7 +967,7 @@ async function declineServerInviteNotification(notification) {
function openServerInviteInviterProfile(inviterName) {
if (!inviterName) return
openUrl(`${config.siteUrl}/user/${encodeURIComponent(inviterName)}`)
void router.push(`/user/${encodeURIComponent(inviterName)}`)
}
async function handleLiveNotification(notification) {
@@ -1407,8 +1408,11 @@ function handleClick(e) {
!target.href.startsWith('https://tauri.localhost') &&
!target.href.startsWith('http://tauri.localhost')
) {
const userPath = parse_modrinth_user_link(target.href)
const parsed = parseModrinthLink(target.href)
if (target.target !== '_blank' && parsed) {
if (userPath) {
void router.push(userPath)
} else if (target.target !== '_blank' && parsed) {
void openModrinthProjectLinkInApp(parsed)
} else {
openUrl(target.href)
@@ -1654,7 +1658,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
:options="[
{
id: 'view-profile',
action: () => openUrl('https://modrinth.com/user/' + credentials.user.username),
action: () => router.push(`/user/${encodeURIComponent(credentials.user.username)}`),
},
{
id: 'sign-out',
@@ -8,13 +8,14 @@ import {
OverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import type { FriendWithUserData } from '@/helpers/friends.ts'
const { formatMessage } = useVIntl()
const router = useRouter()
const props = withDefaults(
defineProps<{
@@ -54,7 +55,7 @@ function createContextMenuOptions(friend: FriendWithUserData) {
}
function openProfile(username: string) {
openUrl('https://modrinth.com/user/' + username)
void router.push(`/user/${encodeURIComponent(username)}`)
}
const friendOptions = useTemplateRef('friendOptions')
@@ -186,8 +186,8 @@ import {
type TeleportOverflowMenuItem,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import type { GameInstance } from '@/helpers/types'
@@ -247,6 +247,7 @@ const messages = defineMessages({
defaultMessage: "This instance's content is being shared to other users.",
},
})
const router = useRouter()
const props = withDefaults(
defineProps<{
@@ -331,7 +332,7 @@ const sharedInstanceManagerLabel = computed(() =>
const sharedInstanceManagerAction = computed(() => {
const manager = props.sharedInstanceManager
if (manager?.type !== 'user') return undefined
return () => openUrl(`https://modrinth.com/user/${encodeURIComponent(manager.name)}`)
return () => router.push(`/user/${encodeURIComponent(manager.name)}`)
})
const playtimeLabel = computed(() => {
if (props.timePlayed <= 0) return formatMessage(messages.neverPlayed)
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Combobox, defineMessages, ThemeSelector, Toggle, useVIntl } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils'
@@ -123,6 +123,11 @@ const messages = defineMessages({
const os = ref(await getOS())
const settings = ref(await get())
const themeOptions = computed(() =>
themeStore
.getThemeOptions()
.filter((theme) => theme !== 'retro' || themeStore.devMode || settings.value.theme === 'retro'),
)
watch(
settings,
@@ -146,7 +151,7 @@ watch(
}
"
:current-theme="settings.theme"
:theme-options="themeStore.getThemeOptions()"
:theme-options="themeOptions"
system-theme-color="system"
/>
@@ -1,6 +1,14 @@
<script setup>
import { BoxIcon, FolderOpenIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, Slider, StyledInput } from '@modrinth/ui'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
Slider,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
@@ -11,9 +19,23 @@ import { showAppDbBackupsFolder } from '@/helpers/utils.js'
import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const settings = ref(await get())
const purgeCacheConfirmModal = ref(null)
const alwaysShowCopyDetailsFlag = 'always_show_copy_details'
const messages = defineMessages({
alwaysShowCopyDetailsTitle: {
id: 'app.resource-management-settings.always-show-copy-details.title',
defaultMessage: 'Always show copy details',
},
alwaysShowCopyDetailsDescription: {
id: 'app.resource-management-settings.always-show-copy-details.description',
defaultMessage:
'Show the Copy details action while an install is queued or running. It is always available for failed or interrupted installs.',
},
})
watch(
settings,
@@ -154,6 +176,28 @@ async function findLauncherDir() {
</p>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.alwaysShowCopyDetailsTitle) }}
</h2>
<p class="m-0 mt-1">
{{ formatMessage(messages.alwaysShowCopyDetailsDescription) }}
</p>
</div>
<Toggle
id="always-show-copy-details"
:model-value="themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)
themeStore.featureFlags[alwaysShowCopyDetailsFlag] = newValue
settings.feature_flags[alwaysShowCopyDetailsFlag] = newValue
}
"
/>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">App database backups</h2>
<button id="open-db-backups-folder" class="btn min-w-max" @click="openDbBackupsFolder">
@@ -6,11 +6,9 @@ import {
injectPopupNotificationManager,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type Ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { config } from '@/config'
import { get_user } from '@/helpers/cache'
import { toError } from '@/helpers/errors'
import {
@@ -238,7 +236,7 @@ export function useSharedInstanceInviteHandler(
markNotificationRead(notification).catch((error) => handleError(toError(error))),
onOpenActor: () => {
if (invite.invitedByUsername) {
openUrl(`${config.siteUrl}/user/${encodeURIComponent(invite.invitedByUsername)}`)
void router.push(`/user/${encodeURIComponent(invite.invitedByUsername)}`)
}
},
},
@@ -23,6 +23,7 @@ import {
type InstallProgress,
} from '@/helpers/install'
import { get_many as getInstances } from '@/helpers/instance'
import { useTheming } from '@/store/state'
const messages = defineMessages({
installs: {
@@ -221,13 +222,6 @@ const failureSummaryMessages = defineMessages({
})
const visibleJobStatuses = new Set<InstallJobStatus>(['queued', 'running', 'failed', 'interrupted'])
const copyDetailsStallMs = 30_000
interface ProgressSnapshot {
signature: string
changedAt: number
timeout: number | null
}
function getDisplayIconUrl(icon: string | null | undefined): string | null {
if (!icon) return null
@@ -241,6 +235,7 @@ export async function useInstallJobNotifications(opts: {
onChange: () => void
}) {
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const jobs = ref<InstallJobSnapshot[]>([])
const iconUrls = ref<Record<string, string | null>>({})
const instanceNames = ref<Record<string, string>>({})
@@ -250,7 +245,6 @@ export async function useInstallJobNotifications(opts: {
let metadataRequest = 0
let nextJobOrder = 0
const copiedResetTimeouts = new Map<string, number>()
const progressSnapshots = new Map<string, ProgressSnapshot>()
function getTitle(job: InstallJobSnapshot): string {
if (job.display?.title) return job.display.title
@@ -421,116 +415,14 @@ export async function useInstallJobNotifications(opts: {
return job.status === 'failed' || job.status === 'interrupted'
}
function canShowStalledProgressDetails(job: InstallJobSnapshot): boolean {
return (
job.status === 'running' &&
job.phase !== 'preparing_instance' &&
job.phase !== 'finalizing' &&
job.phase !== 'rolling_back'
)
}
function getJobSortRank(job: InstallJobSnapshot): number {
if (isTerminalJob(job)) return 0
if (job.status === 'queued' || job.phase === 'preparing_instance') return 2
return 1
}
function progressSignature(job: InstallJobSnapshot): string {
const progress = job.progress
const secondary = progress?.secondary
return [
job.status,
job.phase,
JSON.stringify(job.details),
progress?.current ?? '',
progress?.total ?? '',
secondary?.current ?? '',
secondary?.total ?? '',
].join(':')
}
function clearCopied(jobId: string) {
if (!copiedJobIds.value.has(jobId)) {
return
}
const timeout = copiedResetTimeouts.get(jobId)
if (timeout != null) {
window.clearTimeout(timeout)
copiedResetTimeouts.delete(jobId)
}
const nextCopiedJobIds = new Set(copiedJobIds.value)
nextCopiedJobIds.delete(jobId)
copiedJobIds.value = nextCopiedJobIds
}
function clearProgressSnapshot(jobId: string) {
const snapshot = progressSnapshots.get(jobId)
if (snapshot?.timeout != null) {
window.clearTimeout(snapshot.timeout)
}
progressSnapshots.delete(jobId)
}
function scheduleStaleProgressRefresh(jobId: string) {
const snapshot = progressSnapshots.get(jobId)
if (!snapshot) {
return
}
snapshot.timeout = window.setTimeout(() => {
const snapshot = progressSnapshots.get(jobId)
if (!snapshot) {
return
}
snapshot.timeout = null
opts.onChange()
}, copyDetailsStallMs)
}
function syncProgressSnapshots(nextJobs: InstallJobSnapshot[]) {
const trackedJobIds = new Set<string>()
const now = Date.now()
for (const job of nextJobs) {
if (!canShowStalledProgressDetails(job)) {
continue
}
trackedJobIds.add(job.job_id)
const signature = progressSignature(job)
const snapshot = progressSnapshots.get(job.job_id)
if (snapshot?.signature === signature) {
continue
}
clearProgressSnapshot(job.job_id)
clearCopied(job.job_id)
progressSnapshots.set(job.job_id, {
signature,
changedAt: now,
timeout: null,
})
scheduleStaleProgressRefresh(job.job_id)
}
for (const jobId of progressSnapshots.keys()) {
if (!trackedJobIds.has(jobId)) {
clearProgressSnapshot(jobId)
}
}
}
function hasStalledProgress(job: InstallJobSnapshot): boolean {
const snapshot = progressSnapshots.get(job.job_id)
return !!snapshot && Date.now() - snapshot.changedAt >= copyDetailsStallMs
}
function shouldShowCopyDetails(job: InstallJobSnapshot): boolean {
return isTerminalJob(job) || (canShowStalledProgressDetails(job) && hasStalledProgress(job))
return isTerminalJob(job) || themeStore.getFeatureFlag('always_show_copy_details')
}
function isCopied(job: InstallJobSnapshot): boolean {
@@ -607,6 +499,16 @@ export async function useInstallJobNotifications(opts: {
return buttons
}
function getDismissHandler(job: InstallJobSnapshot): (() => Promise<void>) | undefined {
if (isTerminalJob(job)) {
return async () => {
await install_job_dismiss(job.job_id).catch(opts.handleError)
await refresh()
}
}
return undefined
}
function setJobs(nextJobs: InstallJobSnapshot[]) {
for (const job of nextJobs) {
if (!jobOrder.has(job.job_id)) {
@@ -615,7 +517,6 @@ export async function useInstallJobNotifications(opts: {
}
const visibleJobs = nextJobs.filter((job) => visibleJobStatuses.has(job.status))
syncProgressSnapshots(visibleJobs)
jobs.value = visibleJobs.sort(
(a, b) =>
@@ -642,12 +543,8 @@ export async function useInstallJobNotifications(opts: {
progressCurrent: isTerminalJob(job) ? undefined : progress?.current,
progressTotal: isTerminalJob(job) ? undefined : progress?.total,
buttons: getButtons(job),
onDismiss: isTerminalJob(job)
? async () => {
await install_job_dismiss(job.job_id).catch(opts.handleError)
await refresh()
}
: undefined,
dismissible: isTerminalJob(job),
onDismiss: getDismissHandler(job),
}
}),
)
@@ -730,8 +627,8 @@ export async function useInstallJobNotifications(opts: {
void refreshMetadata()
}
await refresh(false)
const unlisten = await install_job_listener((job: InstallJobSnapshot) => applyJobUpdate(job))
await refresh(false)
return {
active: computed(() => jobs.value.length > 0),
@@ -743,9 +640,6 @@ export async function useInstallJobNotifications(opts: {
for (const timeout of copiedResetTimeouts.values()) {
window.clearTimeout(timeout)
}
for (const jobId of progressSnapshots.keys()) {
clearProgressSnapshot(jobId)
}
unlisten()
},
}
+1 -1
View File
@@ -189,7 +189,7 @@ type AppSettings = {
max_concurrent_downloads: number
max_concurrent_writes: number
theme: 'dark' | 'light' | 'oled'
theme: 'dark' | 'light' | 'oled' | 'retro' | 'system'
default_page: 'Home' | 'Library'
collapsed_navigation: boolean
advanced_rendering: boolean
+51 -6
View File
@@ -1,11 +1,56 @@
import type { Labrinth } from '@modrinth/api-client'
import { invoke } from '@tauri-apps/api/core'
export type SearchUser = {
id: string
username: string
avatar_url: string | null
// Converts user profile links from rendered Markdown/any dynamic content into app routes.
export function parse_modrinth_user_link(href: string): string | null {
try {
const url = new URL(href)
if (url.hostname !== 'modrinth.com' && url.hostname !== 'www.modrinth.com') return null
const segments = url.pathname.split('/').filter(Boolean)
if (segments[0]?.toLowerCase() !== 'user' || !segments[1] || segments.length > 3) return null
const path = `/user/${encodeURIComponent(decodeURIComponent(segments[1]))}`
return segments[2] ? `${path}/${encodeURIComponent(decodeURIComponent(segments[2]))}` : path
} catch {
return null
}
}
export async function search_user(query: string): Promise<SearchUser[]> {
return await invoke<SearchUser[]>('plugin:users|search_user', { query })
export async function search_user(query: string): Promise<Labrinth.Users.v3.SearchUser[]> {
return await invoke<Labrinth.Users.v3.SearchUser[]>('plugin:users|search_user', { query })
}
export async function get_user_profile(userId: string): Promise<Labrinth.Users.v3.User> {
return await invoke<Labrinth.Users.v3.User>('plugin:users|get_user_profile', { userId })
}
export async function get_user_projects(userId: string): Promise<Labrinth.Projects.v2.Project[]> {
return await invoke<Labrinth.Projects.v2.Project[]>('plugin:users|get_user_projects', {
userId,
})
}
export async function get_user_organizations(
userId: string,
): Promise<Labrinth.Organizations.v3.Organization[]> {
return await invoke<Labrinth.Organizations.v3.Organization[]>(
'plugin:users|get_user_organizations',
{ userId },
)
}
export async function get_user_collections(
userId: string,
): Promise<Labrinth.Collections.Collection[]> {
return await invoke<Labrinth.Collections.Collection[]>('plugin:users|get_user_collections', {
userId,
})
}
export async function patch_user(
userId: string,
patch: Partial<Pick<Labrinth.Users.v3.User, 'badges' | 'role'>>,
): Promise<void> {
await invoke('plugin:users|patch_user', { userId, patch })
}
@@ -731,6 +731,12 @@
"app.project.versions.already-installed": {
"message": "Already installed"
},
"app.resource-management-settings.always-show-copy-details.description": {
"message": "Show the Copy details action while an install is queued or running. It is always available for failed or interrupted installs."
},
"app.resource-management-settings.always-show-copy-details.title": {
"message": "Always show copy details"
},
"app.settings.developer-mode-enabled": {
"message": "Developer mode enabled."
},
+4 -2
View File
@@ -975,8 +975,10 @@ const searchState = useBrowseSearch({
watch(
[
() => searchState.query.value,
() => searchState.currentFilters.value,
() => searchState.serverCurrentFilters.value,
() =>
searchState.isServerType.value
? searchState.serverCurrentFilters.value
: searchState.currentFilters.value,
() => projectType.value,
],
() => {
+108
View File
@@ -0,0 +1,108 @@
<template>
<div class="w-full pt-2">
<UserProfilePageLayout
:user-id="userId"
:project-type="projectType"
variant="app"
site-url="https://modrinth.com"
project-link-mode="app"
external-navigation
/>
</div>
</template>
<script setup lang="ts">
import { provideUserProfile, UserProfilePageLayout } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, watch } from 'vue'
import { onBeforeRouteUpdate, useRoute } from 'vue-router'
import {
get_user_collections,
get_user_organizations,
get_user_profile,
get_user_projects,
patch_user,
} from '@/helpers/users'
import { useBreadcrumbs } from '@/store/breadcrumbs'
const route = useRoute()
const queryClient = useQueryClient()
const breadcrumbs = useBreadcrumbs()
const userProfile = provideUserProfile({
getUser: get_user_profile,
getProjects: get_user_projects,
getOrganizations: get_user_organizations,
getCollections: get_user_collections,
patchUser: patch_user,
})
const userId = computed(() => {
const value = route.params.user
return Array.isArray(value) ? (value[0] ?? '') : (value ?? '')
})
const projectType = computed(() => {
const value = route.params.projectType
return Array.isArray(value) ? value[0] : value
})
async function ensureUserProfileData(id: string): Promise<void> {
if (!id) return
let breadcrumbName = id
try {
const user = await queryClient.ensureQueryData({
queryKey: ['user', id],
queryFn: () => userProfile.getUser(id),
staleTime: 30_000,
})
breadcrumbName = user.username
} catch {
// Let the mounted layout's useQuery surface errors; do not fail route setup.
}
await Promise.allSettled([
queryClient.ensureQueryData({
queryKey: ['user', id, 'projects'],
queryFn: () => userProfile.getProjects(id),
staleTime: 30_000,
}),
queryClient.ensureQueryData({
queryKey: ['user', id, 'organizations'],
queryFn: () => userProfile.getOrganizations(id),
staleTime: 30_000,
}),
queryClient.ensureQueryData({
queryKey: ['user', id, 'collections'],
queryFn: () => userProfile.getCollections(id),
staleTime: 30_000,
}),
])
breadcrumbs.setName('User', breadcrumbName)
}
onBeforeRouteUpdate(async (to) => {
const value = to.params.user
const id = Array.isArray(value) ? (value[0] ?? '') : (value ?? '')
await ensureUserProfileData(id)
})
breadcrumbs.setName('User', userId.value)
await ensureUserProfileData(userId.value)
const { data: user } = useQuery({
queryKey: computed(() => ['user', userId.value]),
queryFn: () => userProfile.getUser(userId.value),
enabled: false,
staleTime: 30_000,
})
watch(
[userId, user],
([currentUserId, value]) => {
breadcrumbs.setName('User', value?.username ?? currentUserId)
},
{ immediate: true },
)
</script>
@@ -5,7 +5,6 @@ import {
ServersManageAccessPage,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
const client = injectModrinthClient()
const { serverId } = injectModrinthServerContext()
@@ -29,7 +28,7 @@ try {
}
function userProfileLink(username: string) {
return () => openUrl(`https://modrinth.com/user/${encodeURIComponent(username)}`)
return `/user/${encodeURIComponent(username)}`
}
</script>
+2 -1
View File
@@ -2,6 +2,7 @@ import Browse from './Browse.vue'
import Index from './Index.vue'
import Servers from './Servers.vue'
import Skins from './Skins.vue'
import User from './User.vue'
import Worlds from './Worlds.vue'
export { Browse, Index, Servers, Skins, Worlds }
export { Browse, Index, Servers, Skins, User, Worlds }
@@ -198,6 +198,13 @@ const messages = defineMessages({
let savedModalState: ModpackContentModalState | null = null
function contentOwnerLink(owner: ContentOwner): NonNullable<ContentOwner['link']> {
if (owner.type === 'user') return `/user/${encodeURIComponent(owner.id)}`
return () => {
void openUrl(`https://modrinth.com/organization/${owner.id}`)
}
}
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const { installingItems, installRevisionByInstance, installFailureRevisionByInstance } =
@@ -1390,10 +1397,7 @@ provideContentManager({
owner: linkedModpackOwner.value
? {
...linkedModpackOwner.value,
link: () =>
openUrl(
`https://modrinth.com/${linkedModpackOwner.value!.type}/${linkedModpackOwner.value!.id}`,
),
link: contentOwnerLink(linkedModpackOwner.value),
}
: undefined,
categories: linkedModpackCategories.value,
@@ -1491,7 +1495,7 @@ provideContentManager({
owner: item.owner
? {
...item.owner,
link: () => openUrl(`https://modrinth.com/${item.owner!.type}/${item.owner!.id}`),
link: contentOwnerLink(item.owner),
}
: undefined,
enabled: canMutateContent(item) ? item.enabled : undefined,
@@ -157,7 +157,6 @@ import {
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref, toRef, watch } from 'vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
@@ -417,9 +416,7 @@ function removeMember(row: ShareRow) {
members.remove(row.id)
}
function userProfileLink(username: string) {
return !username || username.includes('@')
? undefined
: () => openUrl(`https://modrinth.com/user/${encodeURIComponent(username)}`)
return !username || username.includes('@') ? undefined : `/user/${encodeURIComponent(username)}`
}
async function requestAuth(flow: ModrinthAuthFlow) {
await auth.requestSignIn(`/instance/${encodeURIComponent(props.instance.id)}/share`, flow, {
@@ -159,7 +159,6 @@ import {
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref, watch } from 'vue'
import {
@@ -310,9 +309,7 @@ const messages = defineMessages({
},
})
function userProfileLink(username: string) {
return !username || username.includes('@')
? undefined
: () => openUrl(`https://modrinth.com/user/${encodeURIComponent(username)}`)
return !username || username.includes('@') ? undefined : `/user/${encodeURIComponent(username)}`
}
function setUsernameRef(id: string, element: Element | null) {
usernameRefs.value[id] = element instanceof HTMLElement ? element : null
@@ -31,8 +31,9 @@
:organization="organization"
:members="members"
:org-link="(slug) => `https://modrinth.com/organization/${slug}`"
:user-link="(username) => `https://modrinth.com/user/${username}`"
:user-link="(username) => `/user/${encodeURIComponent(username)}`"
link-target="_blank"
:user-link-target="null"
class="project-sidebar-section"
/>
<ProjectSidebarDetails
@@ -475,7 +475,7 @@ export function createContentInstall(opts: {
name: owner.user.username,
iconUrl: owner.user.avatar_url,
circle: true,
link: () => openUrl(`https://modrinth.com/user/${owner.user.username}`),
link: `/user/${encodeURIComponent(owner.user.username)}`,
},
}
}
@@ -108,7 +108,7 @@ function getQueuedInstallOwnerFallback(project: InstallableSearchResult) {
id: ownerId,
name: project.author,
type: 'user' as const,
link: `https://modrinth.com/user/${ownerId}`,
link: `/user/${encodeURIComponent(ownerId)}`,
}
}
@@ -144,7 +144,7 @@ async function getQueuedInstallOwner(
name: owner.username,
type: 'user' as const,
avatar_url: owner.avatar_url,
link: `https://modrinth.com/user/${owner.username}`,
link: `/user/${encodeURIComponent(owner.username)}`,
}
}
} catch {
+8
View File
@@ -100,6 +100,14 @@ export default new createRouter({
breadcrumb: [{ name: 'Skin selector' }],
},
},
{
path: '/user/:user/:projectType?',
name: 'User',
component: Pages.User,
meta: {
breadcrumb: [{ name: '?User' }],
},
},
{
path: '/library',
name: 'Library',
+2 -1
View File
@@ -17,9 +17,10 @@ export const DEFAULT_FEATURE_FLAGS = {
i18n_debug: false,
show_instance_play_time: true,
advanced_filters_collapsed: true,
always_show_copy_details: false,
}
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'retro', 'system'] as const
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
export type FeatureFlags = Record<FeatureFlag, boolean>
+8 -1
View File
@@ -278,7 +278,14 @@ fn main() {
.plugin(
"users",
InlinedPlugin::new()
.commands(&["search_user"])
.commands(&[
"search_user",
"get_user_profile",
"get_user_projects",
"get_user_organizations",
"get_user_collections",
"patch_user",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
+34 -1
View File
@@ -1,4 +1,5 @@
use crate::api::Result;
use serde_json::Value;
use theseus::users::SearchUser;
#[tauri::command]
@@ -6,8 +7,40 @@ pub async fn search_user(query: &str) -> Result<Vec<SearchUser>> {
Ok(theseus::users::search_user(query).await?)
}
#[tauri::command]
pub async fn get_user_profile(user_id: &str) -> Result<Value> {
Ok(theseus::users::get_user_profile(user_id).await?)
}
#[tauri::command]
pub async fn get_user_projects(user_id: &str) -> Result<Value> {
Ok(theseus::users::get_user_projects(user_id).await?)
}
#[tauri::command]
pub async fn get_user_organizations(user_id: &str) -> Result<Value> {
Ok(theseus::users::get_user_organizations(user_id).await?)
}
#[tauri::command]
pub async fn get_user_collections(user_id: &str) -> Result<Value> {
Ok(theseus::users::get_user_collections(user_id).await?)
}
#[tauri::command]
pub async fn patch_user(user_id: &str, patch: Value) -> Result<()> {
Ok(theseus::users::patch_user(user_id, patch).await?)
}
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("users")
.invoke_handler(tauri::generate_handler![search_user])
.invoke_handler(tauri::generate_handler![
search_user,
get_user_profile,
get_user_projects,
get_user_organizations,
get_user_collections,
patch_user,
])
.build()
}
+7 -3
View File
@@ -280,15 +280,19 @@ html {
}
.retro-mode {
--color-bg: #191917;
--color-raised-bg: #1d1e1b;
--surface-1: #191917;
--surface-2: rgb(22, 22, 21);
--surface-3: #232421;
--surface-4: #3a3b38;
--surface-5: #5a5c58;
--color-button-bg: #3a3b38;
--color-base: #c3c4b3;
--color-secondary: #777a74;
--color-secondary: #9b9e98;
--color-contrast: #e6e2d1;
--color-brand: #4d9227;
--color-brand-highlight: #25421e;
--color-accent-contrast: #ffffff;
--color-ad: var(--color-brand-highlight);
--color-ad-raised: var(--color-brand);
--color-ad-contrast: black;
@@ -590,6 +590,7 @@ onBeforeUnmount(() => {
})
</script>
<template><div></div></template>
<style>
html.modrinth-cmp-summary-hidden .qc-cmp2-container,
html.modrinth-cmp-summary-hidden #qc-cmp2-container,
@@ -0,0 +1,165 @@
<template>
<div class="rounded-2xl border border-solid border-surface-4 bg-surface-3">
<div class="flex flex-col gap-2 border-0 border-b border-solid border-surface-4 p-4">
<div class="flex flex-wrap justify-between gap-3">
<div class="flex items-center gap-3">
<Avatar :src="authorization.app.icon_url" size="48px" />
<div class="flex flex-col gap-0.5">
<h2 class="m-0 flex items-center gap-1 text-base font-semibold">
{{ authorization.app.name }}
<BadgeCheckIcon
v-if="isOfficial"
v-tooltip="formatMessage(messages.officialTooltip)"
class="size-5 text-green"
fill="currentColor"
fill-opacity="0.125"
/>
</h2>
<PageHeaderMetadata>
<PageHeaderMetadataItem>
<IntlFormatted :message-id="messages.byLabel">
<template #~user>
<nuxt-link
:to="'/user/' + authorization.owner.id"
class="flex items-center gap-1 hover:underline"
target="_blank"
>
<Avatar :src="authorization.owner.avatar_url" size="24px" circle />
{{ authorization.owner.username }}
</nuxt-link>
</template>
</IntlFormatted>
</PageHeaderMetadataItem>
<PageHeaderMetadataItem v-if="authorization.app.url">
<a class="text-link" :to="authorization.app.url" target="_blank">
{{ authorization.app.url }}
</a>
</PageHeaderMetadataItem>
</PageHeaderMetadata>
</div>
</div>
<div>
<ButtonStyled color="red">
<button @click="emit('revoke', authorization.app_id)">
<XCircleIcon />
{{ formatMessage(messages.revokeAction) }}
</button>
</ButtonStyled>
</div>
</div>
<div v-if="showUnofficialDisclosure" class="flex items-center gap-1 text-orange">
<IssuesIcon />
{{ formatMessage(messages.unofficialDisclosure) }}
</div>
</div>
<div class="rounded-b-2xl bg-surface-2 p-4">
<template v-if="authorization.app.description">
<label :for="descriptionId" class="mb-1 flex font-semibold text-contrast">
{{ formatMessage(messages.aboutThisAppLabel) }}
</label>
<div :id="descriptionId" class="mb-4">{{ authorization.app.description }}</div>
</template>
<label :for="scopeListId" class="mb-1 flex font-semibold text-contrast">
{{ formatMessage(commonMessages.permissionsLabel) }}
</label>
<div :id="scopeListId" class="grid gap-1 sm:grid-cols-2">
<div
v-for="scope in scopesToDefinitions(BigInt(authorization.scopes || 0))"
:key="scope"
class="flex items-center gap-1"
>
<CheckIcon class="size-5 shrink-0 text-green" />
{{ scope }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { BadgeCheckIcon, CheckIcon, IssuesIcon, XCircleIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
PageHeaderMetadata,
PageHeaderMetadataItem,
useVIntl,
} from '@modrinth/ui'
import { isOfficialAccount } from '@modrinth/utils'
import { useScopes } from '~/composables/auth/scopes.ts'
export type AuthorizationCardData = Labrinth.OAuth.Internal.OAuthClientAuthorization & {
app: Labrinth.OAuth.Internal.OAuthClient
owner: Labrinth.Users.v2.User
}
const props = defineProps<{
authorization: AuthorizationCardData
}>()
const emit = defineEmits<{
revoke: [appId: string]
}>()
const { formatMessage } = useVIntl()
const { scopesToDefinitions } = useScopes()
const descriptionId = computed(() => `app-description-${props.authorization.id}`)
const scopeListId = computed(() => `app-scope-list-${props.authorization.id}`)
const unofficialTriggerWords = [
'modrinth',
'mod rinth',
'rnodrinth',
'rinth',
'modrith',
'official',
'verified',
'verification',
]
const showUnofficialDisclosure = computed(() => {
return (
(unofficialTriggerWords.some((word) =>
props.authorization.app.name.toLowerCase().includes(word),
) ||
unofficialTriggerWords.some((word) =>
props.authorization.owner.username?.toLowerCase().includes(word),
)) &&
!isOfficial.value
)
})
const isOfficial = computed(() => {
return isOfficialAccount(props.authorization.owner.id)
})
const messages = defineMessages({
revokeAction: {
id: 'settings.authorizations.revoke.action',
defaultMessage: 'Revoke',
},
byLabel: {
id: 'settings.authorizations.created-by',
defaultMessage: 'Created by {user}',
},
aboutThisAppLabel: {
id: 'settings.authorizations.about-this-app',
defaultMessage: 'About this app',
},
unofficialDisclosure: {
id: 'settings.authorizations.unofficial-disclosure',
defaultMessage: 'This app is not affiliated with Modrinth in any way, despite its name.',
},
officialTooltip: {
id: 'settings.authorizations.official-tooltip',
defaultMessage: 'This app is created by an official Modrinth account.',
},
})
</script>
@@ -1838,9 +1838,6 @@
"muralpay.placeholder.cuit-cuil": {
"message": "أدخل CUIT أو CUIL"
},
"profile.user-id": {
"message": "معرّف المستخدم: {id}"
},
"project-member-header.success-decline": {
"message": "لقد رفضت دعوة الفريق"
},
-108
View File
@@ -2105,111 +2105,6 @@
"muralpay.placeholder.account-number": {
"message": "Zadejte číslo účtu"
},
"profile.bio.fallback.creator": {
"message": "Modrinth tvůrce."
},
"profile.bio.fallback.user": {
"message": "Modrinth uživatel."
},
"profile.button.analytics": {
"message": "Zobrazit analytika uživatele"
},
"profile.button.billing": {
"message": "Spravujte fakturaci uživatele"
},
"profile.button.edit-role": {
"message": "Upravit roli"
},
"profile.button.info": {
"message": "Podívejte se na detaily uživatele"
},
"profile.button.manage-projects": {
"message": "Spravujte projekty"
},
"profile.button.remove-affiliate": {
"message": "Odebrat jako partnera"
},
"profile.button.set-affiliate": {
"message": "Nastavit jako partnera"
},
"profile.collection.projects-count": {
"message": "{count, plural, one {# projekt} few {# projekty} other {# projektů}}"
},
"profile.details.label.auth-providers": {
"message": "Poskytovatelé přihlášení"
},
"profile.details.label.email": {
"message": "E-mail"
},
"profile.details.label.email-verified": {
"message": "E-mail ověřen"
},
"profile.details.label.has-password": {
"message": "Má heslo"
},
"profile.details.label.has-totp": {
"message": "Má TOTP"
},
"profile.details.label.payment-methods": {
"message": "Platební metody"
},
"profile.details.tooltip.email-not-verified": {
"message": "E-mail není ověřen"
},
"profile.details.tooltip.email-verified": {
"message": "E-mail je ověřen"
},
"profile.error.not-found": {
"message": "Uživatel nenalezen"
},
"profile.label.affiliate": {
"message": "Partner"
},
"profile.label.badges": {
"message": "Odznaky"
},
"profile.label.collection": {
"message": "Kolekce"
},
"profile.label.joined": {
"message": "Členem od"
},
"profile.label.no-collections": {
"message": "Tento uživatel nemá žádné kolekce!"
},
"profile.label.no-collections-auth": {
"message": "Nemáte žádné kolekce.\nChcete nějakou <create-link>vytvořit</create-link>?"
},
"profile.label.no-projects": {
"message": "Tento uživatel nemá žádné projekty!"
},
"profile.label.no-projects-auth": {
"message": "Nemáte žádné projekty.\nChcete nějaký <create-link>vytvořit</create-link>?"
},
"profile.label.organizations": {
"message": "Organizace"
},
"profile.label.saving": {
"message": "Ukládání..."
},
"profile.meta.description": {
"message": "Stáhněte si projekty od {username} na Modrinthu"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Stáhněte si projekty od {username} na Modrinthu"
},
"profile.official-account": {
"message": "Oficiální Modrinth účet"
},
"profile.official-account.bio": {
"message": "Oficiální uživatelský účet Modrinthu. Podporu získáte na <support-link></support-link> nebo e-mailem na <email></email>"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> sledující projektu} few {<stat>{count}</stat> sledující projektu} other {<stat>{count}</stat> sledujících projektu}}"
},
"profile.user-id": {
"message": "ID uživatele: {id}"
},
"project-member-header.error-decline": {
"message": "Nepodařilo se zamítnout pozvánku do týmu"
},
@@ -2936,9 +2831,6 @@
"settings.authorizations.about-this-app": {
"message": "O této aplikaci"
},
"settings.authorizations.by": {
"message": "od"
},
"settings.authorizations.head-title": {
"message": "Autorizace"
},
@@ -2180,87 +2180,6 @@
"organization.projects.none-with-create-prompt": {
"message": "Denne organisation har ikke nogle projekter endnu. Kunnde dig tænke dig at <create-link>oprette et</create-link>?"
},
"profile.bio.fallback.creator": {
"message": "En Modrinth skaber."
},
"profile.bio.fallback.user": {
"message": "En Modrinth bruger."
},
"profile.button.edit-role": {
"message": "Rediger rolle"
},
"profile.button.info": {
"message": "Vis bruger detaljer"
},
"profile.button.manage-projects": {
"message": "Administrer projekter"
},
"profile.details.label.email": {
"message": "Email"
},
"profile.details.label.email-verified": {
"message": "Email bekræftet"
},
"profile.details.label.has-password": {
"message": "Har adgangskode"
},
"profile.details.label.has-totp": {
"message": "Har TOTP"
},
"profile.details.label.payment-methods": {
"message": "Betalingsmetoder"
},
"profile.details.tooltip.email-not-verified": {
"message": "Email ikke bekræftet"
},
"profile.details.tooltip.email-verified": {
"message": "Email bekræftet"
},
"profile.error.not-found": {
"message": "Bruger ikke fundet"
},
"profile.label.badges": {
"message": "Emblemer"
},
"profile.label.collection": {
"message": "Samling"
},
"profile.label.download-count": {
"message": "{count, plural, one {download} other {downloads}}"
},
"profile.label.joined": {
"message": "Tilsluttet"
},
"profile.label.no-collections": {
"message": "Denne bruger har ingen samlinger!"
},
"profile.label.no-collections-auth": {
"message": "Du har ingen samlinger.\nKunne du tænke dig at <create-link>oprette en</create-link>?"
},
"profile.label.no-projects": {
"message": "Denne bruger har ingen projekter!"
},
"profile.label.no-projects-auth": {
"message": "Du har ingen projekter.\nKunne du tænke dig at <create-link>oprette en</create-link>?"
},
"profile.label.organizations": {
"message": "Organisationer"
},
"profile.label.saving": {
"message": "Gemmer..."
},
"profile.meta.description": {
"message": "Download {username}'s projekter på Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Download {username}'s projekter på Modrinth"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> projekt følger} other {<stat>{count}</stat> projekt følgere}}"
},
"profile.user-id": {
"message": "Bruger-ID: {id}"
},
"project-member-header.error-decline": {
"message": "Afvisning af hold invitation fejlede"
},
@@ -3080,9 +2999,6 @@
"settings.authorizations.about-this-app": {
"message": "Omkring denne app"
},
"settings.authorizations.by": {
"message": "af"
},
"settings.billing.charges.product.midas": {
"message": "Modrinth Plus"
},
-117
View File
@@ -3320,117 +3320,6 @@
"organization.projects.none-with-create-prompt": {
"message": "Diese Organisation hat noch keine Projekte. Möchtest du <create-link>eins erstellen</create-link>?"
},
"profile.bio.fallback.creator": {
"message": "Ein Modrinth Ersteller."
},
"profile.bio.fallback.user": {
"message": "Ein Modrinth Benutzer."
},
"profile.button.analytics": {
"message": "Nutzungsstatistik anzeigen"
},
"profile.button.billing": {
"message": "Nutzer Zahlungen verwalten"
},
"profile.button.edit-role": {
"message": "Rolle bearbeiten"
},
"profile.button.info": {
"message": "Nutzerdetails ansehen"
},
"profile.button.manage-projects": {
"message": "Projekte verwalten"
},
"profile.button.remove-affiliate": {
"message": "Als Partner entfernen"
},
"profile.button.set-affiliate": {
"message": "Als Partner setzen"
},
"profile.collection.projects-count": {
"message": "{count, plural, one {# Projekt} other {# Projekte}}"
},
"profile.details.label.auth-providers": {
"message": "Authentifizierungsanbieter"
},
"profile.details.label.email": {
"message": "E-Mail"
},
"profile.details.label.email-verified": {
"message": "E-Mail verifiziert"
},
"profile.details.label.has-password": {
"message": "Hat Passwort"
},
"profile.details.label.has-totp": {
"message": "Hat TOTP"
},
"profile.details.label.payment-methods": {
"message": "Zahlungsmethoden"
},
"profile.details.tooltip.email-not-verified": {
"message": "E-Mail nicht verifiziert"
},
"profile.details.tooltip.email-verified": {
"message": "E-Mail verifiziert"
},
"profile.error.not-found": {
"message": "Benutzer nicht gefunden"
},
"profile.label.affiliate": {
"message": "Partner"
},
"profile.label.badges": {
"message": "Abzeichen"
},
"profile.label.collection": {
"message": "Kollektion"
},
"profile.label.download-count": {
"message": "{count, plural, one {Download} other {Downloads}}"
},
"profile.label.joined": {
"message": "Beigetreten"
},
"profile.label.no-collections": {
"message": "Dieser Nutzer hat keine Kollektionen!"
},
"profile.label.no-collections-auth": {
"message": "Du hast keine Kollektionen.\nMöchtest du gerne <create-link>eine erstellen</create-link>?"
},
"profile.label.no-projects": {
"message": "Dieser Nutzer hat keine Projekte!"
},
"profile.label.no-projects-auth": {
"message": "Du hast keine Projekte.\nMöchtest du gerne <create-link>eines erstellen</create-link>?"
},
"profile.label.organizations": {
"message": "Organisationen"
},
"profile.label.project-count": {
"message": "{count, plural, one {Projekt} other {Projekte}}"
},
"profile.label.saving": {
"message": "Speichert..."
},
"profile.meta.description": {
"message": "Lade {username}'s Projekte auf Modrinth herunter"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Lade {username}'s Projekte auf Modrinth herunter"
},
"profile.official-account": {
"message": "Offizielles Modrinth-Konto"
},
"profile.official-account.bio": {
"message": "Das offizielle Benutzerkonto von Modrinth. Erhalte Support unter <support-link></support-link> oder per E-Mail an <email></email>"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat>} other {<stat>{count}</stat>}} Projekt-Follower"
},
"profile.user-id": {
"message": "Benutzer ID: {id}"
},
"project-member-header.error-decline": {
"message": "Ablehnen der Team-Einladung fehlgeschlagen"
},
@@ -4883,15 +4772,9 @@
"settings.authorizations.about-this-app": {
"message": "Über diese Anwendung"
},
"settings.authorizations.by": {
"message": "von"
},
"settings.authorizations.description": {
"message": "Wenn du eine Anwendung mit deinem Modrinth-Konto autorisierst, gewährst du dieser Zugriff auf dein Konto. Du kannst den Zugriff auf dein Konto hier jederzeit verwalten und überprüfen."
},
"settings.authorizations.empty-state": {
"message": "Wir konnen aktuell deine autorisierten Anwendungen nicht anzeigen und arbeiten an einer lösung dafür. Bitte besuche diese Seite zu einem späteren Zeitpunkt erneut!"
},
"settings.authorizations.head-title": {
"message": "Autorisierungen"
},
-117
View File
@@ -3320,117 +3320,6 @@
"organization.projects.none-with-create-prompt": {
"message": "Diese Organisation hat noch keine Projekte. Möchtest du <create-link>eins erstellen</create-link>?"
},
"profile.bio.fallback.creator": {
"message": "Ein Modrinth-Ersteller."
},
"profile.bio.fallback.user": {
"message": "Ein Modrinth-Benutzer."
},
"profile.button.analytics": {
"message": "Benutzeranalysen anzeigen"
},
"profile.button.billing": {
"message": "Benutzerabrechnungen verwalten"
},
"profile.button.edit-role": {
"message": "Rolle bearbeiten"
},
"profile.button.info": {
"message": "Nutzerdetails ansehen"
},
"profile.button.manage-projects": {
"message": "Projekte verwalten"
},
"profile.button.remove-affiliate": {
"message": "Als Partner entfernen"
},
"profile.button.set-affiliate": {
"message": "Als Partner setzen"
},
"profile.collection.projects-count": {
"message": "{count, plural, one {# Projekt} other {# Projekte}}"
},
"profile.details.label.auth-providers": {
"message": "Authentifizierungsanbieter"
},
"profile.details.label.email": {
"message": "E-Mail"
},
"profile.details.label.email-verified": {
"message": "E-Mail verifiziert"
},
"profile.details.label.has-password": {
"message": "Hat Passwort"
},
"profile.details.label.has-totp": {
"message": "Hat TOTP"
},
"profile.details.label.payment-methods": {
"message": "Zahlungsmethoden"
},
"profile.details.tooltip.email-not-verified": {
"message": "E-Mail nicht verifiziert"
},
"profile.details.tooltip.email-verified": {
"message": "E-Mail verifiziert"
},
"profile.error.not-found": {
"message": "Benutzer nicht gefunden"
},
"profile.label.affiliate": {
"message": "Partner"
},
"profile.label.badges": {
"message": "Abzeichen"
},
"profile.label.collection": {
"message": "Kollektion"
},
"profile.label.download-count": {
"message": "{count, plural, one {Download} other {Downloads}}"
},
"profile.label.joined": {
"message": "Beigetreten"
},
"profile.label.no-collections": {
"message": "Dieser Nutzer hat keine Kollektionen!"
},
"profile.label.no-collections-auth": {
"message": "Du hast keine Kollektionen.\nMöchtest du eine <create-link>erstellen</create-link>?"
},
"profile.label.no-projects": {
"message": "Dieser Nutzer hat keine Projekte!"
},
"profile.label.no-projects-auth": {
"message": "Du hast keine Projekte.\nMöchtest du eins <create-link>erstellen</create-link>?"
},
"profile.label.organizations": {
"message": "Organisationen"
},
"profile.label.project-count": {
"message": "{count, plural, one {Projekt} other {Projekte}}"
},
"profile.label.saving": {
"message": "Speichert..."
},
"profile.meta.description": {
"message": "Lade Projekte von {username} auf Modrinth herunter"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Lade Projekte von {username} auf Modrinth herunter"
},
"profile.official-account": {
"message": "Offizielles Konto von Modrinth"
},
"profile.official-account.bio": {
"message": "Das offizielle Benutzerkonto von Modrinth. Erhalte Hilfe unter <support-link></support-link> oder per E-Mail unter <email></email>"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat>} other {<stat>{count}</stat>}} Projekt-Follower"
},
"profile.user-id": {
"message": "Benutzer ID: {id}"
},
"project-member-header.error-decline": {
"message": "Teameinladung konnte nicht abgelehnt werden"
},
@@ -4883,15 +4772,9 @@
"settings.authorizations.about-this-app": {
"message": "Über diese Anwendung"
},
"settings.authorizations.by": {
"message": "von"
},
"settings.authorizations.description": {
"message": "Wenn du eine Anwendung mit deinem Modrinth-Konto autorisierst, gewährst du dieser Zugriff auf dein Konto. Du kannst den Zugriff auf dein Konto hier jederzeit verwalten und überprüfen."
},
"settings.authorizations.empty-state": {
"message": "Wir können deine autorisierten Apps derzeit nicht anzeigen, wir arbeiten daran, das zu beheben. Bitte besuche diese Seite zu einem späteren Zeitpunkt erneut!"
},
"settings.authorizations.head-title": {
"message": "Autorisierungen"
},
+13 -115
View File
@@ -3344,117 +3344,6 @@
"organization.projects.none-with-create-prompt": {
"message": "This organization doesn't have any projects yet. Would you like to <create-link>create one</create-link>?"
},
"profile.bio.fallback.creator": {
"message": "A Modrinth creator."
},
"profile.bio.fallback.user": {
"message": "A Modrinth user."
},
"profile.button.analytics": {
"message": "View user analytics"
},
"profile.button.billing": {
"message": "Manage user billing"
},
"profile.button.edit-role": {
"message": "Edit role"
},
"profile.button.info": {
"message": "View user details"
},
"profile.button.manage-projects": {
"message": "Manage projects"
},
"profile.button.remove-affiliate": {
"message": "Remove as affiliate"
},
"profile.button.set-affiliate": {
"message": "Set as affiliate"
},
"profile.collection.projects-count": {
"message": "{count, plural, one {# project} other {# projects}}"
},
"profile.details.label.auth-providers": {
"message": "Auth providers"
},
"profile.details.label.email": {
"message": "Email"
},
"profile.details.label.email-verified": {
"message": "Email verified"
},
"profile.details.label.has-password": {
"message": "Has password"
},
"profile.details.label.has-totp": {
"message": "Has TOTP"
},
"profile.details.label.payment-methods": {
"message": "Payment methods"
},
"profile.details.tooltip.email-not-verified": {
"message": "Email not verified"
},
"profile.details.tooltip.email-verified": {
"message": "Email verified"
},
"profile.error.not-found": {
"message": "User not found"
},
"profile.label.affiliate": {
"message": "Affiliate"
},
"profile.label.badges": {
"message": "Badges"
},
"profile.label.collection": {
"message": "Collection"
},
"profile.label.download-count": {
"message": "{count, plural, one {download} other {downloads}}"
},
"profile.label.joined": {
"message": "Joined"
},
"profile.label.no-collections": {
"message": "This user has no collections!"
},
"profile.label.no-collections-auth": {
"message": "You don't have any collections.\nWould you like to <create-link>create one</create-link>?"
},
"profile.label.no-projects": {
"message": "This user has no projects!"
},
"profile.label.no-projects-auth": {
"message": "You don't have any projects.\nWould you like to <create-link>create one</create-link>?"
},
"profile.label.organizations": {
"message": "Organizations"
},
"profile.label.project-count": {
"message": "{count, plural, one {project} other {projects}}"
},
"profile.label.saving": {
"message": "Saving..."
},
"profile.meta.description": {
"message": "Download {username}'s projects on Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Download {username}'s projects on Modrinth"
},
"profile.official-account": {
"message": "Official Modrinth account"
},
"profile.official-account.bio": {
"message": "The official user account of Modrinth. Get support at <support-link></support-link> or via email at <email></email>"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> project follower} other {<stat>{count}</stat> project followers}}"
},
"profile.user-id": {
"message": "User ID: {id}"
},
"project-member-header.error-decline": {
"message": "Failed to decline team invitation"
},
@@ -4913,18 +4802,24 @@
"settings.authorizations.about-this-app": {
"message": "About this app"
},
"settings.authorizations.by": {
"message": "by"
"settings.authorizations.created-by": {
"message": "Created by {user}"
},
"settings.authorizations.description": {
"message": "When you authorize an application with your Modrinth account, you grant it access to your account. You can manage and review access to your account here at any time."
},
"settings.authorizations.empty-state": {
"message": "We currently can't display your authorized apps, we're working to fix this. Please visit this page at a later date!"
"settings.authorizations.empty-state.description": {
"message": "Applications you authorize will appear here."
},
"settings.authorizations.empty-state.heading": {
"message": "No authorized apps"
},
"settings.authorizations.head-title": {
"message": "Authorizations"
},
"settings.authorizations.official-tooltip": {
"message": "This app is created by an official Modrinth account."
},
"settings.authorizations.revoke.action": {
"message": "Revoke"
},
@@ -4934,6 +4829,9 @@
"settings.authorizations.revoke.confirm.title": {
"message": "Are you sure you want to revoke this application?"
},
"settings.authorizations.unofficial-disclosure": {
"message": "This app is not affiliated with Modrinth in any way, despite its name."
},
"settings.billing.charges.description": {
"message": "All of your past charges to your Modrinth account will be listed here:"
},
-111
View File
@@ -2996,111 +2996,6 @@
"muralpay.warning.wallet-address": {
"message": "Verifica dos veces la dirección de tu billetera. Los fondos enviados a una dirección incorrecta no se pueden recuperar."
},
"profile.bio.fallback.creator": {
"message": "Un creador de Modrinth."
},
"profile.bio.fallback.user": {
"message": "Un usuario de Modrinth."
},
"profile.button.analytics": {
"message": "Ver estadísticas de usuario"
},
"profile.button.billing": {
"message": "Gestionar la facturación de usuario"
},
"profile.button.edit-role": {
"message": "Editar rol"
},
"profile.button.info": {
"message": "Ver detalles de usuario"
},
"profile.button.manage-projects": {
"message": "Gestionar proyectos"
},
"profile.button.remove-affiliate": {
"message": "Remover como afiliado"
},
"profile.button.set-affiliate": {
"message": "Configurar como afiliado"
},
"profile.collection.projects-count": {
"message": "{count, plural, one {# proyecto} other {# proyectos}}"
},
"profile.details.label.auth-providers": {
"message": "Proveedores de autenticación"
},
"profile.details.label.email": {
"message": "Correo electrónico"
},
"profile.details.label.email-verified": {
"message": "Correo electrónico verificado"
},
"profile.details.label.has-password": {
"message": "Tiene contraseña"
},
"profile.details.label.has-totp": {
"message": "Tiene TOTP"
},
"profile.details.label.payment-methods": {
"message": "Métodos de pago"
},
"profile.details.tooltip.email-not-verified": {
"message": "Correo electrónico no verificado"
},
"profile.details.tooltip.email-verified": {
"message": "Correo electrónico verificado"
},
"profile.error.not-found": {
"message": "Usuario no encontrado"
},
"profile.label.affiliate": {
"message": "Afiliado"
},
"profile.label.badges": {
"message": "Insignias"
},
"profile.label.collection": {
"message": "Colección"
},
"profile.label.joined": {
"message": "Se unió"
},
"profile.label.no-collections": {
"message": "¡Este usuario no tiene colecciones!"
},
"profile.label.no-collections-auth": {
"message": "No tienes ninguna colección.\n¿Te gustaría <create-link>crear una</create-link>?"
},
"profile.label.no-projects": {
"message": "¡Este usuario no tiene proyectos!"
},
"profile.label.no-projects-auth": {
"message": "No tienes ningún proyecto.\n¿Te gustaría <create-link>crear uno</create-link>?"
},
"profile.label.organizations": {
"message": "Organizaciones"
},
"profile.label.saving": {
"message": "Guardando..."
},
"profile.meta.description": {
"message": "Descarga proyectos de {username} en Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Descarga proyectos de {username} en Modrinth"
},
"profile.official-account": {
"message": "Cuenta oficial de Modrinth"
},
"profile.official-account.bio": {
"message": "La cuenta de usuario oficial de Modrinth. Obtén soporte en <support-link></support-link> o vía correo en <email></email>"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> seguidor del proyecto} other {<stat>{count}</stat> seguidores del proyecto}}"
},
"profile.user-id": {
"message": "ID de usuario: {id}"
},
"project-member-header.error-decline": {
"message": "Error al rechazar la invitación del equipo"
},
@@ -4385,15 +4280,9 @@
"settings.authorizations.about-this-app": {
"message": "Acerca de esta aplicación"
},
"settings.authorizations.by": {
"message": "por"
},
"settings.authorizations.description": {
"message": "Cuando autorizas una aplicación con tu cuenta de Modrinth, le concedes acceso a tu cuenta. Puedes gestionar y revisar los accesos a tu cuenta aquí en cualquier momento."
},
"settings.authorizations.empty-state": {
"message": "Actualmente no podemos mostrar tus aplicaciones autorizadas. Estamos trabajando para solucionar esto. ¡Por favor, visita esta página más tarde!"
},
"settings.authorizations.head-title": {
"message": "Autorizaciones"
},
-111
View File
@@ -2822,111 +2822,6 @@
"muralpay.warning.wallet-address": {
"message": "Verifique la dirección de su billetera. Los fondos enviados a una dirección incorrecta no se pueden recuperar."
},
"profile.bio.fallback.creator": {
"message": "Creador en Modrinth."
},
"profile.bio.fallback.user": {
"message": "Usuario de Modrinth."
},
"profile.button.analytics": {
"message": "Ver analíticas de usuario"
},
"profile.button.billing": {
"message": "Gestionar la facturación de los usuarios"
},
"profile.button.edit-role": {
"message": "Editar rol"
},
"profile.button.info": {
"message": "Ver detalles del usuario"
},
"profile.button.manage-projects": {
"message": "Gestionar proyectos"
},
"profile.button.remove-affiliate": {
"message": "Borrar como afiliado"
},
"profile.button.set-affiliate": {
"message": "Establecer como afiliado"
},
"profile.collection.projects-count": {
"message": "{count, plural, one {# proyecto} other {# proyectos}}"
},
"profile.details.label.auth-providers": {
"message": "Autentificar proveedores"
},
"profile.details.label.email": {
"message": "Correo electrónico"
},
"profile.details.label.email-verified": {
"message": "Email verificado"
},
"profile.details.label.has-password": {
"message": "Tiene contraseña"
},
"profile.details.label.has-totp": {
"message": "Tiene autentificación de doble factor"
},
"profile.details.label.payment-methods": {
"message": "Métodos de pago"
},
"profile.details.tooltip.email-not-verified": {
"message": "Correo electrónico no verificado"
},
"profile.details.tooltip.email-verified": {
"message": "Correo electrónico verificado"
},
"profile.error.not-found": {
"message": "Usuario no encontrado"
},
"profile.label.affiliate": {
"message": "Afiliado"
},
"profile.label.badges": {
"message": "Medallas"
},
"profile.label.collection": {
"message": "Colección"
},
"profile.label.joined": {
"message": "Se unió hace"
},
"profile.label.no-collections": {
"message": "¡Este usuario no tiene colecciones!"
},
"profile.label.no-collections-auth": {
"message": "No tienes ninguna colección.\n¿Te gustaría <create-link>crear una</create-link>?"
},
"profile.label.no-projects": {
"message": "¡Este usuario no tiene proyectos!"
},
"profile.label.no-projects-auth": {
"message": "No tienes ningún proyecto.\n¿Te gustaría <create-link>crear uno</create-link>?"
},
"profile.label.organizations": {
"message": "Organizaciones"
},
"profile.label.saving": {
"message": "Guardando..."
},
"profile.meta.description": {
"message": "Descargar los proyectos de {username} en Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Descarga los proyectos en Modrinth de {username}"
},
"profile.official-account": {
"message": "Cuenta oficial de Modrinth"
},
"profile.official-account.bio": {
"message": "La cuenta de usuario oficial de Modrinth. Obtén soporte en <support-link></support-link> o vía correo en <email></email>"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> seguidor} other {<stat>{count}</stat> seguidores}}"
},
"profile.user-id": {
"message": "ID de usuario: {id}"
},
"project-member-header.error-decline": {
"message": "No se ha podido rechazar la invitación del equipo"
},
@@ -4058,15 +3953,9 @@
"settings.authorizations.about-this-app": {
"message": "Acerca de esta aplicación"
},
"settings.authorizations.by": {
"message": "por"
},
"settings.authorizations.description": {
"message": "Cuando autorizas una aplicación con tu cuenta de Modrinth, le concedes acceso a tu cuenta. Puedes gestionar y revisar el acceso a tu cuenta aquí en cualquier momento."
},
"settings.authorizations.empty-state": {
"message": "Por el momento no podemos mostrar tus aplicaciones autorizadas, pero estamos trabajando para solucionar el problema. ¡Vuelve a visitar esta página más adelante!"
},
"settings.authorizations.head-title": {
"message": "Autorizaciones"
},
@@ -908,15 +908,6 @@
"moderation.page.reports": {
"message": "Raportit"
},
"profile.label.badges": {
"message": "Arvomerkit"
},
"profile.label.organizations": {
"message": "Organisaatiot"
},
"profile.user-id": {
"message": "Käyttäjätunnus: {id}"
},
"project-moderation-nags.required": {
"message": "Vaadittu"
},
@@ -2153,99 +2153,6 @@
"muralpay.warning.wallet-address": {
"message": "Pakisuri muli ng iyong wallet address. Ang mga pundong nahatid sa maling address ay hindi na mababawi."
},
"profile.bio.fallback.creator": {
"message": "Isang tagagawa sa Modrinth."
},
"profile.bio.fallback.user": {
"message": "Isang tagagamit sa Modrinth."
},
"profile.button.billing": {
"message": "Pamahalaan ang billing ng tagagamit"
},
"profile.button.edit-role": {
"message": "Baguhin ang tungkulin"
},
"profile.button.info": {
"message": "Tingang ang mga detalye ng tagagamit"
},
"profile.button.manage-projects": {
"message": "Pamahalaan ang mga proyekto"
},
"profile.button.remove-affiliate": {
"message": "Tanggalin bilang affiliate"
},
"profile.button.set-affiliate": {
"message": "Itakda bilang affiliate"
},
"profile.details.label.auth-providers": {
"message": "Mga auth provider"
},
"profile.details.label.email": {
"message": "Email"
},
"profile.details.label.email-verified": {
"message": "Napatunayan na email"
},
"profile.details.label.has-password": {
"message": "May password"
},
"profile.details.label.has-totp": {
"message": "May TOTP"
},
"profile.details.label.payment-methods": {
"message": "Mga paraan ng pagbabayad"
},
"profile.details.tooltip.email-not-verified": {
"message": "Hindi napatunayang email"
},
"profile.details.tooltip.email-verified": {
"message": "Napatunayan na email"
},
"profile.error.not-found": {
"message": "Hindi mahanap na tagagamit"
},
"profile.label.affiliate": {
"message": "Affiliate"
},
"profile.label.badges": {
"message": "Tsapa"
},
"profile.label.collection": {
"message": "Koleksiyon"
},
"profile.label.joined": {
"message": "Sumali"
},
"profile.label.no-collections": {
"message": "Walang mga koleksiyon ang tagagamit na ito!"
},
"profile.label.no-collections-auth": {
"message": "Wala kang koleksiyon.\nNais mo bang <create-link>lumikha ng isa</create-link>?"
},
"profile.label.no-projects": {
"message": "Walang mga proyekto ang tagagmit na ito!"
},
"profile.label.no-projects-auth": {
"message": "Wala kang proyekto.\nNais mo bang <create-link>lumikha ng isa</create-link>?"
},
"profile.label.organizations": {
"message": "Mga organisasyon"
},
"profile.label.saving": {
"message": "Sine-save..."
},
"profile.meta.description": {
"message": "Magdownload ng mga proyekto ni {username} sa Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Magdownload ng mga proyekto ni {username} sa Modrinth"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> follower ng proyekto} other {<stat>{count}</stat> na follower ng proyekto}}"
},
"profile.user-id": {
"message": "User ID: {id}"
},
"project-member-header.error-decline": {
"message": "Bigong matanggihan ang anyaya sa koponan"
},
@@ -3026,12 +2933,6 @@
"settings.authorizations.about-this-app": {
"message": "Tungkol sa app"
},
"settings.authorizations.by": {
"message": "ni/ng"
},
"settings.authorizations.empty-state": {
"message": "Kasalukuyang hindi namin mapapakita ang mga pinahintulutang app, kinukumpuni pa namin ito. Mangyaring bisitahin ang pahinang ito sa ibang pagkataon!"
},
"settings.authorizations.head-title": {
"message": "Mga pagpapahintulot"
},
-117
View File
@@ -3323,117 +3323,6 @@
"organization.projects.none-with-create-prompt": {
"message": "Cette organisation n'a pas encore de projet. Souhaitez-vous en <create-link>créer un</create-link> ?"
},
"profile.bio.fallback.creator": {
"message": "Un créateur Modrinth."
},
"profile.bio.fallback.user": {
"message": "Un utilisateur Modrinth."
},
"profile.button.analytics": {
"message": "Voir les analyses de l'utilisateur"
},
"profile.button.billing": {
"message": "Gérer la facturation de lutilisateur"
},
"profile.button.edit-role": {
"message": "Modifier le rôle"
},
"profile.button.info": {
"message": "Afficher les détails de lutilisateur"
},
"profile.button.manage-projects": {
"message": "Gérer les projets"
},
"profile.button.remove-affiliate": {
"message": "Se Désaffilié"
},
"profile.button.set-affiliate": {
"message": "Se mettre comme affilié"
},
"profile.collection.projects-count": {
"message": "{count, plural,one {# projet} other {# projets}}"
},
"profile.details.label.auth-providers": {
"message": "Fournisseurs dauthentification"
},
"profile.details.label.email": {
"message": "Adresse e-mail"
},
"profile.details.label.email-verified": {
"message": "Adresse e-mail vérifiée"
},
"profile.details.label.has-password": {
"message": "Dispose d'un mot de passe"
},
"profile.details.label.has-totp": {
"message": "Dispose d'un TOTP"
},
"profile.details.label.payment-methods": {
"message": "Moyens de paiements"
},
"profile.details.tooltip.email-not-verified": {
"message": "Adresse e-mail non vérifiée"
},
"profile.details.tooltip.email-verified": {
"message": "Adresse e-mail vérifiée"
},
"profile.error.not-found": {
"message": "Utilisateur introuvable"
},
"profile.label.affiliate": {
"message": "Affilié"
},
"profile.label.badges": {
"message": "Badges"
},
"profile.label.collection": {
"message": "Collection"
},
"profile.label.download-count": {
"message": "{count, plural, one {téléchargement} other {téléchargements}}"
},
"profile.label.joined": {
"message": "Rejoint"
},
"profile.label.no-collections": {
"message": "Cet utilisateur n'a aucune collection !"
},
"profile.label.no-collections-auth": {
"message": "Vous n'avez aucune collection.\nVoulez-vous en <create-link>créer une</create-link> ?"
},
"profile.label.no-projects": {
"message": "Cet utilisateur n'a aucun projet !"
},
"profile.label.no-projects-auth": {
"message": "Vous n'avez aucun projet.\nVoulez-vous en <create-link>créer un</create-link> ?"
},
"profile.label.organizations": {
"message": "Organisations"
},
"profile.label.project-count": {
"message": "{count, plural, one {projet} other {projets}}"
},
"profile.label.saving": {
"message": "Sauvegarde en cours..."
},
"profile.meta.description": {
"message": "Téléchargez les projets de {username} sur Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Téléchargez les projets de {username} sur Modrinth"
},
"profile.official-account": {
"message": "Compte Modrinth Officiel"
},
"profile.official-account.bio": {
"message": "Le compte utilisateur officiel de Modrinth. Obtenez de laide via <support-link></support-link> ou par e-mail à <email></email>"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> personne adore} other {<stat>{count}</stat> personnes adorent}}"
},
"profile.user-id": {
"message": "ID utilisateur : {id}"
},
"project-member-header.error-decline": {
"message": "Échec du refus de linvitation à l’équipe"
},
@@ -4871,15 +4760,9 @@
"settings.authorizations.about-this-app": {
"message": "À propos de cette application"
},
"settings.authorizations.by": {
"message": "par"
},
"settings.authorizations.description": {
"message": "Lorsque vous autorisez une application avec votre compte Modrinth, vous lui accordez laccès à votre compte. Vous pouvez gérer et consulter les accès à votre compte ici à tout moment."
},
"settings.authorizations.empty-state": {
"message": "Nous ne pouvons pas afficher vos applications autorisées pour le moment, nous travaillons à résoudre ce problème. Veuillez revenir sur cette page ultérieurement !"
},
"settings.authorizations.head-title": {
"message": "Autorisations"
},
@@ -1778,99 +1778,6 @@
"muralpay.warning.wallet-address": {
"message": "בדוק היטב את כתובת הארנק שלך. לא ניתן לשחזר כספים שנשלחו לכתובת שגויה."
},
"profile.bio.fallback.creator": {
"message": "יוצר ב-Modrinth."
},
"profile.bio.fallback.user": {
"message": "משתמש ב-Modrinth."
},
"profile.button.billing": {
"message": "נהל את חיוב המשתמש"
},
"profile.button.edit-role": {
"message": "עריכת תפקיד"
},
"profile.button.info": {
"message": "הצג את הפרטים של המשתמש"
},
"profile.button.manage-projects": {
"message": "ניהול פרויקטים"
},
"profile.button.remove-affiliate": {
"message": "הסרה מתוכנית השותפים"
},
"profile.button.set-affiliate": {
"message": "הגדר כשותף"
},
"profile.details.label.auth-providers": {
"message": "ספקי אימות זהות"
},
"profile.details.label.email": {
"message": "אימייל"
},
"profile.details.label.email-verified": {
"message": "כתובת האימייל אומתה"
},
"profile.details.label.has-password": {
"message": "יש סיסמה"
},
"profile.details.label.has-totp": {
"message": "אימות דו-שלבי פעיל"
},
"profile.details.label.payment-methods": {
"message": "שיטת תשלום"
},
"profile.details.tooltip.email-not-verified": {
"message": "כתובת האימייל טרם אומתה"
},
"profile.details.tooltip.email-verified": {
"message": "כתובת האימייל אומתה"
},
"profile.error.not-found": {
"message": "המשתמש לא נמצא"
},
"profile.label.affiliate": {
"message": "שותף או משתמש שותף"
},
"profile.label.badges": {
"message": "תגים"
},
"profile.label.collection": {
"message": "אוסף"
},
"profile.label.joined": {
"message": "הצטרף ב-"
},
"profile.label.no-collections": {
"message": "למשתמש זה אין אוספים!"
},
"profile.label.no-collections-auth": {
"message": "אין לך אוספים.\nהאם תרצה <create-link>ליצור אחד</create-link>?"
},
"profile.label.no-projects": {
"message": "למשתמש זה אין פרויקטים!"
},
"profile.label.no-projects-auth": {
"message": "אין לך פרויקטים.\nהאם תרצה <create-link>ליצור אחד</create-link>?"
},
"profile.label.organizations": {
"message": "ארגונים"
},
"profile.label.saving": {
"message": "שומר..."
},
"profile.meta.description": {
"message": "הורד את הפרויקטים של {username} ב-Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - הורד את הפרויקטים של {username} ב-Modrinth"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> project follower} other {<stat>{count}</stat> project followers}}"
},
"profile.user-id": {
"message": "מזהה משתמש: {id}"
},
"project-member-header.error-decline": {
"message": "הפעולה לדחיית ההזמנה לצוות נכשלה"
},
-117
View File
@@ -3068,117 +3068,6 @@
"organization.projects.none-with-create-prompt": {
"message": "Ennek a szervezetnek még nincsenek projektjei. Szeretnél <create-link>létrehozni egyet</create-link>?"
},
"profile.bio.fallback.creator": {
"message": "Egy Modrinth fejlesztő."
},
"profile.bio.fallback.user": {
"message": "Egy Modrinth felhasználó."
},
"profile.button.analytics": {
"message": "Felhasználó statisztikáinak megtekintése"
},
"profile.button.billing": {
"message": "Felhasználói számlázás kezelése"
},
"profile.button.edit-role": {
"message": "Szerep szerkesztése"
},
"profile.button.info": {
"message": "Felhasználói adatok megtekintése"
},
"profile.button.manage-projects": {
"message": "Projektek kezelése"
},
"profile.button.remove-affiliate": {
"message": "Társult vállalkozás törlése"
},
"profile.button.set-affiliate": {
"message": "Beállítás társult vállalkozás ként"
},
"profile.collection.projects-count": {
"message": "{count} projekt"
},
"profile.details.label.auth-providers": {
"message": "Auth szolgáltatók"
},
"profile.details.label.email": {
"message": "E-mail"
},
"profile.details.label.email-verified": {
"message": "E-mail cím ellenőrizve"
},
"profile.details.label.has-password": {
"message": "Van jelszó"
},
"profile.details.label.has-totp": {
"message": "Van TOTP"
},
"profile.details.label.payment-methods": {
"message": "Kifizetési módok"
},
"profile.details.tooltip.email-not-verified": {
"message": "Nem hitelesített e-mail"
},
"profile.details.tooltip.email-verified": {
"message": "E-mail cím hitelesítve"
},
"profile.error.not-found": {
"message": "Felhasználó nem található"
},
"profile.label.affiliate": {
"message": "Társult vállalkozás"
},
"profile.label.badges": {
"message": "Jelvények"
},
"profile.label.collection": {
"message": "Gyűjtemény"
},
"profile.label.download-count": {
"message": "letöltés"
},
"profile.label.joined": {
"message": "Csatlakozott"
},
"profile.label.no-collections": {
"message": "Ennek a felhasználónak nincsenek gyűjteményei!"
},
"profile.label.no-collections-auth": {
"message": "Nincsenek gyűjteményeid.\nSzeretnél <create-link>létrehozni egyet</create-link>?"
},
"profile.label.no-projects": {
"message": "Ennek a felhasználónak nincsenek projektjei!"
},
"profile.label.no-projects-auth": {
"message": "Nincsenek projektjeid.\nSzeretnél <create-link>létrehozni egyet</create-link>?"
},
"profile.label.organizations": {
"message": "Szervezetek"
},
"profile.label.project-count": {
"message": "projekt"
},
"profile.label.saving": {
"message": "Mentés..."
},
"profile.meta.description": {
"message": "Töltsd le {username} projektjeit a Modrinthon"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Töltsd le {username} projektjeit a Modrinthon"
},
"profile.official-account": {
"message": "Hivatalos Modrinth fiók"
},
"profile.official-account.bio": {
"message": "A hivatalos Modrinth fiók. Kérj segítséget a <support-link></support-link> oldalon, vagy e-mailben: <email></email>"
},
"profile.stats.projects-followers": {
"message": "<stat>{count}</stat> projektkövető"
},
"profile.user-id": {
"message": "Felhasználói azonosító: {id}"
},
"project-member-header.error-decline": {
"message": "Nem sikerült elutasítani a meghívást"
},
@@ -4334,15 +4223,9 @@
"settings.authorizations.about-this-app": {
"message": "Az alkalmazásról"
},
"settings.authorizations.by": {
"message": "által"
},
"settings.authorizations.description": {
"message": "Amikor engedélyezel egy alkalmazást a Modrinth fiókoddal, hozzáférést biztosítasz számára a fiókodhoz. A fiókodhoz való hozzáférést bármikor kezelheted és ellenőrizheted itt."
},
"settings.authorizations.empty-state": {
"message": "Jelenleg nem tudjuk megjeleníteni az azonosított alkalmazásokat, dolgozunk a probléma megoldásán. Kérlek látogasd meg ezt az oldalt később!"
},
"settings.authorizations.head-title": {
"message": "Engedélyek"
},
@@ -2159,99 +2159,6 @@
"muralpay.warning.wallet-address": {
"message": "Periksa ulang alamat dompet Anda. Dana yang terkirim ke alamat yang salah tidak dapat dipulihkan."
},
"profile.bio.fallback.creator": {
"message": "Seorang kreator Modrinth."
},
"profile.bio.fallback.user": {
"message": "Seorang pengguna Modrinth."
},
"profile.button.billing": {
"message": "Kelola penagihan pengguna"
},
"profile.button.edit-role": {
"message": "Ubah peran"
},
"profile.button.info": {
"message": "Lihat perincian pengguna"
},
"profile.button.manage-projects": {
"message": "Kelola proyek"
},
"profile.button.remove-affiliate": {
"message": "Hapus sebagai afiliasi"
},
"profile.button.set-affiliate": {
"message": "Tetapkan sebagai afiliasi"
},
"profile.details.label.auth-providers": {
"message": "Penyedia autentikasi"
},
"profile.details.label.email": {
"message": "Sur-el"
},
"profile.details.label.email-verified": {
"message": "Sur-el terverifikasi"
},
"profile.details.label.has-password": {
"message": "Memiliki kata sandi"
},
"profile.details.label.has-totp": {
"message": "Memiliki TOTP"
},
"profile.details.label.payment-methods": {
"message": "Metode pembayaran"
},
"profile.details.tooltip.email-not-verified": {
"message": "Sur-el tidak terverifikasi"
},
"profile.details.tooltip.email-verified": {
"message": "Sur-el terverifikasi"
},
"profile.error.not-found": {
"message": "Pengguna tidak ditemukan"
},
"profile.label.affiliate": {
"message": "Afiliasi"
},
"profile.label.badges": {
"message": "Lencana"
},
"profile.label.collection": {
"message": "Koleksi"
},
"profile.label.joined": {
"message": "Telah bergabung"
},
"profile.label.no-collections": {
"message": "Pengguna ini tidak memiliki koleksi!"
},
"profile.label.no-collections-auth": {
"message": "Anda tidak memiliki koleksi apa pun.\nApakah Anda ingin <create-link>membuatnya</create-link>?"
},
"profile.label.no-projects": {
"message": "Pengguna ini tidak memiliki proyek!"
},
"profile.label.no-projects-auth": {
"message": "Anda tidak memiliki proyek apa pun.\nApakah Anda ingin <create-link>membuatnya</create-link>?"
},
"profile.label.organizations": {
"message": "Organisasi"
},
"profile.label.saving": {
"message": "Menyimpan..."
},
"profile.meta.description": {
"message": "Unduh proyek milik {username} di Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Unduh proyek milik {username} di Modrinth"
},
"profile.stats.projects-followers": {
"message": "{count, plural, other {<stat>{count}</stat> pengikut proyek}}"
},
"profile.user-id": {
"message": "Pengenal Pengguna: {id}"
},
"project-member-header.error-decline": {
"message": "Gagal menolak undangan tim"
},
-117
View File
@@ -3335,117 +3335,6 @@
"organization.projects.none-with-create-prompt": {
"message": "Questa organizzazione non ha alcun progetto. Vorresti <create-link>crearne uno</create-link>?"
},
"profile.bio.fallback.creator": {
"message": "Creatore su Modrinth."
},
"profile.bio.fallback.user": {
"message": "Utente di Modrinth."
},
"profile.button.analytics": {
"message": "Mostra analitiche"
},
"profile.button.billing": {
"message": "Gestisci pagamento utente"
},
"profile.button.edit-role": {
"message": "Modifica ruolo"
},
"profile.button.info": {
"message": "Mostra dettagli utente"
},
"profile.button.manage-projects": {
"message": "Gestisci progetti"
},
"profile.button.remove-affiliate": {
"message": "Rimuovi da affiliato"
},
"profile.button.set-affiliate": {
"message": "Imposta come affiliato"
},
"profile.collection.projects-count": {
"message": "{count, plural, one {# progetto} other {# progetti}}"
},
"profile.details.label.auth-providers": {
"message": "Fornitori autenticazione"
},
"profile.details.label.email": {
"message": "Email"
},
"profile.details.label.email-verified": {
"message": "Email verificata"
},
"profile.details.label.has-password": {
"message": "Usa una password"
},
"profile.details.label.has-totp": {
"message": "Usa TOTP"
},
"profile.details.label.payment-methods": {
"message": "Metodi di pagamento"
},
"profile.details.tooltip.email-not-verified": {
"message": "Email non verificata"
},
"profile.details.tooltip.email-verified": {
"message": "Email verificata"
},
"profile.error.not-found": {
"message": "Utente non trovato"
},
"profile.label.affiliate": {
"message": "Affiliato"
},
"profile.label.badges": {
"message": "Distintivi"
},
"profile.label.collection": {
"message": "Raccolta"
},
"profile.label.download-count": {
"message": "download"
},
"profile.label.joined": {
"message": "Iscrizione"
},
"profile.label.no-collections": {
"message": "Questo utente non ha raccolte!"
},
"profile.label.no-collections-auth": {
"message": "Non hai alcuna raccolta.\nNe vorresti <create-link>creare una</create-link>?"
},
"profile.label.no-projects": {
"message": "Questo utente non ha progetti!"
},
"profile.label.no-projects-auth": {
"message": "Non hai alcun progetto. Ne vorresti <create-link>creare uno</create-link>?"
},
"profile.label.organizations": {
"message": "Organizzazioni"
},
"profile.label.project-count": {
"message": "{count, plural, one {progetto} other {progetti}}"
},
"profile.label.saving": {
"message": "Salvataggio..."
},
"profile.meta.description": {
"message": "Scarica i progetti di {username} su Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Scarica i progetti di {username} su Modrinth"
},
"profile.official-account": {
"message": "Account Modrinth ufficiale"
},
"profile.official-account.bio": {
"message": "L'account ufficiale di Modrinth. Ricevi assistenza presso <support-link></support-link> o tramite mail presso <email></email>"
},
"profile.stats.projects-followers": {
"message": "<stat>{count, plural, =0 {Nessun} other {{count}}}</stat> follower del progetto"
},
"profile.user-id": {
"message": "ID utente: {id}"
},
"project-member-header.error-decline": {
"message": "Errore nel rifiutare l'invito al team"
},
@@ -4871,15 +4760,9 @@
"settings.authorizations.about-this-app": {
"message": "Info sull'app"
},
"settings.authorizations.by": {
"message": "da"
},
"settings.authorizations.description": {
"message": "Quando autorizzi un'applicazione, le concedi l'accesso al tuo account Modrinth. Qui puoi gestirne e controllarne l'accesso in qualsiasi momento."
},
"settings.authorizations.empty-state": {
"message": "Al momento non riusciamo a mostrare le tue app autorizzate, stiamo lavorando per risolvere il problema. Ritorna più tardi!"
},
"settings.authorizations.head-title": {
"message": "Autorizzazioni"
},
@@ -2729,96 +2729,6 @@
"organization.project-transfer.type-column": {
"message": "タイプ"
},
"profile.bio.fallback.creator": {
"message": "Modrinthの作成者。"
},
"profile.bio.fallback.user": {
"message": "Modrinthのユーザー。"
},
"profile.button.billing": {
"message": "ユーザー課金管理"
},
"profile.button.edit-role": {
"message": "ロールを編集"
},
"profile.button.info": {
"message": "ユーザー情報を表示"
},
"profile.button.manage-projects": {
"message": "プロジェクトを管理"
},
"profile.button.remove-affiliate": {
"message": "アフィリエイトを解除する"
},
"profile.button.set-affiliate": {
"message": "アフィリエイトとして設定する"
},
"profile.details.label.auth-providers": {
"message": "認証プロバイダー"
},
"profile.details.label.email": {
"message": "メールドレス"
},
"profile.details.label.email-verified": {
"message": "メールアドレス認証済み"
},
"profile.details.label.has-password": {
"message": "パスワードがすでにあります"
},
"profile.details.label.has-totp": {
"message": "TOTPがあります"
},
"profile.details.label.payment-methods": {
"message": "支払い方法"
},
"profile.details.tooltip.email-not-verified": {
"message": "メールアドレス未認証"
},
"profile.details.tooltip.email-verified": {
"message": "メールアドレス認証済み"
},
"profile.error.not-found": {
"message": "ユーザーが見つかりませんでした"
},
"profile.label.affiliate": {
"message": "アフィリエイト"
},
"profile.label.badges": {
"message": "バッジ"
},
"profile.label.collection": {
"message": "コレクション"
},
"profile.label.joined": {
"message": "参加: "
},
"profile.label.no-collections": {
"message": "このユーザーのコレクションはありません!"
},
"profile.label.no-collections-auth": {
"message": "コレクションがありません。\n <create-link>コレクションを作成しますか</create-link>"
},
"profile.label.no-projects": {
"message": "このユーザーのプロジェクトはありません!"
},
"profile.label.no-projects-auth": {
"message": "プロジェクトがありません\nプロジェクトを<create-link>作成しますか</create-link>"
},
"profile.label.organizations": {
"message": "組織"
},
"profile.label.saving": {
"message": "保存中…"
},
"profile.meta.description": {
"message": "Modrinthで{username}のプロジェクトをダウンロードする"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Modrinthで{username}のプロジェクトをダウンロードする"
},
"profile.user-id": {
"message": "ユーザーID: {id}"
},
"project-member-header.error-decline": {
"message": "チームへの招待の辞退に失敗しました"
},
@@ -3803,15 +3713,9 @@
"settings.authorizations.about-this-app": {
"message": "このアプリについて"
},
"settings.authorizations.by": {
"message": "作成者"
},
"settings.authorizations.description": {
"message": "Modrinthアカウントでアプリケーションを承認することにより、アプリケーションがあなたのアカウントにアクセスできるようになります。 アクセス権はいつでも確認でき、変更可能です。"
},
"settings.authorizations.empty-state": {
"message": "現在承認済みアプリを表示することができません。現在修正中のため、また後ほど確認ください!"
},
"settings.authorizations.head-title": {
"message": "承認"
},
-117
View File
@@ -3290,117 +3290,6 @@
"organization.projects.none-with-create-prompt": {
"message": "이 조직에는 아직 프로젝트가 없습니다. <create-link>하나 생성하시겠습니까?</create-link>?"
},
"profile.bio.fallback.creator": {
"message": "Modrinth 창작자입니다."
},
"profile.bio.fallback.user": {
"message": "Modrinth 사용자입니다."
},
"profile.button.analytics": {
"message": "사용자 분석 보기"
},
"profile.button.billing": {
"message": "사용자 청구 관리"
},
"profile.button.edit-role": {
"message": "역할 수정"
},
"profile.button.info": {
"message": "사용자 상세 정보 보기"
},
"profile.button.manage-projects": {
"message": "프로젝트 관리"
},
"profile.button.remove-affiliate": {
"message": "제휴 취소"
},
"profile.button.set-affiliate": {
"message": "제휴사로 설정"
},
"profile.collection.projects-count": {
"message": "{count, plural, one {프로젝트 #개} other {프로젝트 #개}}"
},
"profile.details.label.auth-providers": {
"message": "인증 제공자"
},
"profile.details.label.email": {
"message": "이메일"
},
"profile.details.label.email-verified": {
"message": "이메일 인증됨"
},
"profile.details.label.has-password": {
"message": "비밀번호 사용"
},
"profile.details.label.has-totp": {
"message": "TOTP 사용"
},
"profile.details.label.payment-methods": {
"message": "결제 수단"
},
"profile.details.tooltip.email-not-verified": {
"message": "이메일이 인증되지 않음"
},
"profile.details.tooltip.email-verified": {
"message": "이메일 인증됨"
},
"profile.error.not-found": {
"message": "사용자를 찾을 수 없습니다"
},
"profile.label.affiliate": {
"message": "제휴"
},
"profile.label.badges": {
"message": "배지"
},
"profile.label.collection": {
"message": "컬렉션"
},
"profile.label.download-count": {
"message": ""
},
"profile.label.joined": {
"message": "가입"
},
"profile.label.no-collections": {
"message": "이 사용자는 컬렉션이 없습니다!"
},
"profile.label.no-collections-auth": {
"message": "아직 컬렉션이 없습니다.\n<create-link>새 컬렉션</create-link>을 생성하시겠습니까?"
},
"profile.label.no-projects": {
"message": "이 사용자는 프로젝트가 없습니다!"
},
"profile.label.no-projects-auth": {
"message": "아직 프로젝트가 없습니다.\n<create-link>새 프로젝트</create-link>를 생성하시겠습니까?"
},
"profile.label.organizations": {
"message": "조직"
},
"profile.label.project-count": {
"message": ""
},
"profile.label.saving": {
"message": "저장..."
},
"profile.meta.description": {
"message": "Modrinth에서 {username}의 프로젝트 다운로드"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Modrinth에서 {username}의 프로젝트 다운로드"
},
"profile.official-account": {
"message": "공식 Modrinth 계정"
},
"profile.official-account.bio": {
"message": "Modrinth의 공식 사용자 계정입니다. <support-link></support-link> 또는 <email></email> 이메일을 통해 지원을 받으실 수 있습니다"
},
"profile.stats.projects-followers": {
"message": "{count, plural, other {<stat>{count}</stat> 프로젝트 팔로워}}"
},
"profile.user-id": {
"message": "사용자 ID: {id}"
},
"project-member-header.error-decline": {
"message": "팀 초대를 거절하지 못했습니다"
},
@@ -4835,15 +4724,9 @@
"settings.authorizations.about-this-app": {
"message": "이 앱에 대해"
},
"settings.authorizations.by": {
"message": "제작자"
},
"settings.authorizations.description": {
"message": "Modrinth 계정으로 애플리케이션에 권한을 부여하면, 해당 앱이 귀하의 계정에 접근할 수 있게 됩니다. 여기에서 언제든지 계정 접근 권한을 관리하고 확인할 수 있습니다."
},
"settings.authorizations.empty-state": {
"message": "현재 승인한 애플리케이션을 표시할 수 없습니다. 이 문제를 해결하기 위해 노력 중입니다. 나중에 다시 확인해 주세요!"
},
"settings.authorizations.head-title": {
"message": "승인"
},
-105
View File
@@ -2684,105 +2684,6 @@
"muralpay.warning.wallet-address": {
"message": "Semak semula alamat dompet anda. Dana yang dihantar ke alamat yang salah tidak boleh dipulihkan."
},
"profile.bio.fallback.creator": {
"message": "Seorang pencipta Modrinth."
},
"profile.bio.fallback.user": {
"message": "Seorang pengguna Modrinth."
},
"profile.button.billing": {
"message": "Urus pengebilan pengguna"
},
"profile.button.edit-role": {
"message": "Sunting peranan"
},
"profile.button.info": {
"message": "Lihat butiran pengguna"
},
"profile.button.manage-projects": {
"message": "Urus projek"
},
"profile.button.remove-affiliate": {
"message": "Alih keluar rakan afiliasi"
},
"profile.button.set-affiliate": {
"message": "Tetapkan sebagai rakan afiliasi"
},
"profile.collection.projects-count": {
"message": "{count, plural, other {# projek}}"
},
"profile.details.label.auth-providers": {
"message": "Penyedia pengesahan"
},
"profile.details.label.email": {
"message": "E-mel"
},
"profile.details.label.email-verified": {
"message": "E-mel disahkan"
},
"profile.details.label.has-password": {
"message": "Mempunyai kata laluan"
},
"profile.details.label.has-totp": {
"message": "Mempunyai TOTP"
},
"profile.details.label.payment-methods": {
"message": "Kaedah pembayaran"
},
"profile.details.tooltip.email-not-verified": {
"message": "E-mel tidak disahkan"
},
"profile.details.tooltip.email-verified": {
"message": "E-mel disahkan"
},
"profile.error.not-found": {
"message": "Pengguna tidak dijumpai"
},
"profile.label.affiliate": {
"message": "Afiliasi"
},
"profile.label.badges": {
"message": "Lencana"
},
"profile.label.collection": {
"message": "Koleksi"
},
"profile.label.joined": {
"message": "Telah menyertai"
},
"profile.label.no-collections": {
"message": "Pengguna ini tidak memiliki sebarang koleksi!"
},
"profile.label.no-collections-auth": {
"message": "Anda tidak memiliki sebarang koleksi.\nAdakah anda mahu <create-link>mencipta sebuah koleksi</create-link>?"
},
"profile.label.no-projects": {
"message": "Pengguna ini tidak memiliki sebarang projek!"
},
"profile.label.no-projects-auth": {
"message": "Anda tidak memiliki sebarang projek.\nAdakah anda mahu <create-link>mencipta sebuah projek</create-link>?"
},
"profile.label.organizations": {
"message": "Organisasi"
},
"profile.label.saving": {
"message": "Sedang menyimpan..."
},
"profile.meta.description": {
"message": "Muat turun projek milik {username} di Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Muat turun projek milik {username} di Modrinth"
},
"profile.official-account.bio": {
"message": "Akaun pengguna rasmi Modrinth. Dapatkan sokongan di <support-link></support-link> atau melalui e-mel di <email></email>"
},
"profile.stats.projects-followers": {
"message": "{count, plural, other {<stat>{count}</stat> pengikut projek}}"
},
"profile.user-id": {
"message": "ID Pengguna: {id}"
},
"project-member-header.error-decline": {
"message": "Gagal menolak jemputan pasukan"
},
@@ -3845,15 +3746,9 @@
"settings.authorizations.about-this-app": {
"message": "Mengenai aplikasi ini"
},
"settings.authorizations.by": {
"message": "oleh"
},
"settings.authorizations.description": {
"message": "Apabila anda membenarkan aplikasi ini dengan akaun Modrinth anda, anda memberikannya akses kepada akaun anda. Anda boleh mengurus dan menyemak akses kepada akaun anda di sini pada bila-bila masa."
},
"settings.authorizations.empty-state": {
"message": "Kami tidak dapat memaparkan aplikasi yang dibenarkan oleh anda pada masa ini, kami sedang berusaha untuk memperbaikinya. Sila kunjungi laman ini pada tarikh yang akan datang!"
},
"settings.authorizations.head-title": {
"message": "Kebenaran"
},
-117
View File
@@ -3341,117 +3341,6 @@
"organization.projects.none-with-create-prompt": {
"message": "Deze organisatie heeft nog geen projecten. Wil je er <create-link>een aanmaken</create-link>?"
},
"profile.bio.fallback.creator": {
"message": "Een Modrinth creator."
},
"profile.bio.fallback.user": {
"message": "Een Modrinth gebruiker."
},
"profile.button.analytics": {
"message": "Gebruikersstatistieken bekijken"
},
"profile.button.billing": {
"message": "Gebruikers facturering beheren"
},
"profile.button.edit-role": {
"message": "Bewerk rol"
},
"profile.button.info": {
"message": "Bekijk gebruikersdetails"
},
"profile.button.manage-projects": {
"message": "Beheer projecten"
},
"profile.button.remove-affiliate": {
"message": "Verwijderen als partner"
},
"profile.button.set-affiliate": {
"message": "Zet als partner"
},
"profile.collection.projects-count": {
"message": "{count, plural, one {# project} other {# projecten}}"
},
"profile.details.label.auth-providers": {
"message": "Beveiliging providers"
},
"profile.details.label.email": {
"message": "Email"
},
"profile.details.label.email-verified": {
"message": "Email geverifieerd"
},
"profile.details.label.has-password": {
"message": "Heeft wachtwoord"
},
"profile.details.label.has-totp": {
"message": "Heeft TOTP"
},
"profile.details.label.payment-methods": {
"message": "Betaalmethodes"
},
"profile.details.tooltip.email-not-verified": {
"message": "Email niet geverifieerd"
},
"profile.details.tooltip.email-verified": {
"message": "Email geverifieerd"
},
"profile.error.not-found": {
"message": "Gebruiker niet gevonden"
},
"profile.label.affiliate": {
"message": "Partners"
},
"profile.label.badges": {
"message": "Badges"
},
"profile.label.collection": {
"message": "Collectie"
},
"profile.label.download-count": {
"message": "{count, plural, one {download} other {downloads}}"
},
"profile.label.joined": {
"message": "Lid geworden"
},
"profile.label.no-collections": {
"message": "Deze gebruiker heeft geen collecties!"
},
"profile.label.no-collections-auth": {
"message": "Je hebt geen collecties.\n<create-link>Wil je er een aanmaken?</create-link>"
},
"profile.label.no-projects": {
"message": "Deze gebruiker heeft geen projecten!"
},
"profile.label.no-projects-auth": {
"message": "Je hebt geen projecten.\n<create-link>Wil je er een aanmaken?</create-link>"
},
"profile.label.organizations": {
"message": "Organisaties"
},
"profile.label.project-count": {
"message": "{count, plural, one {project} other {projecten}}"
},
"profile.label.saving": {
"message": "Opslaan..."
},
"profile.meta.description": {
"message": "Download {username} z'n projecten via Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Download {username}'s projecten op Modrinth"
},
"profile.official-account": {
"message": "Officieel Modrinth-account"
},
"profile.official-account.bio": {
"message": "Het officiële gebruikersaccount van Modrinth. Neem contact op met het Helpcentrum via <support-link></support-link> of per e-mail via <email></email>"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> project volger}other {<stat>{count}</stat> project volgers}}"
},
"profile.user-id": {
"message": "Gebruiker ID: {id}"
},
"project-member-header.error-decline": {
"message": "Team uitnodiging niet afgeslagen"
},
@@ -4910,15 +4799,9 @@
"settings.authorizations.about-this-app": {
"message": "Over deze app"
},
"settings.authorizations.by": {
"message": "door"
},
"settings.authorizations.description": {
"message": "Wanneer je een app via je Modrinth-account autoriseert, verleen je die app toegang tot je account. Je kunt de toegang tot je account hier op elk moment beheren en bekijken."
},
"settings.authorizations.empty-state": {
"message": "We kunnen je geautoriseerde apps momenteel niet weergeven, maar we werken eraan om dit te verhelpen. Kom later nog eens terug op deze pagina!"
},
"settings.authorizations.head-title": {
"message": "Toestemmingen"
},
-108
View File
@@ -2609,108 +2609,6 @@
"muralpay.warning.wallet-address": {
"message": "Dobbeltsjekk lommeboksadressa di. Penger sendt til feil adresse kan ikke bli gjenoppretta."
},
"profile.bio.fallback.creator": {
"message": "En Modrinth-skaper."
},
"profile.bio.fallback.user": {
"message": "En Modrinth-bruker."
},
"profile.button.analytics": {
"message": "Vis brukerstatistikk"
},
"profile.button.billing": {
"message": "Hold styr på brukerfakturering"
},
"profile.button.edit-role": {
"message": "Rediger rolle"
},
"profile.button.info": {
"message": "Vis brukerdetaljer"
},
"profile.button.manage-projects": {
"message": "Hold styr på prosjekt"
},
"profile.button.remove-affiliate": {
"message": "Fjern tilknyting"
},
"profile.button.set-affiliate": {
"message": "Sett som tilknyting"
},
"profile.collection.projects-count": {
"message": "{count, plural, one {# prosjekt} other {# prosjekter}}"
},
"profile.details.label.auth-providers": {
"message": "Autoriseringsgivere"
},
"profile.details.label.email": {
"message": "Epost"
},
"profile.details.label.email-verified": {
"message": "Epostbekreftelse"
},
"profile.details.label.has-password": {
"message": "Har passord"
},
"profile.details.label.has-totp": {
"message": "Har TOTP"
},
"profile.details.label.payment-methods": {
"message": "Betalingsmetoder"
},
"profile.details.tooltip.email-not-verified": {
"message": "Epost ikke bekrefta"
},
"profile.details.tooltip.email-verified": {
"message": "Epost bekrefta"
},
"profile.error.not-found": {
"message": "Bruker ikke funnet"
},
"profile.label.affiliate": {
"message": "Tilknytt"
},
"profile.label.badges": {
"message": "Merker"
},
"profile.label.collection": {
"message": "Samling"
},
"profile.label.joined": {
"message": "Blei med"
},
"profile.label.no-collections": {
"message": "Denne brukeren har ingen samlinger!"
},
"profile.label.no-collections-auth": {
"message": "Du har ikke noen samlinger.\nHar du lyst å <create-link>lage en</create-link>?"
},
"profile.label.no-projects": {
"message": "Denne brukeren har ingen prosjekt!"
},
"profile.label.no-projects-auth": {
"message": "Du har ikke noen prosjekt.\nHar du lyst å <create-link>lage en</create-link>?"
},
"profile.label.organizations": {
"message": "Organisasjoner"
},
"profile.label.saving": {
"message": "Lagrer..."
},
"profile.meta.description": {
"message": "Last ned prosjekta til {username} på Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Last ned prosjekta til {username} på Modrinth"
},
"profile.official-account": {
"message": "Offisiell Modrinth-bruker"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> prosjektfølger} other {<stat>{count}</stat> prosjektfølgere}}"
},
"profile.user-id": {
"message": "Bruker-ID: {id}"
},
"project-member-header.error-decline": {
"message": "Kunne ikke avslå laginvitasjonen"
},
@@ -3734,15 +3632,9 @@
"settings.authorizations.about-this-app": {
"message": "Om denne appen"
},
"settings.authorizations.by": {
"message": "fra"
},
"settings.authorizations.description": {
"message": "Når du autoriserer en applikasjon med Modrinth-kontoen din, så gir du tilgang til kontoen din. Du kan handere og gå gjennom tilgang til kontoen din her når som helst."
},
"settings.authorizations.empty-state": {
"message": "Vi kan ikke vise de autoriserte appene dine akkurat nå, vi jobber med å fikse det. Vær så snill å besøk denne sida seinere!"
},
"settings.authorizations.head-title": {
"message": "Autoriseringer"
},
-117
View File
@@ -3317,117 +3317,6 @@
"organization.projects.none-with-create-prompt": {
"message": "Ta organizacja nie ma jeszcze żadnych projektów. Czy chcesz <create-link>utworzyć nowy</create-link>?"
},
"profile.bio.fallback.creator": {
"message": "Twórca na Modrinth."
},
"profile.bio.fallback.user": {
"message": "Użytkownik Modrinth."
},
"profile.button.analytics": {
"message": "Pokaż dane analityczne użytkownika"
},
"profile.button.billing": {
"message": "Zarządzaj rozliczaniem użytkownika"
},
"profile.button.edit-role": {
"message": "Edytuj rolę"
},
"profile.button.info": {
"message": "Otwórz szczegóły użytkownika"
},
"profile.button.manage-projects": {
"message": "Zarządzaj projektami"
},
"profile.button.remove-affiliate": {
"message": "Usuń afiliację"
},
"profile.button.set-affiliate": {
"message": "Ustaw jako afiliację"
},
"profile.collection.projects-count": {
"message": "{count, plural, one {# projekt} few {# projekty} other {# projektów}}"
},
"profile.details.label.auth-providers": {
"message": "Dostawcy uwierzytelniania"
},
"profile.details.label.email": {
"message": "E-mail"
},
"profile.details.label.email-verified": {
"message": "Zweryfikowano e-mail"
},
"profile.details.label.has-password": {
"message": "Ma hasło"
},
"profile.details.label.has-totp": {
"message": "Ma TOTP"
},
"profile.details.label.payment-methods": {
"message": "Metody płatności"
},
"profile.details.tooltip.email-not-verified": {
"message": "E-mail nie jest zweryfikowany"
},
"profile.details.tooltip.email-verified": {
"message": "E-mail jest zweryfikowany"
},
"profile.error.not-found": {
"message": "Nie znaleziono użytkownika"
},
"profile.label.affiliate": {
"message": "Afiliacja"
},
"profile.label.badges": {
"message": "Odznaki"
},
"profile.label.collection": {
"message": "Kolekcja"
},
"profile.label.download-count": {
"message": "{count, plural, one {pobranie} few {pobrania} other {pobrań}}"
},
"profile.label.joined": {
"message": "Dołączył(-a)"
},
"profile.label.no-collections": {
"message": "Ten użytkownik nie ma żadnych kolekcji!"
},
"profile.label.no-collections-auth": {
"message": "Nie masz żadnych kolekcji.\nCzy chcesz <create-link>utworzyć jedną</create-link>?"
},
"profile.label.no-projects": {
"message": "Ten użytkownik nie ma żadnych projektów!"
},
"profile.label.no-projects-auth": {
"message": "Nie masz żadnych projektów.\nCzy chcesz <create-link>utworzyć nowy</create-link>?"
},
"profile.label.organizations": {
"message": "Organizacje"
},
"profile.label.project-count": {
"message": "{count, plural, one {projekt} few {projekty} other {projektów}}"
},
"profile.label.saving": {
"message": "Zapisywanie..."
},
"profile.meta.description": {
"message": "Pobieraj projekty {username} na Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Pobieraj projekty {username} na Modrinth"
},
"profile.official-account": {
"message": "Oficjalne konto Modrinth"
},
"profile.official-account.bio": {
"message": "Oficjalne konto Modrinth. Otrzymaj wsparcie na stronie <support-link></support-link> lub poprzez e-mail <email></email>"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> obserwujący projekt} other {<stat>{count}</stat> obserwujących projekt}}"
},
"profile.user-id": {
"message": "ID użytkownika: {id}"
},
"project-member-header.error-decline": {
"message": "Odrzucenie zaproszenia do zespołu nie powiodło się"
},
@@ -4826,15 +4715,9 @@
"settings.authorizations.about-this-app": {
"message": "O aplikacji"
},
"settings.authorizations.by": {
"message": "od"
},
"settings.authorizations.description": {
"message": "Kiedy autoryzujesz aplikacje ze swoim kontem Modrinth, dajesz jej dostęp do swojego konta. Możesz tutaj zawsze zarządzać oraz przeglądać stan aplikacji z dostępem do Twojego konta."
},
"settings.authorizations.empty-state": {
"message": "Nie udało się nam wyświetlić twoich zautoryzowanych aplikacji, pracujemy nad tym, aby to naprawić. Odwiedź tę stronę później!"
},
"settings.authorizations.head-title": {
"message": "Autoryzacje"
},
-117
View File
@@ -3344,117 +3344,6 @@
"organization.projects.none-with-create-prompt": {
"message": "Esta organização ainda não tem nenhum projeto. Gostaria de <create-link>criar um</create-link>?"
},
"profile.bio.fallback.creator": {
"message": "Um criador Modrinth."
},
"profile.bio.fallback.user": {
"message": "Usuário comum."
},
"profile.button.analytics": {
"message": "Ver estatísticas do usuário"
},
"profile.button.billing": {
"message": "Gerenciar cobrança do usuário"
},
"profile.button.edit-role": {
"message": "Editar cargo"
},
"profile.button.info": {
"message": "Ver detalhes do usuário"
},
"profile.button.manage-projects": {
"message": "Gerenciar projetos"
},
"profile.button.remove-affiliate": {
"message": "Remover como afiliado"
},
"profile.button.set-affiliate": {
"message": "Configurar como afiliado"
},
"profile.collection.projects-count": {
"message": "{count, plural, one {# projeto} other {# projetos}}"
},
"profile.details.label.auth-providers": {
"message": "Provedores de autenticação"
},
"profile.details.label.email": {
"message": "E-mail"
},
"profile.details.label.email-verified": {
"message": "E-mail verificado"
},
"profile.details.label.has-password": {
"message": "Possui senha"
},
"profile.details.label.has-totp": {
"message": "Possui TOTP"
},
"profile.details.label.payment-methods": {
"message": "Métodos de pagamento"
},
"profile.details.tooltip.email-not-verified": {
"message": "E-mail não verificado"
},
"profile.details.tooltip.email-verified": {
"message": "E-mail verificado"
},
"profile.error.not-found": {
"message": "Usuário não encontrado"
},
"profile.label.affiliate": {
"message": "Afiliado"
},
"profile.label.badges": {
"message": "Emblemas"
},
"profile.label.collection": {
"message": "Coleção"
},
"profile.label.download-count": {
"message": "{count, plural, one {download} other {downloads}}"
},
"profile.label.joined": {
"message": "Entrou"
},
"profile.label.no-collections": {
"message": "Este usuário não possui coleções!"
},
"profile.label.no-collections-auth": {
"message": "Você não tem nenhuma coleção.\nGostaria de <create-link>criar uma</create-link>?"
},
"profile.label.no-projects": {
"message": "Este usuário não tem projetos!"
},
"profile.label.no-projects-auth": {
"message": "Você não tem nenhum projeto.\nGostaria de <create-link>criar um</create-link>?"
},
"profile.label.organizations": {
"message": "Organizações"
},
"profile.label.project-count": {
"message": "{count, plural, one {projeto} other {projetos}}"
},
"profile.label.saving": {
"message": "Salvando..."
},
"profile.meta.description": {
"message": "Baixe os projetos de {username} no Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} — Baixe os projetos de {username} no Modrinth"
},
"profile.official-account": {
"message": "Conta oficial do Modrinth"
},
"profile.official-account.bio": {
"message": "Conta oficial do Modrinth. Obtenha suporte em<support-link></support-link> ou por e-mail via <email></email>"
},
"profile.stats.projects-followers": {
"message": "{count, plural, =0 {{count} seguidores} one {<stat>{count}</stat> seguidor} other {<stat>{count}</stat> seguidores}} do projeto"
},
"profile.user-id": {
"message": "ID do usuário: {id}"
},
"project-member-header.error-decline": {
"message": "Falha ao recusar o convite da equipe"
},
@@ -4913,15 +4802,9 @@
"settings.authorizations.about-this-app": {
"message": "Sobre este aplicativo"
},
"settings.authorizations.by": {
"message": "por"
},
"settings.authorizations.description": {
"message": "Ao autorizar um aplicativo com sua conta do Modrinth, você concede acesso à sua conta. Você pode gerenciar e revisar o acesso à sua conta aqui a qualquer momento."
},
"settings.authorizations.empty-state": {
"message": "No momento, não podemos exibir seus aplicativos autorizados. Estamos trabalhando para corrigir isso. Visite esta página novamente mais tarde!"
},
"settings.authorizations.head-title": {
"message": "Autorizações"
},
@@ -2501,99 +2501,6 @@
"muralpay.warning.wallet-address": {
"message": "Confirme o endereço da carteira. Fundos enviados para um endereço errado não podem ser recuperados."
},
"profile.bio.fallback.creator": {
"message": "Um criador Modrinth."
},
"profile.bio.fallback.user": {
"message": "Um utilizador Modrinth."
},
"profile.button.billing": {
"message": "Gerir faturação do utilizador"
},
"profile.button.edit-role": {
"message": "Editar cargo"
},
"profile.button.info": {
"message": "Ver detalhes de utilizador"
},
"profile.button.manage-projects": {
"message": "Gerir projetos"
},
"profile.button.remove-affiliate": {
"message": "Remover como afiliado"
},
"profile.button.set-affiliate": {
"message": "Definir como afiliado"
},
"profile.details.label.auth-providers": {
"message": "Fornecedores de Autenticação"
},
"profile.details.label.email": {
"message": "E-mail"
},
"profile.details.label.email-verified": {
"message": "E-mail verificado"
},
"profile.details.label.has-password": {
"message": "Tem password"
},
"profile.details.label.has-totp": {
"message": "Tem TOTP"
},
"profile.details.label.payment-methods": {
"message": "Métodos de pagamento"
},
"profile.details.tooltip.email-not-verified": {
"message": "E-mail não verificado"
},
"profile.details.tooltip.email-verified": {
"message": "E-mail verificado"
},
"profile.error.not-found": {
"message": "Utilizador não encontrado"
},
"profile.label.affiliate": {
"message": "Afiliado"
},
"profile.label.badges": {
"message": "Emblemas"
},
"profile.label.collection": {
"message": "Coleção"
},
"profile.label.joined": {
"message": "Entrou"
},
"profile.label.no-collections": {
"message": "Este utilizador não tem coleções!"
},
"profile.label.no-collections-auth": {
"message": "Não tens nenhuma coleção.\nQueres <create-link>criar uma</create-link>?"
},
"profile.label.no-projects": {
"message": "Este utilizador não tem projetos!"
},
"profile.label.no-projects-auth": {
"message": "Não tens nenhum projeto.\nQueres <create-link>criar um</create-link>?"
},
"profile.label.organizations": {
"message": "Organizações"
},
"profile.label.saving": {
"message": "A guardar..."
},
"profile.meta.description": {
"message": "Transfere os projetos de {username} no Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Transfere os projetos de {username} no Modrinth"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> seguidor do projeto} other {<stat>{count}</stat> seguidores do projeto}}"
},
"profile.user-id": {
"message": "ID do utilizador: {id}"
},
"project-member-header.error-decline": {
"message": "Falha a rejeitar o convite de equipa"
},
@@ -3536,15 +3443,9 @@
"settings.authorizations.about-this-app": {
"message": "Sobre esta aplicação"
},
"settings.authorizations.by": {
"message": "por"
},
"settings.authorizations.description": {
"message": "Quando autorizas uma aplicação com a tua conta Modrinth, dás-lhe acesso à tua conta. Podes gerir e rever acesso à tua conta aqui quando quiseres."
},
"settings.authorizations.empty-state": {
"message": "Neste momento, não conseguimos mostrar as tuas aplicações autorizadas, estamos a tentar corrigir isto. Por favor tenta novamente mais tarde!"
},
"settings.authorizations.head-title": {
"message": "Autorizações"
},
@@ -1214,60 +1214,6 @@
"muralpay.rail.fiat-usd.name": {
"message": "Transfer bancar (USD)"
},
"profile.bio.fallback.creator": {
"message": "Un creator Modrinth."
},
"profile.bio.fallback.user": {
"message": "Un utilizator Modrinth."
},
"profile.button.billing": {
"message": "Administrează facturarea utilizatorului"
},
"profile.button.edit-role": {
"message": "Editează rolul"
},
"profile.button.info": {
"message": "Vizualizează detaliile utilizatorului"
},
"profile.button.manage-projects": {
"message": "Administrează proiectele"
},
"profile.error.not-found": {
"message": "Utilizatorul nu a fost găsit"
},
"profile.label.badges": {
"message": "Insigne"
},
"profile.label.no-collections": {
"message": "Acest utilizator nu are colecții!"
},
"profile.label.no-collections-auth": {
"message": "Nu ai nicio colecție.\nVrei să <create-link>creezi una</create-link>?"
},
"profile.label.no-projects": {
"message": "Acest utilizator nu are proiecte!"
},
"profile.label.no-projects-auth": {
"message": "Nu aveți proiecte.\nDoriți să <create-link>creați unul</create-link>?"
},
"profile.label.organizations": {
"message": "Organizații"
},
"profile.label.saving": {
"message": "Salvare..."
},
"profile.meta.description": {
"message": "Descarcă proiectele lui {username} pe Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Descarcă proiectele lui {username} pe Modrinth"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> urmăritor} other {<stat>{count}</stat> urmăritori}}"
},
"profile.user-id": {
"message": "ID-ul utilizatorului: {id}"
},
"project-member-header.error-decline": {
"message": "Refuzarea invitației în echipă a eșuat"
},
-117
View File
@@ -3320,117 +3320,6 @@
"organization.projects.none-with-create-prompt": {
"message": "У этой организации ещё нет проектов. Хотите <create-link>создать новый</create-link>?"
},
"profile.bio.fallback.creator": {
"message": "Автор на Modrinth."
},
"profile.bio.fallback.user": {
"message": "Пользователь Modrinth."
},
"profile.button.analytics": {
"message": "Посмотреть аналитику"
},
"profile.button.billing": {
"message": "Управление платежами"
},
"profile.button.edit-role": {
"message": "Изменить роль"
},
"profile.button.info": {
"message": "Посмотреть подробности"
},
"profile.button.manage-projects": {
"message": "Управление проектами"
},
"profile.button.remove-affiliate": {
"message": "Убрать из партнёров"
},
"profile.button.set-affiliate": {
"message": "Сделать партнёром"
},
"profile.collection.projects-count": {
"message": "{count, plural, one {# проект} few {# проекта} other {# проектов}}"
},
"profile.details.label.auth-providers": {
"message": "Сервисы входа"
},
"profile.details.label.email": {
"message": "Почта"
},
"profile.details.label.email-verified": {
"message": "Почта подтверждена"
},
"profile.details.label.has-password": {
"message": "Есть пароль"
},
"profile.details.label.has-totp": {
"message": "Есть TOTP"
},
"profile.details.label.payment-methods": {
"message": "Способы оплаты"
},
"profile.details.tooltip.email-not-verified": {
"message": "Почта не подтверждена"
},
"profile.details.tooltip.email-verified": {
"message": "Почта подтверждена"
},
"profile.error.not-found": {
"message": "Пользователь не найден"
},
"profile.label.affiliate": {
"message": "Партнёр"
},
"profile.label.badges": {
"message": "Значки"
},
"profile.label.collection": {
"message": "Коллекция"
},
"profile.label.download-count": {
"message": "{count, plural, one {скачивание} few {скачивания} other {скачиваний}}"
},
"profile.label.joined": {
"message": "Регистрация:"
},
"profile.label.no-collections": {
"message": "У этого пользователя нет коллекций!"
},
"profile.label.no-collections-auth": {
"message": "У вас ещё нет коллекций.\nХотите <create-link>создать новую</create-link>?"
},
"profile.label.no-projects": {
"message": "У этого пользователя нет проектов!"
},
"profile.label.no-projects-auth": {
"message": "У вас ещё нет проектов.\nХотите <create-link>создать новый</create-link>?"
},
"profile.label.organizations": {
"message": "Организации"
},
"profile.label.project-count": {
"message": "{count, plural, one {проект} few {проекта} other {проектов}}"
},
"profile.label.saving": {
"message": "Сохранение..."
},
"profile.meta.description": {
"message": "Скачать проекты {username} на Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} — Скачать проекты {username} на Modrinth"
},
"profile.official-account": {
"message": "Официальный аккаунт Modrinth"
},
"profile.official-account.bio": {
"message": "Официальный аккаунт Modrinth. Связаться с поддержкой можно по ссылке <support-link></support-link> или по электронной почте: <email></email>"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> подписчик} few {<stat>{count}</stat> подписчика} other {<stat>{count}</stat> подписчиков}} у проектов"
},
"profile.user-id": {
"message": "ID пользователя: {id}"
},
"project-member-header.error-decline": {
"message": "Не удалось отклонить приглашение в команду"
},
@@ -4859,15 +4748,9 @@
"settings.authorizations.about-this-app": {
"message": "О приложении"
},
"settings.authorizations.by": {
"message": "от"
},
"settings.authorizations.description": {
"message": "Авторизованные приложения получают доступ к аккаунту Modrinth. Здесь можно просматривать и отзывать этот доступ."
},
"settings.authorizations.empty-state": {
"message": "Список приложений временно недоступен. Мы работаем над исправлением — загляните сюда позже."
},
"settings.authorizations.head-title": {
"message": "Авторизации"
},
@@ -1220,51 +1220,6 @@
"muralpay.field.phone-number": {
"message": "Broj telefona"
},
"profile.button.analytics": {
"message": "Pregled analitike korisnika"
},
"profile.button.billing": {
"message": "Upravljaj naplatom korisnika"
},
"profile.button.info": {
"message": "Prikaži podatke o korisniku"
},
"profile.button.manage-projects": {
"message": "Upravljaj projektima"
},
"profile.error.not-found": {
"message": "Korisnik nije pronađen"
},
"profile.label.badges": {
"message": "Bedževi"
},
"profile.label.no-collections": {
"message": "Ovaj korisnik nema kolekcija!"
},
"profile.label.no-collections-auth": {
"message": "Nemaš nijednu kolekciju.\nDa li želiš da <create-link>kreiraš jednu</create-link>?"
},
"profile.label.no-projects": {
"message": "Ovaj korisnik nema projekata!"
},
"profile.label.no-projects-auth": {
"message": "Nemaš nijedan projekat.\nDa li želiš da <create-link>kreiraš jedan</create-link>?"
},
"profile.label.organizations": {
"message": "Organizacije"
},
"profile.meta.description": {
"message": "Preuzmi projekte korisnika {username} na Modrinthu"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Preuzmi projekte korisnika {username} na Modrinthu"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> pratilac projekta} few {<stat>{count}</stat> pratioca projekta} other {<stat>{count}</stat> pratilaca projekta}}"
},
"profile.user-id": {
"message": "ID korisnika: {id}"
},
"project-member-header.error-decline": {
"message": "Neuspešno odbijanje poziva tima"
},
@@ -1724,9 +1679,6 @@
"settings.authorizations.about-this-app": {
"message": "O ovoj aplikaciji"
},
"settings.authorizations.by": {
"message": "od"
},
"settings.authorizations.revoke.action": {
"message": "Oduzmi"
},
@@ -2819,99 +2819,6 @@
"organization.project-transfer.type-column": {
"message": "Typ"
},
"profile.bio.fallback.creator": {
"message": "En Modrinth kreatör."
},
"profile.bio.fallback.user": {
"message": "En Modrinth användare."
},
"profile.button.analytics": {
"message": "Visa användarstatistik"
},
"profile.button.billing": {
"message": "Hantera användarfakturering"
},
"profile.button.edit-role": {
"message": "Redigera roll"
},
"profile.button.info": {
"message": "Visa användardetaljer"
},
"profile.button.manage-projects": {
"message": "Hantera projekt"
},
"profile.collection.projects-count": {
"message": "{count} projekt"
},
"profile.details.label.auth-providers": {
"message": "Autentiseringsleverantörer"
},
"profile.details.label.email": {
"message": "E-post"
},
"profile.details.label.email-verified": {
"message": "E-post verifierad"
},
"profile.details.label.has-password": {
"message": "Har lösenord"
},
"profile.details.label.has-totp": {
"message": "Har TOTP"
},
"profile.details.label.payment-methods": {
"message": "Betalningsmetoder"
},
"profile.details.tooltip.email-not-verified": {
"message": "E-post ej verifierad"
},
"profile.details.tooltip.email-verified": {
"message": "E-post verifierad"
},
"profile.error.not-found": {
"message": "Användare hittades inte"
},
"profile.label.affiliate": {
"message": "Affiliate"
},
"profile.label.badges": {
"message": "Märken"
},
"profile.label.collection": {
"message": "Samling"
},
"profile.label.joined": {
"message": "Gick med"
},
"profile.label.no-collections": {
"message": "Denna användare har inga samlingar!"
},
"profile.label.no-collections-auth": {
"message": "Du har inga samlingar.\nVill du <create-link>skapa en</create-link>?"
},
"profile.label.no-projects": {
"message": "Denna användare har inga projekt!"
},
"profile.label.no-projects-auth": {
"message": "Du har inga projekt.\nVill du <create-link>skapa ett</create-link>?"
},
"profile.label.organizations": {
"message": "Organisationer"
},
"profile.label.saving": {
"message": "Sparar..."
},
"profile.meta.description": {
"message": "Ladda ner {username}s projekt på Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Ladda ned {username}s projekt på Modrinth"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> följare} other {<stat>{count}</stat> följare}}"
},
"profile.user-id": {
"message": "Användar-ID: {id}"
},
"project-member-header.error-decline": {
"message": "Misslyckades att neka inbjudan"
},
@@ -3884,9 +3791,6 @@
"settings.authorizations.about-this-app": {
"message": "Om appen"
},
"settings.authorizations.by": {
"message": "av"
},
"settings.authorizations.head-title": {
"message": "Autentiseringar"
},
-111
View File
@@ -2996,111 +2996,6 @@
"muralpay.warning.wallet-address": {
"message": "Cüzdan adresinizi iki kez kontrol edin. Yanlış adrese gönderilen fonlar kurtarılamaz."
},
"profile.bio.fallback.creator": {
"message": "Bir Modrinth içerik üreticisi."
},
"profile.bio.fallback.user": {
"message": "Bir Modrinth kullanıcısı."
},
"profile.button.analytics": {
"message": "Kullanıcı analizlerini görüntüle"
},
"profile.button.billing": {
"message": "Kullanıcı faturasını yönet"
},
"profile.button.edit-role": {
"message": "Rolü düzenle"
},
"profile.button.info": {
"message": "Kullanıcı ayrıntılarını görüntüle"
},
"profile.button.manage-projects": {
"message": "Projeleri yönet"
},
"profile.button.remove-affiliate": {
"message": "Ortaklıktan kaldır"
},
"profile.button.set-affiliate": {
"message": "Ortak olarak ayarla"
},
"profile.collection.projects-count": {
"message": "{count, plural, one {# project} other {# projects}}"
},
"profile.details.label.auth-providers": {
"message": "Kimlik doğrulama sağlayıcıları"
},
"profile.details.label.email": {
"message": "Eposta"
},
"profile.details.label.email-verified": {
"message": "E-posta doğrulandı"
},
"profile.details.label.has-password": {
"message": "Parola var"
},
"profile.details.label.has-totp": {
"message": "TOTP var"
},
"profile.details.label.payment-methods": {
"message": "Ödeme yöntemleri"
},
"profile.details.tooltip.email-not-verified": {
"message": "Eposta doğrulanmadı"
},
"profile.details.tooltip.email-verified": {
"message": "Eposta doğrulandı"
},
"profile.error.not-found": {
"message": "Kullanıcı bulunamadı"
},
"profile.label.affiliate": {
"message": "Ortak"
},
"profile.label.badges": {
"message": "Rozetler"
},
"profile.label.collection": {
"message": "Koleksiyon"
},
"profile.label.joined": {
"message": "Katılma:"
},
"profile.label.no-collections": {
"message": "Bu kullanıcının hiç koleksiyonu yok!"
},
"profile.label.no-collections-auth": {
"message": "Hiç koleksiyonun yok.\n<create-link>Oluşturmak</create-link> ister misin?"
},
"profile.label.no-projects": {
"message": "Bu kullanıcının hiç projesi yok!"
},
"profile.label.no-projects-auth": {
"message": "Hiç projen yok.\n<create-link>Oluşturmak</create-link> ister misin?"
},
"profile.label.organizations": {
"message": "Organizasyonlar"
},
"profile.label.saving": {
"message": "Kaydediliyor..."
},
"profile.meta.description": {
"message": "{username} kullanıcısının Modrinth'teki projelerini indir"
},
"profile.meta.description-with-bio": {
"message": "{bio} - {username} kullanıcısının Modrinth'teki projelerini indir"
},
"profile.official-account": {
"message": "Resmi Modrinth hesabı"
},
"profile.official-account.bio": {
"message": "Modrinth'in resmi kullanıcı hesabı Destek için<support-link></support-link> adresinden veya <email></email> e-posta adresinden iletişime geçebilirsiniz"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> proje takipçisi} other {<stat>{count}</stat> proje takipçisi}}"
},
"profile.user-id": {
"message": "Kullanıcı Kimliği: {id}"
},
"project-member-header.error-decline": {
"message": "Ekip daveti reddedilemedi"
},
@@ -4316,15 +4211,9 @@
"settings.authorizations.about-this-app": {
"message": "Bu uygulama hakkında"
},
"settings.authorizations.by": {
"message": "tarafından"
},
"settings.authorizations.description": {
"message": "Modrinth hesabınızla bir uygulamayı yetkilendirdiğinizde, ona hesabınıza erişim izni vermiş olursunuz. Hesabınıza verilen erişimi dilediğiniz zaman burada yönetebilir ve gözden geçirebilirsiniz."
},
"settings.authorizations.empty-state": {
"message": "Yetkilendirdiğiniz uygulamalar şu anda görüntülenemiyor, bunu düzeltmek için çalışıyoruz. Lütfen bu sayfayı daha sonra tekrar ziyaret edin."
},
"settings.authorizations.head-title": {
"message": "Yetkiler"
},
-117
View File
@@ -3299,117 +3299,6 @@
"organization.projects.none-with-create-prompt": {
"message": "Ця організація ще не має проєктів. Бажаєте <create-link>створити новий</create-link>?"
},
"profile.bio.fallback.creator": {
"message": "Творець Modrinth."
},
"profile.bio.fallback.user": {
"message": "Користувач Modrinth."
},
"profile.button.analytics": {
"message": "Переглянути аналітику користувача"
},
"profile.button.billing": {
"message": "Керувати розрахунками користувача"
},
"profile.button.edit-role": {
"message": "Редагувати роль"
},
"profile.button.info": {
"message": "Переглянути дані користувача"
},
"profile.button.manage-projects": {
"message": "Керувати проєктами"
},
"profile.button.remove-affiliate": {
"message": "Усунути як партнера"
},
"profile.button.set-affiliate": {
"message": "Установити як партнера"
},
"profile.collection.projects-count": {
"message": "{count, plural, one {# проєкт} few {# проєкти} many {# проєктів} other {# проєкту}}"
},
"profile.details.label.auth-providers": {
"message": "Провайдери автентифікації"
},
"profile.details.label.email": {
"message": "Електронна пошта"
},
"profile.details.label.email-verified": {
"message": "Електронна адреса підтверджена"
},
"profile.details.label.has-password": {
"message": "Має пароль"
},
"profile.details.label.has-totp": {
"message": "Має TOTP"
},
"profile.details.label.payment-methods": {
"message": "Способи оплати"
},
"profile.details.tooltip.email-not-verified": {
"message": "Електронна пошта не перевірена"
},
"profile.details.tooltip.email-verified": {
"message": "Електронна пошта перевірена"
},
"profile.error.not-found": {
"message": "Користувача не знайдено"
},
"profile.label.affiliate": {
"message": "Партнер"
},
"profile.label.badges": {
"message": "Значки"
},
"profile.label.collection": {
"message": "Добірка"
},
"profile.label.download-count": {
"message": "{count, plural, one {завантаження} few {завантаження} many {завантажень} other {завантаження}}"
},
"profile.label.joined": {
"message": "Приєднався"
},
"profile.label.no-collections": {
"message": "У цього користувача немає добірок!"
},
"profile.label.no-collections-auth": {
"message": "У вас немає жодної добірки.\nБажаєте <create-link>створити її</create-link>?"
},
"profile.label.no-projects": {
"message": "У цього користувача немає проєктів!"
},
"profile.label.no-projects-auth": {
"message": "У вас немає жодного проєкту.\nБажаєте <create-link>створити якийсь</create-link>?"
},
"profile.label.organizations": {
"message": "Організації"
},
"profile.label.project-count": {
"message": "{count, plural, one {проєкт} few {проєкти} many {проєктів} other {проєктів}}"
},
"profile.label.saving": {
"message": "Збереження…"
},
"profile.meta.description": {
"message": "Завантажте проєкти {username} на Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} — Завантажуйте проєкти {username} на Modrinth"
},
"profile.official-account": {
"message": "Офіційний обліковий запис Modrinth"
},
"profile.official-account.bio": {
"message": "Офіційний обліковий запис Modrinth. Зв'язатися з підтримкою можна за <support-link></support-link> або через електронну пошту за <email></email>"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> відстежує} few {<stat>{count}</stat> відстежує} other {<stat>{count}</stat> відстежують}}"
},
"profile.user-id": {
"message": "ID користувача: {id}"
},
"project-member-header.error-decline": {
"message": "Не вдалося відхилити запрошення до команди"
},
@@ -4835,15 +4724,9 @@
"settings.authorizations.about-this-app": {
"message": "Про застосунок"
},
"settings.authorizations.by": {
"message": "від"
},
"settings.authorizations.description": {
"message": "Коли ви авторизуєтеся у застосунку за допомогою Modrinth, ви надаєте йому доступ до вашого облікового запису. Ви завжди можете переглядати й керувати доступом до свого облікового запису тут."
},
"settings.authorizations.empty-state": {
"message": "Наразі ми не можемо показати ваші авторизовані застосунки, але ми працюємо, щоб виправити це. Будь ласка, завітайте на цю сторінку пізніше!"
},
"settings.authorizations.head-title": {
"message": "Авторизації"
},
-111
View File
@@ -2843,111 +2843,6 @@
"muralpay.warning.wallet-address": {
"message": "Hãy kiểm tra kỹ địa chỉ ví. Số tiền gửi đến địa chỉ không chính xác sẽ không thể khôi phục."
},
"profile.bio.fallback.creator": {
"message": "Một nhà sáng tạo trên Modrinth."
},
"profile.bio.fallback.user": {
"message": "Một người dùng Modrinth."
},
"profile.button.analytics": {
"message": "Xem phân tích người dùng"
},
"profile.button.billing": {
"message": "Quản lý thanh toán người dùng"
},
"profile.button.edit-role": {
"message": "Chỉnh sửa vai trò"
},
"profile.button.info": {
"message": "Xem chi tiết người dùng"
},
"profile.button.manage-projects": {
"message": "Quản lý dự án"
},
"profile.button.remove-affiliate": {
"message": "Xóa affiliate"
},
"profile.button.set-affiliate": {
"message": "Đặt làm affiliate"
},
"profile.collection.projects-count": {
"message": "{count, plural, other {# dự án}}"
},
"profile.details.label.auth-providers": {
"message": "Nhà cung cấp xác thực"
},
"profile.details.label.email": {
"message": "Email"
},
"profile.details.label.email-verified": {
"message": "Email đã được xác minh"
},
"profile.details.label.has-password": {
"message": "Có mật khẩu"
},
"profile.details.label.has-totp": {
"message": "Có TOTP"
},
"profile.details.label.payment-methods": {
"message": "Phương thức thanh toán"
},
"profile.details.tooltip.email-not-verified": {
"message": "Email chưa được xác minh"
},
"profile.details.tooltip.email-verified": {
"message": "Email đã được xác minh"
},
"profile.error.not-found": {
"message": "Không tìm thấy người dùng"
},
"profile.label.affiliate": {
"message": "Affiliate"
},
"profile.label.badges": {
"message": "Huy hiệu"
},
"profile.label.collection": {
"message": "Bộ sưu tập"
},
"profile.label.joined": {
"message": "Đã tham gia"
},
"profile.label.no-collections": {
"message": "Người dùng này chưa có bộ sưu tập nào!"
},
"profile.label.no-collections-auth": {
"message": "Bạn chưa có bộ sưu tập nào.\nBạn có muốn <create-link>tạo một bộ sưu tập</create-link> không?"
},
"profile.label.no-projects": {
"message": "Người dùng này chưa có dự án nào!"
},
"profile.label.no-projects-auth": {
"message": "Bạn chưa có dự án nào.\nBạn có muốn <create-link>tạo một dự án</create-link> không?"
},
"profile.label.organizations": {
"message": "Tổ chức"
},
"profile.label.saving": {
"message": "Đang lưu..."
},
"profile.meta.description": {
"message": "Tải xuống các dự án của {username} trên Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Tải xuống các dự án của {username} trên Modrinth"
},
"profile.official-account": {
"message": "Tài khoản chính thúc bởi Modrinth"
},
"profile.official-account.bio": {
"message": "Tài khoản chính thức của Modrinth. Nhận hỗ trợ từ <support-link></support-link> hoặc qua email tại <email></email>"
},
"profile.stats.projects-followers": {
"message": "{count, plural, other {<stat>{count}</stat> người theo dõi dự án}}"
},
"profile.user-id": {
"message": "ID người dùng: {id}"
},
"project-member-header.error-decline": {
"message": "Không thể từ chối lời mời vào nhóm"
},
@@ -4016,15 +3911,9 @@
"settings.authorizations.about-this-app": {
"message": "Về ứng dụng này"
},
"settings.authorizations.by": {
"message": "bởi"
},
"settings.authorizations.description": {
"message": "Khi bạn ủy quyền cho một ứng dụng bằng tài khoản Modrinth của mình, bạn đã cấp cho ứng dụng đó quyền truy cập vào tài khoản của bạn. Bạn có thể quản lý và xem lại quyền truy cập vào tài khoản của mình tại đây bất cứ lúc nào."
},
"settings.authorizations.empty-state": {
"message": "Hiện tại chúng tôi không thể hiển thị các ứng dụng đã ủy quyền của bạn, chúng tôi đang nỗ lực khắc phục sự cố này. Vui lòng quay lại trang này sau!"
},
"settings.authorizations.head-title": {
"message": "Quyền hạn"
},
-114
View File
@@ -3344,114 +3344,6 @@
"organization.projects.none-with-create-prompt": {
"message": "该组织还没有项目。想要<create-link>创建一个</create-link>吗?"
},
"profile.bio.fallback.creator": {
"message": "一位 Modrinth 创作者。"
},
"profile.bio.fallback.user": {
"message": "一位 Modrinth 用户。"
},
"profile.button.analytics": {
"message": "查看用户分析"
},
"profile.button.billing": {
"message": "管理用户财务"
},
"profile.button.edit-role": {
"message": "编辑角色"
},
"profile.button.info": {
"message": "查看用户详情"
},
"profile.button.manage-projects": {
"message": "管理项目"
},
"profile.button.remove-affiliate": {
"message": "解除作为联盟伙伴"
},
"profile.button.set-affiliate": {
"message": "设置为联盟伙伴"
},
"profile.details.label.auth-providers": {
"message": "身份验证提供者"
},
"profile.details.label.email": {
"message": "电子邮箱"
},
"profile.details.label.email-verified": {
"message": "电子邮箱已验证"
},
"profile.details.label.has-password": {
"message": "已设置密码"
},
"profile.details.label.has-totp": {
"message": "已设置动态令牌"
},
"profile.details.label.payment-methods": {
"message": "支付方式"
},
"profile.details.tooltip.email-not-verified": {
"message": "电子邮箱未验证"
},
"profile.details.tooltip.email-verified": {
"message": "电子邮箱验证成功"
},
"profile.error.not-found": {
"message": "未找到用户"
},
"profile.label.affiliate": {
"message": "联盟"
},
"profile.label.badges": {
"message": "徽章"
},
"profile.label.collection": {
"message": "收藏夹"
},
"profile.label.download-count": {
"message": "{count, plural, other {下载量}}"
},
"profile.label.joined": {
"message": "加入于"
},
"profile.label.no-collections": {
"message": "该用户没有收藏夹!"
},
"profile.label.no-collections-auth": {
"message": "你还没有收藏夹。\n想要<create-link>创建一个</create-link>吗?"
},
"profile.label.no-projects": {
"message": "该用户没有项目!"
},
"profile.label.no-projects-auth": {
"message": "你还没有项目。\n想要<create-link>创建一个</create-link>吗?"
},
"profile.label.organizations": {
"message": "组织"
},
"profile.label.project-count": {
"message": "{count, plural, other {项目}}"
},
"profile.label.saving": {
"message": "正在保存……"
},
"profile.meta.description": {
"message": "前往 Modrinth 下载 {username} 的项目"
},
"profile.meta.description-with-bio": {
"message": "{bio} - 前往 Modrinth 下载 {username} 的项目"
},
"profile.official-account": {
"message": "Modrinth 官方账户"
},
"profile.official-account.bio": {
"message": "Modrinth的官方用户账号。在<support-link></support-link>处获取支持或通过电子邮件<email></email>获取支持"
},
"profile.stats.projects-followers": {
"message": "{count, plural, other {<stat>{count}</stat>项目关注者}}"
},
"profile.user-id": {
"message": "用户ID{id}"
},
"project-member-header.error-decline": {
"message": "拒绝团队邀请时失败"
},
@@ -4910,15 +4802,9 @@
"settings.authorizations.about-this-app": {
"message": "关于此应用"
},
"settings.authorizations.by": {
"message": "by"
},
"settings.authorizations.description": {
"message": "当您使用 Modrinth 账户授权某个应用程序时,即表示您授予其访问您账户的权限。您可以随时在此处管理和审查对您账户的访问权限。"
},
"settings.authorizations.empty-state": {
"message": "我们目前无法显示您授权的应用程序,我们正在努力解决这个问题。请稍后再访问此页面!"
},
"settings.authorizations.head-title": {
"message": "授权"
},
-117
View File
@@ -3344,117 +3344,6 @@
"organization.projects.none-with-create-prompt": {
"message": "這個組織還沒有任何專案。你想要<create-link>建立一個</create-link>嗎?"
},
"profile.bio.fallback.creator": {
"message": "一位 Modrinth 創作者。"
},
"profile.bio.fallback.user": {
"message": "一位 Modrinth 使用者。"
},
"profile.button.analytics": {
"message": "檢視使用者數據分析"
},
"profile.button.billing": {
"message": "管理使用者帳務"
},
"profile.button.edit-role": {
"message": "編輯角色"
},
"profile.button.info": {
"message": "檢視使用者詳細資訊"
},
"profile.button.manage-projects": {
"message": "管理專案"
},
"profile.button.remove-affiliate": {
"message": "移除聯盟行銷夥伴資格"
},
"profile.button.set-affiliate": {
"message": "設為聯盟行銷夥伴"
},
"profile.collection.projects-count": {
"message": "{count, plural, other {# 個專案}}"
},
"profile.details.label.auth-providers": {
"message": "驗證提供者"
},
"profile.details.label.email": {
"message": "電子郵件"
},
"profile.details.label.email-verified": {
"message": "電子郵件已驗證"
},
"profile.details.label.has-password": {
"message": "是否有密碼"
},
"profile.details.label.has-totp": {
"message": "是否有 TOTP"
},
"profile.details.label.payment-methods": {
"message": "付款方式"
},
"profile.details.tooltip.email-not-verified": {
"message": "電子郵件尚未驗證"
},
"profile.details.tooltip.email-verified": {
"message": "電子郵件已驗證"
},
"profile.error.not-found": {
"message": "找不到使用者"
},
"profile.label.affiliate": {
"message": "聯盟行銷"
},
"profile.label.badges": {
"message": "徽章"
},
"profile.label.collection": {
"message": "收藏"
},
"profile.label.download-count": {
"message": "{count, plural, other {次下載}}"
},
"profile.label.joined": {
"message": "加入時間:"
},
"profile.label.no-collections": {
"message": "這個使用者沒有任何收藏!"
},
"profile.label.no-collections-auth": {
"message": "你還沒有任何收藏。\n你想要<create-link>建立一個</create-link>嗎?"
},
"profile.label.no-projects": {
"message": "這個使用者沒有任何專案!"
},
"profile.label.no-projects-auth": {
"message": "你還沒有任何專案。\n你想要<create-link>建立一個</create-link>嗎?"
},
"profile.label.organizations": {
"message": "組織"
},
"profile.label.project-count": {
"message": "{count, plural, other {個專案}}"
},
"profile.label.saving": {
"message": "儲存中..."
},
"profile.meta.description": {
"message": "前往 Modrinth 下載 {username} 的專案"
},
"profile.meta.description-with-bio": {
"message": "{bio} — 前往 Modrinth 下載 {username} 的專案"
},
"profile.official-account": {
"message": "Modrinth 官方帳號"
},
"profile.official-account.bio": {
"message": "Modrinth 的官方使用者帳號。請至 <support-link></support-link> 取得支援,或透過電子郵件 <email></email> 聯絡客服團隊"
},
"profile.stats.projects-followers": {
"message": "{count, plural, other {<stat>{count}</stat> 位專案追蹤者}}"
},
"profile.user-id": {
"message": "使用者 ID{id}"
},
"project-member-header.error-decline": {
"message": "拒絕團隊邀請失敗"
},
@@ -4913,15 +4802,9 @@
"settings.authorizations.about-this-app": {
"message": "關於這個應用程式"
},
"settings.authorizations.by": {
"message": "作者"
},
"settings.authorizations.description": {
"message": "當你使用 Modrinth 帳號授權應用程式時,即代表你授予該應用程式存取你帳戶的權限。你可以隨時在這裡管理並審查帳號的存取權。"
},
"settings.authorizations.empty-state": {
"message": "目前無法顯示你已授權的應用程式,我們正在努力修正這個問題。請稍後再回來查看!"
},
"settings.authorizations.head-title": {
"message": "授權"
},
+17 -12
View File
@@ -1,10 +1,12 @@
<template>
<div>
<div class="normal-page no-sidebar">
<h1>{{ formatMessage(commonMessages.settingsLabel) }}</h1>
</div>
<div class="normal-page">
<div class="normal-page__sidebar">
<NormalPage sidebar="left">
<template #header>
<h1 class="m-0 text-3xl font-semibold">
{{ formatMessage(commonMessages.settingsLabel) }}
</h1>
</template>
<template #sidebar>
<NavStack
:items="
[
@@ -18,7 +20,6 @@
link: '/settings/language',
label: formatMessage(commonSettingsMessages.language),
icon: LanguagesIcon,
badge: `${formatMessage(commonMessages.beta)}`,
},
flags.developerMode
? {
@@ -81,11 +82,9 @@
].filter(Boolean)
"
/>
</div>
<div class="normal-page__content mt-3 lg:mt-0">
<NuxtPage :route="route" />
</div>
</div>
</template>
<NuxtPage :route="route" />
</NormalPage>
</div>
</template>
<script setup>
@@ -101,7 +100,13 @@ import {
ToggleRightIcon,
UserIcon,
} from '@modrinth/assets'
import { commonMessages, commonSettingsMessages, defineMessages, useVIntl } from '@modrinth/ui'
import {
commonMessages,
commonSettingsMessages,
defineMessages,
NormalPage,
useVIntl,
} from '@modrinth/ui'
import NavStack from '~/components/ui/NavStack.vue'
@@ -1,5 +1,5 @@
<template>
<div class="universal-card">
<div class="flex flex-col">
<ConfirmModal
ref="modal_confirm"
:title="formatMessage(messages.revokeConfirmTitle)"
@@ -7,101 +7,70 @@
:proceed-label="formatMessage(messages.revokeAction)"
@proceed="revokeApp(revokingId)"
/>
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.authorizedApps) }}</h2>
<p>
<h2 class="m-0 text-2xl font-semibold">
{{ formatMessage(commonSettingsMessages.authorizedApps) }}
</h2>
<p class="mb-4 mt-2">
{{ formatMessage(messages.description) }}
</p>
<div v-if="appInfoLookup.length === 0" class="universal-card recessed">
{{ formatMessage(messages.emptyState) }}
</div>
<div
v-for="authorization in appInfoLookup"
:key="authorization.id"
class="universal-card recessed token mt-4"
>
<div class="token-content">
<div>
<div class="icon-name">
<Avatar :src="authorization.app.icon_url" />
<div>
<h2 class="token-title">
{{ authorization.app.name }}
</h2>
<div>
{{ formatMessage(messages.byLabel) }}
<nuxt-link class="text-link" :to="'/user/' + authorization.owner.id">{{
authorization.owner.username
}}</nuxt-link>
<template v-if="authorization.app.url">
<span> </span>
<nuxt-link class="text-link" :to="authorization.app.url">
{{ authorization.app.url }}
</nuxt-link>
</template>
</div>
<template v-if="isLoading">
<div class="flex flex-col gap-4">
<div
v-for="i in 2"
:key="`authorization-skeleton-${i}`"
class="overflow-hidden rounded-2xl border border-solid border-surface-4 bg-surface-3"
>
<div class="flex items-center gap-3 border-0 border-b border-solid border-surface-4 p-4">
<div class="size-12 shrink-0 animate-pulse rounded-xl bg-surface-4" />
<div class="flex flex-1 flex-col gap-2">
<div class="h-4 w-36 animate-pulse rounded-lg bg-surface-4" />
<div class="h-3 w-48 animate-pulse rounded-lg bg-surface-4" />
</div>
<div class="h-9 w-24 animate-pulse rounded-xl bg-surface-4" />
</div>
</div>
<div>
<template v-if="authorization.app.description">
<label for="app-description">
<span class="label__title">{{ formatMessage(messages.aboutThisAppLabel) }}</span>
</label>
<div id="app-description">{{ authorization.app.description }}</div>
</template>
<label for="app-scope-list">
<span class="label__title">{{ formatMessage(commonMessages.scopesLabel) }}</span>
</label>
<div class="scope-list">
<div
v-for="scope in scopesToDefinitions(authorization.scopes)"
:key="scope"
class="scope-list-item"
>
<div class="scope-list-item-icon">
<CheckIcon />
</div>
{{ scope }}
<div class="bg-surface-2 p-4">
<div class="mb-3 h-3 w-28 animate-pulse rounded-lg bg-surface-3" />
<div class="grid gap-2 sm:grid-cols-2">
<div
v-for="j in 4"
:key="`authorization-skeleton-${i}-scope-${j}`"
class="h-4 w-full animate-pulse rounded-lg bg-surface-3"
/>
</div>
</div>
</div>
</div>
<div class="input-group">
<ButtonStyled color="red">
<button
@click="
() => {
revokingId = authorization.app_id
$refs.modal_confirm.show()
}
"
>
<TrashIcon />
{{ formatMessage(messages.revokeAction) }}
</button>
</ButtonStyled>
</div>
</template>
<EmptyState
v-else-if="showEmptyState"
type="done"
:heading="formatMessage(messages.emptyStateHeading)"
:description="formatMessage(messages.emptyStateDescription)"
/>
<div v-else class="flex flex-col gap-4">
<AuthorizationCard
v-for="authorization in appInfoLookup"
:key="authorization.id"
:authorization="authorization"
@revoke="onRevoke"
/>
</div>
</div>
</template>
<script setup>
import { CheckIcon, TrashIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
commonSettingsMessages,
ConfirmModal,
defineMessages,
EmptyState,
injectModrinthClient,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { useScopes } from '~/composables/auth/scopes.ts'
import AuthorizationCard from '~/components/ui/AuthorizationCard.vue'
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
@@ -117,10 +86,13 @@ const messages = defineMessages({
defaultMessage:
'When you authorize an application with your Modrinth account, you grant it access to your account. You can manage and review access to your account here at any time.',
},
emptyState: {
id: 'settings.authorizations.empty-state',
defaultMessage:
"We currently can't display your authorized apps, we're working to fix this. Please visit this page at a later date!",
emptyStateHeading: {
id: 'settings.authorizations.empty-state.heading',
defaultMessage: 'No authorized apps',
},
emptyStateDescription: {
id: 'settings.authorizations.empty-state.description',
defaultMessage: 'Applications you authorize will appear here.',
},
revokeConfirmTitle: {
id: 'settings.authorizations.revoke.confirm.title',
@@ -135,19 +107,10 @@ const messages = defineMessages({
id: 'settings.authorizations.revoke.action',
defaultMessage: 'Revoke',
},
byLabel: {
id: 'settings.authorizations.by',
defaultMessage: 'by',
},
aboutThisAppLabel: {
id: 'settings.authorizations.about-this-app',
defaultMessage: 'About this app',
},
})
const { scopesToDefinitions } = useScopes()
const revokingId = ref(null)
const modal_confirm = ref()
definePageMeta({
middleware: 'auth',
@@ -176,20 +139,58 @@ const { data: appCreatorsInformation } = useQuery({
})
const appInfoLookup = computed(() => {
if (!usersApps.value || !appInformation.value || !appCreatorsInformation.value) {
if (!usersApps.value || !appInformation.value) {
return []
}
return usersApps.value.map((app) => {
const info = appInformation.value.find((c) => c.id === app.app_id)
const owner = appCreatorsInformation.value.find((c) => c.id === info?.created_by)
return {
...app,
app: info || null,
owner: owner || null,
}
})
if (appInformation.value.length === 0) {
return []
}
if (!appCreatorsInformation.value) {
return []
}
return usersApps.value
.map((app) => {
const info = appInformation.value.find((c) => c.id === app.app_id)
const owner = appCreatorsInformation.value.find((c) => c.id === info?.created_by)
if (!info || !owner) {
return null
}
return {
...app,
app: info,
owner,
}
})
.filter(Boolean)
})
const showEmptyState = computed(() => {
if (!usersApps.value) {
return false
}
if (usersApps.value.length === 0) {
return true
}
if (!appInformation.value) {
return false
}
if (appInformation.value.length === 0) {
return true
}
if (!appCreatorsInformation.value) {
return false
}
return appInfoLookup.value.length === 0
})
const isLoading = computed(() => !showEmptyState.value && appInfoLookup.value.length === 0)
function onRevoke(appId) {
revokingId.value = appId
modal_confirm.value.show()
}
async function revokeApp(id) {
try {
await client.labrinth.oauth_internal.revokeAuthorization(id)
@@ -204,73 +205,3 @@ async function revokeApp(id) {
}
}
</script>
<style lang="scss" scoped>
.input-group {
// Overrides for omorphia compat
> * {
padding: var(--gap-sm) var(--gap-lg) !important;
}
}
.scope-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: var(--gap-sm);
.scope-list-item {
display: flex;
align-items: center;
gap: 0.5rem;
border-radius: 0.25rem;
background-color: var(--color-gray-200);
color: var(--color-gray-700);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
// avoid breaking text or overflowing
white-space: nowrap;
overflow: hidden;
}
.scope-list-item-icon {
width: 1.25rem;
height: 1.25rem;
flex: 0 0 auto;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-green);
color: var(--color-raised-bg);
}
}
.icon-name {
display: flex;
align-items: flex-start;
gap: var(--gap-lg);
padding-bottom: var(--gap-sm);
}
.token-content {
width: 100%;
.token-title {
margin-bottom: var(--spacing-card-xs);
}
}
.token {
display: flex;
flex-direction: column;
gap: 0.5rem;
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: flex-start;
}
}
</style>
+76 -809
View File
@@ -1,838 +1,105 @@
<template>
<div v-if="user">
<ModalCreation ref="modal_creation" />
<CollectionCreateModal ref="modal_collection_creation" />
<NewModal ref="editRoleModal" header="Edit role">
<div class="flex w-80 flex-col gap-4">
<div class="flex flex-col gap-2">
<Combobox v-model="selectedRole" :options="roleOptions" placeholder="Select a role" />
</div>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button @click="cancelRoleEdit">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
:disabled="!selectedRole || selectedRole === user.role || isSavingRole"
@click="saveRoleEdit"
>
<template v-if="isSavingRole">
<SpinnerIcon class="animate-spin" /> {{ formatMessage(messages.savingLabel) }}
</template>
<template v-else>
<SaveIcon /> {{ formatMessage(commonMessages.saveChangesButton) }}
</template>
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal v-if="auth.user && isStaff(auth.user)" ref="userDetailsModal" header="User details">
<div class="flex flex-col gap-3">
<div v-if="isAdmin(auth.user)" class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary">{{
formatMessage(commonMessages.emailLabel)
}}</span>
<div>
<span
v-tooltip="
user.email_verified
? formatMessage(messages.emailVerifiedTooltip)
: formatMessage(messages.emailNotVerifiedTooltip)
"
class="flex w-fit items-center gap-1"
>
<span>{{ user.email }}</span>
<CheckIcon v-if="user.email_verified" class="h-4 w-4 text-brand" />
<XIcon v-else class="h-4 w-4 text-red" />
</span>
</div>
</div>
<div v-if="!isAdmin(auth.user)" class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary">{{
formatMessage(messages.emailVerifiedLabel)
}}</span>
<span class="flex w-fit items-center gap-1">
<CheckIcon v-if="user.email_verified" class="h-4 w-4 text-brand" />
<XIcon v-else class="h-4 w-4 text-red" />
{{
user.email_verified
? formatMessage(commonMessages.yesLabel)
: formatMessage(commonMessages.noLabel)
}}
</span>
</div>
<div v-if="isAdmin(auth.user)" class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary">{{
formatMessage(messages.authProvidersLabel)
}}</span>
<span>{{ user.auth_providers.join(', ') }}</span>
</div>
<div v-if="isAdmin(auth.user)" class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary">{{
formatMessage(messages.paymentMethodsLabel)
}}</span>
<span>
<template v-if="user.payout_data?.paypal_address">
Paypal ({{ user.payout_data.paypal_address }} - {{ user.payout_data.paypal_country }})
</template>
<template v-if="user.payout_data?.paypal_address && user.payout_data?.venmo_address">
,
</template>
<template v-if="user.payout_data?.venmo_address">
Venmo ({{ user.payout_data.venmo_address }})
</template>
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary">{{
formatMessage(messages.hasPasswordLabel)
}}</span>
<span>
{{
user.has_password
? formatMessage(commonMessages.yesLabel)
: formatMessage(commonMessages.noLabel)
}}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary">{{
formatMessage(messages.hasTotpLabel)
}}</span>
<span>
{{
user.has_totp
? formatMessage(commonMessages.yesLabel)
: formatMessage(commonMessages.noLabel)
}}
</span>
</div>
</div>
</NewModal>
<div class="new-page sidebar" :class="{ 'alt-layout': cosmetics.leftContentLayout }">
<div class="normal-page__header py-4">
<UserPageHeader
:user="user"
:summary="isModrinthUser ? null : profileHeaderSummary"
:auth-user="auth.user"
:is-modrinth-user="isModrinthUser"
:is-official-account="isOfficialAccount"
:show-affiliate-badge="isAdminViewing && !!isAffiliate"
:is-affiliate="!!isAffiliate"
:is-self="auth.user?.id === user.id"
:is-admin="isAdminViewing"
:is-staff="!!(auth.user && isStaff(auth.user))"
:projects-count="projects?.length || 0"
:downloads="sumDownloads"
@manage-projects="navigateTo('/dashboard/projects')"
@report="reportProfileFromHeader"
@copy-id="copyId"
@copy-permalink="copyPermalink"
@open-billing="navigateTo(`/admin/billing/${user.id}`)"
@toggle-affiliate="toggleAffiliate(user.id)"
@open-info="userDetailsModal?.show()"
@open-analytics="
navigateTo({
path: '/dashboard/analytics',
query: { user: user.username || user.id },
})
"
@edit-role="openRoleEditModal"
>
<template v-if="isModrinthUser" #summary>
<IntlFormatted :message-id="messages.officialAccountBio">
<template #support-link>
<a
href="https://support.modrinth.com"
class="text-link"
target="_blank"
rel="noopener noreferrer"
>
https://support.modrinth.com
</a>
</template>
<template #email>
<a
href="mailto:support@modrinth.com"
class="text-link"
target="_blank"
rel="noopener noreferrer"
>
support@modrinth.com
</a>
</template>
</IntlFormatted>
</template>
</UserPageHeader>
</div>
<div class="normal-page__content">
<div v-if="navLinks.length > 2" class="mb-4 max-w-full overflow-x-auto">
<NavTabs :links="navLinks" replace />
</div>
<div v-if="projects?.length > 0">
<ProjectCardList
v-if="route.params.projectType !== 'collections'"
:layout="cosmetics.searchDisplayMode.user"
>
<ProjectCard
v-for="project in (route.params.projectType !== undefined
? projects.filter(
(x) =>
x.project_type ===
route.params.projectType.substr(0, route.params.projectType.length - 1),
)
: projects
)
.slice()
.sort(projectUserSorting)"
:key="project.id"
:link="`/${project.project_type ?? 'project'}/${project.slug ? project.slug : project.id}`"
:title="project.title"
:icon-url="project.icon_url"
:date-updated="project.updated"
:downloads="project.downloads"
:summary="project.description"
:tags="[...project.categories]"
:all-tags="[
...project.categories,
...project.loaders,
...project.additional_categories,
]"
:followers="project.followers"
:banner="project.gallery.find((element) => element.featured)?.url"
:color="project.color ?? undefined"
:environment="{
clientSide: project.client_side,
serverSide: project.server_side,
}"
:layout="
cosmetics.searchDisplayMode.user === 'grid' ||
cosmetics.searchDisplayMode.user === 'gallery'
? 'grid'
: 'list'
"
:status="project.status"
/>
</ProjectCardList>
</div>
<div
v-else-if="
(route.params.projectType && route.params.projectType !== 'collections') ||
(!route.params.projectType && collections?.length === 0)
"
class="error"
>
<UpToDate class="icon" />
<br />
<span v-if="auth.user && auth.user.id === user.id" class="preserve-lines text">
<IntlFormatted :message-id="messages.profileNoProjectsAuthLabel">
<template #create-link="{ children }">
<a class="link" @click.prevent="$refs.modal_creation.show()">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</span>
<span v-else class="text">{{ formatMessage(messages.profileNoProjectsLabel) }}</span>
</div>
<div
v-if="!route.params.projectType || route.params.projectType === 'collections'"
class="collections-grid"
:class="{ 'mt-3': projects?.length > 0 }"
>
<nuxt-link
v-for="collection in sortedCollections"
:key="collection.id"
:to="`/collection/${collection.id}`"
class="card collection-item"
>
<div class="collection">
<Avatar :src="collection.icon_url" size="64px" />
<div class="details">
<h2 class="title">{{ collection.name }}</h2>
<div class="stats">
<LibraryIcon aria-hidden="true" />
{{ formatMessage(messages.collectionLabel) }}
</div>
</div>
</div>
<div class="description">
{{ collection.description }}
</div>
<div class="stat-bar">
<div class="stats">
<BoxIcon />
{{
formatMessage(messages.collectionProjectsCount, {
count: collection.projects?.length || 0,
})
}}
</div>
<div class="stats">
<template v-if="collection.status === 'listed'">
<GlobeIcon />
<span> {{ formatMessage(commonMessages.publicLabel) }} </span>
</template>
<template v-else-if="collection.status === 'unlisted'">
<LinkIcon />
<span> {{ formatMessage(commonMessages.unlistedLabel) }} </span>
</template>
<template v-else-if="collection.status === 'private'">
<LockIcon />
<span> {{ formatMessage(commonMessages.privateLabel) }} </span>
</template>
<template v-else-if="collection.status === 'rejected'">
<XIcon />
<span> {{ formatMessage(commonMessages.rejectedLabel) }} </span>
</template>
</div>
</div>
</nuxt-link>
</div>
<div
v-if="route.params.projectType === 'collections' && collections?.length === 0"
class="error"
>
<UpToDate class="icon" />
<br />
<span v-if="auth.user && auth.user.id === user.id" class="preserve-lines text">
<IntlFormatted :message-id="messages.profileNoCollectionsAuthLabel">
<template #create-link="{ children }">
<a
class="link"
@click.prevent="(event) => $refs.modal_collection_creation.show(event)"
>
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</span>
<span v-else class="text">{{ formatMessage(messages.profileNoCollectionsLabel) }}</span>
</div>
</div>
<div class="normal-page__sidebar">
<div
v-if="organizations?.length > 0"
class="mb-4 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4 pt-3"
>
<h2 class="m-0 mb-2 text-lg font-semibold text-contrast">
{{ formatMessage(messages.profileOrganizations) }}
</h2>
<div class="flex flex-wrap gap-2">
<nuxt-link
v-for="org in sortedOrgs"
:key="org.id"
v-tooltip="org.name"
class="organization"
:to="`/organization/${org.slug}`"
>
<Avatar :src="org.icon_url" :alt="'Icon for ' + org.name" size="3rem" />
</nuxt-link>
</div>
</div>
<UserBadges
:downloads="sumDownloads"
:join-date="joinDate"
:role="user.role"
:badges="user.badges"
:has-midas="hasActiveMidas(user)"
:has-pride="hasPride26Badge(user)"
:earliest-project-by-type="earliestProjectByType"
class="mb-4 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4 pt-3"
/>
<AdPlaceholder v-if="!auth.user" />
</div>
</div>
</div>
<ProjectCreateModal ref="projectCreateModal" />
<CollectionCreateModal ref="collectionCreateModal" />
<UserProfilePageLayout
:user-id="userId"
:project-type="projectType"
:display-mode="cosmetics.searchDisplayMode.user"
:sidebar-position="cosmetics.leftContentLayout ? 'left' : 'right'"
:site-url="config.public.siteUrl"
:on-create-project="openProjectCreateModal"
:on-create-collection="openCollectionCreateModal"
>
<template #sidebar>
<AdPlaceholder v-if="!auth.user" />
</template>
</UserProfilePageLayout>
</template>
<script setup>
import {
BoxIcon,
CheckIcon,
GlobeIcon,
LibraryIcon,
LinkIcon,
LockIcon,
SaveIcon,
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
Combobox,
commonMessages,
defineMessages,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
NavTabs,
NewModal,
ProjectCard,
ProjectCardList,
UserBadges,
useVIntl,
} from '@modrinth/ui'
import { isAdmin, isStaff, UserBadge } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { onServerPrefetch } from 'vue'
import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { injectModrinthClient, provideUserProfile, UserProfilePageLayout } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
import UserPageHeader from '~/components/ui/UserPageHeader.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { projectUserSorting } from '~/utils/projects.ts'
import { reportUser } from '~/utils/report-helpers.ts'
import { hasActiveMidas, hasPride26Badge } from '~/utils/user-membership.ts'
import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
const data = useNuxtApp()
const route = useNativeRoute()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const userProfile = provideUserProfile({
getUser: (userId) => client.labrinth.users_v3.get(userId),
getProjects: (userId) => client.labrinth.users_v2.getProjects(userId),
getOrganizations: (userId) => client.labrinth.users_v2.getOrganizations(userId),
getCollections: (userId) => client.labrinth.users_v2.getCollections(userId),
patchUser: (userId, patch) => client.labrinth.users_v2.patch(userId, patch),
})
const auth = await useAuth()
const cosmetics = useCosmetics()
const tags = useGeneratedState()
const config = useRuntimeConfig()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const messages = defineMessages({
collectionProjectsCount: {
id: 'profile.collection.projects-count',
defaultMessage: '{count, plural, one {# project} other {# projects}}',
},
savingLabel: {
id: 'profile.label.saving',
defaultMessage: 'Saving...',
},
emailLabel: {
id: 'profile.details.label.email',
defaultMessage: 'Email',
},
emailVerifiedLabel: {
id: 'profile.details.label.email-verified',
defaultMessage: 'Email verified',
},
emailVerifiedTooltip: {
id: 'profile.details.tooltip.email-verified',
defaultMessage: 'Email verified',
},
emailNotVerifiedTooltip: {
id: 'profile.details.tooltip.email-not-verified',
defaultMessage: 'Email not verified',
},
authProvidersLabel: {
id: 'profile.details.label.auth-providers',
defaultMessage: 'Auth providers',
},
paymentMethodsLabel: {
id: 'profile.details.label.payment-methods',
defaultMessage: 'Payment methods',
},
hasPasswordLabel: {
id: 'profile.details.label.has-password',
defaultMessage: 'Has password',
},
hasTotpLabel: {
id: 'profile.details.label.has-totp',
defaultMessage: 'Has TOTP',
},
bioFallbackUser: {
id: 'profile.bio.fallback.user',
defaultMessage: 'A Modrinth user.',
},
bioFallbackCreator: {
id: 'profile.bio.fallback.creator',
defaultMessage: 'A Modrinth creator.',
},
collectionLabel: {
id: 'profile.label.collection',
defaultMessage: 'Collection',
},
profileProjectsFollowersStats: {
id: 'profile.stats.projects-followers',
defaultMessage:
'{count, plural, one {<stat>{count}</stat> project follower} other {<stat>{count}</stat> project followers}}',
},
profileUserId: {
id: 'profile.user-id',
defaultMessage: 'User ID: {id}',
},
profileOrganizations: {
id: 'profile.label.organizations',
defaultMessage: 'Organizations',
},
profileBadges: {
id: 'profile.label.badges',
defaultMessage: 'Badges',
},
profileMetaDescription: {
id: 'profile.meta.description',
defaultMessage: "Download {username}'s projects on Modrinth",
},
profileMetaDescriptionWithBio: {
id: 'profile.meta.description-with-bio',
defaultMessage: "{bio} - Download {username}'s projects on Modrinth",
},
profileNoProjectsLabel: {
id: 'profile.label.no-projects',
defaultMessage: 'This user has no projects!',
},
profileNoProjectsAuthLabel: {
id: 'profile.label.no-projects-auth',
defaultMessage:
"You don't have any projects.\nWould you like to <create-link>create one</create-link>?",
},
profileNoCollectionsLabel: {
id: 'profile.label.no-collections',
defaultMessage: 'This user has no collections!',
},
profileNoCollectionsAuthLabel: {
id: 'profile.label.no-collections-auth',
defaultMessage:
"You don't have any collections.\nWould you like to <create-link>create one</create-link>?",
},
userNotFoundError: {
id: 'profile.error.not-found',
defaultMessage: 'User not found',
},
officialAccountBio: {
id: 'profile.official-account.bio',
defaultMessage:
'The official user account of Modrinth. Get support at <support-link></support-link> or via email at <email></email>',
},
const userId = computed(() => String(route.params.user))
const projectType = computed(() => {
const value = route.params.projectType
return Array.isArray(value) ? value[0] : value
})
const client = injectModrinthClient()
const userId = useRouteId('user')
const {
data: user,
error: userError,
suspense: userSuspense,
} = useQuery({
queryKey: computed(() => ['user', userId]),
queryFn: () => client.labrinth.users_v3.get(userId),
})
watch(
userError,
(error) => {
if (error) {
const status = error.statusCode ?? error.status ?? 404
showError({
fatal: true,
statusCode: status,
message: formatMessage(messages.userNotFoundError),
})
}
},
{ immediate: true },
)
const { data: projects, suspense: projectsSuspense } = useQuery({
queryKey: computed(() => ['user', userId, 'projects']),
queryFn: async () => {
const projects = await client.labrinth.users_v2.getProjects(userId)
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
project.project_type = data.$getProjectTypeForUrl(
project.project_type,
project.categories,
tags.value,
)
}
return projects
},
})
const { data: organizations, suspense: orgsSuspense } = useQuery({
queryKey: computed(() => ['user', userId, 'organizations']),
queryFn: () => client.labrinth.users_v2.getOrganizations(userId),
})
const { data: collections, suspense: collectionsSuspense } = useQuery({
queryKey: computed(() => ['user', userId, 'collections']),
queryFn: () => client.labrinth.users_v2.getCollections(userId),
})
onServerPrefetch(async () => {
await Promise.allSettled([
userSuspense(),
projectsSuspense(),
orgsSuspense(),
collectionsSuspense(),
])
})
const sortedOrgs = computed(() =>
organizations.value ? [...organizations.value].sort((a, b) => a.name.localeCompare(b.name)) : [],
)
const isModrinthUser = computed(() => user.value?.id === '2REoufqX')
const isAutoMod = computed(() => user.value?.id === '')
const isOfficialAccount = computed(
() => isModrinthUser.value || isAutoMod.value || user.value?.id === 'GVFjtWTf',
)
const sortedCollections = computed(() => {
const list = collections.value
if (!list?.length) return []
return [...list].sort((a, b) => {
const updatedB = new Date(b.updated).getTime()
const updatedA = new Date(a.updated).getTime()
if (updatedB !== updatedA) return updatedB - updatedA
return new Date(b.created).getTime() - new Date(a.created).getTime()
let prefetchedUser: Labrinth.Users.v3.User | undefined
try {
prefetchedUser = await queryClient.ensureQueryData({
queryKey: ['user', userId.value],
queryFn: () => userProfile.getUser(userId.value),
staleTime: 30_000,
})
})
} catch {
// Let the mounted layout's useQuery surface errors; do not fail route setup.
}
const title = computed(() => (user.value ? `${user.value.username} - Modrinth` : 'Modrinth'))
const description = computed(() =>
user.value?.bio
? formatMessage(messages.profileMetaDescriptionWithBio, {
bio: user.value.bio,
username: user.value.username,
})
: user.value
? formatMessage(messages.profileMetaDescription, { username: user.value.username })
: '',
await Promise.allSettled([
queryClient.ensureQueryData({
queryKey: ['user', userId.value, 'projects'],
queryFn: () => userProfile.getProjects(userId.value),
staleTime: 30_000,
}),
queryClient.ensureQueryData({
queryKey: ['user', userId.value, 'organizations'],
queryFn: () => userProfile.getOrganizations(userId.value),
staleTime: 30_000,
}),
queryClient.ensureQueryData({
queryKey: ['user', userId.value, 'collections'],
queryFn: () => userProfile.getCollections(userId.value),
staleTime: 30_000,
}),
])
const title = computed(() =>
prefetchedUser ? `${prefetchedUser.username} - Modrinth` : 'Modrinth',
)
const description = computed(() => {
if (!prefetchedUser) return ''
return prefetchedUser.bio
? `${prefetchedUser.bio} - Download ${prefetchedUser.username}'s projects on Modrinth`
: `Download ${prefetchedUser.username}'s projects on Modrinth`
})
useSeoMeta({
title: () => title.value,
description: () => description.value,
ogTitle: () => title.value,
ogDescription: () => description.value,
ogImage: () => user.value?.avatar_url ?? 'https://cdn.modrinth.com/placeholder.png',
ogImage: () => prefetchedUser?.avatar_url ?? 'https://cdn.modrinth.com/placeholder.png',
})
const projectTypes = computed(() => {
const obj = {}
const projectCreateModal = ref<InstanceType<typeof ProjectCreateModal> | null>(null)
const collectionCreateModal = ref<InstanceType<typeof CollectionCreateModal> | null>(null)
if (collections.value?.length > 0) {
obj.collection = true
}
for (const project of projects.value ?? []) {
obj[project.project_type] = true
}
delete obj.project
return Object.keys(obj)
})
const sumDownloads = computed(() => {
let sum = 0
for (const project of projects.value ?? []) {
sum += project.downloads
}
return sum
})
const joinDate = computed(() => new Date(user.value.created))
async function copyId() {
await navigator.clipboard.writeText(user.value.id)
function openProjectCreateModal(event?: MouseEvent) {
projectCreateModal.value?.show(event)
}
const earliestProjectByType = computed(() => {
const obj = {}
for (const project of projects.value ?? []) {
obj[project.project_type] = new Date(project.published)
}
return obj
})
async function copyPermalink() {
await navigator.clipboard.writeText(`${config.public.siteUrl}/user/${user.value.id}`)
}
function reportProfileFromHeader() {
if (!user.value) return
if (auth.value.user) {
reportUser(user.value.id)
} else {
navigateTo(getSignInRouteObj(route))
}
}
const isAffiliate = computed(() => user.value?.badges & UserBadge.AFFILIATE)
const isAdminViewing = computed(() => isAdmin(auth.value.user))
const userDetailsModal = useTemplateRef('userDetailsModal')
async function toggleAffiliate(id) {
await client.labrinth.users_v2.patch(id, { badges: user.value.badges ^ (1 << 7) })
queryClient.invalidateQueries({ queryKey: ['user', userId] })
}
const profileHeaderSummary = computed(() =>
user.value?.bio
? user.value.bio
: (projects.value?.length ?? 0) === 0
? formatMessage(messages.bioFallbackUser)
: formatMessage(messages.bioFallbackCreator),
)
const navLinks = computed(() => [
{
label: formatMessage(commonMessages.allProjectType),
href: `/user/${user.value.username}`,
},
...projectTypes.value
.map((x) => {
return {
label: formatMessage(getProjectTypeMessage(x, true)),
href: `/user/${user.value.username}/${x}s`,
}
})
.slice()
.sort((a, b) => a.label.localeCompare(b.label)),
])
const selectedRole = ref(user.value?.role)
const isSavingRole = ref(false)
const roleOptions = [
{ value: 'developer', label: 'Developer' },
{ value: 'moderator', label: 'Moderator' },
{ value: 'admin', label: 'Admin' },
]
const editRoleModal = useTemplateRef('editRoleModal')
const openRoleEditModal = () => {
selectedRole.value = user.value.role
editRoleModal.value?.show()
}
const cancelRoleEdit = () => {
selectedRole.value = user.value.role
editRoleModal.value?.hide()
}
function saveRoleEdit() {
if (!selectedRole.value || selectedRole.value === user.value.role) {
return
}
isSavingRole.value = true
client.labrinth.users_v2
.patch(user.value.id, { role: selectedRole.value })
.then(() => {
user.value.role = selectedRole.value
editRoleModal.value?.hide()
})
.catch((error) => {
console.error('Failed to update user role:', error)
addNotification({
type: 'error',
title: 'Failed to update role',
message: 'An error occurred while updating the user role. Please try again.',
})
})
.finally(() => {
isSavingRole.value = false
})
function openCollectionCreateModal(event?: MouseEvent) {
collectionCreateModal.value?.show(event)
}
</script>
<script>
export default defineNuxtComponent({
methods: {},
})
</script>
<style lang="scss" scoped>
.collections-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@media screen and (max-width: 800px) {
grid-template-columns: repeat(1, 1fr);
}
gap: var(--gap-md);
.collection-item {
display: flex;
flex-direction: column;
gap: var(--gap-md);
margin-bottom: 0px;
}
.description {
flex-grow: 1;
color: var(--color-text);
font-size: 16px;
}
.stat-bar {
display: flex;
align-items: center;
gap: var(--gap-md);
margin-top: auto;
}
.stats {
display: flex;
align-items: center;
gap: var(--gap-xs);
svg {
color: var(--color-secondary);
}
}
.collection {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--gap-md);
.icon {
width: 100% !important;
height: 6rem !important;
max-width: unset !important;
max-height: unset !important;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.details {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
.title {
color: var(--color-contrast);
font-weight: 700;
font-size: var(--font-size-lg);
margin: 0;
}
}
}
}
.new-page {
column-gap: 1.5rem;
}
</style>
+10
View File
@@ -32,6 +32,16 @@ TYPESENSE_URL=http://localhost:8108
TYPESENSE_API_KEY=modrinth
TYPESENSE_INDEX_PREFIX=labrinth
TYPESENSE_IMPORT_BATCH_SIZE=5000
SEARCH_TYPESENSE_DEFAULT_QUERY_BY=name,indexed_name,slug,author,indexed_author,summary
SEARCH_TYPESENSE_DEFAULT_QUERY_BY_WEIGHTS=[15,15,10,3,3,1]
SEARCH_TYPESENSE_DEFAULT_PREFIX=[true,true,true,true,true,true]
SEARCH_TYPESENSE_DEFAULT_PRIORITIZE_EXACT_MATCH=true
SEARCH_TYPESENSE_DEFAULT_PRIORITIZE_NUM_MATCHING_FIELDS=false
SEARCH_TYPESENSE_DEFAULT_PRIORITIZE_TOKEN_POSITIONS=true
SEARCH_TYPESENSE_DEFAULT_DROP_TOKENS_THRESHOLD=0
SEARCH_TYPESENSE_DEFAULT_TEXT_MATCH_TYPE=max_weight
SEARCH_TYPESENSE_DEFAULT_BUCKETING='{"buckets":5}'
SEARCH_TYPESENSE_DEFAULT_MAX_CANDIDATES=24
REDIS_MODE=standalone
REDIS_CONNECTION_TYPE=pooled
+10
View File
@@ -50,6 +50,16 @@ TYPESENSE_URL=http://localhost:8108
TYPESENSE_API_KEY=modrinth
TYPESENSE_INDEX_PREFIX=labrinth
TYPESENSE_IMPORT_BATCH_SIZE=5000
SEARCH_TYPESENSE_DEFAULT_QUERY_BY=name,indexed_name,slug,author,indexed_author,summary
SEARCH_TYPESENSE_DEFAULT_QUERY_BY_WEIGHTS=[15,15,10,3,3,1]
SEARCH_TYPESENSE_DEFAULT_PREFIX=[true,true,true,true,true,true]
SEARCH_TYPESENSE_DEFAULT_PRIORITIZE_EXACT_MATCH=true
SEARCH_TYPESENSE_DEFAULT_PRIORITIZE_NUM_MATCHING_FIELDS=false
SEARCH_TYPESENSE_DEFAULT_PRIORITIZE_TOKEN_POSITIONS=true
SEARCH_TYPESENSE_DEFAULT_DROP_TOKENS_THRESHOLD=0
SEARCH_TYPESENSE_DEFAULT_TEXT_MATCH_TYPE=max_weight
SEARCH_TYPESENSE_DEFAULT_BUCKETING='{"buckets":5}'
SEARCH_TYPESENSE_DEFAULT_MAX_CANDIDATES=24
REDIS_MODE=standalone
REDIS_CONNECTION_TYPE=pooled
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO blocked_users (user_id, blocked_id)\n VALUES ($1, $2)\n ON CONFLICT (user_id, blocked_id) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "57895dd22cb3f6d954ed742a730edece682a771d504ef209cbece81c46e07efb"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM blocked_users\n WHERE user_id = $1 AND blocked_id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "c08a594796f4af4b7cc536c672731756440e8d1165e88dc644f898a9e40abc48"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT 1 FROM blocked_users\n WHERE user_id = $1 AND blocked_id = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
null
]
},
"hash": "c88172a879d9c2e9cb7fa1480e274e650cd29bc04083708a5fc8580c25be7747"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT blocked_id FROM blocked_users\n WHERE user_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "blocked_id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "d446d0fe439ea6d8ea7df14419955d93660afa7b634cb7b5edb77c277d89e986"
}
@@ -0,0 +1,5 @@
CREATE TABLE blocked_users (
user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
blocked_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
PRIMARY KEY (user_id, blocked_id)
);
@@ -0,0 +1,94 @@
use crate::database::models::DBUserId;
pub struct DBBlockedUser {
pub user_id: DBUserId,
pub blocked_id: DBUserId,
}
impl DBBlockedUser {
pub async fn insert<'a, E>(&self, exec: E) -> Result<(), sqlx::Error>
where
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
{
sqlx::query!(
"
INSERT INTO blocked_users (user_id, blocked_id)
VALUES ($1, $2)
ON CONFLICT (user_id, blocked_id) DO NOTHING
",
self.user_id.0,
self.blocked_id.0,
)
.execute(exec)
.await?;
Ok(())
}
pub async fn is_blocked<'a, E>(
user_id: DBUserId,
blocked_id: DBUserId,
exec: E,
) -> Result<bool, sqlx::Error>
where
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
{
let blocked = sqlx::query_scalar!(
"
SELECT 1 FROM blocked_users
WHERE user_id = $1 AND blocked_id = $2
",
user_id.0,
blocked_id.0,
)
.fetch_optional(exec)
.await?;
Ok(blocked.is_some())
}
pub async fn get_blocked_for_user<'a, E>(
user_id: DBUserId,
exec: E,
) -> Result<Vec<DBUserId>, sqlx::Error>
where
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
{
let blocked = sqlx::query_scalar!(
"
SELECT blocked_id FROM blocked_users
WHERE user_id = $1
",
user_id.0,
)
.fetch_all(exec)
.await?
.into_iter()
.map(DBUserId)
.collect();
Ok(blocked)
}
pub async fn remove<'a, E>(
user_id: DBUserId,
blocked_id: DBUserId,
exec: E,
) -> Result<(), sqlx::Error>
where
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
{
sqlx::query!(
"
DELETE FROM blocked_users
WHERE user_id = $1 AND blocked_id = $2
",
user_id.0,
blocked_id.0,
)
.execute(exec)
.await?;
Ok(())
}
}
+1
View File
@@ -2,6 +2,7 @@ use thiserror::Error;
pub mod affiliate_code_item;
pub mod analytics_event_item;
pub mod blocked_user_item;
pub mod categories;
pub mod charge_item;
pub mod collection_item;
+21
View File
@@ -216,6 +216,27 @@ vars! {
TYPESENSE_IMPORT_BATCH_SIZE: usize = 5000usize;
TYPESENSE_DELETE_BATCH_SIZE: usize = 10_000usize;
TYPESENSE_USE_CACHE: bool = true;
SEARCH_TYPESENSE_DEFAULT_QUERY_BY: StringCsv = StringCsv(vec![
"name".into(),
"indexed_name".into(),
"slug".into(),
"author".into(),
"indexed_author".into(),
"summary".into(),
]);
SEARCH_TYPESENSE_DEFAULT_QUERY_BY_WEIGHTS: Json<Vec<u8>> =
Json(vec![15, 15, 10, 3, 3, 1]);
SEARCH_TYPESENSE_DEFAULT_PREFIX: Json<Vec<bool>> =
Json(vec![true, true, true, true, true, true]);
SEARCH_TYPESENSE_DEFAULT_PRIORITIZE_EXACT_MATCH: bool = true;
SEARCH_TYPESENSE_DEFAULT_PRIORITIZE_NUM_MATCHING_FIELDS: bool = false;
SEARCH_TYPESENSE_DEFAULT_PRIORITIZE_TOKEN_POSITIONS: bool = true;
SEARCH_TYPESENSE_DEFAULT_DROP_TOKENS_THRESHOLD: usize = 0usize;
SEARCH_TYPESENSE_DEFAULT_TEXT_MATCH_TYPE: crate::search::backend::typesense::TextMatchType =
crate::search::backend::typesense::TextMatchType::MaxWeight;
SEARCH_TYPESENSE_DEFAULT_BUCKETING: Json<crate::search::backend::typesense::Bucketing> =
Json(crate::search::backend::typesense::Bucketing::Buckets(5));
SEARCH_TYPESENSE_DEFAULT_MAX_CANDIDATES: usize = 24usize;
// storage
STORAGE_BACKEND: crate::file_hosting::FileHostKind = crate::file_hosting::FileHostKind::Local;
-1
View File
@@ -24,7 +24,6 @@ pub struct LegacyUser {
pub has_totp: Option<bool>,
pub payout_data: Option<UserPayoutData>, // this was changed in v3, but not ones we want to keep out of v2
// DEPRECATED. Always returns None
pub github_id: Option<u64>,
}
+8 -1
View File
@@ -70,8 +70,11 @@ pub struct User {
#[serde(skip_serializing_if = "Option::is_none")]
pub moderation_notes: Option<Option<ModerationNote>>,
// DEPRECATED. Always returns None
pub github_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub discord_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub steam_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -119,6 +122,8 @@ impl From<DBUser> for User {
has_password: None,
has_totp: None,
github_id: None,
discord_id: None,
steam_id: None,
stripe_customer_id: None,
allow_friend_requests: None,
eligibility_verified_at: None,
@@ -180,6 +185,8 @@ impl User {
has_password: Some(db_user.password.is_some()),
has_totp: Some(db_user.totp_secret.is_some()),
github_id: None,
discord_id: None,
steam_id: None,
payout_data: Some(UserPayoutData {
paypal_address: db_user.paypal_email,
paypal_country: db_user.paypal_country,
@@ -0,0 +1,41 @@
use crate::database::PgPool;
use crate::database::models::DBUserId;
use crate::database::models::blocked_user_item::DBBlockedUser;
use crate::routes::ApiError;
use crate::util::guards::admin_key_guard;
use actix_web::{get, web};
use ariadne::ids::base62_impl::parse_base62;
use serde::Serialize;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(block_status);
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct BlockStatus {
pub blocked: bool,
}
/// Check whether one user has blocked another.
#[utoipa::path(tag = "blocked_users", responses((status = OK, body = BlockStatus)))]
#[get("/block/{user_id}/{target_id}", guard = "admin_key_guard")]
pub async fn block_status(
info: web::Path<(String, String)>,
pool: web::Data<PgPool>,
) -> Result<web::Json<BlockStatus>, ApiError> {
let (user_id, target_id) = info.into_inner();
let user_id =
DBUserId(parse_base62(&user_id).map_err(|_| {
ApiError::InvalidInput("invalid user_id".to_string())
})? as i64);
let target_id =
DBUserId(parse_base62(&target_id).map_err(|_| {
ApiError::InvalidInput("invalid target_id".to_string())
})? as i64);
let blocked =
DBBlockedUser::is_blocked(user_id, target_id, &**pool).await?;
Ok(web::Json(BlockStatus { blocked }))
}
+3
View File
@@ -2,6 +2,7 @@ pub mod admin;
pub mod affiliate;
pub mod attribution;
pub mod billing;
pub mod blocked_users;
pub mod campaign;
pub mod delphi;
pub mod external_notifications;
@@ -29,6 +30,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
web::scope("/_internal")
.wrap(default_cors())
.configure(admin::config)
.configure(blocked_users::config)
.configure(session::config)
.configure(flows::config)
.configure(pats::config)
@@ -65,6 +67,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
),
paths(
admin::count_download,
blocked_users::block_status,
admin::force_reindex,
admin::force_reindex_project,
session::list,
@@ -0,0 +1,121 @@
use crate::auth::get_user_from_headers;
use crate::database::PgPool;
use crate::database::models::DBUser;
use crate::database::models::blocked_user_item::DBBlockedUser;
use crate::database::models::friend_item::DBFriend;
use crate::models::pats::Scopes;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use actix_web::{HttpRequest, delete, get, post, web};
use ariadne::ids::UserId;
use eyre::eyre;
use xredis::RedisPool;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(block_user);
cfg.service(unblock_user);
cfg.service(get_blocked_users);
}
/// Block a user.
#[utoipa::path(tag = "blocked_users", responses((status = NO_CONTENT)))]
#[post("/block/{id}")]
pub async fn block_user(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<(), ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::USER_WRITE,
)
.await?
.1;
let user_id = info.into_inner().0;
let Some(blocked) = DBUser::get(&user_id, &**pool, &redis).await? else {
return Err(ApiError::NotFound);
};
if blocked.id == user.id.into() {
return Err(ApiError::Request(eyre!("you cannot block yourself")));
}
let mut transaction = pool.begin().await?;
DBFriend::remove(user.id.into(), blocked.id, &mut transaction).await?;
DBBlockedUser {
user_id: user.id.into(),
blocked_id: blocked.id,
}
.insert(&mut transaction)
.await?;
transaction.commit().await?;
Ok(())
}
/// Unblock a user.
#[utoipa::path(tag = "blocked_users", responses((status = NO_CONTENT)))]
#[delete("/block/{id}")]
pub async fn unblock_user(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<(), ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::USER_WRITE,
)
.await?
.1;
let user_id = info.into_inner().0;
let Some(blocked) = DBUser::get(&user_id, &**pool, &redis).await? else {
return Err(ApiError::NotFound);
};
DBBlockedUser::remove(user.id.into(), blocked.id, &**pool).await?;
Ok(())
}
/// List the users blocked by the current user.
#[utoipa::path(tag = "blocked_users", responses((status = OK, body = Vec<UserId>)))]
#[get("/blocks")]
pub async fn get_blocked_users(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<Vec<UserId>>, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::USER_READ,
)
.await?
.1;
let blocked = DBBlockedUser::get_blocked_for_user(user.id.into(), &**pool)
.await?
.into_iter()
.map(UserId::from)
.collect();
Ok(web::Json(blocked))
}
+13
View File
@@ -1,5 +1,6 @@
use crate::auth::get_user_from_headers;
use crate::database::PgPool;
use crate::database::models::blocked_user_item::DBBlockedUser;
use crate::database::models::friend_item::DBFriend;
use crate::database::models::{DBUser, DBUserId};
use crate::models::pats::Scopes;
@@ -49,6 +50,18 @@ pub async fn add_friend(
return Err(ApiError::NotFound);
};
if DBBlockedUser::is_blocked(friend.id, user.id.into(), &**pool).await? {
return Err(ApiError::InvalidInput(
"You've been blocked the other user!".to_string(),
));
} else if DBBlockedUser::is_blocked(user.id.into(), friend.id, &**pool)
.await?
{
return Err(ApiError::InvalidInput(
"You've blocked the other user!".to_string(),
));
}
let mut transaction = pool.begin().await?;
if let Some(friend) =
+5
View File
@@ -6,6 +6,7 @@ use serde_json::json;
pub mod analytics_event;
pub mod analytics_get;
pub mod blocked_users;
pub mod collections;
pub mod content;
pub mod friends;
@@ -66,6 +67,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(version_file::config)
.configure(versions::config)
.configure(friends::config)
.configure(blocked_users::config)
.configure(content::config),
);
}
@@ -227,6 +229,9 @@ pub fn config(cfg: &mut web::ServiceConfig) {
friends::add_friend,
friends::remove_friend,
friends::friends,
blocked_users::block_user,
blocked_users::unblock_user,
blocked_users::get_blocked_users,
content::resolve_content,
),
modifiers(&V3PathModifier, &SecurityAddon)
+9 -1
View File
@@ -481,7 +481,15 @@ pub async fn user_get(
let user_id = data.id;
let mut response: crate::models::users::User = if is_admin {
crate::models::users::User::from_full(data)
let github_id =
data.github_id.and_then(|id| u64::try_from(id).ok());
let discord_id = data.discord_id.map(|id| id.to_string());
let steam_id = data.steam_id.map(|id| id.to_string());
let mut user = crate::models::users::User::from_full(data);
user.github_id = github_id;
user.discord_id = discord_id;
user.steam_id = steam_id;
user
} else {
data.into()
};
@@ -191,8 +191,9 @@ pub enum Bucketing {
BucketSize(u64),
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
#[derive(Serialize, Deserialize, Debug, Clone, Default, strum::EnumString)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum TextMatchType {
MaxScore,
#[default]
@@ -232,9 +233,9 @@ pub struct RequestConfig {
pub prioritize_token_positions: bool,
#[serde(default = "default_drop_tokens_threshold")]
pub drop_tokens_threshold: usize,
#[serde(default)]
#[serde(default = "default_text_match_type")]
pub text_match_type: TextMatchType,
#[serde(default)]
#[serde(default = "default_bucketing")]
pub bucketing: Bucketing,
#[serde(default = "default_max_candidates")]
pub max_candidates: usize,
@@ -251,55 +252,51 @@ impl Default for RequestConfig {
default_prioritize_num_matching_fields(),
prioritize_token_positions: default_prioritize_token_positions(),
drop_tokens_threshold: default_drop_tokens_threshold(),
text_match_type: TextMatchType::default(),
bucketing: Bucketing::default(),
text_match_type: default_text_match_type(),
bucketing: default_bucketing(),
max_candidates: default_max_candidates(),
}
}
}
fn default_query_by() -> Vec<String> {
[
"name",
"indexed_name",
"slug",
"author",
"indexed_author",
"summary",
]
.into_iter()
.map(str::to_string)
.collect()
ENV.SEARCH_TYPESENSE_DEFAULT_QUERY_BY.0.clone()
}
fn default_query_by_weights() -> Vec<u8> {
vec![15, 15, 10, 3, 3, 1]
ENV.SEARCH_TYPESENSE_DEFAULT_QUERY_BY_WEIGHTS.0.clone()
}
fn default_prefix() -> Vec<bool> {
vec![true, true, true, true, true, true]
ENV.SEARCH_TYPESENSE_DEFAULT_PREFIX.0.clone()
}
const fn default_prioritize_exact_match() -> bool {
true
fn default_prioritize_exact_match() -> bool {
ENV.SEARCH_TYPESENSE_DEFAULT_PRIORITIZE_EXACT_MATCH
}
const fn default_prioritize_num_matching_fields() -> bool {
false
fn default_prioritize_num_matching_fields() -> bool {
ENV.SEARCH_TYPESENSE_DEFAULT_PRIORITIZE_NUM_MATCHING_FIELDS
}
const fn default_prioritize_token_positions() -> bool {
true
// false
fn default_prioritize_token_positions() -> bool {
ENV.SEARCH_TYPESENSE_DEFAULT_PRIORITIZE_TOKEN_POSITIONS
}
const fn default_drop_tokens_threshold() -> usize {
0
// 1
fn default_drop_tokens_threshold() -> usize {
ENV.SEARCH_TYPESENSE_DEFAULT_DROP_TOKENS_THRESHOLD
}
const fn default_max_candidates() -> usize {
8
fn default_text_match_type() -> TextMatchType {
ENV.SEARCH_TYPESENSE_DEFAULT_TEXT_MATCH_TYPE.clone()
}
fn default_bucketing() -> Bucketing {
ENV.SEARCH_TYPESENSE_DEFAULT_BUCKETING.0.clone()
}
fn default_max_candidates() -> usize {
ENV.SEARCH_TYPESENSE_DEFAULT_MAX_CANDIDATES
}
impl TypesenseConfig {
+19 -2
View File
@@ -2,7 +2,8 @@ use std::time::Instant;
use eyre::{WrapErr, eyre};
use neverbounce::{
ResponseStatus, SingleCheckParams, SingleCheckResponse, VerificationResult,
ReqwestErrorReason, ResponseStatus, SingleCheckParams, SingleCheckResponse,
VerificationResult,
};
use tracing::{debug, error};
@@ -27,11 +28,21 @@ pub async fn check_email(email: &str) -> eyre::Result<VerificationResult> {
{
Ok(response) => response,
Err(source) => {
let reason = ReqwestErrorReason::from(&source);
let is_transient = reason.is_transient();
error!(
result = "unknown",
request.error_reason = ?reason,
request.time_ms = check_time_start.elapsed().as_millis(),
error = ?source,
"NeverBounce email check failed",
);
if is_transient {
return Ok(VerificationResult::Unknown);
}
return Err(eyre!(source)).wrap_err("failed to check email");
}
};
@@ -65,13 +76,19 @@ pub async fn check_email(email: &str) -> eyre::Result<VerificationResult> {
}
failure_type => {
let result = result.unwrap_or(VerificationResult::Unknown);
let is_transient = failure_type.is_transient();
error!(
failure_type = response_failure_type(&failure_type),
result = result.as_str(),
request.time_ms = check_time.as_millis(),
"NeverBounce email check failed",
);
Err(email_check_error_generic())
if is_transient {
Ok(VerificationResult::Unknown)
} else {
Err(email_check_error_generic())
}
}
}
}
@@ -45,14 +45,15 @@ export class LabrinthOAuthInternalModule extends AbstractModule {
* @returns Promise resolving to an array of OAuth clients
*/
public async getApps(ids: string[]): Promise<Labrinth.OAuth.Internal.OAuthClient[]> {
return this.client.request<Labrinth.OAuth.Internal.OAuthClient[]>(
`/oauth/apps?ids=${encodeURIComponent(JSON.stringify(ids))}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
if (ids.length === 0) {
return []
}
// bulk `/oauth/apps` is broken on backend, fetch by id instead
// TODO: Remove this once the backend is fixed
const results = await Promise.all(ids.map((id) => this.getApp(id).catch(() => null)))
return results.filter((app): app is Labrinth.OAuth.Internal.OAuthClient => app !== null)
}
/**
@@ -1663,6 +1663,8 @@ export namespace Labrinth {
allow_friend_requests?: boolean
moderation_notes?: Common.ModerationNote | null
github_id?: number
discord_id?: string
steam_id?: string
}
export type SearchUser = {
+100 -1
View File
@@ -1,7 +1,8 @@
use crate::State;
use crate::util::fetch::fetch_json;
use crate::util::fetch::{fetch_advanced, fetch_json};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SearchUser {
@@ -30,3 +31,101 @@ pub async fn search_user(query: &str) -> crate::Result<Vec<SearchUser>> {
)
.await
}
#[tracing::instrument]
pub async fn get_user_profile(user_id: &str) -> crate::Result<Value> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_json(
Method::GET,
&format!("{}user/{}", env!("MODRINTH_API_URL_V3"), user_id),
None,
None,
Some("/v3/user/:id"),
&state.api_semaphore,
&state.pool,
)
.await
}
#[tracing::instrument]
pub async fn get_user_projects(user_id: &str) -> crate::Result<Value> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_json(
Method::GET,
&format!("{}user/{}/projects", env!("MODRINTH_API_URL"), user_id),
None,
None,
Some("/v2/user/:id/projects"),
&state.api_semaphore,
&state.pool,
)
.await
}
#[tracing::instrument]
pub async fn get_user_organizations(user_id: &str) -> crate::Result<Value> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_json(
Method::GET,
&format!(
"{}user/{}/organizations",
env!("MODRINTH_API_URL_V3"),
user_id
),
None,
None,
Some("/v3/user/:id/organizations"),
&state.api_semaphore,
&state.pool,
)
.await
}
#[tracing::instrument]
pub async fn get_user_collections(user_id: &str) -> crate::Result<Value> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_json(
Method::GET,
&format!(
"{}user/{}/collections",
env!("MODRINTH_API_URL_V3"),
user_id
),
None,
None,
Some("/v3/user/:id/collections"),
&state.api_semaphore,
&state.pool,
)
.await
}
#[tracing::instrument(skip(patch))]
pub async fn patch_user(user_id: &str, patch: Value) -> crate::Result<()> {
let state = State::get().await?;
let user_id = urlencoding::encode(user_id);
fetch_advanced(
Method::PATCH,
&format!("{}user/{}", env!("MODRINTH_API_URL"), user_id),
None,
Some(patch),
None,
None,
None,
Some("/v2/user/:id"),
&state.api_semaphore,
&state.pool,
)
.await?;
Ok(())
}
+14 -5
View File
@@ -202,11 +202,20 @@ pub async fn emit_install_job(
{
use tauri::Emitter;
let event_state = crate::EventState::get()?;
event_state
.app
.emit("install_job", snapshot)
.map_err(crate::event::EventError::from)?;
let result: crate::Result<()> = (|| {
let event_state = crate::EventState::get()?;
event_state
.app
.emit("install_job", snapshot)
.map_err(crate::event::EventError::from)?;
Ok(())
})();
if let Err(error) = result {
tracing::warn!(
"Failed to emit install job {} update: {error}",
snapshot.job_id
);
}
}
Ok(())
+75 -37
View File
@@ -262,29 +262,51 @@ async fn copy_symlink(source: &Path, target: &Path) -> crate::Result<()> {
pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
let jobs = store::list_interrupted_candidates(state).await?;
for mut job in jobs {
if job.state.display.is_none() {
job.state.display = display_from_request(&job.state);
for job in jobs {
let job_id = job.id;
if let Err(error) = recover_interrupted_job(job, state).await {
tracing::error!(
"Error recovering interrupted install job {job_id}: {error}"
);
}
let interrupted_phase = job.state.progress.phase;
job.state.record_event(InstallJobEventKind::Interrupted {
reason: InstallInterruptReason::AppClosed,
phase: interrupted_phase,
});
job.state.progress.phase = InstallPhaseId::RollingBack;
job.state.progress.progress = None;
job.state.progress.details = InstallPhaseDetails::Empty;
job.state.error = Some(InstallErrorView::from_message(
"app_closed",
interrupted_phase,
"App closed while install was running",
));
}
job.state
.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job.state.cleanup.clone(),
});
if let Err(error) = apply_cleanup(&job.state, state).await {
Ok(())
}
async fn recover_interrupted_job(
mut job: store::InstallJobRecord,
state: &State,
) -> crate::Result<()> {
if job.state.display.is_none() {
job.state.display = display_from_request(&job.state);
}
let interrupted_phase = job.state.progress.phase;
job.state.record_event(InstallJobEventKind::Interrupted {
reason: InstallInterruptReason::AppClosed,
phase: interrupted_phase,
});
job.state.progress.phase = InstallPhaseId::RollingBack;
job.state.progress.progress = None;
job.state.progress.details = InstallPhaseDetails::Empty;
job.state.error = Some(InstallErrorView::from_message(
"app_closed",
interrupted_phase,
"App closed while install was running",
));
job.state
.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job.state.cleanup.clone(),
});
let cleanup_succeeded = match apply_cleanup(&job.state, state).await {
Ok(()) => {
job.state
.record_event(InstallJobEventKind::RollbackCompleted);
clear_deleted_new_instance_id(&mut job.state);
true
}
Err(error) => {
tracing::error!(
"Error cleaning up interrupted install job {}: {error}",
job.id
@@ -298,20 +320,19 @@ pub async fn recover_interrupted_jobs(state: &State) -> crate::Result<()> {
job.state.record_event(InstallJobEventKind::RollbackFailed {
message: error.to_string(),
});
} else {
job.state
.record_event(InstallJobEventKind::RollbackCompleted);
false
}
clear_deleted_new_instance_id(&mut job.state);
};
let record = store::update_status(
job.id,
InstallJobStatus::Interrupted,
&job.state,
state,
)
.await?;
if job.state.rollback_error.is_none() {
if let Some(record) = store::finish_active(
job.id,
InstallJobStatus::Interrupted,
&job.state,
state,
)
.await?
{
if cleanup_succeeded {
clear_staging_dir(&job.state).await;
}
emit_install_job(&record.snapshot()).await?;
@@ -383,10 +404,20 @@ pub async fn apply_cleanup(
match &job_state.cleanup {
InstallCleanup::DeleteNewInstance { instance_id } => {
if let Some(instance_id) = instance_id {
let _ = crate::state::remove_instance(instance_id, state).await;
let _ =
if crate::state::get_instance(instance_id, &state.pool)
.await?
.is_some()
{
crate::state::remove_instance(instance_id, state).await?;
}
if let Err(error) =
emit_instance(instance_id, InstancePayloadType::Removed)
.await;
.await
{
tracing::warn!(
"Failed to emit removed instance {instance_id}: {error}"
);
}
}
}
InstallCleanup::RestoreExistingInstance { instance_id } => {
@@ -410,7 +441,14 @@ pub async fn apply_cleanup(
)
.await?;
}
emit_instance(instance_id, InstancePayloadType::Edited).await?;
if let Err(error) =
emit_instance(instance_id, InstancePayloadType::Edited)
.await
{
tracing::warn!(
"Failed to emit restored instance {instance_id}: {error}"
);
}
}
}
}
+254 -114
View File
@@ -163,19 +163,57 @@ pub async fn retry_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
job.state.progress.phase = InstallPhaseId::PreparingInstance;
job.state.progress.progress = None;
job.state.progress.details = InstallPhaseDetails::Empty;
prepare_initial_instance(&mut job.state, &state).await?;
if let Err(error) = prepare_initial_instance(&mut job.state, &state).await {
if let Err(cleanup_error) =
recovery::apply_cleanup(&job.state, &state).await
{
tracing::error!(
"Error cleaning up install job {job_id} retry preparation: {cleanup_error}"
);
}
return Err(error);
}
job.state.record_event(InstallJobEventKind::JobQueued {
kind: job.state.request.kind(),
});
let record = store::update_status(
let record = match store::update_status(
job_id,
InstallJobStatus::Queued,
&job.state,
&state,
)
.await?;
lock_existing_instance_if_needed(&job.state, &state).await?;
.await
{
Ok(record) => record,
Err(error) => {
if let Err(cleanup_error) =
recovery::apply_cleanup(&job.state, &state).await
{
tracing::error!(
"Error cleaning up unqueued install job {job_id}: {cleanup_error}"
);
}
return Err(error);
}
};
if let Err(error) =
lock_existing_instance_if_needed(&job.state, &state).await
{
let error_view = install_error_view(
job.state.progress.phase,
&error,
job.state.context.clone(),
);
if let Err(terminal_error) =
terminalize_failed_job(job_id, job.state, error_view, &state).await
{
tracing::error!(
"Failed to terminalize retried install job {job_id}: {terminal_error}"
);
}
return Err(error);
}
emit_install_job(&record.snapshot()).await?;
spawn_job(job_id);
@@ -206,31 +244,50 @@ pub async fn cancel_job(job_id: Uuid) -> crate::Result<InstallJobSnapshot> {
.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job.state.cleanup.clone(),
});
match recovery::apply_cleanup(&job.state, &state).await {
Ok(()) => job
.state
.record_event(InstallJobEventKind::RollbackCompleted),
Err(error) => {
job.state.rollback_error = Some(InstallErrorView::from_error(
"rollback_error",
InstallPhaseId::RollingBack,
&error,
None,
));
job.state.record_event(InstallJobEventKind::RollbackFailed {
message: error.to_string(),
});
}
}
clear_deleted_new_instance_id(&mut job.state);
let record = store::update_status(
let status_updated = store::update_status_if(
job_id,
InstallJobStatus::Canceled,
InstallJobStatus::Queued,
InstallJobStatus::Running,
&job.state,
&state,
)
.await?;
if job.state.rollback_error.is_none() {
.await?
.is_some();
if !status_updated {
return Err(crate::ErrorKind::InputError(
"Install job is no longer queued".to_string(),
)
.into());
}
let cleanup_succeeded =
match recovery::apply_cleanup(&job.state, &state).await {
Ok(()) => {
job.state
.record_event(InstallJobEventKind::RollbackCompleted);
clear_deleted_new_instance_id(&mut job.state);
true
}
Err(error) => {
job.state.rollback_error = Some(InstallErrorView::from_error(
"rollback_error",
InstallPhaseId::RollingBack,
&error,
None,
));
job.state.record_event(InstallJobEventKind::RollbackFailed {
message: error.to_string(),
});
false
}
};
let status = if cleanup_succeeded {
InstallJobStatus::Canceled
} else {
InstallJobStatus::Failed
};
let record =
store::update_status(job_id, status, &job.state, &state).await?;
if cleanup_succeeded {
recovery::clear_staging_dir(&job.state).await;
}
emit_install_job(&record.snapshot()).await?;
@@ -247,10 +304,53 @@ async fn start(request: InstallRequest) -> crate::Result<InstallJobSnapshot> {
let state = State::get().await?;
let id = Uuid::new_v4();
let mut job_state = InstallJobState::new(request);
prepare_initial_instance(&mut job_state, &state).await?;
let record =
store::insert(id, &job_state, InstallJobStatus::Queued, &state).await?;
lock_existing_instance_if_needed(&job_state, &state).await?;
if let Err(error) = prepare_initial_instance(&mut job_state, &state).await {
if let Err(cleanup_error) =
recovery::apply_cleanup(&job_state, &state).await
{
tracing::error!(
"Error cleaning up install job preparation: {cleanup_error}"
);
}
return Err(error);
}
let record = match store::insert(
id,
&job_state,
InstallJobStatus::Queued,
&state,
)
.await
{
Ok(record) => record,
Err(error) => {
if let Err(cleanup_error) =
recovery::apply_cleanup(&job_state, &state).await
{
tracing::error!(
"Error cleaning up untracked install job {id}: {cleanup_error}"
);
}
return Err(error);
}
};
if let Err(error) =
lock_existing_instance_if_needed(&job_state, &state).await
{
let error_view = install_error_view(
job_state.progress.phase,
&error,
job_state.context.clone(),
);
if let Err(terminal_error) =
terminalize_failed_job(id, job_state, error_view, &state).await
{
tracing::error!(
"Failed to terminalize install job {id} after setup error: {terminal_error}"
);
}
return Err(error);
}
emit_install_job(&record.snapshot()).await?;
spawn_job(id);
Ok(record.snapshot())
@@ -370,9 +470,9 @@ async fn prepare_initial_instance(
metadata.instance.icon_path,
);
let instance_id = metadata.instance.id;
set_instance_id(job_state, instance_id.clone());
attach_pending_shared_instance(&instance_id, &data, state).await?;
emit_instance(&instance_id, InstancePayloadType::Edited).await?;
set_instance_id(job_state, instance_id);
}
InstallRequest::ImportInstance {
instance_folder, ..
@@ -433,9 +533,14 @@ async fn prepare_initial_instance(
fn spawn_job(job_id: Uuid) {
tokio::spawn(async move {
if let Err(error) = Box::pin(run_job(job_id)).await {
tracing::error!(
"Install job {job_id} failed to update state: {error}"
);
let failure = error.to_string();
tracing::error!("Install job {job_id} terminated: {failure}");
if let Err(error) = terminalize_stranded_job(job_id, failure).await
{
tracing::error!(
"Failed to terminalize stranded install job {job_id}: {error}"
);
}
}
});
}
@@ -457,13 +562,17 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
let mut job_state = job.state.clone();
job_state.record_event(InstallJobEventKind::JobStarted);
let record = store::update_status(
let Some(record) = store::update_status_if(
job_id,
InstallJobStatus::Queued,
InstallJobStatus::Running,
&job_state,
&state,
)
.await?;
.await?
else {
return Ok(());
};
emit_install_job(&record.snapshot()).await?;
let result = Box::pin(run_request(job_id, &mut job_state, &state)).await;
@@ -472,19 +581,21 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
}
let result = match result {
Ok(instance_id) => {
if let Some(instance_id) = instance_id {
set_instance_id(&mut job_state, instance_id);
}
finalize_existing_instance_success(&job_state, &state).await
Ok(Some(instance_id)) => {
set_instance_id(&mut job_state, instance_id.clone());
Ok(instance_id)
}
Ok(None) => Err(crate::ErrorKind::InputError(
"Install job completed without an instance id".to_string(),
)
.into()),
Err(error) => Err(error),
};
match result {
Ok(()) => {
Ok(instance_id) => {
job_state.record_event(InstallJobEventKind::JobSucceeded {
instance_id: current_instance_id(&job_state),
instance_id: Some(instance_id.clone()),
});
job_state.progress.phase = InstallPhaseId::Finalizing;
job_state.progress.progress = None;
@@ -492,71 +603,119 @@ async fn run_job(job_id: Uuid) -> crate::Result<()> {
job_state.error = None;
job_state.rollback_error = None;
job_state.context = None;
let record = store::update_status(
job_id,
InstallJobStatus::Succeeded,
&job_state,
&state,
)
.await?;
recovery::clear_staging_dir(&job_state).await;
emit_install_job(&record.snapshot()).await?;
if let Some(record) =
store::complete_success(job_id, &job_state, &state).await?
{
recovery::clear_staging_dir(&job_state).await;
if let Err(error) =
emit_instance(&instance_id, InstancePayloadType::Edited)
.await
{
tracing::warn!(
"Failed to emit completed instance {instance_id}: {error}"
);
}
emit_install_job(&record.snapshot()).await?;
}
}
Err(error) => {
let failed_phase = job_state.progress.phase;
tracing::error!("Install job {job_id} failed: {error}");
let error_view = install_error_view(
failed_phase,
job_state.progress.phase,
&error,
job_state.context.clone(),
);
job_state.record_event(InstallJobEventKind::Failed {
phase: failed_phase,
code: error_view.code.clone(),
message: error_view.message.clone(),
});
job_state.error = Some(error_view);
job_state.progress.phase = InstallPhaseId::RollingBack;
job_state.progress.progress = None;
job_state.progress.details = InstallPhaseDetails::Empty;
job_state.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job_state.cleanup.clone(),
});
if let Err(rollback_error) =
recovery::apply_cleanup(&job_state, &state).await
{
tracing::error!(
"Error rolling back failed install job {job_id}: {rollback_error}"
);
job_state.rollback_error = Some(install_error_view(
InstallPhaseId::RollingBack,
&rollback_error,
None,
));
job_state.record_event(InstallJobEventKind::RollbackFailed {
message: rollback_error.to_string(),
});
} else {
job_state.record_event(InstallJobEventKind::RollbackCompleted);
}
clear_deleted_new_instance_id(&mut job_state);
let record = store::update_status(
job_id,
InstallJobStatus::Failed,
&job_state,
&state,
)
.await?;
if job_state.rollback_error.is_none() {
recovery::clear_staging_dir(&job_state).await;
}
emit_install_job(&record.snapshot()).await?;
return Err(error);
terminalize_failed_job(job_id, job_state, error_view, &state)
.await?;
}
}
Ok(())
}
async fn terminalize_stranded_job(
job_id: Uuid,
message: String,
) -> crate::Result<()> {
let state = State::get().await?;
let job = store::get_required(job_id, &state).await?;
if !matches!(
job.status,
InstallJobStatus::Queued | InstallJobStatus::Running
) {
return Ok(());
}
let error_view = InstallErrorView::from_message(
"install_worker_terminated",
job.state.progress.phase,
message,
);
terminalize_failed_job(job_id, job.state, error_view, &state).await
}
async fn terminalize_failed_job(
job_id: Uuid,
mut job_state: InstallJobState,
error_view: InstallErrorView,
state: &State,
) -> crate::Result<()> {
let failed_phase = job_state.progress.phase;
job_state.record_event(InstallJobEventKind::Failed {
phase: failed_phase,
code: error_view.code.clone(),
message: error_view.message.clone(),
});
job_state.error = Some(error_view);
job_state.rollback_error = None;
job_state.progress.phase = InstallPhaseId::RollingBack;
job_state.progress.progress = None;
job_state.progress.details = InstallPhaseDetails::Empty;
job_state.record_event(InstallJobEventKind::RollbackStarted {
cleanup: job_state.cleanup.clone(),
});
let cleanup_succeeded = match recovery::apply_cleanup(&job_state, state)
.await
{
Ok(()) => {
job_state.record_event(InstallJobEventKind::RollbackCompleted);
clear_deleted_new_instance_id(&mut job_state);
true
}
Err(rollback_error) => {
tracing::error!(
"Error rolling back failed install job {job_id}: {rollback_error}"
);
job_state.rollback_error = Some(install_error_view(
InstallPhaseId::RollingBack,
&rollback_error,
None,
));
job_state.record_event(InstallJobEventKind::RollbackFailed {
message: rollback_error.to_string(),
});
false
}
};
if let Some(record) = store::finish_active(
job_id,
InstallJobStatus::Failed,
&job_state,
state,
)
.await?
{
if cleanup_succeeded {
recovery::clear_staging_dir(&job_state).await;
}
emit_install_job(&record.snapshot()).await?;
}
Ok(())
}
async fn run_request(
job_id: Uuid,
job_state: &mut InstallJobState,
@@ -1176,25 +1335,6 @@ async fn lock_existing_instance(
Ok(())
}
async fn finalize_existing_instance_success(
job_state: &InstallJobState,
state: &State,
) -> crate::Result<()> {
if let InstallCleanup::RestoreExistingInstance { instance_id } =
&job_state.cleanup
{
crate::state::instances::commands::set_instance_install_stage(
instance_id,
InstanceInstallStage::Installed,
&state.pool,
)
.await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
}
Ok(())
}
pub(super) async fn update_progress(
job_id: Uuid,
job_state: &mut InstallJobState,
+156 -2
View File
@@ -171,7 +171,7 @@ pub async fn list(
.await?
};
rows.into_iter().map(row_to_record).collect()
Ok(deserialize_rows(rows))
}
pub async fn list_interrupted_candidates(
@@ -198,7 +198,7 @@ pub async fn list_interrupted_candidates(
.fetch_all(&app_state.pool)
.await?;
rows.into_iter().map(row_to_record).collect()
Ok(deserialize_rows(rows))
}
pub async fn update_state(
@@ -262,6 +262,143 @@ pub async fn update_status(
get_required(id, app_state).await
}
pub async fn update_status_if(
id: Uuid,
expected_status: InstallJobStatus,
status: InstallJobStatus,
state: &InstallJobState,
app_state: &State,
) -> crate::Result<Option<InstallJobRecord>> {
let now = Utc::now();
let finished = status.is_finished().then_some(now.timestamp());
let json = serde_json::to_string(state)?;
let status_value = status.as_str();
let expected_status_value = expected_status.as_str();
let instance_id = instance_id(state);
let id_value = id.to_string();
let modified = now.timestamp();
let result = sqlx::query(
"
UPDATE install_jobs
SET instance_id = ?, status = ?, state = ?, modified = ?, finished = ?
WHERE id = ? AND status = ?
",
)
.bind(instance_id)
.bind(status_value)
.bind(json)
.bind(modified)
.bind(finished)
.bind(id_value)
.bind(expected_status_value)
.execute(&app_state.pool)
.await?;
if result.rows_affected() == 0 {
return Ok(None);
}
get_required(id, app_state).await.map(Some)
}
pub async fn finish_active(
id: Uuid,
status: InstallJobStatus,
state: &InstallJobState,
app_state: &State,
) -> crate::Result<Option<InstallJobRecord>> {
let now = Utc::now();
let finished = now.timestamp();
let json = serde_json::to_string(state)?;
let status_value = status.as_str();
let instance_id = instance_id(state);
let id_value = id.to_string();
let modified = finished;
let result = sqlx::query(
"
UPDATE install_jobs
SET instance_id = ?, status = ?, state = ?, modified = ?, finished = ?
WHERE id = ? AND status IN ('queued', 'running')
",
)
.bind(instance_id)
.bind(status_value)
.bind(json)
.bind(modified)
.bind(finished)
.bind(id_value)
.execute(&app_state.pool)
.await?;
if result.rows_affected() == 0 {
return Ok(None);
}
get_required(id, app_state).await.map(Some)
}
pub async fn complete_success(
id: Uuid,
state: &InstallJobState,
app_state: &State,
) -> crate::Result<Option<InstallJobRecord>> {
let Some(instance_id) = instance_id(state) else {
return Err(crate::ErrorKind::InputError(
"Install job is missing its instance id".to_string(),
)
.into());
};
let now = Utc::now().timestamp();
let json = serde_json::to_string(state)?;
let id_value = id.to_string();
let mut transaction = app_state.pool.begin().await?;
let job_result = sqlx::query(
"
UPDATE install_jobs
SET instance_id = ?, status = 'succeeded', state = ?, modified = ?, finished = ?
WHERE id = ? AND status = 'running'
",
)
.bind(&instance_id)
.bind(json)
.bind(now)
.bind(now)
.bind(id_value)
.execute(&mut *transaction)
.await?;
if job_result.rows_affected() == 0 {
transaction.rollback().await?;
return Ok(None);
}
let instance_result = sqlx::query(
"
UPDATE instances
SET install_stage = 'installed', modified = ?
WHERE id = ?
",
)
.bind(now)
.bind(&instance_id)
.execute(&mut *transaction)
.await?;
if instance_result.rows_affected() == 0 {
transaction.rollback().await?;
return Err(crate::ErrorKind::InputError(format!(
"Unknown instance {instance_id}"
))
.into());
}
transaction.commit().await?;
get_required(id, app_state).await.map(Some)
}
pub async fn dismiss(id: Uuid, app_state: &State) -> crate::Result<()> {
let id = id.to_string();
let modified = Utc::now().timestamp();
@@ -308,6 +445,23 @@ fn row_to_record(row: InstallJobRow) -> crate::Result<InstallJobRecord> {
})
}
fn deserialize_rows(rows: Vec<InstallJobRow>) -> Vec<InstallJobRecord> {
rows.into_iter()
.filter_map(|row| {
let id = row.id.clone();
match row_to_record(row) {
Ok(record) => Some(record),
Err(error) => {
tracing::error!(
"Failed to deserialize install job {id}: {error}"
);
None
}
}
})
.collect()
}
fn instance_id(state: &InstallJobState) -> Option<String> {
match &state.target {
super::model::InstallTarget::NewInstance { instance_id } => {
+9 -7
View File
@@ -603,19 +603,21 @@ pub async fn install_minecraft_with_reporter(
let protocol_version = read_protocol_version_from_jar(client_path).await?;
crate::state::instances::commands::set_instance_install_stage(
&instance.id,
InstanceInstallStage::Installed,
&state.pool,
)
.await?;
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
crate::state::instances::commands::set_applied_content_set_protocol_version(
&instance.id,
protocol_version,
&state.pool,
)
.await?;
if reporter.is_none() {
crate::state::instances::commands::set_instance_install_stage(
&instance.id,
InstanceInstallStage::Installed,
&state.pool,
)
.await?;
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
}
if let Some(loading_bar) = &loading_bar {
emit_loading(loading_bar, 1.0, Some("Finished installing"))?;
}
+4
View File
@@ -65,6 +65,7 @@ pub enum FeatureFlag {
ShowInstancePlayTime,
SkipNonEssentialWarnings,
AdvancedFiltersCollapsed,
AlwaysShowCopyDetails,
}
impl Settings {
@@ -328,6 +329,7 @@ pub enum Theme {
Dark,
Light,
Oled,
Retro,
System,
}
@@ -337,6 +339,7 @@ impl Theme {
Theme::Dark => "dark",
Theme::Light => "light",
Theme::Oled => "oled",
Theme::Retro => "retro",
Theme::System => "system",
}
}
@@ -346,6 +349,7 @@ impl Theme {
"dark" => Theme::Dark,
"light" => Theme::Light,
"oled" => Theme::Oled,
"retro" => Theme::Retro,
"system" => Theme::System,
_ => Theme::Dark,
}
+33
View File
@@ -422,6 +422,39 @@ html {
}
.retro-mode {
@extend .dark-mode;
--surface-1: #191917;
--surface-2: rgb(22, 22, 21);
--surface-2-5: #3a3c3e;
--surface-3: #232421;
--surface-4: #3a3b38;
--surface-5: #5a5c58;
--color-button-bg: #3a3b38;
--color-base: #c3c4b3;
--color-secondary: #9b9e98;
--color-contrast: #e6e2d1;
--color-brand: #4d9227;
--color-brand-highlight: #25421e;
--color-accent-contrast: #ffffff;
--color-ad: var(--color-brand-highlight);
--color-ad-raised: var(--color-brand);
--color-ad-contrast: black;
--color-ad-highlight: var(--color-brand);
--color-red: rgb(232, 32, 13);
--color-orange: rgb(232, 141, 13);
--color-green: rgb(60, 219, 54);
--color-blue: rgb(9, 159, 239);
--color-purple: rgb(139, 129, 230);
--color-gray: #718096;
--color-red-highlight: rgba(232, 32, 13, 0.25);
--color-orange-highlight: rgba(232, 141, 13, 0.25);
--color-green-highlight: rgba(60, 219, 54, 0.25);
--color-blue-highlight: rgba(9, 159, 239, 0.25);
--color-purple-highlight: rgba(139, 129, 230, 0.25);
--color-gray-highlight: rgba(113, 128, 150, 0.25);
--brand-gradient-strong-bg: #3a3b38;
}
+67 -2
View File
@@ -4,7 +4,65 @@ use std::time::Duration;
pub const DEFAULT_API_URL: &str = "https://api.neverbounce.com";
pub const SINGLE_CHECK_PATH: &str = "/v4/single/check";
pub const TIMEOUT: Duration = Duration::from_secs(10);
pub const TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ReqwestErrorReason {
Builder,
Redirect,
Status(reqwest::StatusCode),
Timeout,
Request,
Connect,
Body,
Decode,
Unknown,
}
impl ReqwestErrorReason {
#[must_use]
pub fn is_transient(&self) -> bool {
match self {
Self::Status(status) => {
status.is_server_error()
|| matches!(
*status,
reqwest::StatusCode::REQUEST_TIMEOUT
| reqwest::StatusCode::TOO_MANY_REQUESTS
)
}
Self::Timeout | Self::Request | Self::Connect | Self::Body => true,
Self::Builder | Self::Redirect | Self::Decode | Self::Unknown => {
false
}
}
}
}
impl From<&reqwest::Error> for ReqwestErrorReason {
fn from(error: &reqwest::Error) -> Self {
if error.is_timeout() {
Self::Timeout
} else if error.is_connect() {
Self::Connect
} else if error.is_builder() {
Self::Builder
} else if error.is_redirect() {
Self::Redirect
} else if error.is_status() {
error.status().map_or(Self::Unknown, Self::Status)
} else if error.is_request() {
Self::Request
} else if error.is_body() {
Self::Body
} else if error.is_decode() {
Self::Decode
} else {
Self::Unknown
}
}
}
/// Authentication and email parameters for a single verification.
///
@@ -71,6 +129,13 @@ pub enum ResponseStatus {
Unrecognized(String),
}
impl ResponseStatus {
#[must_use]
pub fn is_transient(&self) -> bool {
matches!(self, Self::TemporarilyUnavailable | Self::ThrottleTriggered)
}
}
impl<'de> Deserialize<'de> for ResponseStatus {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
@@ -201,7 +266,7 @@ struct SingleCheckRequest<'a> {
/// This endpoint should only be called in response to an action such as a form
/// submission. Existing lists and databases must use NeverBounce's bulk API.
/// Both the server verification timeout and the complete HTTP request timeout
/// are ten seconds.
/// are five seconds.
pub async fn single_check(
client: &Client,
params: &SingleCheckParams<'_>,
@@ -52,6 +52,7 @@
:progress-current="progressItem.progressCurrent"
:progress-total="progressItem.progressTotal"
:actions="progressItem.buttons"
:dismissible="progressItem.dismissible"
@dismiss="handleProgressItemDismiss(item, progressItem)"
@action="(index) => handleProgressItemAction(progressItem, index)"
/>
@@ -49,7 +49,7 @@
</template>
</template>
</p>
<ButtonStyled size="small" type="transparent" circular>
<ButtonStyled v-if="dismissible" size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
@@ -96,7 +96,7 @@
{{ entityLabel }}
</p>
<div class="col-start-2 row-start-1 justify-self-end">
<ButtonStyled size="small" type="transparent" circular>
<ButtonStyled v-if="dismissible" size="small" type="transparent" circular>
<button
type="button"
class="notification-toast-dismiss"
@@ -213,6 +213,7 @@ const props = withDefaults(
progressCurrent?: number
progressTotal?: number
actions?: PopupNotificationButton[]
dismissible?: boolean
}>(),
{
actionLoading: null,
@@ -224,6 +225,7 @@ const props = withDefaults(
showProgress: true,
wrapText: false,
progressType: 'percentage',
dismissible: true,
},
)
@@ -25,7 +25,7 @@
:key="`member-${member.id}`"
class="flex gap-2 items-center w-fit text-primary leading-[1.2] group"
:to="userLink(member.user.username)"
:target="linkTarget ?? null"
:target="resolveLinkTarget(userLinkTarget)"
>
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="32px" circle />
<div class="flex flex-col">
@@ -36,7 +36,7 @@
v-tooltip="formatMessage(messages.owner)"
class="text-brand-orange"
/>
<ExternalIcon v-if="linkTarget === '_blank'" />
<ExternalIcon v-if="resolveLinkTarget(userLinkTarget) === '_blank'" />
</span>
<span class="text-sm font-normal text-secondary">{{ member.role }}</span>
</div>
@@ -79,8 +79,13 @@ const props = defineProps<{
orgLink: (slug: string) => string
userLink: (username: string) => string
linkTarget?: string
userLinkTarget?: string | null
}>()
function resolveLinkTarget(target: string | null | undefined): string | null {
return target === undefined ? (props.linkTarget ?? null) : target
}
// Members should be an array of all members, without the accepted ones, and with the user with the Owner role at the start
// The rest of the members should be sorted by role, then by name
const sortedMembers = computed(() => {
@@ -19,7 +19,7 @@
:option="option"
:included="included(option)"
:excluded="excluded(option)"
:supports-negative-filter="supportsNegativeFilter"
:supports="supports"
@toggle="(o) => emit('toggle', o)"
@toggle-exclude="(o) => emit('toggleExclude', o)"
>
@@ -44,13 +44,13 @@
import { DropdownIcon } from '@modrinth/assets'
import { ref } from 'vue'
import type { FilterOption } from '../../utils/search'
import type { FilterMode, FilterOption } from '../../utils/search'
import SearchFilterOption from './SearchFilterOption.vue'
defineProps<{
groupName: string
options: FilterOption[]
supportsNegativeFilter: boolean
supports: FilterMode[]
included: (option: FilterOption) => boolean
excluded: (option: FilterOption) => boolean
}>()
@@ -1,13 +1,13 @@
<template>
<div class="search-filter-option group flex gap-1 items-center">
<button
:class="`flex border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-2 [@media(hover:hover)]:py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98] ${included ? 'bg-brand-highlight text-contrast hover:brightness-125' : excluded ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg focus-visible:bg-button-bg [&>svg.check-icon]:hover:text-brand [&>svg.check-icon]:focus-visible:text-brand'}`"
@click="() => emit('toggle', option)"
:class="`flex border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-2 [@media(hover:hover)]:py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98] ${included ? 'bg-brand-highlight text-contrast hover:brightness-125' : excluded ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg focus-visible:bg-button-bg [&>svg.check-icon]:hover:text-brand [&>svg.check-icon]:focus-visible:text-brand [&>svg.ban-icon]:hover:text-red [&>svg.ban-icon]:focus-visible:text-red'}`"
@click="() => emit(primaryAction === 'exclude' ? 'toggleExclude' : 'toggle', option)"
>
<slot> </slot>
<BanIcon
v-if="excluded"
:class="`filter-action-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${excluded ? '' : '[@media(hover:hover)]:opacity-0'}`"
v-if="excluded || primaryAction === 'exclude'"
:class="`filter-action-icon ban-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${excluded ? '' : '[@media(hover:hover)]:opacity-0'}`"
aria-hidden="true"
/>
<CheckIcon
@@ -17,12 +17,12 @@
/>
</button>
<div
v-if="supportsNegativeFilter && !excluded"
v-if="showExcludeButton"
class="w-px h-[1.75rem] bg-button-bg [@media(hover:hover)]:contents"
:class="{ 'opacity-0': included }"
></div>
<button
v-if="supportsNegativeFilter && !excluded"
v-if="showExcludeButton"
v-tooltip="formatMessage(messages.excludeTooltip)"
class="flex border-none cursor-pointer items-center justify-center gap-2 rounded-xl bg-transparent px-2 py-1 text-sm font-semibold text-secondary [@media(hover:hover)]:opacity-0 transition-all hover:bg-button-bg hover:text-red focus-visible:bg-button-bg focus-visible:text-red active:scale-[0.96]"
@click="() => emit('toggleExclude', option)"
@@ -34,22 +34,30 @@
<script setup lang="ts">
import { BanIcon, CheckIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import type { FilterOption } from '../../utils/search'
import type { FilterMode, FilterOption } from '../../utils/search'
withDefaults(
const props = withDefaults(
defineProps<{
option: FilterOption
included: boolean
excluded: boolean
supportsNegativeFilter?: boolean
supports?: FilterMode[]
}>(),
{
supportsNegativeFilter: false,
supports: () => ['include'],
},
)
const supportsInclude = computed(() => props.supports.includes('include'))
const supportsExclude = computed(() => props.supports.includes('exclude'))
const primaryAction = computed<FilterMode>(() => (supportsInclude.value ? 'include' : 'exclude'))
const showExcludeButton = computed(
() => supportsInclude.value && supportsExclude.value && !props.excluded,
)
const { formatMessage } = useVIntl()
const emit = defineEmits<{
@@ -71,105 +71,82 @@
</template>
<template v-else #default>
<slot name="prefix" />
<div
v-if="filterType.display === 'toggle'"
:class="innerPanelClass ? innerPanelClass : ''"
class="flex flex-col gap-3"
>
<label
v-for="option in filterType.options"
:key="`${filterType.id}-toggle-${option.id}`"
class="flex cursor-pointer items-center justify-between text-secondary gap-3 font-semibold"
>
<span class="text-sm">{{ option.formatted_name ?? option.id }}</span>
<Toggle
:model-value="isExcluded(option)"
small
class="shrink-0"
@update:model-value="toggleNegativeFilter(option)"
/>
</label>
</div>
<template v-if="filterType.display !== 'toggle'">
<StyledInput
v-if="filterType.searchable"
:id="`search-${filterType.id}`"
v-model="query"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
autocomplete="off"
clearable
size="small"
input-class="!bg-button-bg"
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
/>
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
<template v-if="groupedOptions">
<SearchFilterGroup
v-for="[groupName, options] in groupedOptions"
:key="`${filterType.id}-group-${groupName}`"
:group-name="groupName"
:options="options"
:supports-negative-filter="filterType.supports_negative_filter"
:included="isIncluded"
:excluded="isExcluded"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
/>
</template>
<template v-else>
<SearchFilterOption
v-for="option in visibleOptions"
:key="`${filterType.id}-${option}`"
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
:supports-negative-filter="filterType.supports_negative_filter"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
>
<slot name="option" :filter="filterType" :option="option">
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
>
<div
v-if="typeof option.icon === 'string'"
class="h-4 w-4"
v-html="option.icon"
/>
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
</template>
<button
v-if="filterType.display === 'expandable'"
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
@click="showMore = !showMore"
<StyledInput
v-if="filterType.searchable"
:id="`search-${filterType.id}`"
v-model="query"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
autocomplete="off"
clearable
size="small"
input-class="!bg-button-bg"
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
/>
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
<template v-if="groupedOptions">
<SearchFilterGroup
v-for="[groupName, options] in groupedOptions"
:key="`${filterType.id}-group-${groupName}`"
:group-name="groupName"
:options="options"
:supports="filterType.supports"
:included="isIncluded"
:excluded="isExcluded"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
/>
</template>
<template v-else>
<SearchFilterOption
v-for="option in visibleOptions"
:key="`${filterType.id}-${option}`"
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
:supports="filterType.supports"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
>
<DropdownIcon
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': showMore }"
/>
<span class="truncate text-sm">
{{
showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore)
}}
</span>
</button>
</div>
</ScrollablePanel>
</template>
<slot name="option" :filter="filterType" :option="option">
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
>
<div
v-if="typeof option.icon === 'string'"
class="h-4 w-4"
v-html="option.icon"
/>
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
</template>
<button
v-if="filterType.display === 'expandable'"
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
@click="showMore = !showMore"
>
<DropdownIcon
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': showMore }"
/>
<span class="truncate text-sm">
{{ showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore) }}
</span>
</button>
</div>
</ScrollablePanel>
<div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden">
<Checkbox
v-for="group in filterType.toggle_groups"
@@ -213,7 +190,6 @@ import { defineMessages, useVIntl } from '../../composables/i18n'
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import Accordion from '../base/Accordion.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import Toggle from '../base/Toggle.vue'
import { Checkbox, ScrollablePanel, StyledInput } from '../index'
import SearchFilterGroup from './SearchFilterGroup.vue'
import SearchFilterOption from './SearchFilterOption.vue'

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