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:
122
packages/ui/src/components/servers/InstallingBanner.vue
Normal file
122
packages/ui/src/components/servers/InstallingBanner.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<Admonition type="info" show-actions-underneath>
|
||||
<template #icon>
|
||||
<slot name="icon">
|
||||
<SpinnerIcon class="h-6 w-6 flex-none animate-spin text-brand-blue" />
|
||||
</slot>
|
||||
</template>
|
||||
<template #header>We're preparing your server!</template>
|
||||
<template v-if="progress">{{ phaseLabel }}</template>
|
||||
<div v-else class="ticker-container">
|
||||
<div class="ticker-content">
|
||||
<div
|
||||
v-for="(message, index) in tickerMessages"
|
||||
:key="message"
|
||||
class="ticker-item"
|
||||
:class="{ active: index === currentIndex % tickerMessages.length }"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #actions>
|
||||
<ProgressBar
|
||||
v-if="progress"
|
||||
:progress="progress.percent"
|
||||
:max="100"
|
||||
color="blue"
|
||||
full-width
|
||||
/>
|
||||
<ProgressBar v-else :progress="0" :max="1" color="blue" full-width waiting />
|
||||
</template>
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SpinnerIcon from '@modrinth/assets/icons/spinner.svg'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import Admonition from '../base/Admonition.vue'
|
||||
import ProgressBar from '../base/ProgressBar.vue'
|
||||
|
||||
export interface SyncProgress {
|
||||
phase: 'Analyzing' | 'InstallingPack' | 'InstallingLoader' | 'Addons'
|
||||
percent: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
progress?: SyncProgress | null
|
||||
}>()
|
||||
|
||||
const phaseLabel = computed(() => {
|
||||
switch (props.progress?.phase) {
|
||||
case 'InstallingLoader':
|
||||
return 'Installing platform...'
|
||||
case 'InstallingPack':
|
||||
return 'Installing modpack...'
|
||||
case 'Addons':
|
||||
return 'Installing addons...'
|
||||
default:
|
||||
return 'Installing...'
|
||||
}
|
||||
})
|
||||
|
||||
const tickerMessages = [
|
||||
'Organizing files...',
|
||||
'Downloading mods...',
|
||||
'Configuring server...',
|
||||
'Setting up environment...',
|
||||
'Adding Java...',
|
||||
]
|
||||
|
||||
const currentIndex = ref(0)
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
intervalId = setInterval(() => {
|
||||
currentIndex.value = (currentIndex.value + 1) % tickerMessages.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;
|
||||
white-space: nowrap;
|
||||
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>
|
||||
@@ -203,8 +203,8 @@ async function dataURLToBlob(dataURL: string): Promise<Blob> {
|
||||
|
||||
const { data: image } = useQuery({
|
||||
queryKey: ['server-icon', props.server_id] as const,
|
||||
queryFn: async (): Promise<string | undefined> => {
|
||||
if (!props.server_id || props.status !== 'available') return undefined
|
||||
queryFn: async (): Promise<string | null> => {
|
||||
if (!props.server_id || props.status !== 'available') return null
|
||||
|
||||
try {
|
||||
const auth = await archon.servers_v0.getFilesystemAuth(props.server_id)
|
||||
@@ -244,8 +244,10 @@ const { data: image } = useQuery({
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Icon processing failed:', error)
|
||||
return undefined
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
enabled: computed(() => !!props.server_id && props.status === 'available'),
|
||||
})
|
||||
|
||||
254
packages/ui/src/components/servers/ServerSetupModal.vue
Normal file
254
packages/ui/src/components/servers/ServerSetupModal.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<CreationFlowModal
|
||||
ref="creationFlowRef"
|
||||
type="reset-server"
|
||||
:available-loaders="serverLoaders"
|
||||
:show-snapshot-toggle="true"
|
||||
:disable-close="props.initialSetup"
|
||||
:is-initial-setup="props.initialSetup"
|
||||
:initial-loader="initialLoader"
|
||||
:initial-game-version="initialGameVersion"
|
||||
:fade="props.initialSetup ? undefined : 'danger'"
|
||||
:search-modpacks="searchModpacks"
|
||||
:get-project-versions="getProjectVersions"
|
||||
@create="onFlowComplete"
|
||||
@hide="$emit('hide')"
|
||||
@browse-modpacks="$emit('browse-modpacks')"
|
||||
/>
|
||||
|
||||
<NewModal
|
||||
ref="uploadModal"
|
||||
:header="formatMessage(messages.uploadingModpackHeader)"
|
||||
:closable="false"
|
||||
>
|
||||
<div class="flex flex-col gap-4 md:w-[400px]">
|
||||
<AppearingProgressBar :max-value="totalBytes" :current-value="uploadedBytes" />
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.uploadWarningText) }}</p>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon, ModrinthApiError } from '@modrinth/api-client'
|
||||
import { computed, nextTick, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { injectModrinthClient } from '../../providers/api-client'
|
||||
import { injectModrinthServerContext } from '../../providers/server-context'
|
||||
import { injectNotificationManager } from '../../providers/web-notifications'
|
||||
import { AppearingProgressBar } from '../base'
|
||||
import type { CreationFlowContextValue } from '../flows/creation-flow-modal/creation-flow-context'
|
||||
import CreationFlowModal from '../flows/creation-flow-modal/index.vue'
|
||||
import { NewModal } from '../modal'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
uploadingModpackHeader: {
|
||||
id: 'servers.setup.uploading-modpack.header',
|
||||
defaultMessage: 'Uploading modpack',
|
||||
},
|
||||
uploadWarningText: {
|
||||
id: 'servers.setup.upload-warning',
|
||||
defaultMessage: "Please don't close this page while uploading.",
|
||||
},
|
||||
rateLimitTitle: {
|
||||
id: 'servers.setup.rate-limit.title',
|
||||
defaultMessage: 'Cannot reinstall server',
|
||||
},
|
||||
rateLimitText: {
|
||||
id: 'servers.setup.rate-limit.text',
|
||||
defaultMessage: 'You are being rate limited. Please try again later.',
|
||||
},
|
||||
reinstallFailedTitle: {
|
||||
id: 'servers.setup.reinstall-failed.title',
|
||||
defaultMessage: 'Reinstall Failed',
|
||||
},
|
||||
reinstallFailedText: {
|
||||
id: 'servers.setup.reinstall-failed.text',
|
||||
defaultMessage: 'An unexpected error occurred while reinstalling. Please try again later.',
|
||||
},
|
||||
})
|
||||
|
||||
const debug = useDebugLogger('ServerSetupModal')
|
||||
const client = injectModrinthClient()
|
||||
const serverContext = injectModrinthServerContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const serverLoaders = ['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']
|
||||
|
||||
async function searchModpacks(query: string, limit: number = 10) {
|
||||
return client.labrinth.projects_v2.search({
|
||||
query: query || undefined,
|
||||
facets: [['project_type:modpack'], ['client_side:required'], ['server_side:required']],
|
||||
limit,
|
||||
})
|
||||
}
|
||||
|
||||
async function getProjectVersions(projectId: string) {
|
||||
const versions = await client.labrinth.versions_v3.getProjectVersions(projectId)
|
||||
return versions.map((v) => ({ id: v.id }))
|
||||
}
|
||||
|
||||
function toApiLoader(loader: string): Archon.Content.v1.Modloader {
|
||||
if (loader === 'neoforge') return 'neo_forge'
|
||||
return loader as Archon.Content.v1.Modloader
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
initialSetup?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
reinstall: [args?: { loader: string; lVersion: string; mVersion: string | null }]
|
||||
hide: []
|
||||
'browse-modpacks': []
|
||||
}>()
|
||||
|
||||
const initialLoader = computed(() => {
|
||||
const loader = serverContext.server.value.loader
|
||||
if (!loader || loader === 'Vanilla') return undefined
|
||||
return loader.toLowerCase()
|
||||
})
|
||||
|
||||
const initialGameVersion = computed(() => serverContext.server.value.mc_version ?? undefined)
|
||||
|
||||
const creationFlowRef = useTemplateRef<InstanceType<typeof CreationFlowModal>>('creationFlowRef')
|
||||
const uploadModal = useTemplateRef<InstanceType<typeof NewModal>>('uploadModal')
|
||||
|
||||
const uploadedBytes = ref(0)
|
||||
const totalBytes = ref(0)
|
||||
|
||||
async function onFlowComplete(ctx: CreationFlowContextValue) {
|
||||
debug('onFlowComplete:', {
|
||||
setupType: ctx.setupType.value,
|
||||
hasModpackFile: !!ctx.modpackFile.value,
|
||||
modpackSelection: ctx.modpackSelection.value,
|
||||
selectedLoader: ctx.selectedLoader.value,
|
||||
selectedGameVersion: ctx.selectedGameVersion.value,
|
||||
selectedLoaderVersion: ctx.selectedLoaderVersion.value,
|
||||
worldId: serverContext.worldId.value,
|
||||
})
|
||||
|
||||
try {
|
||||
if (ctx.setupType.value === 'modpack' && ctx.modpackFile.value) {
|
||||
debug('onFlowComplete: mrpack upload path')
|
||||
await handleMrpackUpload(ctx.modpackFile.value, ctx.buildProperties())
|
||||
} else if (ctx.setupType.value === 'modpack' && ctx.modpackSelection.value) {
|
||||
debug('onFlowComplete: modpack selection path, calling installContent')
|
||||
await client.archon.content_v1.installContent(
|
||||
serverContext.serverId,
|
||||
serverContext.worldId.value!,
|
||||
{
|
||||
content_variant: 'modpack',
|
||||
spec: {
|
||||
platform: 'modrinth',
|
||||
project_id: ctx.modpackSelection.value.projectId,
|
||||
version_id: ctx.modpackSelection.value.versionId,
|
||||
},
|
||||
soft_override: false,
|
||||
properties: ctx.buildProperties(),
|
||||
},
|
||||
)
|
||||
debug('onFlowComplete: modpack installContent returned, emitting reinstall')
|
||||
emitReinstall()
|
||||
} else {
|
||||
const loader = ctx.selectedLoader.value
|
||||
const loaderVersion =
|
||||
!loader || loader === 'vanilla' ? '' : (ctx.selectedLoaderVersion.value ?? '')
|
||||
|
||||
debug('onFlowComplete: bare install path', {
|
||||
loader,
|
||||
loaderVersion,
|
||||
gameVersion: ctx.selectedGameVersion.value,
|
||||
apiLoader: toApiLoader(loader ?? 'vanilla'),
|
||||
})
|
||||
|
||||
await client.archon.content_v1.installContent(
|
||||
serverContext.serverId,
|
||||
serverContext.worldId.value!,
|
||||
{
|
||||
content_variant: 'bare',
|
||||
loader: toApiLoader(loader ?? 'vanilla'),
|
||||
version: loaderVersion,
|
||||
game_version: ctx.selectedGameVersion.value ?? undefined,
|
||||
soft_override: false,
|
||||
properties: ctx.buildProperties(),
|
||||
},
|
||||
)
|
||||
|
||||
debug('onFlowComplete: bare installContent returned, emitting reinstall')
|
||||
emitReinstall({
|
||||
loader: loader ?? 'vanilla',
|
||||
lVersion: loaderVersion,
|
||||
mVersion: ctx.selectedGameVersion.value,
|
||||
})
|
||||
}
|
||||
|
||||
creationFlowRef.value?.hide()
|
||||
} catch (error) {
|
||||
debug('onFlowComplete: ERROR', error)
|
||||
if ((error as ModrinthApiError).statusCode === 429) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.rateLimitTitle),
|
||||
text: formatMessage(messages.rateLimitText),
|
||||
type: 'error',
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
title: formatMessage(messages.reinstallFailedTitle),
|
||||
text: formatMessage(messages.reinstallFailedText),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
ctx.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMrpackUpload(file: File, properties: Archon.Content.v1.PropertiesFields) {
|
||||
uploadedBytes.value = 0
|
||||
totalBytes.value = file.size
|
||||
|
||||
creationFlowRef.value?.hide()
|
||||
await nextTick()
|
||||
uploadModal.value?.show()
|
||||
|
||||
try {
|
||||
const handle = client.kyros.content_v1.uploadModpackFile(
|
||||
serverContext.worldId.value!,
|
||||
file,
|
||||
properties,
|
||||
{
|
||||
softOverride: false,
|
||||
onProgress: ({ loaded, total }) => {
|
||||
uploadedBytes.value = loaded
|
||||
totalBytes.value = total
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await handle.promise
|
||||
emitReinstall()
|
||||
} finally {
|
||||
uploadModal.value?.hide()
|
||||
}
|
||||
}
|
||||
|
||||
function emitReinstall(args?: { loader: string; lVersion: string; mVersion: string | null }) {
|
||||
debug('emitReinstall:', args)
|
||||
emit('reinstall', args)
|
||||
}
|
||||
|
||||
function show() {
|
||||
creationFlowRef.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
creationFlowRef.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide, ctx: computed(() => creationFlowRef.value?.ctx) })
|
||||
</script>
|
||||
@@ -90,7 +90,8 @@ const props = defineProps<{
|
||||
const backupsQueryKey = ['backups', 'list', ctx.serverId]
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => client.archon.backups_v0.create(ctx.serverId, { name }),
|
||||
mutationFn: (name: string) =>
|
||||
client.archon.backups_v1.create(ctx.serverId, ctx.worldId.value!, { name }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
|
||||
})
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ const backupsQueryKey = ['backups', 'list', ctx.serverId]
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
|
||||
client.archon.backups_v0.rename(ctx.serverId, backupId, { name }),
|
||||
client.archon.backups_v1.rename(ctx.serverId, ctx.worldId.value!, backupId, { name }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
|
||||
})
|
||||
|
||||
|
||||
@@ -58,7 +58,8 @@ const ctx = injectModrinthServerContext()
|
||||
|
||||
const backupsQueryKey = ['backups', 'list', ctx.serverId]
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: (backupId: string) => client.archon.backups_v0.restore(ctx.serverId, backupId),
|
||||
mutationFn: (backupId: string) =>
|
||||
client.archon.backups_v1.restore(ctx.serverId, ctx.worldId.value!, backupId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<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 type { Labrinth } from '@modrinth/api-client'
|
||||
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 { computed, ref } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
export type ListedGameVersion = {
|
||||
name: string
|
||||
release: boolean
|
||||
}
|
||||
|
||||
export type ListedPlatform = {
|
||||
name: string
|
||||
isType: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
versions: Labrinth.Versions.v2.Version[]
|
||||
gameVersions: Labrinth.Tags.v2.GameVersion[]
|
||||
listedGameVersions: ListedGameVersion[]
|
||||
listedPlatforms: ListedPlatform[]
|
||||
baseId?: string
|
||||
type: 'Mod' | 'Plugin'
|
||||
platformTags: {
|
||||
name: string
|
||||
supported_project_types: string[]
|
||||
}[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:query'])
|
||||
|
||||
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[]>([])
|
||||
|
||||
function updateFilters() {
|
||||
emit('update:query', {
|
||||
g: selectedGameVersions.value,
|
||||
l: selectedPlatforms.value,
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
selectedGameVersions,
|
||||
selectedPlatforms,
|
||||
})
|
||||
</script>
|
||||
@@ -97,6 +97,8 @@
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Create new..."
|
||||
:disabled="disabled"
|
||||
:tooltip="disabled ? disabledTooltip : undefined"
|
||||
class="!h-10 justify-center gap-2 !border-[1px] !border-surface-5"
|
||||
:options="[
|
||||
{ id: 'file', action: () => $emit('create', 'file') },
|
||||
@@ -191,6 +193,8 @@ const props = defineProps<{
|
||||
searchQuery: string
|
||||
showRefreshButton?: boolean
|
||||
baseId: string
|
||||
disabled?: boolean
|
||||
disabledTooltip?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -99,6 +99,8 @@ interface FileItemProps {
|
||||
index: number
|
||||
isLast: boolean
|
||||
selected: boolean
|
||||
writeDisabled?: boolean
|
||||
writeDisabledTooltip?: string
|
||||
}
|
||||
|
||||
const props = defineProps<FileItemProps>()
|
||||
@@ -145,35 +147,48 @@ 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 menuOptions = computed(() => {
|
||||
const item = { name: props.name, type: props.type, path: props.path }
|
||||
const wd = props.writeDisabled
|
||||
const wdTooltip = props.writeDisabledTooltip
|
||||
return [
|
||||
{
|
||||
id: 'extract',
|
||||
shown: isZip.value,
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => emit('extract', item),
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown: isZip.value,
|
||||
},
|
||||
{
|
||||
id: 'rename',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => emit('rename', item),
|
||||
},
|
||||
{
|
||||
id: 'move',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => emit('move', item),
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
action: () => emit('download', item),
|
||||
shown: props.type !== 'directory',
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => emit('delete', item),
|
||||
color: 'red' as const,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
if (props.type === 'directory') {
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
:index="visibleRange.start + idx"
|
||||
:is-last="visibleRange.start + idx === props.items.length - 1"
|
||||
:selected="selectedItems.has(item.path)"
|
||||
:write-disabled="writeDisabled"
|
||||
:write-disabled-tooltip="writeDisabledTooltip"
|
||||
@delete="$emit('delete', item)"
|
||||
@rename="$emit('rename', item)"
|
||||
@extract="$emit('extract', item)"
|
||||
@@ -47,13 +49,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Kyros } from '@modrinth/api-client'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { toRef } from 'vue'
|
||||
|
||||
import { useVirtualScroll } from '../../../../composables/virtual-scroll'
|
||||
import FileItem from './FileItem.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
items: Kyros.Files.v0.DirectoryItem[]
|
||||
selectedItems: Set<string>
|
||||
writeDisabled?: boolean
|
||||
writeDisabledTooltip?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -70,64 +75,12 @@ const emit = defineEmits<{
|
||||
'toggle-select': [path: string]
|
||||
}>()
|
||||
|
||||
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)
|
||||
})
|
||||
const { listContainer, totalHeight, visibleRange, visibleTop, visibleItems } = useVirtualScroll(
|
||||
toRef(props, 'items'),
|
||||
{
|
||||
itemHeight: 61,
|
||||
bufferSize: 5,
|
||||
onNearEnd: () => emit('loadMore'),
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<button
|
||||
ref="triggerRef"
|
||||
class="teleport-overflow-menu-trigger"
|
||||
:class="btnClass"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-haspopup="true"
|
||||
@mousedown="handleMouseDown"
|
||||
@@ -45,6 +46,8 @@
|
||||
if (el) menuItemsRef[index] = el as HTMLElement
|
||||
}
|
||||
"
|
||||
v-tooltip="option.tooltip"
|
||||
:disabled="option.disabled"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@@ -92,6 +95,8 @@ interface Option {
|
||||
action?: (() => void) | string
|
||||
shown?: boolean
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
type Divider = {
|
||||
@@ -109,14 +114,17 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
options: Item[]
|
||||
hoverable?: boolean
|
||||
btnClass?: string | string[] | Record<string, boolean>
|
||||
}>(),
|
||||
{
|
||||
hoverable: false,
|
||||
btnClass: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [option: Option]
|
||||
open: []
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
@@ -187,6 +195,7 @@ const toggleMenu = (event: MouseEvent) => {
|
||||
|
||||
const openMenu = () => {
|
||||
isOpen.value = true
|
||||
emit('open')
|
||||
disableBodyScroll()
|
||||
nextTick(() => {
|
||||
menuStyle.value = calculateMenuPosition()
|
||||
@@ -255,6 +264,7 @@ const handleMouseMove = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
const handleItemClick = (option: Option, index: number) => {
|
||||
if (option.disabled) return
|
||||
selectedIndex.value = index
|
||||
selectOption(option)
|
||||
}
|
||||
|
||||
11
packages/ui/src/components/servers/flows/index.ts
Normal file
11
packages/ui/src/components/servers/flows/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
CreationFlowContextValue,
|
||||
CreationFlowOptions,
|
||||
Difficulty,
|
||||
FlowType,
|
||||
Gamemode,
|
||||
GeneratorSettingsMode,
|
||||
LoaderVersionType,
|
||||
SetupType,
|
||||
} from '../../flows/creation-flow-modal/creation-flow-context'
|
||||
export { default as CreationFlowModal } from '../../flows/creation-flow-modal/index.vue'
|
||||
@@ -1,8 +1,11 @@
|
||||
export * from './backups'
|
||||
export * from './files'
|
||||
export * from './flows'
|
||||
export * from './icons'
|
||||
export { default as InstallingBanner } from './InstallingBanner.vue'
|
||||
export * from './labels'
|
||||
export * from './marketing'
|
||||
export type { PendingChange } from './ServerListing.vue'
|
||||
export { default as ServerListing } from './ServerListing.vue'
|
||||
export { default as ServerSetupModal } from './ServerSetupModal.vue'
|
||||
export { default as ServersPromo } from './ServersPromo.vue'
|
||||
|
||||
Reference in New Issue
Block a user