mirror of
https://github.com/modrinth/code.git
synced 2026-08-28 18:45:15 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
283f38c936 | ||
|
|
1dba3bfee8 | ||
|
|
37a5e14657 | ||
|
|
cc66d80165 | ||
|
|
9628b4c269 | ||
|
|
99683f570a | ||
|
|
762d9fe4f4 | ||
|
|
f948b5d358 | ||
|
|
4dbea80e65 | ||
|
|
74c672df49 | ||
|
|
3597bb4821 | ||
|
|
cf50e28dda | ||
|
|
4ca48e4589 |
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type LoadingBarType = { "type": "legacy_data_migration" } | { "type": "directory_move", old: string, new: string, } | { "type": "java_download", version: number, } | { "type": "pack_file_download", instance_id: string, pack_name: string, icon: string | null, pack_version: string, } | { "type": "pack_download", instance_id: string, pack_name: string, icon: string | null, pack_id: string | null, pack_version: string | null, } | { "type": "minecraft_download", instance_id: string, instance_name: string, } | { "type": "instance_update", instance_id: string, instance_name: string, } | { "type": "zip_extract", instance_id: string, instance_name: string, } | { "type": "config_change", new_path: string, } | { "type": "copy_instance", import_location: string, instance_name: string, } | { "type": "launcher_update", version: string, current_version: string, };
|
||||
export type LoadingBarType = { "type": "legacy_data_migration" } | { "type": "directory_move", old: string, new: string, } | { "type": "java_download", version: number, } | { "type": "pack_file_download", instance_id: string, pack_name: string, icon: string | null, pack_version: string, } | { "type": "pack_download", instance_id: string, pack_name: string, icon: string | null, pack_id: string | null, pack_version: string | null, } | { "type": "pack_import", pack_name: string, } | { "type": "minecraft_download", instance_id: string, instance_name: string, } | { "type": "instance_update", instance_id: string, instance_name: string, } | { "type": "zip_extract", instance_id: string, instance_name: string, } | { "type": "pack_export", instance_id: string, instance_name: string, } | { "type": "config_change", new_path: string, } | { "type": "copy_instance", import_location: string, instance_name: string, } | { "type": "launcher_update", version: string, current_version: string, };
|
||||
|
||||
@@ -35,7 +35,7 @@ declare type FixedLengthArray<T, L extends number, TObj = [T, ...Array<T>]> =
|
||||
}
|
||||
|
||||
export type AppEvent = { tag: "loading", value: LoadingPayload } | { tag: "process", value: ProcessPayload } | { tag: "instance", value: InstancePayload } | { tag: "instance_bulk_update_progress", value: InstanceBulkUpdateProgressPayload } | { tag: "install_job", value: InstallJobSnapshot } | { tag: "command", value: CommandPayload } | { tag: "warning", value: WarningPayload } | { tag: "friend", value: FriendPayload } | { tag: "notification", value: string } | { tag: "log", value: LogPayload } | { tag: "ads_consent_required", value: boolean }
|
||||
export type LoadingBarType = { tag: "legacy_data_migration" } | { tag: "directory_move", value: { old: string, new: string } } | { tag: "java_download", value: { version: u32 } } | { tag: "pack_file_download", value: { instance_id: string, pack_name: string, icon: string | undefined, pack_version: string } } | { tag: "pack_download", value: { instance_id: string, pack_name: string, icon: string | undefined, pack_id: string | undefined, pack_version: string | undefined } } | { tag: "minecraft_download", value: { instance_id: string, instance_name: string } } | { tag: "instance_update", value: { instance_id: string, instance_name: string } } | { tag: "zip_extract", value: { instance_id: string, instance_name: string } } | { tag: "config_change", value: { new_path: string } } | { tag: "copy_instance", value: { import_location: string, instance_name: string } } | { tag: "launcher_update", value: { version: string, current_version: string } }
|
||||
export type LoadingBarType = { tag: "legacy_data_migration" } | { tag: "directory_move", value: { old: string, new: string } } | { tag: "java_download", value: { version: u32 } } | { tag: "pack_file_download", value: { instance_id: string, pack_name: string, icon: string | undefined, pack_version: string } } | { tag: "pack_download", value: { instance_id: string, pack_name: string, icon: string | undefined, pack_id: string | undefined, pack_version: string | undefined } } | { tag: "pack_import", value: { pack_name: string } } | { tag: "minecraft_download", value: { instance_id: string, instance_name: string } } | { tag: "instance_update", value: { instance_id: string, instance_name: string } } | { tag: "zip_extract", value: { instance_id: string, instance_name: string } } | { tag: "pack_export", value: { instance_id: string, instance_name: string } } | { tag: "config_change", value: { new_path: string } } | { tag: "copy_instance", value: { import_location: string, instance_name: string } } | { tag: "launcher_update", value: { version: string, current_version: string } }
|
||||
export type LoadingPayload = { event: LoadingBarType, loader_uuid: string, fraction: f64 | undefined, message: string }
|
||||
export type WarningPayload = { message: string }
|
||||
export type InstanceBulkUpdateProgressPayload = { instanceId: string, stage: InstanceBulkUpdateProgressStage, current: u64, total: u64 }
|
||||
|
||||
@@ -129,15 +129,14 @@ function deserialize_LOADING_BAR_TYPE(d) {
|
||||
};
|
||||
case 5:
|
||||
return {
|
||||
tag: "minecraft_download",
|
||||
tag: "pack_import",
|
||||
value: {
|
||||
instance_id: d.deserialize_string(),
|
||||
instance_name: d.deserialize_string()
|
||||
pack_name: d.deserialize_string()
|
||||
}
|
||||
};
|
||||
case 6:
|
||||
return {
|
||||
tag: "instance_update",
|
||||
tag: "minecraft_download",
|
||||
value: {
|
||||
instance_id: d.deserialize_string(),
|
||||
instance_name: d.deserialize_string()
|
||||
@@ -145,20 +144,36 @@ function deserialize_LOADING_BAR_TYPE(d) {
|
||||
};
|
||||
case 7:
|
||||
return {
|
||||
tag: "zip_extract",
|
||||
tag: "instance_update",
|
||||
value: {
|
||||
instance_id: d.deserialize_string(),
|
||||
instance_name: d.deserialize_string()
|
||||
}
|
||||
};
|
||||
case 8:
|
||||
return {
|
||||
tag: "zip_extract",
|
||||
value: {
|
||||
instance_id: d.deserialize_string(),
|
||||
instance_name: d.deserialize_string()
|
||||
}
|
||||
};
|
||||
case 9:
|
||||
return {
|
||||
tag: "pack_export",
|
||||
value: {
|
||||
instance_id: d.deserialize_string(),
|
||||
instance_name: d.deserialize_string()
|
||||
}
|
||||
};
|
||||
case 10:
|
||||
return {
|
||||
tag: "config_change",
|
||||
value: {
|
||||
new_path: d.deserialize_string()
|
||||
}
|
||||
};
|
||||
case 9:
|
||||
case 11:
|
||||
return {
|
||||
tag: "copy_instance",
|
||||
value: {
|
||||
@@ -166,7 +181,7 @@ function deserialize_LOADING_BAR_TYPE(d) {
|
||||
instance_name: d.deserialize_string()
|
||||
}
|
||||
};
|
||||
case 10:
|
||||
case 12:
|
||||
return {
|
||||
tag: "launcher_update",
|
||||
value: {
|
||||
|
||||
@@ -15,7 +15,7 @@ export type InstanceContentData = {
|
||||
|
||||
export type InstanceContentModpackData = {
|
||||
project: ManagedContentProject
|
||||
version: ManagedContentVersion
|
||||
version: ManagedContentVersion | null
|
||||
updateVersionId: string | null
|
||||
}
|
||||
|
||||
@@ -53,10 +53,12 @@ function normalizeLinkedModpackInfo(
|
||||
slug: modpackInfo.project.slug ?? modpackInfo.project.id,
|
||||
icon_url: modpackInfo.project.icon_url ?? undefined,
|
||||
},
|
||||
version: {
|
||||
...modpackInfo.version,
|
||||
date_published: modpackInfo.version.date_published.toString(),
|
||||
},
|
||||
version: modpackInfo.version
|
||||
? {
|
||||
...modpackInfo.version,
|
||||
date_published: modpackInfo.version.date_published.toString(),
|
||||
}
|
||||
: null,
|
||||
updateVersionId: modpackInfo.update_version_id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export async function refresh_content_updates(instanceId: string): Promise<void>
|
||||
// Linked modpack info returned from backend
|
||||
export interface LinkedModpackInfo {
|
||||
project: Labrinth.Projects.v2.Project
|
||||
version: Labrinth.Versions.v2.Version
|
||||
version: Labrinth.Versions.v2.Version | null
|
||||
owner: ContentOwner | null
|
||||
has_update: boolean
|
||||
update_version_id: string | null
|
||||
|
||||
@@ -123,6 +123,7 @@ const {
|
||||
effectiveServerWorldId,
|
||||
serverContextServerData,
|
||||
serverContentProjectIds,
|
||||
queuedServerInstallRootProjectIds,
|
||||
queuedServerInstallProjectIds,
|
||||
queuedServerInstallCount,
|
||||
selectedServerInstallProjects,
|
||||
@@ -143,6 +144,7 @@ const {
|
||||
enforceSetupModpackRoute,
|
||||
getQueuedServerInstallPlans,
|
||||
setQueuedServerInstallPlans,
|
||||
resolveQueuedServerInstallPlan,
|
||||
openServerModpackInstallFlow,
|
||||
onServerFlowBack,
|
||||
handleServerModpackFlowCreate,
|
||||
@@ -870,6 +872,7 @@ function getCardActions(
|
||||
['modpack', 'mod', 'plugin', 'datapack'].includes(currentProjectType)
|
||||
) {
|
||||
const isQueued = queuedServerInstallProjectIds.value.has(projectResult.project_id)
|
||||
const isQueuedRoot = queuedServerInstallRootProjectIds.value.has(projectResult.project_id)
|
||||
const isInstallingSelection = isInstallingQueuedServerInstalls.value
|
||||
const validatingInstall =
|
||||
isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection
|
||||
@@ -897,14 +900,16 @@ function getCardActions(
|
||||
? CheckIcon
|
||||
: PlusIcon,
|
||||
iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
|
||||
disabled: showAsInstalled || isInstalling || isInstallingSelection,
|
||||
disabled:
|
||||
showAsInstalled || isInstalling || isInstallingSelection || (isQueued && !isQueuedRoot),
|
||||
color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
|
||||
type: 'outlined',
|
||||
onClick: async () => {
|
||||
if (isQueued) {
|
||||
if (isQueuedRoot) {
|
||||
removeQueuedServerInstall(projectResult.project_id)
|
||||
return
|
||||
}
|
||||
if (isQueued) return
|
||||
|
||||
const contentType = currentProjectType as BrowseInstallContentType
|
||||
const isModpack = contentType === 'modpack'
|
||||
@@ -913,7 +918,7 @@ function getCardActions(
|
||||
setProjectInstalling(projectResult.project_id, true)
|
||||
}
|
||||
try {
|
||||
await requestInstall({
|
||||
const plan = await requestInstall({
|
||||
project: projectResult,
|
||||
contentType,
|
||||
mode: isModpack ? 'immediate' : 'queue',
|
||||
@@ -937,7 +942,9 @@ function getCardActions(
|
||||
iconUrl: plan.project.icon_url ?? undefined,
|
||||
}),
|
||||
})
|
||||
if (!isModpack) await resolveQueuedServerInstallPlan(plan)
|
||||
} catch (err) {
|
||||
if (!isModpack) removeQueuedServerInstall(projectResult.project_id)
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
if (shouldShowInstalling) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AbstractModrinthClient, Archon, Labrinth } from '@modrinth/api-client'
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
addPendingServerContentInstalls,
|
||||
type BrowseInstallPlan,
|
||||
@@ -6,15 +6,17 @@ import {
|
||||
createContext,
|
||||
type CreationFlowContextValue,
|
||||
flushStoredServerAddonInstallQueue,
|
||||
getServerAddonInstallPlanProjectIds,
|
||||
getStoredServerAddonInstallQueue,
|
||||
getTargetInstallPreferences,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
type ModpackSearchResult,
|
||||
type PendingServerContentInstall,
|
||||
type PendingServerContentInstallType,
|
||||
readPendingServerContentInstalls,
|
||||
readStoredServerInstallQueue,
|
||||
removePendingServerContentInstall,
|
||||
resolveServerAddonInstallPlans,
|
||||
writePendingServerContentInstallBaseline,
|
||||
writeStoredServerInstallQueue,
|
||||
} from '@modrinth/ui'
|
||||
@@ -54,6 +56,7 @@ export interface ServerInstallContentContext {
|
||||
effectiveServerWorldId: ComputedRef<string | null>
|
||||
serverContextServerData: Ref<Archon.Servers.v0.Server | null>
|
||||
serverContentProjectIds: Ref<Set<string>>
|
||||
queuedServerInstallRootProjectIds: ComputedRef<Set<string>>
|
||||
queuedServerInstallProjectIds: ComputedRef<Set<string>>
|
||||
queuedServerInstallCount: ComputedRef<number>
|
||||
selectedServerInstallProjects: ComputedRef<BrowseSelectedProject[]>
|
||||
@@ -76,6 +79,9 @@ export interface ServerInstallContentContext {
|
||||
setQueuedServerInstallPlans: (
|
||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||
) => void
|
||||
resolveQueuedServerInstallPlan: (
|
||||
plan: BrowseInstallPlan<InstallableSearchResult>,
|
||||
) => Promise<void>
|
||||
openServerModpackInstallFlow: (request: ServerModpackSelectionRequest) => Promise<void>
|
||||
onServerFlowBack: () => void
|
||||
handleServerModpackFlowCreate: (config: CreationFlowContextValue) => Promise<void>
|
||||
@@ -112,48 +118,6 @@ function getQueuedInstallOwnerFallback(project: InstallableSearchResult) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getQueuedInstallOwner(
|
||||
client: AbstractModrinthClient,
|
||||
project: InstallableSearchResult,
|
||||
) {
|
||||
const fallback = getQueuedInstallOwnerFallback(project)
|
||||
|
||||
try {
|
||||
if (project.organization) {
|
||||
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
|
||||
if (organization) {
|
||||
return {
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
type: 'organization' as const,
|
||||
avatar_url: organization.icon_url ?? undefined,
|
||||
link: `https://modrinth.com/organization/${organization.slug}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
|
||||
const owner =
|
||||
members.find((member) => member.user.id === project.author_id)?.user ??
|
||||
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
|
||||
members[0]?.user
|
||||
|
||||
if (owner) {
|
||||
return {
|
||||
id: owner.id,
|
||||
name: owner.username,
|
||||
type: 'user' as const,
|
||||
avatar_url: owner.avatar_url,
|
||||
link: `/user/${encodeURIComponent(owner.username)}`,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function getQueuedAddonInstallPlans(
|
||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||
) {
|
||||
@@ -187,17 +151,6 @@ function getQueuedInstallPlaceholderFallbacks(
|
||||
)
|
||||
}
|
||||
|
||||
async function getQueuedInstallPlaceholders(
|
||||
client: AbstractModrinthClient,
|
||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||
) {
|
||||
return Promise.all(
|
||||
getQueuedAddonInstallPlans(plans).map(async (plan) =>
|
||||
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(client, plan.project)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function createServerInstallContent(opts: {
|
||||
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
|
||||
}) {
|
||||
@@ -250,7 +203,12 @@ export function createServerInstallContent(opts: {
|
||||
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
|
||||
new Map(),
|
||||
)
|
||||
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
|
||||
const queuedServerInstallRootProjectIds = computed(
|
||||
() => new Set(queuedServerInstalls.value.keys()),
|
||||
)
|
||||
const queuedServerInstallProjectIds = computed(() =>
|
||||
getServerAddonInstallPlanProjectIds(queuedServerInstalls.value.values()),
|
||||
)
|
||||
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
|
||||
const selectedServerInstallProjects = computed<BrowseSelectedProject[]>(() =>
|
||||
Array.from(queuedServerInstalls.value.values()).map((plan) => ({
|
||||
@@ -462,6 +420,59 @@ export function createServerInstallContent(opts: {
|
||||
writeStoredServerInstallQueue(serverId, worldId, plans)
|
||||
}
|
||||
|
||||
function toResolvePreferences(
|
||||
preferences?: BrowseInstallPlan<InstallableSearchResult>['preferences'],
|
||||
): Labrinth.Content.v3.ResolutionPreferences {
|
||||
return {
|
||||
game_versions: preferences?.gameVersions,
|
||||
loaders: preferences?.loaders,
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAddonPlan(
|
||||
plan: BrowseInstallPlan<InstallableSearchResult>,
|
||||
existingProjectIds: string[],
|
||||
) {
|
||||
const target = getTargetInstallPreferences(
|
||||
{
|
||||
gameVersion: serverContextServerData.value?.mc_version,
|
||||
loader: serverContextServerData.value?.loader,
|
||||
},
|
||||
plan.contentType,
|
||||
)
|
||||
const resolved = await client.labrinth.content_v3.resolve({
|
||||
project_id: plan.projectId,
|
||||
version_id: plan.versionId,
|
||||
content_type: plan.contentType as Labrinth.Content.v3.ContentType,
|
||||
selected: toResolvePreferences(plan.preferences),
|
||||
target: toResolvePreferences(target),
|
||||
existing_project_ids: existingProjectIds,
|
||||
})
|
||||
|
||||
return [resolved.primary, ...resolved.dependencies].map((item) => ({
|
||||
projectId: item.project_id,
|
||||
versionId: item.version_id,
|
||||
}))
|
||||
}
|
||||
|
||||
async function resolveQueuedServerInstallPlan(plan: BrowseInstallPlan<InstallableSearchResult>) {
|
||||
const resolvedContent = await resolveAddonPlan(plan, Array.from(serverContentProjectIds.value))
|
||||
const storedPlan = queuedServerInstalls.value.get(plan.projectId)
|
||||
if (!storedPlan || storedPlan.versionId !== plan.versionId) return
|
||||
|
||||
const nextPlans = new Map(queuedServerInstalls.value)
|
||||
nextPlans.set(plan.projectId, { ...storedPlan, resolvedContent })
|
||||
setQueuedServerInstallPlans(nextPlans)
|
||||
}
|
||||
|
||||
async function resolveQueuedAddonPlans(plans: BrowseInstallPlan<InstallableSearchResult>[]) {
|
||||
return await resolveServerAddonInstallPlans({
|
||||
plans,
|
||||
existingProjectIds: serverContentProjectIds.value,
|
||||
resolvePlan: resolveAddonPlan,
|
||||
})
|
||||
}
|
||||
|
||||
async function flushQueuedServerInstalls(
|
||||
serverId: string | null = serverIdQuery.value,
|
||||
worldId: string | null = effectiveServerWorldId.value,
|
||||
@@ -486,15 +497,12 @@ export function createServerInstallContent(opts: {
|
||||
const result = await flushStoredServerAddonInstallQueue({
|
||||
serverId,
|
||||
worldId,
|
||||
install: (plans) =>
|
||||
client.archon.content_v1.addAddons(
|
||||
serverId,
|
||||
worldId,
|
||||
plans.map((plan) => ({
|
||||
project_id: plan.projectId,
|
||||
version_id: plan.versionId,
|
||||
})),
|
||||
),
|
||||
install: async (plans) => {
|
||||
const addons = await resolveQueuedAddonPlans(plans)
|
||||
if (addons.length > 0) {
|
||||
await client.archon.content_v1.addAddons(serverId, worldId, addons)
|
||||
}
|
||||
},
|
||||
onQueueChange: (plans) => setStoredServerInstallPlans(serverId, worldId, plans),
|
||||
})
|
||||
|
||||
@@ -544,21 +552,10 @@ export function createServerInstallContent(opts: {
|
||||
writeStoredServerInstallQueue(sid, wid, plans)
|
||||
writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value)
|
||||
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
|
||||
void getQueuedInstallPlaceholders(client, plans)
|
||||
.then((items) => {
|
||||
const pendingProjectIds = new Set(
|
||||
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
|
||||
)
|
||||
addPendingServerContentInstalls(
|
||||
sid,
|
||||
wid,
|
||||
items.filter((item) => pendingProjectIds.has(item.projectId)),
|
||||
)
|
||||
})
|
||||
.catch((err) => handleError(err as Error))
|
||||
}
|
||||
const installed = await flushQueuedServerInstalls(sid, wid)
|
||||
if (!installed) return false
|
||||
await router.push(backUrl)
|
||||
void flushQueuedServerInstalls(sid, wid)
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -627,6 +624,7 @@ export function createServerInstallContent(opts: {
|
||||
effectiveServerWorldId,
|
||||
serverContextServerData,
|
||||
serverContentProjectIds,
|
||||
queuedServerInstallRootProjectIds,
|
||||
queuedServerInstallProjectIds,
|
||||
queuedServerInstallCount,
|
||||
selectedServerInstallProjects,
|
||||
@@ -647,6 +645,7 @@ export function createServerInstallContent(opts: {
|
||||
enforceSetupModpackRoute,
|
||||
getQueuedServerInstallPlans,
|
||||
setQueuedServerInstallPlans,
|
||||
resolveQueuedServerInstallPlan,
|
||||
openServerModpackInstallFlow,
|
||||
onServerFlowBack,
|
||||
handleServerModpackFlowCreate,
|
||||
|
||||
@@ -400,10 +400,10 @@ defineExpose({ show, hide })
|
||||
</Combobox>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.deleteAllGroups)"
|
||||
type="quiet"
|
||||
color="red"
|
||||
type="base"
|
||||
:label="formatMessage(messages.deleteAllGroups)"
|
||||
:disabled="titleButtonsDisabled"
|
||||
class="[&:not(:disabled):focus-visible>svg]:!text-red [&:not(:disabled):hover>svg]:!text-red"
|
||||
@click="showConfirmClearGroups"
|
||||
>
|
||||
<TrashIcon v-if="!isClearing" aria-hidden="true" />
|
||||
|
||||
@@ -14,14 +14,15 @@ import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
flushStoredServerAddonInstallQueue,
|
||||
getServerAddonInstallPlanProjectIds,
|
||||
getStoredServerAddonInstallQueue,
|
||||
getTargetInstallPreferences,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
readPendingServerContentInstalls,
|
||||
readStoredServerInstallQueue,
|
||||
removePendingServerContentInstall,
|
||||
requestInstall,
|
||||
resolveServerAddonInstallPlans,
|
||||
stripServerRuntimeInstallFilters,
|
||||
stripServerRuntimeInstallOverrides,
|
||||
useVIntl,
|
||||
@@ -177,7 +178,12 @@ export function useServerInstallContent({
|
||||
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<ServerInstallSearchResult>>>(
|
||||
readStoredServerInstallQueue(currentServerId.value, currentWorldId.value),
|
||||
)
|
||||
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
|
||||
const queuedServerInstallRootProjectIds = computed(
|
||||
() => new Set(queuedServerInstalls.value.keys()),
|
||||
)
|
||||
const queuedServerInstallProjectIds = computed(() =>
|
||||
getServerAddonInstallPlanProjectIds(queuedServerInstalls.value.values()),
|
||||
)
|
||||
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
|
||||
const selectedServerInstallProjects = computed(() =>
|
||||
Array.from(queuedServerInstalls.value.values()).map((plan) => ({
|
||||
@@ -219,45 +225,6 @@ export function useServerInstallContent({
|
||||
writeStoredServerInstallQueue(serverId, worldId, plans)
|
||||
}
|
||||
|
||||
async function getQueuedInstallOwner(project: ServerInstallSearchResult) {
|
||||
const fallback = getQueuedInstallOwnerFallback(project)
|
||||
|
||||
try {
|
||||
if (project.organization) {
|
||||
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
|
||||
if (organization) {
|
||||
return {
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
type: 'organization' as const,
|
||||
avatar_url: organization.icon_url ?? undefined,
|
||||
link: `/organization/${organization.slug}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
|
||||
const owner =
|
||||
members.find((member) => member.user.id === project.author_id)?.user ??
|
||||
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
|
||||
members[0]?.user
|
||||
|
||||
if (owner) {
|
||||
return {
|
||||
id: owner.id,
|
||||
name: owner.username,
|
||||
type: 'user' as const,
|
||||
avatar_url: owner.avatar_url,
|
||||
link: `/user/${owner.username}`,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function getQueuedInstallPlaceholder(
|
||||
plan: BrowseInstallPlan<ServerInstallSearchResult>,
|
||||
owner: PendingServerContentInstallInput['owner'],
|
||||
@@ -284,16 +251,6 @@ export function useServerInstallContent({
|
||||
)
|
||||
}
|
||||
|
||||
async function getQueuedInstallPlaceholders(
|
||||
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
|
||||
) {
|
||||
return Promise.all(
|
||||
getQueuedAddonInstallPlans(plans).map(async (plan) =>
|
||||
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(plan.project)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function setProjectInstalling(projectId: string, installing: boolean) {
|
||||
const next = new Set(installingProjectIds.value)
|
||||
if (installing) {
|
||||
@@ -437,32 +394,43 @@ export function useServerInstallContent({
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAddonPlan(
|
||||
plan: BrowseInstallPlan<ServerInstallSearchResult>,
|
||||
existingProjectIds: string[],
|
||||
) {
|
||||
const resolved = await client.labrinth.content_v3.resolve({
|
||||
project_id: plan.projectId,
|
||||
version_id: plan.versionId,
|
||||
content_type: plan.contentType as Labrinth.Content.v3.ContentType,
|
||||
selected: toResolvePreferences(plan.preferences),
|
||||
target: toResolvePreferences(getServerInstallTargetPreferences(plan.contentType)),
|
||||
existing_project_ids: existingProjectIds,
|
||||
})
|
||||
|
||||
return [resolved.primary, ...resolved.dependencies].map((item) => ({
|
||||
projectId: item.project_id,
|
||||
versionId: item.version_id,
|
||||
}))
|
||||
}
|
||||
|
||||
async function resolveAndStoreQueuedAddonPlan(
|
||||
plan: BrowseInstallPlan<ServerInstallSearchResult>,
|
||||
) {
|
||||
const resolvedContent = await resolveAddonPlan(plan, Array.from(getServerInstalledProjectIds()))
|
||||
const storedPlan = queuedServerInstalls.value.get(plan.projectId)
|
||||
if (!storedPlan || storedPlan.versionId !== plan.versionId) return
|
||||
|
||||
const nextPlans = new Map(queuedServerInstalls.value)
|
||||
nextPlans.set(plan.projectId, { ...storedPlan, resolvedContent })
|
||||
serverInstallQueue.set(nextPlans)
|
||||
}
|
||||
|
||||
async function resolveQueuedAddonPlans(plans: BrowseInstallPlan<ServerInstallSearchResult>[]) {
|
||||
const existingProjectIds = getServerInstalledProjectIds()
|
||||
const resolvedAddons: Array<{ project_id: string; version_id: string }> = []
|
||||
|
||||
for (const plan of plans) {
|
||||
const resolved = await client.labrinth.content_v3.resolve({
|
||||
project_id: plan.projectId,
|
||||
version_id: plan.versionId,
|
||||
content_type: plan.contentType as Labrinth.Content.v3.ContentType,
|
||||
selected: toResolvePreferences(plan.preferences),
|
||||
target: toResolvePreferences(getServerInstallTargetPreferences(plan.contentType)),
|
||||
existing_project_ids: Array.from(existingProjectIds),
|
||||
})
|
||||
const content = [resolved.primary, ...resolved.dependencies]
|
||||
|
||||
for (const item of content) {
|
||||
if (existingProjectIds.has(item.project_id)) continue
|
||||
existingProjectIds.add(item.project_id)
|
||||
resolvedAddons.push({
|
||||
project_id: item.project_id,
|
||||
version_id: item.version_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedAddons
|
||||
return await resolveServerAddonInstallPlans({
|
||||
plans,
|
||||
existingProjectIds: getServerInstalledProjectIds(),
|
||||
resolvePlan: resolveAddonPlan,
|
||||
})
|
||||
}
|
||||
|
||||
function getInstallProjectVersions(projectId: string) {
|
||||
@@ -533,10 +501,9 @@ export function useServerInstallContent({
|
||||
total: result.flushedPlans.length,
|
||||
}
|
||||
if (result.flushedPlans.length > 0) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list'] }),
|
||||
])
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['content', 'list', 'v1', serverId],
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -564,21 +531,10 @@ export function useServerInstallContent({
|
||||
...optimisticallyInstalledProjectIds.value,
|
||||
])
|
||||
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
|
||||
void getQueuedInstallPlaceholders(plans)
|
||||
.then((items) => {
|
||||
const pendingProjectIds = new Set(
|
||||
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
|
||||
)
|
||||
addPendingServerContentInstalls(
|
||||
sid,
|
||||
wid,
|
||||
items.filter((item) => pendingProjectIds.has(item.projectId)),
|
||||
)
|
||||
})
|
||||
.catch((err) => handleError(err as Error))
|
||||
}
|
||||
const installed = await flushQueuedServerInstalls(sid, wid)
|
||||
if (!installed) return false
|
||||
await navigateTo(backUrl)
|
||||
void flushQueuedServerInstalls(sid, wid)
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -598,16 +554,17 @@ export function useServerInstallContent({
|
||||
const isModpack = contentType === 'modpack'
|
||||
|
||||
try {
|
||||
if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) {
|
||||
if (!isModpack && queuedServerInstallRootProjectIds.value.has(project.project_id)) {
|
||||
removeQueuedServerInstall(project.project_id)
|
||||
return
|
||||
}
|
||||
if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) return
|
||||
|
||||
if (isModpack || !queuedServerInstallProjectIds.value.has(project.project_id)) {
|
||||
setProjectInstalling(project.project_id, true)
|
||||
}
|
||||
|
||||
await requestInstall({
|
||||
const plan = await requestInstall({
|
||||
project,
|
||||
contentType,
|
||||
mode: isModpack ? 'immediate' : 'queue',
|
||||
@@ -646,10 +603,13 @@ export function useServerInstallContent({
|
||||
ctx.modal.value?.setStage('final-config')
|
||||
},
|
||||
})
|
||||
if (!isModpack) await resolveAndStoreQueuedAddonPlan(plan)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
if (isModpack) {
|
||||
setProjectInstalling(project.project_id, false)
|
||||
} else {
|
||||
removeQueuedServerInstall(project.project_id)
|
||||
}
|
||||
handleError(e instanceof Error ? e : new Error(`Error installing content ${e}`))
|
||||
} finally {
|
||||
@@ -804,6 +764,7 @@ export function useServerInstallContent({
|
||||
hideSelectedServerInstalls,
|
||||
installingProjectIds,
|
||||
optimisticallyInstalledProjectIds,
|
||||
queuedServerInstallRootProjectIds,
|
||||
queuedServerInstallProjectIds,
|
||||
queuedServerInstallCount,
|
||||
isInstallingQueuedServerInstalls,
|
||||
|
||||
@@ -3458,6 +3458,12 @@
|
||||
"project.actions.back-to-project-page": {
|
||||
"message": "Back to project page"
|
||||
},
|
||||
"project.actions.check-modpack-archives": {
|
||||
"message": "Check modpack unzip"
|
||||
},
|
||||
"project.actions.checking-modpack-archives": {
|
||||
"message": "Checking..."
|
||||
},
|
||||
"project.actions.create-server": {
|
||||
"message": "Create a server"
|
||||
},
|
||||
@@ -3743,9 +3749,24 @@
|
||||
"project.moderation.title": {
|
||||
"message": "Moderation"
|
||||
},
|
||||
"project.modpack-archive-warning.description": {
|
||||
"message": "Importing this .mrpack might be broken."
|
||||
},
|
||||
"project.modpack-archive-warning.title": {
|
||||
"message": "This modpack was published during export bug"
|
||||
},
|
||||
"project.navigation.changelog": {
|
||||
"message": "Changelog"
|
||||
},
|
||||
"project.notification.check-modpack-archives.failed": {
|
||||
"message": "Some modpack files could not be unzipped"
|
||||
},
|
||||
"project.notification.check-modpack-archives.no-files": {
|
||||
"message": "No .mrpack files were found for this project."
|
||||
},
|
||||
"project.notification.check-modpack-archives.success": {
|
||||
"message": "{count, plural, one {The modpack file can be unzipped.} other {All # modpack files can be unzipped.}}"
|
||||
},
|
||||
"project.notification.icon-updated.message": {
|
||||
"message": "Your project's icon has been updated."
|
||||
},
|
||||
|
||||
@@ -405,6 +405,36 @@
|
||||
:auth="auth"
|
||||
:tags="tags"
|
||||
/>
|
||||
<Admonition
|
||||
v-if="
|
||||
auth.user &&
|
||||
tags.staffRoles.includes(auth.user.role) &&
|
||||
project.actualProjectType === 'modpack' &&
|
||||
hasModpackArchiveInWarningWindow
|
||||
"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.modpackArchiveWarningTitle)"
|
||||
class="mt-3"
|
||||
>
|
||||
{{ formatMessage(messages.modpackArchiveWarningDescription) }}
|
||||
<template #actions>
|
||||
<Button
|
||||
type="colored"
|
||||
color="orange"
|
||||
:loading="isCheckingModpackArchives"
|
||||
@click="checkModpackArchives"
|
||||
>
|
||||
<FileArchiveIcon />
|
||||
{{
|
||||
formatMessage(
|
||||
isCheckingModpackArchives
|
||||
? messages.checkingModpackArchives
|
||||
: messages.checkModpackArchives,
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-if="
|
||||
currentMember &&
|
||||
@@ -529,6 +559,7 @@ import {
|
||||
ClipboardCopyIcon,
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
FileArchiveIcon,
|
||||
FolderSearchIcon,
|
||||
HeartIcon,
|
||||
LeftArrowIcon,
|
||||
@@ -663,6 +694,7 @@ const downloadModal = ref()
|
||||
const openInAppModal = ref()
|
||||
const overTheTopDownloadAnimation = ref()
|
||||
const scanModal = ref()
|
||||
const isCheckingModpackArchives = ref(false)
|
||||
|
||||
const projectV3Loaded = computed(() => !projectV3Pending.value || projectV3.value != null)
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
@@ -818,6 +850,35 @@ const messages = defineMessages({
|
||||
id: 'project.actions.rescan-modpack',
|
||||
defaultMessage: 'Rescan modpack',
|
||||
},
|
||||
checkModpackArchives: {
|
||||
id: 'project.actions.check-modpack-archives',
|
||||
defaultMessage: 'Check modpack unzip',
|
||||
},
|
||||
checkingModpackArchives: {
|
||||
id: 'project.actions.checking-modpack-archives',
|
||||
defaultMessage: 'Checking...',
|
||||
},
|
||||
checkModpackArchivesSuccess: {
|
||||
id: 'project.notification.check-modpack-archives.success',
|
||||
defaultMessage:
|
||||
'{count, plural, one {The modpack file can be unzipped.} other {All # modpack files can be unzipped.}}',
|
||||
},
|
||||
checkModpackArchivesFailed: {
|
||||
id: 'project.notification.check-modpack-archives.failed',
|
||||
defaultMessage: 'Some modpack files could not be unzipped',
|
||||
},
|
||||
checkModpackArchivesNoFiles: {
|
||||
id: 'project.notification.check-modpack-archives.no-files',
|
||||
defaultMessage: 'No .mrpack files were found for this project.',
|
||||
},
|
||||
modpackArchiveWarningTitle: {
|
||||
id: 'project.modpack-archive-warning.title',
|
||||
defaultMessage: 'This modpack was published during export bug',
|
||||
},
|
||||
modpackArchiveWarningDescription: {
|
||||
id: 'project.modpack-archive-warning.description',
|
||||
defaultMessage: 'Importing this .mrpack might be broken.',
|
||||
},
|
||||
serversPromoDescription: {
|
||||
id: 'project.actions.servers-promo.description',
|
||||
defaultMessage: 'Modrinth Hosting is the easiest way to play with your friends without hassle!',
|
||||
@@ -1135,7 +1196,7 @@ const {
|
||||
|
||||
const dependencies = computed(() => dependenciesRaw.value ?? null)
|
||||
|
||||
// V3 Versions - lazy loaded client-side only
|
||||
// V3 Versions - lazy loaded client-side only (except for staff, who need v3 versions for moderation)
|
||||
const versionsEnabled = ref(false)
|
||||
const {
|
||||
data: versionsV3,
|
||||
@@ -1149,7 +1210,7 @@ const {
|
||||
apiVersion: 3,
|
||||
}),
|
||||
staleTime: STALE_TIME_LONG,
|
||||
enabled: computed(() => !!projectId.value && versionsEnabled.value),
|
||||
enabled: computed(() => !!projectId.value && (versionsEnabled.value || isStaff(auth.value.user))),
|
||||
})
|
||||
|
||||
// Organization
|
||||
@@ -1749,6 +1810,90 @@ const showProjectHeaderCreateServerAction = computed(
|
||||
const projectHeaderCreateServerTo = computed(() =>
|
||||
project.value ? `/hosting?project=${project.value.id}#plan` : '/hosting',
|
||||
)
|
||||
|
||||
const MRPACK_ARCHIVE_WARNING_START = new Date('2026-08-10T17:00:00.000Z').getTime()
|
||||
const MRPACK_ARCHIVE_WARNING_END = new Date('2026-08-13T20:00:00.000Z').getTime()
|
||||
const hasModpackArchiveInWarningWindow = computed(() =>
|
||||
(versionsV3.value ?? []).some((version) => {
|
||||
const publishedAt = new Date(version.date_published).getTime()
|
||||
return (
|
||||
version.files.some((file) => file.filename.toLowerCase().endsWith('.mrpack')) &&
|
||||
publishedAt >= MRPACK_ARCHIVE_WARNING_START &&
|
||||
publishedAt <= MRPACK_ARCHIVE_WARNING_END
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
async function checkModpackArchives() {
|
||||
if (!project.value || isCheckingModpackArchives.value) return
|
||||
|
||||
isCheckingModpackArchives.value = true
|
||||
startLoading()
|
||||
|
||||
try {
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(project.value.id)
|
||||
const filesByUrl = new Map(
|
||||
versions
|
||||
.flatMap((version) => version.files)
|
||||
.filter((file) => file.filename.toLowerCase().endsWith('.mrpack'))
|
||||
.map((file) => [file.url, file]),
|
||||
)
|
||||
const files = [...filesByUrl.values()]
|
||||
|
||||
if (files.length === 0) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: formatMessage(messages.checkModpackArchivesNoFiles),
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const { default: JSZip } = await import('jszip')
|
||||
const failures = []
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const response = await fetch(file.url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed (${response.status} ${response.statusText})`)
|
||||
}
|
||||
|
||||
await JSZip.loadAsync(await response.blob(), { checkCRC32: true })
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
filename: file.filename,
|
||||
error: error?.message ?? String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.checkModpackArchivesFailed),
|
||||
text: failures.map((failure) => `${failure.filename}: ${failure.error}`).join('\n'),
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.successLabel),
|
||||
text: formatMessage(messages.checkModpackArchivesSuccess, { count: files.length }),
|
||||
type: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: error?.data?.description ?? error?.message ?? String(error),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isCheckingModpackArchives.value = false
|
||||
stopLoading()
|
||||
}
|
||||
}
|
||||
|
||||
const projectHeaderMoreActions = computed(() => {
|
||||
const isStaff = !!(auth.value.user && tags.value.staffRoles.includes(auth.value.user.role))
|
||||
|
||||
@@ -1787,6 +1932,19 @@ const projectHeaderMoreActions = computed(() => {
|
||||
tone: 'orange',
|
||||
shown: !!auth.value.user && isStaff && project.value?.actualProjectType === 'modpack',
|
||||
},
|
||||
{
|
||||
id: 'moderation-modpack-check-archives',
|
||||
label: formatMessage(
|
||||
isCheckingModpackArchives.value
|
||||
? messages.checkingModpackArchives
|
||||
: messages.checkModpackArchives,
|
||||
),
|
||||
icon: FileArchiveIcon,
|
||||
action: checkModpackArchives,
|
||||
tone: 'orange',
|
||||
disabled: isCheckingModpackArchives.value,
|
||||
shown: !!auth.value.user && isStaff && project.value?.actualProjectType === 'modpack',
|
||||
},
|
||||
{ type: 'divider', shown: !!auth.value.user && isStaff },
|
||||
{
|
||||
id: 'report',
|
||||
|
||||
@@ -163,6 +163,7 @@ const {
|
||||
hideSelectedServerInstalls,
|
||||
installingProjectIds,
|
||||
optimisticallyInstalledProjectIds,
|
||||
queuedServerInstallRootProjectIds,
|
||||
queuedServerInstallProjectIds,
|
||||
queuedServerInstallCount,
|
||||
isInstallingQueuedServerInstalls,
|
||||
@@ -327,6 +328,7 @@ function getCardActions(
|
||||
|
||||
if (serverData.value) {
|
||||
const isQueued = queuedServerInstallProjectIds.value.has(result.project_id)
|
||||
const isQueuedRoot = queuedServerInstallRootProjectIds.value.has(result.project_id)
|
||||
const isInstalled =
|
||||
projectResult.installed ||
|
||||
optimisticallyInstalledProjectIds.value.has(result.project_id) ||
|
||||
@@ -362,7 +364,8 @@ function getCardActions(
|
||||
? CheckIcon
|
||||
: DownloadIcon,
|
||||
iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
|
||||
disabled: !!isInstalled || isInstalling || isInstallingSelection,
|
||||
disabled:
|
||||
!!isInstalled || isInstalling || isInstallingSelection || (isQueued && !isQueuedRoot),
|
||||
color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
|
||||
type: 'outlined',
|
||||
onClick: () => serverInstall(projectResult),
|
||||
|
||||
@@ -318,6 +318,7 @@ vars! {
|
||||
NEVERBOUNCE_BASE_URL: String = neverbounce::DEFAULT_API_URL;
|
||||
|
||||
EMAIL_DOMAIN_BLACKLIST: StringCsv = StringCsv(vec![]);
|
||||
EMAIL_DOMAIN_WHITELIST: StringCsv = StringCsv(vec![]);
|
||||
|
||||
CLICKHOUSE_REPLICATED: bool = false;
|
||||
CLICKHOUSE_URL: String = "http://localhost:8123";
|
||||
|
||||
@@ -1847,9 +1847,35 @@ impl From<NewAccount> for AccountRegisterFlow {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bundled disposable domain list is checked first. Environment blacklist
|
||||
/// entries are matched literally, unless they begin with `*.`, in which case
|
||||
/// they match any subdomain of the remaining suffix.
|
||||
#[derive(PartialEq, Eq)]
|
||||
enum EmailDomainStatus {
|
||||
Whitelisted,
|
||||
Neutral,
|
||||
}
|
||||
|
||||
/// Environment list entries are matched literally, unless they begin with `*.`,
|
||||
/// in which case they match any subdomain of the remaining suffix.
|
||||
fn matches_domain_entry(domain: &str, entry: &str) -> bool {
|
||||
let entry = entry.trim().to_ascii_lowercase();
|
||||
|
||||
match entry.strip_prefix("*.") {
|
||||
Some(suffix) => domain
|
||||
.strip_suffix(suffix)
|
||||
.is_some_and(|subdomain| subdomain.ends_with('.')),
|
||||
None => entry == domain,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_whitelisted_domain(domain: &str) -> bool {
|
||||
let domain = domain.to_ascii_lowercase();
|
||||
|
||||
ENV.EMAIL_DOMAIN_WHITELIST
|
||||
.iter()
|
||||
.any(|entry| matches_domain_entry(&domain, entry))
|
||||
}
|
||||
|
||||
/// The bundled disposable domain list is checked first, then the environment
|
||||
/// blacklist.
|
||||
fn is_blacklisted_domain(domain: &str) -> bool {
|
||||
let domain = domain.to_ascii_lowercase();
|
||||
|
||||
@@ -1863,35 +1889,39 @@ fn is_blacklisted_domain(domain: &str) -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
ENV.EMAIL_DOMAIN_BLACKLIST.iter().any(|entry| {
|
||||
let entry = entry.trim().to_ascii_lowercase();
|
||||
|
||||
match entry.strip_prefix("*.") {
|
||||
Some(suffix) => domain
|
||||
.strip_suffix(suffix)
|
||||
.is_some_and(|subdomain| subdomain.ends_with('.')),
|
||||
None => entry == domain,
|
||||
}
|
||||
})
|
||||
ENV.EMAIL_DOMAIN_BLACKLIST
|
||||
.iter()
|
||||
.any(|entry| matches_domain_entry(&domain, entry))
|
||||
}
|
||||
|
||||
fn ensure_email_domain_is_allowed(email: &str) -> Result<(), ApiError> {
|
||||
fn ensure_email_domain_is_allowed(
|
||||
email: &str,
|
||||
) -> Result<EmailDomainStatus, ApiError> {
|
||||
let Some((_, domain)) = email.rsplit_once('@') else {
|
||||
return Err(ApiError::Request(email_check_error_generic()));
|
||||
};
|
||||
|
||||
if is_whitelisted_domain(domain) {
|
||||
info!(email.domain = domain, "whitelisted email domain, allowing");
|
||||
return Ok(EmailDomainStatus::Whitelisted);
|
||||
}
|
||||
|
||||
if is_blacklisted_domain(domain) {
|
||||
info!(email.domain = domain, "blacklisted email domain, denying");
|
||||
return Err(ApiError::Request(email_check_error_generic()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(EmailDomainStatus::Neutral)
|
||||
}
|
||||
|
||||
async fn ensure_email_is_usable(email: &str) -> Result<(), ApiError> {
|
||||
ensure_email_domain_is_allowed(email)
|
||||
let status = ensure_email_domain_is_allowed(email)
|
||||
.wrap_api_err("validating email domain is allowed")?;
|
||||
|
||||
if status == EmailDomainStatus::Whitelisted {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = check_email(email)
|
||||
.await
|
||||
.wrap_request_err("checking email address")?;
|
||||
|
||||
@@ -7,6 +7,10 @@ export type UploadSessionFile = {
|
||||
filename: string
|
||||
}
|
||||
|
||||
function getUploadSessionPath(scope: Kyros.UploadSessions.v1.Scope, worldId: string) {
|
||||
return `/worlds/${worldId}/${scope}/upload-session`
|
||||
}
|
||||
|
||||
export class KyrosUploadSessionsV1Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'kyros_upload_sessions_v1'
|
||||
@@ -17,7 +21,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
|
||||
worldId: string,
|
||||
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
|
||||
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
|
||||
`/worlds/${worldId}/files/upload-session`,
|
||||
getUploadSessionPath(scope, worldId),
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
@@ -32,7 +36,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
|
||||
worldId: string,
|
||||
): Promise<Kyros.UploadSessions.v1.GetUploadSessionResponse> {
|
||||
return this.client.request<Kyros.UploadSessions.v1.GetUploadSessionResponse>(
|
||||
`/worlds/${worldId}/files/upload-session`,
|
||||
getUploadSessionPath(scope, worldId),
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
@@ -58,7 +62,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
|
||||
}
|
||||
|
||||
return this.client.upload<Kyros.UploadSessions.v1.UploadSessionResponse>(
|
||||
`/worlds/${worldId}/files/upload-session/${uploadId}/files`,
|
||||
`${getUploadSessionPath(scope, worldId)}/${uploadId}/files`,
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
@@ -76,7 +80,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
|
||||
uploadId: string,
|
||||
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
|
||||
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
|
||||
`/worlds/${worldId}/files/upload-session/${uploadId}/finalize`,
|
||||
`${getUploadSessionPath(scope, worldId)}/${uploadId}/finalize`,
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
@@ -92,7 +96,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
|
||||
uploadId: string,
|
||||
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
|
||||
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
|
||||
`/worlds/${worldId}/files/upload-session/${uploadId}`,
|
||||
`${getUploadSessionPath(scope, worldId)}/${uploadId}`,
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
|
||||
@@ -463,32 +463,25 @@ fn pack_get_relative_path(
|
||||
fn get_mrpack_environment(
|
||||
environment: Option<VersionEnvironment>,
|
||||
) -> HashMap<EnvType, SideType> {
|
||||
let (client, server) = match environment
|
||||
.unwrap_or(VersionEnvironment::Unknown)
|
||||
{
|
||||
VersionEnvironment::ClientAndServer
|
||||
| VersionEnvironment::SingleplayerOnly => {
|
||||
(SideType::Required, SideType::Required)
|
||||
}
|
||||
VersionEnvironment::ClientOnly => {
|
||||
(SideType::Required, SideType::Unsupported)
|
||||
}
|
||||
VersionEnvironment::ClientOnlyServerOptional => {
|
||||
(SideType::Required, SideType::Optional)
|
||||
}
|
||||
VersionEnvironment::ServerOnly
|
||||
| VersionEnvironment::DedicatedServerOnly => {
|
||||
(SideType::Unsupported, SideType::Required)
|
||||
}
|
||||
VersionEnvironment::ServerOnlyClientOptional => {
|
||||
(SideType::Optional, SideType::Required)
|
||||
}
|
||||
VersionEnvironment::ClientOrServer
|
||||
| VersionEnvironment::ClientOrServerPrefersBoth => {
|
||||
(SideType::Optional, SideType::Optional)
|
||||
}
|
||||
VersionEnvironment::Unknown => (SideType::Optional, SideType::Optional),
|
||||
};
|
||||
let (client, server) =
|
||||
match environment.unwrap_or(VersionEnvironment::Unknown) {
|
||||
VersionEnvironment::ClientAndServer
|
||||
| VersionEnvironment::ClientOnlyServerOptional
|
||||
| VersionEnvironment::ServerOnly
|
||||
| VersionEnvironment::ServerOnlyClientOptional
|
||||
| VersionEnvironment::ClientOrServer
|
||||
| VersionEnvironment::ClientOrServerPrefersBoth
|
||||
| VersionEnvironment::Unknown => {
|
||||
(SideType::Required, SideType::Required)
|
||||
}
|
||||
VersionEnvironment::ClientOnly
|
||||
| VersionEnvironment::SingleplayerOnly => {
|
||||
(SideType::Required, SideType::Unsupported)
|
||||
}
|
||||
VersionEnvironment::DedicatedServerOnly => {
|
||||
(SideType::Unsupported, SideType::Required)
|
||||
}
|
||||
};
|
||||
|
||||
HashMap::from([(EnvType::Client, client), (EnvType::Server, server)])
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ pub(crate) async fn list_linked_modpack_content(
|
||||
let Some((_, version_id)) = linked_modpack_ids(&link) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let ids = match get_modpack_identifiers(
|
||||
let modpack_ids = match get_modpack_identifiers(
|
||||
&version_id,
|
||||
&resolved.content_set,
|
||||
&state.pool,
|
||||
@@ -308,19 +308,25 @@ pub(crate) async fn list_linked_modpack_content(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ids) => ids,
|
||||
Ok(ids) => Some(ids),
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to fetch modpack identifiers: {}", err);
|
||||
return Ok(Vec::new());
|
||||
None
|
||||
}
|
||||
};
|
||||
let files = content_projects_for_scope(
|
||||
&resolved,
|
||||
cache_behaviour,
|
||||
state,
|
||||
ContentFilter::OnlyModpack(&ids),
|
||||
)
|
||||
.await?;
|
||||
let filter = if let Some(ids) = modpack_ids.as_ref() {
|
||||
ContentFilter::OnlyModpack(ids)
|
||||
} else if let Some(source_kind) = linked_modpack_source_kind(&link) {
|
||||
ContentFilter::OnlySourceKind {
|
||||
source_kind,
|
||||
include_untracked: true,
|
||||
}
|
||||
} else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let files =
|
||||
content_projects_for_scope(&resolved, cache_behaviour, state, filter)
|
||||
.await?;
|
||||
let files = files.into_iter().collect::<Vec<_>>();
|
||||
|
||||
content_files_to_content_items(
|
||||
@@ -371,30 +377,30 @@ pub(crate) async fn get_linked_modpack_info(
|
||||
&state.api_semaphore,
|
||||
),
|
||||
)?;
|
||||
let version = version.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Linked modpack version {version_id} not found"
|
||||
))
|
||||
})?;
|
||||
let (project, all_versions) = if version.project_id != project_id {
|
||||
let (modpack_project, modpack_versions) = tokio::try_join!(
|
||||
CachedEntry::get_project(
|
||||
&version.project_id,
|
||||
cache_behaviour,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
),
|
||||
CachedEntry::get_project_versions(
|
||||
&version.project_id,
|
||||
cache_behaviour,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
),
|
||||
)?;
|
||||
(modpack_project.or(project), modpack_versions)
|
||||
} else {
|
||||
(project, all_versions)
|
||||
};
|
||||
let version_project_id = version
|
||||
.as_ref()
|
||||
.filter(|version| version.project_id != project_id)
|
||||
.map(|version| version.project_id.clone());
|
||||
let (project, all_versions) =
|
||||
if let Some(version_project_id) = version_project_id {
|
||||
let (modpack_project, modpack_versions) = tokio::try_join!(
|
||||
CachedEntry::get_project(
|
||||
&version_project_id,
|
||||
cache_behaviour,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
),
|
||||
CachedEntry::get_project_versions(
|
||||
&version_project_id,
|
||||
cache_behaviour,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
),
|
||||
)?;
|
||||
(modpack_project.or(project), modpack_versions)
|
||||
} else {
|
||||
(project, all_versions)
|
||||
};
|
||||
let project = project.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Linked modpack project {project_id} not found"
|
||||
@@ -433,12 +439,17 @@ pub(crate) async fn get_linked_modpack_info(
|
||||
})
|
||||
})
|
||||
};
|
||||
let (has_update, update_version_id, update_version) = check_modpack_update(
|
||||
&version_id,
|
||||
&version,
|
||||
all_versions,
|
||||
resolved.instance.update_channel,
|
||||
);
|
||||
let (has_update, update_version_id, update_version) = version
|
||||
.as_ref()
|
||||
.map(|version| {
|
||||
check_modpack_update(
|
||||
&version_id,
|
||||
version,
|
||||
all_versions,
|
||||
resolved.instance.update_channel,
|
||||
)
|
||||
})
|
||||
.unwrap_or((false, None, None));
|
||||
|
||||
Ok(Some(LinkedModpackInfo {
|
||||
project,
|
||||
|
||||
@@ -77,7 +77,7 @@ pub enum OwnerType {
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct LinkedModpackInfo {
|
||||
pub project: Project,
|
||||
pub version: Version,
|
||||
pub version: Option<Version>,
|
||||
pub owner: Option<ContentItemOwner>,
|
||||
pub has_update: bool,
|
||||
pub update_version_id: Option<String>,
|
||||
|
||||
@@ -10,6 +10,44 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2026-08-14T17:47:21+00:00`,
|
||||
product: 'app',
|
||||
version: '0.17.9',
|
||||
body: `## Fixed
|
||||
- Reverted server panel change that wasn't meant to go out yet.`,
|
||||
},
|
||||
{
|
||||
date: `2026-08-14T17:47:21+00:00`,
|
||||
product: 'hosting',
|
||||
body: `## Fixed
|
||||
- Reverted server panel change that wasn't meant to go out yet.`,
|
||||
},
|
||||
{
|
||||
date: `2026-08-14T17:47:21+00:00`,
|
||||
product: 'web',
|
||||
body: `## Fixed
|
||||
- Fixed project pages failing to load when user is not logged in.`,
|
||||
},
|
||||
{
|
||||
date: `2026-08-14T17:04:48+00:00`,
|
||||
product: 'hosting',
|
||||
body: `## Changed
|
||||
- The server panel will no longer be locked while individual content is being installed when using the Browse content button.
|
||||
- Better stability when content is being installed
|
||||
|
||||
## Fixed
|
||||
- Fixed issue when trying to upgrade a medal server from the manage servers page.`,
|
||||
},
|
||||
{
|
||||
date: `2026-08-14T17:04:48+00:00`,
|
||||
product: 'app',
|
||||
version: '0.17.8',
|
||||
body: `## Fixed
|
||||
- Fixed some issues rendering Ears skins
|
||||
- Fixed mrpack exports setting the wrong environment inclusion values.
|
||||
- Fixed issue where if your instance was linked to a modpack version that has been deleted from Modrinth the Managed content card on the content tab would not show up`,
|
||||
},
|
||||
{
|
||||
date: `2026-08-13T17:59:06+00:00`,
|
||||
product: 'app',
|
||||
|
||||
@@ -61,13 +61,17 @@ const customer = ref<any>(null)
|
||||
const paymentMethods = ref<any[]>([])
|
||||
const selectedCurrency = ref<string>('USD')
|
||||
|
||||
const pyroProducts = (props.products as Labrinth.Billing.Internal.Product[])
|
||||
.filter((p) => p?.metadata?.type === 'pyro' || p?.metadata?.type === 'medal')
|
||||
.sort((a, b) => {
|
||||
const aRam = a?.metadata?.type === 'pyro' || a?.metadata?.type === 'medal' ? a.metadata.ram : 0
|
||||
const bRam = b?.metadata?.type === 'pyro' || b?.metadata?.type === 'medal' ? b.metadata.ram : 0
|
||||
return aRam - bRam
|
||||
})
|
||||
const pyroProducts = computed(() =>
|
||||
(props.products as Labrinth.Billing.Internal.Product[])
|
||||
.filter((p) => p?.metadata?.type === 'pyro' || p?.metadata?.type === 'medal')
|
||||
.sort((a, b) => {
|
||||
const aRam =
|
||||
a?.metadata?.type === 'pyro' || a?.metadata?.type === 'medal' ? a.metadata.ram : 0
|
||||
const bRam =
|
||||
b?.metadata?.type === 'pyro' || b?.metadata?.type === 'medal' ? b.metadata.ram : 0
|
||||
return aRam - bRam
|
||||
}),
|
||||
)
|
||||
|
||||
function handleError(err: unknown) {
|
||||
debug('Purchase modal error:', err)
|
||||
@@ -120,7 +124,7 @@ const subscription = ref<Labrinth.Billing.Internal.UserSubscription | null>(null
|
||||
const pendingDowngradeBody = ref<Labrinth.Billing.Internal.EditSubscriptionRequest | null>(null)
|
||||
const currentPlanFromSubscription = computed<Labrinth.Billing.Internal.Product | undefined>(() => {
|
||||
return subscription.value
|
||||
? (pyroProducts.find((p) =>
|
||||
? (pyroProducts.value.find((p) =>
|
||||
p.prices.some((price) => price.id === subscription.value?.price_id),
|
||||
) ?? undefined)
|
||||
: undefined
|
||||
|
||||
@@ -68,6 +68,7 @@ useQuery({
|
||||
from_modpack: false,
|
||||
}),
|
||||
enabled: computed(() => !!worldId.value),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const serverSettingsTabComponentMap = {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@click="onCanvasClick"
|
||||
>
|
||||
<div
|
||||
data-skin-preview-debug="controls"
|
||||
class="absolute left-0 right-0 z-10 flex items-center justify-center pointer-events-none"
|
||||
:style="previewControlsPositionStyle"
|
||||
>
|
||||
@@ -18,6 +19,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.subtitle"
|
||||
data-skin-preview-debug="subtitle"
|
||||
class="absolute left-0 right-0 z-10 flex items-center justify-center pointer-events-none"
|
||||
:style="subtitlePositionStyle"
|
||||
>
|
||||
@@ -27,6 +29,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="nametag || $slots['nametag-badge']"
|
||||
data-skin-preview-debug="nametag"
|
||||
class="absolute left-1/2 pointer-events-none z-10"
|
||||
:style="nametagStyle"
|
||||
>
|
||||
@@ -267,16 +270,23 @@ const {
|
||||
},
|
||||
})
|
||||
|
||||
const { hasEarsFeatures, isModelLoaded, isTextureLoaded, modelCenter, modelSize, scene } =
|
||||
useSkinPreviewScene({
|
||||
selectedModelSrc,
|
||||
textureSrc: toRef(props, 'textureSrc'),
|
||||
earsTextureSrc: toRef(props, 'earsTextureSrc'),
|
||||
capeSrc: toRef(props, 'capeSrc'),
|
||||
earsEnabled: toRef(props, 'earsEnabled'),
|
||||
initializeAnimations,
|
||||
cleanupAnimationState,
|
||||
})
|
||||
const {
|
||||
hasEarsFeatures,
|
||||
isModelLoaded,
|
||||
isTextureLoaded,
|
||||
modelCenter,
|
||||
modelSize,
|
||||
scene,
|
||||
visibleBounds,
|
||||
} = useSkinPreviewScene({
|
||||
selectedModelSrc,
|
||||
textureSrc: toRef(props, 'textureSrc'),
|
||||
earsTextureSrc: toRef(props, 'earsTextureSrc'),
|
||||
capeSrc: toRef(props, 'capeSrc'),
|
||||
earsEnabled: toRef(props, 'earsEnabled'),
|
||||
initializeAnimations,
|
||||
cleanupAnimationState,
|
||||
})
|
||||
|
||||
function syncDamageFlashShaderMaterials() {
|
||||
syncDamageFlashShader(scene.value, damageFlashIntensity.value)
|
||||
@@ -310,6 +320,8 @@ const {
|
||||
subtitleWrapped: isSubtitleWrapped,
|
||||
modelCenter,
|
||||
modelSize,
|
||||
scene,
|
||||
visibleBounds,
|
||||
isModelLoaded,
|
||||
})
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export function createRadialSpotlightShader() {
|
||||
`,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
depthTest: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -199,7 +199,6 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
|
||||
}
|
||||
: current,
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: contentListKey(serverId) })
|
||||
}
|
||||
|
||||
function handleWorldContentBaseUpdate(
|
||||
|
||||
@@ -14,6 +14,11 @@ export interface SkinPreviewFitPadding {
|
||||
left: number
|
||||
}
|
||||
|
||||
export interface SkinPreviewBounds {
|
||||
min: SkinPreviewTuple
|
||||
max: SkinPreviewTuple
|
||||
}
|
||||
|
||||
export interface SkinPreviewFitLock {
|
||||
containerSize: {
|
||||
width: number
|
||||
@@ -23,6 +28,7 @@ export interface SkinPreviewFitLock {
|
||||
modelSize: SkinPreviewTuple
|
||||
padding: SkinPreviewFitPadding
|
||||
rotation: number
|
||||
visibleBounds: SkinPreviewBounds
|
||||
}
|
||||
|
||||
export type SkinPreviewTuple = [number, number, number]
|
||||
|
||||
@@ -1939,6 +1939,10 @@ export function removeEarsMod(model: THREE.Object3D | null) {
|
||||
registry.removeFromParent()
|
||||
}
|
||||
|
||||
export function isEarsModFeature(object: THREE.Object3D) {
|
||||
return object.name.startsWith(EARS_FEATURE_PREFIX)
|
||||
}
|
||||
|
||||
export function applyEarsMod(model: THREE.Object3D, sourceTexture: THREE.Texture, enabled = true) {
|
||||
removeEarsMod(model)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from 'vue'
|
||||
|
||||
import type {
|
||||
SkinPreviewBounds,
|
||||
SkinPreviewFitLock,
|
||||
SkinPreviewFitPadding,
|
||||
SkinPreviewFraming,
|
||||
@@ -41,8 +42,88 @@ function cloneModelTuple(tuple: SkinPreviewTuple): SkinPreviewTuple {
|
||||
return [tuple[0], tuple[1], tuple[2]]
|
||||
}
|
||||
|
||||
function cloneBounds(bounds: SkinPreviewBounds): SkinPreviewBounds {
|
||||
return {
|
||||
min: cloneModelTuple(bounds.min),
|
||||
max: cloneModelTuple(bounds.max),
|
||||
}
|
||||
}
|
||||
|
||||
const MODEL_ROTATION_AXIS = new THREE.Vector3(0, 1, 0)
|
||||
|
||||
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
|
||||
|
||||
type SkinPreviewDebugEntry = Record<string, unknown>
|
||||
|
||||
type SkinPreviewDebugWindow = Window & {
|
||||
__SKIN_PREVIEW_DEBUG__?: SkinPreviewDebugEntry[]
|
||||
}
|
||||
|
||||
function serializeRect(rect: DOMRect) {
|
||||
return {
|
||||
bottom: rect.bottom,
|
||||
height: rect.height,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
}
|
||||
}
|
||||
|
||||
function serializeElement(element: Element | null) {
|
||||
if (!(element instanceof HTMLElement)) return null
|
||||
|
||||
const style = window.getComputedStyle(element)
|
||||
return {
|
||||
rect: serializeRect(element.getBoundingClientRect()),
|
||||
style: {
|
||||
bottom: style.bottom,
|
||||
height: style.height,
|
||||
left: style.left,
|
||||
position: style.position,
|
||||
top: style.top,
|
||||
transform: style.transform,
|
||||
width: style.width,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getVisibleMeshDebugBounds(root: THREE.Object3D | null) {
|
||||
if (!root) return []
|
||||
|
||||
root.updateWorldMatrix(true, true)
|
||||
const entries: SkinPreviewDebugEntry[] = []
|
||||
|
||||
root.traverse((object) => {
|
||||
const mesh = object as THREE.Mesh
|
||||
if (!mesh.isMesh || !mesh.geometry || mesh.visible === false) return
|
||||
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
if (materials.length && materials.every((material) => material.visible === false)) return
|
||||
|
||||
if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox()
|
||||
if (!mesh.geometry.boundingBox) return
|
||||
|
||||
const box = mesh.geometry.boundingBox.clone().applyMatrix4(mesh.matrixWorld)
|
||||
entries.push({
|
||||
materials: materials.map((material) => ({
|
||||
name: material.name,
|
||||
side: material.side,
|
||||
visible: material.visible,
|
||||
})),
|
||||
max: box.max.toArray(),
|
||||
min: box.min.toArray(),
|
||||
name: mesh.name,
|
||||
parent: mesh.parent?.name,
|
||||
uuid: mesh.uuid,
|
||||
})
|
||||
})
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
export function useSkinPreviewFit({
|
||||
containerElement,
|
||||
fit,
|
||||
@@ -59,6 +140,8 @@ export function useSkinPreviewFit({
|
||||
subtitleWrapped,
|
||||
modelCenter,
|
||||
modelSize,
|
||||
scene,
|
||||
visibleBounds,
|
||||
isModelLoaded,
|
||||
}: {
|
||||
containerElement: MaybeReadonlyRef<HTMLElement | null>
|
||||
@@ -76,11 +159,15 @@ export function useSkinPreviewFit({
|
||||
subtitleWrapped: MaybeReadonlyRef<boolean>
|
||||
modelCenter: MaybeReadonlyRef<SkinPreviewTuple>
|
||||
modelSize: MaybeReadonlyRef<SkinPreviewTuple>
|
||||
scene: MaybeReadonlyRef<THREE.Object3D | null>
|
||||
visibleBounds: MaybeReadonlyRef<SkinPreviewBounds>
|
||||
isModelLoaded: MaybeReadonlyRef<boolean>
|
||||
}) {
|
||||
const containerSize = ref({ width: 1, height: 1 })
|
||||
const fitLock = ref<SkinPreviewFitLock | null>(null)
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
let debugAnimationFrame: number | null = null
|
||||
const pendingDebugReasons = new Set<string>()
|
||||
|
||||
const fitEnabled = computed(() => {
|
||||
if (fit.value !== undefined) return fit.value
|
||||
@@ -113,6 +200,11 @@ export function useSkinPreviewFit({
|
||||
const fitModelRotation = computed(() =>
|
||||
lockFitEnabled.value ? (fitLock.value?.rotation ?? modelRotation.value) : modelRotation.value,
|
||||
)
|
||||
const fitVisibleBounds = computed(() =>
|
||||
lockFitEnabled.value
|
||||
? (fitLock.value?.visibleBounds ?? visibleBounds.value)
|
||||
: visibleBounds.value,
|
||||
)
|
||||
|
||||
const resolvedFitPadding = computed<SkinPreviewFitPadding>(() => {
|
||||
const preset = FRAMING_PRESETS[currentFraming.value].padding
|
||||
@@ -198,22 +290,42 @@ export function useSkinPreviewFit({
|
||||
}
|
||||
})
|
||||
|
||||
const modelFeetTop = computed(() => {
|
||||
const projectedVisibleBounds = computed(() => {
|
||||
if (!fitEnabled.value) return null
|
||||
|
||||
const width = Math.max(containerSize.value.width, 1)
|
||||
const height = Math.max(containerSize.value.height, 1)
|
||||
const [, sizeY] = fitModelSize.value
|
||||
const { fov: resolvedFov, position, target } = cameraConfig.value
|
||||
const distance = Math.max(Math.abs(position[2] - target[2]), 0.001)
|
||||
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
|
||||
const modelFeetY = -sizeY / 2
|
||||
const projectedY =
|
||||
(modelFeetY - target[1]) / distance / Math.max(Math.tan(verticalFov / 2), 0.001)
|
||||
const topPercent = THREE.MathUtils.clamp(((1 - projectedY) / 2) * 100, 0, 100)
|
||||
const camera = new THREE.PerspectiveCamera(resolvedFov, width / height, 0.1, 1000)
|
||||
camera.position.set(...position)
|
||||
camera.lookAt(...target)
|
||||
camera.updateProjectionMatrix()
|
||||
camera.updateMatrixWorld(true)
|
||||
|
||||
return (topPercent / 100) * height
|
||||
const bounds = fitVisibleBounds.value
|
||||
const [offsetX, offsetY, offsetZ] = modelOffset.value
|
||||
let top = Number.POSITIVE_INFINITY
|
||||
let bottom = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (const x of [bounds.min[0], bounds.max[0]]) {
|
||||
for (const y of [bounds.min[1], bounds.max[1]]) {
|
||||
for (const z of [bounds.min[2], bounds.max[2]]) {
|
||||
const point = new THREE.Vector3(x + offsetX, y + offsetY, z + offsetZ)
|
||||
point.applyAxisAngle(MODEL_ROTATION_AXIS, fitModelRotation.value)
|
||||
point.project(camera)
|
||||
|
||||
const screenY = ((1 - point.y) / 2) * height
|
||||
top = Math.min(top, screenY)
|
||||
bottom = Math.max(bottom, screenY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { bottom, top }
|
||||
})
|
||||
|
||||
const modelFeetTop = computed(() => projectedVisibleBounds.value?.bottom ?? null)
|
||||
|
||||
const previewControlsTop = computed(() =>
|
||||
modelFeetTop.value === null ? null : modelFeetTop.value + PREVIEW_CONTROLS_FOOT_OFFSET,
|
||||
)
|
||||
@@ -250,22 +362,8 @@ export function useSkinPreviewFit({
|
||||
const nametagTop = computed(() => {
|
||||
if (!fitEnabled.value) return '18%'
|
||||
|
||||
const height = Math.max(containerSize.value.height, 1)
|
||||
const [sizeX, sizeY, sizeZ] = fitModelSize.value
|
||||
const { fov: resolvedFov, position, target } = cameraConfig.value
|
||||
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
|
||||
const modelTopY = sizeY / 2
|
||||
const halfX = sizeX / 2
|
||||
const halfZ = sizeZ / 2
|
||||
const sinRotation = Math.sin(fitModelRotation.value)
|
||||
const cosRotation = Math.cos(fitModelRotation.value)
|
||||
const modelTopZ = -Math.abs(halfX * sinRotation) - Math.abs(halfZ * cosRotation)
|
||||
const distance = Math.max(Math.abs(position[2] - target[2]) + modelTopZ, 0.001)
|
||||
const projectedY =
|
||||
(modelTopY - target[1]) / distance / Math.max(Math.tan(verticalFov / 2), 0.001)
|
||||
const topPercent = ((1 - projectedY) / 2) * 100
|
||||
|
||||
return `${(topPercent / 100) * height - NAMETAG_HEAD_OFFSET}px`
|
||||
const top = projectedVisibleBounds.value?.top
|
||||
return top === undefined ? '18%' : `${top - NAMETAG_HEAD_OFFSET}px`
|
||||
})
|
||||
|
||||
const spotlightY = computed(() => {
|
||||
@@ -292,6 +390,89 @@ export function useSkinPreviewFit({
|
||||
return [radius, radius, radius]
|
||||
})
|
||||
|
||||
function captureDebugSnapshot(reasons: string[]) {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const container = containerElement.value
|
||||
const canvas = container?.querySelector('canvas') ?? null
|
||||
const debugWindow = window as SkinPreviewDebugWindow
|
||||
const entries = (debugWindow.__SKIN_PREVIEW_DEBUG__ ??= [])
|
||||
const snapshot = JSON.parse(
|
||||
JSON.stringify({
|
||||
camera: cameraConfig.value,
|
||||
currentInputs: {
|
||||
isModelLoaded: isModelLoaded.value,
|
||||
modelCenter: modelCenter.value,
|
||||
modelSize: modelSize.value,
|
||||
padding: resolvedFitPadding.value,
|
||||
rotation: modelRotation.value,
|
||||
visibleBounds: visibleBounds.value,
|
||||
},
|
||||
dom: {
|
||||
canvas: {
|
||||
...serializeElement(canvas),
|
||||
bufferHeight: canvas instanceof HTMLCanvasElement ? canvas.height : null,
|
||||
bufferWidth: canvas instanceof HTMLCanvasElement ? canvas.width : null,
|
||||
},
|
||||
container: serializeElement(container),
|
||||
controls: serializeElement(
|
||||
container?.querySelector('[data-skin-preview-debug="controls"]') ?? null,
|
||||
),
|
||||
nametag: serializeElement(
|
||||
container?.querySelector('[data-skin-preview-debug="nametag"]') ?? null,
|
||||
),
|
||||
subtitle: serializeElement(
|
||||
container?.querySelector('[data-skin-preview-debug="subtitle"]') ?? null,
|
||||
),
|
||||
},
|
||||
effectiveFit: {
|
||||
containerSize: fitContainerSize.value,
|
||||
modelCenter: fitModelCenter.value,
|
||||
modelOffset: modelOffset.value,
|
||||
modelSize: fitModelSize.value,
|
||||
padding: fitResolvedPadding.value,
|
||||
rotation: fitModelRotation.value,
|
||||
visibleBounds: fitVisibleBounds.value,
|
||||
},
|
||||
fitEnabled: fitEnabled.value,
|
||||
fitLock: fitLock.value,
|
||||
lockFitEnabled: lockFitEnabled.value,
|
||||
meshBounds: getVisibleMeshDebugBounds(scene.value),
|
||||
overlayCalculations: {
|
||||
nametagTop: nametagTop.value,
|
||||
previewControlsPositionStyle: previewControlsPositionStyle.value,
|
||||
projectedVisibleBounds: projectedVisibleBounds.value,
|
||||
subtitlePositionStyle: subtitlePositionStyle.value,
|
||||
},
|
||||
reasons,
|
||||
timestamp: new Date().toISOString(),
|
||||
window: {
|
||||
devicePixelRatio: window.devicePixelRatio,
|
||||
innerHeight: window.innerHeight,
|
||||
innerWidth: window.innerWidth,
|
||||
},
|
||||
}),
|
||||
) as SkinPreviewDebugEntry
|
||||
|
||||
entries.push(snapshot)
|
||||
if (entries.length > 100) entries.splice(0, entries.length - 100)
|
||||
console.log('[SkinPreviewDebug]', snapshot)
|
||||
}
|
||||
|
||||
function queueDebugSnapshot(reason: string) {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
pendingDebugReasons.add(reason)
|
||||
if (debugAnimationFrame !== null) return
|
||||
|
||||
debugAnimationFrame = window.requestAnimationFrame(() => {
|
||||
debugAnimationFrame = null
|
||||
const reasons = Array.from(pendingDebugReasons)
|
||||
pendingDebugReasons.clear()
|
||||
captureDebugSnapshot(reasons)
|
||||
})
|
||||
}
|
||||
|
||||
function lockFitState() {
|
||||
if (!fitEnabled.value || !lockFitEnabled.value || fitLock.value || !isModelLoaded.value) return
|
||||
|
||||
@@ -304,7 +485,9 @@ export function useSkinPreviewFit({
|
||||
modelSize: cloneModelTuple(modelSize.value),
|
||||
padding: { ...resolvedFitPadding.value },
|
||||
rotation: modelRotation.value,
|
||||
visibleBounds: cloneBounds(visibleBounds.value),
|
||||
}
|
||||
queueDebugSnapshot('fit-lock-created')
|
||||
}
|
||||
|
||||
function resetFitLockForLayoutChange() {
|
||||
@@ -312,6 +495,7 @@ export function useSkinPreviewFit({
|
||||
|
||||
fitLock.value = null
|
||||
lockFitState()
|
||||
queueDebugSnapshot('fit-lock-reset')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -332,10 +516,12 @@ export function useSkinPreviewFit({
|
||||
|
||||
if (didContainerSizeChange) {
|
||||
resetFitLockForLayoutChange()
|
||||
queueDebugSnapshot('container-resized')
|
||||
}
|
||||
})
|
||||
|
||||
resizeObserver.observe(el)
|
||||
queueDebugSnapshot('mounted')
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -358,8 +544,22 @@ export function useSkinPreviewFit({
|
||||
lockFitState()
|
||||
})
|
||||
|
||||
watch([modelCenter, modelSize, visibleBounds, resolvedFitPadding], () => {
|
||||
resetFitLockForLayoutChange()
|
||||
queueDebugSnapshot('model-inputs-changed')
|
||||
})
|
||||
|
||||
watch(
|
||||
projectedVisibleBounds,
|
||||
() => {
|
||||
queueDebugSnapshot('projection-changed')
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
if (debugAnimationFrame !== null) window.cancelAnimationFrame(debugAnimationFrame)
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
loadTexture as loadSkinTexture,
|
||||
} from '#ui/utils/webgl/skin-rendering.ts'
|
||||
|
||||
import type { SkinPreviewTuple } from './types'
|
||||
import { applyEarsMod, removeEarsMod } from './use-ears-mod-features'
|
||||
import type { SkinPreviewBounds, SkinPreviewTuple } from './types'
|
||||
import { applyEarsMod, isEarsModFeature, removeEarsMod } from './use-ears-mod-features'
|
||||
|
||||
const SKIN_LAYER_DEPTH_BIAS = -1
|
||||
|
||||
@@ -77,16 +77,25 @@ function disposeSceneMaterials(root: THREE.Object3D | null) {
|
||||
materials.forEach((material) => material.dispose())
|
||||
}
|
||||
|
||||
function getVisibleMeshBox(root: THREE.Object3D): THREE.Box3 | null {
|
||||
function getVisibleMeshBox(
|
||||
root: THREE.Object3D,
|
||||
includeMesh: (mesh: THREE.Mesh) => boolean = () => true,
|
||||
): THREE.Box3 | null {
|
||||
root.updateWorldMatrix(true, true)
|
||||
|
||||
const result = new THREE.Box3()
|
||||
const meshBox = new THREE.Box3()
|
||||
const rootParentInverse = new THREE.Matrix4()
|
||||
const meshToRootParent = new THREE.Matrix4()
|
||||
let found = false
|
||||
|
||||
if (root.parent) {
|
||||
rootParentInverse.copy(root.parent.matrixWorld).invert()
|
||||
}
|
||||
|
||||
root.traverse((object) => {
|
||||
const mesh = object as THREE.Mesh
|
||||
if (!mesh.isMesh || !mesh.geometry || mesh.visible === false) return
|
||||
if (!mesh.isMesh || !mesh.geometry || mesh.visible === false || !includeMesh(mesh)) return
|
||||
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
if (materials.length && materials.every((material) => material.visible === false)) return
|
||||
@@ -97,7 +106,8 @@ function getVisibleMeshBox(root: THREE.Object3D): THREE.Box3 | null {
|
||||
|
||||
if (!mesh.geometry.boundingBox) return
|
||||
|
||||
meshBox.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld)
|
||||
meshToRootParent.multiplyMatrices(rootParentInverse, mesh.matrixWorld)
|
||||
meshBox.copy(mesh.geometry.boundingBox).applyMatrix4(meshToRootParent)
|
||||
result.union(meshBox)
|
||||
found = true
|
||||
})
|
||||
@@ -105,6 +115,13 @@ function getVisibleMeshBox(root: THREE.Object3D): THREE.Box3 | null {
|
||||
return found && !result.isEmpty() ? result.clone() : null
|
||||
}
|
||||
|
||||
function isPlayerMesh(mesh: THREE.Mesh) {
|
||||
if (isEarsModFeature(mesh)) return false
|
||||
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
return materials.some((material) => material.name !== 'cape')
|
||||
}
|
||||
|
||||
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
|
||||
|
||||
export function useSkinPreviewScene({
|
||||
@@ -136,6 +153,10 @@ export function useSkinPreviewScene({
|
||||
const transparentTexture = createTransparentTexture()
|
||||
const modelCenter = ref<SkinPreviewTuple>([0, 1, 0])
|
||||
const modelSize = ref<SkinPreviewTuple>([1, 2, 1])
|
||||
const visibleBounds = ref<SkinPreviewBounds>({
|
||||
min: [-0.5, 0, -0.5],
|
||||
max: [0.5, 2, 0.5],
|
||||
})
|
||||
const isModelLoaded = ref(false)
|
||||
const isTextureLoaded = ref(false)
|
||||
const hasEarsFeatures = ref(false)
|
||||
@@ -252,6 +273,7 @@ export function useSkinPreviewScene({
|
||||
capeTexture.value = loadedCapeTexture
|
||||
loadedCapeSrc.value = src
|
||||
applyCapeTextureToLoadedModel()
|
||||
updateModelInfo()
|
||||
}
|
||||
|
||||
async function loadAndApplyEarsTexture(src: string | undefined) {
|
||||
@@ -269,21 +291,44 @@ export function useSkinPreviewScene({
|
||||
|
||||
function updateModelInfo() {
|
||||
const box = scene.value ? getVisibleMeshBox(scene.value) : null
|
||||
const playerBox = scene.value ? getVisibleMeshBox(scene.value, isPlayerMesh) : null
|
||||
|
||||
if (!box) {
|
||||
modelCenter.value = [0, 1, 0]
|
||||
modelSize.value = [1, 2, 1]
|
||||
visibleBounds.value = {
|
||||
min: [-0.5, 0, -0.5],
|
||||
max: [0.5, 2, 0.5],
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const center = new THREE.Vector3()
|
||||
const size = new THREE.Vector3()
|
||||
const playerCenter = new THREE.Vector3()
|
||||
const rotationCenterBox = playerBox ?? box
|
||||
|
||||
box.getCenter(center)
|
||||
box.getSize(size)
|
||||
rotationCenterBox.getCenter(playerCenter)
|
||||
|
||||
modelCenter.value = [center.x, center.y, center.z]
|
||||
modelSize.value = [Math.max(size.x, 0.001), Math.max(size.y, 0.001), Math.max(size.z, 0.001)]
|
||||
const halfWidth = Math.max(
|
||||
Math.abs(box.min.x - playerCenter.x),
|
||||
Math.abs(box.max.x - playerCenter.x),
|
||||
)
|
||||
const halfDepth = Math.max(
|
||||
Math.abs(box.min.z - playerCenter.z),
|
||||
Math.abs(box.max.z - playerCenter.z),
|
||||
)
|
||||
|
||||
modelCenter.value = [playerCenter.x, center.y, playerCenter.z]
|
||||
modelSize.value = [
|
||||
Math.max(halfWidth * 2, 0.001),
|
||||
Math.max(box.max.y - box.min.y, 0.001),
|
||||
Math.max(halfDepth * 2, 0.001),
|
||||
]
|
||||
visibleBounds.value = {
|
||||
min: [box.min.x, box.min.y, box.min.z],
|
||||
max: [box.max.x, box.max.y, box.max.z],
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -303,6 +348,7 @@ export function useSkinPreviewScene({
|
||||
texture.value = loadedTexture
|
||||
loadedTextureSrc.value = newSrc
|
||||
applyTextureToLoadedModel()
|
||||
updateModelInfo()
|
||||
isTextureLoaded.value = true
|
||||
},
|
||||
)
|
||||
@@ -365,5 +411,6 @@ export function useSkinPreviewScene({
|
||||
modelCenter,
|
||||
modelSize,
|
||||
scene,
|
||||
visibleBounds,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@ export interface BrowseInstallTarget {
|
||||
loader?: string | null
|
||||
}
|
||||
|
||||
export interface BrowseResolvedInstallContent {
|
||||
projectId: string
|
||||
versionId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal project shape needed by shared install resolution.
|
||||
*/
|
||||
@@ -59,6 +64,7 @@ export interface BrowseInstallPlan<TProject extends BrowseInstallProject = Brows
|
||||
contentType: BrowseInstallContentType
|
||||
preferences: BrowseInstallPreferences
|
||||
source: BrowseInstallPlanSource
|
||||
resolvedContent?: BrowseResolvedInstallContent[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -335,6 +341,15 @@ export interface FlushStoredServerAddonInstallQueueOptions<TProject extends Brow
|
||||
onQueueChange?: (plans: Map<string, BrowseInstallPlan<TProject>>) => void
|
||||
}
|
||||
|
||||
export interface ResolveServerAddonInstallPlansOptions<TProject extends BrowseInstallProject> {
|
||||
plans: readonly BrowseInstallPlan<TProject>[]
|
||||
existingProjectIds: Iterable<string>
|
||||
resolvePlan: (
|
||||
plan: BrowseInstallPlan<TProject>,
|
||||
existingProjectIds: string[],
|
||||
) => Promise<BrowseResolvedInstallContent[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a queue flush. Failed plans are also written back to the queue.
|
||||
*/
|
||||
@@ -646,6 +661,62 @@ export function getStoredServerAddonInstallQueue<
|
||||
return addonPlans
|
||||
}
|
||||
|
||||
export function getServerAddonInstallPlanProjectIds<TProject extends BrowseInstallProject>(
|
||||
plans: Iterable<BrowseInstallPlan<TProject>>,
|
||||
) {
|
||||
const projectIds = new Set<string>()
|
||||
for (const plan of plans) {
|
||||
projectIds.add(plan.projectId)
|
||||
for (const item of plan.resolvedContent ?? []) {
|
||||
projectIds.add(item.projectId)
|
||||
}
|
||||
}
|
||||
return projectIds
|
||||
}
|
||||
|
||||
export async function resolveServerAddonInstallPlans<TProject extends BrowseInstallProject>({
|
||||
plans,
|
||||
existingProjectIds,
|
||||
resolvePlan,
|
||||
}: ResolveServerAddonInstallPlansOptions<TProject>) {
|
||||
const installedProjectIds = new Set(existingProjectIds)
|
||||
const resolutionExistingProjectIds = Array.from(installedProjectIds)
|
||||
const resolvedPlans = await Promise.all(
|
||||
plans.map(
|
||||
async (plan) =>
|
||||
plan.resolvedContent ?? (await resolvePlan(plan, resolutionExistingProjectIds)),
|
||||
),
|
||||
)
|
||||
const explicitProjectIds = new Set(plans.map((plan) => plan.projectId))
|
||||
const addons = new Map<string, { project_id: string; version_id: string }>()
|
||||
|
||||
for (const plan of plans) {
|
||||
if (installedProjectIds.has(plan.projectId)) continue
|
||||
addons.set(plan.projectId, {
|
||||
project_id: plan.projectId,
|
||||
version_id: plan.versionId,
|
||||
})
|
||||
}
|
||||
|
||||
for (const content of resolvedPlans) {
|
||||
for (const item of content) {
|
||||
if (
|
||||
installedProjectIds.has(item.projectId) ||
|
||||
explicitProjectIds.has(item.projectId) ||
|
||||
addons.has(item.projectId)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
addons.set(item.projectId, {
|
||||
project_id: item.projectId,
|
||||
version_id: item.versionId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(addons.values())
|
||||
}
|
||||
|
||||
export async function flushStoredServerAddonInstallQueue<TProject extends BrowseInstallProject>({
|
||||
serverId,
|
||||
worldId,
|
||||
@@ -869,7 +940,22 @@ function isStoredBrowseInstallPlan(
|
||||
typeof record.versionId === 'string' &&
|
||||
isStoredBrowseInstallContentType(record.contentType) &&
|
||||
isStoredBrowseInstallPreferences(record.preferences) &&
|
||||
(record.source === 'filtered' || record.source === 'target')
|
||||
(record.source === 'filtered' || record.source === 'target') &&
|
||||
isOptionalStoredResolvedContent(record.resolvedContent)
|
||||
)
|
||||
}
|
||||
|
||||
function isOptionalStoredResolvedContent(value: unknown) {
|
||||
return (
|
||||
value === undefined ||
|
||||
(Array.isArray(value) &&
|
||||
value.every(
|
||||
(item) =>
|
||||
!!item &&
|
||||
typeof item === 'object' &&
|
||||
typeof (item as Record<string, unknown>).projectId === 'string' &&
|
||||
typeof (item as Record<string, unknown>).versionId === 'string',
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -245,6 +245,7 @@ const addonsQuery = useQuery({
|
||||
queryFn: () =>
|
||||
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
|
||||
enabled: computed(() => worldId.value !== null),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const modpack = computed(() => addonsQuery.data.value?.modpack ?? null)
|
||||
@@ -411,6 +412,9 @@ async function uploadLocalModpackWithSoftOverride() {
|
||||
|
||||
provideInstallationSettings({
|
||||
closeSettings: serverSettings.closeModal,
|
||||
afterSave: async () => {
|
||||
serverSettings.closeModal?.()
|
||||
},
|
||||
onGameVersionHover: handleGameVersionHover,
|
||||
loading: computed(() => !server.value || addonsQuery.isLoading.value),
|
||||
installationInfo: computed(() => {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
flushStoredServerAddonInstallQueue,
|
||||
getStoredServerAddonInstallQueue,
|
||||
getTargetInstallPreferences,
|
||||
resolveServerAddonInstallPlans,
|
||||
} from '../../../shared/browse-tab/composables/install-logic'
|
||||
import ManagedContentModal from '../../../shared/content-tab/components/managed-content-modal/index.vue'
|
||||
import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue'
|
||||
@@ -157,7 +158,7 @@ const contentQuery = useQuery({
|
||||
queryFn: () =>
|
||||
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
|
||||
enabled: computed(() => worldId.value !== null),
|
||||
staleTime: 0,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const isModpackContentModalOpen = ref(false)
|
||||
@@ -168,7 +169,7 @@ const modpackContentQuery = useQuery({
|
||||
from_modpack: true,
|
||||
}),
|
||||
enabled: computed(() => worldId.value !== null && !!contentQuery.data.value?.modpack),
|
||||
staleTime: 0,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const setupActionDisabled = computed(() => !canSetup.value || busyReasons.value.length > 0)
|
||||
@@ -395,37 +396,32 @@ function toResolvePreferences(
|
||||
}
|
||||
|
||||
async function resolveStoredServerAddonPlans(plans: BrowseInstallPlan[]) {
|
||||
const existingProjectIds = getInstalledProjectIds()
|
||||
const resolvedAddons: Array<{ project_id: string; version_id: string }> = []
|
||||
|
||||
for (const plan of plans) {
|
||||
const target = getTargetInstallPreferences(
|
||||
{
|
||||
gameVersion: server.value?.mc_version,
|
||||
loader: server.value?.loader,
|
||||
},
|
||||
plan.contentType,
|
||||
)
|
||||
const resolved = await client.labrinth.content_v3.resolve({
|
||||
project_id: plan.projectId,
|
||||
version_id: plan.versionId,
|
||||
content_type: plan.contentType as Labrinth.Content.v3.ContentType,
|
||||
selected: toResolvePreferences(plan.preferences),
|
||||
target: toResolvePreferences(target),
|
||||
existing_project_ids: Array.from(existingProjectIds),
|
||||
})
|
||||
|
||||
for (const item of [resolved.primary, ...resolved.dependencies]) {
|
||||
if (existingProjectIds.has(item.project_id)) continue
|
||||
existingProjectIds.add(item.project_id)
|
||||
resolvedAddons.push({
|
||||
project_id: item.project_id,
|
||||
version_id: item.version_id,
|
||||
return await resolveServerAddonInstallPlans({
|
||||
plans,
|
||||
existingProjectIds: getInstalledProjectIds(),
|
||||
resolvePlan: async (plan, existingProjectIds) => {
|
||||
const target = getTargetInstallPreferences(
|
||||
{
|
||||
gameVersion: server.value?.mc_version,
|
||||
loader: server.value?.loader,
|
||||
},
|
||||
plan.contentType,
|
||||
)
|
||||
const resolved = await client.labrinth.content_v3.resolve({
|
||||
project_id: plan.projectId,
|
||||
version_id: plan.versionId,
|
||||
content_type: plan.contentType as Labrinth.Content.v3.ContentType,
|
||||
selected: toResolvePreferences(plan.preferences),
|
||||
target: toResolvePreferences(target),
|
||||
existing_project_ids: existingProjectIds,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedAddons
|
||||
return [resolved.primary, ...resolved.dependencies].map((item) => ({
|
||||
projectId: item.project_id,
|
||||
versionId: item.version_id,
|
||||
}))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function addonMatchesPendingInstall(
|
||||
|
||||
@@ -1124,8 +1124,13 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) =>
|
||||
)
|
||||
}
|
||||
|
||||
let newModInvalidateTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const handleNewMod = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
|
||||
if (newModInvalidateTimer) clearTimeout(newModInvalidateTimer)
|
||||
newModInvalidateTimer = setTimeout(() => {
|
||||
newModInvalidateTimer = null
|
||||
void queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => {
|
||||
@@ -1520,6 +1525,10 @@ function initializeServer() {
|
||||
|
||||
const cleanup = () => {
|
||||
isMounted.value = false
|
||||
if (newModInvalidateTimer) {
|
||||
clearTimeout(newModInvalidateTimer)
|
||||
newModInvalidateTimer = null
|
||||
}
|
||||
|
||||
saveWsStateToCache()
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): voi
|
||||
const propertiesNeedUpdate = setShaderMaterialProperties(mat, {
|
||||
alphaTest: 0.1,
|
||||
flatShading: true,
|
||||
side: THREE.FrontSide,
|
||||
side: isSkinLayer ? THREE.DoubleSide : THREE.FrontSide,
|
||||
toneMapped: false,
|
||||
transparent: isSkinLayer,
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ export const getUserLink = (user) => {
|
||||
}
|
||||
|
||||
export const isStaff = (user) => {
|
||||
return user && STAFF_ROLES.includes(user.role)
|
||||
return !!user && STAFF_ROLES.includes(user.role)
|
||||
}
|
||||
|
||||
export const isAdmin = (user) => {
|
||||
|
||||
Reference in New Issue
Block a user