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:
Calum H.
2026-03-12 20:24:32 +00:00
committed by GitHub
parent f0224dfff7
commit 7d92e4ec7f
302 changed files with 20016 additions and 12142 deletions

View File

@@ -4,6 +4,7 @@
:stages="ctx.stageConfigs"
:context="ctx"
:breadcrumbs="!editingVersion"
:close-on-click-outside="false"
@hide="() => (modalOpen = false)"
/>
<DropArea

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(() =>

View File

@@ -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,
)

View File

@@ -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>

View File

@@ -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()

View File

@@ -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>

View File

@@ -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>

View File

@@ -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[]) => {