feat: content tab rewrite for worlds (#5136)
* feat: base content card component * fix: tooltips + colors * feat: fix orgs * feat: base content tab internals rewrite * feat: fix invalidmodal * feat: add ContentModpackCard * fix: extract types * draft: layout * feat: unlink modal * feat: impl content tab * fix: lint * fix: toggling * temp: disable updating stuff * feat: selection v-model * feat: bulk selection * feat: mods tab rough draft * feat: use fuse.js * feat: add project combobox * clean up project combobox * feat: start install to play modal * fix: events * feat: use v-on * feat: bulk actions + fix floating action bar width * feat: figma alignments * feat: migrate toggle to tailwind * fix: row borders * feat: disabled state * feat: virtual list impl for card table based on window scroll * fix: lint * feat: virtualization + smaller contentcard items * feat: use ContentCardTable + ContentCardItems * feat: fix gap + border issues on last elm * feat: cleanup + use proper searching * fix: use TeleportOverflowMenu * fix: fallback to svg if src is invalid on avatar component * fix: storybook * feat: start on updater modal * feat: finish content updater modal * feat: i18n pass * feat: impl modal * feat(app): backend changes for content tab refactor (#5237) * feat: include_changelog=false for updater modal * fix: hash overrides * feat: update checking for modpack * feat: qa * feat: modpack content modal * fix: padding in table to match modals + tightness * fix: lint * feat: delete modal * feat: fix toggle bugs * fix: prepr * fix: duplicate messages * qa: full width search * qa: use bg-surface-1.5 * qa: animation for filter pills * qa: standardize hover colors * fix: border-[1px] is border * qa: mass de-select actually mass selecting * qa: match figma designs for floating action bar * qa: modal fixes * q: modal fixes x2 * fix: table border * qa: confirm modals * qa: modal alignment * qa: re-add stuck heading + dedupe logic * qa: dedupe virtual scrolling + remove dead components * qa: responsiveness for content table + link fixes * qa: version column link, tooltips + lint fixes * qa: instance busy protections * fix: installation freeze bug * chore: remove old mods page * refactor: deduplicate layout * chore: delete old content page(s) * qa * qa * qa * feat: sort btn - to iterate * fix: ml * feat: date added * fix: lint * fix: formatting.ts removal * feat: get_dependencies_as_content_items * qa: final QA changes * refactor: deduplicate + polish content.rs * feat: hook up content.vue with v1 * feat: hide v1 content api behind frontend feature flag * fix: query keys + copy on empty state * chore: i18n pass * feat: reimpl unlink + upload endpoint * feat: use bulk endpoints v1 * fix: lint * fix: flags * fix: responsiveness via container queries * fix: lint * qa: 1 * qa: fixes * qa: fix ssr issues with browse content * qa: header page divider * qa: modals * fix: prepr * fix: issues * fix: lint * fix: toggle v1 ff * qa: 5 * qa: delete modal copy * feat: creation flow modals (#5383) * refactor: delete content v0 usages + impl * feat: qa + fixes * feat: installing banner using state event * feat: fix modpack card bugs + filtering issues * refactor: delete backups v0 api module * feat: v1 servers GET endpoint * fix: backups * feat: swap to kyros upload v1 addon * fix: use tanstack for loader.vue * feat: finish install from discovery modal * qa: bug fixes * feat: set up installation settings * fix: lint * fix: typos * fix: bugs * fix: disable inline content * feat: content tab improvements — upload UX, installation settings, and client-only indicators Upload cancellation and navigation guard: - Add ConfirmLeaveModal that prompts when navigating away during upload - Cancel in-flight XHR uploads when user confirms leaving the page - Add beforeunload handler to warn on browser/tab close during upload - Track uploadedBytes/totalBytes in UploadState for progress display - Replace Collapsible with Transition for upload progress admonition - Show byte progress and percentage in upload banner - Clamp upload progress to prevent exceeding 100% Installation settings (server.properties): - Add KnownPropertiesFields and PropertiesFields types to Archon types - Add buildProperties() to creation flow context to collect gamemode, difficulty, seed, world type, structures, and generator settings - Pass properties through installContent on onboarding, discovery, and ServerSetupModal flows Server setup and discovery flow improvements: - Migrate ServerSetupModal from servers_v0.reinstall to content_v1.installContent - Replace loaderApiNames lookup with toApiLoader() helper - Remove eraseDataOnInstall toggle — always use soft_override: false - Simplify modpack install on discovery page to use first available version and route through creation flow modal for both onboarding and non-onboarding - Differentiate post-install navigation: content page for onboarding, loader options for existing servers Modpack update flow: - Replace updateModpack() call with installContent() using soft_override: true to support version selection in the content updater modal Client-only mod indicators: - Add environment field to AddonVersion (reuses Labrinth.Projects.v3.Environment) - Add environment to ContentItem and isClientOnly to ContentCardTableItem - Show orange TriangleAlertIcon with tooltip on client-only mods in content table - Add "Client-only" filter pill to content filtering (controlled via showClientOnlyFilter on ContentManagerContext) - Apply client-only indicators in both ContentPageLayout and ModpackContentModal Misc: - Add CLAUDE.md note about using prepr commands for lint checks - Export ConfirmLeaveModal from instances barrel * fix: piping * fix: switch content disable for linked server instances * feat: client only filter * fix: prepr * feat: hasUpdate shape update * feat: bulk update endpoint impl for content in panel * feat: websocket state impl again with new phases * fix: ws * fix: use timeout fn for sync admon + fix content card layout scroll for browsers with overflow anchor bug * fix: qa bugs * fix: lint, a11y and i18n * refactor: set up layouts folder properly * fix: linked data cache stuff + lint * feat: move installationsettings to shared layout * fix: lint * fix: issues * feat: temp fuck staging up * fix: lockfile * fix: data sync issues on loader.vue * fix: lint * Hide shader configuration files from content list (#5499) * feat: workaround search problem + split out reset * fix: qa * fix: changelog not showing on first open * fix: qa + optimistic updating improvements * fix: prepr+lint * fix: qa * feat: qa * fix: lint * fix: lint * fix: build * fix: build * fix: type errors * fix: fade and JAVA_HOME passthrough * feat: qa * feat: impl diff shit * fix: qa * fix: app qa * feat: update diff modal * fix: endpoint * fix: qa * fix: qa * fix: use bulk in modpack modal * feat: abort signal impl + fix issues * fix: diff modal trunc * feat: qa * fix: qa * feat: tooltip content tab * fix: prepr * fix: dismiss on settings btn * feat: qa * feat: dont clear handlers on disconnect * fix: lint * fix: wrangler + introduce staging-archon env file --------- Signed-off-by: Calum H. <calum@modrinth.com> Co-authored-by: tdgao <mr.trumgao@gmail.com> Co-authored-by: Artyom Ezri <61311568+Artezon@users.noreply.github.com>
This commit is contained in:
@@ -8,31 +8,11 @@
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
I18nDebugPanel,
|
||||
NotificationPanel,
|
||||
provideModrinthClient,
|
||||
provideNotificationManager,
|
||||
providePageContext,
|
||||
} from '@modrinth/ui'
|
||||
import { I18nDebugPanel, NotificationPanel } from '@modrinth/ui'
|
||||
|
||||
import ModrinthLoadingIndicator from '~/components/ui/modrinth-loading-indicator.ts'
|
||||
import { createModrinthClient } from '~/helpers/api.ts'
|
||||
import { FrontendNotificationManager } from '~/providers/frontend-notifications.ts'
|
||||
import { setupProviders } from '~/providers/setup.ts'
|
||||
|
||||
const auth = await useAuth()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
provideNotificationManager(new FrontendNotificationManager())
|
||||
|
||||
const client = createModrinthClient(auth, {
|
||||
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
|
||||
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
|
||||
rateLimitKey: config.rateLimitKey,
|
||||
})
|
||||
provideModrinthClient(client)
|
||||
providePageContext({
|
||||
hierarchicalSidebarAvailable: ref(false),
|
||||
showAds: ref(false),
|
||||
})
|
||||
setupProviders(auth)
|
||||
</script>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
:stages="ctx.stageConfigs"
|
||||
:context="ctx"
|
||||
:breadcrumbs="!editingVersion"
|
||||
:close-on-click-outside="false"
|
||||
@hide="() => (modalOpen = false)"
|
||||
/>
|
||||
<DropArea
|
||||
|
||||
@@ -42,15 +42,15 @@
|
||||
<ButtonStyled v-if="content" type="outlined">
|
||||
<button
|
||||
class="!border-[1px]"
|
||||
@click="handleSwitchCompatibility"
|
||||
:disabled="!hasPermission"
|
||||
@click="handleSwitchCompatibility"
|
||||
>
|
||||
<ArrowLeftRightIcon />
|
||||
Switch type
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button @click="handleSetCompatibility" :disabled="!hasPermission">
|
||||
<button :disabled="!hasPermission" @click="handleSetCompatibility">
|
||||
<ComponentIcon />
|
||||
Set compatibility
|
||||
</button>
|
||||
@@ -189,8 +189,8 @@
|
||||
<ButtonStyled v-if="content">
|
||||
<button
|
||||
class="!w-full !max-w-[160px]"
|
||||
@click="handleUpdateContent"
|
||||
:disabled="!hasPermission"
|
||||
@click="handleUpdateContent"
|
||||
>
|
||||
<RefreshCwIcon />
|
||||
Update
|
||||
|
||||
@@ -1,558 +0,0 @@
|
||||
<template>
|
||||
<NewModal ref="modModal" :header="`Editing ${type.toLocaleLowerCase()} version`">
|
||||
<template #title>
|
||||
<div class="flex min-w-full items-center gap-2 md:w-[calc(420px-5.5rem)]">
|
||||
<Avatar :src="modDetails?.icon_url" size="48px" :alt="`${modDetails?.name} Icon`" />
|
||||
<span class="truncate text-xl font-extrabold text-contrast">{{ modDetails?.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-2 md:w-[420px]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<template v-if="versionsLoading">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-fit animate-pulse select-none rounded-md bg-button-bg font-semibold">
|
||||
<span class="opacity-0" aria-hidden="true">{{ type }} version</span>
|
||||
</div>
|
||||
<div class="min-h-[22px] min-w-[140px] animate-pulse rounded-full bg-button-bg" />
|
||||
</div>
|
||||
<div class="min-h-9 w-full animate-pulse rounded-xl bg-button-bg" />
|
||||
<div class="w-fit animate-pulse select-none rounded-md bg-button-bg">
|
||||
<span class="ml-6 opacity-0" aria-hidden="true">
|
||||
Show any beta and alpha releases
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-semibold text-contrast">{{ type }} version</div>
|
||||
<NuxtLink
|
||||
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
|
||||
@click="
|
||||
versionFilter &&
|
||||
(unlockFilterAccordion.isOpen
|
||||
? unlockFilterAccordion.close()
|
||||
: unlockFilterAccordion.open())
|
||||
"
|
||||
>
|
||||
<TagItem
|
||||
v-if="formattedVersions.game_versions.length > 0"
|
||||
v-tooltip="formattedVersions.game_versions.join(', ')"
|
||||
:style="`--_color: var(--color-green)`"
|
||||
>
|
||||
{{ formattedVersions.game_versions[0] }}
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-if="formattedVersions.loaders.length > 0"
|
||||
v-tooltip="formattedVersions.loaders.join(', ')"
|
||||
:style="`--_color: var(--color-platform-${formattedVersions.loaders[0].toLowerCase()})`"
|
||||
>
|
||||
{{ formattedVersions.loaders[0] }}
|
||||
</TagItem>
|
||||
<DropdownIcon
|
||||
:class="[
|
||||
'transition-all duration-200 ease-in-out',
|
||||
{ 'rotate-180': unlockFilterAccordion.isOpen },
|
||||
{ 'opacity-0': !versionFilter },
|
||||
]"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<Combobox
|
||||
v-model="selectedVersion"
|
||||
name="Project"
|
||||
:options="
|
||||
filteredVersions.map((v) => ({
|
||||
value: v,
|
||||
label: typeof v === 'object' ? v.version_number : String(v),
|
||||
}))
|
||||
"
|
||||
:display-value="
|
||||
selectedVersion
|
||||
? typeof selectedVersion === 'object'
|
||||
? selectedVersion.version_number
|
||||
: String(selectedVersion)
|
||||
: 'No valid versions found'
|
||||
"
|
||||
class="!min-w-full"
|
||||
:disabled="filteredVersions.length === 0"
|
||||
/>
|
||||
<Checkbox v-model="showBetaAlphaReleases"> Show any beta and alpha releases </Checkbox>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Accordion
|
||||
ref="unlockFilterAccordion"
|
||||
:open-by-default="!versionFilter"
|
||||
:class="[
|
||||
versionFilter ? '' : '!border-solid border-orange bg-bg-orange !text-contrast',
|
||||
'flex flex-col gap-2 rounded-2xl border-2 border-dashed border-surface-5 p-3 transition-all',
|
||||
]"
|
||||
>
|
||||
<p class="m-0 items-center font-bold">
|
||||
<span>
|
||||
{{
|
||||
noCompatibleVersions
|
||||
? `No compatible versions of this ${type.toLowerCase()} were found`
|
||||
: versionFilter
|
||||
? 'Game version and platform is provided by the server'
|
||||
: 'Incompatible game version and platform versions are unlocked'
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
<p class="m-0 text-sm">
|
||||
{{
|
||||
noCompatibleVersions
|
||||
? `No versions compatible with your server were found. You can still select any available version.`
|
||||
: versionFilter
|
||||
? `Unlocking this filter may allow you to change this ${type.toLowerCase()}
|
||||
to an incompatible version.`
|
||||
: "You might see versions listed that aren't compatible with your server configuration."
|
||||
}}
|
||||
</p>
|
||||
<ContentVersionFilter
|
||||
v-if="currentVersions"
|
||||
ref="filtersRef"
|
||||
:versions="currentVersions"
|
||||
:game-versions="tags.gameVersions"
|
||||
:select-classes="'w-full'"
|
||||
:type="type"
|
||||
:disabled="versionFilter"
|
||||
:platform-tags="tags.loaders"
|
||||
:listed-game-versions="gameVersions"
|
||||
:listed-platforms="platforms"
|
||||
@update:query="updateFiltersFromUi($event)"
|
||||
@vue:mounted="updateFiltersToUi"
|
||||
>
|
||||
<template #platform>
|
||||
<LoaderIcon
|
||||
v-if="filtersRef?.selectedPlatforms.length === 0"
|
||||
:loader="'Vanilla'"
|
||||
class="size-5 flex-none"
|
||||
/>
|
||||
<component
|
||||
:is="getLoaderIcon(filtersRef.selectedPlatforms[0])"
|
||||
v-else-if="
|
||||
filtersRef?.selectedPlatforms[0] && getLoaderIcon(filtersRef.selectedPlatforms[0])
|
||||
"
|
||||
class="size-5 flex-none"
|
||||
/>
|
||||
|
||||
<div class="w-full truncate text-left">
|
||||
{{
|
||||
filtersRef?.selectedPlatforms.length === 0
|
||||
? 'All platforms'
|
||||
: filtersRef?.selectedPlatforms
|
||||
.map((x) => {
|
||||
return formatLoader(formatMessage, x)
|
||||
})
|
||||
.join(', ')
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
<template #game-versions>
|
||||
<GameIcon class="size-5 flex-none" />
|
||||
<div class="w-full truncate text-left">
|
||||
{{
|
||||
filtersRef?.selectedGameVersions.length === 0
|
||||
? 'All game versions'
|
||||
: filtersRef?.selectedGameVersions.join(', ')
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</ContentVersionFilter>
|
||||
|
||||
<ButtonStyled v-if="!noCompatibleVersions" color-fill="text">
|
||||
<button
|
||||
class="w-full"
|
||||
:disabled="gameVersions.length < 2 && platforms.length < 2"
|
||||
@click="
|
||||
() => {
|
||||
versionFilter = !versionFilter
|
||||
setInitialFilters()
|
||||
updateFiltersToUi()
|
||||
}
|
||||
"
|
||||
>
|
||||
<LockOpenIcon />
|
||||
{{
|
||||
gameVersions.length < 2 && platforms.length < 2
|
||||
? 'No other platforms or versions available'
|
||||
: versionFilter
|
||||
? 'Unlock'
|
||||
: 'Return to compatibility'
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</Accordion>
|
||||
|
||||
<Admonition
|
||||
v-if="versionsError"
|
||||
type="critical"
|
||||
header="Failed to load versions"
|
||||
class="mb-2"
|
||||
>
|
||||
<div>
|
||||
<span>
|
||||
Something went wrong trying to load versions for this
|
||||
{{ type.toLocaleLowerCase() }}. Please try again later or contact support if the issue
|
||||
persists.
|
||||
</span>
|
||||
<CopyCode class="!mt-2 !break-all" :text="versionsError" />
|
||||
</div>
|
||||
</Admonition>
|
||||
|
||||
<Admonition
|
||||
v-else-if="props.modPack"
|
||||
type="warning"
|
||||
header="Changing version may cause issues"
|
||||
class="mb-2"
|
||||
>
|
||||
Your server was created using a modpack. It's recommended to use the modpack's version of
|
||||
the mod.
|
||||
<NuxtLink
|
||||
class="mt-2 flex items-center gap-1"
|
||||
:to="`/hosting/manage/${props.serverId}/options/loader`"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalIcon class="size-5 flex-none"></ExternalIcon> Modify modpack version
|
||||
</NuxtLink>
|
||||
</Admonition>
|
||||
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:disabled="versionsLoading || selectedVersion.id === modDetails?.version_id"
|
||||
@click="emitChangeModVersion"
|
||||
>
|
||||
<CheckIcon />
|
||||
Install
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="modModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckIcon,
|
||||
DropdownIcon,
|
||||
ExternalIcon,
|
||||
GameIcon,
|
||||
getLoaderIcon,
|
||||
LockOpenIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
CopyCode,
|
||||
formatLoader,
|
||||
NewModal,
|
||||
TagItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { formatVersionsForDisplay, type Mod, type Version } from '@modrinth/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Accordion from '~/components/ui/Accordion.vue'
|
||||
import ContentVersionFilter, {
|
||||
type ListedGameVersion,
|
||||
type ListedPlatform,
|
||||
} from '~/components/ui/servers/ContentVersionFilter.vue'
|
||||
import LoaderIcon from '~/components/ui/servers/icons/LoaderIcon.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'Mod' | 'Plugin'
|
||||
loader: string
|
||||
gameVersion: string
|
||||
modPack: boolean
|
||||
serverId: string
|
||||
}>()
|
||||
|
||||
interface ContentItem extends Mod {
|
||||
changing?: boolean
|
||||
}
|
||||
|
||||
interface EditVersion extends Version {
|
||||
installed: boolean
|
||||
upgrade?: boolean
|
||||
}
|
||||
|
||||
const modModal = ref()
|
||||
const modDetails = ref<ContentItem>()
|
||||
const currentVersions = ref<EditVersion[] | null>(null)
|
||||
const versionsLoading = ref(false)
|
||||
const versionsError = ref('')
|
||||
const showBetaAlphaReleases = ref(false)
|
||||
const unlockFilterAccordion = ref()
|
||||
const versionFilter = ref(true)
|
||||
const tags = useGeneratedState()
|
||||
const noCompatibleVersions = ref(false)
|
||||
|
||||
const { pluginLoaders, modLoaders } = tags.value.loaders.reduce(
|
||||
(acc, tag) => {
|
||||
if (tag.supported_project_types.includes('plugin')) {
|
||||
acc.pluginLoaders.push(tag.name)
|
||||
}
|
||||
if (tag.supported_project_types.includes('mod')) {
|
||||
acc.modLoaders.push(tag.name)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ pluginLoaders: [] as string[], modLoaders: [] as string[] },
|
||||
)
|
||||
|
||||
const selectedVersion = ref()
|
||||
const filtersRef: Ref<InstanceType<typeof ContentVersionFilter> | null> = ref(null)
|
||||
interface SelectedContentFilters {
|
||||
selectedGameVersions: string[]
|
||||
selectedPlatforms: string[]
|
||||
}
|
||||
const selectedFilters = ref<SelectedContentFilters>({
|
||||
selectedGameVersions: [],
|
||||
selectedPlatforms: [],
|
||||
})
|
||||
|
||||
const backwardCompatPlatformMap = {
|
||||
purpur: ['purpur', 'paper', 'spigot', 'bukkit'],
|
||||
paper: ['paper', 'spigot', 'bukkit'],
|
||||
spigot: ['spigot', 'bukkit'],
|
||||
}
|
||||
|
||||
const platforms = ref<ListedPlatform[]>([])
|
||||
const gameVersions = ref<ListedGameVersion[]>([])
|
||||
const initPlatform = ref<string>('')
|
||||
|
||||
const setInitialFilters = () => {
|
||||
selectedFilters.value = {
|
||||
selectedGameVersions: [
|
||||
gameVersions.value.find((version) => version.name === props.gameVersion)?.name ??
|
||||
gameVersions.value.find((version) => version.release)?.name ??
|
||||
gameVersions.value[0]?.name,
|
||||
],
|
||||
selectedPlatforms: [initPlatform.value],
|
||||
}
|
||||
}
|
||||
|
||||
const updateFiltersToUi = () => {
|
||||
if (!filtersRef.value) return
|
||||
filtersRef.value.selectedGameVersions = selectedFilters.value.selectedGameVersions
|
||||
filtersRef.value.selectedPlatforms = selectedFilters.value.selectedPlatforms
|
||||
|
||||
selectedVersion.value = filteredVersions.value[0]
|
||||
}
|
||||
|
||||
const updateFiltersFromUi = (event: { g: string[]; l: string[] }) => {
|
||||
selectedFilters.value = {
|
||||
selectedGameVersions: event.g,
|
||||
selectedPlatforms: event.l,
|
||||
}
|
||||
}
|
||||
|
||||
const filteredVersions = computed(() => {
|
||||
if (!currentVersions.value) return []
|
||||
|
||||
const versionsWithoutReleaseFilter = currentVersions.value.filter((version: EditVersion) => {
|
||||
if (version.installed) return true
|
||||
return (
|
||||
filtersRef.value?.selectedPlatforms.every((platform) =>
|
||||
(
|
||||
backwardCompatPlatformMap[platform as keyof typeof backwardCompatPlatformMap] || [
|
||||
platform,
|
||||
]
|
||||
).some((loader) => version.loaders.includes(loader)),
|
||||
) &&
|
||||
filtersRef.value?.selectedGameVersions.every((gameVersion) =>
|
||||
version.game_versions.includes(gameVersion),
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const versionTypes = new Set(versionsWithoutReleaseFilter.map((v: EditVersion) => v.version_type))
|
||||
const releaseVersions = versionTypes.has('release')
|
||||
const betaVersions = versionTypes.has('beta')
|
||||
const alphaVersions = versionTypes.has('alpha')
|
||||
|
||||
const versions = versionsWithoutReleaseFilter.filter((version: EditVersion) => {
|
||||
if (showBetaAlphaReleases.value || version.installed) return true
|
||||
return releaseVersions
|
||||
? version.version_type === 'release'
|
||||
: betaVersions
|
||||
? version.version_type === 'beta'
|
||||
: alphaVersions
|
||||
? version.version_type === 'alpha'
|
||||
: false
|
||||
})
|
||||
|
||||
return versions.map((version: EditVersion) => {
|
||||
let suffix = ''
|
||||
|
||||
if (version.version_type === 'alpha' && releaseVersions && betaVersions) {
|
||||
suffix += ' (alpha)'
|
||||
} else if (version.version_type === 'beta' && releaseVersions) {
|
||||
suffix += ' (beta)'
|
||||
}
|
||||
|
||||
return {
|
||||
...version,
|
||||
version_number: version.version_number + suffix,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const formattedVersions = computed(() => {
|
||||
return {
|
||||
game_versions: formatVersionsForDisplay(
|
||||
selectedVersion.value?.game_versions || [],
|
||||
tags.value.gameVersions,
|
||||
),
|
||||
loaders: (selectedVersion.value?.loaders || [])
|
||||
.sort((firstLoader: string, secondLoader: string) => {
|
||||
const loaderList = backwardCompatPlatformMap[
|
||||
props.loader as keyof typeof backwardCompatPlatformMap
|
||||
] || [props.loader]
|
||||
|
||||
const firstLoaderPosition = loaderList.indexOf(firstLoader.toLowerCase())
|
||||
const secondLoaderPosition = loaderList.indexOf(secondLoader.toLowerCase())
|
||||
|
||||
if (firstLoaderPosition === -1 && secondLoaderPosition === -1) return 0
|
||||
if (firstLoaderPosition === -1) return 1
|
||||
if (secondLoaderPosition === -1) return -1
|
||||
return firstLoaderPosition - secondLoaderPosition
|
||||
})
|
||||
.map((loader: string) => formatLoader(formatMessage, loader)),
|
||||
}
|
||||
})
|
||||
|
||||
async function show(mod: ContentItem) {
|
||||
versionFilter.value = true
|
||||
modModal.value.show()
|
||||
versionsLoading.value = true
|
||||
modDetails.value = mod
|
||||
versionsError.value = ''
|
||||
currentVersions.value = null
|
||||
|
||||
try {
|
||||
const result = await useBaseFetch(`project/${mod.project_id}/version`, {}, false)
|
||||
if (
|
||||
Array.isArray(result) &&
|
||||
result.every(
|
||||
(item) =>
|
||||
'id' in item &&
|
||||
'version_number' in item &&
|
||||
'version_type' in item &&
|
||||
'loaders' in item &&
|
||||
'game_versions' in item,
|
||||
)
|
||||
) {
|
||||
currentVersions.value = result as EditVersion[]
|
||||
} else {
|
||||
throw new Error('Invalid version data received.')
|
||||
}
|
||||
|
||||
// find the installed version and move it to the top of the list
|
||||
const currentModIndex = currentVersions.value.findIndex(
|
||||
(item: { id: string }) => item.id === mod.version_id,
|
||||
)
|
||||
if (currentModIndex === -1) {
|
||||
currentVersions.value[currentModIndex] = {
|
||||
...currentVersions.value[currentModIndex],
|
||||
installed: true,
|
||||
version_number: `${mod.version_number} (current) (external)`,
|
||||
}
|
||||
} else {
|
||||
currentVersions.value[currentModIndex].version_number = `${mod.version_number} (current)`
|
||||
currentVersions.value[currentModIndex].installed = true
|
||||
}
|
||||
|
||||
// initially filter the platform and game versions for the server config
|
||||
const platformSet = new Set<string>()
|
||||
const gameVersionSet = new Set<string>()
|
||||
for (const version of currentVersions.value) {
|
||||
for (const loader of version.loaders) {
|
||||
platformSet.add(loader)
|
||||
}
|
||||
for (const gameVersion of version.game_versions) {
|
||||
gameVersionSet.add(gameVersion)
|
||||
}
|
||||
}
|
||||
if (gameVersionSet.size > 0) {
|
||||
const filteredGameVersions = tags.value.gameVersions.filter((x) =>
|
||||
gameVersionSet.has(x.version),
|
||||
)
|
||||
|
||||
gameVersions.value = filteredGameVersions.map((x) => ({
|
||||
name: x.version,
|
||||
release: x.version_type === 'release',
|
||||
}))
|
||||
}
|
||||
if (platformSet.size > 0) {
|
||||
const tempPlatforms = Array.from(platformSet).map((platform) => ({
|
||||
name: platform,
|
||||
isType:
|
||||
props.type === 'Plugin'
|
||||
? pluginLoaders.includes(platform)
|
||||
: props.type === 'Mod'
|
||||
? modLoaders.includes(platform)
|
||||
: false,
|
||||
}))
|
||||
platforms.value = tempPlatforms
|
||||
}
|
||||
|
||||
// set default platform
|
||||
const defaultPlatform = Array.from(platformSet)[0]
|
||||
initPlatform.value = platformSet.has(props.loader)
|
||||
? props.loader
|
||||
: props.loader in backwardCompatPlatformMap
|
||||
? backwardCompatPlatformMap[props.loader as keyof typeof backwardCompatPlatformMap].find(
|
||||
(p) => platformSet.has(p),
|
||||
) || defaultPlatform
|
||||
: defaultPlatform
|
||||
|
||||
// check if there's nothing compatible with the server config
|
||||
noCompatibleVersions.value =
|
||||
!platforms.value.some((p) => p.isType) ||
|
||||
!gameVersions.value.some((v) => v.name === props.gameVersion)
|
||||
|
||||
if (noCompatibleVersions.value) {
|
||||
unlockFilterAccordion.value.open()
|
||||
versionFilter.value = false
|
||||
}
|
||||
|
||||
setInitialFilters()
|
||||
versionsLoading.value = false
|
||||
} catch (error) {
|
||||
console.error('Error loading versions:', error)
|
||||
versionsError.value = error instanceof Error ? error.message : 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
changeVersion: [string]
|
||||
}>()
|
||||
|
||||
function emitChangeModVersion() {
|
||||
if (!selectedVersion.value) return
|
||||
emit('changeVersion', selectedVersion.value.id.toString())
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide: () => modModal.value.hide(),
|
||||
})
|
||||
</script>
|
||||
@@ -1,175 +0,0 @@
|
||||
<template>
|
||||
<div class="experimental-styles-within flex w-full flex-col items-center gap-2">
|
||||
<ManySelect
|
||||
v-model="selectedPlatforms"
|
||||
:tooltip="
|
||||
filterOptions.platform.length < 2 && !disabled ? 'No other platforms available' : undefined
|
||||
"
|
||||
:options="filterOptions.platform"
|
||||
:dropdown-id="`${baseId}-platform`"
|
||||
search
|
||||
show-always
|
||||
class="w-full"
|
||||
:disabled="disabled || filterOptions.platform.length < 2"
|
||||
:dropdown-class="'w-full'"
|
||||
@change="updateFilters"
|
||||
>
|
||||
<slot name="platform">
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
Platform
|
||||
</slot>
|
||||
<template #option="{ option }">
|
||||
{{ formatLoader(formatMessage, String(option)) }}
|
||||
</template>
|
||||
<template v-if="hasAnyUnsupportedPlatforms" #footer>
|
||||
<Checkbox
|
||||
v-model="showSupportedPlatformsOnly"
|
||||
class="mx-1"
|
||||
:label="`Show ${type?.toLowerCase()} platforms only`"
|
||||
/>
|
||||
</template>
|
||||
</ManySelect>
|
||||
<ManySelect
|
||||
v-model="selectedGameVersions"
|
||||
:tooltip="
|
||||
filterOptions.gameVersion.length < 2 && !disabled
|
||||
? 'No other game versions available'
|
||||
: undefined
|
||||
"
|
||||
:options="filterOptions.gameVersion"
|
||||
:dropdown-id="`${baseId}-game-version`"
|
||||
search
|
||||
show-always
|
||||
class="w-full"
|
||||
:disabled="disabled || filterOptions.gameVersion.length < 2"
|
||||
:dropdown-class="'w-full'"
|
||||
@change="updateFilters"
|
||||
>
|
||||
<slot name="game-versions">
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
Game versions
|
||||
</slot>
|
||||
<template v-if="hasAnySnapshots" #footer>
|
||||
<Checkbox v-model="showSnapshots" class="mx-1" :label="`Show all versions`" />
|
||||
</template>
|
||||
</ManySelect>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FilterIcon } from '@modrinth/assets'
|
||||
import { formatLoader, useVIntl } from '@modrinth/ui'
|
||||
import Checkbox from '@modrinth/ui/src/components/base/Checkbox.vue'
|
||||
import ManySelect from '@modrinth/ui/src/components/base/ManySelect.vue'
|
||||
import type { GameVersionTag, Version } from '@modrinth/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
export type ListedGameVersion = {
|
||||
name: string
|
||||
release: boolean
|
||||
}
|
||||
|
||||
export type ListedPlatform = {
|
||||
name: string
|
||||
isType: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
versions: Version[]
|
||||
gameVersions: GameVersionTag[]
|
||||
listedGameVersions: ListedGameVersion[]
|
||||
listedPlatforms: ListedPlatform[]
|
||||
baseId?: string
|
||||
type: 'Mod' | 'Plugin'
|
||||
platformTags: {
|
||||
name: string
|
||||
supported_project_types: string[]
|
||||
}[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:query'])
|
||||
const route = useRoute()
|
||||
|
||||
const showSnapshots = ref(false)
|
||||
const hasAnySnapshots = computed(() => {
|
||||
return props.versions.some((x) =>
|
||||
props.gameVersions.some(
|
||||
(y) => y.version_type !== 'release' && x.game_versions.includes(y.version),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const hasOnlySnapshots = computed(() => {
|
||||
return props.versions.every((version) => {
|
||||
return version.game_versions.every((gv) => {
|
||||
const matched = props.gameVersions.find((tag) => tag.version === gv)
|
||||
return matched && matched.version_type !== 'release'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const hasAnyUnsupportedPlatforms = computed(() => {
|
||||
return props.listedPlatforms.some((x) => !x.isType)
|
||||
})
|
||||
|
||||
const hasOnlyUnsupportedPlatforms = computed(() => {
|
||||
return props.listedPlatforms.every((x) => !x.isType)
|
||||
})
|
||||
|
||||
const showSupportedPlatformsOnly = ref(true)
|
||||
|
||||
const filterOptions = computed<Record<'gameVersion' | 'platform', string[]>>(() => {
|
||||
const filters: Record<'gameVersion' | 'platform', string[]> = {
|
||||
gameVersion: [],
|
||||
platform: [],
|
||||
}
|
||||
|
||||
filters.gameVersion = props.listedGameVersions
|
||||
.filter((x) => {
|
||||
return showSnapshots.value || hasOnlySnapshots.value ? true : x.release
|
||||
})
|
||||
.map((x) => x.name)
|
||||
|
||||
filters.platform = props.listedPlatforms
|
||||
.filter((x) => {
|
||||
return !showSupportedPlatformsOnly.value || hasOnlyUnsupportedPlatforms.value
|
||||
? true
|
||||
: x.isType
|
||||
})
|
||||
.map((x) => x.name)
|
||||
|
||||
return filters
|
||||
})
|
||||
|
||||
const selectedGameVersions = ref<string[]>([])
|
||||
const selectedPlatforms = ref<string[]>([])
|
||||
|
||||
selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : []
|
||||
selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : []
|
||||
|
||||
function updateFilters() {
|
||||
emit('update:query', {
|
||||
g: selectedGameVersions.value,
|
||||
l: selectedPlatforms.value,
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
selectedGameVersions,
|
||||
selectedPlatforms,
|
||||
})
|
||||
|
||||
function getArrayOrString(x: string | (string | null)[]): string[] {
|
||||
if (typeof x === 'string') {
|
||||
return [x]
|
||||
} else {
|
||||
return x.filter((item): item is string => item !== null)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -1,345 +0,0 @@
|
||||
<template>
|
||||
<li
|
||||
role="button"
|
||||
:class="[
|
||||
containerClasses,
|
||||
isDragOver && type === 'directory' ? 'bg-brand-highlight' : '',
|
||||
isDragging ? 'opacity-50' : '',
|
||||
]"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
@click="selectItem"
|
||||
@contextmenu="openContextMenu"
|
||||
@keydown="(e) => e.key === 'Enter' && selectItem()"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<div class="pointer-events-none flex flex-1 items-center gap-3 truncate">
|
||||
<Checkbox
|
||||
class="pointer-events-auto"
|
||||
:model-value="selected"
|
||||
@click.stop
|
||||
@update:model-value="emit('toggle-select')"
|
||||
/>
|
||||
<div class="pointer-events-none flex size-5 items-center justify-center">
|
||||
<component :is="iconComponent" class="size-5" />
|
||||
</div>
|
||||
<div class="pointer-events-none flex flex-col truncate">
|
||||
<span
|
||||
class="pointer-events-none truncate group-hover:text-contrast group-focus:text-contrast"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pointer-events-auto flex w-fit flex-shrink-0 items-center gap-4 md:gap-12">
|
||||
<span class="hidden w-[100px] text-nowrap text-sm text-secondary md:block">
|
||||
{{ formattedSize }}
|
||||
</span>
|
||||
<span class="hidden w-[160px] text-nowrap text-sm text-secondary md:block">
|
||||
{{ formattedCreationDate }}
|
||||
</span>
|
||||
<span class="hidden w-[160px] text-nowrap text-sm text-secondary md:block">
|
||||
{{ formattedModifiedDate }}
|
||||
</span>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<TeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
|
||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||
<template #extract><PackageOpenIcon /> Extract</template>
|
||||
<template #rename><EditIcon /> Rename</template>
|
||||
<template #move><RightArrowIcon /> Move</template>
|
||||
<template #download><DownloadIcon /> Download</template>
|
||||
<template #delete><TrashIcon /> Delete</template>
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
FolderOpenIcon,
|
||||
MoreHorizontalIcon,
|
||||
PackageOpenIcon,
|
||||
PaletteIcon,
|
||||
RightArrowIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
getFileExtension,
|
||||
getFileExtensionIcon,
|
||||
isEditableFile as isEditableFileExt,
|
||||
isImageFile,
|
||||
useFormatDateTime,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, h, ref, shallowRef } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { UiServersIconsCogFolderIcon, UiServersIconsEarthIcon } from '#components'
|
||||
|
||||
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
||||
|
||||
interface FileItemProps {
|
||||
name: string
|
||||
type: 'directory' | 'file'
|
||||
size?: number
|
||||
count?: number
|
||||
modified: number
|
||||
created: number
|
||||
path: string
|
||||
index: number
|
||||
isLast: boolean
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<FileItemProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'rename' | 'move' | 'download' | 'delete' | 'edit' | 'extract' | 'hover',
|
||||
item: { name: string; type: string; path: string },
|
||||
): void
|
||||
(e: 'moveDirectTo', item: { name: string; type: string; path: string; destination: string }): void
|
||||
(e: 'contextmenu', x: number, y: number): void
|
||||
(e: 'toggle-select'): void
|
||||
}>()
|
||||
|
||||
const isDragOver = ref(false)
|
||||
const isDragging = ref(false)
|
||||
|
||||
const units = Object.freeze(['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'])
|
||||
|
||||
const route = shallowRef(useRoute())
|
||||
const router = useRouter()
|
||||
|
||||
const formatDateTime = useFormatDateTime({
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})
|
||||
|
||||
const containerClasses = computed(() => [
|
||||
'group m-0 flex w-full select-none items-center justify-between overflow-hidden border-0 border-t border-solid border-surface-3 px-4 py-3 focus:!outline-none',
|
||||
props.index % 2 === 0 ? 'bg-surface-2' : 'bg-surface-3',
|
||||
props.isLast ? 'rounded-b-[20px] border-b' : '',
|
||||
isEditableFile.value ? 'cursor-pointer' : props.type === 'directory' ? 'cursor-pointer' : '',
|
||||
isDragOver.value ? '!bg-brand-highlight' : '',
|
||||
'hover:brightness-110 focus:brightness-110',
|
||||
])
|
||||
|
||||
const fileExtension = computed(() => getFileExtension(props.name))
|
||||
|
||||
const isZip = computed(() => fileExtension.value === 'zip')
|
||||
|
||||
const menuOptions = computed(() => [
|
||||
{
|
||||
id: 'extract',
|
||||
shown: isZip.value,
|
||||
action: () => emit('extract', { name: props.name, type: props.type, path: props.path }),
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown: isZip.value,
|
||||
},
|
||||
{
|
||||
id: 'rename',
|
||||
action: () => emit('rename', { name: props.name, type: props.type, path: props.path }),
|
||||
},
|
||||
{
|
||||
id: 'move',
|
||||
action: () => emit('move', { name: props.name, type: props.type, path: props.path }),
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
action: () => emit('download', { name: props.name, type: props.type, path: props.path }),
|
||||
shown: props.type !== 'directory',
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
action: () => emit('delete', { name: props.name, type: props.type, path: props.path }),
|
||||
color: 'red' as const,
|
||||
},
|
||||
])
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
if (props.type === 'directory') {
|
||||
if (props.name === 'config') return UiServersIconsCogFolderIcon
|
||||
if (props.name === 'world') return UiServersIconsEarthIcon
|
||||
if (props.name === 'resourcepacks') return PaletteIcon
|
||||
return FolderOpenIcon
|
||||
}
|
||||
|
||||
return getFileExtensionIcon(fileExtension.value)
|
||||
})
|
||||
|
||||
const formattedModifiedDate = computed(() => {
|
||||
const date = new Date(props.modified * 1000)
|
||||
return formatDateTime(date)
|
||||
})
|
||||
|
||||
const formattedCreationDate = computed(() => {
|
||||
const date = new Date(props.created * 1000)
|
||||
return formatDateTime(date)
|
||||
})
|
||||
|
||||
const isEditableFile = computed(() => {
|
||||
if (props.type === 'file') {
|
||||
const ext = fileExtension.value
|
||||
return !props.name.includes('.') || isEditableFileExt(ext) || isImageFile(ext)
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const formattedSize = computed(() => {
|
||||
if (props.type === 'directory') {
|
||||
return `${props.count} ${props.count === 1 ? 'item' : 'items'}`
|
||||
}
|
||||
|
||||
if (props.size === undefined) return ''
|
||||
const bytes = props.size
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
const size = (bytes / Math.pow(1024, exponent)).toFixed(2)
|
||||
return `${size} ${units[exponent]}`
|
||||
})
|
||||
|
||||
function openContextMenu(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
emit('contextmenu', event.clientX, event.clientY)
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
emit('hover', { name: props.name, type: props.type, path: props.path })
|
||||
}
|
||||
|
||||
function navigateToFolder() {
|
||||
const currentPath = route.value.query.path?.toString() || ''
|
||||
const newPath = currentPath.endsWith('/')
|
||||
? `${currentPath}${props.name}`
|
||||
: `${currentPath}/${props.name}`
|
||||
router.push({ query: { path: newPath } })
|
||||
}
|
||||
|
||||
const isNavigating = ref(false)
|
||||
|
||||
function selectItem() {
|
||||
if (isNavigating.value) return
|
||||
isNavigating.value = true
|
||||
|
||||
if (props.type === 'directory') {
|
||||
navigateToFolder()
|
||||
} else if (props.type === 'file' && isEditableFile.value) {
|
||||
emit('edit', { name: props.name, type: props.type, path: props.path })
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isNavigating.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
async function getDragIcon() {
|
||||
// Reuse iconComponent computed for consistency
|
||||
return await renderToString(h(iconComponent.value))
|
||||
}
|
||||
|
||||
async function handleDragStart(event: DragEvent) {
|
||||
if (!event.dataTransfer) return
|
||||
isDragging.value = true
|
||||
|
||||
const dragGhost = document.createElement('div')
|
||||
dragGhost.className =
|
||||
'fixed left-0 top-0 flex items-center max-w-[500px] flex-row gap-3 rounded-lg bg-bg-raised p-3 shadow-lg pointer-events-none'
|
||||
|
||||
const iconContainer = document.createElement('div')
|
||||
iconContainer.className = 'flex size-6 items-center justify-center'
|
||||
|
||||
const icon = document.createElement('div')
|
||||
icon.className = 'size-4'
|
||||
icon.innerHTML = await getDragIcon()
|
||||
iconContainer.appendChild(icon)
|
||||
|
||||
const nameSpan = document.createElement('span')
|
||||
nameSpan.className = 'font-bold truncate text-contrast'
|
||||
nameSpan.textContent = props.name
|
||||
|
||||
dragGhost.appendChild(iconContainer)
|
||||
dragGhost.appendChild(nameSpan)
|
||||
document.body.appendChild(dragGhost)
|
||||
|
||||
event.dataTransfer.setDragImage(dragGhost, 0, 0)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
document.body.removeChild(dragGhost)
|
||||
})
|
||||
|
||||
event.dataTransfer.setData(
|
||||
'application/modrinth-file-move',
|
||||
JSON.stringify({
|
||||
name: props.name,
|
||||
type: props.type,
|
||||
path: props.path,
|
||||
}),
|
||||
)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
function isChildPath(parentPath: string, childPath: string) {
|
||||
return childPath.startsWith(parentPath + '/')
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
function handleDragEnter() {
|
||||
if (props.type !== 'directory') return
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
if (props.type !== 'directory' || !event.dataTransfer) return
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
isDragOver.value = false
|
||||
if (props.type !== 'directory' || !event.dataTransfer) return
|
||||
|
||||
try {
|
||||
const dragData = JSON.parse(event.dataTransfer.getData('application/modrinth-file-move'))
|
||||
|
||||
if (dragData.path === props.path) return
|
||||
|
||||
if (dragData.type === 'directory' && isChildPath(dragData.path, props.path)) {
|
||||
console.error('Cannot move a folder into its own subfolder')
|
||||
return
|
||||
}
|
||||
|
||||
emit('moveDirectTo', {
|
||||
name: dragData.name,
|
||||
type: dragData.type,
|
||||
path: dragData.path,
|
||||
destination: props.path,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error handling file drop:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,41 +0,0 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full items-center justify-center gap-6 p-20">
|
||||
<FileIcon class="size-28" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="m-0 text-2xl font-bold text-red">{{ title }}</h3>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ message }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled>
|
||||
<button size="sm" @click="$emit('refetch')">
|
||||
<LoadingIcon class="h-5 w-5" />
|
||||
Try again
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button size="sm" @click="$emit('home')">
|
||||
<HomeIcon class="h-5 w-5" />
|
||||
Go to home folder
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FileIcon, HomeIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
|
||||
import LoadingIcon from './icons/LoadingIcon.vue'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
message: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'refetch' | 'home'): void
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,128 +0,0 @@
|
||||
<template>
|
||||
<div ref="listContainer" class="relative w-full">
|
||||
<div
|
||||
:style="{
|
||||
position: 'relative',
|
||||
minHeight: `${totalHeight}px`,
|
||||
}"
|
||||
>
|
||||
<ul
|
||||
class="list-none"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: `${visibleTop}px`,
|
||||
width: '100%',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}"
|
||||
>
|
||||
<FileItem
|
||||
v-for="(item, idx) in visibleItems"
|
||||
:key="item.path"
|
||||
:count="item.count"
|
||||
:created="item.created"
|
||||
:modified="item.modified"
|
||||
:name="item.name"
|
||||
:path="item.path"
|
||||
:type="item.type"
|
||||
:size="item.size"
|
||||
:index="visibleRange.start + idx"
|
||||
:is-last="visibleRange.start + idx === props.items.length - 1"
|
||||
:selected="selectedItems.has(item.path)"
|
||||
@delete="$emit('delete', item)"
|
||||
@rename="$emit('rename', item)"
|
||||
@extract="$emit('extract', item)"
|
||||
@download="$emit('download', item)"
|
||||
@move="$emit('move', item)"
|
||||
@move-direct-to="$emit('moveDirectTo', $event)"
|
||||
@edit="$emit('edit', item)"
|
||||
@hover="$emit('hover', item)"
|
||||
@contextmenu="(x, y) => $emit('contextmenu', item, x, y)"
|
||||
@toggle-select="$emit('toggle-select', item.path)"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import FileItem from './FileItem.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
items: any[]
|
||||
selectedItems: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'delete' | 'rename' | 'download' | 'move' | 'edit' | 'moveDirectTo' | 'extract' | 'hover',
|
||||
item: any,
|
||||
): void
|
||||
(e: 'contextmenu', item: any, x: number, y: number): void
|
||||
(e: 'loadMore'): void
|
||||
(e: 'toggle-select', path: string): void
|
||||
}>()
|
||||
|
||||
const ITEM_HEIGHT = 61
|
||||
const BUFFER_SIZE = 5
|
||||
|
||||
const listContainer = ref<HTMLElement | null>(null)
|
||||
const windowScrollY = ref(0)
|
||||
const windowHeight = ref(0)
|
||||
|
||||
const totalHeight = computed(() => props.items.length * ITEM_HEIGHT)
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
if (!listContainer.value) return { start: 0, end: 0 }
|
||||
|
||||
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY
|
||||
const relativeScrollTop = Math.max(0, windowScrollY.value - containerTop)
|
||||
|
||||
const start = Math.floor(relativeScrollTop / ITEM_HEIGHT)
|
||||
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT)
|
||||
|
||||
return {
|
||||
start: Math.max(0, start - BUFFER_SIZE),
|
||||
end: Math.min(props.items.length, start + visibleCount + BUFFER_SIZE * 2),
|
||||
}
|
||||
})
|
||||
|
||||
const visibleTop = computed(() => {
|
||||
return visibleRange.value.start * ITEM_HEIGHT
|
||||
})
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
return props.items.slice(visibleRange.value.start, visibleRange.value.end)
|
||||
})
|
||||
|
||||
function handleScroll() {
|
||||
windowScrollY.value = window.scrollY
|
||||
|
||||
if (!listContainer.value) return
|
||||
|
||||
const containerBottom = listContainer.value.getBoundingClientRect().bottom
|
||||
const remainingScroll = containerBottom - window.innerHeight
|
||||
|
||||
if (remainingScroll < windowHeight.value * 0.2) {
|
||||
emit('loadMore')
|
||||
}
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
windowHeight.value = window.innerHeight
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
windowHeight.value = window.innerHeight
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener('resize', handleResize, { passive: true })
|
||||
handleScroll()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
@@ -1,173 +0,0 @@
|
||||
<template>
|
||||
<header
|
||||
class="flex select-none flex-col justify-between gap-2 sm:flex-row sm:items-center"
|
||||
aria-label="File navigation"
|
||||
>
|
||||
<nav
|
||||
aria-label="Breadcrumb navigation"
|
||||
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
|
||||
>
|
||||
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
|
||||
<li class="mr-4 flex-shrink-0">
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="'Back to home'"
|
||||
type="button"
|
||||
class="!size-10 bg-surface-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
@click="$emit('navigate', -1)"
|
||||
@mouseenter="$emit('prefetch-home')"
|
||||
>
|
||||
<HomeIcon />
|
||||
<span class="sr-only">Home</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
<li class="m-0 -ml-2 min-w-0 flex-shrink p-0">
|
||||
<ol class="m-0 flex min-w-0 flex-shrink items-center overflow-hidden p-0">
|
||||
<TransitionGroup
|
||||
name="breadcrumb"
|
||||
tag="span"
|
||||
class="relative flex min-w-0 flex-shrink items-center"
|
||||
>
|
||||
<li
|
||||
v-for="(segment, index) in breadcrumbSegments"
|
||||
:key="`${segment || index}-group`"
|
||||
class="relative flex min-w-0 flex-shrink items-center text-sm"
|
||||
>
|
||||
<div class="flex min-w-0 flex-shrink items-center">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="cursor-pointer truncate focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
:aria-current="
|
||||
index === breadcrumbSegments.length - 1 ? 'location' : undefined
|
||||
"
|
||||
:class="{
|
||||
'!text-contrast': index === breadcrumbSegments.length - 1,
|
||||
}"
|
||||
@click="$emit('navigate', index)"
|
||||
>
|
||||
{{ segment || '' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ChevronRightIcon
|
||||
v-if="index < breadcrumbSegments.length - 1"
|
||||
class="size-4 flex-shrink-0 text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-shrink-0 items-center gap-2">
|
||||
<StyledInput
|
||||
id="search-folder"
|
||||
:model-value="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
placeholder="Search files"
|
||||
wrapper-class="w-full sm:w-[280px]"
|
||||
@update:model-value="$emit('update:searchQuery', $event)"
|
||||
/>
|
||||
|
||||
<ButtonStyled type="outlined">
|
||||
<OverflowMenu
|
||||
:dropdown-id="`create-new-${baseId}`"
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Create new..."
|
||||
class="!h-10 justify-center gap-2 !border-[1px] !border-surface-5"
|
||||
:options="[
|
||||
{ id: 'file', action: () => $emit('create', 'file') },
|
||||
{ id: 'directory', action: () => $emit('create', 'directory') },
|
||||
{ id: 'upload', action: () => $emit('upload') },
|
||||
{ divider: true },
|
||||
{ id: 'upload-zip', shown: false, action: () => $emit('upload-zip') },
|
||||
{ id: 'install-from-url', action: () => $emit('unzip-from-url', false) },
|
||||
{ id: 'install-cf-pack', action: () => $emit('unzip-from-url', true) },
|
||||
]"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" class="h-5 w-5" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5" />
|
||||
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
|
||||
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
|
||||
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </template>
|
||||
<template #upload-zip>
|
||||
<FileArchiveIcon aria-hidden="true" /> Upload from .zip file
|
||||
</template>
|
||||
<template #install-from-url>
|
||||
<LinkIcon aria-hidden="true" /> Upload from .zip URL
|
||||
</template>
|
||||
<template #install-cf-pack>
|
||||
<CurseForgeIcon aria-hidden="true" /> Install CurseForge pack
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BoxIcon,
|
||||
ChevronRightIcon,
|
||||
CurseForgeIcon,
|
||||
DropdownIcon,
|
||||
FileArchiveIcon,
|
||||
FolderOpenIcon,
|
||||
HomeIcon,
|
||||
LinkIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
UploadIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonStyled, OverflowMenu, StyledInput } from '@modrinth/ui'
|
||||
|
||||
defineProps<{
|
||||
breadcrumbSegments: string[]
|
||||
searchQuery: string
|
||||
currentFilter: string
|
||||
baseId: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'navigate', index: number): void
|
||||
(e: 'create', type: 'file' | 'directory'): void
|
||||
(e: 'upload' | 'upload-zip' | 'prefetch-home'): void
|
||||
(e: 'unzip-from-url', cf: boolean): void
|
||||
(e: 'update:searchQuery' | 'filter', value: string): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumb-move,
|
||||
.breadcrumb-enter-active,
|
||||
.breadcrumb-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.breadcrumb-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px) scale(0.9);
|
||||
}
|
||||
|
||||
.breadcrumb-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px) scale(0.8);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
.breadcrumb-leave-active {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.breadcrumb-move {
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,104 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed"
|
||||
:style="{
|
||||
transform: `translateY(${isAtBottom ? '-100%' : '0'})`,
|
||||
top: `${y}px`,
|
||||
left: `${x}px`,
|
||||
}"
|
||||
>
|
||||
<Transition>
|
||||
<div
|
||||
v-if="item"
|
||||
id="item-context-menu"
|
||||
ref="ctxRef"
|
||||
:style="{
|
||||
border: '1px solid var(--color-divider)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
backgroundColor: 'var(--color-raised-bg)',
|
||||
padding: 'var(--gap-sm)',
|
||||
boxShadow: 'var(--shadow-floating)',
|
||||
gap: 'var(--gap-xs)',
|
||||
width: 'max-content',
|
||||
}"
|
||||
class="flex h-fit w-fit select-none flex-col"
|
||||
>
|
||||
<button
|
||||
class="btn btn-transparent flex !w-full items-center"
|
||||
@click="$emit('rename', item)"
|
||||
>
|
||||
<EditIcon class="h-5 w-5" />
|
||||
Rename
|
||||
</button>
|
||||
<button class="btn btn-transparent flex !w-full items-center" @click="$emit('move', item)">
|
||||
<RightArrowIcon />
|
||||
Move
|
||||
</button>
|
||||
<button
|
||||
v-if="item.type !== 'directory'"
|
||||
class="btn btn-transparent flex !w-full items-center"
|
||||
@click="$emit('download', item)"
|
||||
>
|
||||
<DownloadIcon class="h-5 w-5" />
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-transparent btn-red flex !w-full items-center"
|
||||
@click="$emit('delete', item)"
|
||||
>
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, EditIcon, RightArrowIcon, TrashIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface FileItem {
|
||||
type: string
|
||||
name: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
item: FileItem | null
|
||||
x: number
|
||||
y: number
|
||||
isAtBottom: boolean
|
||||
}>()
|
||||
|
||||
const ctxRef = ref<HTMLElement | null>(null)
|
||||
|
||||
defineEmits<{
|
||||
(e: 'rename' | 'move' | 'download' | 'delete', item: FileItem): void
|
||||
}>()
|
||||
|
||||
defineExpose({
|
||||
ctxRef,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#item-context-menu {
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
opacity 0.1s ease;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
#item-context-menu.v-enter-active,
|
||||
#item-context-menu.v-leave-active {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#item-context-menu.v-enter-from,
|
||||
#item-context-menu.v-leave-to {
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,94 +0,0 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="`Creating a ${displayType}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
<StyledInput
|
||||
ref="createInput"
|
||||
v-model="itemName"
|
||||
autofocus
|
||||
wrapper-class="bg-bg-input w-full rounded-lg p-4"
|
||||
:placeholder="`e.g. ${type === 'file' ? 'config.yml' : 'plugins'}`"
|
||||
/>
|
||||
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
||||
</div>
|
||||
<div class="flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error" type="submit">
|
||||
<PlusIcon class="h-5 w-5" />
|
||||
Create {{ displayType }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, NewModal, StyledInput } from '@modrinth/ui'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'file' | 'directory'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'create', name: string): void
|
||||
}>()
|
||||
|
||||
const modal = ref<typeof NewModal>()
|
||||
const displayType = computed(() => (props.type === 'directory' ? 'folder' : props.type))
|
||||
const createInput = ref<HTMLInputElement | null>(null)
|
||||
const itemName = ref('')
|
||||
const submitted = ref(false)
|
||||
|
||||
const error = computed(() => {
|
||||
if (!itemName.value) {
|
||||
return 'Name is required.'
|
||||
}
|
||||
if (props.type === 'file') {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return 'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.'
|
||||
}
|
||||
} else {
|
||||
const validPattern = /^[a-zA-Z0-9-_\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return 'Name must contain only alphanumeric characters, dashes, underscores, or spaces.'
|
||||
}
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitted.value = true
|
||||
if (!error.value) {
|
||||
emit('create', itemName.value)
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
itemName.value = ''
|
||||
submitted.value = false
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
createInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -1,77 +0,0 @@
|
||||
<template>
|
||||
<NewModal ref="modal" danger :header="`Deleting ${item?.type}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div
|
||||
class="relative flex w-full items-center gap-2 rounded-2xl border border-solid border-brand-red bg-bg-red p-6 shadow-md"
|
||||
>
|
||||
<div
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-highlight-red p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
|
||||
>
|
||||
<FolderOpenIcon v-if="item?.type === 'directory'" class="h-5 w-5" />
|
||||
<FileIcon v-else-if="item?.type === 'file'" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold group-hover:text-contrast">{{ item?.name }}</span>
|
||||
<span
|
||||
v-if="item?.type === 'directory'"
|
||||
class="text-xs text-secondary group-hover:text-primary"
|
||||
>
|
||||
{{ item?.count }} items
|
||||
</span>
|
||||
<span v-else class="text-xs text-secondary group-hover:text-primary">
|
||||
{{ ((item?.size ?? 0) / 1024 / 1024).toFixed(2) }} MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-start gap-4">
|
||||
<ButtonStyled color="red">
|
||||
<button type="submit">
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
Delete {{ item?.type }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FileIcon, FolderOpenIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, NewModal } from '@modrinth/ui'
|
||||
|
||||
defineProps<{
|
||||
item: {
|
||||
name: string
|
||||
type: string
|
||||
count?: number
|
||||
size?: number
|
||||
} | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<typeof NewModal>()
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('delete')
|
||||
hide()
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -1,136 +0,0 @@
|
||||
<template>
|
||||
<header
|
||||
data-pyro-files-state="editing"
|
||||
class="flex select-none items-center justify-between gap-2 sm:flex-row"
|
||||
aria-label="File editor navigation"
|
||||
>
|
||||
<nav
|
||||
aria-label="Breadcrumb navigation"
|
||||
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
|
||||
>
|
||||
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
|
||||
<li class="mr-4 flex-shrink-0">
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="'Back to home'"
|
||||
type="button"
|
||||
class="!size-10 bg-surface-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
@click="goHome"
|
||||
>
|
||||
<HomeIcon />
|
||||
<span class="sr-only">Home</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
<li class="m-0 -ml-2 p-0">
|
||||
<ol class="m-0 flex items-center p-0">
|
||||
<li
|
||||
v-for="(segment, index) in breadcrumbSegments"
|
||||
:key="index"
|
||||
class="flex items-center text-sm"
|
||||
>
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
:class="{
|
||||
'!text-contrast': index === breadcrumbSegments.length - 1,
|
||||
}"
|
||||
@click="$emit('navigate', index)"
|
||||
>
|
||||
{{ segment || '' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ChevronRightIcon
|
||||
v-if="index < breadcrumbSegments.length"
|
||||
class="size-4 text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</li>
|
||||
<li class="flex items-center px-3 text-sm">
|
||||
<span class="font-semibold !text-contrast" aria-current="location">{{
|
||||
fileName
|
||||
}}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div v-if="!isImage" class="flex gap-2">
|
||||
<Button
|
||||
v-if="isLogFile"
|
||||
v-tooltip="'Share to mclo.gs'"
|
||||
icon-only
|
||||
transparent
|
||||
aria-label="Share to mclo.gs"
|
||||
@click="$emit('share')"
|
||||
>
|
||||
<ShareIcon />
|
||||
</Button>
|
||||
<ButtonStyled type="transparent">
|
||||
<TeleportOverflowMenu
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Save file"
|
||||
:options="[
|
||||
{ id: 'save', action: () => $emit('save') },
|
||||
{ id: 'save-as', action: () => $emit('save-as') },
|
||||
{ id: 'save&restart', action: () => $emit('save-restart') },
|
||||
]"
|
||||
>
|
||||
<SaveIcon aria-hidden="true" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #save> <SaveIcon aria-hidden="true" /> Save </template>
|
||||
<template #save-as> <SaveIcon aria-hidden="true" /> Save as... </template>
|
||||
<template #save&restart>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Save & restart
|
||||
</template>
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon, DropdownIcon, HomeIcon, SaveIcon, ShareIcon } from '@modrinth/assets'
|
||||
import { Button, ButtonStyled } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
breadcrumbSegments: string[]
|
||||
fileName?: string
|
||||
isImage: boolean
|
||||
filePath?: string
|
||||
}>()
|
||||
|
||||
const isLogFile = computed(() => {
|
||||
return props.filePath?.startsWith('logs') || props.filePath?.endsWith('.log')
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancel' | 'save' | 'save-as' | 'save-restart' | 'share'): void
|
||||
(e: 'navigate', index: number): void
|
||||
}>()
|
||||
|
||||
const goHome = () => {
|
||||
emit('cancel')
|
||||
router.push({ path: '/hosting/manage/' + route.params.id + '/files' })
|
||||
}
|
||||
</script>
|
||||
@@ -1,260 +0,0 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col gap-4">
|
||||
<FilesRenameItemModal ref="renameModal" :item="file" @rename="handleRenameItem" />
|
||||
|
||||
<FilesEditingNavbar
|
||||
:file-name="file?.name"
|
||||
:is-image="isEditingImage"
|
||||
:file-path="file?.path"
|
||||
class="-mt-2"
|
||||
:breadcrumb-segments="breadcrumbSegments"
|
||||
@cancel="handleCancel"
|
||||
@save="() => saveFileContent(true)"
|
||||
@save-as="saveFileContentAs"
|
||||
@save-restart="saveFileContentRestart"
|
||||
@share="requestShareLink"
|
||||
@navigate="(index) => emit('navigate', index)"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col shadow-md">
|
||||
<div class="h-full w-full flex-grow">
|
||||
<component
|
||||
:is="props.editorComponent"
|
||||
v-if="!isEditingImage && props.editorComponent"
|
||||
v-model:value="fileContent"
|
||||
:lang="editorLanguage"
|
||||
theme="modrinth"
|
||||
:print-margin="false"
|
||||
style="height: 750px; font-size: 1rem"
|
||||
class="ace-modrinth rounded-[20px]"
|
||||
@init="onEditorInit"
|
||||
/>
|
||||
<FilesImageViewer v-else-if="isEditingImage && imagePreview" :image-blob="imagePreview" />
|
||||
<div
|
||||
v-else-if="isLoading || !props.editorComponent"
|
||||
class="flex h-[750px] items-center justify-center rounded-[20px] bg-bg-raised"
|
||||
>
|
||||
<SpinnerIcon class="h-8 w-8 animate-spin text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
getEditorLanguage,
|
||||
getFileExtension,
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
isImageFile,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import FilesEditingNavbar from '~/components/ui/servers/FilesEditingNavbar.vue'
|
||||
import FilesImageViewer from '~/components/ui/servers/FilesImageViewer.vue'
|
||||
import FilesRenameItemModal from '~/components/ui/servers/FilesRenameItemModal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
file: { name: string; type: string; path: string } | null
|
||||
breadcrumbSegments: string[]
|
||||
editorComponent: any
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
navigate: [index: number]
|
||||
}>()
|
||||
|
||||
const notifications = injectNotificationManager()
|
||||
const { addNotification } = notifications
|
||||
const client = injectModrinthClient()
|
||||
const serverContext = injectModrinthServerContext()
|
||||
const { serverId } = serverContext
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
|
||||
|
||||
// Internal state
|
||||
const fileContent = ref('')
|
||||
const isEditingImage = ref(false)
|
||||
const imagePreview = ref<Blob | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const renameModal = ref()
|
||||
const closeAfterRename = ref(false)
|
||||
const editorInstance = ref<any>(null)
|
||||
|
||||
const editorLanguage = computed(() => {
|
||||
const ext = getFileExtension(props.file?.name ?? '')
|
||||
return getEditorLanguage(ext)
|
||||
})
|
||||
|
||||
// Load file content when file prop changes
|
||||
watch(
|
||||
() => props.file,
|
||||
async (newFile) => {
|
||||
if (newFile) {
|
||||
await loadFileContent(newFile)
|
||||
} else {
|
||||
resetState()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function loadFileContent(file: { name: string; type: string; path: string }) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
window.scrollTo(0, 0)
|
||||
const extension = getFileExtension(file.name)
|
||||
|
||||
if (file.type === 'file' && isImageFile(extension)) {
|
||||
// Images are not prefetched, fetch directly
|
||||
const content = await client.kyros.files_v0.downloadFile(file.path)
|
||||
isEditingImage.value = true
|
||||
imagePreview.value = content
|
||||
} else {
|
||||
isEditingImage.value = false
|
||||
// Check cache first for text files (may have been prefetched on hover)
|
||||
const cachedContent = queryClient.getQueryData<string>(['file-content', serverId, file.path])
|
||||
if (cachedContent) {
|
||||
fileContent.value = cachedContent
|
||||
} else {
|
||||
const content = await client.kyros.files_v0.downloadFile(file.path)
|
||||
fileContent.value = await content.text()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching file content:', error)
|
||||
addNotification({
|
||||
title: 'Failed to open file',
|
||||
text: 'Could not load file contents.',
|
||||
type: 'error',
|
||||
})
|
||||
emit('close')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
fileContent.value = ''
|
||||
isEditingImage.value = false
|
||||
imagePreview.value = null
|
||||
closeAfterRename.value = false
|
||||
}
|
||||
|
||||
function onEditorInit(editor: any) {
|
||||
editorInstance.value = editor
|
||||
|
||||
editor.commands.addCommand({
|
||||
name: 'save',
|
||||
bindKey: { win: 'Ctrl-S', mac: 'Command-S' },
|
||||
exec: () => saveFileContent(false),
|
||||
})
|
||||
}
|
||||
|
||||
async function saveFileContent(exit: boolean = true) {
|
||||
if (!props.file) return
|
||||
|
||||
try {
|
||||
await client.kyros.files_v0.updateFile(props.file.path, fileContent.value)
|
||||
|
||||
if (exit) {
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
emit('close')
|
||||
}
|
||||
|
||||
addNotification({
|
||||
title: 'File saved',
|
||||
text: 'Your file has been saved.',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error saving file content:', error)
|
||||
addNotification({ title: 'Save failed', text: 'Could not save the file.', type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFileContentRestart() {
|
||||
await saveFileContent(false)
|
||||
await client.archon.servers_v0.power(serverId, 'Restart')
|
||||
|
||||
addNotification({
|
||||
title: 'Server restarted',
|
||||
text: 'Your server has been restarted.',
|
||||
type: 'success',
|
||||
})
|
||||
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function saveFileContentAs() {
|
||||
await saveFileContent(false)
|
||||
closeAfterRename.value = true
|
||||
renameModal.value?.show(props.file)
|
||||
}
|
||||
|
||||
async function handleRenameItem(newName: string) {
|
||||
if (!props.file) return
|
||||
|
||||
try {
|
||||
await client.kyros.files_v0.renameFileOrFolder(props.file.path, newName)
|
||||
|
||||
addNotification({ title: 'Renamed', text: `Renamed to ${newName}`, type: 'success' })
|
||||
|
||||
if (closeAfterRename.value) {
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
closeAfterRename.value = false
|
||||
emit('close')
|
||||
}
|
||||
} catch (err: any) {
|
||||
addNotification({ title: 'Rename failed', text: err.message, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function requestShareLink() {
|
||||
try {
|
||||
const response = (await $fetch('https://api.mclo.gs/1/log', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ content: fileContent.value }),
|
||||
})) as any
|
||||
|
||||
if (response.success) {
|
||||
await navigator.clipboard.writeText(response.url)
|
||||
addNotification({
|
||||
title: 'Log URL copied',
|
||||
text: 'Your log file URL has been copied to your clipboard.',
|
||||
type: 'success',
|
||||
})
|
||||
} else {
|
||||
throw new Error(response.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sharing file:', error)
|
||||
addNotification({
|
||||
title: 'Failed to share file',
|
||||
text: 'Could not upload to mclo.gs.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await modulesLoaded
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
editorInstance.value = null
|
||||
resetState()
|
||||
})
|
||||
</script>
|
||||
@@ -1,180 +0,0 @@
|
||||
<template>
|
||||
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center">
|
||||
<div
|
||||
ref="container"
|
||||
class="relative w-full flex-grow cursor-grab overflow-hidden rounded-[20px] bg-black active:cursor-grabbing"
|
||||
@mousedown="startPan"
|
||||
@mousemove="handlePan"
|
||||
@mouseup="stopPan"
|
||||
@mouseleave="stopPan"
|
||||
@wheel.prevent="handleWheel"
|
||||
>
|
||||
<div v-if="state.isLoading" />
|
||||
<div
|
||||
v-if="state.hasError"
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-8"
|
||||
>
|
||||
<PanelErrorIcon />
|
||||
<p class="m-0">{{ state.errorMessage || 'Invalid or empty image file.' }}</p>
|
||||
</div>
|
||||
<img
|
||||
v-show="isReady"
|
||||
ref="imageRef"
|
||||
:src="imageObjectUrl"
|
||||
class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform"
|
||||
:style="imageStyle"
|
||||
alt="Viewed image"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!state.hasError"
|
||||
class="absolute bottom-0 mb-2 flex w-fit justify-center gap-2 space-x-4 rounded-2xl bg-bg p-2"
|
||||
>
|
||||
<ButtonStyled type="transparent" @click="zoom(ZOOM_IN_FACTOR)">
|
||||
<button v-tooltip="'Zoom in'">
|
||||
<ZoomInIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent" @click="zoom(ZOOM_OUT_FACTOR)">
|
||||
<button v-tooltip="'Zoom out'">
|
||||
<ZoomOutIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent" @click="reset">
|
||||
<button>
|
||||
<span class="font-mono">{{ Math.round(state.scale * 100) }}%</span>
|
||||
<span class="ml-4 text-sm text-blue">Reset</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ZoomInIcon, ZoomOutIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import PanelErrorIcon from './icons/PanelErrorIcon.vue'
|
||||
|
||||
const ZOOM_MIN = 0.1
|
||||
const ZOOM_MAX = 5
|
||||
const ZOOM_IN_FACTOR = 1.2
|
||||
const ZOOM_OUT_FACTOR = 0.8
|
||||
const INITIAL_SCALE = 0.5
|
||||
const MAX_IMAGE_DIMENSION = 4096
|
||||
|
||||
const props = defineProps<{
|
||||
imageBlob: Blob
|
||||
}>()
|
||||
|
||||
const state = ref({
|
||||
scale: INITIAL_SCALE,
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
isPanning: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
errorMessage: '',
|
||||
})
|
||||
|
||||
const imageRef = ref<HTMLImageElement | null>(null)
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const imageObjectUrl = ref('')
|
||||
const rafId = ref(0)
|
||||
|
||||
const isReady = computed(() => !state.value.isLoading && !state.value.hasError)
|
||||
|
||||
const imageStyle = computed(() => ({
|
||||
transform: `translate(-50%, -50%) scale(${state.value.scale}) translate(${state.value.translateX}px, ${state.value.translateY}px)`,
|
||||
transition: state.value.isPanning ? 'none' : 'transform 0.3s ease-out',
|
||||
}))
|
||||
|
||||
const validateImageDimensions = (img: HTMLImageElement): boolean => {
|
||||
if (img.naturalWidth > MAX_IMAGE_DIMENSION || img.naturalHeight > MAX_IMAGE_DIMENSION) {
|
||||
state.value.hasError = true
|
||||
state.value.errorMessage = `Image too large to view (max ${MAX_IMAGE_DIMENSION}x${MAX_IMAGE_DIMENSION} pixels)`
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const updateImageUrl = (blob: Blob) => {
|
||||
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
|
||||
imageObjectUrl.value = URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
const handleImageLoad = () => {
|
||||
if (!imageRef.value || !validateImageDimensions(imageRef.value)) {
|
||||
state.value.isLoading = false
|
||||
return
|
||||
}
|
||||
state.value.isLoading = false
|
||||
reset()
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
state.value.isLoading = false
|
||||
state.value.hasError = true
|
||||
state.value.errorMessage = 'Failed to load image'
|
||||
}
|
||||
|
||||
const zoom = (factor: number) => {
|
||||
const newScale = state.value.scale * factor
|
||||
state.value.scale = Math.max(ZOOM_MIN, Math.min(newScale, ZOOM_MAX))
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
state.value.scale = INITIAL_SCALE
|
||||
state.value.translateX = 0
|
||||
state.value.translateY = 0
|
||||
}
|
||||
|
||||
const startPan = (e: MouseEvent) => {
|
||||
state.value.isPanning = true
|
||||
state.value.startX = e.clientX - state.value.translateX
|
||||
state.value.startY = e.clientY - state.value.translateY
|
||||
}
|
||||
|
||||
const handlePan = (e: MouseEvent) => {
|
||||
if (!state.value.isPanning) return
|
||||
cancelAnimationFrame(rafId.value)
|
||||
rafId.value = requestAnimationFrame(() => {
|
||||
state.value.translateX = e.clientX - state.value.startX
|
||||
state.value.translateY = e.clientY - state.value.startY
|
||||
})
|
||||
}
|
||||
|
||||
const stopPan = () => {
|
||||
state.value.isPanning = false
|
||||
}
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
const delta = e.deltaY * -0.001
|
||||
const factor = 1 + delta
|
||||
zoom(factor)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.imageBlob,
|
||||
(newBlob) => {
|
||||
if (!newBlob) return
|
||||
state.value.isLoading = true
|
||||
state.value.hasError = false
|
||||
updateImageUrl(newBlob)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.imageBlob) updateImageUrl(props.imageBlob)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
|
||||
cancelAnimationFrame(rafId.value)
|
||||
})
|
||||
</script>
|
||||
@@ -1,102 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="sticky top-0 z-20 flex w-full select-none flex-row items-center justify-between border border-b-0 border-solid border-surface-3 bg-surface-3 p-4 text-sm font-medium transition-[border-radius] duration-100 before:pointer-events-none before:absolute before:inset-x-0 before:-top-5 before:h-5 before:bg-surface-3"
|
||||
:class="isStuck ? 'rounded-none' : 'rounded-t-[20px]'"
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-3">
|
||||
<Checkbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected && !allSelected"
|
||||
@update:model-value="$emit('toggle-all')"
|
||||
/>
|
||||
<button
|
||||
class="flex appearance-none items-center gap-1.5 bg-transparent text-contrast hover:text-brand"
|
||||
@click="$emit('sort', 'name')"
|
||||
>
|
||||
<span>Name</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'name' && !sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'name' && sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-4 md:gap-12">
|
||||
<button
|
||||
class="hidden w-[100px] appearance-none items-center justify-start gap-1 bg-transparent text-primary hover:text-brand md:flex"
|
||||
@click="$emit('sort', 'size')"
|
||||
>
|
||||
<span>Size</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'size' && !sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'size' && sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="hidden w-[160px] appearance-none items-center justify-start gap-1 bg-transparent text-primary hover:text-brand md:flex"
|
||||
@click="$emit('sort', 'created')"
|
||||
>
|
||||
<span>Created</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'created' && !sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'created' && sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="hidden w-[160px] appearance-none items-center justify-start gap-1 bg-transparent text-primary hover:text-brand md:flex"
|
||||
@click="$emit('sort', 'modified')"
|
||||
>
|
||||
<span>Modified</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'modified' && !sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'modified' && sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<span class="w-[51px] text-right text-primary">Actions</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Checkbox } from '@modrinth/ui'
|
||||
|
||||
import ChevronDownIcon from './icons/ChevronDownIcon.vue'
|
||||
import ChevronUpIcon from './icons/ChevronUpIcon.vue'
|
||||
|
||||
defineProps<{
|
||||
sortField: string
|
||||
sortDesc: boolean
|
||||
allSelected: boolean
|
||||
someSelected: boolean
|
||||
isStuck: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'sort', field: string): void
|
||||
(e: 'toggle-all'): void
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,80 +0,0 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="`Moving ${item?.name}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<StyledInput
|
||||
ref="destinationInput"
|
||||
v-model="destination"
|
||||
autofocus
|
||||
wrapper-class="bg-bg-input w-full rounded-lg p-4"
|
||||
placeholder="e.g. /mods/modname"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-nowrap">
|
||||
New location:
|
||||
<div class="w-full rounded-lg bg-table-alternateRow p-2 font-bold text-contrast">
|
||||
<span class="text-secondary">/root</span>{{ newpath }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button type="submit">
|
||||
<ArrowBigUpDashIcon class="h-5 w-5" />
|
||||
Move
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowBigUpDashIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, NewModal, StyledInput } from '@modrinth/ui'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
const destinationInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
item: { name: string } | null
|
||||
currentPath: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'move', destination: string): void
|
||||
}>()
|
||||
|
||||
const modal = ref<typeof NewModal>()
|
||||
const destination = ref('')
|
||||
const newpath = computed(() => {
|
||||
const path = destination.value.replace('//', '/')
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('move', newpath.value)
|
||||
hide()
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
destination.value = props.currentPath
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
destinationInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -1,92 +0,0 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="`Renaming ${item?.type}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
<StyledInput
|
||||
ref="renameInput"
|
||||
v-model="itemName"
|
||||
autofocus
|
||||
wrapper-class="bg-bg-input w-full rounded-lg p-4"
|
||||
/>
|
||||
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
||||
</div>
|
||||
<div class="flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error" type="submit">
|
||||
<EditIcon class="h-5 w-5" />
|
||||
Rename
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, NewModal, StyledInput } from '@modrinth/ui'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
item: { name: string; type: string } | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'rename', newName: string): void
|
||||
}>()
|
||||
|
||||
const modal = ref<typeof NewModal>()
|
||||
const renameInput = ref<HTMLInputElement | null>(null)
|
||||
const itemName = ref('')
|
||||
const submitted = ref(false)
|
||||
|
||||
const error = computed(() => {
|
||||
if (!itemName.value) {
|
||||
return 'Name is required.'
|
||||
}
|
||||
if (props.item?.type === 'file') {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return 'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.'
|
||||
}
|
||||
} else {
|
||||
const validPattern = /^[a-zA-Z0-9-_\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return 'Name must contain only alphanumeric characters, dashes, underscores, or spaces.'
|
||||
}
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitted.value = true
|
||||
if (!error.value) {
|
||||
emit('rename', itemName.value)
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
const show = (item: { name: string; type: string }) => {
|
||||
itemName.value = item.name
|
||||
submitted.value = false
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
renameInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -1,56 +0,0 @@
|
||||
<template>
|
||||
<ConfirmModal
|
||||
ref="modal"
|
||||
title="Do you want to overwrite these conflicting files?"
|
||||
:proceed-label="`Overwrite`"
|
||||
:proceed-icon="CheckIcon"
|
||||
@proceed="proceed"
|
||||
>
|
||||
<div class="flex max-w-[30rem] flex-col gap-4">
|
||||
<p class="m-0 font-semibold leading-normal">
|
||||
<template v-if="hasMany">
|
||||
Over 100 files will be overwritten if you proceed with extraction; here is just some of
|
||||
them:
|
||||
</template>
|
||||
<template v-else>
|
||||
The following {{ files.length }} files already exist on your server, and will be
|
||||
overwritten if you proceed with extraction:
|
||||
</template>
|
||||
</p>
|
||||
<ul class="m-0 max-h-80 list-none overflow-auto rounded-2xl bg-bg px-4 py-3">
|
||||
<li v-for="file in files" :key="file" class="flex items-center gap-1 py-1 font-medium">
|
||||
<XIcon class="shrink-0 text-red" /> {{ file }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import { ConfirmModal } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const path = ref('')
|
||||
const files = ref<string[]>([])
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'proceed', path: string): void
|
||||
}>()
|
||||
|
||||
const modal = ref<typeof ConfirmModal>()
|
||||
|
||||
const hasMany = computed(() => files.value.length > 100)
|
||||
|
||||
const show = (zipPath: string, conflictingFiles: string[]) => {
|
||||
path.value = zipPath
|
||||
files.value = conflictingFiles
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
const proceed = () => {
|
||||
emit('proceed', path.value)
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@@ -1,75 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
v-if="isDragging"
|
||||
:class="[
|
||||
'absolute inset-0 flex items-center justify-center rounded-2xl bg-black/60 text-contrast shadow',
|
||||
overlayClass,
|
||||
]"
|
||||
>
|
||||
<div class="text-center">
|
||||
<UploadIcon class="mx-auto h-16 w-16 shadow-2xl" />
|
||||
<p class="mt-2 text-xl">
|
||||
Drop {{ type ? type.toLocaleLowerCase() : 'file' }}s here to upload
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UploadIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'filesDropped', files: File[]): void
|
||||
}>()
|
||||
|
||||
defineProps<{
|
||||
overlayClass?: string
|
||||
type?: string
|
||||
}>()
|
||||
|
||||
const isDragging = ref(false)
|
||||
const dragCounter = ref(0)
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
if (!event.dataTransfer?.types.includes('application/modrinth-file-move')) {
|
||||
dragCounter.value++
|
||||
isDragging.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
dragCounter.value--
|
||||
if (dragCounter.value === 0) {
|
||||
isDragging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
isDragging.value = false
|
||||
dragCounter.value = 0
|
||||
|
||||
const isInternalMove = event.dataTransfer?.types.includes('application/modrinth-file-move')
|
||||
if (isInternalMove) return
|
||||
|
||||
const files = event.dataTransfer?.files
|
||||
if (files) {
|
||||
emit('filesDropped', Array.from(files))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,335 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
|
||||
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
|
||||
<div
|
||||
ref="statusContentRef"
|
||||
v-bind="$attrs"
|
||||
:class="['flex flex-col p-4 text-sm text-contrast']"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 font-bold">
|
||||
<FolderOpenIcon class="size-4" />
|
||||
<span>
|
||||
<span class="capitalize">
|
||||
{{ props.fileType ? props.fileType : 'File' }} uploads
|
||||
</span>
|
||||
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : '' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-2">
|
||||
<div
|
||||
v-for="item in uploadQueue"
|
||||
:key="item.file.name"
|
||||
class="flex h-6 items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-2 truncate">
|
||||
<transition-group name="status-icon" mode="out-in">
|
||||
<PanelSpinner
|
||||
v-show="item.status === 'uploading'"
|
||||
key="spinner"
|
||||
class="absolute !size-4"
|
||||
/>
|
||||
<CheckCircleIcon
|
||||
v-show="item.status === 'completed'"
|
||||
key="check"
|
||||
class="absolute size-4 text-green"
|
||||
/>
|
||||
<XCircleIcon
|
||||
v-show="
|
||||
item.status.includes('error') ||
|
||||
item.status === 'cancelled' ||
|
||||
item.status === 'incorrect-type'
|
||||
"
|
||||
key="error"
|
||||
class="absolute size-4 text-red"
|
||||
/>
|
||||
</transition-group>
|
||||
<span class="ml-6 truncate">{{ item.file.name }}</span>
|
||||
<span class="text-secondary">{{ item.size }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[80px] items-center justify-end gap-2">
|
||||
<template v-if="item.status === 'completed'">
|
||||
<span>Done</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error-file-exists'">
|
||||
<span class="text-red">Failed - File already exists</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error-generic'">
|
||||
<span class="text-red"
|
||||
>Failed - {{ item.error?.message || 'An unexpected error occured.' }}</span
|
||||
>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'incorrect-type'">
|
||||
<span class="text-red">Failed - Incorrect file type</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="item.status === 'uploading'">
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
|
||||
<button>Cancel</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'cancelled'">
|
||||
<span class="text-red">Cancelled</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, FolderOpenIcon, XCircleIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import PanelSpinner from './PanelSpinner.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
interface UploadItem {
|
||||
file: File
|
||||
progress: number
|
||||
status:
|
||||
| 'pending'
|
||||
| 'uploading'
|
||||
| 'completed'
|
||||
| 'error-file-exists'
|
||||
| 'error-generic'
|
||||
| 'cancelled'
|
||||
| 'incorrect-type'
|
||||
size: string
|
||||
uploader?: ReturnType<typeof client.kyros.files_v0.uploadFile>
|
||||
error?: Error
|
||||
}
|
||||
|
||||
interface Props {
|
||||
currentPath: string
|
||||
fileType?: string
|
||||
marginBottom?: number
|
||||
acceptedTypes?: Array<string>
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'uploadComplete'): void
|
||||
}>()
|
||||
|
||||
const uploadStatusRef = ref<HTMLElement | null>(null)
|
||||
const statusContentRef = ref<HTMLElement | null>(null)
|
||||
const uploadQueue = ref<UploadItem[]>([])
|
||||
|
||||
const isUploading = computed(() => uploadQueue.value.length > 0)
|
||||
const activeUploads = computed(() =>
|
||||
uploadQueue.value.filter((item) => item.status === 'pending' || item.status === 'uploading'),
|
||||
)
|
||||
|
||||
const onUploadStatusEnter = (el: Element) => {
|
||||
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0)
|
||||
;(el as HTMLElement).style.height = '0'
|
||||
|
||||
void (el as HTMLElement).offsetHeight
|
||||
;(el as HTMLElement).style.height = `${height}px`
|
||||
}
|
||||
|
||||
const onUploadStatusLeave = (el: Element) => {
|
||||
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0)
|
||||
;(el as HTMLElement).style.height = `${height}px`
|
||||
|
||||
void (el as HTMLElement).offsetHeight
|
||||
;(el as HTMLElement).style.height = '0'
|
||||
}
|
||||
|
||||
watch(
|
||||
uploadQueue,
|
||||
() => {
|
||||
if (!uploadStatusRef.value) return
|
||||
const el = uploadStatusRef.value
|
||||
const itemsHeight = uploadQueue.value.length * 32
|
||||
const headerHeight = 12
|
||||
const gap = 8
|
||||
const padding = 32
|
||||
const totalHeight = padding + headerHeight + gap + itemsHeight + (props.marginBottom || 0)
|
||||
el.style.height = `${totalHeight}px`
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + ' MB'
|
||||
return (bytes / 1024 ** 3).toFixed(1) + ' GB'
|
||||
}
|
||||
|
||||
const cancelUpload = (item: UploadItem) => {
|
||||
if (item.uploader && item.status === 'uploading') {
|
||||
item.uploader.cancel()
|
||||
item.status = 'cancelled'
|
||||
|
||||
setTimeout(async () => {
|
||||
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name)
|
||||
if (index !== -1) {
|
||||
uploadQueue.value.splice(index, 1)
|
||||
await nextTick()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
const badFileTypeMsg = 'Upload had incorrect file type'
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
const uploadItem: UploadItem = {
|
||||
file,
|
||||
progress: 0,
|
||||
status: 'pending',
|
||||
size: formatFileSize(file.size),
|
||||
}
|
||||
|
||||
uploadQueue.value.push(uploadItem)
|
||||
|
||||
try {
|
||||
if (
|
||||
props.acceptedTypes &&
|
||||
!props.acceptedTypes.includes(file.type) &&
|
||||
!props.acceptedTypes.some((type) => file.name.endsWith(type))
|
||||
) {
|
||||
throw new Error(badFileTypeMsg)
|
||||
}
|
||||
|
||||
uploadItem.status = 'uploading'
|
||||
const filePath = `${props.currentPath}/${file.name}`.replace('//', '/')
|
||||
|
||||
const uploader = client.kyros.files_v0.uploadFile(filePath, file, {
|
||||
onProgress: ({ progress }) => {
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||
if (index !== -1) {
|
||||
uploadQueue.value[index].progress = Math.round(progress)
|
||||
}
|
||||
},
|
||||
})
|
||||
uploadItem.uploader = uploader
|
||||
|
||||
await uploader.promise
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
|
||||
uploadQueue.value[index].status = 'completed'
|
||||
uploadQueue.value[index].progress = 100
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
setTimeout(async () => {
|
||||
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||
if (removeIndex !== -1) {
|
||||
uploadQueue.value.splice(removeIndex, 1)
|
||||
await nextTick()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
emit('uploadComplete')
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error)
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
|
||||
const target = uploadQueue.value[index]
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message === badFileTypeMsg) {
|
||||
target.status = 'incorrect-type'
|
||||
} else if (target.progress === 100 && error.message.includes('401')) {
|
||||
target.status = 'error-file-exists'
|
||||
} else {
|
||||
target.status = 'error-generic'
|
||||
target.error = error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||
if (removeIndex !== -1) {
|
||||
uploadQueue.value.splice(removeIndex, 1)
|
||||
await nextTick()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
if (error instanceof Error && error.message !== 'Upload cancelled') {
|
||||
addNotification({
|
||||
title: 'Upload failed',
|
||||
text: `Failed to upload ${file.name}`,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
uploadFile,
|
||||
cancelUpload,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-status {
|
||||
overflow: hidden;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-status-enter-active,
|
||||
.upload-status-leave-active {
|
||||
transition: height 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-status-enter-from,
|
||||
.upload-status-leave-to {
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.status-icon-enter-active,
|
||||
.status-icon-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.status-icon-enter-from,
|
||||
.status-icon-leave-to {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.status-icon-enter-to,
|
||||
.status-icon-leave-from {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,162 +0,0 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="cf ? `Installing a CurseForge pack` : `Uploading .zip contents from URL`"
|
||||
>
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-bold text-contrast">
|
||||
{{ cf ? `How to get the modpack version's URL` : 'URL of .zip file' }}
|
||||
</div>
|
||||
<ol v-if="cf" class="mb-1 mt-0 flex flex-col gap-1 pl-8 leading-normal text-secondary">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.curseforge.com/minecraft/search?page=1&pageSize=40&sortBy=relevancy&class=modpacks"
|
||||
class="inline-flex font-semibold text-[#F16436] transition-all hover:underline active:brightness-[--hover-brightness]"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Find the CurseForge modpack
|
||||
<ExternalIcon class="ml-1 inline size-4" stroke-width="3" />
|
||||
</a>
|
||||
you'd like to install on your server.
|
||||
</li>
|
||||
<li>
|
||||
On the modpack's page, go to the
|
||||
<span class="font-semibold text-primary">"Files"</span> tab, and
|
||||
<span class="font-semibold text-primary">select the version</span> of the modpack you
|
||||
want to install.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-semibold text-primary">Copy the URL</span> of the version you want to
|
||||
install, and paste it in the box below.
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="mb-1 mt-0">Copy and paste the direct download URL of a .zip file.</p>
|
||||
<StyledInput
|
||||
ref="urlInput"
|
||||
v-model="url"
|
||||
autofocus
|
||||
:disabled="submitted"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-protonpass-ignore="true"
|
||||
:placeholder="
|
||||
cf
|
||||
? 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
|
||||
: 'https://www.example.com/.../modpack-name-1.0.2.zip'
|
||||
"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
||||
</div>
|
||||
<BackupWarning :backup-link="`/hosting/manage/${serverId}/backups`" />
|
||||
<div class="flex justify-start gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button v-tooltip="error" :disabled="submitted || !!error" type="submit">
|
||||
<SpinnerIcon v-if="submitted" class="animate-spin" />
|
||||
<DownloadIcon v-else class="h-5 w-5" />
|
||||
{{ submitted ? 'Installing...' : 'Install' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ submitted ? 'Close' : 'Cancel' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, ExternalIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
BackupWarning,
|
||||
ButtonStyled,
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
} from '@modrinth/ui'
|
||||
import { ModrinthServersFetchError } from '@modrinth/utils'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { handleServersError } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
const notifications = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const { serverId } = injectModrinthServerContext()
|
||||
|
||||
const cf = ref(false)
|
||||
|
||||
const modal = ref<typeof NewModal>()
|
||||
const urlInput = ref<HTMLInputElement | null>(null)
|
||||
const url = ref('')
|
||||
const submitted = ref(false)
|
||||
|
||||
const trimmedUrl = computed(() => url.value.trim())
|
||||
|
||||
const regex = /https:\/\/(www\.)?curseforge\.com\/minecraft\/modpacks\/[^/]+\/files\/\d+/
|
||||
|
||||
const error = computed(() => {
|
||||
if (trimmedUrl.value.length === 0) {
|
||||
return 'URL is required.'
|
||||
}
|
||||
if (cf.value && !regex.test(trimmedUrl.value)) {
|
||||
return 'URL must be a CurseForge modpack version URL.'
|
||||
} else if (!cf.value && !trimmedUrl.value.includes('/')) {
|
||||
return 'URL must be valid.'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
submitted.value = true
|
||||
if (!error.value) {
|
||||
// hide();
|
||||
try {
|
||||
const dry = await client.kyros.files_v0.extractFile(trimmedUrl.value, true, true)
|
||||
|
||||
if (!cf.value || dry.modpack_name) {
|
||||
await client.kyros.files_v0.extractFile(trimmedUrl.value, true, false)
|
||||
hide()
|
||||
} else {
|
||||
submitted.value = false
|
||||
handleServersError(
|
||||
new ModrinthServersFetchError(
|
||||
'Could not find CurseForge modpack at that URL.',
|
||||
404,
|
||||
new Error(`No modpack found at ${url.value}`),
|
||||
),
|
||||
notifications,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
submitted.value = false
|
||||
console.error('Error installing:', error)
|
||||
handleServersError(error, notifications)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const show = (isCf: boolean) => {
|
||||
cf.value = isCf
|
||||
url.value = ''
|
||||
submitted.value = false
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
urlInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<div class="ticker-container">
|
||||
<div class="ticker-content">
|
||||
<div
|
||||
v-for="(message, index) in msgs"
|
||||
:key="message"
|
||||
class="ticker-item text-xs"
|
||||
:class="{ active: index === currentIndex % msgs.length }"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const msgs = [
|
||||
'Organizing files...',
|
||||
'Downloading mods...',
|
||||
'Configuring server...',
|
||||
'Setting up environment...',
|
||||
'Adding Java...',
|
||||
]
|
||||
|
||||
const currentIndex = ref(0)
|
||||
|
||||
let intervalId: NodeJS.Timeout | null = null
|
||||
|
||||
onMounted(() => {
|
||||
intervalId = setInterval(() => {
|
||||
currentIndex.value = (currentIndex.value + 1) % msgs.length
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ticker-container {
|
||||
height: 20px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ticker-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ticker-item {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-secondary-text);
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
filter: blur(4px);
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.ticker-item.active {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
</style>
|
||||
@@ -68,11 +68,7 @@
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="standard" color="brand">
|
||||
<button
|
||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||
:disabled="!canTakeAction"
|
||||
@click="handlePrimaryAction"
|
||||
>
|
||||
<button v-tooltip="busyReason" :disabled="!canTakeAction" @click="handlePrimaryAction">
|
||||
<div v-if="isTransitionState" class="grid place-content-center">
|
||||
<LoadingIcon />
|
||||
</div>
|
||||
@@ -120,20 +116,17 @@ import {
|
||||
UpdatedIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonStyled, Checkbox, NewModal, ServerInfoLabels, useVIntl } from '@modrinth/ui'
|
||||
import { ButtonStyled, Checkbox, NewModal, ServerInfoLabels } from '@modrinth/ui'
|
||||
import type { PowerAction as ServerPowerAction, ServerState } from '@modrinth/utils'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
|
||||
|
||||
import LoadingIcon from './icons/LoadingIcon.vue'
|
||||
import PanelSpinner from './PanelSpinner.vue'
|
||||
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
interface PowerAction {
|
||||
action: ServerPowerAction
|
||||
@@ -148,7 +141,7 @@ const props = defineProps<{
|
||||
serverName?: string
|
||||
serverData: object
|
||||
uptimeSeconds: number
|
||||
backupInProgress?: BackupInProgressReason
|
||||
busyReason?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -170,11 +163,7 @@ const dontAskAgain = ref(false)
|
||||
const startingDelay = ref(false)
|
||||
|
||||
const canTakeAction = computed(
|
||||
() =>
|
||||
!props.isActioning &&
|
||||
!startingDelay.value &&
|
||||
!isTransitionState.value &&
|
||||
!props.backupInProgress,
|
||||
() => !props.isActioning && !startingDelay.value && !isTransitionState.value && !props.busyReason,
|
||||
)
|
||||
const isRunning = computed(() => serverState.value === 'running')
|
||||
const isTransitionState = computed(() =>
|
||||
|
||||
@@ -64,15 +64,22 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, Combobox, injectNotificationManager, NewModal, Toggle } from '@modrinth/ui'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
Toggle,
|
||||
} from '@modrinth/ui'
|
||||
import { ModrinthServersFetchError } from '@modrinth/utils'
|
||||
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
const { serverId } = injectModrinthServerContext()
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
project: any
|
||||
versions: any[]
|
||||
currentVersion?: any
|
||||
@@ -98,11 +105,12 @@ const handleReinstall = async () => {
|
||||
try {
|
||||
const versionId = props.versions.find((v) => v.version_number === selectedVersion.value)?.id
|
||||
|
||||
await props.server.general.reinstall(
|
||||
false,
|
||||
props.project.id,
|
||||
versionId,
|
||||
undefined,
|
||||
await client.archon.servers_v0.reinstall(
|
||||
serverId,
|
||||
{
|
||||
project_id: props.project.id,
|
||||
version_id: versionId,
|
||||
},
|
||||
hardReset.value,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
<template>
|
||||
<NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow">
|
||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||
<AppearingProgressBar :max-value="totalBytes" :current-value="uploadedBytes" />
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-20"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-20"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="!isLoading" class="flex flex-col gap-4">
|
||||
<p
|
||||
v-if="isMrpackModalSecondPhase"
|
||||
:style="{
|
||||
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
|
||||
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
|
||||
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
|
||||
}"
|
||||
>
|
||||
This will reinstall your server and erase all data. You may want to back up your server
|
||||
before proceeding. Are you sure you want to continue?
|
||||
</p>
|
||||
<div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4">
|
||||
<div class="mx-auto flex flex-row items-center gap-4">
|
||||
<div
|
||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<UploadIcon class="size-10" />
|
||||
</div>
|
||||
<ArrowBigRightDashIcon class="size-10" />
|
||||
<div
|
||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
|
||||
>
|
||||
<ServerIcon class="size-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".mrpack"
|
||||
class=""
|
||||
:disabled="isLoading"
|
||||
@change="uploadMrpack"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
|
||||
Erase all data
|
||||
</label>
|
||||
<Toggle id="hard-reset" v-model="hardReset" class="shrink-0" />
|
||||
</div>
|
||||
<div>
|
||||
Removes all data on your server, including your worlds, mods, and configuration
|
||||
files, then reinstalls it with the selected version.
|
||||
</div>
|
||||
<div class="font-bold">
|
||||
This does not affect your backups, which are stored off-site.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BackupWarning :backup-link="`/hosting/manage/${props.server?.serverId}/backups`" />
|
||||
</div>
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||
<button
|
||||
v-tooltip="backupInProgress ? backupInProgress.tooltip : undefined"
|
||||
:disabled="canInstall || !!backupInProgress"
|
||||
@click="handleReinstall"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
{{
|
||||
isMrpackModalSecondPhase
|
||||
? 'Erase and install'
|
||||
: loadingServerCheck
|
||||
? 'Loading...'
|
||||
: isDangerous
|
||||
? 'Erase and install'
|
||||
: 'Install'
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
:disabled="isLoading"
|
||||
@click="
|
||||
() => {
|
||||
if (isMrpackModalSecondPhase) {
|
||||
isMrpackModalSecondPhase = false
|
||||
} else {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
{{ isMrpackModalSecondPhase ? 'Go back' : 'Cancel' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowBigRightDashIcon,
|
||||
RightArrowIcon,
|
||||
ServerIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
AppearingProgressBar,
|
||||
BackupWarning,
|
||||
ButtonStyled,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
Toggle,
|
||||
} from '@modrinth/ui'
|
||||
import { ModrinthServersFetchError } from '@modrinth/utils'
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers'
|
||||
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (isLoading.value) {
|
||||
event.preventDefault()
|
||||
return 'Upload in progress. Are you sure you want to leave?'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
backupInProgress?: BackupInProgressReason
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
reinstall: [any?]
|
||||
}>()
|
||||
|
||||
const mrpackModal = ref()
|
||||
const isMrpackModalSecondPhase = ref(false)
|
||||
const hardReset = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const loadingServerCheck = ref(false)
|
||||
const mrpackFile = ref<File | null>(null)
|
||||
const uploadedBytes = ref(0)
|
||||
const totalBytes = ref(0)
|
||||
|
||||
const isDangerous = computed(() => hardReset.value)
|
||||
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value)
|
||||
|
||||
const uploadMrpack = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (!target.files || target.files.length === 0) {
|
||||
return
|
||||
}
|
||||
mrpackFile.value = target.files[0]
|
||||
}
|
||||
|
||||
const handleReinstall = async () => {
|
||||
if (hardReset.value && !isMrpackModalSecondPhase.value) {
|
||||
isMrpackModalSecondPhase.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (!mrpackFile.value) {
|
||||
addNotification({
|
||||
title: 'No file selected',
|
||||
text: 'Choose a .mrpack file before installing.',
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
uploadedBytes.value = 0
|
||||
totalBytes.value = mrpackFile.value.size
|
||||
|
||||
const { onProgress, promise } = props.server.general.reinstallFromMrpack(
|
||||
mrpackFile.value,
|
||||
hardReset.value,
|
||||
)
|
||||
|
||||
onProgress(({ loaded, total }) => {
|
||||
uploadedBytes.value = loaded
|
||||
totalBytes.value = total
|
||||
})
|
||||
|
||||
try {
|
||||
await promise
|
||||
|
||||
emit('reinstall', {
|
||||
loader: 'mrpack',
|
||||
lVersion: '',
|
||||
mVersion: '',
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
window.scrollTo(0, 0)
|
||||
hide()
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
|
||||
addNotification({
|
||||
title: 'Cannot upload and install modpack to server',
|
||||
text: 'You are being rate limited. Please try again later.',
|
||||
type: 'error',
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
title: 'Modpack upload and install failed',
|
||||
text: 'An unexpected error occurred while uploading/installing. Please try again later.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
const onShow = () => {
|
||||
hardReset.value = false
|
||||
isMrpackModalSecondPhase.value = false
|
||||
loadingServerCheck.value = false
|
||||
isLoading.value = false
|
||||
mrpackFile.value = null
|
||||
uploadedBytes.value = 0
|
||||
totalBytes.value = 0
|
||||
}
|
||||
|
||||
const show = () => mrpackModal.value?.show()
|
||||
const hide = () => mrpackModal.value?.hide()
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -152,17 +152,14 @@
|
||||
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
|
||||
</div>
|
||||
|
||||
<BackupWarning
|
||||
v-if="!initialSetup"
|
||||
:backup-link="`/hosting/manage/${props.server?.serverId}/backups`"
|
||||
/>
|
||||
<BackupWarning v-if="!initialSetup" :backup-link="`/hosting/manage/${serverId}/backups`" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||
<button
|
||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||
:disabled="canInstall || !!backupInProgress"
|
||||
v-tooltip="busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined"
|
||||
:disabled="canInstall || busyReasons.length > 0"
|
||||
@click="handleReinstall"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
@@ -205,6 +202,8 @@ import {
|
||||
BackupWarning,
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
Toggle,
|
||||
@@ -213,12 +212,11 @@ import {
|
||||
import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
|
||||
import { $fetch } from 'ofetch'
|
||||
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
|
||||
|
||||
import LoaderIcon from './icons/LoaderIcon.vue'
|
||||
import LoadingIcon from './icons/LoadingIcon.vue'
|
||||
|
||||
const { server, serverId, busyReasons } = injectModrinthServerContext()
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -236,9 +234,7 @@ type VersionMap = Record<string, LoaderVersion[]>
|
||||
type VersionCache = Record<string, any>
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
currentLoader: Loaders | undefined
|
||||
backupInProgress?: BackupInProgressReason
|
||||
initialSetup?: boolean
|
||||
}>()
|
||||
|
||||
@@ -472,11 +468,14 @@ const handleReinstall = async () => {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
await props.server.general?.reinstall(
|
||||
true,
|
||||
selectedLoader.value,
|
||||
selectedMCVersion.value,
|
||||
selectedLoader.value === 'Vanilla' ? '' : selectedLoaderVersion.value,
|
||||
await client.archon.servers_v0.reinstall(
|
||||
serverId,
|
||||
{
|
||||
loader: selectedLoader.value,
|
||||
loader_version:
|
||||
selectedLoader.value === 'Vanilla' ? undefined : selectedLoaderVersion.value || undefined,
|
||||
game_version: selectedMCVersion.value,
|
||||
},
|
||||
props.initialSetup ? true : hardReset.value,
|
||||
)
|
||||
|
||||
@@ -507,7 +506,7 @@ const handleReinstall = async () => {
|
||||
}
|
||||
|
||||
const onShow = () => {
|
||||
selectedMCVersion.value = props.server.general?.mc_version || ''
|
||||
selectedMCVersion.value = server.value?.mc_version || ''
|
||||
if (isSnapshotSelected.value) {
|
||||
showSnapshots.value = true
|
||||
}
|
||||
@@ -530,7 +529,7 @@ const show = (loader: Loaders) => {
|
||||
selectedLoaderVersion.value = ''
|
||||
}
|
||||
selectedLoader.value = loader
|
||||
selectedMCVersion.value = props.server.general?.mc_version || ''
|
||||
selectedMCVersion.value = server.value?.mc_version || ''
|
||||
versionSelectModal.value?.show()
|
||||
}
|
||||
const hide = () => versionSelectModal.value?.hide()
|
||||
|
||||
@@ -30,9 +30,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
import { ButtonStyled, injectModrinthClient } from '@modrinth/ui'
|
||||
|
||||
const props = defineProps<{
|
||||
isUpdating: boolean
|
||||
@@ -40,12 +38,14 @@ const props = defineProps<{
|
||||
save: () => void
|
||||
reset: () => void
|
||||
isVisible: boolean
|
||||
server: ModrinthServer
|
||||
serverId: string
|
||||
}>()
|
||||
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const saveAndRestart = async () => {
|
||||
props.save()
|
||||
await props.server.general?.power('Restart')
|
||||
await client.archon.servers_v0.power(props.serverId, 'Restart')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
<template>
|
||||
<PlatformVersionSelectModal
|
||||
ref="versionSelectModal"
|
||||
:server="props.server"
|
||||
:current-loader="ignoreCurrentInstallation ? undefined : (data?.loader as Loaders)"
|
||||
:backup-in-progress="backupInProgress"
|
||||
:initial-setup="ignoreCurrentInstallation"
|
||||
@reinstall="emit('reinstall', $event)"
|
||||
/>
|
||||
|
||||
<PlatformMrpackModal
|
||||
ref="mrpackModal"
|
||||
:server="props.server"
|
||||
@reinstall="emit('reinstall', $event)"
|
||||
/>
|
||||
|
||||
<PlatformChangeModpackVersionModal
|
||||
ref="modpackVersionModal"
|
||||
:server="props.server"
|
||||
:project="data?.project"
|
||||
:versions="Array.isArray(versions) ? versions : []"
|
||||
:current-version="currentVersion"
|
||||
:current-version-id="data?.upstream?.version_id"
|
||||
:server-status="data?.status"
|
||||
@reinstall="emit('reinstall')"
|
||||
/>
|
||||
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div v-if="data && versions" class="flex w-full flex-col">
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="flex select-none flex-col items-center justify-between gap-2 lg:flex-row">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 text-lg font-bold text-contrast">Modpack</h2>
|
||||
<div
|
||||
v-if="updateAvailable"
|
||||
class="rounded-full bg-bg-orange px-2 py-1 text-xs font-medium text-orange"
|
||||
>
|
||||
<span>Update available</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="data.upstream" class="flex gap-4">
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="!w-full sm:!w-auto"
|
||||
:disabled="isInstalling"
|
||||
@click="mrpackModal.show()"
|
||||
>
|
||||
<UploadIcon class="size-4" /> Import .mrpack
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<!-- dumb hack to make a button link not a link -->
|
||||
<ButtonStyled>
|
||||
<template v-if="isInstalling">
|
||||
<button :disabled="isInstalling">
|
||||
<TransferIcon class="size-4" />
|
||||
Switch modpack
|
||||
</button>
|
||||
</template>
|
||||
<nuxt-link v-else :to="`/discover/modpacks?sid=${props.server.serverId}`">
|
||||
<TransferIcon class="size-4" />
|
||||
Switch modpack
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="data.upstream" class="flex flex-col gap-2">
|
||||
<div
|
||||
v-if="versionsError || currentVersionError"
|
||||
class="rounded-2xl border border-solid border-red p-4 text-contrast"
|
||||
>
|
||||
<p class="m-0 font-bold">Something went wrong while loading your modpack.</p>
|
||||
<p class="m-0 mb-2 mt-1 text-sm">
|
||||
{{ versionsError || currentVersionError }}
|
||||
</p>
|
||||
<ButtonStyled>
|
||||
<button :disabled="isInstalling" @click="refreshData">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<ProjectCard
|
||||
v-if="!versionsError && !currentVersionError"
|
||||
class="!bg-bg"
|
||||
:title="projectCardData.title"
|
||||
:icon-url="projectCardData.icon_url"
|
||||
:date-updated="projectCardData.date_modified"
|
||||
:followers="projectCardData.follows"
|
||||
:downloads="projectCardData.downloads"
|
||||
layout="list"
|
||||
:summary="projectCardData.description"
|
||||
:tags="data.project?.categories || []"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="isInstalling" @click="modpackVersionModal.show()">
|
||||
<SettingsIcon class="size-4" />
|
||||
Change version
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ProjectCard>
|
||||
</div>
|
||||
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
|
||||
<ButtonStyled>
|
||||
<nuxt-link
|
||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||
:class="{ disabled: backupInProgress }"
|
||||
class="!w-full sm:!w-auto"
|
||||
:to="`/discover/modpacks?sid=${props.server.serverId}`"
|
||||
>
|
||||
<CompassIcon class="size-4" /> Find a modpack
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<span class="hidden sm:block">or</span>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||
:disabled="!!backupInProgress"
|
||||
class="!w-full sm:!w-auto"
|
||||
@click="mrpackModal.show()"
|
||||
>
|
||||
<UploadIcon class="size-4" /> Upload .mrpack file
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="m-0 text-lg font-bold text-contrast">Platform</h2>
|
||||
<p class="m-0">Your server's platform is the software that runs mods and plugins.</p>
|
||||
<div v-if="data.upstream" class="mt-2 flex items-center gap-2">
|
||||
<InfoIcon class="hidden sm:block" />
|
||||
<span class="text-sm text-secondary">
|
||||
The current platform was automatically selected based on your modpack.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full flex-col gap-1 rounded-2xl"
|
||||
:class="{
|
||||
'pointer-events-none cursor-not-allowed select-none opacity-50':
|
||||
props.server.general?.status === 'installing',
|
||||
}"
|
||||
:tabindex="props.server.general?.status === 'installing' ? -1 : 0"
|
||||
>
|
||||
<LoaderSelector
|
||||
:data="
|
||||
ignoreCurrentInstallation
|
||||
? {
|
||||
loader: null,
|
||||
loader_version: null,
|
||||
}
|
||||
: data
|
||||
"
|
||||
:is-installing="isInstalling"
|
||||
@select-loader="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CompassIcon, InfoIcon, SettingsIcon, TransferIcon, UploadIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, ProjectCard, useVIntl } from '@modrinth/ui'
|
||||
import type { Loaders } from '@modrinth/utils'
|
||||
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
|
||||
|
||||
import LoaderSelector from './LoaderSelector.vue'
|
||||
import PlatformChangeModpackVersionModal from './PlatformChangeModpackVersionModal.vue'
|
||||
import PlatformMrpackModal from './PlatformMrpackModal.vue'
|
||||
import PlatformVersionSelectModal from './PlatformVersionSelectModal.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
ignoreCurrentInstallation?: boolean
|
||||
backupInProgress?: BackupInProgressReason
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
reinstall: [any?]
|
||||
}>()
|
||||
|
||||
const isInstalling = computed(() => props.server.general?.status === 'installing')
|
||||
|
||||
const versionSelectModal = ref()
|
||||
const mrpackModal = ref()
|
||||
const modpackVersionModal = ref()
|
||||
|
||||
const data = computed(() => props.server.general)
|
||||
|
||||
const {
|
||||
data: versions,
|
||||
error: versionsError,
|
||||
refresh: refreshVersions,
|
||||
} = await useAsyncData(
|
||||
`content-loader-versions-${data.value?.upstream?.project_id}`,
|
||||
async () => {
|
||||
if (!data.value?.upstream?.project_id) return []
|
||||
try {
|
||||
const result = await useBaseFetch(`project/${data.value.upstream.project_id}/version`)
|
||||
return result || []
|
||||
} catch (e) {
|
||||
console.error('couldnt fetch all versions:', e)
|
||||
throw new Error('Failed to load modpack versions.')
|
||||
}
|
||||
},
|
||||
{ default: () => [] },
|
||||
)
|
||||
|
||||
const {
|
||||
data: currentVersion,
|
||||
error: currentVersionError,
|
||||
refresh: refreshCurrentVersion,
|
||||
} = await useAsyncData(
|
||||
`content-loader-version-${data.value?.upstream?.version_id}`,
|
||||
async () => {
|
||||
if (!data.value?.upstream?.version_id) return null
|
||||
try {
|
||||
const result = await useBaseFetch(`version/${data.value.upstream.version_id}`)
|
||||
return result || null
|
||||
} catch (e) {
|
||||
console.error('couldnt fetch version:', e)
|
||||
throw new Error('Failed to load modpack version.')
|
||||
}
|
||||
},
|
||||
{ default: () => null },
|
||||
)
|
||||
|
||||
const projectCardData = computed(() => ({
|
||||
icon_url: data.value?.project?.icon_url,
|
||||
title: data.value?.project?.title,
|
||||
description: data.value?.project?.description,
|
||||
downloads: data.value?.project?.downloads,
|
||||
follows: data.value?.project?.followers,
|
||||
// @ts-ignore
|
||||
date_modified: currentVersion.value?.date_published || data.value?.project?.updated,
|
||||
}))
|
||||
|
||||
const selectLoader = (loader: string) => {
|
||||
versionSelectModal.value?.show(loader as Loaders)
|
||||
}
|
||||
|
||||
const refreshData = async () => {
|
||||
await Promise.all([refreshVersions(), refreshCurrentVersion()])
|
||||
}
|
||||
|
||||
const updateAvailable = computed(() => {
|
||||
// so sorry
|
||||
// @ts-ignore
|
||||
if (!data.value?.upstream || !versions.value?.length || !currentVersion.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const latestVersion = versions.value[0]
|
||||
// @ts-ignore
|
||||
return latestVersion.id !== currentVersion.value.id
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.server.general?.status,
|
||||
async (newStatus, oldStatus) => {
|
||||
if (oldStatus === 'installing' && newStatus === 'available') {
|
||||
await Promise.all([
|
||||
refreshVersions(),
|
||||
refreshCurrentVersion(),
|
||||
props.server.refresh(['general']),
|
||||
])
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.button-base:active {
|
||||
scale: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -24,12 +24,7 @@
|
||||
</div>
|
||||
|
||||
<div class="h-full w-full">
|
||||
<NuxtPage
|
||||
:route="route"
|
||||
:server="server"
|
||||
:backup-in-progress="backupInProgress"
|
||||
@reinstall="onReinstall"
|
||||
/>
|
||||
<NuxtPage :route="route" @reinstall="onReinstall" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -38,9 +33,6 @@
|
||||
import { RightArrowIcon } from '@modrinth/assets'
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
|
||||
|
||||
const emit = defineEmits(['reinstall'])
|
||||
|
||||
defineProps<{
|
||||
@@ -52,8 +44,6 @@ defineProps<{
|
||||
shown?: boolean
|
||||
}[]
|
||||
route: RouteLocationNormalized
|
||||
server: ModrinthServer
|
||||
backupInProgress?: BackupInProgressReason
|
||||
}>()
|
||||
|
||||
const onReinstall = (...args: any[]) => {
|
||||
|
||||
@@ -43,6 +43,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
hidePreviewBanner: false,
|
||||
i18nDebug: false,
|
||||
showDiscoverProjectButtons: false,
|
||||
useV1ContentTabAPI: true,
|
||||
labrinthApiCanary: false,
|
||||
} as const)
|
||||
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
import type { AbstractWebNotificationManager } from '@modrinth/ui'
|
||||
import type { JWTAuth, ModuleError, ModuleName } from '@modrinth/utils'
|
||||
import { ModrinthServerError } from '@modrinth/utils'
|
||||
|
||||
import { ContentModule, GeneralModule, NetworkModule, StartupModule } from './modules/index.ts'
|
||||
import { useServersFetch } from './servers-fetch.ts'
|
||||
|
||||
export function handleServersError(err: any, notifications: AbstractWebNotificationManager) {
|
||||
if (err instanceof ModrinthServerError && err.v1Error) {
|
||||
notifications.addNotification({
|
||||
title: err.v1Error?.context ?? `An error occurred`,
|
||||
type: 'error',
|
||||
text: err.v1Error.description,
|
||||
errorCode: err.v1Error.error,
|
||||
})
|
||||
} else {
|
||||
notifications.addNotification({
|
||||
title: 'An error occurred',
|
||||
type: 'error',
|
||||
text: err.message ?? (err.data ? err.data.description : err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class ModrinthServer {
|
||||
readonly serverId: string
|
||||
private errors: Partial<Record<ModuleName, ModuleError>> = {}
|
||||
|
||||
readonly general: GeneralModule
|
||||
readonly content: ContentModule
|
||||
readonly network: NetworkModule
|
||||
readonly startup: StartupModule
|
||||
|
||||
constructor(serverId: string) {
|
||||
this.serverId = serverId
|
||||
|
||||
this.general = new GeneralModule(this)
|
||||
this.content = new ContentModule(this)
|
||||
this.network = new NetworkModule(this)
|
||||
this.startup = new StartupModule(this)
|
||||
}
|
||||
|
||||
async fetchConfigFile(fileName: string): Promise<any> {
|
||||
return await useServersFetch(`servers/${this.serverId}/config/${fileName}`)
|
||||
}
|
||||
|
||||
constructServerProperties(properties: any): string {
|
||||
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`
|
||||
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
if (typeof value === 'object') {
|
||||
fileContent += `${key}=${JSON.stringify(value)}\n`
|
||||
} else if (typeof value === 'boolean') {
|
||||
fileContent += `${key}=${value ? 'true' : 'false'}\n`
|
||||
} else {
|
||||
fileContent += `${key}=${value}\n`
|
||||
}
|
||||
}
|
||||
|
||||
return fileContent
|
||||
}
|
||||
|
||||
async processImage(iconUrl: string | undefined): Promise<string | undefined> {
|
||||
const sharedImage = useState<string | undefined>(`server-icon-${this.serverId}`)
|
||||
|
||||
if (sharedImage.value) {
|
||||
return sharedImage.value
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`)
|
||||
try {
|
||||
const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, {
|
||||
override: auth,
|
||||
retry: 1, // Reduce retries for optional resources
|
||||
})
|
||||
|
||||
if (fileData instanceof Blob && import.meta.client) {
|
||||
const dataURL = await new Promise<string>((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
canvas.width = 512
|
||||
canvas.height = 512
|
||||
ctx?.drawImage(img, 0, 0, 512, 512)
|
||||
const dataURL = canvas.toDataURL('image/png')
|
||||
sharedImage.value = dataURL
|
||||
resolve(dataURL)
|
||||
URL.revokeObjectURL(img.src)
|
||||
}
|
||||
img.src = URL.createObjectURL(fileData)
|
||||
})
|
||||
return dataURL
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServerError) {
|
||||
if (error.statusCode && error.statusCode >= 500) {
|
||||
console.debug('Service unavailable, skipping icon processing')
|
||||
sharedImage.value = undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (error.statusCode === 404 && iconUrl) {
|
||||
try {
|
||||
const response = await fetch(iconUrl)
|
||||
if (!response.ok) throw new Error('Failed to fetch icon')
|
||||
const file = await response.blob()
|
||||
const originalFile = new File([file], 'server-icon-original.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
|
||||
if (import.meta.client) {
|
||||
const dataURL = await new Promise<string>((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
canvas.width = 64
|
||||
canvas.height = 64
|
||||
ctx?.drawImage(img, 0, 0, 64, 64)
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (blob) {
|
||||
const scaledFile = new File([blob], 'server-icon.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
await useServersFetch(`/create?path=/server-icon.png&type=file`, {
|
||||
method: 'POST',
|
||||
contentType: 'application/octet-stream',
|
||||
body: scaledFile,
|
||||
override: auth,
|
||||
})
|
||||
await useServersFetch(`/create?path=/server-icon-original.png&type=file`, {
|
||||
method: 'POST',
|
||||
contentType: 'application/octet-stream',
|
||||
body: originalFile,
|
||||
override: auth,
|
||||
})
|
||||
}
|
||||
}, 'image/png')
|
||||
const dataURL = canvas.toDataURL('image/png')
|
||||
sharedImage.value = dataURL
|
||||
resolve(dataURL)
|
||||
URL.revokeObjectURL(img.src)
|
||||
}
|
||||
img.src = URL.createObjectURL(file)
|
||||
})
|
||||
return dataURL
|
||||
}
|
||||
} catch (externalError: any) {
|
||||
console.debug('Could not process external icon:', externalError.message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.debug('Icon processing failed:', error.message)
|
||||
}
|
||||
|
||||
sharedImage.value = undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
async testNodeReachability(): Promise<boolean> {
|
||||
if (!this.general?.node?.instance) {
|
||||
console.warn('No node instance available for ping test')
|
||||
return false
|
||||
}
|
||||
|
||||
const wsUrl = `wss://${this.general.node.instance}/pingtest`
|
||||
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
const socket = new WebSocket(wsUrl)
|
||||
const timeout = setTimeout(() => {
|
||||
socket.close()
|
||||
resolve(false)
|
||||
}, 5000)
|
||||
|
||||
socket.onopen = () => {
|
||||
clearTimeout(timeout)
|
||||
socket.send(performance.now().toString())
|
||||
}
|
||||
|
||||
socket.onmessage = () => {
|
||||
clearTimeout(timeout)
|
||||
socket.close()
|
||||
resolve(true)
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Failed to ping node ${wsUrl}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(
|
||||
modules: ModuleName[] = [],
|
||||
options?: {
|
||||
preserveConnection?: boolean
|
||||
preserveInstallState?: boolean
|
||||
},
|
||||
): Promise<void> {
|
||||
const modulesToRefresh =
|
||||
modules.length > 0 ? modules : (['general', 'content', 'network', 'startup'] as ModuleName[])
|
||||
|
||||
for (const module of modulesToRefresh) {
|
||||
this.errors[module] = undefined
|
||||
|
||||
try {
|
||||
switch (module) {
|
||||
case 'general': {
|
||||
if (options?.preserveConnection) {
|
||||
const currentImage = this.general.image
|
||||
const currentMotd = this.general.motd
|
||||
const currentStatus = this.general.status
|
||||
|
||||
await this.general.fetch()
|
||||
|
||||
if (currentImage) {
|
||||
this.general.image = currentImage
|
||||
}
|
||||
if (currentMotd) {
|
||||
this.general.motd = currentMotd
|
||||
}
|
||||
if (options.preserveInstallState && currentStatus === 'installing') {
|
||||
this.general.status = 'installing'
|
||||
}
|
||||
} else {
|
||||
await this.general.fetch()
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'content':
|
||||
await this.content.fetch()
|
||||
break
|
||||
case 'network':
|
||||
await this.network.fetch()
|
||||
break
|
||||
case 'startup':
|
||||
await this.startup.fetch()
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServerError) {
|
||||
if (error.statusCode === 404 && module === 'content') {
|
||||
console.debug(`Optional ${module} resource not found:`, error.message)
|
||||
continue
|
||||
}
|
||||
|
||||
if (error.statusCode && error.statusCode >= 500) {
|
||||
console.debug(`Temporary ${module} unavailable:`, error.message)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
this.errors[module] = {
|
||||
error:
|
||||
error instanceof ModrinthServerError
|
||||
? error
|
||||
: new ModrinthServerError('Unknown error', undefined, error as Error),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get moduleErrors() {
|
||||
return this.errors
|
||||
}
|
||||
}
|
||||
|
||||
export const useModrinthServers = async (
|
||||
serverId: string,
|
||||
includedModules: ModuleName[] = ['general'],
|
||||
) => {
|
||||
const server = new ModrinthServer(serverId)
|
||||
await server.refresh(includedModules)
|
||||
return reactive(server)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import type { AutoBackupSettings, Backup } from '@modrinth/utils'
|
||||
|
||||
import { useServersFetch } from '../servers-fetch.ts'
|
||||
import { ServerModule } from './base.ts'
|
||||
|
||||
export class BackupsModule extends ServerModule {
|
||||
data: Backup[] = []
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
this.data = await useServersFetch<Backup[]>(`servers/${this.serverId}/backups`, {}, 'backups')
|
||||
}
|
||||
|
||||
async create(backupName: string): Promise<string> {
|
||||
const tempId = `temp-${Date.now()}-${Math.random().toString(36).substring(7)}`
|
||||
const tempBackup: Backup = {
|
||||
id: tempId,
|
||||
name: backupName,
|
||||
created_at: new Date().toISOString(),
|
||||
locked: false,
|
||||
automated: false,
|
||||
interrupted: false,
|
||||
ongoing: true,
|
||||
task: { create: { progress: 0, state: 'ongoing' } },
|
||||
}
|
||||
this.data.push(tempBackup)
|
||||
|
||||
try {
|
||||
const response = await useServersFetch<{ id: string }>(`servers/${this.serverId}/backups`, {
|
||||
method: 'POST',
|
||||
body: { name: backupName },
|
||||
})
|
||||
|
||||
const backup = this.data.find((b) => b.id === tempId)
|
||||
if (backup) {
|
||||
backup.id = response.id
|
||||
}
|
||||
|
||||
return response.id
|
||||
} catch (error) {
|
||||
this.data = this.data.filter((b) => b.id !== tempId)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async rename(backupId: string, newName: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/rename`, {
|
||||
method: 'POST',
|
||||
body: { name: newName },
|
||||
})
|
||||
await this.fetch()
|
||||
}
|
||||
|
||||
async delete(backupId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await this.fetch()
|
||||
}
|
||||
|
||||
async restore(backupId: string): Promise<void> {
|
||||
const backup = this.data.find((b) => b.id === backupId)
|
||||
if (backup) {
|
||||
if (!backup.task) backup.task = {}
|
||||
backup.task.restore = { progress: 0, state: 'ongoing' }
|
||||
}
|
||||
|
||||
try {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/restore`, {
|
||||
method: 'POST',
|
||||
})
|
||||
} catch (error) {
|
||||
if (backup?.task?.restore) {
|
||||
delete backup.task.restore
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async lock(backupId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/lock`, {
|
||||
method: 'POST',
|
||||
})
|
||||
await this.fetch()
|
||||
}
|
||||
|
||||
async unlock(backupId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/unlock`, {
|
||||
method: 'POST',
|
||||
})
|
||||
await this.fetch()
|
||||
}
|
||||
|
||||
async retry(backupId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/retry`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async updateAutoBackup(autoBackup: 'enable' | 'disable', interval: number): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/autobackup`, {
|
||||
method: 'POST',
|
||||
body: { set: autoBackup, interval },
|
||||
})
|
||||
}
|
||||
|
||||
async getAutoBackup(): Promise<AutoBackupSettings> {
|
||||
return await useServersFetch(`servers/${this.serverId}/autobackup`)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ModrinthServer } from '../modrinth-servers.ts'
|
||||
|
||||
export abstract class ServerModule {
|
||||
protected server: ModrinthServer
|
||||
|
||||
constructor(server: ModrinthServer) {
|
||||
this.server = server
|
||||
}
|
||||
|
||||
protected get serverId(): string {
|
||||
return this.server.serverId
|
||||
}
|
||||
|
||||
abstract fetch(): Promise<void>
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { ContentType, Mod } from '@modrinth/utils'
|
||||
|
||||
import { useServersFetch } from '../servers-fetch.ts'
|
||||
import { ServerModule } from './base.ts'
|
||||
|
||||
export class ContentModule extends ServerModule {
|
||||
data: Mod[] = []
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
const mods = await useServersFetch<Mod[]>(`servers/${this.serverId}/mods`, {}, 'content')
|
||||
this.data = mods.sort((a, b) => (a?.name ?? '').localeCompare(b?.name ?? ''))
|
||||
}
|
||||
|
||||
async install(contentType: ContentType, projectId: string, versionId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/mods`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
rinth_ids: { project_id: projectId, version_id: versionId },
|
||||
install_as: contentType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async remove(path: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/deleteMod`, {
|
||||
method: 'POST',
|
||||
body: { path },
|
||||
})
|
||||
}
|
||||
|
||||
async reinstall(replace: string, projectId: string, versionId: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/mods/update`, {
|
||||
method: 'POST',
|
||||
body: { replace, project_id: projectId, version_id: versionId },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import type { JWTAuth, PowerAction, Project, ServerGeneral } from '@modrinth/utils'
|
||||
import { $fetch } from 'ofetch'
|
||||
|
||||
import { useServersFetch } from '../servers-fetch.ts'
|
||||
import { ServerModule } from './base.ts'
|
||||
|
||||
export class GeneralModule extends ServerModule implements ServerGeneral {
|
||||
server_id!: string
|
||||
name!: string
|
||||
owner_id!: string
|
||||
net!: { ip: string; port: number; domain: string }
|
||||
game!: string
|
||||
backup_quota!: number
|
||||
used_backup_quota!: number
|
||||
status!: string
|
||||
suspension_reason!: string
|
||||
loader!: string
|
||||
loader_version!: string
|
||||
mc_version!: string
|
||||
upstream!: {
|
||||
kind: 'modpack' | 'mod' | 'resourcepack'
|
||||
version_id: string
|
||||
project_id: string
|
||||
} | null
|
||||
|
||||
motd?: string
|
||||
image?: string
|
||||
project?: Project
|
||||
sftp_username!: string
|
||||
sftp_password!: string
|
||||
sftp_host!: string
|
||||
datacenter?: string
|
||||
notices?: any[]
|
||||
node!: { token: string; instance: string }
|
||||
flows?: { intro?: boolean }
|
||||
|
||||
is_medal?: boolean
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
const data = await useServersFetch<ServerGeneral>(`servers/${this.serverId}`, {}, 'general')
|
||||
|
||||
if (data.upstream?.project_id) {
|
||||
const project = await $fetch(
|
||||
`https://api.modrinth.com/v2/project/${data.upstream.project_id}`,
|
||||
)
|
||||
data.project = project as Project
|
||||
}
|
||||
|
||||
if (import.meta.client) {
|
||||
data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined
|
||||
}
|
||||
|
||||
// Copy data to this module
|
||||
Object.assign(this, data)
|
||||
}
|
||||
|
||||
async updateName(newName: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/name`, {
|
||||
method: 'POST',
|
||||
body: { name: newName },
|
||||
})
|
||||
}
|
||||
|
||||
async power(action: PowerAction): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/power`, {
|
||||
method: 'POST',
|
||||
body: { action },
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
await this.fetch() // Refresh this module
|
||||
}
|
||||
|
||||
async reinstall(
|
||||
loader: boolean,
|
||||
projectId: string,
|
||||
versionId?: string,
|
||||
loaderVersionId?: string,
|
||||
hardReset: boolean = false,
|
||||
): Promise<void> {
|
||||
const hardResetParam = hardReset ? 'true' : 'false'
|
||||
if (loader) {
|
||||
if (projectId.toLowerCase() === 'neoforge') {
|
||||
projectId = 'NeoForge'
|
||||
}
|
||||
await useServersFetch(`servers/${this.serverId}/reinstall?hard=${hardResetParam}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
loader: projectId,
|
||||
loader_version: loaderVersionId,
|
||||
game_version: versionId,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await useServersFetch(`servers/${this.serverId}/reinstall?hard=${hardResetParam}`, {
|
||||
method: 'POST',
|
||||
body: { project_id: projectId, version_id: versionId },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
reinstallFromMrpack(
|
||||
mrpack: File,
|
||||
hardReset: boolean = false,
|
||||
): {
|
||||
promise: Promise<void>
|
||||
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => void
|
||||
} {
|
||||
const hardResetParam = hardReset ? 'true' : 'false'
|
||||
|
||||
const progressSubject = new EventTarget()
|
||||
|
||||
const uploadPromise = (async () => {
|
||||
try {
|
||||
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/reinstallFromMrpack`)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
progressSubject.dispatchEvent(
|
||||
new CustomEvent('progress', {
|
||||
detail: {
|
||||
loaded: e.loaded,
|
||||
total: e.total,
|
||||
progress: (e.loaded / e.total) * 100,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.onload = () =>
|
||||
xhr.status >= 200 && xhr.status < 300
|
||||
? resolve()
|
||||
: reject(new Error(`[pyroservers] XHR error status: ${xhr.status}`))
|
||||
|
||||
xhr.onerror = () => reject(new Error('[pyroservers] .mrpack upload failed'))
|
||||
xhr.onabort = () => reject(new Error('[pyroservers] .mrpack upload cancelled'))
|
||||
xhr.ontimeout = () => reject(new Error('[pyroservers] .mrpack upload timed out'))
|
||||
xhr.timeout = 30 * 60 * 1000
|
||||
|
||||
xhr.open('POST', `https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${auth.token}`)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', mrpack)
|
||||
xhr.send(formData)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error reinstalling from mrpack:', err)
|
||||
throw err
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
promise: uploadPromise,
|
||||
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) =>
|
||||
progressSubject.addEventListener('progress', ((e: CustomEvent) =>
|
||||
cb(e.detail)) as EventListener),
|
||||
}
|
||||
}
|
||||
|
||||
async suspend(status: boolean): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/suspend`, {
|
||||
method: 'POST',
|
||||
body: { suspended: status },
|
||||
})
|
||||
}
|
||||
|
||||
async endIntro(): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/flows/intro`, {
|
||||
method: 'DELETE',
|
||||
version: 1,
|
||||
})
|
||||
await this.fetch() // Refresh this module
|
||||
}
|
||||
|
||||
async setMotd(motd: string): Promise<void> {
|
||||
try {
|
||||
const props = (await this.server.fetchConfigFile('ServerProperties')) as any
|
||||
if (props) {
|
||||
props.motd = motd
|
||||
const newProps = this.server.constructServerProperties(props)
|
||||
const octetStream = new Blob([newProps], { type: 'application/octet-stream' })
|
||||
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`)
|
||||
|
||||
await useServersFetch(`/update?path=/server.properties`, {
|
||||
method: 'PUT',
|
||||
contentType: 'application/octet-stream',
|
||||
body: octetStream,
|
||||
override: auth,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
console.error(
|
||||
'[Modrinth Hosting] [General] Failed to set MOTD due to lack of server properties file.',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export * from './backups.ts'
|
||||
export * from './base.ts'
|
||||
export * from './content.ts'
|
||||
export * from './general.ts'
|
||||
export * from './network.ts'
|
||||
export * from './startup.ts'
|
||||
export * from './ws.ts'
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Allocation } from '@modrinth/utils'
|
||||
|
||||
import { useServersFetch } from '../servers-fetch.ts'
|
||||
import { ServerModule } from './base.ts'
|
||||
|
||||
export class NetworkModule extends ServerModule {
|
||||
allocations: Allocation[] = []
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
this.allocations = await useServersFetch<Allocation[]>(
|
||||
`servers/${this.serverId}/allocations`,
|
||||
{},
|
||||
'network',
|
||||
)
|
||||
}
|
||||
|
||||
async reserveAllocation(name: string): Promise<Allocation> {
|
||||
return await useServersFetch<Allocation>(`servers/${this.serverId}/allocations?name=${name}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async updateAllocation(port: number, name: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/allocations/${port}?name=${name}`, {
|
||||
method: 'PUT',
|
||||
})
|
||||
}
|
||||
|
||||
async deleteAllocation(port: number): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/allocations/${port}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
async checkSubdomainAvailability(subdomain: string): Promise<boolean> {
|
||||
const result = (await useServersFetch(`subdomains/${subdomain}/isavailable`)) as {
|
||||
available: boolean
|
||||
}
|
||||
return result.available
|
||||
}
|
||||
|
||||
async changeSubdomain(subdomain: string): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/subdomain`, {
|
||||
method: 'POST',
|
||||
body: { subdomain },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { JDKBuild, JDKVersion, Startup } from '@modrinth/utils'
|
||||
|
||||
import { useServersFetch } from '../servers-fetch.ts'
|
||||
import { ServerModule } from './base.ts'
|
||||
|
||||
export class StartupModule extends ServerModule implements Startup {
|
||||
invocation!: string
|
||||
original_invocation!: string
|
||||
jdk_version!: JDKVersion
|
||||
jdk_build!: JDKBuild
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
const data = await useServersFetch<Startup>(`servers/${this.serverId}/startup`, {}, 'startup')
|
||||
Object.assign(this, data)
|
||||
}
|
||||
|
||||
async update(invocation: string, jdkVersion: JDKVersion, jdkBuild: JDKBuild): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/startup`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
invocation: invocation || null,
|
||||
jdk_version: jdkVersion || null,
|
||||
jdk_build: jdkBuild || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { JWTAuth } from '@modrinth/utils'
|
||||
|
||||
import { useServersFetch } from '../servers-fetch.ts'
|
||||
import { ServerModule } from './base.ts'
|
||||
|
||||
export class WSModule extends ServerModule implements JWTAuth {
|
||||
url!: string
|
||||
token!: string
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
const data = await useServersFetch<JWTAuth>(`servers/${this.serverId}/ws`, {}, 'ws')
|
||||
Object.assign(this, data)
|
||||
}
|
||||
}
|
||||
131
apps/frontend/src/composables/servers/use-server-image.ts
Normal file
131
apps/frontend/src/composables/servers/use-server-image.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { injectModrinthClient } from '@modrinth/ui'
|
||||
import { type ComputedRef, ref, watch } from 'vue'
|
||||
|
||||
// TODO: Remove and use V1 when available
|
||||
export function useServerImage(
|
||||
serverId: string,
|
||||
upstream: ComputedRef<Archon.Servers.v0.Server['upstream'] | null>,
|
||||
) {
|
||||
const client = injectModrinthClient()
|
||||
const image = ref<string | undefined>()
|
||||
|
||||
const sharedImage = useState<string | undefined>(`server-icon-${serverId}`)
|
||||
if (sharedImage.value) {
|
||||
image.value = sharedImage.value
|
||||
}
|
||||
|
||||
async function loadImage() {
|
||||
if (sharedImage.value) {
|
||||
image.value = sharedImage.value
|
||||
return
|
||||
}
|
||||
|
||||
if (import.meta.server) return
|
||||
|
||||
const cached = localStorage.getItem(`server-icon-${serverId}`)
|
||||
if (cached) {
|
||||
sharedImage.value = cached
|
||||
image.value = cached
|
||||
return
|
||||
}
|
||||
|
||||
let projectIconUrl: string | undefined
|
||||
const upstreamVal = upstream.value
|
||||
if (upstreamVal?.project_id) {
|
||||
try {
|
||||
const project = await $fetch<{ icon_url?: string }>(
|
||||
`https://api.modrinth.com/v2/project/${upstreamVal.project_id}`,
|
||||
)
|
||||
projectIconUrl = project.icon_url
|
||||
} catch {
|
||||
// project fetch failed, continue without icon url
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fileData = await client.kyros.files_v0.downloadFile('/server-icon-original.png')
|
||||
|
||||
if (fileData instanceof Blob) {
|
||||
const dataURL = await resizeImage(fileData, 512)
|
||||
sharedImage.value = dataURL
|
||||
localStorage.setItem(`server-icon-${serverId}`, dataURL)
|
||||
image.value = dataURL
|
||||
return
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode >= 500) {
|
||||
image.value = undefined
|
||||
return
|
||||
}
|
||||
|
||||
if (error?.statusCode === 404 && projectIconUrl) {
|
||||
try {
|
||||
const response = await fetch(projectIconUrl)
|
||||
if (!response.ok) throw new Error('Failed to fetch icon')
|
||||
const file = await response.blob()
|
||||
const originalFile = new File([file], 'server-icon-original.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
|
||||
const dataURL = await new Promise<string>((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
canvas.width = 64
|
||||
canvas.height = 64
|
||||
ctx?.drawImage(img, 0, 0, 64, 64)
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (blob) {
|
||||
const scaledFile = new File([blob], 'server-icon.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
client.kyros.files_v0
|
||||
.uploadFile('/server-icon.png', scaledFile)
|
||||
.promise.catch(() => {})
|
||||
client.kyros.files_v0
|
||||
.uploadFile('/server-icon-original.png', originalFile)
|
||||
.promise.catch(() => {})
|
||||
}
|
||||
}, 'image/png')
|
||||
const result = canvas.toDataURL('image/png')
|
||||
sharedImage.value = result
|
||||
localStorage.setItem(`server-icon-${serverId}`, result)
|
||||
resolve(result)
|
||||
URL.revokeObjectURL(img.src)
|
||||
}
|
||||
img.src = URL.createObjectURL(file)
|
||||
})
|
||||
image.value = dataURL
|
||||
return
|
||||
} catch (externalError: any) {
|
||||
console.debug('Could not process external icon:', externalError.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
image.value = undefined
|
||||
}
|
||||
|
||||
watch(upstream, () => loadImage(), { immediate: true })
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
function resizeImage(blob: Blob, size: number): Promise<string> {
|
||||
return new Promise<string>((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
ctx?.drawImage(img, 0, 0, size, size)
|
||||
const dataURL = canvas.toDataURL('image/png')
|
||||
resolve(dataURL)
|
||||
URL.revokeObjectURL(img.src)
|
||||
}
|
||||
img.src = URL.createObjectURL(blob)
|
||||
})
|
||||
}
|
||||
17
apps/frontend/src/composables/servers/use-server-project.ts
Normal file
17
apps/frontend/src/composables/servers/use-server-project.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import type { Project } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { $fetch } from 'ofetch'
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
|
||||
// TODO: Remove and use v1
|
||||
export function useServerProject(
|
||||
upstream: ComputedRef<Archon.Servers.v0.Server['upstream'] | null>,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: computed(() => ['servers', 'project', upstream.value?.project_id ?? null]),
|
||||
queryFn: () =>
|
||||
$fetch<Project>(`https://api.modrinth.com/v2/project/${upstream.value!.project_id}`),
|
||||
enabled: computed(() => !!upstream.value?.project_id),
|
||||
})
|
||||
}
|
||||
@@ -1304,6 +1304,39 @@
|
||||
"hosting-marketing.why.your-favorite-mods.description": {
|
||||
"message": "Choose between Vanilla, Fabric, Forge, Quilt and NeoForge. If it's on Modrinth, it can run on your server."
|
||||
},
|
||||
"hosting.loader.failed-to-change-version": {
|
||||
"message": "Failed to change modpack version"
|
||||
},
|
||||
"hosting.loader.failed-to-load-versions": {
|
||||
"message": "Failed to load versions"
|
||||
},
|
||||
"hosting.loader.failed-to-reinstall": {
|
||||
"message": "Failed to reinstall modpack"
|
||||
},
|
||||
"hosting.loader.failed-to-repair": {
|
||||
"message": "Failed to repair server"
|
||||
},
|
||||
"hosting.loader.failed-to-save-settings": {
|
||||
"message": "Failed to save installation settings"
|
||||
},
|
||||
"hosting.loader.failed-to-unlink": {
|
||||
"message": "Failed to unlink modpack"
|
||||
},
|
||||
"hosting.loader.loader-version": {
|
||||
"message": "{loader, select, null {Loader} other {{loader}}} version"
|
||||
},
|
||||
"hosting.loader.repair-started-text": {
|
||||
"message": "Your server installation has been repaired."
|
||||
},
|
||||
"hosting.loader.repair-started-title": {
|
||||
"message": "Repair completed"
|
||||
},
|
||||
"hosting.loader.reset-server": {
|
||||
"message": "Reset server"
|
||||
},
|
||||
"hosting.loader.reset-server-description": {
|
||||
"message": "Removes all data on your server, including your worlds, mods, and configuration files. Backups will remain and can be restored."
|
||||
},
|
||||
"hosting.plan.out-of-stock": {
|
||||
"message": "Out of stock"
|
||||
},
|
||||
@@ -2783,9 +2816,18 @@
|
||||
"search.filter.locked.server.sync": {
|
||||
"message": "Sync with server"
|
||||
},
|
||||
"servers.backup.restore.in-progress.tooltip": {
|
||||
"servers.busy.backup-creating": {
|
||||
"message": "Backup creation in progress"
|
||||
},
|
||||
"servers.busy.backup-restoring": {
|
||||
"message": "Backup restore in progress"
|
||||
},
|
||||
"servers.busy.installing": {
|
||||
"message": "Server is installing"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"message": "Content sync in progress"
|
||||
},
|
||||
"servers.notice.actions": {
|
||||
"message": "Actions"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
title="Are you sure you want to remove this project from the organization?"
|
||||
description="If you proceed, this project will no longer be managed by the organization."
|
||||
proceed-label="Remove"
|
||||
:noblur="!(cosmetics?.advancedRendering ?? true)"
|
||||
@proceed="onRemoveFromOrg"
|
||||
/>
|
||||
<Card>
|
||||
@@ -568,7 +567,6 @@ const {
|
||||
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
|
||||
const cosmetics = useCosmetics()
|
||||
const auth = await useAuth()
|
||||
|
||||
const allTeamMembers = ref([])
|
||||
|
||||
@@ -350,24 +350,16 @@
|
||||
</template>
|
||||
</ProjectCard>
|
||||
</ProjectCardList>
|
||||
<div v-else>
|
||||
<div class="mx-auto flex flex-col justify-center gap-8 p-6 text-center">
|
||||
<EmptyIllustration class="h-[120px] w-auto" />
|
||||
<div class="-mt-4 flex flex-col gap-4">
|
||||
<div class="flex flex-col items-center gap-1.5">
|
||||
<span class="text-lg text-contrast md:text-2xl">{{
|
||||
formatMessage(messages.noProjectsLabel)
|
||||
}}</span>
|
||||
</div>
|
||||
<ButtonStyled v-if="auth.user && auth.user.id === creator.id" color="brand">
|
||||
<nuxt-link class="mx-auto w-min" to="/discover/mods">
|
||||
<CompassIcon class="size-5" />
|
||||
Discover mods
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-else type="empty-inbox" :heading="formatMessage(messages.noProjectsLabel)">
|
||||
<template #actions>
|
||||
<ButtonStyled v-if="auth.user && auth.user.id === creator.id" color="brand">
|
||||
<nuxt-link class="mx-auto w-min" to="/discover/mods">
|
||||
<CompassIcon class="size-5" />
|
||||
Discover mods
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</NormalPage>
|
||||
</template>
|
||||
|
||||
@@ -377,7 +369,6 @@ import {
|
||||
ChevronLeftIcon,
|
||||
CompassIcon,
|
||||
EditIcon,
|
||||
EmptyIllustration,
|
||||
GlobeIcon,
|
||||
HeartMinusIcon,
|
||||
LinkIcon,
|
||||
@@ -398,6 +389,7 @@ import {
|
||||
ConfirmModal,
|
||||
defineMessage,
|
||||
defineMessages,
|
||||
EmptyState,
|
||||
FileInput,
|
||||
HorizontalRule,
|
||||
injectModrinthClient,
|
||||
|
||||
@@ -72,14 +72,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mx-auto flex flex-col justify-center p-6 text-center">
|
||||
<span class="text-lg text-contrast md:text-xl">{{
|
||||
formatMessage(messages.noTransactions)
|
||||
}}</span>
|
||||
<span class="max-w-[256px] text-base text-secondary md:text-lg">{{
|
||||
formatMessage(messages.noTransactionsDesc)
|
||||
}}</span>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else
|
||||
:heading="formatMessage(messages.noTransactions)"
|
||||
:description="formatMessage(messages.noTransactionsDesc)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
@@ -94,6 +91,7 @@ import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
defineMessages,
|
||||
EmptyState,
|
||||
useFormatDateTime,
|
||||
useFormatMoney,
|
||||
useVIntl,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
BookmarkIcon,
|
||||
CheckIcon,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
InfoIcon,
|
||||
LeftArrowIcon,
|
||||
ListIcon,
|
||||
MinecraftServerIcon,
|
||||
MoreVerticalIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
type CreationFlowContextValue,
|
||||
CreationFlowModal,
|
||||
defineMessages,
|
||||
DropdownSelect,
|
||||
injectModrinthClient,
|
||||
@@ -31,30 +34,31 @@ import {
|
||||
SearchSidebarFilter,
|
||||
type SortType,
|
||||
StyledInput,
|
||||
Toggle,
|
||||
useDebugLogger,
|
||||
useSearch,
|
||||
useServerSearch,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { capitalizeString, cycleValue } from '@modrinth/utils'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useThrottleFn, useTimeoutFn } from '@vueuse/core'
|
||||
import { computed, type Reactive, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
|
||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
import { projectQueryOptions } from '~/composables/queries/project'
|
||||
import { versionQueryOptions } from '~/composables/queries/version'
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
|
||||
import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const debug = useDebugLogger('Discover')
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const filtersMenuOpen = ref(false)
|
||||
|
||||
const route = useNativeRoute()
|
||||
const router = useNativeRouter()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const cosmetics = useCosmetics()
|
||||
const tags = useGeneratedState()
|
||||
@@ -62,8 +66,6 @@ const flags = useFeatureFlags()
|
||||
const auth = await useAuth()
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const modrinthClient = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
let prefetchTimeout: ReturnType<typeof useTimeoutFn> | null = null
|
||||
const HOVER_DURATION_TO_PREFETCH_MS = 500
|
||||
@@ -71,14 +73,20 @@ const HOVER_DURATION_TO_PREFETCH_MS = 500
|
||||
const handleProjectMouseEnter = (result: Labrinth.Search.v2.ResultSearchProject) => {
|
||||
const slug = result.slug || result.project_id
|
||||
prefetchTimeout = useTimeoutFn(
|
||||
() => queryClient.prefetchQuery(projectQueryOptions.v2(slug, modrinthClient)),
|
||||
() => {
|
||||
queryClient.prefetchQuery(projectQueryOptions.v2(slug, client))
|
||||
queryClient.prefetchQuery(projectQueryOptions.v3(result.project_id, client))
|
||||
queryClient.prefetchQuery(projectQueryOptions.members(result.project_id, client))
|
||||
queryClient.prefetchQuery(projectQueryOptions.dependencies(result.project_id, client))
|
||||
queryClient.prefetchQuery(projectQueryOptions.versionsV3(result.project_id, client))
|
||||
},
|
||||
HOVER_DURATION_TO_PREFETCH_MS,
|
||||
{ immediate: false },
|
||||
)
|
||||
prefetchTimeout.start()
|
||||
}
|
||||
|
||||
const handleServerProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject) => {
|
||||
const _handleServerProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject) => {
|
||||
const slug = result.slug || result.project_id
|
||||
|
||||
prefetchTimeout = useTimeoutFn(
|
||||
@@ -105,10 +113,6 @@ const currentType = computed(() =>
|
||||
queryAsStringOrEmpty(route.params.type).replaceAll(/^\/|s\/?$/g, ''),
|
||||
)
|
||||
|
||||
watch(currentType, (newType) => {
|
||||
console.log('currentType changed:', newType)
|
||||
})
|
||||
|
||||
const projectType = computed(() => tags.value.projectTypes.find((x) => x.id === currentType.value))
|
||||
const projectTypes = computed(() => (projectType.value ? [projectType.value.id] : []))
|
||||
|
||||
@@ -121,58 +125,104 @@ const resultsDisplayMode = computed<DisplayMode>(() =>
|
||||
: 'list',
|
||||
)
|
||||
|
||||
const server = ref<Reactive<ModrinthServer>>()
|
||||
const serverHideInstalled = ref(false)
|
||||
const eraseDataOnInstall = ref(false)
|
||||
const currentServerId = computed(() => queryAsString(route.query.sid) || null)
|
||||
const fromContext = computed(() => queryAsString(route.query.from) || null)
|
||||
const currentWorldId = computed(() => queryAsString(route.query.wid) || undefined)
|
||||
debug('currentServerId:', currentServerId.value)
|
||||
|
||||
const PERSISTENT_QUERY_PARAMS = ['sid', 'shi']
|
||||
|
||||
async function updateServerContext() {
|
||||
const serverId = queryAsString(route.query.sid)
|
||||
|
||||
if (!serverId) {
|
||||
server.value = undefined
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!auth.value.user) {
|
||||
router.push('/auth/sign-in?redirect=' + encodeURIComponent(route.fullPath))
|
||||
return
|
||||
}
|
||||
|
||||
if (!server.value || server.value.serverId !== serverId) {
|
||||
server.value = await useModrinthServers(serverId, ['general', 'content'])
|
||||
}
|
||||
|
||||
if (route.query.shi && projectType.value?.id !== 'modpack' && server.value) {
|
||||
serverHideInstalled.value = route.query.shi === 'true'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load server context:', error)
|
||||
server.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.client && route.query.sid) {
|
||||
updateServerContext().catch((error) => {
|
||||
console.error('Failed to initialize server context:', error)
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.sid,
|
||||
() => {
|
||||
updateServerContext().catch((error) => {
|
||||
console.error('Failed to update server context:', error)
|
||||
})
|
||||
const {
|
||||
data: serverData,
|
||||
isLoading: serverDataLoading,
|
||||
error: serverDataError,
|
||||
} = useQuery({
|
||||
queryKey: computed(() => ['servers', 'detail', currentServerId.value] as const),
|
||||
queryFn: () => {
|
||||
debug('serverData queryFn firing for:', currentServerId.value)
|
||||
return client.archon.servers_v0.get(currentServerId.value!)
|
||||
},
|
||||
enabled: computed(() => {
|
||||
const enabled = !!currentServerId.value
|
||||
debug('serverData enabled:', enabled)
|
||||
return enabled
|
||||
}),
|
||||
})
|
||||
|
||||
watch(serverData, (val) =>
|
||||
debug('serverData changed:', val?.server_id, val?.name, val?.loader, val?.mc_version),
|
||||
)
|
||||
watch(serverDataLoading, (val) => debug('serverData loading:', val))
|
||||
watch(serverDataError, (val) => {
|
||||
if (val) debug('serverData error:', val)
|
||||
})
|
||||
|
||||
const serverIcon = computed(() => {
|
||||
if (!currentServerId.value || !import.meta.client) return null
|
||||
return localStorage.getItem(`server-icon-${currentServerId.value}`)
|
||||
})
|
||||
|
||||
const serverHideInstalled = ref(false)
|
||||
|
||||
// TanStack Query for server content list
|
||||
const contentQueryKey = computed(() => ['content', 'list', currentServerId.value ?? ''] as const)
|
||||
const { data: serverContentData, error: serverContentError } = useQuery({
|
||||
queryKey: contentQueryKey,
|
||||
queryFn: () => client.archon.content_v1.getAddons(currentServerId.value!, currentWorldId.value!),
|
||||
enabled: computed(() => !!currentServerId.value && !!currentWorldId.value),
|
||||
})
|
||||
|
||||
// Watch for errors and notify user
|
||||
watch(serverContentError, (error) => {
|
||||
if (error) {
|
||||
console.error('Failed to load server content:', error)
|
||||
handleError(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Re-run search when server content loads so "Hide installed" filter applies
|
||||
watch(serverContentData, () => {
|
||||
if (serverHideInstalled.value) {
|
||||
updateSearchResults(1, false)
|
||||
}
|
||||
})
|
||||
|
||||
// Install content mutation
|
||||
const installContentMutation = useMutation({
|
||||
mutationFn: ({
|
||||
serverId,
|
||||
projectId,
|
||||
versionId,
|
||||
}: {
|
||||
serverId: string
|
||||
projectId: string
|
||||
versionId: string
|
||||
}) =>
|
||||
client.archon.content_v1.addAddon(serverId, currentWorldId.value!, {
|
||||
project_id: projectId,
|
||||
version_id: versionId,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
if (currentServerId.value) {
|
||||
queryClient.refetchQueries({ queryKey: ['content', 'list', currentServerId.value] })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const PERSISTENT_QUERY_PARAMS = ['sid', 'wid', 'shi', 'from']
|
||||
|
||||
if (route.query.shi && projectType.value?.id !== 'modpack') {
|
||||
serverHideInstalled.value = route.query.shi === 'true'
|
||||
}
|
||||
|
||||
const serverFilters = computed(() => {
|
||||
debug(
|
||||
'serverFilters recomputing, serverData:',
|
||||
!!serverData.value,
|
||||
'projectType:',
|
||||
projectType.value?.id,
|
||||
)
|
||||
const filters = []
|
||||
if (server.value && projectType.value?.id !== 'modpack') {
|
||||
const gameVersion = server.value.general?.mc_version
|
||||
if (serverData.value && projectType.value?.id !== 'modpack') {
|
||||
const gameVersion = serverData.value.mc_version
|
||||
if (gameVersion) {
|
||||
filters.push({
|
||||
type: 'game_version',
|
||||
@@ -180,7 +230,7 @@ const serverFilters = computed(() => {
|
||||
})
|
||||
}
|
||||
|
||||
const platform = server.value.general?.loader?.toLowerCase()
|
||||
const platform = serverData.value.loader?.toLowerCase()
|
||||
|
||||
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
|
||||
|
||||
@@ -200,13 +250,20 @@ const serverFilters = computed(() => {
|
||||
})
|
||||
}
|
||||
|
||||
if (serverHideInstalled.value) {
|
||||
const installedMods = server.value.content?.data
|
||||
.filter((x: InstallableMod) => x.project_id)
|
||||
.map((x: InstallableMod) => x.project_id)
|
||||
.filter((id): id is string => id !== undefined)
|
||||
if (projectType.value?.id === 'mod') {
|
||||
filters.push({
|
||||
type: 'environment',
|
||||
option: 'server',
|
||||
})
|
||||
}
|
||||
|
||||
installedMods
|
||||
if (serverHideInstalled.value && serverContentData.value) {
|
||||
const installedIds = (serverContentData.value.addons ?? [])
|
||||
.filter((x) => x.project_id)
|
||||
.map((x) => x.project_id)
|
||||
.filter((id): id is string => id !== null)
|
||||
|
||||
installedIds
|
||||
.map((x: string) => ({
|
||||
type: 'project_id',
|
||||
option: `project_id:${x}`,
|
||||
@@ -215,6 +272,20 @@ const serverFilters = computed(() => {
|
||||
.forEach((x) => filters.push(x))
|
||||
}
|
||||
}
|
||||
|
||||
if (currentServerId.value && projectType.value?.id === 'modpack') {
|
||||
filters.push(
|
||||
{
|
||||
type: 'environment',
|
||||
option: 'client',
|
||||
},
|
||||
{
|
||||
type: 'environment',
|
||||
option: 'server',
|
||||
},
|
||||
)
|
||||
}
|
||||
debug('serverFilters result:', filters)
|
||||
return filters
|
||||
})
|
||||
|
||||
@@ -256,6 +327,7 @@ const {
|
||||
// Functions
|
||||
createPageParams,
|
||||
} = useSearch(projectTypes, tags, serverFilters)
|
||||
debug('useSearch initialized, requestParams:', requestParams.value)
|
||||
|
||||
const selectedFilterTags = computed(() =>
|
||||
currentFilters.value
|
||||
@@ -315,42 +387,67 @@ interface InstallableSearchResult extends Labrinth.Search.v2.ResultSearchProject
|
||||
}
|
||||
|
||||
async function serverInstall(project: InstallableSearchResult) {
|
||||
if (!server.value) {
|
||||
if (!serverData.value || !currentServerId.value) {
|
||||
handleError(new Error('No server to install to.'))
|
||||
return
|
||||
}
|
||||
project.installing = true
|
||||
try {
|
||||
const versions = (await useBaseFetch(
|
||||
`project/${project.project_id}/version`,
|
||||
{},
|
||||
true,
|
||||
)) as Labrinth.Versions.v2.Version[]
|
||||
|
||||
const version =
|
||||
versions.find(
|
||||
(x) =>
|
||||
x.game_versions.includes(server.value!.general.mc_version) &&
|
||||
x.loaders.includes(server.value!.general.loader.toLowerCase()),
|
||||
) ?? versions[0]
|
||||
|
||||
if (projectType.value?.id === 'modpack') {
|
||||
await server.value.general.reinstall(
|
||||
false,
|
||||
project.project_id,
|
||||
version.id,
|
||||
undefined,
|
||||
eraseDataOnInstall.value,
|
||||
)
|
||||
project.installed = true
|
||||
navigateTo(`/hosting/manage/${server.value.serverId}/options/loader`)
|
||||
} else if (projectType.value?.id === 'mod') {
|
||||
await server.value.content.install('mod', version.project_id, version.id)
|
||||
await server.value.refresh(['content'])
|
||||
project.installed = true
|
||||
} else if (projectType.value?.id === 'plugin') {
|
||||
await server.value.content.install('plugin', version.project_id, version.id)
|
||||
await server.value.refresh(['content'])
|
||||
// TODO: restore limit=1 once the backend fix for version ordering is deployed (limit is applied before sorting)
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, {
|
||||
include_changelog: false,
|
||||
})
|
||||
const versionId = versions[0]?.id ?? project.latest_version
|
||||
if (!versionId) {
|
||||
handleError(new Error('No version found for this modpack'))
|
||||
project.installing = false
|
||||
return
|
||||
}
|
||||
const modalInstance = onboardingModalRef.value
|
||||
if (modalInstance) {
|
||||
onboardingInstallingProject.value = project
|
||||
modalInstance.show()
|
||||
await nextTick()
|
||||
const ctx = modalInstance.ctx
|
||||
ctx.setupType.value = 'modpack'
|
||||
ctx.modpackSelection.value = {
|
||||
projectId: project.project_id,
|
||||
versionId,
|
||||
name: project.title,
|
||||
iconUrl: project.icon_url ?? undefined,
|
||||
}
|
||||
ctx.modal.value?.setStage('final-config')
|
||||
}
|
||||
return
|
||||
} else if (
|
||||
projectType.value?.id === 'mod' ||
|
||||
projectType.value?.id === 'plugin' ||
|
||||
projectType.value?.id === 'datapack'
|
||||
) {
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id)
|
||||
const isDatapack = projectType.value?.id === 'datapack'
|
||||
const version = versions.find((x) => {
|
||||
if (!x.game_versions.includes(serverData.value!.mc_version!)) return false
|
||||
if (isDatapack) return true
|
||||
return x.loaders.includes(serverData.value!.loader!.toLowerCase())
|
||||
})
|
||||
if (!version) {
|
||||
handleError(
|
||||
new Error(
|
||||
isDatapack
|
||||
? `No compatible version found for ${serverData.value!.mc_version}`
|
||||
: `No compatible version found for ${serverData.value!.mc_version} / ${serverData.value!.loader}`,
|
||||
),
|
||||
)
|
||||
project.installing = false
|
||||
return
|
||||
}
|
||||
await installContentMutation.mutateAsync({
|
||||
serverId: currentServerId.value,
|
||||
projectId: version.project_id,
|
||||
versionId: version.id,
|
||||
})
|
||||
project.installed = true
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -361,28 +458,6 @@ async function serverInstall(project: InstallableSearchResult) {
|
||||
}
|
||||
|
||||
const noLoad = ref(false)
|
||||
|
||||
const {
|
||||
serverCurrentSortType,
|
||||
serverCurrentFilters,
|
||||
serverToggledGroups,
|
||||
serverSortTypes,
|
||||
serverFilterTypes,
|
||||
serverRequestParams,
|
||||
createServerPageParams,
|
||||
} = useServerSearch({ tags, query, maxResults, currentPage })
|
||||
|
||||
const effectiveSortType = computed({
|
||||
get: () => (currentType.value === 'server' ? serverCurrentSortType.value : currentSortType.value),
|
||||
set: (v: SortType) => {
|
||||
if (currentType.value === 'server') serverCurrentSortType.value = v
|
||||
else currentSortType.value = v
|
||||
},
|
||||
})
|
||||
const effectiveSortTypes = computed(() =>
|
||||
currentType.value === 'server' ? serverSortTypes : [...sortTypes],
|
||||
)
|
||||
|
||||
const {
|
||||
data: rawResults,
|
||||
refresh: refreshSearch,
|
||||
@@ -390,35 +465,26 @@ const {
|
||||
} = useLazyFetch(
|
||||
() => {
|
||||
const config = useRuntimeConfig()
|
||||
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
|
||||
const base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
|
||||
|
||||
if (currentType.value === 'server') {
|
||||
base = base.replace(/\/v\d\//, '/v3/').replace(/\/v\d$/, '/v3')
|
||||
return `${base}search${serverRequestParams.value}`
|
||||
}
|
||||
|
||||
return `${base}search${requestParams.value}`
|
||||
const url = `${base}search${requestParams.value}`
|
||||
debug('useLazyFetch URL:', url)
|
||||
return url
|
||||
},
|
||||
{
|
||||
watch: false,
|
||||
transform: (
|
||||
hits: Labrinth.Search.v2.SearchResults | Labrinth.Search.v3.SearchResults,
|
||||
): Labrinth.Search.v2.SearchResults => {
|
||||
transform: (hits) => {
|
||||
debug('useLazyFetch transform, hits:', (hits as any)?.total_hits)
|
||||
noLoad.value = false
|
||||
if ('hits_per_page' in hits) {
|
||||
return {
|
||||
hits: hits.hits as unknown as Labrinth.Search.v2.ResultSearchProject[],
|
||||
total_hits: hits.total_hits,
|
||||
limit: hits.hits_per_page,
|
||||
offset: (hits.page - 1) * hits.hits_per_page,
|
||||
}
|
||||
}
|
||||
return hits
|
||||
return hits as Labrinth.Search.v2.SearchResults
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const results = shallowRef(toRaw(rawResults))
|
||||
watch(searchLoading, (val) => debug('searchLoading:', val))
|
||||
watch(rawResults, (val) => debug('rawResults changed, total_hits:', val?.total_hits))
|
||||
|
||||
const results = computed(() => rawResults.value)
|
||||
const pageCount = computed(() =>
|
||||
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
|
||||
)
|
||||
@@ -428,6 +494,14 @@ function scrollToTop(behavior: ScrollBehavior = 'smooth') {
|
||||
}
|
||||
|
||||
function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
|
||||
debug(
|
||||
'updateSearchResults called, page:',
|
||||
pageNumber,
|
||||
'query:',
|
||||
query.value,
|
||||
'requestParams:',
|
||||
requestParams.value,
|
||||
)
|
||||
currentPage.value = pageNumber
|
||||
if (resetScroll) {
|
||||
scrollToTop()
|
||||
@@ -435,9 +509,11 @@ function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
|
||||
noLoad.value = true
|
||||
|
||||
if (query.value === null) {
|
||||
debug('updateSearchResults: query is null, returning early')
|
||||
return
|
||||
}
|
||||
|
||||
debug('updateSearchResults: calling refreshSearch')
|
||||
refreshSearch()
|
||||
|
||||
if (import.meta.client) {
|
||||
@@ -457,7 +533,7 @@ function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
|
||||
|
||||
const params = {
|
||||
...persistentParams,
|
||||
...(currentType.value === 'server' ? createServerPageParams() : createPageParams()),
|
||||
...createPageParams(),
|
||||
}
|
||||
|
||||
router.replace({ path: route.path, query: params })
|
||||
@@ -468,12 +544,6 @@ watch([currentFilters], () => {
|
||||
updateSearchResults(1, false)
|
||||
})
|
||||
|
||||
watch([serverCurrentFilters, serverCurrentSortType], () => {
|
||||
if (currentType.value === 'server') {
|
||||
updateSearchResults(1, false)
|
||||
}
|
||||
})
|
||||
|
||||
const throttledSearch = useThrottleFn(() => updateSearchResults(), 500, true)
|
||||
|
||||
function cycleSearchDisplayMode() {
|
||||
@@ -507,79 +577,116 @@ const description = computed(
|
||||
`Search and browse thousands of Minecraft ${projectType.value?.display ?? 'project'}s on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft ${projectType.value?.display ?? 'project'}s.`,
|
||||
)
|
||||
|
||||
const serverBackUrl = computed(() => {
|
||||
if (!serverData.value) return ''
|
||||
const id = serverData.value.server_id
|
||||
if (fromContext.value === 'onboarding') return `/hosting/manage/${id}?resumeModal=setup-type`
|
||||
if (fromContext.value === 'reset-server') return `/hosting/manage/${id}/options/loader`
|
||||
return `/hosting/manage/${id}/content`
|
||||
})
|
||||
|
||||
// Onboarding modpack flow: show creation flow modal overlay on discovery page
|
||||
const onboardingModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
|
||||
const onboardingInstallingProject = ref<InstallableSearchResult | null>(null)
|
||||
|
||||
function onOnboardingHide() {
|
||||
if (onboardingInstallingProject.value) {
|
||||
onboardingInstallingProject.value.installing = false
|
||||
onboardingInstallingProject.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onOnboardingBack() {
|
||||
onboardingModalRef.value?.hide()
|
||||
}
|
||||
|
||||
async function onModpackFlowCreate(config: CreationFlowContextValue) {
|
||||
if (!currentServerId.value || !config.modpackSelection.value) return
|
||||
|
||||
try {
|
||||
await client.archon.content_v1.installContent(currentServerId.value, currentWorldId.value!, {
|
||||
content_variant: 'modpack',
|
||||
spec: {
|
||||
platform: 'modrinth',
|
||||
project_id: config.modpackSelection.value.projectId,
|
||||
version_id: config.modpackSelection.value.versionId,
|
||||
},
|
||||
soft_override: false,
|
||||
properties: config.buildProperties(),
|
||||
} satisfies Archon.Content.v1.InstallWorldContent)
|
||||
|
||||
if (fromContext.value === 'onboarding') {
|
||||
await client.archon.servers_v1.endIntro(currentServerId.value)
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] })
|
||||
navigateTo(`/hosting/manage/${currentServerId.value}/content`)
|
||||
} else {
|
||||
navigateTo(`/hosting/manage/${currentServerId.value}/options/loader`)
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(new Error(`Error installing modpack: ${e}`))
|
||||
config.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
description,
|
||||
ogTitle,
|
||||
ogDescription: description,
|
||||
})
|
||||
|
||||
const serverHits = computed(
|
||||
() =>
|
||||
((rawResults.value as unknown as Labrinth.Search.v3.SearchResults)
|
||||
?.hits as Labrinth.Search.v3.ResultSearchProject[]) ?? [],
|
||||
)
|
||||
|
||||
const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) => {
|
||||
const content = hit.minecraft_java_server?.content
|
||||
if (content?.kind === 'modpack') {
|
||||
const { project_name, project_icon, project_id } = content
|
||||
if (!project_name) return undefined
|
||||
return {
|
||||
name: project_name,
|
||||
icon: project_icon,
|
||||
onclick:
|
||||
project_id !== hit.project_id
|
||||
? () => {
|
||||
navigateTo(`/project/${project_id}`)
|
||||
}
|
||||
: undefined,
|
||||
showCustomModpackTooltip: project_id === hit.project_id,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
|
||||
<div class="search-background"></div>
|
||||
</Teleport>
|
||||
<Teleport v-if="server" to="#discover-header-prefix">
|
||||
<Teleport v-if="serverData" to="#discover-header-prefix" defer>
|
||||
<div
|
||||
class="mb-4 flex flex-wrap items-center justify-between gap-3 border-0 border-b border-solid border-divider pb-4"
|
||||
>
|
||||
<nuxt-link
|
||||
:to="`/servers/manage/${server.serverId}/content`"
|
||||
<button
|
||||
tabindex="-1"
|
||||
class="flex flex-col gap-4 text-primary"
|
||||
class="flex cursor-pointer flex-col gap-4 bg-transparent text-primary"
|
||||
@click="navigateTo(serverBackUrl)"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<Avatar
|
||||
:src="
|
||||
server.general.is_medal
|
||||
serverData.is_medal
|
||||
? 'https://cdn-raw.modrinth.com/medal_icon.webp'
|
||||
: server.general.image
|
||||
: (serverIcon ?? MinecraftServerIcon)
|
||||
"
|
||||
size="48px"
|
||||
/>
|
||||
<span class="flex flex-col gap-2">
|
||||
<span class="bold font-extrabold text-contrast">
|
||||
{{ server.general.name }}
|
||||
{{ serverData.name }}
|
||||
</span>
|
||||
<span class="flex items-center gap-2 font-semibold text-secondary">
|
||||
<GameIcon class="h-5 w-5 text-secondary" />
|
||||
{{ server.general.loader }} {{ server.general.mc_version }}
|
||||
{{ serverData.loader }} {{ serverData.mc_version }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</nuxt-link>
|
||||
</button>
|
||||
<ButtonStyled>
|
||||
<nuxt-link :to="`/hosting/manage/${server.serverId}/content`">
|
||||
<button @click="navigateTo(serverBackUrl)">
|
||||
<LeftArrowIcon />
|
||||
Back to server
|
||||
</nuxt-link>
|
||||
{{
|
||||
fromContext === 'onboarding'
|
||||
? 'Back to setup'
|
||||
: fromContext === 'reset-server'
|
||||
? 'Cancel reset'
|
||||
: 'Back to server'
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<h1 class="m-0 text-xl font-extrabold leading-none text-contrast">Install content to server</h1>
|
||||
<h1 class="m-0 text-xl font-extrabold leading-none text-contrast">
|
||||
{{
|
||||
fromContext === 'reset-server'
|
||||
? 'Select modpack to install after reset'
|
||||
: 'Install content to server'
|
||||
}}
|
||||
</h1>
|
||||
</Teleport>
|
||||
|
||||
<aside
|
||||
@@ -588,7 +695,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
|
||||
}"
|
||||
aria-label="Filters"
|
||||
>
|
||||
<AdPlaceholder v-if="!auth.user && !server" />
|
||||
<AdPlaceholder v-if="!auth.user && !serverData" />
|
||||
<div v-if="filtersMenuOpen" class="fixed inset-0 z-40 bg-bg"></div>
|
||||
<div
|
||||
class="flex flex-col gap-3"
|
||||
@@ -615,23 +722,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div
|
||||
v-if="server && projectType?.id === 'modpack'"
|
||||
class="card-shadow rounded-2xl bg-bg-raised"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2 px-6 py-4 text-contrast">
|
||||
<h3 class="m-0 text-lg">Options</h3>
|
||||
</div>
|
||||
<div class="flex flex-row items-center justify-between gap-2 px-6">
|
||||
<label for="erase-data-on-install"> Erase all data on install </label>
|
||||
<Toggle id="erase-data-on-install" v-model="eraseDataOnInstall" class="flex-none" />
|
||||
</div>
|
||||
<div class="px-6 py-4 text-sm">
|
||||
If enabled, existing mods, worlds, and configurations, will be deleted before installing
|
||||
the selected modpack.
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="server && projectType?.id !== 'modpack'"
|
||||
v-if="serverData && projectType?.id !== 'modpack'"
|
||||
class="card-shadow rounded-2xl bg-bg-raised p-4"
|
||||
>
|
||||
<Checkbox
|
||||
@@ -641,73 +732,41 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
|
||||
@update:model-value="updateSearchResults()"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="currentType === 'server'">
|
||||
<SearchSidebarFilter
|
||||
v-for="filterType in serverFilterTypes.filter((f) => f.options.length > 0)"
|
||||
:key="`server-filter-${filterType.id}`"
|
||||
v-model:selected-filters="serverCurrentFilters"
|
||||
v-model:toggled-groups="serverToggledGroups"
|
||||
:provided-filters="serverFilters"
|
||||
:filter-type="filterType"
|
||||
:class="
|
||||
filtersMenuOpen
|
||||
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
|
||||
: 'card-shadow rounded-2xl bg-bg-raised'
|
||||
"
|
||||
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
|
||||
content-class="mb-4 mx-3"
|
||||
inner-panel-class="p-1"
|
||||
:open-by-default="
|
||||
![
|
||||
'server_category_minecraft_server_meta',
|
||||
'server_category_minecraft_server_community',
|
||||
'server_game_version',
|
||||
'server_status',
|
||||
].includes(filterType.id)
|
||||
"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="m-0 text-lg">{{ filterType.formatted_name }}</h3>
|
||||
</template>
|
||||
</SearchSidebarFilter>
|
||||
</template>
|
||||
<template v-else>
|
||||
<SearchSidebarFilter
|
||||
v-for="filter in filters.filter((f) => f.display !== 'none')"
|
||||
:key="`filter-${filter.id}`"
|
||||
v-model:selected-filters="currentFilters"
|
||||
v-model:toggled-groups="toggledGroups"
|
||||
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
|
||||
:provided-filters="serverFilters"
|
||||
:filter-type="filter"
|
||||
:class="
|
||||
filtersMenuOpen
|
||||
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
|
||||
: 'card-shadow rounded-2xl bg-bg-raised'
|
||||
"
|
||||
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
|
||||
content-class="mb-4 mx-3"
|
||||
inner-panel-class="p-1"
|
||||
:open-by-default="!(currentType === 'shader' && filter.id === 'game_version')"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
|
||||
</template>
|
||||
<template v-if="currentType === 'shader' && filter.id === 'game_version'" #prefix>
|
||||
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
|
||||
<InfoIcon class="mt-1 size-4" />
|
||||
<span> {{ formatMessage(messages.gameVersionShaderMessage) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #locked-game_version>
|
||||
{{ formatMessage(messages.gameVersionProvidedByServer) }}
|
||||
</template>
|
||||
<template #locked-mod_loader>
|
||||
{{ formatMessage(messages.modLoaderProvidedByServer) }}
|
||||
</template>
|
||||
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
|
||||
</SearchSidebarFilter>
|
||||
</template>
|
||||
<SearchSidebarFilter
|
||||
v-for="filter in filters.filter((f) => f.display !== 'none')"
|
||||
:key="`filter-${filter.id}`"
|
||||
v-model:selected-filters="currentFilters"
|
||||
v-model:toggled-groups="toggledGroups"
|
||||
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
|
||||
:provided-filters="serverFilters"
|
||||
:filter-type="filter"
|
||||
:class="
|
||||
filtersMenuOpen
|
||||
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
|
||||
: 'card-shadow rounded-2xl bg-bg-raised'
|
||||
"
|
||||
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
|
||||
content-class="mb-4 mx-3"
|
||||
inner-panel-class="p-1"
|
||||
:open-by-default="!(currentType === 'shader' && filter.id === 'game_version')"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
|
||||
</template>
|
||||
<template v-if="currentType === 'shader' && filter.id === 'game_version'" #prefix>
|
||||
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
|
||||
<InfoIcon class="mt-1 size-4" />
|
||||
<span> {{ formatMessage(messages.gameVersionShaderMessage) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #locked-game_version>
|
||||
{{ formatMessage(messages.gameVersionProvidedByServer) }}
|
||||
</template>
|
||||
<template #locked-mod_loader>
|
||||
{{ formatMessage(messages.modLoaderProvidedByServer) }}
|
||||
</template>
|
||||
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
|
||||
</SearchSidebarFilter>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="normal-page__content">
|
||||
@@ -727,10 +786,10 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DropdownSelect
|
||||
v-slot="{ selected }"
|
||||
v-model="effectiveSortType"
|
||||
v-model="currentSortType"
|
||||
class="!w-auto flex-grow md:flex-grow-0"
|
||||
name="Sort by"
|
||||
:options="effectiveSortTypes"
|
||||
:options="[...sortTypes]"
|
||||
:display-name="(option?: SortType) => option?.display"
|
||||
@change="updateSearchResults()"
|
||||
>
|
||||
@@ -776,14 +835,6 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
|
||||
/>
|
||||
</div>
|
||||
<SearchFilterControl
|
||||
v-if="currentType === 'server'"
|
||||
v-model:selected-filters="serverCurrentFilters"
|
||||
:filters="serverFilterTypes"
|
||||
:provided-filters="[]"
|
||||
:overridden-provided-filter-types="[]"
|
||||
/>
|
||||
<SearchFilterControl
|
||||
v-else
|
||||
v-model:selected-filters="currentFilters"
|
||||
:filters="filters.filter((f) => f.display !== 'none')"
|
||||
:provided-filters="serverFilters"
|
||||
@@ -791,14 +842,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
|
||||
:provided-message="messages.providedByServer"
|
||||
/>
|
||||
<LogoAnimated v-if="searchLoading && !noLoad" />
|
||||
<div
|
||||
v-else-if="
|
||||
currentType === 'server'
|
||||
? serverHits.length === 0
|
||||
: results && results.hits && results.hits.length === 0
|
||||
"
|
||||
class="no-results"
|
||||
>
|
||||
<div v-else-if="results && results.hits && results.hits.length === 0" class="no-results">
|
||||
<p>No results found for your query!</p>
|
||||
</div>
|
||||
<div v-else class="search-results-container">
|
||||
@@ -808,37 +852,8 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
|
||||
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
|
||||
"
|
||||
>
|
||||
<template v-if="currentType === 'server'">
|
||||
<template v-for="result in results?.hits" :key="result.project_id">
|
||||
<ProjectCard
|
||||
v-for="project in serverHits"
|
||||
:key="`server-card-${project.project_id}`"
|
||||
:title="project.name"
|
||||
:icon-url="project.icon_url || undefined"
|
||||
:summary="project.summary"
|
||||
:tags="project.categories"
|
||||
:link="`/server/${project.slug}`"
|
||||
:server-online-players="
|
||||
project.minecraft_java_server?.ping?.data?.players_online ?? 0
|
||||
"
|
||||
:server-recent-plays="project.minecraft_java_server?.verified_plays_2w ?? 0"
|
||||
:server-region="project.minecraft_server?.region"
|
||||
:server-status-online="!!project.minecraft_java_server?.ping?.data"
|
||||
:server-modpack-content="getServerModpackContent(project)"
|
||||
:layout="
|
||||
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
|
||||
"
|
||||
:max-tags="2"
|
||||
is-server-project
|
||||
exclude-loaders
|
||||
@mouseenter="handleServerProjectMouseEnter(project)"
|
||||
@mouseleave="handleProjectHoverEnd"
|
||||
>
|
||||
</ProjectCard>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ProjectCard
|
||||
v-for="result in results?.hits"
|
||||
:key="result.project_id"
|
||||
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
|
||||
:title="result.title"
|
||||
:icon-url="result.icon_url"
|
||||
@@ -858,8 +873,8 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
|
||||
:environment="
|
||||
['mod', 'modpack'].includes(currentType)
|
||||
? {
|
||||
clientSide: result.client_side as Labrinth.Projects.v2.Environment,
|
||||
serverSide: result.server_side as Labrinth.Projects.v2.Environment,
|
||||
clientSide: result.client_side,
|
||||
serverSide: result.server_side,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
@@ -869,7 +884,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
|
||||
@mouseenter="handleProjectMouseEnter(result)"
|
||||
@mouseleave="handleProjectHoverEnd"
|
||||
>
|
||||
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
|
||||
<template v-if="flags.showDiscoverProjectButtons || serverData" #actions>
|
||||
<template v-if="flags.showDiscoverProjectButtons">
|
||||
<ButtonStyled color="brand">
|
||||
<button>
|
||||
@@ -893,16 +908,16 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else-if="server">
|
||||
<template v-else-if="serverData">
|
||||
<ButtonStyled color="brand" type="outlined">
|
||||
<button
|
||||
v-if="
|
||||
(result as InstallableSearchResult).installed ||
|
||||
(server?.content?.data &&
|
||||
server.content.data.find(
|
||||
(x: InstallableMod) => x.project_id === result.project_id,
|
||||
(serverContentData &&
|
||||
(serverContentData.addons ?? []).find(
|
||||
(x) => x.project_id === result.project_id,
|
||||
)) ||
|
||||
server.general?.project?.id === result.project_id
|
||||
serverData.upstream?.project_id === result.project_id
|
||||
"
|
||||
disabled
|
||||
>
|
||||
@@ -933,6 +948,18 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CreationFlowModal
|
||||
v-if="currentServerId && projectType?.id === 'modpack'"
|
||||
ref="onboardingModalRef"
|
||||
:type="fromContext === 'reset-server' ? 'reset-server' : 'server-onboarding'"
|
||||
:available-loaders="['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']"
|
||||
:show-snapshot-toggle="true"
|
||||
:on-back="onOnboardingBack"
|
||||
@hide="onOnboardingHide"
|
||||
@browse-modpacks="() => {}"
|
||||
@create="onModpackFlowCreate"
|
||||
/>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.normal-page__content {
|
||||
|
||||
@@ -51,10 +51,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
server.moduleErrors?.general?.error.statusCode === 403 ||
|
||||
server.moduleErrors?.general?.error.statusCode === 404
|
||||
"
|
||||
v-else-if="serverError?.statusCode === 403 || serverError?.statusCode === 404"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<ErrorInformationCard
|
||||
@@ -67,7 +64,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.moduleErrors?.general?.error || !nodeAccessible"
|
||||
v-else-if="serverError || !nodeAccessible"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<ErrorInformationCard
|
||||
@@ -95,39 +92,18 @@
|
||||
</template>
|
||||
</ErrorInformationCard>
|
||||
</div>
|
||||
<!-- <div
|
||||
v-else-if="server.moduleErrors?.general?.error"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<ErrorInformationCard
|
||||
title="Connection lost"
|
||||
description=""
|
||||
:icon="TransferIcon"
|
||||
icon-color="orange"
|
||||
:action="connectionLostAction"
|
||||
>
|
||||
<template #description>
|
||||
<div class="space-y-4">
|
||||
<p class="text-lg text-secondary">
|
||||
Something went wrong, and we couldn't connect to your server. This is likely due to a
|
||||
temporary network issue.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</ErrorInformationCard>
|
||||
</div> -->
|
||||
<!-- SERVER START -->
|
||||
<div
|
||||
v-else-if="serverData"
|
||||
data-pyro-server-manager-root
|
||||
class="experimental-styles-within mobile-blurred-servericon relative mx-auto mb-12 box-border flex min-h-screen w-full min-w-0 max-w-[1280px] flex-col gap-6 px-6 transition-all duration-300"
|
||||
:style="{
|
||||
'--server-bg-image': serverData.image
|
||||
? `url(${serverData.image})`
|
||||
'--server-bg-image': serverImage
|
||||
? `url(${serverImage})`
|
||||
: `linear-gradient(180deg, rgba(153,153,153,1) 0%, rgba(87,87,87,1) 100%)`,
|
||||
}"
|
||||
>
|
||||
<div>
|
||||
<div class="border-0 border-b border-solid border-divider pb-4">
|
||||
<NuxtLink to="/hosting/manage" class="breadcrumb goto-link flex w-fit items-center">
|
||||
<LeftArrowIcon />
|
||||
All servers
|
||||
@@ -135,7 +111,7 @@
|
||||
<div class="flex w-full min-w-0 select-none flex-col items-center gap-4 pt-4 sm:flex-row">
|
||||
<ServerIcon
|
||||
:image="
|
||||
serverData.is_medal ? 'https://cdn-raw.modrinth.com/medal_icon.webp' : serverData.image
|
||||
serverData.is_medal ? 'https://cdn-raw.modrinth.com/medal_icon.webp' : serverImage
|
||||
"
|
||||
class="drop-shadow-lg sm:drop-shadow-none"
|
||||
/>
|
||||
@@ -163,7 +139,9 @@
|
||||
:server-name="serverData.name"
|
||||
:server-data="serverData"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
:backup-in-progress="backupInProgress"
|
||||
:busy-reason="
|
||||
busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined
|
||||
"
|
||||
@action="sendPowerAction"
|
||||
/>
|
||||
</div>
|
||||
@@ -188,26 +166,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="serverData.flows?.intro">
|
||||
<div
|
||||
v-if="serverData?.status === 'installing'"
|
||||
class="w-50 h-50 flex items-center justify-center gap-2 text-center text-lg font-bold"
|
||||
>
|
||||
<PanelSpinner class="size-10 animate-spin" /> Setting up your server...
|
||||
</div>
|
||||
<div v-else>
|
||||
<h2 class="my-4 text-xl font-extrabold">
|
||||
What would you like to install on your new server?
|
||||
</h2>
|
||||
|
||||
<ServerInstallation
|
||||
:server="server as ModrinthServer"
|
||||
:backup-in-progress="backupInProgress"
|
||||
ignore-current-installation
|
||||
@reinstall="onReinstall"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<ServerOnboardingPanelPage v-if="serverData.flows?.intro" />
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
@@ -309,7 +268,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="serverData.is_medal" class="mb-4">
|
||||
<MedalServerCountdown :server-id="server.serverId" />
|
||||
<MedalServerCountdown :server-id="serverId" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -330,21 +289,28 @@
|
||||
Hang on, we're reconnecting to your server.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="serverData.status === 'installing'"
|
||||
data-pyro-server-installing
|
||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-40"
|
||||
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
|
||||
leave-from-class="opacity-100 max-h-40"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<ServerIcon :image="serverData.image" class="!h-10 !w-10" />
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold"> We're preparing your server! </span>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<PanelSpinner class="!h-3 !w-3" />
|
||||
<InstallingTicker />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InstallingBanner
|
||||
v-if="
|
||||
(serverData.status === 'installing' || isSyncingContent) &&
|
||||
syncProgress?.phase !== 'Analyzing'
|
||||
"
|
||||
data-pyro-server-installing
|
||||
class="mb-4"
|
||||
:progress="syncProgress"
|
||||
>
|
||||
<template #icon>
|
||||
<ServerIcon :image="serverImage" class="!h-6 !w-6" />
|
||||
</template>
|
||||
</InstallingBanner>
|
||||
</Transition>
|
||||
<NuxtPage
|
||||
:route="route"
|
||||
:is-connected="isConnected"
|
||||
@@ -353,9 +319,8 @@
|
||||
:stats="stats"
|
||||
:server-power-state="serverPowerState"
|
||||
:power-state-details="powerStateDetails"
|
||||
:server="server"
|
||||
:backup-in-progress="backupInProgress"
|
||||
@reinstall="onReinstall"
|
||||
@reinstall-failed="onReinstallFailed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -366,7 +331,7 @@
|
||||
>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Server data</h2>
|
||||
<pre class="markdown-body w-full overflow-auto rounded-2xl bg-bg-raised p-4 text-sm">{{
|
||||
safeStringify(server)
|
||||
safeStringify(serverData)
|
||||
}}</pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -374,7 +339,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Intercom, shutdown } from '@intercom/messenger-js-sdk'
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { clearNodeAuthState, setNodeAuthState } from '@modrinth/api-client'
|
||||
import { clearNodeAuthState, ModrinthApiError, setNodeAuthState } from '@modrinth/api-client'
|
||||
import {
|
||||
BoxesIcon,
|
||||
CheckIcon,
|
||||
@@ -390,37 +355,41 @@ import {
|
||||
SettingsIcon,
|
||||
TransferIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { MessageDescriptor } from '@modrinth/ui'
|
||||
import type { BusyReason } from '@modrinth/ui'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessage,
|
||||
ErrorInformationCard,
|
||||
formatLoaderLabel,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
InstallingBanner,
|
||||
provideModrinthServerContext,
|
||||
ServerIcon,
|
||||
ServerInfoLabels,
|
||||
ServerNotice,
|
||||
ServerOnboardingPanelPage,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { PowerAction, Stats } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { computed, onMounted, onUnmounted, type Reactive, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { reloadNuxtApp } from '#app'
|
||||
import NavTabs from '~/components/ui/NavTabs.vue'
|
||||
import PanelErrorIcon from '~/components/ui/servers/icons/PanelErrorIcon.vue'
|
||||
import InstallingTicker from '~/components/ui/servers/InstallingTicker.vue'
|
||||
import MedalServerCountdown from '~/components/ui/servers/marketing/MedalServerCountdown.vue'
|
||||
import PanelServerActionButton from '~/components/ui/servers/PanelServerActionButton.vue'
|
||||
import PanelSpinner from '~/components/ui/servers/PanelSpinner.vue'
|
||||
import ServerInstallation from '~/components/ui/servers/ServerInstallation.vue'
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
import { useServerImage } from '~/composables/servers/use-server-image.ts'
|
||||
import { useServerProject } from '~/composables/servers/use-server-project.ts'
|
||||
import { useModrinthServersConsole } from '~/store/console.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const isReconnecting = ref(false)
|
||||
@@ -440,26 +409,46 @@ const createdAt = ref(
|
||||
auth.value?.user?.created ? Math.floor(new Date(auth.value.user.created).getTime() / 1000) : null,
|
||||
)
|
||||
|
||||
const debug = useDebugLogger('ServerManage')
|
||||
const route = useNativeRoute()
|
||||
const router = useRouter()
|
||||
const serverId = route.params.id as string
|
||||
|
||||
// TODO: ditch useModrinthServers for this + ctx DI.
|
||||
const { data: n_server } = useQuery({
|
||||
const { data: serverData, error: serverQueryError } = useQuery({
|
||||
queryKey: ['servers', 'detail', serverId],
|
||||
queryFn: () => client.archon.servers_v0.get(serverId)!,
|
||||
})
|
||||
|
||||
const server: Reactive<ModrinthServer> = await useModrinthServers(serverId, ['general', 'ws'])
|
||||
function updateServerData(patch: Partial<Archon.Servers.v0.Server>) {
|
||||
if (!serverData.value) return
|
||||
queryClient.setQueryData(['servers', 'detail', serverId], {
|
||||
...serverData.value,
|
||||
...patch,
|
||||
})
|
||||
}
|
||||
|
||||
const loadModulesPromise = Promise.resolve().then(() => {
|
||||
if (server.general?.status === 'suspended') {
|
||||
return
|
||||
}
|
||||
return server.refresh(['content', 'backups', 'network', 'startup'])
|
||||
const serverError = computed(() => {
|
||||
const err = serverQueryError.value
|
||||
if (err instanceof ModrinthApiError) return err
|
||||
return err ? ModrinthApiError.fromUnknown(err) : null
|
||||
})
|
||||
|
||||
provide('modulesLoaded', loadModulesPromise)
|
||||
const { data: serverFull } = useQuery({
|
||||
queryKey: ['servers', 'v1', 'detail', serverId],
|
||||
queryFn: () => client.archon.servers_v1.get(serverId),
|
||||
})
|
||||
|
||||
const worldId = computed(() => {
|
||||
if (!serverFull.value) return null
|
||||
const activeWorld = serverFull.value.worlds.find((w) => w.is_active)
|
||||
return activeWorld?.id ?? serverFull.value.worlds[0]?.id ?? null
|
||||
})
|
||||
|
||||
const serverImage = useServerImage(
|
||||
serverId,
|
||||
computed(() => serverData.value?.upstream ?? null),
|
||||
)
|
||||
const { data: serverProject } = useServerProject(computed(() => serverData.value?.upstream ?? null))
|
||||
|
||||
const errorTitle = ref('Error')
|
||||
const errorMessage = ref('An unexpected error occurred.')
|
||||
@@ -483,7 +472,6 @@ function safeStringify(obj: unknown, indent = ' '): string {
|
||||
)
|
||||
}
|
||||
|
||||
const serverData = computed(() => server.general)
|
||||
const isConnected = ref(false)
|
||||
const isWSAuthIncorrect = ref(false)
|
||||
const modrinthServersConsole = useModrinthServersConsole()
|
||||
@@ -502,6 +490,70 @@ const markBackupCancelled = (backupId: string) => {
|
||||
cancelledBackups.add(backupId)
|
||||
}
|
||||
|
||||
// Parthenon state event
|
||||
const syncProgress = ref<Archon.Websocket.v0.SyncContentProgress | null>(null)
|
||||
const syncProgressActive = ref(false)
|
||||
const isAwaitingPostInstallRefresh = ref(false)
|
||||
const { start: startSyncHide, stop: cancelSyncHide } = useTimeoutFn(
|
||||
() => (syncProgressActive.value = false),
|
||||
1000,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
watch(syncProgress, (progress) => {
|
||||
if (progress != null) {
|
||||
cancelSyncHide()
|
||||
syncProgressActive.value = true
|
||||
} else if (syncProgressActive.value) {
|
||||
startSyncHide()
|
||||
}
|
||||
})
|
||||
|
||||
const isSyncingContent = computed(
|
||||
() => syncProgressActive.value || isAwaitingPostInstallRefresh.value,
|
||||
)
|
||||
|
||||
const busyReasons = computed(() => {
|
||||
const reasons: BusyReason[] = []
|
||||
if (serverData.value?.status === 'installing') {
|
||||
reasons.push({
|
||||
reason: defineMessage({
|
||||
id: 'servers.busy.installing',
|
||||
defaultMessage: 'Server is installing',
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (isSyncingContent.value) {
|
||||
reasons.push({
|
||||
reason: defineMessage({
|
||||
id: 'servers.busy.syncing-content',
|
||||
defaultMessage: 'Content sync in progress',
|
||||
}),
|
||||
})
|
||||
}
|
||||
for (const entry of backupsState.values()) {
|
||||
if (entry.create?.state === 'ongoing') {
|
||||
reasons.push({
|
||||
reason: defineMessage({
|
||||
id: 'servers.busy.backup-creating',
|
||||
defaultMessage: 'Backup creation in progress',
|
||||
}),
|
||||
})
|
||||
break
|
||||
}
|
||||
if (entry.restore?.state === 'ongoing') {
|
||||
reasons.push({
|
||||
reason: defineMessage({
|
||||
id: 'servers.busy.backup-restoring',
|
||||
defaultMessage: 'Backup restore in progress',
|
||||
}),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
return reasons
|
||||
})
|
||||
|
||||
const fsAuth = ref<{ url: string; token: string } | null>(null)
|
||||
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
|
||||
const fsQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
|
||||
@@ -520,12 +572,15 @@ setNodeAuthState(() => fsAuth.value, refreshFsAuth)
|
||||
|
||||
provideModrinthServerContext({
|
||||
serverId,
|
||||
server: n_server as Ref<Archon.Servers.v0.Server>,
|
||||
worldId,
|
||||
server: serverData as Ref<Archon.Servers.v0.Server>,
|
||||
isConnected,
|
||||
powerState: serverPowerState,
|
||||
isServerRunning,
|
||||
backupsState,
|
||||
markBackupCancelled,
|
||||
isSyncingContent,
|
||||
busyReasons,
|
||||
fsAuth,
|
||||
fsOps,
|
||||
fsQueuedOps,
|
||||
@@ -665,8 +720,8 @@ const popupOptions = computed(
|
||||
server_id: serverData.value?.server_id,
|
||||
loader: serverData.value?.loader,
|
||||
game_version: serverData.value?.mc_version,
|
||||
modpack_id: serverData.value?.project?.id,
|
||||
modpack_name: serverData.value?.project?.title,
|
||||
modpack_id: serverProject.value?.id,
|
||||
modpack_name: serverProject.value?.title,
|
||||
},
|
||||
onOpen: () => console.log(`Opened survey notice: ${surveyNotice.value?.id}`),
|
||||
onClose: async () => await dismissSurvey(),
|
||||
@@ -736,6 +791,57 @@ const handlePowerState = (data: Archon.Websocket.v0.WSPowerStateEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleState = (data: Archon.Websocket.v0.WSStateEvent) => {
|
||||
debug('[id.vue] handleState received:', {
|
||||
power_variant: data.power_variant,
|
||||
progress: data.progress,
|
||||
serverStatus: serverData.value?.status,
|
||||
})
|
||||
syncProgress.value = data.progress
|
||||
|
||||
// Sync power state from the state event
|
||||
const powerMap: Record<Archon.Websocket.v0.FlattenedPowerState, Archon.Websocket.v0.PowerState> =
|
||||
{
|
||||
not_ready: 'stopped',
|
||||
starting: 'starting',
|
||||
running: 'running',
|
||||
stopping: 'stopping',
|
||||
idle:
|
||||
data.was_oom || (data.exit_code != null && data.exit_code !== 0) ? 'crashed' : 'stopped',
|
||||
}
|
||||
updatePowerState(powerMap[data.power_variant], {
|
||||
exit_code: data.exit_code ?? undefined,
|
||||
oom_killed: data.was_oom,
|
||||
})
|
||||
|
||||
// Sync uptime
|
||||
if (data.uptime > 0) {
|
||||
stopUptimeUpdates()
|
||||
uptimeSeconds.value = data.uptime
|
||||
startUptimeUpdates()
|
||||
}
|
||||
|
||||
// Update installing status from progress presence
|
||||
if (serverData.value) {
|
||||
if (data.progress != null && serverData.value.status !== 'installing') {
|
||||
debug('[id.vue] handleState: progress != null, setting status to installing')
|
||||
hasSeenInstallProgress = true
|
||||
updateServerData({ status: 'installing' })
|
||||
} else if (data.progress != null) {
|
||||
hasSeenInstallProgress = true
|
||||
} else if (
|
||||
data.progress == null &&
|
||||
serverData.value.status === 'installing' &&
|
||||
hasSeenInstallProgress
|
||||
) {
|
||||
debug('[id.vue] handleState: progress null + was installing, applying optimistic update')
|
||||
hasSeenInstallProgress = false
|
||||
applyOptimisticCompletion()
|
||||
invalidateAfterInstall()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUptime = (data: Archon.Websocket.v0.WSUptimeEvent) => {
|
||||
stopUptimeUpdates()
|
||||
uptimeSeconds.value = data.uptime
|
||||
@@ -847,21 +953,27 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) =>
|
||||
}
|
||||
|
||||
const handleNewMod = () => {
|
||||
server.refresh(['content'])
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
|
||||
}
|
||||
|
||||
const newLoader = ref<string | null>(null)
|
||||
const newLoaderVersion = ref<string | null>(null)
|
||||
const newMCVersion = ref<string | null>(null)
|
||||
let hasSeenInstallProgress = false
|
||||
|
||||
const onReinstall = async (potentialArgs: any) => {
|
||||
debug('[id.vue] onReinstall called with:', potentialArgs)
|
||||
|
||||
const onReinstall = (potentialArgs: any) => {
|
||||
if (serverData.value?.flows?.intro) {
|
||||
server.general?.endIntro()
|
||||
await client.archon.servers_v1.endIntro(serverId)
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
}
|
||||
|
||||
if (!serverData.value) return
|
||||
|
||||
serverData.value.status = 'installing'
|
||||
debug('[id.vue] onReinstall: setting serverData.status to installing')
|
||||
hasSeenInstallProgress = false
|
||||
updateServerData({ status: 'installing' })
|
||||
|
||||
if (potentialArgs?.loader) {
|
||||
newLoader.value = potentialArgs.loader
|
||||
@@ -873,52 +985,110 @@ const onReinstall = (potentialArgs: any) => {
|
||||
newMCVersion.value = potentialArgs.mVersion
|
||||
}
|
||||
|
||||
debug('[id.vue] onReinstall: stored refs:', {
|
||||
newLoader: newLoader.value,
|
||||
newLoaderVersion: newLoaderVersion.value,
|
||||
newMCVersion: newMCVersion.value,
|
||||
})
|
||||
|
||||
error.value = null
|
||||
errorTitle.value = 'Error'
|
||||
errorMessage.value = 'An unexpected error occurred.'
|
||||
|
||||
// Immediately refetch so loader.vue has fresh data (buttons stay locked via isSyncingContent)
|
||||
debug('[id.vue] onReinstall: triggering immediate invalidation for loader.vue')
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
|
||||
}
|
||||
|
||||
const onReinstallFailed = () => {
|
||||
debug('[id.vue] onReinstallFailed: reverting status to available')
|
||||
updateServerData({ status: 'available' })
|
||||
newLoader.value = null
|
||||
newLoaderVersion.value = null
|
||||
newMCVersion.value = null
|
||||
}
|
||||
|
||||
function applyOptimisticCompletion() {
|
||||
const patch: Partial<Archon.Servers.v0.Server> = { status: 'available' }
|
||||
if (newLoader.value) patch.loader = formatLoaderLabel(newLoader.value) as Archon.Servers.v0.Loader
|
||||
if (newLoaderVersion.value) patch.loader_version = newLoaderVersion.value
|
||||
if (newMCVersion.value) patch.mc_version = newMCVersion.value
|
||||
|
||||
debug('[id.vue] applyOptimisticCompletion: patch:', patch)
|
||||
updateServerData(patch)
|
||||
|
||||
const addonsQueries = queryClient.getQueriesData<Archon.Content.v1.Addons>({
|
||||
queryKey: ['content', 'list', 'v1', serverId],
|
||||
})
|
||||
debug(
|
||||
'[id.vue] applyOptimisticCompletion: found',
|
||||
addonsQueries.length,
|
||||
'addons queries to patch',
|
||||
)
|
||||
for (const [key, data] of addonsQueries) {
|
||||
if (!data) continue
|
||||
const addonsPatch: Record<string, string> = {}
|
||||
if (newLoader.value) addonsPatch.modloader = newLoader.value
|
||||
if (newLoaderVersion.value) addonsPatch.modloader_version = newLoaderVersion.value
|
||||
if (newMCVersion.value) addonsPatch.game_version = newMCVersion.value
|
||||
if (Object.keys(addonsPatch).length > 0) {
|
||||
debug('[id.vue] applyOptimisticCompletion: patching addons cache:', addonsPatch)
|
||||
queryClient.setQueryData(key, { ...data, ...addonsPatch })
|
||||
}
|
||||
}
|
||||
|
||||
newLoader.value = null
|
||||
newLoaderVersion.value = null
|
||||
newMCVersion.value = null
|
||||
}
|
||||
|
||||
async function invalidateAfterInstall() {
|
||||
debug(
|
||||
'[id.vue] invalidateAfterInstall: setting isAwaitingPostInstallRefresh=true, scheduling 2s delayed invalidation',
|
||||
)
|
||||
isAwaitingPostInstallRefresh.value = true
|
||||
setTimeout(async () => {
|
||||
debug('[id.vue] invalidateAfterInstall: delayed invalidation firing now')
|
||||
try {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'startup', 'v1', serverId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list'] }),
|
||||
])
|
||||
debug('[id.vue] invalidateAfterInstall: delayed invalidation complete')
|
||||
} catch (err: unknown) {
|
||||
console.error('Error refreshing data after installation:', err)
|
||||
} finally {
|
||||
debug('[id.vue] invalidateAfterInstall: setting isAwaitingPostInstallRefresh=false')
|
||||
isAwaitingPostInstallRefresh.value = false
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => {
|
||||
debug('[id.vue] handleInstallationResult received:', data)
|
||||
switch (data.result) {
|
||||
case 'ok': {
|
||||
debug('[id.vue] handleInstallationResult: ok received')
|
||||
if (!serverData.value) break
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
debug('[id.vue] handleInstallationResult: stored refs:', {
|
||||
newLoader: newLoader.value,
|
||||
newLoaderVersion: newLoaderVersion.value,
|
||||
newMCVersion: newMCVersion.value,
|
||||
})
|
||||
debug('[id.vue] handleInstallationResult: current serverData:', {
|
||||
status: serverData.value.status,
|
||||
loader: serverData.value.loader,
|
||||
loader_version: serverData.value.loader_version,
|
||||
mc_version: serverData.value.mc_version,
|
||||
})
|
||||
|
||||
let attempts = 0
|
||||
const maxAttempts = 3
|
||||
let hasValidData = false
|
||||
|
||||
while (!hasValidData && attempts < maxAttempts) {
|
||||
attempts++
|
||||
|
||||
await server.refresh(['general'], {
|
||||
preserveConnection: true,
|
||||
preserveInstallState: true,
|
||||
})
|
||||
|
||||
if (serverData.value?.loader && serverData.value?.mc_version) {
|
||||
hasValidData = true
|
||||
serverData.value.status = 'available'
|
||||
await server.refresh(['content', 'startup'])
|
||||
break
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
}
|
||||
|
||||
if (!hasValidData) {
|
||||
console.error('Failed to get valid server data after installation')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('Error refreshing data after installation:', err)
|
||||
}
|
||||
|
||||
newLoader.value = null
|
||||
newLoaderVersion.value = null
|
||||
newMCVersion.value = null
|
||||
applyOptimisticCompletion()
|
||||
error.value = null
|
||||
invalidateAfterInstall()
|
||||
|
||||
break
|
||||
}
|
||||
case 'err': {
|
||||
@@ -1010,7 +1180,7 @@ const sendPowerAction = async (action: PowerAction) => {
|
||||
const actionName = action.charAt(0).toUpperCase() + action.slice(1)
|
||||
try {
|
||||
isActioning.value = true
|
||||
await server.general?.power(action)
|
||||
await client.archon.servers_v0.power(serverId, action)
|
||||
} catch (error) {
|
||||
console.error(`Error ${toAdverb(actionName)} server:`, error)
|
||||
notifyError(
|
||||
@@ -1030,46 +1200,24 @@ const notifyError = (title: string, text: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
export type BackupInProgressReason = {
|
||||
type: string
|
||||
tooltip: MessageDescriptor
|
||||
}
|
||||
|
||||
const restoreInProgressReason = {
|
||||
type: 'restore',
|
||||
tooltip: defineMessage({
|
||||
id: 'servers.backup.restore.in-progress.tooltip',
|
||||
defaultMessage: 'Backup restore in progress',
|
||||
}),
|
||||
} satisfies BackupInProgressReason
|
||||
|
||||
const backupInProgress = computed(() => {
|
||||
for (const entry of backupsState.values()) {
|
||||
if (entry.restore?.state === 'ongoing') {
|
||||
return restoreInProgressReason
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const nodeUnavailableDetails = computed(() => [
|
||||
{
|
||||
label: 'Server ID',
|
||||
value: server.serverId,
|
||||
value: serverId,
|
||||
type: 'inline' as const,
|
||||
},
|
||||
{
|
||||
label: 'Node',
|
||||
value:
|
||||
(server.moduleErrors?.general?.error.responseData as any)?.hostname ??
|
||||
server.general?.datacenter ??
|
||||
(serverError.value?.responseData as any)?.hostname ??
|
||||
serverData.value?.datacenter ??
|
||||
'Unknown',
|
||||
type: 'inline' as const,
|
||||
},
|
||||
{
|
||||
label: 'Error message',
|
||||
value: nodeAccessible.value
|
||||
? (server.moduleErrors?.general?.error.message ?? 'Unknown')
|
||||
? (serverError.value?.message ?? 'Unknown')
|
||||
: 'Unable to reach node. Ping test failed.',
|
||||
type: 'block' as const,
|
||||
},
|
||||
@@ -1088,38 +1236,38 @@ const suspendedDescription = computed(() => {
|
||||
const generalErrorDetails = computed(() => [
|
||||
{
|
||||
label: 'Server ID',
|
||||
value: server.serverId,
|
||||
value: serverId,
|
||||
type: 'inline' as const,
|
||||
},
|
||||
{
|
||||
label: 'Timestamp',
|
||||
value: String(server.moduleErrors?.general?.timestamp),
|
||||
value: String(new Date().toISOString()),
|
||||
type: 'inline' as const,
|
||||
},
|
||||
{
|
||||
label: 'Error Name',
|
||||
value: server.moduleErrors?.general?.error.name,
|
||||
value: serverError.value?.name,
|
||||
type: 'inline' as const,
|
||||
},
|
||||
{
|
||||
label: 'Error Message',
|
||||
value: server.moduleErrors?.general?.error.message,
|
||||
value: serverError.value?.message,
|
||||
type: 'block' as const,
|
||||
},
|
||||
...(server.moduleErrors?.general?.error.originalError
|
||||
...(serverError.value?.originalError
|
||||
? [
|
||||
{
|
||||
label: 'Original Error',
|
||||
value: String(server.moduleErrors.general.error.originalError),
|
||||
value: String(serverError.value.originalError),
|
||||
type: 'hidden' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(server.moduleErrors?.general?.error.stack
|
||||
...(serverError.value?.stack
|
||||
? [
|
||||
{
|
||||
label: 'Stack Trace',
|
||||
value: server.moduleErrors.general.error.stack,
|
||||
value: serverError.value.stack,
|
||||
type: 'hidden' as const,
|
||||
},
|
||||
]
|
||||
@@ -1186,35 +1334,70 @@ const cleanup = () => {
|
||||
}
|
||||
|
||||
async function dismissNotice(noticeId: number) {
|
||||
await useServersFetch(`servers/${serverId}/notices/${noticeId}/dismiss`, {
|
||||
method: 'POST',
|
||||
}).catch((err) => {
|
||||
await client.archon.servers_v0.dismissNotice(serverId, noticeId).catch((err) => {
|
||||
addNotification({
|
||||
title: 'Error dismissing notice',
|
||||
text: err,
|
||||
type: 'error',
|
||||
})
|
||||
})
|
||||
await server.refresh(['general'])
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
}
|
||||
|
||||
const nodeAccessible = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true
|
||||
if (server.general?.status === 'suspended') {
|
||||
async function testNodeReachability(): Promise<boolean> {
|
||||
const nodeInstance = serverData.value?.node?.instance
|
||||
if (!nodeInstance) {
|
||||
console.warn('No node instance available for ping test')
|
||||
return false
|
||||
}
|
||||
|
||||
const wsUrl = `wss://${nodeInstance}/pingtest`
|
||||
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
const socket = new WebSocket(wsUrl)
|
||||
const timeout = setTimeout(() => {
|
||||
socket.close()
|
||||
resolve(false)
|
||||
}, 5000)
|
||||
|
||||
socket.onopen = () => {
|
||||
clearTimeout(timeout)
|
||||
socket.send(performance.now().toString())
|
||||
}
|
||||
|
||||
socket.onmessage = () => {
|
||||
clearTimeout(timeout)
|
||||
socket.close()
|
||||
resolve(true)
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Failed to ping node ${wsUrl}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function initializeServer() {
|
||||
if (serverData.value?.status === 'suspended') {
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Skip node test if node is null (upgrading/provisioning)
|
||||
if (server.general?.node === null) {
|
||||
if (serverData.value?.node === null) {
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
server
|
||||
.testNodeReachability()
|
||||
testNodeReachability()
|
||||
.then((result) => {
|
||||
nodeAccessible.value = result
|
||||
if (!nodeAccessible.value) {
|
||||
@@ -1227,7 +1410,7 @@ onMounted(() => {
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
if (server.moduleErrors.general?.error) {
|
||||
if (serverError.value) {
|
||||
isLoading.value = false
|
||||
} else {
|
||||
client.archon.sockets
|
||||
@@ -1244,6 +1427,7 @@ onMounted(() => {
|
||||
unsubscribers.value = [
|
||||
client.archon.sockets.on(serverId, 'log', handleLog),
|
||||
client.archon.sockets.on(serverId, 'stats', handleStats),
|
||||
client.archon.sockets.on(serverId, 'state', handleState),
|
||||
client.archon.sockets.on(serverId, 'power-state', handlePowerState),
|
||||
client.archon.sockets.on(serverId, 'uptime', handleUptime),
|
||||
client.archon.sockets.on(serverId, 'auth-incorrect', handleAuthIncorrect),
|
||||
@@ -1255,14 +1439,33 @@ onMounted(() => {
|
||||
]
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to connect WebSocket:', error)
|
||||
debug('[id.vue] Failed to connect WebSocket:', error)
|
||||
isConnected.value = false
|
||||
isLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
if (server.general?.flows?.intro && server.general?.project) {
|
||||
server.general?.endIntro()
|
||||
if (serverData.value?.flows?.intro && serverProject.value) {
|
||||
client.archon.servers_v1.endIntro(serverId).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true
|
||||
|
||||
// serverData comes from useQuery and may not be available yet at mount time.
|
||||
// Wait for it before initializing WebSocket, node reachability, etc.
|
||||
if (serverData.value) {
|
||||
initializeServer()
|
||||
} else {
|
||||
const stopWatch = watch(serverData, (data) => {
|
||||
if (data) {
|
||||
stopWatch()
|
||||
initializeServer()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (username.value && email.value && userId.value && createdAt.value) {
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<NuxtPage :route="route" :server="props.server" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
import { injectModrinthServerContext, ServersManageContentPage } from '@modrinth/ui'
|
||||
|
||||
const route = useNativeRoute()
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
}>()
|
||||
|
||||
const data = computed(() => props.server.general)
|
||||
const { server } = injectModrinthServerContext()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
useHead({
|
||||
title: `Content - ${data.value?.name ?? 'Server'} - Modrinth`,
|
||||
title: `Content - ${server.value?.name ?? 'Server'} - Modrinth`,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ServersManageContentPage :show-client-only-filter="flags.developerMode" />
|
||||
</template>
|
||||
|
||||
@@ -1,704 +0,0 @@
|
||||
<template>
|
||||
<ContentVersionEditModal
|
||||
v-if="!invalidModal"
|
||||
ref="versionEditModal"
|
||||
:type="type"
|
||||
:mod-pack="Boolean(props.server.general?.upstream)"
|
||||
:game-version="props.server.general?.mc_version ?? ''"
|
||||
:loader="props.server.general?.loader?.toLowerCase() ?? ''"
|
||||
:server-id="props.server.serverId"
|
||||
@change-version="changeModVersion($event)"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="server.moduleErrors.content"
|
||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<IssuesIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load content</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
We couldn't load your server's {{ type.toLowerCase() }}s. Here's what we know:
|
||||
<span class="break-all font-mono">{{
|
||||
JSON.stringify(server.moduleErrors.content.error)
|
||||
}}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['content'])">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
|
||||
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
|
||||
<div class="relative flex h-full w-full flex-col">
|
||||
<div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3">
|
||||
<div class="flex w-full flex-col-reverse items-center gap-2 sm:flex-row">
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<div class="flex-1 text-sm">
|
||||
<label class="sr-only" for="search">Search</label>
|
||||
<StyledInput
|
||||
id="search"
|
||||
v-model="searchInput"
|
||||
wrapper-class="w-full"
|
||||
type="search"
|
||||
:icon="SearchIcon"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
:placeholder="`Search ${localMods.length} ${type.toLocaleLowerCase()}s...`"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled>
|
||||
<TeleportOverflowMenu
|
||||
position="bottom"
|
||||
direction="left"
|
||||
:aria-label="`Filter ${type}s`"
|
||||
:options="[
|
||||
{ id: 'all', action: () => (filterMethod = 'all') },
|
||||
{ id: 'enabled', action: () => (filterMethod = 'enabled') },
|
||||
{ id: 'disabled', action: () => (filterMethod = 'disabled') },
|
||||
]"
|
||||
>
|
||||
<span class="hidden whitespace-pre sm:block">
|
||||
{{ filterMethodLabel }}
|
||||
</span>
|
||||
<FilterIcon aria-hidden="true" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #all> All {{ type.toLocaleLowerCase() }}s </template>
|
||||
<template #enabled> Only enabled </template>
|
||||
<template #disabled> Only disabled </template>
|
||||
</TeleportOverflowMenu></ButtonStyled
|
||||
>
|
||||
</div>
|
||||
<div v-if="hasMods" class="flex w-full items-center gap-2 sm:w-fit">
|
||||
<ButtonStyled>
|
||||
<button class="w-full text-nowrap sm:w-fit" @click="initiateFileUpload">
|
||||
<FileIcon />
|
||||
Add file
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<nuxt-link
|
||||
class="w-full text-nowrap sm:w-fit"
|
||||
:to="`/discover/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
|
||||
>
|
||||
<PlusIcon />
|
||||
Add {{ type.toLocaleLowerCase() }}
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FilesUploadDropdown
|
||||
ref="uploadDropdownRef"
|
||||
class="rounded-xl bg-bg-raised"
|
||||
:margin-bottom="16"
|
||||
:file-type="type"
|
||||
:current-path="`/${type.toLocaleLowerCase()}s`"
|
||||
:accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')"
|
||||
@upload-complete="() => props.server.refresh(['content'])"
|
||||
/>
|
||||
<FilesUploadDragAndDrop
|
||||
v-if="server.general && localMods"
|
||||
class="relative min-h-[50vh]"
|
||||
overlay-class="rounded-xl border-2 border-dashed border-secondary"
|
||||
:type="type"
|
||||
@files-dropped="handleDroppedFiles"
|
||||
>
|
||||
<div v-if="hasFilteredMods" class="flex flex-col gap-2 transition-all">
|
||||
<div ref="listContainer" class="relative w-full">
|
||||
<div :style="{ position: 'relative', height: `${totalHeight}px` }">
|
||||
<div
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: `${visibleTop}px`,
|
||||
width: '100%',
|
||||
}"
|
||||
>
|
||||
<template v-for="mod in visibleItems.items" :key="getStableModKey(mod)">
|
||||
<div
|
||||
class="relative mb-2 flex w-full items-center justify-between rounded-xl bg-bg-raised"
|
||||
:class="mod.disabled ? 'bg-table-alternateRow text-secondary' : ''"
|
||||
style="height: 64px"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="
|
||||
mod.project_id
|
||||
? `/project/${mod.project_id}/version/${mod.version_id}`
|
||||
: `files?path=${type.toLocaleLowerCase()}s`
|
||||
"
|
||||
class="flex min-w-0 flex-1 items-center gap-2 rounded-xl p-2"
|
||||
draggable="false"
|
||||
>
|
||||
<Avatar
|
||||
:src="mod.icon_url"
|
||||
size="sm"
|
||||
alt="Server Icon"
|
||||
:class="mod.disabled ? 'opacity-75 grayscale' : ''"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<span class="text-md flex min-w-0 items-center gap-2 font-bold">
|
||||
<span class="truncate text-contrast">{{ friendlyModName(mod) }}</span>
|
||||
<span
|
||||
v-if="mod.disabled"
|
||||
class="hidden rounded-full bg-button-bg p-1 px-2 text-xs text-contrast sm:block"
|
||||
>Disabled</span
|
||||
>
|
||||
</span>
|
||||
<div class="min-w-0 text-xs text-secondary">
|
||||
<span v-if="mod.owner" class="hidden sm:block"> by {{ mod.owner }} </span>
|
||||
<span class="block font-semibold sm:hidden">
|
||||
{{ mod.version_number || `External ${type.toLocaleLowerCase()}` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div class="ml-2 hidden min-w-0 flex-1 flex-col text-sm sm:flex">
|
||||
<div class="truncate font-semibold text-contrast">
|
||||
<span v-tooltip="`${type} version`">{{
|
||||
mod.version_number || `External ${type.toLocaleLowerCase()}`
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="truncate">
|
||||
<span v-tooltip="`${type} file name`">
|
||||
{{ mod.filename }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-end gap-2 pr-4 font-semibold text-contrast sm:min-w-44"
|
||||
>
|
||||
<ButtonStyled color="red" type="transparent">
|
||||
<button
|
||||
v-tooltip="`Delete ${type.toLocaleLowerCase()}`"
|
||||
:disabled="mod.changing"
|
||||
class="!hidden sm:!block"
|
||||
@click="removeMod(mod)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="
|
||||
mod.project_id
|
||||
? `Edit ${type.toLocaleLowerCase()} version`
|
||||
: `External ${type.toLocaleLowerCase()}s cannot be edited`
|
||||
"
|
||||
:disabled="mod.changing || !mod.project_id"
|
||||
class="!hidden sm:!block"
|
||||
@click="showVersionModal(mod)"
|
||||
>
|
||||
<template v-if="mod.changing">
|
||||
<LoadingIcon class="animate-spin" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<!-- Dropdown for mobile -->
|
||||
<div class="mr-2 flex items-center sm:hidden">
|
||||
<LoadingIcon
|
||||
v-if="mod.changing"
|
||||
class="mr-2 h-5 w-5 animate-spin"
|
||||
style="color: var(--color-base)"
|
||||
/>
|
||||
<ButtonStyled v-else circular type="transparent">
|
||||
<TeleportOverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'edit',
|
||||
action: () => showVersionModal(mod),
|
||||
shown: !!(mod.project_id && !mod.changing),
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
action: () => removeMod(mod),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #edit>
|
||||
<EditIcon class="h-5 w-5" />
|
||||
<span>Edit</span>
|
||||
</template>
|
||||
<template #delete>
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
<span>Delete</span>
|
||||
</template>
|
||||
</TeleportOverflowMenu></ButtonStyled
|
||||
>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
:id="`toggle-${getStableModKey(mod)}`"
|
||||
:model-value="!mod.disabled"
|
||||
:disabled="mod.changing"
|
||||
@update:model-value="toggleMod(mod)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- no mods has platform -->
|
||||
<div
|
||||
v-else-if="
|
||||
props.server.general?.loader &&
|
||||
props.server.general?.loader.toLocaleLowerCase() !== 'vanilla'
|
||||
"
|
||||
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
||||
>
|
||||
<div
|
||||
v-if="!hasFilteredMods && hasMods"
|
||||
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
||||
>
|
||||
<SearchIcon class="size-24" />
|
||||
<p class="m-0 font-bold text-contrast">
|
||||
No {{ type.toLocaleLowerCase() }}s found for your query!
|
||||
</p>
|
||||
<p class="m-0">Try another query, or show everything.</p>
|
||||
<ButtonStyled>
|
||||
<button @click="showAll">
|
||||
<ListIcon />
|
||||
Show everything
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
||||
>
|
||||
<PackageClosedIcon class="size-24" />
|
||||
<p class="m-0 font-bold text-contrast">No {{ type.toLocaleLowerCase() }}s found!</p>
|
||||
<p class="m-0">
|
||||
Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here.
|
||||
</p>
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="w-full text-nowrap sm:w-fit" @click="initiateFileUpload">
|
||||
<FileIcon />
|
||||
Add file
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<nuxt-link
|
||||
class="w-full text-nowrap sm:w-fit"
|
||||
:to="`/discover/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
|
||||
>
|
||||
<PlusIcon />
|
||||
Add {{ type.toLocaleLowerCase() }}
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center">
|
||||
<LoaderIcon loader="Vanilla" class="size-24" />
|
||||
<p class="m-0 pt-3 font-bold text-contrast">Your server is running Vanilla Minecraft</p>
|
||||
<p class="m-0">
|
||||
Add content to your server by installing a modpack or choosing a different platform that
|
||||
supports {{ type }}s.
|
||||
</p>
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<ButtonStyled class="mt-8">
|
||||
<NuxtLink :to="`/discover/modpacks?sid=${props.server.serverId}`">
|
||||
<CompassIcon />
|
||||
Find a modpack
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
<div>or</div>
|
||||
<ButtonStyled class="mt-8">
|
||||
<NuxtLink :to="`/hosting/manage/${props.server.serverId}/options/loader`">
|
||||
<WrenchIcon />
|
||||
Change platform
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</FilesUploadDragAndDrop>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CompassIcon,
|
||||
DropdownIcon,
|
||||
EditIcon,
|
||||
FileIcon,
|
||||
FilterIcon,
|
||||
IssuesIcon,
|
||||
ListIcon,
|
||||
MoreVerticalIcon,
|
||||
PackageClosedIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
TrashIcon,
|
||||
WrenchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
Toggle,
|
||||
} from '@modrinth/ui'
|
||||
import type { Mod } from '@modrinth/utils'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import ContentVersionEditModal from '~/components/ui/servers/ContentVersionEditModal.vue'
|
||||
import FilesUploadDragAndDrop from '~/components/ui/servers/FilesUploadDragAndDrop.vue'
|
||||
import FilesUploadDropdown from '~/components/ui/servers/FilesUploadDropdown.vue'
|
||||
import LoaderIcon from '~/components/ui/servers/icons/LoaderIcon.vue'
|
||||
import LoadingIcon from '~/components/ui/servers/icons/LoadingIcon.vue'
|
||||
import TeleportOverflowMenu from '~/components/ui/servers/TeleportOverflowMenu.vue'
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
import { acceptFileFromProjectType } from '~/helpers/fileUtils.js'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
}>()
|
||||
|
||||
const type = computed(() => {
|
||||
const loader = props.server.general?.loader?.toLowerCase()
|
||||
return loader === 'paper' || loader === 'purpur' ? 'Plugin' : 'Mod'
|
||||
})
|
||||
|
||||
interface ContentItem extends Mod {
|
||||
changing?: boolean
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 72
|
||||
const BUFFER_SIZE = 5
|
||||
|
||||
const listContainer = ref<HTMLElement | null>(null)
|
||||
const windowScrollY = ref(0)
|
||||
const windowHeight = ref(0)
|
||||
|
||||
const localMods = ref<ContentItem[]>([])
|
||||
|
||||
const searchInput = ref('')
|
||||
const modSearchInput = ref('')
|
||||
const filterMethod = ref('all')
|
||||
|
||||
const uploadDropdownRef = ref()
|
||||
|
||||
const versionEditModal = ref()
|
||||
const currentEditMod = ref<ContentItem | null>(null)
|
||||
const invalidModal = computed(
|
||||
() => !props.server.general?.mc_version || !props.server.general?.loader,
|
||||
)
|
||||
async function changeModVersion(event: string) {
|
||||
const mod = currentEditMod.value
|
||||
|
||||
if (mod) mod.changing = true
|
||||
|
||||
try {
|
||||
versionEditModal.value.hide()
|
||||
|
||||
// This will be used instead once backend implementation is done
|
||||
// await props.server.content?.reinstall(
|
||||
// `/${type.value.toLowerCase()}s/${event.fileName}`,
|
||||
// currentMod.value.project_id,
|
||||
// currentVersion.value.id,
|
||||
// );
|
||||
|
||||
await props.server.content?.install(
|
||||
type.value.toLowerCase() as 'mod' | 'plugin',
|
||||
mod?.project_id || '',
|
||||
event,
|
||||
)
|
||||
|
||||
await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod?.filename}`)
|
||||
|
||||
await props.server.refresh(['general', 'content'])
|
||||
} catch (error) {
|
||||
const errmsg = `Error changing mod version: ${error}`
|
||||
console.error(errmsg)
|
||||
addNotification({
|
||||
text: errmsg,
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (mod) mod.changing = false
|
||||
}
|
||||
|
||||
function showVersionModal(mod: ContentItem) {
|
||||
if (invalidModal.value || !mod?.project_id || !mod?.filename) {
|
||||
const errmsg = invalidModal.value
|
||||
? 'Data required for changing mod version was not found.'
|
||||
: `${!mod?.project_id ? 'No mod project ID found' : 'No mod filename found'} for ${friendlyModName(mod!)}`
|
||||
console.error(errmsg)
|
||||
addNotification({
|
||||
text: errmsg,
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
currentEditMod.value = mod
|
||||
versionEditModal.value.show(mod)
|
||||
}
|
||||
|
||||
const handleDroppedFiles = (files: File[]) => {
|
||||
files.forEach((file) => {
|
||||
uploadDropdownRef.value?.uploadFile(file)
|
||||
})
|
||||
}
|
||||
|
||||
const initiateFileUpload = () => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = acceptFileFromProjectType(type.value.toLowerCase())
|
||||
input.multiple = true
|
||||
input.onchange = () => {
|
||||
if (input.files) {
|
||||
Array.from(input.files).forEach((file) => {
|
||||
uploadDropdownRef.value?.uploadFile(file)
|
||||
})
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
const showAll = () => {
|
||||
searchInput.value = ''
|
||||
modSearchInput.value = ''
|
||||
filterMethod.value = 'all'
|
||||
}
|
||||
|
||||
const filterMethodLabel = computed(() => {
|
||||
switch (filterMethod.value) {
|
||||
case 'disabled':
|
||||
return 'Only disabled'
|
||||
case 'enabled':
|
||||
return 'Only enabled'
|
||||
default:
|
||||
return `All ${type.value.toLocaleLowerCase()}s`
|
||||
}
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => {
|
||||
const itemsHeight = filteredMods.value.length * ITEM_HEIGHT
|
||||
return itemsHeight
|
||||
})
|
||||
|
||||
const getVisibleRange = () => {
|
||||
if (!listContainer.value) return { start: 0, end: 0 }
|
||||
|
||||
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY
|
||||
const scrollTop = Math.max(0, windowScrollY.value - containerTop)
|
||||
|
||||
const start = Math.floor(scrollTop / ITEM_HEIGHT)
|
||||
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT)
|
||||
|
||||
return {
|
||||
start: Math.max(0, start - BUFFER_SIZE),
|
||||
end: Math.min(filteredMods.value.length, start + visibleCount + BUFFER_SIZE * 2),
|
||||
}
|
||||
}
|
||||
|
||||
const visibleTop = computed(() => {
|
||||
const range = getVisibleRange()
|
||||
return range.start * ITEM_HEIGHT
|
||||
})
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
const range = getVisibleRange()
|
||||
const items = filteredMods.value
|
||||
|
||||
return {
|
||||
items: items.slice(Math.max(0, range.start), Math.min(items.length, range.end)),
|
||||
}
|
||||
})
|
||||
|
||||
const handleScroll = () => {
|
||||
windowScrollY.value = window.scrollY
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
windowHeight.value = window.innerHeight
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
windowHeight.value = window.innerHeight
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener('resize', handleResize, { passive: true })
|
||||
handleScroll()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.server.content?.data,
|
||||
(newMods) => {
|
||||
if (newMods) {
|
||||
localMods.value = [...newMods]
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const debounce = <T extends (...args: any[]) => void>(
|
||||
func: T,
|
||||
wait: number,
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
return function (...args: Parameters<T>): void {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => func(...args), wait)
|
||||
}
|
||||
}
|
||||
|
||||
const pyroContentSentinel = ref<HTMLElement | null>(null)
|
||||
const debouncedSearch = debounce(() => {
|
||||
modSearchInput.value = searchInput.value
|
||||
|
||||
if (pyroContentSentinel.value) {
|
||||
const sentinelRect = pyroContentSentinel.value.getBoundingClientRect()
|
||||
if (sentinelRect.top < 0 || sentinelRect.bottom > window.innerHeight) {
|
||||
pyroContentSentinel.value.scrollIntoView({
|
||||
// behavior: "smooth",
|
||||
block: 'start',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, 300)
|
||||
|
||||
function friendlyModName(mod: ContentItem) {
|
||||
if (mod.name) return mod.name
|
||||
|
||||
// remove .disabled if at the end of the filename
|
||||
let cleanName = mod.filename.endsWith('.disabled') ? mod.filename.slice(0, -9) : mod.filename
|
||||
|
||||
// remove everything after the last dot
|
||||
const lastDotIndex = cleanName.lastIndexOf('.')
|
||||
if (lastDotIndex !== -1) cleanName = cleanName.substring(0, lastDotIndex)
|
||||
return cleanName
|
||||
}
|
||||
|
||||
function getStableModKey(mod: ContentItem): string {
|
||||
if (mod.project_id) {
|
||||
return `project-${mod.project_id}`
|
||||
}
|
||||
|
||||
// external file
|
||||
const baseFilename = mod.filename.endsWith('.disabled') ? mod.filename.slice(0, -9) : mod.filename
|
||||
return `file-${baseFilename}`
|
||||
}
|
||||
|
||||
async function toggleMod(mod: ContentItem) {
|
||||
mod.changing = true
|
||||
|
||||
const originalFilename = mod.filename
|
||||
try {
|
||||
const newFilename = mod.filename.endsWith('.disabled')
|
||||
? mod.filename.slice(0, -9)
|
||||
: `${mod.filename}.disabled`
|
||||
|
||||
const folder = `${type.value.toLocaleLowerCase()}s`
|
||||
const sourcePath = `/${folder}/${mod.filename}`
|
||||
const destinationPath = `/${folder}/${newFilename}`
|
||||
|
||||
mod.disabled = newFilename.endsWith('.disabled')
|
||||
mod.filename = newFilename
|
||||
|
||||
await client.kyros.files_v0.moveFileOrFolder(sourcePath, destinationPath)
|
||||
|
||||
await props.server.refresh(['general', 'content'])
|
||||
} catch (error) {
|
||||
mod.filename = originalFilename
|
||||
mod.disabled = originalFilename.endsWith('.disabled')
|
||||
|
||||
console.error('Error toggling mod:', error)
|
||||
addNotification({
|
||||
text: `Something went wrong toggling ${friendlyModName(mod)}`,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
mod.changing = false
|
||||
}
|
||||
|
||||
async function removeMod(mod: ContentItem) {
|
||||
mod.changing = true
|
||||
|
||||
try {
|
||||
await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod.filename}`)
|
||||
await props.server.refresh(['general', 'content'])
|
||||
} catch (error) {
|
||||
console.error('Error removing mod:', error)
|
||||
|
||||
addNotification({
|
||||
text: `couldn't remove ${mod.name || mod.filename}`,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
mod.changing = false
|
||||
}
|
||||
|
||||
const hasMods = computed(() => {
|
||||
return localMods.value?.length > 0
|
||||
})
|
||||
|
||||
const hasFilteredMods = computed(() => {
|
||||
return filteredMods.value?.length > 0
|
||||
})
|
||||
|
||||
const filteredMods = computed(() => {
|
||||
const mods = modSearchInput.value.trim()
|
||||
? localMods.value.filter(
|
||||
(mod) =>
|
||||
mod.name?.toLowerCase().includes(modSearchInput.value.toLowerCase()) ||
|
||||
mod.filename.toLowerCase().includes(modSearchInput.value.toLowerCase()),
|
||||
)
|
||||
: localMods.value
|
||||
|
||||
const statusFilteredMods = (() => {
|
||||
switch (filterMethod.value) {
|
||||
case 'disabled':
|
||||
return mods.filter((mod) => mod.disabled)
|
||||
case 'enabled':
|
||||
return mods.filter((mod) => !mod.disabled)
|
||||
default:
|
||||
return mods
|
||||
}
|
||||
})()
|
||||
|
||||
return statusFilteredMods.sort((a, b) => {
|
||||
return friendlyModName(a).localeCompare(friendlyModName(b))
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sentinel {
|
||||
position: absolute;
|
||||
top: -1rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,78 +1,58 @@
|
||||
<template>
|
||||
<div class="relative flex select-none flex-col gap-6" data-pyro-server-manager-root>
|
||||
<div
|
||||
<Admonition v-if="backupBusyReason" type="warning" :header="backupBusyReason">
|
||||
Your server is still accessible during this time.
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-if="inspectingError && isConnected && !isWsAuthIncorrect"
|
||||
data-pyro-servers-inspecting-error
|
||||
class="flex justify-between rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
|
||||
type="critical"
|
||||
:header="`${serverData?.name} shut down unexpectedly.`"
|
||||
dismissible
|
||||
@dismiss="clearError"
|
||||
>
|
||||
<div class="flex w-full justify-between gap-2">
|
||||
<div v-if="inspectingError.analysis.problems.length" class="flex flex-row gap-4">
|
||||
<IssuesIcon class="hidden h-8 w-8 text-red sm:block" />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold">
|
||||
{{ serverData?.name }} shut down unexpectedly. We've automatically analyzed the logs
|
||||
and found the following problems:
|
||||
</div>
|
||||
|
||||
<li
|
||||
v-for="problem in inspectingError.analysis.problems"
|
||||
:key="problem.message"
|
||||
class="list-none"
|
||||
>
|
||||
<h4 class="m-0 text-sm font-normal sm:text-lg sm:font-semibold">
|
||||
{{ problem.message }}
|
||||
</h4>
|
||||
<ul class="m-0 ml-6">
|
||||
<li v-for="solution in problem.solutions" :key="solution.message">
|
||||
<span class="m-0 text-sm font-normal">{{ solution.message }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<template v-if="inspectingError.analysis.problems.length">
|
||||
<p class="m-0 text-sm opacity-80">
|
||||
We automatically analyzed the logs and found the following:
|
||||
</p>
|
||||
<div class="mt-2 flex flex-col gap-2">
|
||||
<div
|
||||
v-for="problem in inspectingError.analysis.problems"
|
||||
:key="problem.message"
|
||||
class="bg-raised-bg/30 rounded-xl px-3 py-2"
|
||||
>
|
||||
<p class="m-0 text-sm font-semibold">{{ problem.message }}</p>
|
||||
<ul v-if="problem.solutions.length" class="m-0 ml-4 mt-1.5 flex flex-col gap-1">
|
||||
<li
|
||||
v-for="solution in problem.solutions"
|
||||
:key="solution.message"
|
||||
class="text-sm opacity-80"
|
||||
>
|
||||
{{ solution.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.serverPowerState === 'crashed'" class="flex flex-row gap-4">
|
||||
<IssuesIcon class="hidden h-8 w-8 text-red sm:block" />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold">{{ serverData?.name }} shut down unexpectedly.</div>
|
||||
<div class="font-normal">
|
||||
<template v-if="props.powerStateDetails?.oom_killed">
|
||||
The server stopped because it ran out of memory. There may be a memory leak caused
|
||||
by a mod or plugin, or you may need to upgrade your Modrinth Server.
|
||||
</template>
|
||||
<template v-else-if="props.powerStateDetails?.exit_code !== undefined">
|
||||
We could not automatically determine the specific cause of the crash, but your
|
||||
server exited with code
|
||||
{{ props.powerStateDetails.exit_code }}.
|
||||
{{
|
||||
props.powerStateDetails.exit_code === 1
|
||||
? 'There may be a mod or plugin causing the issue, or an issue with your server configuration.'
|
||||
: ''
|
||||
}}
|
||||
</template>
|
||||
<template v-else> We could not determine the specific cause of the crash. </template>
|
||||
<div class="mt-2">You can try restarting the server.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-row gap-4">
|
||||
<IssuesIcon class="hidden h-8 w-8 text-red sm:block" />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold">{{ serverData?.name }} shut down unexpectedly.</div>
|
||||
<div class="font-normal">
|
||||
We could not find any specific problems, but you can try restarting the server.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled color="red" @click="clearError">
|
||||
<button>
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="props.serverPowerState === 'crashed'">
|
||||
<template v-if="props.powerStateDetails?.oom_killed">
|
||||
The server stopped because it ran out of memory. There may be a memory leak caused by a
|
||||
mod or plugin, or you may need to upgrade your Modrinth Server.
|
||||
</template>
|
||||
<template v-else-if="props.powerStateDetails?.exit_code !== undefined">
|
||||
Your server exited with code {{ props.powerStateDetails.exit_code }}.
|
||||
<template v-if="props.powerStateDetails.exit_code === 1">
|
||||
There may be a mod or plugin causing the issue, or an issue with your server
|
||||
configuration.
|
||||
</template>
|
||||
</template>
|
||||
<template v-else> We could not determine the specific cause of the crash. </template>
|
||||
<p class="m-0 mt-2">You can try restarting the server.</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
We could not find any specific problems, but you can try restarting the server.
|
||||
</template>
|
||||
</Admonition>
|
||||
|
||||
<div class="flex flex-col-reverse gap-6 md:flex-col">
|
||||
<ServerStats
|
||||
@@ -181,14 +161,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IssuesIcon, TerminalSquareIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectModrinthClient } from '@modrinth/ui'
|
||||
import { TerminalSquareIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { ServerState, Stats } from '@modrinth/utils'
|
||||
|
||||
import PanelServerStatus from '~/components/ui/servers/PanelServerStatus.vue'
|
||||
import PanelTerminal from '~/components/ui/servers/PanelTerminal.vue'
|
||||
import ServerStats from '~/components/ui/servers/ServerStats.vue'
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
type ServerProps = {
|
||||
isConnected: boolean
|
||||
@@ -200,13 +184,22 @@ type ServerProps = {
|
||||
exit_code?: number
|
||||
}
|
||||
isServerRunning: boolean
|
||||
server: ModrinthServer
|
||||
}
|
||||
|
||||
const props = defineProps<ServerProps>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const client = injectModrinthClient()
|
||||
const serverId = props.server.serverId
|
||||
const { server: serverData, serverId, busyReasons } = injectModrinthServerContext()
|
||||
|
||||
const backupBusyReason = computed(() => {
|
||||
const reason = busyReasons.value.find(
|
||||
(r) =>
|
||||
r.reason.id === 'servers.busy.backup-creating' ||
|
||||
r.reason.id === 'servers.busy.backup-restoring',
|
||||
)
|
||||
return reason ? formatMessage(reason.reason) : null
|
||||
})
|
||||
|
||||
interface ErrorData {
|
||||
id: string
|
||||
@@ -581,7 +574,6 @@ const commandInput = ref('')
|
||||
const suggestions = ref<string[]>([])
|
||||
const selectedSuggestionIndex = ref(0)
|
||||
|
||||
const serverData = computed(() => props.server.general)
|
||||
// const serverIP = computed(() => serverData.value?.net.ip ?? "");
|
||||
// const serverPort = computed(() => serverData.value?.net.port ?? 0);
|
||||
// const serverDomain = computed(() => serverData.value?.net.domain ?? "");
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<ServerSidebar
|
||||
:route="route"
|
||||
:nav-links="navLinks"
|
||||
:server="server"
|
||||
:backup-in-progress="backupInProgress"
|
||||
/>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Admonition v-if="backupBusyReason" type="warning" :header="backupBusyReason">
|
||||
Some options may not be editable while the operation is in progress.
|
||||
</Admonition>
|
||||
<ServerSidebar :route="route" :nav-links="navLinks" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
@@ -18,26 +18,32 @@ import {
|
||||
VersionIcon,
|
||||
WrenchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Admonition, injectModrinthServerContext, useVIntl } from '@modrinth/ui'
|
||||
import { isAdmin as isUserAdmin, type User } from '@modrinth/utils'
|
||||
|
||||
import ServerSidebar from '~/components/ui/servers/ServerSidebar.vue'
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
|
||||
|
||||
const route = useRoute()
|
||||
const serverId = route.params.id as string
|
||||
const auth = await useAuth()
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
backupInProgress?: BackupInProgressReason
|
||||
}>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { server, busyReasons } = injectModrinthServerContext()
|
||||
|
||||
useHead({
|
||||
title: `Options - ${props.server.general?.name ?? 'Server'} - Modrinth`,
|
||||
const backupBusyReason = computed(() => {
|
||||
const reason = busyReasons.value.find(
|
||||
(r) =>
|
||||
r.reason.id === 'servers.busy.backup-creating' ||
|
||||
r.reason.id === 'servers.busy.backup-restoring',
|
||||
)
|
||||
return reason ? formatMessage(reason.reason) : null
|
||||
})
|
||||
|
||||
const ownerId = computed(() => props.server.general?.owner_id ?? 'Ghost')
|
||||
useHead({
|
||||
title: `Options - ${server.value?.name ?? 'Server'} - Modrinth`,
|
||||
})
|
||||
|
||||
const ownerId = computed(() => server.value?.owner_id ?? 'Ghost')
|
||||
const isOwner = computed(() => (auth.value?.user as User | null)?.id === ownerId.value)
|
||||
const isAdmin = computed(() => isUserAdmin(auth.value?.user))
|
||||
|
||||
@@ -46,7 +52,12 @@ const navLinks = computed(() => [
|
||||
{ icon: WrenchIcon, label: 'Platform', href: `/hosting/manage/${serverId}/options/loader` },
|
||||
{ icon: TextQuoteIcon, label: 'Startup', href: `/hosting/manage/${serverId}/options/startup` },
|
||||
{ icon: VersionIcon, label: 'Network', href: `/hosting/manage/${serverId}/options/network` },
|
||||
{ icon: ListIcon, label: 'Properties', href: `/hosting/manage/${serverId}/options/properties` },
|
||||
{
|
||||
icon: ListIcon,
|
||||
label: 'Properties',
|
||||
href: `/hosting/manage/${serverId}/options/properties`,
|
||||
shown: server.value?.status !== 'installing',
|
||||
},
|
||||
{
|
||||
icon: UserIcon,
|
||||
label: 'Preferences',
|
||||
|
||||
@@ -105,8 +105,8 @@
|
||||
<div v-else />
|
||||
<SaveBanner
|
||||
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
|
||||
:server="props.server"
|
||||
:is-updating="isUpdating"
|
||||
:server-id="serverId"
|
||||
:is-updating="isUpdating || busyReasons.length > 0"
|
||||
:save="saveGeneral"
|
||||
:reset="resetGeneral"
|
||||
/>
|
||||
@@ -117,29 +117,28 @@
|
||||
import { EditIcon, TransferIcon } from '@modrinth/assets'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
ServerIcon,
|
||||
StyledInput,
|
||||
} from '@modrinth/ui'
|
||||
import ButtonStyled from '@modrinth/ui/src/components/base/ButtonStyled.vue'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
|
||||
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const { server, serverId, busyReasons } = injectModrinthServerContext()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
}>()
|
||||
|
||||
const data = computed(() => props.server.general)
|
||||
const data = server
|
||||
const serverName = ref(data.value?.name)
|
||||
const serverSubdomain = ref(data.value?.net?.domain ?? '')
|
||||
const isValidLengthSubdomain = computed(() => serverSubdomain.value.length >= 5)
|
||||
const isValidCharsSubdomain = computed(() => /^[a-zA-Z0-9-]+$/.test(serverSubdomain.value))
|
||||
const isValidSubdomain = computed(() => isValidLengthSubdomain.value && isValidCharsSubdomain.value)
|
||||
const icon = computed(() => data.value?.image)
|
||||
const icon = useState<string | undefined>(`server-icon-${serverId}`)
|
||||
|
||||
const isUpdating = ref(false)
|
||||
const hasUnsavedChanges = computed(
|
||||
@@ -161,14 +160,14 @@ const saveGeneral = async () => {
|
||||
try {
|
||||
isUpdating.value = true
|
||||
if (serverName.value !== data.value?.name) {
|
||||
await data.value?.updateName(serverName.value ?? '')
|
||||
await client.archon.servers_v0.updateName(serverId, serverName.value ?? '')
|
||||
}
|
||||
if (serverSubdomain.value !== data.value?.net?.domain) {
|
||||
try {
|
||||
// type shit backend makes me do
|
||||
const available = await props.server.network?.checkSubdomainAvailability(
|
||||
const result = await client.archon.servers_v0.checkSubdomainAvailability(
|
||||
serverSubdomain.value,
|
||||
)
|
||||
const available = result.available
|
||||
|
||||
if (!available) {
|
||||
addNotification({
|
||||
@@ -179,7 +178,7 @@ const saveGeneral = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
await props.server.network?.changeSubdomain(serverSubdomain.value)
|
||||
await client.archon.servers_v0.changeSubdomain(serverId, serverSubdomain.value)
|
||||
} catch (error) {
|
||||
console.error('Error checking subdomain availability:', error)
|
||||
addNotification({
|
||||
@@ -191,7 +190,7 @@ const saveGeneral = async () => {
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
await props.server.refresh()
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Server settings updated',
|
||||
@@ -247,7 +246,7 @@ const uploadFile = async (e: Event) => {
|
||||
})
|
||||
|
||||
try {
|
||||
if (data.value?.image) {
|
||||
if (icon.value) {
|
||||
await client.kyros.files_v0.deleteFileOrFolder('/server-icon.png', false)
|
||||
await client.kyros.files_v0.deleteFileOrFolder('/server-icon-original.png', false)
|
||||
}
|
||||
@@ -264,8 +263,7 @@ const uploadFile = async (e: Event) => {
|
||||
canvas.height = 512
|
||||
ctx?.drawImage(img, 0, 0, 512, 512)
|
||||
const dataURL = canvas.toDataURL('image/png')
|
||||
useState(`server-icon-${props.server.serverId}`).value = dataURL
|
||||
if (data.value) data.value.image = dataURL
|
||||
useState(`server-icon-${serverId}`).value = dataURL
|
||||
resolve()
|
||||
URL.revokeObjectURL(img.src)
|
||||
}
|
||||
@@ -288,15 +286,14 @@ const uploadFile = async (e: Event) => {
|
||||
}
|
||||
|
||||
const resetIcon = async () => {
|
||||
if (data.value?.image) {
|
||||
if (icon.value) {
|
||||
try {
|
||||
await client.kyros.files_v0.deleteFileOrFolder('/server-icon.png', false)
|
||||
await client.kyros.files_v0.deleteFileOrFolder('/server-icon-original.png', false)
|
||||
|
||||
useState(`server-icon-${props.server.serverId}`).value = undefined
|
||||
if (data.value) data.value.image = undefined
|
||||
useState(`server-icon-${serverId}`).value = undefined
|
||||
|
||||
await props.server.refresh(['general'])
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
|
||||
@@ -119,16 +119,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CopyIcon, ExternalIcon, EyeIcon, EyeOffIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, CopyCode, injectNotificationManager } from '@modrinth/ui'
|
||||
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
import {
|
||||
ButtonStyled,
|
||||
CopyCode,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
}>()
|
||||
|
||||
const data = computed(() => props.server.general)
|
||||
const { server: data, serverId } = injectModrinthServerContext()
|
||||
const showPassword = ref(false)
|
||||
|
||||
const sftpUrl = computed(() => `sftp://${data.value?.sftp_username}@${data.value?.sftp_host}`)
|
||||
@@ -146,7 +145,7 @@ const copyToClipboard = (name: string, textToCopy?: string) => {
|
||||
}
|
||||
|
||||
const properties = [
|
||||
{ name: 'Server ID', value: props.server.serverId ?? 'Unknown' },
|
||||
{ name: 'Server ID', value: serverId ?? 'Unknown' },
|
||||
{ name: 'Node', value: data.value?.node?.instance ?? 'Unknown' },
|
||||
{ name: 'Kind', value: data.value?.upstream?.kind ?? data.value?.loader ?? 'Unknown' },
|
||||
{ name: 'Project ID', value: data.value?.upstream?.project_id ?? 'Unknown' },
|
||||
|
||||
@@ -1,22 +1,593 @@
|
||||
<template>
|
||||
<ServerInstallation
|
||||
:server="props.server"
|
||||
:backup-in-progress="props.backupInProgress"
|
||||
@reinstall="emit('reinstall')"
|
||||
/>
|
||||
<div class="flex flex-col gap-6 rounded-2xl bg-surface-3 p-6">
|
||||
<InstallationSettingsLayout ref="installationSettingsLayout">
|
||||
<template #extra>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="text-lg font-semibold text-contrast">{{
|
||||
formatMessage(messages.resetServerTitle)
|
||||
}}</span>
|
||||
<span class="text-primary">
|
||||
{{ formatMessage(messages.resetServerDescription) }}
|
||||
</span>
|
||||
<div>
|
||||
<ButtonStyled color="red">
|
||||
<button class="!shadow-none" :disabled="isInstalling" @click="setupModal?.show()">
|
||||
<RotateCounterClockwiseIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.resetServerButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #extra-modals>
|
||||
<ServerSetupModal
|
||||
ref="setupModal"
|
||||
@reinstall="onReinstall"
|
||||
@browse-modpacks="onBrowseModpacks"
|
||||
/>
|
||||
</template>
|
||||
</InstallationSettingsLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ServerInstallation from '~/components/ui/servers/ServerInstallation.vue'
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
|
||||
import type { Archon, LauncherMeta } from '@modrinth/api-client'
|
||||
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
formatLoaderLabel,
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
injectTags,
|
||||
InstallationSettingsLayout,
|
||||
provideInstallationSettings,
|
||||
ServerSetupModal,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
backupInProgress?: BackupInProgressReason
|
||||
}>()
|
||||
const debug = useDebugLogger('LoaderPage')
|
||||
const client = injectModrinthClient()
|
||||
const { server, serverId, worldId, isSyncingContent, busyReasons } = injectModrinthServerContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
const tags = injectTags()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
resetServerTitle: {
|
||||
id: 'hosting.loader.reset-server',
|
||||
defaultMessage: 'Reset server',
|
||||
},
|
||||
resetServerDescription: {
|
||||
id: 'hosting.loader.reset-server-description',
|
||||
defaultMessage:
|
||||
'Removes all data on your server, including your worlds, mods, and configuration files. Backups will remain and can be restored.',
|
||||
},
|
||||
loaderVersionLabel: {
|
||||
id: 'hosting.loader.loader-version',
|
||||
defaultMessage: '{loader, select, null {Loader} other {{loader}}} version',
|
||||
},
|
||||
failedToLoadVersions: {
|
||||
id: 'hosting.loader.failed-to-load-versions',
|
||||
defaultMessage: 'Failed to load versions',
|
||||
},
|
||||
failedToChangeVersion: {
|
||||
id: 'hosting.loader.failed-to-change-version',
|
||||
defaultMessage: 'Failed to change modpack version',
|
||||
},
|
||||
failedToSaveSettings: {
|
||||
id: 'hosting.loader.failed-to-save-settings',
|
||||
defaultMessage: 'Failed to save installation settings',
|
||||
},
|
||||
repairStartedTitle: {
|
||||
id: 'hosting.loader.repair-started-title',
|
||||
defaultMessage: 'Repair completed',
|
||||
},
|
||||
repairStartedText: {
|
||||
id: 'hosting.loader.repair-started-text',
|
||||
defaultMessage: 'Your server installation has been repaired.',
|
||||
},
|
||||
failedToRepair: {
|
||||
id: 'hosting.loader.failed-to-repair',
|
||||
defaultMessage: 'Failed to repair server',
|
||||
},
|
||||
failedToReinstall: {
|
||||
id: 'hosting.loader.failed-to-reinstall',
|
||||
defaultMessage: 'Failed to reinstall modpack',
|
||||
},
|
||||
failedToUnlink: {
|
||||
id: 'hosting.loader.failed-to-unlink',
|
||||
defaultMessage: 'Failed to unlink modpack',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
reinstall: [any?]
|
||||
'reinstall-failed': []
|
||||
}>()
|
||||
|
||||
const isInstalling = computed(() => {
|
||||
const val =
|
||||
server.value?.status === 'installing' || isSyncingContent.value || busyReasons.value.length > 0
|
||||
debug(
|
||||
'isInstalling:',
|
||||
val,
|
||||
'server.status:',
|
||||
server.value?.status,
|
||||
'isSyncingContent:',
|
||||
isSyncingContent.value,
|
||||
)
|
||||
return val
|
||||
})
|
||||
const installationSettingsLayout = ref<InstanceType<typeof InstallationSettingsLayout>>()
|
||||
const setupModal = ref<InstanceType<typeof ServerSetupModal>>()
|
||||
|
||||
async function invalidateServerState() {
|
||||
debug('invalidateServerState: starting')
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }),
|
||||
])
|
||||
debug('invalidateServerState: complete')
|
||||
}
|
||||
|
||||
const addonsQuery = useQuery({
|
||||
queryKey: computed(() => ['content', 'list', 'v1', serverId]),
|
||||
queryFn: () =>
|
||||
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
|
||||
enabled: computed(() => worldId.value !== null),
|
||||
})
|
||||
|
||||
const modpack = computed(() => addonsQuery.data.value?.modpack ?? null)
|
||||
|
||||
const modpackVersionsQuery = useQuery({
|
||||
queryKey: computed(() => ['labrinth', 'versions', 'v2', modpack.value?.spec.project_id]),
|
||||
queryFn: () =>
|
||||
client.labrinth.versions_v2.getProjectVersions(modpack.value!.spec.project_id, {
|
||||
include_changelog: false,
|
||||
}),
|
||||
enabled: computed(() => !!modpack.value?.spec.project_id),
|
||||
})
|
||||
|
||||
const editingPlatform = ref(server.value?.loader?.toLowerCase() ?? 'vanilla')
|
||||
const editingGameVersion = ref(server.value?.mc_version ?? '')
|
||||
|
||||
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
|
||||
|
||||
function toApiLoaderName(loader: string): string {
|
||||
return loader === 'neoforge' ? 'neo' : loader
|
||||
}
|
||||
|
||||
const apiLoaderName = computed(() =>
|
||||
modLoaders.includes(editingPlatform.value) ? toApiLoaderName(editingPlatform.value) : null,
|
||||
)
|
||||
|
||||
const manifestQuery = useQuery({
|
||||
queryKey: computed(() => ['loader-manifest', apiLoaderName.value] as const),
|
||||
queryFn: () => client.launchermeta.manifest_v0.getManifest(apiLoaderName.value!),
|
||||
enabled: computed(() => !!apiLoaderName.value),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const paperBuildsQuery = useQuery({
|
||||
queryKey: computed(() => ['paper-builds', editingGameVersion.value] as const),
|
||||
queryFn: () => client.paper.versions_v3.getBuilds(editingGameVersion.value),
|
||||
enabled: computed(() => editingPlatform.value === 'paper' && !!editingGameVersion.value),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const purpurBuildsQuery = useQuery({
|
||||
queryKey: computed(() => ['purpur-builds', editingGameVersion.value] as const),
|
||||
queryFn: () => client.purpur.versions_v2.getBuilds(editingGameVersion.value),
|
||||
enabled: computed(() => editingPlatform.value === 'purpur' && !!editingGameVersion.value),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
type LoaderVersionEntry = LauncherMeta.Manifest.v0.LoaderVersion
|
||||
|
||||
function getLoaderVersionsForGameVersion(
|
||||
loader: string,
|
||||
gameVersion: string,
|
||||
): LoaderVersionEntry[] {
|
||||
if (loader === 'paper') {
|
||||
return (paperBuildsQuery.data.value?.builds ?? [])
|
||||
.toSorted((a, b) => b - a)
|
||||
.map((b) => ({ id: String(b), stable: true }))
|
||||
}
|
||||
if (loader === 'purpur') {
|
||||
return (purpurBuildsQuery.data.value?.builds.all ?? [])
|
||||
.toSorted((a, b) => parseInt(b) - parseInt(a))
|
||||
.map((b) => ({ id: b, stable: true }))
|
||||
}
|
||||
|
||||
const manifest = manifestQuery.data.value?.gameVersions
|
||||
if (!manifest) return []
|
||||
|
||||
const placeholder = manifest.find((x) => x.id === '${modrinth.gameVersion}')
|
||||
if (placeholder) return placeholder.loaders
|
||||
|
||||
const entry = manifest.find((x) => x.id === gameVersion)
|
||||
return entry?.loaders ?? []
|
||||
}
|
||||
|
||||
function toApiLoader(loader: string): Archon.Content.v1.Modloader {
|
||||
if (loader === 'neoforge') return 'neo_forge'
|
||||
return loader as Archon.Content.v1.Modloader
|
||||
}
|
||||
|
||||
provideInstallationSettings({
|
||||
loading: computed(() => !server.value || addonsQuery.isLoading.value),
|
||||
installationInfo: computed(() => {
|
||||
const addons = addonsQuery.data.value
|
||||
const rawLoader = addons?.modloader ?? server.value?.loader ?? null
|
||||
const loader = rawLoader ? formatLoaderLabel(rawLoader) : null
|
||||
const gameVersion = addons?.game_version ?? server.value?.mc_version ?? null
|
||||
const loaderVersion = addons?.modloader_version ?? server.value?.loader_version ?? null
|
||||
|
||||
debug('installationInfo computed:', {
|
||||
'addons?.modloader': addons?.modloader,
|
||||
'server.loader': server.value?.loader,
|
||||
rawLoader,
|
||||
loader,
|
||||
'addons?.game_version': addons?.game_version,
|
||||
'server.mc_version': server.value?.mc_version,
|
||||
gameVersion,
|
||||
'addons?.modloader_version': addons?.modloader_version,
|
||||
'server.loader_version': server.value?.loader_version,
|
||||
loaderVersion,
|
||||
'addonsQuery.isLoading': addonsQuery.isLoading.value,
|
||||
'addonsQuery.isFetching': addonsQuery.isFetching.value,
|
||||
})
|
||||
|
||||
const rows = [
|
||||
{ label: formatMessage(commonMessages.platformLabel), value: loader },
|
||||
{ label: formatMessage(commonMessages.gameVersionLabel), value: gameVersion },
|
||||
]
|
||||
if (loader !== 'Vanilla') {
|
||||
rows.push({
|
||||
label: formatMessage(messages.loaderVersionLabel, { loader: loader ?? 'null' }),
|
||||
value: loaderVersion,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}),
|
||||
isLinked: computed(() => {
|
||||
const val = !!modpack.value
|
||||
debug('isLinked:', val, 'modpack:', modpack.value?.spec?.project_id)
|
||||
return val
|
||||
}),
|
||||
isBusy: isInstalling,
|
||||
modpack: computed(() => {
|
||||
if (!modpack.value) return null
|
||||
return {
|
||||
iconUrl: modpack.value.icon_url,
|
||||
title: modpack.value.title ?? modpack.value.spec.project_id,
|
||||
link: `/project/${modpack.value.spec.project_id}`,
|
||||
versionNumber: modpack.value.version_number,
|
||||
owner: modpack.value.owner
|
||||
? {
|
||||
id: modpack.value.owner.id,
|
||||
name: modpack.value.owner.name,
|
||||
iconUrl: modpack.value.owner.icon_url,
|
||||
type: modpack.value.owner.type as 'user' | 'organization',
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}),
|
||||
currentPlatform: computed(() => server.value?.loader?.toLowerCase() ?? 'vanilla'),
|
||||
currentGameVersion: computed(() => server.value?.mc_version ?? ''),
|
||||
currentLoaderVersion: computed(() => server.value?.loader_version ?? ''),
|
||||
availablePlatforms: ['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur'],
|
||||
|
||||
editingPlatformRef: editingPlatform,
|
||||
editingGameVersionRef: editingGameVersion,
|
||||
|
||||
resolveGameVersions(loader, showSnapshots) {
|
||||
const versions = showSnapshots
|
||||
? tags.gameVersions.value
|
||||
: tags.gameVersions.value.filter((v) => v.version_type === 'release')
|
||||
|
||||
if (loader && loader !== 'vanilla' && !['paper', 'purpur'].includes(loader)) {
|
||||
const manifest = manifestQuery.data.value?.gameVersions
|
||||
if (manifest) {
|
||||
const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}')
|
||||
if (!hasPlaceholder) {
|
||||
const supportedVersions = new Set(
|
||||
manifest.filter((x) => x.loaders.length > 0).map((x) => x.id),
|
||||
)
|
||||
return versions
|
||||
.filter((v) => supportedVersions.has(v.version))
|
||||
.map((v) => ({ value: v.version, label: v.version }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return versions.map((v) => ({ value: v.version, label: v.version }))
|
||||
},
|
||||
|
||||
resolveLoaderVersions(loader, gameVersion) {
|
||||
if (loader === 'vanilla' || !gameVersion) return []
|
||||
return getLoaderVersionsForGameVersion(loader, gameVersion)
|
||||
},
|
||||
|
||||
resolveHasSnapshots(loader) {
|
||||
if (loader === 'vanilla' || ['paper', 'purpur'].includes(loader)) {
|
||||
return tags.gameVersions.value.some((v) => v.version_type !== 'release')
|
||||
}
|
||||
const manifest = manifestQuery.data.value?.gameVersions
|
||||
if (!manifest) return false
|
||||
const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}')
|
||||
if (hasPlaceholder) {
|
||||
return tags.gameVersions.value.some((v) => v.version_type !== 'release')
|
||||
}
|
||||
const supportedVersions = new Set(manifest.filter((x) => x.loaders.length > 0).map((x) => x.id))
|
||||
const supported = tags.gameVersions.value.filter((v) => supportedVersions.has(v.version))
|
||||
return supported.some((v) => v.version_type !== 'release')
|
||||
},
|
||||
|
||||
async save(platform, gameVersion, loaderVersionId) {
|
||||
debug('save: called with', { platform, gameVersion, loaderVersionId })
|
||||
const currentPlatform = server.value?.loader?.toLowerCase() ?? 'vanilla'
|
||||
const platformChanged = platform !== currentPlatform
|
||||
|
||||
debug('save: emitting reinstall before API call')
|
||||
emit(
|
||||
'reinstall',
|
||||
platformChanged
|
||||
? { loader: platform, lVersion: loaderVersionId, mVersion: gameVersion }
|
||||
: { mVersion: gameVersion },
|
||||
)
|
||||
try {
|
||||
if (platformChanged) {
|
||||
const request: Archon.Content.v1.InstallWorldContent = {
|
||||
content_variant: 'bare',
|
||||
loader: toApiLoader(platform),
|
||||
version: loaderVersionId ?? '',
|
||||
game_version: gameVersion || undefined,
|
||||
soft_override: true,
|
||||
}
|
||||
debug('save: platform changed, calling installContent', request)
|
||||
await client.archon.content_v1.installContent(serverId, worldId.value!, request)
|
||||
} else {
|
||||
debug('save: game version only, calling applyGameVersionUpdate', gameVersion)
|
||||
await client.archon.content_v1.applyGameVersionUpdate(serverId, worldId.value!, gameVersion)
|
||||
}
|
||||
debug('save: succeeded, invalidating')
|
||||
invalidateServerState()
|
||||
} catch (err) {
|
||||
debug('save: failed, emitting reinstall-failed', err)
|
||||
emit('reinstall-failed')
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToSaveSettings),
|
||||
})
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
async repair() {
|
||||
debug('repair: called')
|
||||
try {
|
||||
await client.archon.content_v1.repair(serverId, worldId.value!)
|
||||
debug('repair: API succeeded, invalidating')
|
||||
await invalidateServerState()
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.repairStartedTitle),
|
||||
text: formatMessage(messages.repairStartedText),
|
||||
})
|
||||
} catch (err) {
|
||||
debug('repair: failed', err)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToRepair),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
async reinstallModpack() {
|
||||
if (!modpack.value) return
|
||||
debug(
|
||||
'reinstallModpack: called, project:',
|
||||
modpack.value.spec.project_id,
|
||||
'version:',
|
||||
modpack.value.spec.version_id,
|
||||
)
|
||||
debug('reinstallModpack: emitting reinstall before API call')
|
||||
emit('reinstall')
|
||||
try {
|
||||
await client.archon.content_v1.installContent(serverId, worldId.value!, {
|
||||
content_variant: 'modpack',
|
||||
spec: {
|
||||
platform: 'modrinth',
|
||||
project_id: modpack.value.spec.project_id,
|
||||
version_id: modpack.value.spec.version_id,
|
||||
},
|
||||
soft_override: false,
|
||||
})
|
||||
debug('reinstallModpack: installContent succeeded, invalidating')
|
||||
invalidateServerState()
|
||||
} catch (err) {
|
||||
debug('reinstallModpack: failed, emitting reinstall-failed', err)
|
||||
emit('reinstall-failed')
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToReinstall),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
async unlinkModpack() {
|
||||
debug('unlinkModpack: called')
|
||||
const previousData = addonsQuery.data.value
|
||||
if (previousData) {
|
||||
debug('unlinkModpack: optimistically removing modpack from cache')
|
||||
queryClient.setQueryData(['content', 'list', 'v1', serverId], {
|
||||
...previousData,
|
||||
modpack: null,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await client.archon.content_v1.unlinkModpack(serverId, worldId.value!)
|
||||
debug('unlinkModpack: API succeeded')
|
||||
} catch (err) {
|
||||
debug('unlinkModpack: failed, reverting cache', err)
|
||||
if (previousData) {
|
||||
queryClient.setQueryData(['content', 'list', 'v1', serverId], previousData)
|
||||
}
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToUnlink),
|
||||
})
|
||||
} finally {
|
||||
debug('unlinkModpack: invalidating queries')
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['servers', 'detail', serverId],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['content', 'list', 'v1', serverId],
|
||||
}),
|
||||
])
|
||||
debug('unlinkModpack: invalidation complete')
|
||||
}
|
||||
},
|
||||
|
||||
getCachedModpackVersions: () => modpackVersionsQuery.data.value ?? null,
|
||||
|
||||
async fetchModpackVersions() {
|
||||
debug('fetchModpackVersions: called, project:', modpack.value?.spec.project_id)
|
||||
try {
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(
|
||||
modpack.value!.spec.project_id,
|
||||
{
|
||||
include_changelog: false,
|
||||
},
|
||||
)
|
||||
debug('fetchModpackVersions: got', versions.length, 'versions')
|
||||
return versions
|
||||
} catch (err) {
|
||||
debug('fetchModpackVersions: failed', err)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToLoadVersions),
|
||||
})
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
async getVersionChangelog(versionId) {
|
||||
debug('getVersionChangelog: called, versionId:', versionId)
|
||||
try {
|
||||
return await client.labrinth.versions_v2.getVersion(versionId)
|
||||
} catch {
|
||||
debug('getVersionChangelog: failed for', versionId)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
async onModpackVersionConfirm(version) {
|
||||
if (!modpack.value) return
|
||||
debug('onModpackVersionConfirm: called, version:', version.id)
|
||||
debug('onModpackVersionConfirm: emitting reinstall before API call')
|
||||
emit('reinstall')
|
||||
try {
|
||||
await client.archon.content_v1.installContent(serverId, worldId.value!, {
|
||||
content_variant: 'modpack',
|
||||
spec: {
|
||||
platform: 'modrinth',
|
||||
project_id: modpack.value.spec.project_id,
|
||||
version_id: version.id,
|
||||
},
|
||||
soft_override: true,
|
||||
})
|
||||
debug('onModpackVersionConfirm: installContent succeeded, invalidating')
|
||||
invalidateServerState()
|
||||
} catch (err) {
|
||||
debug('onModpackVersionConfirm: failed, emitting reinstall-failed', err)
|
||||
emit('reinstall-failed')
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToChangeVersion),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
updaterModalProps: computed(() => ({
|
||||
isApp: false,
|
||||
currentVersionId: modpack.value?.spec.version_id ?? '',
|
||||
projectIconUrl: modpack.value?.icon_url ?? undefined,
|
||||
projectName:
|
||||
modpack.value?.title ??
|
||||
modpack.value?.spec.project_id ??
|
||||
formatMessage(commonMessages.modpackLabel),
|
||||
currentGameVersion: addonsQuery.data.value?.game_version ?? server.value?.mc_version ?? '',
|
||||
currentLoader: addonsQuery.data.value?.modloader ?? server.value?.loader ?? '',
|
||||
})),
|
||||
|
||||
isServer: true,
|
||||
isApp: false,
|
||||
|
||||
lockPlatform: true,
|
||||
hideLoaderVersion: true,
|
||||
|
||||
async previewSave(_platform, gameVersion, _loaderVersionId, signal) {
|
||||
const result = await client.archon.content_v1.getUpdateGameVersionPreview(
|
||||
serverId,
|
||||
worldId.value!,
|
||||
gameVersion,
|
||||
signal,
|
||||
)
|
||||
if (result.addon_changes.length === 0 && !result.has_unknown_content) return null
|
||||
return {
|
||||
diffs: result.addon_changes.map((diff) => ({
|
||||
type: diff.type,
|
||||
projectName: diff.project?.title ?? undefined,
|
||||
fileName: diff.file_name ?? undefined,
|
||||
currentVersionName: diff.current_version?.version_number ?? undefined,
|
||||
newVersionName: diff.new_version?.version_number ?? undefined,
|
||||
})),
|
||||
newGameVersion: result.new_game_version,
|
||||
newLoaderVersion: result.new_loader_version,
|
||||
hasUnknownContent: result.has_unknown_content,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => server.value?.status,
|
||||
(newStatus, oldStatus) => {
|
||||
debug('status watcher:', oldStatus, '->', newStatus, {
|
||||
'server.loader': server.value?.loader,
|
||||
'server.mc_version': server.value?.mc_version,
|
||||
'server.loader_version': server.value?.loader_version,
|
||||
})
|
||||
if (oldStatus === 'installing' && newStatus === 'available') {
|
||||
debug('status installing->available, resetting editing refs')
|
||||
editingPlatform.value = server.value?.loader?.toLowerCase() ?? 'vanilla'
|
||||
editingGameVersion.value = server.value?.mc_version ?? ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function onReinstall(event?: any) {
|
||||
installationSettingsLayout.value?.cancelEditing()
|
||||
emit('reinstall', event)
|
||||
}
|
||||
|
||||
function onBrowseModpacks() {
|
||||
debug('onBrowseModpacks: navigating to modpack discovery')
|
||||
navigateTo({
|
||||
path: '/discover/modpacks',
|
||||
query: { sid: serverId, from: 'reset-server', wid: worldId.value },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
<div class="relative h-full w-full overflow-y-auto">
|
||||
<div
|
||||
v-if="server.moduleErrors.network"
|
||||
v-if="allocationsError"
|
||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
@@ -72,10 +72,10 @@
|
||||
<p class="text-lg text-secondary">
|
||||
We couldn't load your server's network settings. Here's what we know:
|
||||
<span class="break-all font-mono">{{
|
||||
JSON.stringify(server.moduleErrors.network.error)
|
||||
allocationsError?.message ?? 'Unknown error'
|
||||
}}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['network'])">
|
||||
<ButtonStyled size="large" color="brand" @click="() => refetchAllocations()">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -249,7 +249,7 @@
|
||||
</div>
|
||||
<SaveBanner
|
||||
:is-visible="!!hasUnsavedChanges && !!isValidSubdomain"
|
||||
:server="props.server"
|
||||
:server-id="serverId"
|
||||
:is-updating="isUpdating"
|
||||
:save="saveNetwork"
|
||||
:reset="resetNetwork"
|
||||
@@ -273,22 +273,24 @@ import {
|
||||
ButtonStyled,
|
||||
ConfirmModal,
|
||||
CopyCode,
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
}>()
|
||||
const { server, serverId } = injectModrinthServerContext()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const isUpdating = ref(false)
|
||||
const data = computed(() => props.server.general)
|
||||
const data = server
|
||||
|
||||
const serverIP = ref(data?.value?.net?.ip ?? '')
|
||||
const serverSubdomain = ref(data?.value?.net?.domain ?? '')
|
||||
@@ -296,8 +298,15 @@ const serverPrimaryPort = ref(data?.value?.net?.port ?? 0)
|
||||
const userDomain = ref('')
|
||||
const exampleDomain = 'play.example.com'
|
||||
|
||||
const network = computed(() => props.server.network)
|
||||
const allocations = computed(() => network.value?.allocations)
|
||||
const {
|
||||
data: allocationsData,
|
||||
error: allocationsError,
|
||||
refetch: refetchAllocations,
|
||||
} = useQuery({
|
||||
queryKey: ['servers', 'allocations', serverId] as const,
|
||||
queryFn: () => client.archon.servers_v0.getAllocations(serverId),
|
||||
})
|
||||
const allocations = allocationsData
|
||||
|
||||
const newAllocationModal = ref<typeof NewModal>()
|
||||
const editAllocationModal = ref<typeof NewModal>()
|
||||
@@ -316,8 +325,8 @@ const addNewAllocation = async () => {
|
||||
if (!newAllocationName.value) return
|
||||
|
||||
try {
|
||||
await props.server.network?.reserveAllocation(newAllocationName.value)
|
||||
await props.server.refresh(['network'])
|
||||
await client.archon.servers_v0.reserveAllocation(serverId, newAllocationName.value)
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
|
||||
|
||||
newAllocationModal.value?.hide()
|
||||
newAllocationName.value = ''
|
||||
@@ -360,8 +369,8 @@ const showConfirmDeleteModal = (port: number) => {
|
||||
const confirmDeleteAllocation = async () => {
|
||||
if (allocationToDelete.value === null) return
|
||||
|
||||
await props.server.network?.deleteAllocation(allocationToDelete.value)
|
||||
await props.server.refresh(['network'])
|
||||
await client.archon.servers_v0.deleteAllocation(serverId, allocationToDelete.value)
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
@@ -376,8 +385,12 @@ const editAllocation = async () => {
|
||||
if (!newAllocationName.value) return
|
||||
|
||||
try {
|
||||
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value)
|
||||
await props.server.refresh(['network'])
|
||||
await client.archon.servers_v0.updateAllocation(
|
||||
serverId,
|
||||
newAllocationPort.value,
|
||||
newAllocationName.value,
|
||||
)
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
|
||||
|
||||
editAllocationModal.value?.hide()
|
||||
newAllocationName.value = ''
|
||||
@@ -397,7 +410,8 @@ const saveNetwork = async () => {
|
||||
|
||||
try {
|
||||
isUpdating.value = true
|
||||
const available = await props.server.network?.checkSubdomainAvailability(serverSubdomain.value)
|
||||
const result = await client.archon.servers_v0.checkSubdomainAvailability(serverSubdomain.value)
|
||||
const available = result.available
|
||||
if (!available) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
@@ -407,13 +421,18 @@ const saveNetwork = async () => {
|
||||
return
|
||||
}
|
||||
if (serverSubdomain.value !== data?.value?.net?.domain) {
|
||||
await props.server.network?.changeSubdomain(serverSubdomain.value)
|
||||
await client.archon.servers_v0.changeSubdomain(serverId, serverSubdomain.value)
|
||||
}
|
||||
if (serverPrimaryPort.value !== data?.value?.net?.port) {
|
||||
await props.server.network?.updateAllocation(serverPrimaryPort.value, newAllocationName.value)
|
||||
await client.archon.servers_v0.updateAllocation(
|
||||
serverId,
|
||||
serverPrimaryPort.value,
|
||||
newAllocationName.value,
|
||||
)
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
await props.server.refresh()
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Server settings updated',
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
<SaveBanner
|
||||
:is-visible="hasUnsavedChanges"
|
||||
:server="props.server"
|
||||
:server-id="serverId"
|
||||
:is-updating="false"
|
||||
:save="savePreferences"
|
||||
:reset="resetPreferences"
|
||||
@@ -45,16 +45,11 @@ import { injectNotificationManager, Toggle } from '@modrinth/ui'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const route = useNativeRoute()
|
||||
const serverId = route.params.id as string
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
}>()
|
||||
|
||||
const preferences = {
|
||||
ramAsNumber: {
|
||||
displayName: 'RAM as bytes',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full select-none overflow-y-auto">
|
||||
<div
|
||||
v-if="propsData && status === 'success'"
|
||||
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
|
||||
>
|
||||
<div v-if="propsData" class="flex h-full w-full flex-col justify-between gap-4 overflow-y-auto">
|
||||
<Admonition
|
||||
v-if="missingKnownProperties.length > 0"
|
||||
type="warning"
|
||||
body="Some expected properties are missing from your server.properties - this usually means the server hasn't completed its first startup yet."
|
||||
/>
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="m-0 text-lg font-bold text-contrast">Server properties</h2>
|
||||
@@ -22,7 +24,7 @@
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="w-full text-sm">
|
||||
<label for="search-server-properties" class="sr-only">Search server properties</label>
|
||||
<label for="search-server-properties" class="sr-only"> Search server properties </label>
|
||||
<StyledInput
|
||||
id="search-server-properties"
|
||||
v-model="searchInput"
|
||||
@@ -35,303 +37,258 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="(property, index) in filteredProperties"
|
||||
:key="index"
|
||||
v-for="(_value, key) in filteredProperties"
|
||||
:key="key"
|
||||
class="flex flex-row flex-wrap items-center justify-between py-2"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span :id="`property-label-${index}`">{{ formatPropertyName(index) }}</span>
|
||||
<span v-if="overrides[index] && overrides[index].info" class="ml-2">
|
||||
<EyeIcon v-tooltip="overrides[index].info" />
|
||||
</span>
|
||||
</div>
|
||||
<span :id="`property-label-${key}`">{{ formatPropertyName(key) }}</span>
|
||||
|
||||
<div
|
||||
v-if="overrides[index] && overrides[index].type === 'dropdown'"
|
||||
v-if="getPropertyDef(key).type === 'dropdown'"
|
||||
class="mt-2 flex w-full sm:w-[320px] sm:justify-end"
|
||||
>
|
||||
<Combobox
|
||||
:id="`server-property-${index}`"
|
||||
v-model="liveProperties[index]"
|
||||
:name="formatPropertyName(index)"
|
||||
:options="(overrides[index].options || []).map((v) => ({ value: v, label: v }))"
|
||||
:aria-labelledby="`property-label-${index}`"
|
||||
:display-value="String(liveProperties[index] ?? 'Select...')"
|
||||
:id="`server-property-${key}`"
|
||||
v-model="liveProperties[key]"
|
||||
:name="formatPropertyName(key)"
|
||||
:options="
|
||||
(getPropertyDef(key) as DropdownPropertyDef).options.map((v) => ({
|
||||
value: v,
|
||||
label: formatPropertyName(v),
|
||||
}))
|
||||
"
|
||||
:aria-labelledby="`property-label-${key}`"
|
||||
:display-value="formatPropertyName(String(liveProperties[key] ?? 'Select...'))"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="typeof property === 'boolean'" class="flex justify-end">
|
||||
<div v-else-if="getPropertyDef(key).type === 'toggle'" class="flex justify-end">
|
||||
<Toggle
|
||||
:id="`server-property-${index}`"
|
||||
v-model="liveProperties[index]"
|
||||
:aria-labelledby="`property-label-${index}`"
|
||||
:id="`server-property-${key}`"
|
||||
:model-value="liveProperties[key] === 'true'"
|
||||
:aria-labelledby="`property-label-${key}`"
|
||||
@update:model-value="liveProperties[key] = $event ? 'true' : 'false'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="typeof property === 'number' && index !== 'level-seed' && index !== 'seed'"
|
||||
class="mt-2 w-full sm:w-[320px]"
|
||||
>
|
||||
<div v-else-if="getPropertyDef(key).type === 'number'" class="mt-2 w-full sm:w-[320px]">
|
||||
<StyledInput
|
||||
:id="`server-property-${index}`"
|
||||
:model-value="liveProperties[index]"
|
||||
:id="`server-property-${key}`"
|
||||
:model-value="liveProperties[key]"
|
||||
type="number"
|
||||
wrapper-class="w-full"
|
||||
:aria-labelledby="`property-label-${index}`"
|
||||
@update:model-value="liveProperties[index] = $event"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="index === 'level-seed' || index === 'seed'"
|
||||
class="mt-2 w-full sm:w-[320px]"
|
||||
>
|
||||
<StyledInput
|
||||
:id="`server-property-${index}`"
|
||||
:model-value="liveProperties[index]"
|
||||
wrapper-class="w-full"
|
||||
:aria-labelledby="`property-label-${index}`"
|
||||
@update:model-value="liveProperties[index] = $event"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="isComplexProperty(property)" class="mt-2 w-full sm:w-[320px]">
|
||||
<StyledInput
|
||||
:id="`server-property-${index}`"
|
||||
v-model="liveProperties[index]"
|
||||
multiline
|
||||
resize="vertical"
|
||||
input-class="p-2"
|
||||
:aria-labelledby="`property-label-${index}`"
|
||||
:aria-labelledby="`property-label-${key}`"
|
||||
@update:model-value="liveProperties[key] = String($event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="mt-2 flex w-full justify-end sm:w-[320px]">
|
||||
<StyledInput
|
||||
:id="`server-property-${index}`"
|
||||
:model-value="liveProperties[index]"
|
||||
:id="`server-property-${key}`"
|
||||
v-model="liveProperties[key]"
|
||||
wrapper-class="w-full"
|
||||
:aria-labelledby="`property-label-${index}`"
|
||||
@update:model-value="liveProperties[index] = $event"
|
||||
:aria-labelledby="`property-label-${key}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="card flex h-full w-full items-center justify-center">
|
||||
<p class="text-contrast">
|
||||
The server properties file has not been generated yet. Start up your server to generate it.
|
||||
</p>
|
||||
<div v-else class="flex h-full w-full items-center justify-center">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
</div>
|
||||
|
||||
<SaveBanner
|
||||
:is-visible="hasUnsavedChanges"
|
||||
:server="props.server"
|
||||
:is-updating="isUpdating"
|
||||
:server-id="serverId"
|
||||
:is-updating="isUpdating || busyReasons.length > 0"
|
||||
restart
|
||||
:save="saveProperties"
|
||||
:save="() => saveProperties()"
|
||||
:reset="resetProperties"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EyeIcon, SearchIcon } from '@modrinth/assets'
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { SearchIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Combobox,
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
Toggle,
|
||||
} from '@modrinth/ui'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import Fuse from 'fuse.js'
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
}>()
|
||||
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const isUpdating = ref(false)
|
||||
const { serverId, worldId, powerState, busyReasons } = injectModrinthServerContext()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const searchInput = ref('')
|
||||
|
||||
const data = computed(() => props.server.general)
|
||||
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
|
||||
const { data: propsData, status } = await useAsyncData('ServerProperties', async () => {
|
||||
await modulesLoaded
|
||||
try {
|
||||
const blob = await client.kyros.files_v0.downloadFile('/server.properties')
|
||||
const rawProps = await blob.text()
|
||||
if (!rawProps) return null
|
||||
type DropdownPropertyDef = { type: 'dropdown'; options: string[] }
|
||||
type PropertyDef = { type: 'toggle' } | { type: 'number' } | { type: 'text' } | DropdownPropertyDef
|
||||
|
||||
const properties: Record<string, any> = {}
|
||||
const lines = rawProps.split('\n')
|
||||
const KNOWN_PROPERTIES: Record<string, PropertyDef> = {
|
||||
allow_cheats: { type: 'toggle' },
|
||||
allow_flight: { type: 'toggle' },
|
||||
difficulty: { type: 'dropdown', options: ['peaceful', 'easy', 'normal', 'hard'] },
|
||||
enforce_whitelist: { type: 'toggle' },
|
||||
force_gamemode: { type: 'toggle' },
|
||||
gamemode: { type: 'dropdown', options: ['survival', 'creative', 'adventure', 'spectator'] },
|
||||
generate_structures: { type: 'toggle' },
|
||||
generator_settings: { type: 'text' },
|
||||
hardcore: { type: 'toggle' },
|
||||
level_seed: { type: 'text' },
|
||||
level_type: { type: 'text' },
|
||||
max_players: { type: 'number' },
|
||||
max_tick_time: { type: 'number' },
|
||||
motd: { type: 'text' },
|
||||
pause_when_empty_seconds: { type: 'number' },
|
||||
player_idle_timeout: { type: 'number' },
|
||||
require_resource_pack: { type: 'toggle' },
|
||||
resource_pack: { type: 'text' },
|
||||
resource_pack_id: { type: 'text' },
|
||||
resource_pack_sha1: { type: 'text' },
|
||||
simulation_distance: { type: 'number' },
|
||||
spawn_protection: { type: 'number' },
|
||||
sync_chunk_writes: { type: 'toggle' },
|
||||
view_distance: { type: 'number' },
|
||||
white_list: { type: 'toggle' },
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('#') || !line.includes('=')) continue
|
||||
const [key, ...valueParts] = line.split('=')
|
||||
const rawValue = valueParts.join('=')
|
||||
let value: string | boolean | number = rawValue
|
||||
function getPropertyDef(key: string): PropertyDef {
|
||||
return KNOWN_PROPERTIES[key] ?? { type: 'text' }
|
||||
}
|
||||
|
||||
if (rawValue.toLowerCase() === 'true' || rawValue.toLowerCase() === 'false') {
|
||||
value = rawValue.toLowerCase() === 'true'
|
||||
} else {
|
||||
const intLike = /^[-+]?\d+$/.test(rawValue)
|
||||
if (intLike) {
|
||||
const n = Number(rawValue)
|
||||
if (Number.isSafeInteger(n)) {
|
||||
value = n
|
||||
}
|
||||
}
|
||||
}
|
||||
const queryKey = computed(() => ['servers', 'properties', 'v1', serverId, worldId.value])
|
||||
|
||||
properties[key.trim()] = value
|
||||
}
|
||||
|
||||
return properties
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const { data: propsData } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => client.archon.properties_v1.getProperties(serverId, worldId.value!),
|
||||
enabled: computed(() => worldId.value !== null),
|
||||
})
|
||||
|
||||
const liveProperties = ref<Record<string, any>>({})
|
||||
const originalProperties = ref<Record<string, any>>({})
|
||||
function flattenProperties(data: Archon.Content.v1.PropertiesFields): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
if (data.known) {
|
||||
for (const [key, value] of Object.entries(data.known)) {
|
||||
if (value != null) result[key] = value
|
||||
}
|
||||
}
|
||||
if (data.custom) {
|
||||
for (const [key, value] of Object.entries(data.custom)) {
|
||||
if (value != null) result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const liveProperties = ref<Record<string, string>>({})
|
||||
const originalProperties = ref<Record<string, string>>({})
|
||||
|
||||
function syncFormFromData() {
|
||||
if (!propsData.value) return
|
||||
const flat = flattenProperties(propsData.value)
|
||||
liveProperties.value = { ...flat }
|
||||
originalProperties.value = { ...flat }
|
||||
}
|
||||
|
||||
watch(
|
||||
propsData,
|
||||
(newPropsData) => {
|
||||
if (newPropsData) {
|
||||
console.log(newPropsData)
|
||||
liveProperties.value = JSON.parse(JSON.stringify(newPropsData))
|
||||
originalProperties.value = JSON.parse(JSON.stringify(newPropsData))
|
||||
(newData, oldData) => {
|
||||
if (newData && !oldData) {
|
||||
syncFormFromData()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const hasUnsavedChanges = computed(() => {
|
||||
return Object.keys(liveProperties.value).some(
|
||||
(key) =>
|
||||
JSON.stringify(liveProperties.value[key]) !== JSON.stringify(originalProperties.value[key]),
|
||||
)
|
||||
watch(powerState, () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
})
|
||||
|
||||
const getDifficultyOptions = () => {
|
||||
const pre113Versions = tags.value.gameVersions
|
||||
.filter((v) => {
|
||||
const versionNumbers = v.version.split('.').map(Number)
|
||||
return versionNumbers[0] === 1 && versionNumbers[1] < 13
|
||||
})
|
||||
.map((v) => v.version)
|
||||
if (data.value?.mc_version && pre113Versions.includes(data.value.mc_version)) {
|
||||
return ['0', '1', '2', '3']
|
||||
} else {
|
||||
return ['peaceful', 'easy', 'normal', 'hard']
|
||||
}
|
||||
}
|
||||
const missingKnownProperties = computed(() =>
|
||||
Object.keys(KNOWN_PROPERTIES).filter((key) => !(key in liveProperties.value)),
|
||||
)
|
||||
|
||||
const overrides: { [key: string]: { type: string; options?: string[]; info?: string } } = {
|
||||
difficulty: {
|
||||
type: 'dropdown',
|
||||
options: getDifficultyOptions(),
|
||||
},
|
||||
gamemode: {
|
||||
type: 'dropdown',
|
||||
options: ['survival', 'creative', 'adventure', 'spectator'],
|
||||
},
|
||||
}
|
||||
const hasUnsavedChanges = computed(() =>
|
||||
Object.keys(liveProperties.value).some(
|
||||
(key) => liveProperties.value[key] !== originalProperties.value[key],
|
||||
),
|
||||
)
|
||||
|
||||
const fuse = computed(() => {
|
||||
if (!liveProperties.value) return null
|
||||
function buildPatch(): Archon.Content.v1.PatchPropertiesFields {
|
||||
const known: Record<string, string> = {}
|
||||
const custom: Record<string, string> = {}
|
||||
|
||||
const propertiesToFuse = Object.entries(liveProperties.value).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
}))
|
||||
|
||||
return new Fuse(propertiesToFuse, {
|
||||
keys: ['key', 'value'],
|
||||
threshold: 0.2,
|
||||
})
|
||||
})
|
||||
|
||||
const filteredProperties = computed(() => {
|
||||
if (!searchInput.value?.trim()) {
|
||||
return liveProperties.value
|
||||
}
|
||||
|
||||
const results = fuse.value?.search(searchInput.value) ?? []
|
||||
|
||||
return Object.fromEntries(results.map(({ item }) => [item.key, liveProperties.value[item.key]]))
|
||||
})
|
||||
|
||||
const constructServerProperties = (): string => {
|
||||
const properties = liveProperties.value
|
||||
|
||||
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`
|
||||
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
if (typeof value === 'object') {
|
||||
fileContent += `${key}=${JSON.stringify(value)}\n`
|
||||
} else if (typeof value === 'boolean') {
|
||||
fileContent += `${key}=${value ? 'true' : 'false'}\n`
|
||||
for (const key of Object.keys(liveProperties.value)) {
|
||||
if (liveProperties.value[key] === originalProperties.value[key]) continue
|
||||
if (key in KNOWN_PROPERTIES) {
|
||||
known[key] = liveProperties.value[key]
|
||||
} else {
|
||||
fileContent += `${key}=${value}\n`
|
||||
custom[key] = liveProperties.value[key]
|
||||
}
|
||||
}
|
||||
|
||||
return fileContent
|
||||
const patch: Archon.Content.v1.PatchPropertiesFields = {}
|
||||
if (Object.keys(known).length > 0) {
|
||||
patch.known = known as Archon.Content.v1.KnownPropertiesFields
|
||||
}
|
||||
if (Object.keys(custom).length > 0) {
|
||||
patch.custom = custom
|
||||
}
|
||||
return patch
|
||||
}
|
||||
|
||||
const saveProperties = async () => {
|
||||
try {
|
||||
isUpdating.value = true
|
||||
await client.kyros.files_v0.updateFile('/server.properties', constructServerProperties())
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
originalProperties.value = JSON.parse(JSON.stringify(liveProperties.value))
|
||||
await props.server.refresh()
|
||||
const { mutate: saveProperties, isPending: isUpdating } = useMutation({
|
||||
mutationFn: () =>
|
||||
client.archon.properties_v1.patchProperties(serverId, worldId.value!, buildPatch()),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
syncFormFromData()
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Server properties updated',
|
||||
text: 'Your server properties were successfully changed.',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating server properties:', error)
|
||||
},
|
||||
onError: (error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to update server properties',
|
||||
text: 'An error occurred while attempting to update your server properties.',
|
||||
text: error instanceof Error ? error.message : 'An error occurred.',
|
||||
})
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function resetProperties() {
|
||||
syncFormFromData()
|
||||
}
|
||||
|
||||
const resetProperties = async () => {
|
||||
liveProperties.value = JSON.parse(JSON.stringify(originalProperties.value))
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
}
|
||||
const fuse = computed(() => {
|
||||
const entries = Object.entries(liveProperties.value).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
}))
|
||||
return new Fuse(entries, { keys: ['key', 'value'], threshold: 0.2 })
|
||||
})
|
||||
|
||||
const formatPropertyName = (propertyName: string): string => {
|
||||
return propertyName
|
||||
.split(/[-.]/)
|
||||
const filteredProperties = computed(() => {
|
||||
if (!searchInput.value?.trim()) return liveProperties.value
|
||||
const results = fuse.value.search(searchInput.value)
|
||||
return Object.fromEntries(results.map(({ item }) => [item.key, liveProperties.value[item.key]]))
|
||||
})
|
||||
|
||||
function formatPropertyName(name: string): string {
|
||||
return name
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const isComplexProperty = (property: any): boolean => {
|
||||
return (
|
||||
typeof property === 'object' ||
|
||||
(typeof property === 'string' &&
|
||||
(property.includes(',') ||
|
||||
property.includes('{') ||
|
||||
property.includes('}') ||
|
||||
property.includes('[') ||
|
||||
property.includes(']') ||
|
||||
property.length > 30))
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,32 +1,6 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full">
|
||||
<div
|
||||
v-if="server.moduleErrors.startup"
|
||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<IssuesIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load startup settings</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
We couldn't load your server's startup settings. Here's what we know:
|
||||
</p>
|
||||
<p>
|
||||
<span class="break-all font-mono">{{
|
||||
JSON.stringify(server.moduleErrors.startup.error)
|
||||
}}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['startup'])">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="data" class="flex h-full w-full flex-col gap-4">
|
||||
<div class="flex h-full w-full flex-col gap-4">
|
||||
<div
|
||||
class="rounded-2xl border-[1px] border-solid border-orange bg-bg-orange p-4 text-contrast"
|
||||
>
|
||||
@@ -42,7 +16,7 @@
|
||||
</label>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
:disabled="invocation === originalInvocation"
|
||||
:disabled="isStartupLoading || startupCommand === defaultStartupCommand"
|
||||
class="!w-full sm:!w-auto"
|
||||
@click="resetToDefault"
|
||||
>
|
||||
@@ -51,13 +25,22 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="startup-command-field"
|
||||
v-model="invocation"
|
||||
multiline
|
||||
resize="vertical"
|
||||
input-class="min-h-[270px] font-[family-name:var(--mono-font)]"
|
||||
/>
|
||||
<div class="relative">
|
||||
<StyledInput
|
||||
id="startup-command-field"
|
||||
v-model="startupCommand"
|
||||
multiline
|
||||
resize="vertical"
|
||||
input-class="min-h-[270px] font-[family-name:var(--mono-font)]"
|
||||
:disabled="isStartupLoading"
|
||||
/>
|
||||
<div
|
||||
v-if="isStartupLoading"
|
||||
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
|
||||
>
|
||||
<SpinnerIcon class="h-6 w-6 animate-spin text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col gap-8">
|
||||
@@ -70,168 +53,203 @@
|
||||
different Java version to work properly.
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle id="show-all-versions" v-model="showAllVersions" class="flex-none" />
|
||||
<label for="show-all-versions" class="text-sm">Show all Java versions</label>
|
||||
<div class="relative max-w-xs">
|
||||
<Combobox
|
||||
:id="'java-version-field'"
|
||||
v-model="javaVersion"
|
||||
name="java-version"
|
||||
:options="displayedJavaVersions"
|
||||
:display-value="javaVersionLabel ?? 'Java Version'"
|
||||
:disabled="isStartupLoading"
|
||||
>
|
||||
<template #dropdown-footer>
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center justify-center gap-1.5 border-0 border-t border-solid border-surface-5 bg-transparent py-3 text-center text-sm font-semibold text-secondary transition-colors hover:text-contrast"
|
||||
@mousedown.prevent
|
||||
@click="showAllVersions = !showAllVersions"
|
||||
>
|
||||
<EyeOffIcon v-if="showAllVersions" class="size-4" />
|
||||
<EyeIcon v-else class="size-4" />
|
||||
{{ showAllVersions ? 'Hide extra versions' : 'Show all versions' }}
|
||||
</button>
|
||||
</template>
|
||||
</Combobox>
|
||||
<div
|
||||
v-if="isStartupLoading"
|
||||
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
|
||||
>
|
||||
<SpinnerIcon class="h-5 w-5 animate-spin text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
<Combobox
|
||||
:id="'java-version-field'"
|
||||
v-model="jdkVersion"
|
||||
name="java-version"
|
||||
:options="displayedJavaVersions.map((v) => ({ value: v, label: v }))"
|
||||
:display-value="jdkVersion ?? 'Java Version'"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-lg font-bold text-contrast">Runtime</span>
|
||||
<span> The Java runtime your server will use. </span>
|
||||
</div>
|
||||
<Combobox
|
||||
:id="'runtime-field'"
|
||||
v-model="jdkBuild"
|
||||
name="runtime"
|
||||
:options="['Corretto', 'Temurin', 'GraalVM'].map((v) => ({ value: v, label: v }))"
|
||||
:display-value="jdkBuild ?? 'Runtime'"
|
||||
/>
|
||||
<div class="relative max-w-xs">
|
||||
<Combobox
|
||||
:id="'runtime-field'"
|
||||
v-model="jreVendor"
|
||||
name="runtime"
|
||||
:options="JRE_VENDORS"
|
||||
:display-value="jreVendorLabel ?? 'Runtime'"
|
||||
:disabled="isStartupLoading"
|
||||
/>
|
||||
<div
|
||||
v-if="isStartupLoading"
|
||||
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
|
||||
>
|
||||
<SpinnerIcon class="h-5 w-5 animate-spin text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SaveBanner
|
||||
:is-visible="!!hasUnsavedChanges"
|
||||
:server="props.server"
|
||||
:is-updating="isUpdating"
|
||||
:save="saveStartup"
|
||||
:server-id="serverId"
|
||||
:is-updating="isPending"
|
||||
:save="() => saveStartup()"
|
||||
:reset="resetStartup"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IssuesIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { EyeIcon, EyeOffIcon, SpinnerIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
Toggle,
|
||||
} from '@modrinth/ui'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
|
||||
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
}>()
|
||||
const { server, serverId, worldId } = injectModrinthServerContext()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
await props.server.startup.fetch()
|
||||
const startupQueryKey = computed(() => ['servers', 'startup', 'v1', serverId, worldId.value])
|
||||
|
||||
const data = computed(() => props.server.general)
|
||||
const showAllVersions = ref(false)
|
||||
const { data: startupData, isLoading: isStartupLoading } = useQuery({
|
||||
queryKey: startupQueryKey,
|
||||
queryFn: () => client.archon.options_v1.getStartup(serverId, worldId.value!),
|
||||
enabled: computed(() => worldId.value !== null),
|
||||
})
|
||||
|
||||
const jdkVersionMap = [
|
||||
{ value: 'lts8', label: 'Java 8' },
|
||||
{ value: 'lts11', label: 'Java 11' },
|
||||
{ value: 'lts17', label: 'Java 17' },
|
||||
{ value: 'lts21', label: 'Java 21' },
|
||||
const JAVA_VERSIONS = [
|
||||
{ value: 8, label: 'Java 8' },
|
||||
{ value: 11, label: 'Java 11' },
|
||||
{ value: 17, label: 'Java 17' },
|
||||
{ value: 21, label: 'Java 21' },
|
||||
]
|
||||
|
||||
const jdkBuildMap = [
|
||||
const JRE_VENDORS: { value: Archon.Content.v1.JreVendor; label: string }[] = [
|
||||
{ value: 'corretto', label: 'Corretto' },
|
||||
{ value: 'temurin', label: 'Temurin' },
|
||||
{ value: 'graal', label: 'GraalVM' },
|
||||
]
|
||||
|
||||
const invocation = ref(props.server.startup.invocation)
|
||||
const jdkVersion = ref(
|
||||
jdkVersionMap.find((v) => v.value === props.server.startup.jdk_version)?.label,
|
||||
// Saved state derived directly from query
|
||||
const savedStartupCommand = computed(() => startupData.value?.startup_command ?? '')
|
||||
const savedJavaVersion = computed(() => startupData.value?.java_version ?? undefined)
|
||||
const savedJreVendor = computed(() => startupData.value?.jre_vendor ?? undefined)
|
||||
const defaultStartupCommand = computed(
|
||||
() => startupData.value?.original_invocation ?? savedStartupCommand.value,
|
||||
)
|
||||
const jdkBuild = ref(jdkBuildMap.find((v) => v.value === props.server.startup.jdk_build)?.label)
|
||||
|
||||
const originalInvocation = ref(invocation.value)
|
||||
const originalJdkVersion = ref(jdkVersion.value)
|
||||
const originalJdkBuild = ref(jdkBuild.value)
|
||||
// Local form state
|
||||
const startupCommand = ref('')
|
||||
const javaVersion = ref<number>()
|
||||
const jreVendor = ref<Archon.Content.v1.JreVendor>()
|
||||
|
||||
// Display labels for comboboxes
|
||||
const javaVersionLabel = computed(
|
||||
() => JAVA_VERSIONS.find((v) => v.value === javaVersion.value)?.label,
|
||||
)
|
||||
const jreVendorLabel = computed(() => JRE_VENDORS.find((v) => v.value === jreVendor.value)?.label)
|
||||
|
||||
function syncFormFromData() {
|
||||
startupCommand.value = savedStartupCommand.value
|
||||
javaVersion.value = savedJavaVersion.value
|
||||
jreVendor.value = savedJreVendor.value
|
||||
}
|
||||
|
||||
watch(
|
||||
startupData,
|
||||
(newData, oldData) => {
|
||||
if (newData && !oldData) {
|
||||
syncFormFromData()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const hasUnsavedChanges = computed(
|
||||
() =>
|
||||
invocation.value !== originalInvocation.value ||
|
||||
jdkVersion.value !== originalJdkVersion.value ||
|
||||
jdkBuild.value !== originalJdkBuild.value,
|
||||
startupCommand.value !== savedStartupCommand.value ||
|
||||
javaVersion.value !== savedJavaVersion.value ||
|
||||
jreVendor.value !== savedJreVendor.value,
|
||||
)
|
||||
|
||||
const isUpdating = ref(false)
|
||||
|
||||
const compatibleJavaVersions = computed(() => {
|
||||
const mcVersion = data.value?.mc_version ?? ''
|
||||
if (!mcVersion) return jdkVersionMap.map((v) => v.label)
|
||||
|
||||
const [major, minor] = mcVersion.split('.').map(Number)
|
||||
|
||||
if (major >= 1) {
|
||||
if (minor >= 20) return ['Java 21']
|
||||
if (minor >= 18) return ['Java 17', 'Java 21']
|
||||
if (minor >= 17) return ['Java 16', 'Java 17', 'Java 21']
|
||||
if (minor >= 12) return ['Java 8', 'Java 11', 'Java 17', 'Java 21']
|
||||
if (minor >= 6) return ['Java 8', 'Java 11']
|
||||
}
|
||||
|
||||
return ['Java 8']
|
||||
})
|
||||
// Java version filtering
|
||||
const showAllVersions = ref(false)
|
||||
|
||||
const displayedJavaVersions = computed(() => {
|
||||
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value
|
||||
if (showAllVersions.value) return JAVA_VERSIONS
|
||||
|
||||
const mcVersion = server.value?.mc_version ?? ''
|
||||
if (!mcVersion) return JAVA_VERSIONS
|
||||
|
||||
const [, minor] = mcVersion.split('.').map(Number)
|
||||
|
||||
if (minor >= 20) return JAVA_VERSIONS.filter((v) => v.value === 21)
|
||||
if (minor >= 17) return JAVA_VERSIONS.filter((v) => [17, 21].includes(v.value))
|
||||
if (minor >= 12) return JAVA_VERSIONS
|
||||
if (minor >= 6) return JAVA_VERSIONS.filter((v) => [8, 11].includes(v.value))
|
||||
return JAVA_VERSIONS.filter((v) => v.value === 8)
|
||||
})
|
||||
|
||||
async function saveStartup() {
|
||||
try {
|
||||
isUpdating.value = true
|
||||
const invocationValue = invocation.value ?? ''
|
||||
const jdkVersionKey = jdkVersionMap.find((v) => v.label === jdkVersion.value)?.value
|
||||
const jdkBuildKey = jdkBuildMap.find((v) => v.label === jdkBuild.value)?.value
|
||||
await props.server.startup?.update(invocationValue, jdkVersionKey as any, jdkBuildKey as any)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
await props.server.refresh(['startup'])
|
||||
|
||||
if (props.server.startup) {
|
||||
invocation.value = props.server.startup.invocation
|
||||
jdkVersion.value =
|
||||
jdkVersionMap.find((v) => v.value === props.server.startup?.jdk_version)?.label || ''
|
||||
jdkBuild.value =
|
||||
jdkBuildMap.find((v) => v.value === props.server.startup?.jdk_build)?.label || ''
|
||||
|
||||
originalInvocation.value = invocation.value
|
||||
originalJdkVersion.value = jdkVersion.value
|
||||
originalJdkBuild.value = jdkBuild.value
|
||||
}
|
||||
|
||||
// Save mutation
|
||||
const { mutate: saveStartup, isPending } = useMutation({
|
||||
mutationFn: () =>
|
||||
client.archon.options_v1.patchStartup(serverId, worldId.value!, {
|
||||
startup_command: startupCommand.value || null,
|
||||
java_version: javaVersion.value ?? null,
|
||||
jre_vendor: jreVendor.value ?? null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: startupQueryKey.value })
|
||||
syncFormFromData()
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Server settings updated',
|
||||
text: 'Your server settings were successfully changed.',
|
||||
})
|
||||
} catch (error) {
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to update server arguments',
|
||||
text: 'Please try again later.',
|
||||
})
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function resetStartup() {
|
||||
invocation.value = originalInvocation.value
|
||||
jdkVersion.value = originalJdkVersion.value
|
||||
jdkBuild.value = originalJdkBuild.value
|
||||
syncFormFromData()
|
||||
}
|
||||
|
||||
function resetToDefault() {
|
||||
invocation.value = originalInvocation.value ?? ''
|
||||
startupCommand.value = defaultStartupCommand.value
|
||||
}
|
||||
</script>
|
||||
|
||||
16
apps/frontend/src/providers/setup.ts
Normal file
16
apps/frontend/src/providers/setup.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { provideNotificationManager } from '@modrinth/ui'
|
||||
|
||||
import { FrontendNotificationManager } from './frontend-notifications'
|
||||
import { setupFilePickerProvider } from './setup/file-picker'
|
||||
import { setupModrinthClientProvider } from './setup/modrinth-client'
|
||||
import { setupPageContextProvider } from './setup/page-context'
|
||||
import { setupTagsProvider } from './setup/tags'
|
||||
|
||||
export function setupProviders(auth: Awaited<ReturnType<typeof useAuth>>) {
|
||||
provideNotificationManager(new FrontendNotificationManager())
|
||||
|
||||
setupModrinthClientProvider(auth)
|
||||
setupTagsProvider()
|
||||
setupFilePickerProvider()
|
||||
setupPageContextProvider()
|
||||
}
|
||||
23
apps/frontend/src/providers/setup/file-picker.ts
Normal file
23
apps/frontend/src/providers/setup/file-picker.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { provideFilePicker } from '@modrinth/ui'
|
||||
|
||||
function pickFile(accept: string): Promise<{ file: File; previewUrl: string } | null> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = accept
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return resolve(null)
|
||||
resolve({ file, previewUrl: URL.createObjectURL(file) })
|
||||
}
|
||||
input.oncancel = () => resolve(null)
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
|
||||
export function setupFilePickerProvider() {
|
||||
provideFilePicker({
|
||||
pickImage: () => pickFile('image/png,image/jpeg,image/jpg,image/svg+xml,image/webp,image/gif'),
|
||||
pickModpackFile: () => pickFile('.mrpack,application/x-modrinth-modpack+zip,application/zip'),
|
||||
})
|
||||
}
|
||||
14
apps/frontend/src/providers/setup/modrinth-client.ts
Normal file
14
apps/frontend/src/providers/setup/modrinth-client.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { provideModrinthClient } from '@modrinth/ui'
|
||||
|
||||
import { createModrinthClient } from '~/helpers/api.ts'
|
||||
|
||||
export function setupModrinthClientProvider(auth: Awaited<ReturnType<typeof useAuth>>) {
|
||||
const config = useRuntimeConfig()
|
||||
const client = createModrinthClient(auth, {
|
||||
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
|
||||
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
|
||||
rateLimitKey: config.rateLimitKey,
|
||||
})
|
||||
provideModrinthClient(client)
|
||||
return client
|
||||
}
|
||||
14
apps/frontend/src/providers/setup/page-context.ts
Normal file
14
apps/frontend/src/providers/setup/page-context.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { provideModalBehavior, providePageContext } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export function setupPageContextProvider() {
|
||||
const cosmetics = useCosmetics()
|
||||
|
||||
providePageContext({
|
||||
hierarchicalSidebarAvailable: ref(false),
|
||||
showAds: ref(false),
|
||||
})
|
||||
provideModalBehavior({
|
||||
noblur: computed(() => !(cosmetics.value?.advancedRendering ?? true)),
|
||||
})
|
||||
}
|
||||
10
apps/frontend/src/providers/setup/tags.ts
Normal file
10
apps/frontend/src/providers/setup/tags.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { provideTags } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export function setupTagsProvider() {
|
||||
const generatedState = useGeneratedState()
|
||||
provideTags({
|
||||
gameVersions: computed(() => generatedState.value.gameVersions),
|
||||
loaders: computed(() => generatedState.value.loaders),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user