refactor: align files tab with content tab design (#5621)

* fix: files.vue bugs before styling changes

* feat: move files tab to shared layout structure

* fix: qa

* fix: qa

* fix: bugs

* fix: lint

* fix: admonition cleanup with progress + actions

* fix: cleanup

* fix: modals

* fix: admon title

* fix: i18n standard

* fix: lint + i18n pass

* fix: remove transition

* fix: type errors

* feat: files tab in app

* fix: qa

* fix: backup item minmax

* fix: use ContentPageHeader for server panel

* fix: lint

* fix: lint

* fix: lint

* feat: page leave safety

* fix: lint

* fix: cargo fmt fix

* fix: blank in prod

* fix: content card table stuff

* Revert "fix: blank in prod"

This reverts commit 74758fe185cf85a4a20355857f889cb091b97ace.

* fix: import

* feat: browse worlds/servers flow

* fix: worlds tab parity with content tab

* fix: perf bug + shader filter pill copy

* feat: singleplayer filter

* fix: ordering

* fix: breadcrumbs

* fix: lint

* fix: qa

* feat: store server proj id when adding to a non-linked instance

* fix: lint

* fix: i18n + qa

* fix: conflict

* qa: already installed modal + placeholders not server-specific

* fix: qa

* fix: add + edit server modals

* fix: qa

* fix: security

* fix: devin flags

* fix: lint

* chore: change file to break build cache

* fix: admon

* fix: import path stuff

* feat: qa

* fix: fmt fmt idiot

---------

Signed-off-by: Calum H. <calum@modrinth.com>
This commit is contained in:
Calum H.
2026-03-26 18:55:15 +00:00
committed by GitHub
parent 706eb800cb
commit 381ea51cce
170 changed files with 8052 additions and 4571 deletions

View File

@@ -86,6 +86,7 @@ Each project may have its own `CLAUDE.md` with detailed instructions:
- Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to - Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to
- For Frontend, when doing lint checks, only use the `prepr` commands, do not use `typecheck` or `tsc` etc. - For Frontend, when doing lint checks, only use the `prepr` commands, do not use `typecheck` or `tsc` etc.
- Types in `@modrinth/utils` are considered highly outdated, if a component needs them, check if you can switch said component to use types from `packages/api-client` - Types in `@modrinth/utils` are considered highly outdated, if a component needs them, check if you can switch said component to use types from `packages/api-client`
- When provided problems, do not say "I didn't introduce these problems" (shifting the blame/effort) - just fix them.
## Edit Tool - Whitespace Handling (CLAUDE ONLY) ## Edit Tool - Whitespace Handling (CLAUDE ONLY)

2
Cargo.lock generated
View File

@@ -10241,6 +10241,7 @@ dependencies = [
name = "theseus_gui" name = "theseus_gui"
version = "1.0.0-local" version = "1.0.0-local"
dependencies = [ dependencies = [
"async_zip",
"chrono", "chrono",
"daedalus", "daedalus",
"dashmap", "dashmap",
@@ -10258,6 +10259,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-deep-link", "tauri-plugin-deep-link",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-http", "tauri-plugin-http",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-os", "tauri-plugin-os",

View File

@@ -179,6 +179,7 @@ tauri = "2.8.5"
tauri-build = "2.4.1" tauri-build = "2.4.1"
tauri-plugin-deep-link = "2.4.3" tauri-plugin-deep-link = "2.4.3"
tauri-plugin-dialog = "2.4.0" tauri-plugin-dialog = "2.4.0"
tauri-plugin-fs = "2.4.5"
tauri-plugin-http = "2.5.7" tauri-plugin-http = "2.5.7"
tauri-plugin-opener = "2.5.0" tauri-plugin-opener = "2.5.0"
tauri-plugin-os = "2.3.1" tauri-plugin-os = "2.3.1"

View File

@@ -22,6 +22,7 @@
"@tanstack/vue-query": "^5.90.7", "@tanstack/vue-query": "^5.90.7",
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1", "@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-http": "~2.5.7", "@tauri-apps/plugin-http": "~2.5.7",
"@tauri-apps/plugin-opener": "^2.2.6", "@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-os": "^2.2.1",

View File

@@ -3,6 +3,7 @@
ref="outerRef" ref="outerRef"
data-tauri-drag-region data-tauri-drag-region
class="min-w-0 overflow-hidden pl-3" class="min-w-0 overflow-hidden pl-3"
:class="{ 'breadcrumb-fade-mask': isOverflowing }"
:style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined" :style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined"
@mouseenter="onMouseEnter" @mouseenter="onMouseEnter"
@mouseleave="onMouseLeave" @mouseleave="onMouseLeave"
@@ -128,6 +129,16 @@ watch(breadcrumbs, () => {
</script> </script>
<style scoped> <style scoped>
.breadcrumb-fade-mask {
mask-image: linear-gradient(
to right,
transparent,
black 12px,
black calc(100% - 12px),
transparent
);
}
.breadcrumbs-scroll { .breadcrumbs-scroll {
animation: breadcrumb-scroll 10s ease-in-out infinite; animation: breadcrumb-scroll 10s ease-in-out infinite;
} }

View File

@@ -2,6 +2,7 @@
import { GameIcon, LeftArrowIcon } from '@modrinth/assets' import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, FormattedTag } from '@modrinth/ui' import { Avatar, ButtonStyled, FormattedTag } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { computed } from 'vue'
type Instance = { type Instance = {
game_version: string game_version: string
@@ -12,18 +13,23 @@ type Instance = {
name: string name: string
} }
const props = withDefaults(
defineProps<{ defineProps<{
instance: Instance instance: Instance
}>() backTab?: string
}>(),
{ backTab: undefined },
)
const instanceLink = computed(() => {
const base = `/instance/${encodeURIComponent(props.instance.path)}`
return props.backTab ? `${base}/${props.backTab}` : base
})
</script> </script>
<template> <template>
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4"> <div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
<router-link <router-link :to="instanceLink" tabindex="-1" class="flex flex-col gap-4 text-primary">
:to="`/instance/${encodeURIComponent(instance.path)}`"
tabindex="-1"
class="flex flex-col gap-4 text-primary"
>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<Avatar <Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined" :src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
@@ -43,9 +49,7 @@ defineProps<{
</span> </span>
</router-link> </router-link>
<ButtonStyled> <ButtonStyled>
<router-link :to="`/instance/${encodeURIComponent(instance.path)}`"> <router-link :to="instanceLink"> <LeftArrowIcon /> Back to instance </router-link>
<LeftArrowIcon /> Back to instance
</router-link>
</ButtonStyled> </ButtonStyled>
</div> </div>
</template> </template>

View File

@@ -1,185 +0,0 @@
<template>
<nav
v-if="filteredLinks.length > 1"
ref="scrollContainer"
class="card-shadow experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<RouterLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="tabLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
:class="`button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full ${activeIndex === index && !subpageSelected ? 'text-button-textSelected' : activeIndex === index && subpageSelected ? 'text-contrast' : 'text-primary'}`"
>
<component :is="link.icon" v-if="link.icon" class="size-5" />
<span class="text-nowrap">{{ link.label }}</span>
</RouterLink>
<div
:class="[
'pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1',
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
{ 'navtabs-transition': transitionsEnabled },
]"
:style="{
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: sliderReady && activeIndex !== -1 ? 1 : 0,
}"
aria-hidden="true"
></div>
</nav>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { RouterLink, useRoute } from 'vue-router'
const route = useRoute()
interface Tab {
label: string
href: string | RouteLocationRaw
shown?: boolean
icon?: unknown
subpages?: string[]
}
const props = defineProps<{
links: Tab[]
query?: string
}>()
const scrollContainer = ref<HTMLElement | null>(null)
const sliderLeft = ref(4)
const sliderTop = ref(4)
const sliderRight = ref(4)
const sliderBottom = ref(4)
const activeIndex = ref(-1)
const subpageSelected = ref(false)
const sliderReady = ref(false)
const transitionsEnabled = ref(false)
const sliderDelays = ref({ left: '0ms', top: '0ms', right: '0ms', bottom: '0ms' })
const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
)
const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
const sliderTopPx = computed(() => `${sliderTop.value}px`)
const sliderRightPx = computed(() => `${sliderRight.value}px`)
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
const leftDelay = computed(() => sliderDelays.value.left)
const rightDelay = computed(() => sliderDelays.value.right)
const topDelay = computed(() => sliderDelays.value.top)
const bottomDelay = computed(() => sliderDelays.value.bottom)
function pickLink() {
let index = -1
subpageSelected.value = false
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]
if (route.path === (typeof link.href === 'string' ? link.href : link.href.path)) {
index = i
break
} else if (link.subpages && link.subpages.some((subpage) => route.path.includes(subpage))) {
index = i
subpageSelected.value = true
break
}
}
activeIndex.value = index
if (activeIndex.value !== -1) {
startAnimation()
} else {
sliderLeft.value = 0
sliderRight.value = 0
}
}
function getTabElement(index: number): HTMLElement | null {
if (index === -1) return null
const container = scrollContainer.value
if (!container) return null
const tabs = container.querySelectorAll('.button-animation')
return (tabs[index] as HTMLElement) ?? null
}
function startAnimation() {
const el = getTabElement(activeIndex.value)
if (!el?.offsetParent) return
const parent = el.offsetParent as HTMLElement
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: parent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: parent.offsetHeight - el.offsetTop - el.offsetHeight,
}
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
if (isInitialPosition) {
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
sliderReady.value = true
requestAnimationFrame(() => {
transitionsEnabled.value = true
})
} else {
const STAGGER_DELAY = '200ms'
sliderDelays.value = {
left: newValues.left < sliderLeft.value ? '0ms' : STAGGER_DELAY,
right: newValues.left < sliderLeft.value ? STAGGER_DELAY : '0ms',
top: newValues.top < sliderTop.value ? '0ms' : STAGGER_DELAY,
bottom: newValues.top < sliderTop.value ? STAGGER_DELAY : '0ms',
}
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
}
}
onMounted(async () => {
window.addEventListener('resize', pickLink)
await nextTick()
pickLink()
})
onUnmounted(() => {
window.removeEventListener('resize', pickLink)
})
watch(
filteredLinks,
async () => {
await nextTick()
pickLink()
},
{ deep: true },
)
watch(route, async () => {
await nextTick()
pickLink()
})
</script>
<style scoped>
.navtabs-transition {
/* Delay on opacity is to hide any jankiness as the page loads */
transition:
left 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(leftDelay),
right 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(rightDelay),
top 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(topDelay),
bottom 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(bottomDelay),
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
</style>

View File

@@ -30,19 +30,19 @@ const deleteConfirmModal = ref()
const { instance } = injectInstanceSettings() const { instance } = injectInstanceSettings()
const title = ref(instance.name) const title = ref(instance.value.name)
const icon: Ref<string | undefined> = ref(instance.icon_path) const icon: Ref<string | undefined> = ref(instance.value.icon_path)
const groups = ref(instance.groups) const groups = ref([...instance.value.groups])
const newCategoryInput = ref('') const newCategoryInput = ref('')
const installing = computed(() => instance.install_stage !== 'installed') const installing = computed(() => instance.value.install_stage !== 'installed')
async function duplicateProfile() { async function duplicateProfile() {
await duplicate(instance.path).catch(handleError) await duplicate(instance.value.path).catch(handleError)
trackEvent('InstanceDuplicate', { trackEvent('InstanceDuplicate', {
loader: instance.loader, loader: instance.value.loader,
game_version: instance.game_version, game_version: instance.value.game_version,
}) })
} }
@@ -53,7 +53,7 @@ const availableGroups = computed(() => [
async function resetIcon() { async function resetIcon() {
icon.value = undefined icon.value = undefined
await edit_icon(instance.path, null).catch(handleError) await edit_icon(instance.value.path, null).catch(handleError)
trackEvent('InstanceRemoveIcon') trackEvent('InstanceRemoveIcon')
} }
@@ -71,7 +71,7 @@ async function setIcon() {
if (!value) return if (!value) return
icon.value = value icon.value = value
await edit_icon(instance.path, icon.value).catch(handleError) await edit_icon(instance.value.path, icon.value).catch(handleError)
trackEvent('InstanceSetIcon') trackEvent('InstanceSetIcon')
} }
@@ -102,7 +102,7 @@ watch(
[title, groups, groups], [title, groups, groups],
async () => { async () => {
if (removing.value) return if (removing.value) return
await edit(instance.path, editProfileObject.value).catch(handleError) await edit(instance.value.path, editProfileObject.value).catch(handleError)
}, },
{ deep: true }, { deep: true },
) )
@@ -110,11 +110,11 @@ watch(
const removing = ref(false) const removing = ref(false)
async function removeProfile() { async function removeProfile() {
removing.value = true removing.value = true
const path = instance.path const path = instance.value.path
trackEvent('InstanceRemove', { trackEvent('InstanceRemove', {
loader: instance.loader, loader: instance.value.loader,
game_version: instance.game_version, game_version: instance.value.game_version,
}) })
await router.push({ path: '/' }) await router.push({ path: '/' })

View File

@@ -22,9 +22,11 @@ const { instance } = injectInstanceSettings()
const globalSettings = (await get().catch(handleError)) as AppSettings const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideHooks = ref( const overrideHooks = ref(
!!instance.hooks.pre_launch || !!instance.hooks.wrapper || !!instance.hooks.post_exit, !!instance.value.hooks.pre_launch ||
!!instance.value.hooks.wrapper ||
!!instance.value.hooks.post_exit,
) )
const hooks = ref(instance.hooks ?? globalSettings.hooks) const hooks = ref(instance.value.hooks ?? globalSettings.hooks)
const editProfileObject = computed(() => { const editProfileObject = computed(() => {
const editProfile: { const editProfile: {
@@ -40,7 +42,7 @@ const editProfileObject = computed(() => {
watch( watch(
[overrideHooks, hooks], [overrideHooks, hooks],
async () => { async () => {
await edit(instance.path, editProfileObject.value) await edit(instance.value.path, editProfileObject.value)
}, },
{ deep: true }, { deep: true },
) )

View File

@@ -73,9 +73,9 @@ const [
]) ])
const { data: modpackInfo } = useQuery({ const { data: modpackInfo } = useQuery({
queryKey: computed(() => ['linkedModpackInfo', instance.path]), queryKey: computed(() => ['linkedModpackInfo', instance.value.path]),
queryFn: () => get_linked_modpack_info(instance.path, 'must_revalidate'), queryFn: () => get_linked_modpack_info(instance.value.path, 'must_revalidate'),
enabled: computed(() => !!instance.linked_data?.project_id && !offline), enabled: computed(() => !!instance.value.linked_data?.project_id && !offline),
}) })
const repairing = ref(false) const repairing = ref(false)
@@ -101,13 +101,13 @@ function getManifest(loader: string) {
provideAppBackup({ provideAppBackup({
async createBackup() { async createBackup() {
const allProfiles = await list() const allProfiles = await list()
const prefix = `${instance.name} - Backup #` const prefix = `${instance.value.name} - Backup #`
const existingNums = allProfiles const existingNums = allProfiles
.filter((p) => p.name.startsWith(prefix)) .filter((p) => p.name.startsWith(prefix))
.map((p) => parseInt(p.name.slice(prefix.length), 10)) .map((p) => parseInt(p.name.slice(prefix.length), 10))
.filter((n) => !isNaN(n)) .filter((n) => !isNaN(n))
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1 const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const newPath = await duplicate(instance.path) const newPath = await duplicate(instance.value.path)
await edit(newPath, { name: `${prefix}${nextNum}` }) await edit(newPath, { name: `${prefix}${nextNum}` })
}, },
}) })
@@ -118,27 +118,30 @@ provideInstallationSettings({
const rows = [ const rows = [
{ {
label: formatMessage(commonMessages.platformLabel), label: formatMessage(commonMessages.platformLabel),
value: formatLoaderLabel(instance.loader), value: formatLoaderLabel(instance.value.loader),
}, },
{ {
label: formatMessage(commonMessages.gameVersionLabel), label: formatMessage(commonMessages.gameVersionLabel),
value: instance.game_version, value: instance.value.game_version,
}, },
] ]
if (instance.loader !== 'vanilla' && instance.loader_version) { if (instance.value.loader !== 'vanilla' && instance.value.loader_version) {
rows.push({ rows.push({
label: formatMessage(messages.loaderVersion, { label: formatMessage(messages.loaderVersion, {
loader: formatLoaderLabel(instance.loader), loader: formatLoaderLabel(instance.value.loader),
}), }),
value: instance.loader_version, value: instance.value.loader_version,
}) })
} }
return rows return rows
}), }),
isLinked: computed(() => !!instance.linked_data?.locked), isLinked: computed(() => !!instance.value.linked_data?.locked),
isBusy: computed( isBusy: computed(
() => () =>
instance.install_stage !== 'installed' || repairing.value || reinstalling.value || !!offline, instance.value.install_stage !== 'installed' ||
repairing.value ||
reinstalling.value ||
!!offline,
), ),
modpack: computed(() => { modpack: computed(() => {
if (!modpackInfo.value) return null if (!modpackInfo.value) return null
@@ -149,9 +152,9 @@ provideInstallationSettings({
versionNumber: modpackInfo.value.version?.version_number, versionNumber: modpackInfo.value.version?.version_number,
} }
}), }),
currentPlatform: computed(() => instance.loader), currentPlatform: computed(() => instance.value.loader),
currentGameVersion: computed(() => instance.game_version), currentGameVersion: computed(() => instance.value.game_version),
currentLoaderVersion: computed(() => instance.loader_version ?? ''), currentLoaderVersion: computed(() => instance.value.loader_version ?? ''),
availablePlatforms: loaders?.value?.map((x) => x.name) ?? [], availablePlatforms: loaders?.value?.map((x) => x.name) ?? [],
resolveGameVersions(loader, showSnapshots) { resolveGameVersions(loader, showSnapshots) {
@@ -194,50 +197,50 @@ provideInstallationSettings({
if (platform !== 'vanilla' && loaderVersionId) { if (platform !== 'vanilla' && loaderVersionId) {
editProfile.loader_version = loaderVersionId editProfile.loader_version = loaderVersionId
} }
await edit(instance.path, editProfile).catch(handleError) await edit(instance.value.path, editProfile).catch(handleError)
}, },
afterSave: async () => { afterSave: async () => {
await install(instance.path, false).catch(handleError) await install(instance.value.path, false).catch(handleError)
trackEvent('InstanceRepair', { trackEvent('InstanceRepair', {
loader: instance.loader, loader: instance.value.loader,
game_version: instance.game_version, game_version: instance.value.game_version,
}) })
}, },
async repair() { async repair() {
repairing.value = true repairing.value = true
await install(instance.path, true).catch(handleError) await install(instance.value.path, true).catch(handleError)
repairing.value = false repairing.value = false
trackEvent('InstanceRepair', { trackEvent('InstanceRepair', {
loader: instance.loader, loader: instance.value.loader,
game_version: instance.game_version, game_version: instance.value.game_version,
}) })
}, },
async reinstallModpack() { async reinstallModpack() {
reinstalling.value = true reinstalling.value = true
await update_repair_modrinth(instance.path).catch(handleError) await update_repair_modrinth(instance.value.path).catch(handleError)
reinstalling.value = false reinstalling.value = false
trackEvent('InstanceRepair', { trackEvent('InstanceRepair', {
loader: instance.loader, loader: instance.value.loader,
game_version: instance.game_version, game_version: instance.value.game_version,
}) })
}, },
async unlinkModpack() { async unlinkModpack() {
await edit(instance.path, { await edit(instance.value.path, {
linked_data: null as unknown as undefined, linked_data: null as unknown as undefined,
}) })
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ['linkedModpackInfo', instance.path], queryKey: ['linkedModpackInfo', instance.value.path],
}) })
onUnlinked() onUnlinked()
}, },
getCachedModpackVersions: () => null, getCachedModpackVersions: () => null,
async fetchModpackVersions() { async fetchModpackVersions() {
const versions = await get_project_versions(instance.linked_data!.project_id!).catch( const versions = await get_project_versions(instance.value.linked_data!.project_id!).catch(
handleError, handleError,
) )
return (versions ?? []) as Labrinth.Versions.v2.Version[] return (versions ?? []) as Labrinth.Versions.v2.Version[]
@@ -250,20 +253,20 @@ provideInstallationSettings({
}, },
async onModpackVersionConfirm(version) { async onModpackVersionConfirm(version) {
await update_managed_modrinth_version(instance.path, version.id) await update_managed_modrinth_version(instance.value.path, version.id)
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ['linkedModpackInfo', instance.path], queryKey: ['linkedModpackInfo', instance.value.path],
}) })
}, },
updaterModalProps: computed(() => ({ updaterModalProps: computed(() => ({
isApp: true, isApp: true,
currentVersionId: currentVersionId:
modpackInfo.value?.update_version_id ?? instance.linked_data?.version_id ?? '', modpackInfo.value?.update_version_id ?? instance.value.linked_data?.version_id ?? '',
projectIconUrl: modpackInfo.value?.project?.icon_url, projectIconUrl: modpackInfo.value?.project?.icon_url,
projectName: modpackInfo.value?.project?.title ?? 'Modpack', projectName: modpackInfo.value?.project?.title ?? 'Modpack',
currentGameVersion: instance.game_version, currentGameVersion: instance.value.game_version,
currentLoader: instance.loader, currentLoader: instance.value.loader,
})), })),
isServer: false, isServer: false,

View File

@@ -25,20 +25,24 @@ const { instance } = injectInstanceSettings()
const globalSettings = (await get().catch(handleError)) as unknown as AppSettings const globalSettings = (await get().catch(handleError)) as unknown as AppSettings
const overrideJavaInstall = ref(!!instance.java_path) const overrideJavaInstall = ref(!!instance.value.java_path)
const optimalJava = readonly(await get_optimal_jre_key(instance.path).catch(handleError)) const optimalJava = readonly(await get_optimal_jre_key(instance.value.path).catch(handleError))
const javaInstall = ref({ path: optimalJava.path ?? instance.java_path }) const javaInstall = ref({ path: optimalJava.path ?? instance.value.java_path })
const overrideJavaArgs = ref((instance.extra_launch_args?.length ?? 0) > 0) const overrideJavaArgs = ref((instance.value.extra_launch_args?.length ?? 0) > 0)
const javaArgs = ref((instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' ')) const javaArgs = ref(
(instance.value.extra_launch_args ?? globalSettings.extra_launch_args).join(' '),
const overrideEnvVars = ref((instance.custom_env_vars?.length ?? 0) > 0)
const envVars = ref(
(instance.custom_env_vars ?? globalSettings.custom_env_vars).map((x) => x.join('=')).join(' '),
) )
const overrideMemorySettings = ref(!!instance.memory) const overrideEnvVars = ref((instance.value.custom_env_vars?.length ?? 0) > 0)
const memory = ref(instance.memory ?? globalSettings.memory) const envVars = ref(
(instance.value.custom_env_vars ?? globalSettings.custom_env_vars)
.map((x) => x.join('='))
.join(' '),
)
const overrideMemorySettings = ref(!!instance.value.memory)
const memory = ref(instance.value.memory ?? globalSettings.memory)
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as { const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
maxMemory: number maxMemory: number
snapPoints: number[] snapPoints: number[]
@@ -76,7 +80,7 @@ watch(
memory, memory,
], ],
async () => { async () => {
await edit(instance.path, editProfileObject.value) await edit(instance.value.path, editProfileObject.value)
}, },
{ deep: true }, { deep: true },
) )

View File

@@ -22,12 +22,14 @@ const { instance } = injectInstanceSettings()
const globalSettings = (await get().catch(handleError)) as AppSettings const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideWindowSettings = ref(!!instance.game_resolution || !!instance.force_fullscreen) const overrideWindowSettings = ref(
!!instance.value.game_resolution || !!instance.value.force_fullscreen,
)
const resolution: Ref<[number, number]> = ref( const resolution: Ref<[number, number]> = ref(
instance.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]), instance.value.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]),
) )
const fullscreenSetting: Ref<boolean> = ref( const fullscreenSetting: Ref<boolean> = ref(
instance.force_fullscreen ?? globalSettings.force_fullscreen, instance.value.force_fullscreen ?? globalSettings.force_fullscreen,
) )
const editProfileObject = computed(() => { const editProfileObject = computed(() => {
@@ -46,7 +48,7 @@ const editProfileObject = computed(() => {
watch( watch(
[overrideWindowSettings, resolution, fullscreenSetting], [overrideWindowSettings, resolution, fullscreenSetting],
async () => { async () => {
await edit(instance.path, editProfileObject.value) await edit(instance.value.path, editProfileObject.value)
}, },
{ deep: true }, { deep: true },
) )

View File

@@ -44,8 +44,10 @@ const emit = defineEmits<{
const isMinecraftServer = ref(false) const isMinecraftServer = ref(false)
const handleUnlinked = () => emit('unlinked') const handleUnlinked = () => emit('unlinked')
const instanceRef = computed(() => props.instance)
provideInstanceSettings({ provideInstanceSettings({
instance: props.instance, instance: instanceRef,
offline: props.offline, offline: props.offline,
isMinecraftServer, isMinecraftServer,
onUnlinked: handleUnlinked, onUnlinked: handleUnlinked,

View File

@@ -1,21 +1,31 @@
<template> <template>
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="warning" max-width="500px"> <NewModal ref="modal" :header="formatMessage(messages.header)" fade="warning" max-width="500px">
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)"> <p class="m-0 text-secondary">
{{ formatMessage(messages.admonitionBody, { instanceName }) }} <IntlFormatted :message-id="messages.body" :values="{ instanceName }">
</Admonition> <template #bold="{ children }">
<span class="font-medium text-contrast"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</p>
<template #actions> <template #actions>
<div class="flex gap-2 justify-end"> <div class="flex gap-2 justify-end">
<ButtonStyled type="outlined"> <ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="handleGoToInstance"> <button class="!border !border-surface-4" @click="handleCancel">
<ExternalIcon /> <XIcon />
{{ formatMessage(messages.goToInstance) }} {{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleGoToInstance">
{{ formatMessage(messages.instance) }}
<RightArrowIcon />
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled color="orange"> <ButtonStyled color="orange">
<button @click="handleCreateAnyway"> <button @click="handleCreateAnyway">
<PlusIcon /> <PlusIcon />
{{ formatMessage(messages.createAnyway) }} {{ formatMessage(messages.create) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@@ -24,8 +34,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ExternalIcon, PlusIcon } from '@modrinth/assets' import { PlusIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, defineMessages, NewModal, useVIntl } from '@modrinth/ui' import {
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue' import { ref } from 'vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -35,21 +52,18 @@ const messages = defineMessages({
id: 'app.instance.modpack-already-installed.header', id: 'app.instance.modpack-already-installed.header',
defaultMessage: 'Modpack already installed', defaultMessage: 'Modpack already installed',
}, },
admonitionHeader: { body: {
id: 'app.instance.modpack-already-installed.admonition-header', id: 'app.instance.modpack-already-installed.body',
defaultMessage: 'Duplicate modpack', defaultMessage:
'This modpack is already installed in the <bold>{instanceName}</bold> instance. Are you sure you want to duplicate it?',
}, },
admonitionBody: { instance: {
id: 'app.instance.modpack-already-installed.admonition-body', id: 'app.instance.modpack-already-installed.instance',
defaultMessage: 'This modpack is already installed in the "{instanceName}" instance.', defaultMessage: 'Instance',
}, },
goToInstance: { create: {
id: 'app.instance.modpack-already-installed.go-to-instance', id: 'app.instance.modpack-already-installed.create',
defaultMessage: 'Go to instance', defaultMessage: 'Create',
},
createAnyway: {
id: 'app.instance.modpack-already-installed.create-anyway',
defaultMessage: 'Create anyway',
}, },
}) })
@@ -68,6 +82,10 @@ function show(name: string, path: string) {
modal.value?.show() modal.value?.show()
} }
function handleCancel() {
modal.value?.hide()
}
function handleGoToInstance() { function handleGoToInstance() {
modal.value?.hide() modal.value?.hide()
emit('go-to-instance', instancePath.value) emit('go-to-instance', instancePath.value)

View File

@@ -189,6 +189,22 @@ const messages = defineMessages({
id: 'instance.worlds.linked_server', id: 'instance.worlds.linked_server',
defaultMessage: 'Managed by server project', defaultMessage: 'Managed by server project',
}, },
incompatibleVersion: {
id: 'app.world.world-item.incompatible-version',
defaultMessage: 'Incompatible version {version}',
},
playersOnline: {
id: 'app.world.world-item.players-online',
defaultMessage: '{count} online',
},
offline: {
id: 'app.world.world-item.offline',
defaultMessage: 'Offline',
},
notPlayedYet: {
id: 'app.world.world-item.not-played-yet',
defaultMessage: 'Not played yet',
},
}) })
</script> </script>
<template> <template>
@@ -243,13 +259,17 @@ const messages = defineMessages({
> >
<template v-if="refreshing"> <template v-if="refreshing">
<SpinnerIcon aria-hidden="true" class="animate-spin shrink-0" /> <SpinnerIcon aria-hidden="true" class="animate-spin shrink-0" />
Loading... {{ formatMessage(commonMessages.loadingLabel) }}
</template> </template>
<template v-else-if="serverStatus"> <template v-else-if="serverStatus">
<template v-if="serverIncompatible"> <template v-if="serverIncompatible">
<IssuesIcon class="shrink-0 text-orange" aria-hidden="true" /> <IssuesIcon class="shrink-0 text-orange" aria-hidden="true" />
<span class="text-orange"> <span class="text-orange">
Incompatible version {{ serverStatus.version?.name }} {{
formatMessage(messages.incompatibleVersion, {
version: serverStatus.version?.name,
})
}}
</span> </span>
</template> </template>
<template v-else> <template v-else>
@@ -265,8 +285,11 @@ const messages = defineMessages({
/> />
<Tooltip :disabled="!hasPlayersTooltip"> <Tooltip :disabled="!hasPlayersTooltip">
<span :class="{ 'cursor-help': hasPlayersTooltip }"> <span :class="{ 'cursor-help': hasPlayersTooltip }">
{{ formatNumber(serverStatus.players?.online) }} {{
online formatMessage(messages.playersOnline, {
count: formatNumber(serverStatus.players?.online ?? 0),
})
}}
</span> </span>
<template #popper> <template #popper>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
@@ -280,7 +303,7 @@ const messages = defineMessages({
</template> </template>
<template v-else> <template v-else>
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" /> <NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" />
Offline {{ formatMessage(messages.offline) }}
</template> </template>
</div> </div>
</div> </div>
@@ -299,7 +322,7 @@ const messages = defineMessages({
}) })
}} }}
</template> </template>
<template v-else> Not played yet </template> <template v-else> {{ formatMessage(messages.notPlayedYet) }} </template>
</div> </div>
<template v-if="instancePath"> <template v-if="instancePath">

View File

@@ -5,12 +5,11 @@ import {
commonMessages, commonMessages,
defineMessages, defineMessages,
injectNotificationManager, injectNotificationManager,
NewModal,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { ref } from 'vue' import { ref } from 'vue'
import InstanceModalTitlePrefix from '@/components/ui/modal/InstanceModalTitlePrefix.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue' import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { add_server_to_profile, type ServerPackStatus, type ServerWorld } from '@/helpers/worlds.ts' import { add_server_to_profile, type ServerPackStatus, type ServerWorld } from '@/helpers/worlds.ts'
@@ -26,10 +25,10 @@ const props = defineProps<{
instance: GameInstance instance: GameInstance
}>() }>()
const modal = ref() const modal = ref<InstanceType<typeof NewModal>>()
const name = ref() const name = ref('')
const address = ref() const address = ref('')
const resourcePack = ref<ServerPackStatus>('enabled') const resourcePack = ref<ServerPackStatus>('enabled')
async function addServer(play: boolean) { async function addServer(play: boolean) {
@@ -60,11 +59,11 @@ function show() {
name.value = '' name.value = ''
address.value = '' address.value = ''
resourcePack.value = 'enabled' resourcePack.value = 'enabled'
modal.value.show() modal.value?.show()
} }
function hide() { function hide() {
modal.value.hide() modal.value?.hide()
} }
const messages = defineMessages({ const messages = defineMessages({
@@ -85,23 +84,18 @@ const messages = defineMessages({
defineExpose({ show, hide }) defineExpose({ show, hide })
</script> </script>
<template> <template>
<ModalWrapper ref="modal"> <NewModal ref="modal" :header="formatMessage(messages.title)" width="500px" max-width="500px">
<template #title>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<InstanceModalTitlePrefix :instance="instance" />
<span class="font-extrabold text-contrast">{{ formatMessage(messages.title) }}</span>
</span>
</template>
<ServerModalBody <ServerModalBody
v-model:name="name" v-model:name="name"
v-model:address="address" v-model:address="address"
v-model:resource-pack="resourcePack" v-model:resource-pack="resourcePack"
/> />
<div class="flex gap-2 mt-4"> <template #actions>
<ButtonStyled color="brand"> <div class="flex gap-2 justify-end">
<button :disabled="!address" @click="addServer(true)"> <ButtonStyled type="outlined">
<PlayIcon /> <button class="!border !border-surface-4" @click="hide()">
{{ formatMessage(messages.addAndPlay) }} <XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled>
@@ -110,12 +104,13 @@ defineExpose({ show, hide })
{{ formatMessage(messages.addServer) }} {{ formatMessage(messages.addServer) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled color="brand">
<button @click="hide()"> <button :disabled="!address" @click="addServer(true)">
<XIcon /> <PlayIcon />
{{ formatMessage(commonMessages.cancelButton) }} {{ formatMessage(messages.addAndPlay) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</ModalWrapper> </template>
</NewModal>
</template> </template>

View File

@@ -5,11 +5,11 @@ import {
commonMessages, commonMessages,
defineMessage, defineMessage,
injectNotificationManager, injectNotificationManager,
NewModal,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue' import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue' import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
@@ -32,7 +32,7 @@ const props = defineProps<{
instance: GameInstance instance: GameInstance
}>() }>()
const modal = ref() const modal = ref<InstanceType<typeof NewModal>>()
const name = ref<string>('') const name = ref<string>('')
const address = ref<string>('') const address = ref<string>('')
@@ -81,11 +81,11 @@ function show(server: ServerWorld) {
index.value = server.index index.value = server.index
displayStatus.value = server.display_status displayStatus.value = server.display_status
hideFromHome.value = server.display_status === 'hidden' hideFromHome.value = server.display_status === 'hidden'
modal.value.show() modal.value?.show()
} }
function hide() { function hide() {
modal.value.hide() modal.value?.hide()
} }
defineExpose({ show }) defineExpose({ show })
@@ -96,29 +96,28 @@ const titleMessage = defineMessage({
}) })
</script> </script>
<template> <template>
<ModalWrapper ref="modal"> <NewModal ref="modal" :header="formatMessage(titleMessage)" width="500px" max-width="500px">
<template #title>
<span class="font-extrabold text-lg text-contrast">{{ formatMessage(titleMessage) }}</span>
</template>
<ServerModalBody <ServerModalBody
v-model:name="name" v-model:name="name"
v-model:address="address" v-model:address="address"
v-model:resource-pack="resourcePack" v-model:resource-pack="resourcePack"
/> />
<HideFromHomeOption v-model="hideFromHome" class="mt-3" /> <HideFromHomeOption v-model="hideFromHome" class="mt-3" />
<div class="flex gap-2 mt-4"> <template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button :disabled="!address" @click="saveServer"> <button :disabled="!address" @click="saveServer">
<SaveIcon /> <SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }} {{ formatMessage(commonMessages.saveChangesButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled>
<button @click="hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div> </div>
</ModalWrapper> </template>
</NewModal>
</template> </template>

View File

@@ -49,34 +49,40 @@ const messages = defineMessages({
id: 'instance.server-modal.placeholder-name', id: 'instance.server-modal.placeholder-name',
defaultMessage: 'Minecraft Server', defaultMessage: 'Minecraft Server',
}, },
placeholderAddress: {
id: 'app.world.server-modal.placeholder-address',
defaultMessage: 'example.modrinth.gg',
},
selectAnOption: {
id: 'app.world.server-modal.select-an-option',
defaultMessage: 'Select an option',
},
}) })
defineExpose({ resourcePackOptions }) defineExpose({ resourcePackOptions })
</script> </script>
<template> <template>
<div class="w-[450px]"> <div class="space-y-4 w-full">
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1"> <label class="flex flex-col gap-2">
{{ formatMessage(messages.name) }} <span class="font-semibold text-contrast">{{ formatMessage(messages.name) }}</span>
</h2>
<StyledInput <StyledInput
v-model="name" v-model="name"
:placeholder="formatMessage(messages.placeholderName)" :placeholder="formatMessage(messages.placeholderName)"
autocomplete="off" autocomplete="off"
wrapper-class="w-full" wrapper-class="w-full"
/> />
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1"> </label>
{{ formatMessage(messages.address) }} <label class="flex flex-col gap-2">
</h2> <span class="font-semibold text-contrast">{{ formatMessage(messages.address) }}</span>
<StyledInput <StyledInput
v-model="address" v-model="address"
placeholder="example.modrinth.gg" :placeholder="formatMessage(messages.placeholderAddress)"
autocomplete="off" autocomplete="off"
wrapper-class="w-full" wrapper-class="w-full"
/> />
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1"> </label>
{{ formatMessage(messages.resourcePack) }} <label class="flex flex-col gap-2">
</h2> <span class="font-semibold text-contrast">{{ formatMessage(messages.resourcePack) }}</span>
<div>
<Combobox <Combobox
v-model="resourcePack" v-model="resourcePack"
:options=" :options="
@@ -89,9 +95,9 @@ defineExpose({ resourcePackOptions })
:display-value=" :display-value="
resourcePack resourcePack
? formatMessage(resourcePackOptionMessages[resourcePack]) ? formatMessage(resourcePackOptionMessages[resourcePack])
: 'Select an option' : formatMessage(messages.selectAnOption)
" "
/> />
</div> </label>
</div> </div>
</template> </template>

View File

@@ -30,6 +30,8 @@ export type ServerWorld = BaseWorld & {
index: number index: number
address: string address: string
pack_status: ServerPackStatus pack_status: ServerPackStatus
project_id?: string
content_kind?: string
} }
export type World = SingleplayerWorld | ServerWorld export type World = SingleplayerWorld | ServerWorld
@@ -140,12 +142,16 @@ export async function add_server_to_profile(
name: string, name: string,
address: string, address: string,
packStatus: ServerPackStatus, packStatus: ServerPackStatus,
projectId?: string,
contentKind?: string,
): Promise<number> { ): Promise<number> {
return await invoke('plugin:worlds|add_server_to_profile', { return await invoke('plugin:worlds|add_server_to_profile', {
path, path,
name, name,
address, address,
packStatus, packStatus,
projectId,
contentKind,
}) })
} }

View File

@@ -5,6 +5,39 @@
"app.auth-servers.unreachable.header": { "app.auth-servers.unreachable.header": {
"message": "Cannot reach authentication servers" "message": "Cannot reach authentication servers"
}, },
"app.browse.add-server-to-instance": {
"message": "Add server to instance"
},
"app.browse.add-servers-to-instance": {
"message": "Add servers to your instance"
},
"app.browse.add-to-instance": {
"message": "Add to instance"
},
"app.browse.add-to-instance-name": {
"message": "Add to {instanceName}"
},
"app.browse.added": {
"message": "Added"
},
"app.browse.already-added": {
"message": "Already added"
},
"app.browse.discover-content": {
"message": "Discover content"
},
"app.browse.discover-servers": {
"message": "Discover servers"
},
"app.browse.hide-added-servers": {
"message": "Hide added servers"
},
"app.browse.hide-installed-content": {
"message": "Hide installed content"
},
"app.browse.install-content-to-instance": {
"message": "Install content to instance"
},
"app.export-modal.description-placeholder": { "app.export-modal.description-placeholder": {
"message": "Enter modpack description..." "message": "Enter modpack description..."
}, },
@@ -41,33 +74,21 @@
"app.instance.confirm-delete.header": { "app.instance.confirm-delete.header": {
"message": "Delete instance" "message": "Delete instance"
}, },
"app.instance.modpack-already-installed.admonition-body": { "app.instance.modpack-already-installed.body": {
"message": "This modpack is already installed in the \"{instanceName}\" instance." "message": "This modpack is already installed in the <bold>{instanceName}</bold> instance. Are you sure you want to duplicate it?"
}, },
"app.instance.modpack-already-installed.admonition-header": { "app.instance.modpack-already-installed.create": {
"message": "Duplicate modpack" "message": "Create"
},
"app.instance.modpack-already-installed.create-anyway": {
"message": "Create anyway"
},
"app.instance.modpack-already-installed.go-to-instance": {
"message": "Go to instance"
}, },
"app.instance.modpack-already-installed.header": { "app.instance.modpack-already-installed.header": {
"message": "Modpack already installed" "message": "Modpack already installed"
}, },
"app.instance.modpack-already-installed.instance": {
"message": "Instance"
},
"app.instance.mods.content-type-project": { "app.instance.mods.content-type-project": {
"message": "project" "message": "project"
}, },
"app.instance.mods.copy-link": {
"message": "Copy link"
},
"app.instance.mods.installing": {
"message": "Installing..."
},
"app.instance.mods.modpack-fallback": {
"message": "Modpack"
},
"app.instance.mods.project-was-added": { "app.instance.mods.project-was-added": {
"message": "\"{name}\" was added" "message": "\"{name}\" was added"
}, },
@@ -80,17 +101,53 @@
"app.instance.mods.share-title": { "app.instance.mods.share-title": {
"message": "Sharing modpack content" "message": "Sharing modpack content"
}, },
"app.instance.mods.show-file": {
"message": "Show file"
},
"app.instance.mods.successfully-uploaded": { "app.instance.mods.successfully-uploaded": {
"message": "Successfully uploaded" "message": "Successfully uploaded"
}, },
"app.instance.mods.unknown-version": { "app.instance.worlds.add-server": {
"message": "Unknown" "message": "Add server"
}, },
"app.instance.mods.updating": { "app.instance.worlds.browse-servers": {
"message": "Updating..." "message": "Browse servers"
},
"app.instance.worlds.delete-world-description": {
"message": "'{name}' will be **permanently deleted**, and there will be no way to recover it."
},
"app.instance.worlds.delete-world-title": {
"message": "Are you sure you want to permanently delete this world?"
},
"app.instance.worlds.filter-modded": {
"message": "Modded"
},
"app.instance.worlds.filter-offline": {
"message": "Offline"
},
"app.instance.worlds.filter-online": {
"message": "Online"
},
"app.instance.worlds.filter-vanilla": {
"message": "Vanilla"
},
"app.instance.worlds.no-worlds-description": {
"message": "Add a server or browse to get started"
},
"app.instance.worlds.no-worlds-heading": {
"message": "No servers or worlds added"
},
"app.instance.worlds.remove-server-description": {
"message": "'{name}' will be removed from your list, including in-game, and there will be no way to recover it."
},
"app.instance.worlds.remove-server-description-with-address": {
"message": "'{name}' ({address}) will be removed from your list, including in-game, and there will be no way to recover it."
},
"app.instance.worlds.remove-server-title": {
"message": "Are you sure you want to remove {name}?"
},
"app.instance.worlds.search-worlds-placeholder": {
"message": "Search {count} worlds..."
},
"app.instance.worlds.this-server": {
"message": "this server"
}, },
"app.modal.install-to-play.content-required": { "app.modal.install-to-play.content-required": {
"message": "Content required" "message": "Content required"
@@ -197,6 +254,24 @@
"app.update.reload-to-update": { "app.update.reload-to-update": {
"message": "Reload to install update" "message": "Reload to install update"
}, },
"app.world.server-modal.placeholder-address": {
"message": "example.modrinth.gg"
},
"app.world.server-modal.select-an-option": {
"message": "Select an option"
},
"app.world.world-item.incompatible-version": {
"message": "Incompatible version {version}"
},
"app.world.world-item.not-played-yet": {
"message": "Not played yet"
},
"app.world.world-item.offline": {
"message": "Offline"
},
"app.world.world-item.players-online": {
"message": "{count} online"
},
"friends.action.add-friend": { "friends.action.add-friend": {
"message": "Add a friend" "message": "Add a friend"
}, },
@@ -296,6 +371,12 @@
"instance.edit-world.title": { "instance.edit-world.title": {
"message": "Edit world" "message": "Edit world"
}, },
"instance.files.adding-files": {
"message": "Adding files ({completed}/{total})"
},
"instance.files.save-as": {
"message": "Save as..."
},
"instance.server-modal.address": { "instance.server-modal.address": {
"message": "Address" "message": "Address"
}, },
@@ -467,9 +548,6 @@
"instance.worlds.dont_show_on_home": { "instance.worlds.dont_show_on_home": {
"message": "Don't show on Home" "message": "Don't show on Home"
}, },
"instance.worlds.filter.available": {
"message": "Available"
},
"instance.worlds.game_already_open": { "instance.worlds.game_already_open": {
"message": "Instance is already open" "message": "Instance is already open"
}, },
@@ -494,12 +572,6 @@
"instance.worlds.play_instance": { "instance.worlds.play_instance": {
"message": "Play instance" "message": "Play instance"
}, },
"instance.worlds.type.server": {
"message": "Server"
},
"instance.worlds.type.singleplayer": {
"message": "Singleplayer"
},
"instance.worlds.view_instance": { "instance.worlds.view_instance": {
"message": "View instance" "message": "View instance"
}, },

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client' import type { Labrinth } from '@modrinth/api-client'
import { import {
CheckIcon,
ClipboardCopyIcon, ClipboardCopyIcon,
ExternalIcon, ExternalIcon,
GlobeIcon, GlobeIcon,
@@ -14,10 +15,12 @@ import {
Admonition, Admonition,
ButtonStyled, ButtonStyled,
Checkbox, Checkbox,
commonMessages,
defineMessages, defineMessages,
DropdownSelect, DropdownSelect,
injectNotificationManager, injectNotificationManager,
LoadingIndicator, LoadingIndicator,
NavTabs,
Pagination, Pagination,
ProjectCard, ProjectCard,
ProjectCardList, ProjectCardList,
@@ -33,12 +36,11 @@ import { openUrl } from '@tauri-apps/plugin-opener'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { computed, nextTick, onUnmounted, ref, shallowRef, toRaw, watch } from 'vue' import { computed, nextTick, onUnmounted, ref, shallowRef, toRaw, watch } from 'vue'
import type { LocationQuery } from 'vue-router' import type { LocationQuery } from 'vue-router'
import { useRoute, useRouter } from 'vue-router' import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue' import ContextMenu from '@/components/ui/ContextMenu.vue'
import type Instance from '@/components/ui/Instance.vue' import type Instance from '@/components/ui/Instance.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue' import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import SearchCard from '@/components/ui/SearchCard.vue' import SearchCard from '@/components/ui/SearchCard.vue'
import { get_project_v3, get_search_results_v3 } from '@/helpers/cache.js' import { get_project_v3, get_search_results_v3 } from '@/helpers/cache.js'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
@@ -51,7 +53,7 @@ import {
} from '@/helpers/profile.js' } from '@/helpers/profile.js'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags' import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { getServerLatency } from '@/helpers/worlds' import { add_server_to_profile, get_profile_worlds, getServerLatency } from '@/helpers/worlds'
import { injectServerInstall } from '@/providers/server-install' import { injectServerInstall } from '@/providers/server-install'
import { useBreadcrumbs } from '@/store/breadcrumbs' import { useBreadcrumbs } from '@/store/breadcrumbs'
import { getServerAddress } from '@/store/install.js' import { getServerAddress } from '@/store/install.js'
@@ -108,19 +110,27 @@ const installedProjectIds: Ref<string[] | null> = ref(null)
const instanceHideInstalled = ref(false) const instanceHideInstalled = ref(false)
const newlyInstalled = ref<string[]>([]) const newlyInstalled = ref<string[]>([])
const isServerInstance = ref(false) const isServerInstance = ref(false)
const isFromWorlds = computed(() => route.query.from === 'worlds')
if (isFromWorlds.value && route.params.projectType !== 'server') {
router.replace({
path: '/browse/server',
query: route.query,
})
}
const allInstalledIds = computed( const allInstalledIds = computed(
() => new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])]), () => new Set([...newlyInstalled.value, ...(installedProjectIds.value ?? [])]),
) )
const PERSISTENT_QUERY_PARAMS = ['i', 'ai'] const PERSISTENT_QUERY_PARAMS = ['i', 'ai', 'from']
await initInstanceContext() await initInstanceContext()
async function initInstanceContext() { async function initInstanceContext() {
debugLog('initInstanceContext', { queryI: route.query.i, queryAi: route.query.ai }) debugLog('initInstanceContext', { queryI: route.query.i, queryAi: route.query.ai })
if (route.query.i) { if (route.query.i) {
instance.value = await getInstance(route.query.i).catch(handleError) instance.value = (await getInstance(route.query.i as string).catch(handleError)) ?? null
debugLog('instance loaded', { debugLog('instance loaded', {
name: instance.value?.name, name: instance.value?.name,
loader: instance.value?.loader, loader: instance.value?.loader,
@@ -130,12 +140,24 @@ async function initInstanceContext() {
// Load installed project IDs in background — the page and initial search render immediately. // Load installed project IDs in background — the page and initial search render immediately.
// When this resolves, instanceFilters recomputes and triggers a search refresh // When this resolves, instanceFilters recomputes and triggers a search refresh
// that applies the "hide installed" negative filters and marks installed badges. // that applies the "hide installed" negative filters and marks installed badges.
getInstalledProjectIds(route.query.i) if (route.query.from === 'worlds') {
get_profile_worlds(route.query.i as string)
.then((worlds) => {
const serverProjectIds = worlds
.filter((w) => w.type === 'server' && 'project_id' in w && w.project_id)
.map((w) => (w as { project_id: string }).project_id)
debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length })
installedProjectIds.value = serverProjectIds
})
.catch(handleError)
} else {
getInstalledProjectIds(route.query.i as string)
.then((ids) => { .then((ids) => {
debugLog('installedProjectIds loaded', { count: ids?.length }) debugLog('installedProjectIds loaded', { count: ids?.length })
installedProjectIds.value = ids installedProjectIds.value = ids
}) })
.catch(handleError) .catch(handleError)
}
if (instance.value?.linked_data?.project_id) { if (instance.value?.linked_data?.project_id) {
debugLog('checking linked project for server status', instance.value.linked_data.project_id) debugLog('checking linked project for server status', instance.value.linked_data.project_id)
@@ -246,6 +268,10 @@ const activeGameVersion = computed(() => {
}) })
const serverHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([]) const serverHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
const filteredServerHits = computed(() => {
if (!instanceHideInstalled.value || allInstalledIds.value.size === 0) return serverHits.value
return serverHits.value.filter((hit) => !allInstalledIds.value.has(hit.project_id))
})
const serverPings = shallowRef<Record<string, number | undefined>>({}) const serverPings = shallowRef<Record<string, number | undefined>>({})
const runningServerProjects = ref<Record<string, string>>({}) const runningServerProjects = ref<Record<string, string>>({})
@@ -281,12 +307,29 @@ async function handlePlayServerProject(projectId: string) {
checkServerRunningStates(serverHits.value) checkServerRunningStates(serverHits.value)
} }
function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) { async function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) {
debugLog('handleAddServerToInstance', { projectId: project.project_id, name: project.name }) debugLog('handleAddServerToInstance', { projectId: project.project_id, name: project.name })
const address = getServerAddress(project.minecraft_java_server) const address = getServerAddress(project.minecraft_java_server)
if (!address) return if (!address) return
if (instance.value) {
try {
await add_server_to_profile(
instance.value.path,
project.name,
address,
'prompt',
project.project_id,
project.minecraft_java_server?.content?.kind,
)
newlyInstalled.value.push(project.project_id)
} catch (err) {
handleError(err as Error)
}
} else {
showAddServerToInstanceModal(project.name, address) showAddServerToInstanceModal(project.name, address)
} }
}
const unlistenProcesses = await process_listener( const unlistenProcesses = await process_listener(
(e: { event: string; profile_path_id: string }) => { (e: { event: string; profile_path_id: string }) => {
@@ -317,6 +360,16 @@ const {
createServerPageParams, createServerPageParams,
} = useServerSearch({ tags, query, maxResults, currentPage }) } = useServerSearch({ tags, query, maxResults, currentPage })
if (instance.value?.game_version) {
const gv = instance.value.game_version
const alreadyHasGv = serverCurrentFilters.value.some(
(f) => f.type === 'server_game_version' && f.option === gv,
)
if (!alreadyHasGv) {
serverCurrentFilters.value.push({ type: 'server_game_version', option: gv })
}
}
async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) { async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
debugLog('pingServerHits', { hitCount: hits.length }) debugLog('pingServerHits', { hitCount: hits.length })
const pingsToFetch = hits.filter((hit) => hit.minecraft_java_server?.address) const pingsToFetch = hits.filter((hit) => hit.minecraft_java_server?.address)
@@ -346,8 +399,107 @@ window.addEventListener('online', () => {
offline.value = false offline.value = false
}) })
const messages = defineMessages({
addServerToInstance: {
id: 'app.browse.add-server-to-instance',
defaultMessage: 'Add server to instance',
},
addServersToInstance: {
id: 'app.browse.add-servers-to-instance',
defaultMessage: 'Add servers to your instance',
},
addToInstance: {
id: 'app.browse.add-to-instance',
defaultMessage: 'Add to instance',
},
addToInstanceName: {
id: 'app.browse.add-to-instance-name',
defaultMessage: 'Add to {instanceName}',
},
added: {
id: 'app.browse.added',
defaultMessage: 'Added',
},
alreadyAdded: {
id: 'app.browse.already-added',
defaultMessage: 'Already added',
},
discoverContent: {
id: 'app.browse.discover-content',
defaultMessage: 'Discover content',
},
discoverServers: {
id: 'app.browse.discover-servers',
defaultMessage: 'Discover servers',
},
environmentProvidedByServer: {
id: 'search.filter.locked.server-environment.title',
defaultMessage: 'Only client-side mods can be added to the server instance',
},
gameVersionProvidedByInstance: {
id: 'search.filter.locked.instance-game-version.title',
defaultMessage: 'Game version is provided by the instance',
},
gameVersionProvidedByServer: {
id: 'search.filter.locked.server-game-version.title',
defaultMessage: 'Game version is provided by the server',
},
hideAddedServers: {
id: 'app.browse.hide-added-servers',
defaultMessage: 'Hide added servers',
},
hideInstalledContent: {
id: 'app.browse.hide-installed-content',
defaultMessage: 'Hide installed content',
},
installContentToInstance: {
id: 'app.browse.install-content-to-instance',
defaultMessage: 'Install content to instance',
},
modLoaderProvidedByInstance: {
id: 'search.filter.locked.instance-loader.title',
defaultMessage: 'Loader is provided by the instance',
},
modLoaderProvidedByServer: {
id: 'search.filter.locked.server-loader.title',
defaultMessage: 'Loader is provided by the server',
},
providedByInstance: {
id: 'search.filter.locked.instance',
defaultMessage: 'Provided by the instance',
},
providedByServer: {
id: 'search.filter.locked.server',
defaultMessage: 'Provided by the server',
},
syncFilterButton: {
id: 'search.filter.locked.instance.sync',
defaultMessage: 'Sync with instance',
},
})
const breadcrumbs = useBreadcrumbs() const breadcrumbs = useBreadcrumbs()
breadcrumbs.setContext({ name: 'Discover content', link: route.path, query: route.query }) const browseTitle = computed(() =>
formatMessage(isFromWorlds.value ? messages.discoverServers : messages.discoverContent),
)
breadcrumbs.setName('BrowseTitle', browseTitle.value)
if (instance.value) {
const instanceLink = `/instance/${encodeURIComponent(instance.value.path)}`
breadcrumbs.setContext({
name: instance.value.name,
link: isFromWorlds.value ? `${instanceLink}/worlds` : instanceLink,
})
} else {
breadcrumbs.setContext(null)
}
onBeforeRouteLeave(() => {
breadcrumbs.setContext({
name: browseTitle.value,
link: `/browse/${projectType.value}`,
query: route.query,
})
})
const loading = ref(true) const loading = ref(true)
@@ -484,11 +636,6 @@ async function refreshSearch() {
...(isServer ? createServerPageParams() : createPageParams()), ...(isServer ? createServerPageParams() : createPageParams()),
} }
breadcrumbs.setContext({
name: 'Discover content',
link: `/browse/${projectType.value}`,
query: params,
})
debugLog('updating URL', params) debugLog('updating URL', params)
router.replace({ path: route.path, query: params }) router.replace({ path: route.path, query: params })
@@ -532,6 +679,18 @@ watch(
debugLog('projectType route param changed', { from: projectType.value, to: newType }) debugLog('projectType route param changed', { from: projectType.value, to: newType })
projectType.value = newType projectType.value = newType
// If instance context was removed (e.g. sidebar browse navigation), reset state
if (!route.query.i && instance.value) {
debugLog('instance context removed, resetting')
instance.value = null
installedProjectIds.value = null
instanceHideInstalled.value = false
newlyInstalled.value = []
isServerInstance.value = false
breadcrumbs.setName('BrowseTitle', formatMessage(messages.discoverContent))
breadcrumbs.setContext(null)
}
currentSortType.value = { display: 'Relevance', name: 'relevance' } currentSortType.value = { display: 'Relevance', name: 'relevance' }
query.value = '' query.value = ''
}, },
@@ -569,64 +728,25 @@ const selectableProjectTypes = computed(() => {
if (route.query.ai) { if (route.query.ai) {
params.ai = route.query.ai params.ai = route.query.ai
} }
if (route.query.from) {
params.from = route.query.from
}
const links = [ const queryString = new URLSearchParams(params as Record<string, string>).toString()
{ label: 'Modpacks', href: `/browse/modpack`, shown: modpacks }, const suffix = queryString ? `?${queryString}` : ''
{ label: 'Mods', href: `/browse/mod`, shown: mods },
{ label: 'Resource Packs', href: `/browse/resourcepack` }, if (isFromWorlds.value) {
{ label: 'Data Packs', href: `/browse/datapack`, shown: dataPacks }, return [{ label: 'Servers', href: `/browse/server${suffix}` }]
{ label: 'Shaders', href: `/browse/shader` }, }
{ label: 'Servers', href: `/browse/server`, shown: !instance.value },
return [
{ label: 'Modpacks', href: `/browse/modpack${suffix}`, shown: modpacks },
{ label: 'Mods', href: `/browse/mod${suffix}`, shown: mods },
{ label: 'Resource Packs', href: `/browse/resourcepack${suffix}` },
{ label: 'Data Packs', href: `/browse/datapack${suffix}`, shown: dataPacks },
{ label: 'Shaders', href: `/browse/shader${suffix}` },
{ label: 'Servers', href: `/browse/server${suffix}`, shown: !instance.value },
] ]
if (params) {
return links.map((link) => {
return {
...link,
href: {
path: link.href,
query: params,
},
}
})
}
return links
})
const messages = defineMessages({
gameVersionProvidedByInstance: {
id: 'search.filter.locked.instance-game-version.title',
defaultMessage: 'Game version is provided by the instance',
},
gameVersionProvidedByServer: {
id: 'search.filter.locked.server-game-version.title',
defaultMessage: 'Game version is provided by the server',
},
modLoaderProvidedByInstance: {
id: 'search.filter.locked.instance-loader.title',
defaultMessage: 'Loader is provided by the instance',
},
modLoaderProvidedByServer: {
id: 'search.filter.locked.server-loader.title',
defaultMessage: 'Loader is provided by the server',
},
environmentProvidedByServer: {
id: 'search.filter.locked.server-environment.title',
defaultMessage: 'Only client-side mods can be added to the server instance',
},
providedByInstance: {
id: 'search.filter.locked.instance',
defaultMessage: 'Provided by the instance',
},
providedByServer: {
id: 'search.filter.locked.server',
defaultMessage: 'Provided by the server',
},
syncFilterButton: {
id: 'search.filter.locked.instance.sync',
defaultMessage: 'Sync with instance',
},
}) })
const getServerModpackContent = (project: Labrinth.Search.v3.ResultSearchProject) => { const getServerModpackContent = (project: Labrinth.Search.v3.ResultSearchProject) => {
@@ -697,7 +817,9 @@ previousFilterState.value = JSON.stringify({
> >
<Checkbox <Checkbox
v-model="instanceHideInstalled" v-model="instanceHideInstalled"
label="Hide installed content" :label="
formatMessage(isFromWorlds ? messages.hideAddedServers : messages.hideInstalledContent)
"
class="filter-checkbox" class="filter-checkbox"
@update:model-value="onSearchChangeToTop()" @update:model-value="onSearchChangeToTop()"
@click.prevent.stop @click.prevent.stop
@@ -776,9 +898,15 @@ previousFilterState.value = JSON.stringify({
</Teleport> </Teleport>
<div ref="searchWrapper" class="flex flex-col gap-3 p-6"> <div ref="searchWrapper" class="flex flex-col gap-3 p-6">
<template v-if="instance"> <template v-if="instance">
<InstanceIndicator :instance="instance" /> <InstanceIndicator :instance="instance" :back-tab="isFromWorlds ? 'worlds' : undefined" />
<h1 class="m-0 mb-1 text-xl">Install content to instance</h1> <h1 class="m-0 mb-1 text-xl">
<Admonition v-if="isServerInstance" type="warning" class="mb-1"> {{
formatMessage(
isFromWorlds ? messages.addServersToInstance : messages.installContentToInstance,
)
}}
</h1>
<Admonition v-if="isServerInstance && !isFromWorlds" type="warning" class="mb-1">
Adding content can break compatibility when joining the server. Any added content will also Adding content can break compatibility when joining the server. Any added content will also
be lost when you update the server instance content. be lost when you update the server instance content.
</Admonition> </Admonition>
@@ -850,7 +978,7 @@ previousFilterState.value = JSON.stringify({
<section <section
v-else-if=" v-else-if="
projectType === 'server' projectType === 'server'
? serverHits.length === 0 ? filteredServerHits.length === 0
: results && results.hits && results.hits.length === 0 : results && results.hits && results.hits.length === 0
" "
class="offline" class="offline"
@@ -861,7 +989,7 @@ previousFilterState.value = JSON.stringify({
<ProjectCardList v-else :layout="'list'"> <ProjectCardList v-else :layout="'list'">
<template v-if="projectType === 'server'"> <template v-if="projectType === 'server'">
<ProjectCard <ProjectCard
v-for="project in serverHits" v-for="project in filteredServerHits"
:key="`server-card-${project.project_id}`" :key="`server-card-${project.project_id}`"
:title="project.name" :title="project.name"
:icon-url="project.icon_url || undefined" :icon-url="project.icon_url || undefined"
@@ -887,12 +1015,41 @@ previousFilterState.value = JSON.stringify({
> >
<template #actions> <template #actions>
<div class="flex gap-2"> <div class="flex gap-2">
<ButtonStyled circular> <template v-if="isFromWorlds && instance">
<ButtonStyled color="brand" type="outlined">
<button <button
v-tooltip="'Add server to instance'" :disabled="allInstalledIds.has(project.project_id)"
@click.stop="() => handleAddServerToInstance(project)" @click.stop="() => handleAddServerToInstance(project)"
> >
<PlusIcon /> <CheckIcon v-if="allInstalledIds.has(project.project_id)" />
<PlusIcon v-else />
{{
formatMessage(
allInstalledIds.has(project.project_id)
? messages.added
: messages.addToInstance,
)
}}
</button>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled circular>
<button
v-tooltip="
allInstalledIds.has(project.project_id)
? formatMessage(messages.alreadyAdded)
: instance
? formatMessage(messages.addToInstanceName, {
instanceName: instance.name,
})
: formatMessage(messages.addServerToInstance)
"
:disabled="allInstalledIds.has(project.project_id)"
@click.stop="() => handleAddServerToInstance(project)"
>
<CheckIcon v-if="allInstalledIds.has(project.project_id)" />
<PlusIcon v-else />
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled <ButtonStyled
@@ -902,22 +1059,27 @@ previousFilterState.value = JSON.stringify({
> >
<button @click="() => handleStopServerProject(project.project_id)"> <button @click="() => handleStopServerProject(project.project_id)">
<StopCircleIcon /> <StopCircleIcon />
Stop {{ formatMessage(commonMessages.stopButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else color="brand" type="outlined"> <ButtonStyled v-else color="brand" type="outlined">
<button <button
:disabled="(installingServerProjects as string[]).includes(project.project_id)" :disabled="
(installingServerProjects as string[]).includes(project.project_id)
"
@click="() => handlePlayServerProject(project.project_id)" @click="() => handlePlayServerProject(project.project_id)"
> >
<PlayIcon /> <PlayIcon />
{{ {{
formatMessage(
(installingServerProjects as string[]).includes(project.project_id) (installingServerProjects as string[]).includes(project.project_id)
? 'Installing...' ? commonMessages.installingLabel
: 'Play' : commonMessages.playButton,
)
}} }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</template>
</div> </div>
</template> </template>
</ProjectCard> </ProjectCard>

View File

@@ -0,0 +1,345 @@
<script setup lang="ts">
import type { EditingFile, FileItem, UploadState } from '@modrinth/ui'
import {
commonMessages,
defineMessages,
FilePageLayout,
injectNotificationManager,
provideFileManager,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
import {
mkdir,
readDir,
readFile as readFileBytes,
readTextFile,
remove,
rename,
stat,
writeFile as writeFileBytes,
writeTextFile,
} from '@tauri-apps/plugin-fs'
import { onUnmounted, ref, watch } from 'vue'
import { profile_listener } from '@/helpers/events'
import { get_full_path } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { highlightInFolder } from '@/helpers/utils'
const props = defineProps<{
instance: GameInstance
options: unknown
offline: boolean
playing: boolean
installed: boolean
isServerInstance: boolean
}>()
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const debug = useDebugLogger('Files')
const messages = defineMessages({
saveAs: {
id: 'instance.files.save-as',
defaultMessage: 'Save as...',
},
addingFiles: {
id: 'instance.files.adding-files',
defaultMessage: 'Adding files ({completed}/{total})',
},
})
const instanceRoot = ref('')
const items = ref<FileItem[]>([])
const loading = ref(true)
const error = ref<Error | null>(null)
const currentPath = ref('')
const editingFile = ref<EditingFile | null>(null)
debug('setup: start, instance.path =', props.instance.path)
instanceRoot.value = await get_full_path(props.instance.path)
debug('setup: instanceRoot =', instanceRoot.value)
await refresh()
debug('setup: refresh complete, items =', items.value.length, 'error =', error.value)
function resolvePath(relativePath: string): string {
return relativePath ? `${instanceRoot.value}/${relativePath}` : instanceRoot.value
}
async function listDirectory(dirPath: string): Promise<FileItem[]> {
const absPath = resolvePath(dirPath)
debug('listDirectory: dirPath =', dirPath, 'absPath =', absPath)
const entries = await readDir(absPath)
debug('listDirectory: got', entries.length, 'entries')
const results = await Promise.all(
entries.map(async (entry) => {
const entryAbsPath = `${absPath}/${entry.name}`
let metadata
try {
metadata = await stat(entryAbsPath)
} catch {
debug('listDirectory: stat failed for', entry.name, '- skipping')
return null
}
const item: FileItem = {
name: entry.name,
type: entry.isDirectory ? 'directory' : 'file',
path: dirPath ? `${dirPath}/${entry.name}` : entry.name,
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : 0,
created: metadata.birthtime ? Math.floor(metadata.birthtime.getTime() / 1000) : 0,
}
if (!entry.isDirectory) {
item.size = metadata.size
}
if (entry.isDirectory) {
try {
const children = await readDir(entryAbsPath)
item.count = children.length
} catch {
item.count = 0
}
}
return item
}),
)
return results.filter((item): item is FileItem => item !== null)
}
async function refresh() {
debug('refresh: called, currentPath =', currentPath.value, 'instanceRoot =', instanceRoot.value)
loading.value = true
error.value = null
try {
items.value = await listDirectory(currentPath.value)
debug('refresh: success, items =', items.value.length)
} catch (e) {
debug('refresh: error =', e)
error.value = e instanceof Error ? e : new Error(String(e))
items.value = []
} finally {
loading.value = false
}
}
function navigateTo(path: string) {
debug('navigateTo:', path)
currentPath.value = path.startsWith('/') ? path.slice(1) : path
refresh()
}
function startEditing(file: EditingFile) {
editingFile.value = file
}
function stopEditing() {
editingFile.value = null
}
async function handleCreateItem(name: string, type: 'file' | 'directory') {
const targetPath = currentPath.value ? `${currentPath.value}/${name}` : name
const absPath = resolvePath(targetPath)
try {
if (type === 'directory') {
await mkdir(absPath)
} else {
await writeTextFile(absPath, '')
}
await refresh()
} catch (e) {
addNotification({
title: formatMessage(commonMessages.createFailedLabel),
text: e instanceof Error ? e.message : '',
type: 'error',
})
}
}
async function handleRenameItem(path: string, newName: string) {
const oldAbs = resolvePath(path)
const parentDir = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : ''
const newPath = parentDir ? `${parentDir}/${newName}` : newName
const newAbs = resolvePath(newPath)
try {
await rename(oldAbs, newAbs)
await refresh()
} catch (e) {
addNotification({
title: formatMessage(commonMessages.renameFailedLabel),
text: e instanceof Error ? e.message : '',
type: 'error',
})
}
}
async function handleMoveItem(source: string, destination: string) {
try {
await rename(resolvePath(source), resolvePath(destination))
await refresh()
} catch (e) {
addNotification({
title: formatMessage(commonMessages.moveFailedLabel),
text: e instanceof Error ? e.message : '',
type: 'error',
})
}
}
async function handleDeleteItem(path: string, recursive: boolean) {
try {
await remove(resolvePath(path), { recursive })
await refresh()
} catch (e) {
addNotification({
title: formatMessage(commonMessages.deleteFailedLabel),
text: e instanceof Error ? e.message : '',
type: 'error',
})
}
}
async function handleReadFile(path: string): Promise<string> {
return await readTextFile(resolvePath(path))
}
async function handleReadFileAsBlob(path: string): Promise<Blob> {
const bytes = await readFileBytes(resolvePath(path))
return new Blob([bytes])
}
async function handleWriteFile(path: string, content: string) {
await writeTextFile(resolvePath(path), content)
}
async function handleDownloadFile(path: string, _fileName: string) {
await invoke('plugin:files|file_save_as', {
instancePath: props.instance.path,
filePath: path,
})
}
const uploadState = ref<UploadState>({
isUploading: false,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: 0,
completedFiles: 0,
totalFiles: 0,
})
async function handleUploadFiles(files: File[]) {
if (files.length === 0) return
uploadState.value = {
isUploading: true,
currentFileName: '',
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: files.reduce((sum, f) => sum + f.size, 0),
completedFiles: 0,
totalFiles: files.length,
}
try {
for (const file of files) {
uploadState.value.currentFileName = file.name
const buffer = await file.arrayBuffer()
const targetPath = resolvePath(
currentPath.value ? `${currentPath.value}/${file.name}` : file.name,
)
await writeFileBytes(targetPath, new Uint8Array(buffer))
uploadState.value.completedFiles++
uploadState.value.uploadedBytes += file.size
uploadState.value.currentFileProgress = 1
}
} catch (e) {
addNotification({
title: formatMessage(commonMessages.uploadFailedLabel),
text: e instanceof Error ? e.message : '',
type: 'error',
})
} finally {
uploadState.value.isUploading = false
await refresh()
}
}
async function handleExtractFile(path: string, override: boolean, dry: boolean) {
try {
return await invoke('plugin:files|file_extract_zip', {
instancePath: props.instance.path,
filePath: path,
overrideConflicts: override,
dryRun: dry,
})
} catch (e) {
addNotification({
title: formatMessage(commonMessages.extractFailedLabel),
text: e instanceof Error ? e.message : '',
type: 'error',
})
}
}
debug('setup: registering profile_listener')
const unlistenProfiles = await profile_listener(
async (event: { event: string; profile_path_id: string }) => {
debug('profile_listener: event =', event.event, 'path =', event.profile_path_id)
if (event.profile_path_id === props.instance.path && event.event === 'synced') {
debug('profile_listener: synced event matched, calling refresh')
await refresh()
}
},
)
debug('setup: profile_listener registered')
onUnmounted(() => {
unlistenProfiles()
})
watch(
() => props.instance.path,
async () => {
debug('watch instance.path: changed to', props.instance.path)
instanceRoot.value = await get_full_path(props.instance.path)
currentPath.value = ''
await refresh()
},
)
provideFileManager({
items,
loading,
error,
currentPath,
navigateTo,
editingFile,
startEditing,
stopEditing,
createItem: handleCreateItem,
renameItem: handleRenameItem,
moveItem: handleMoveItem,
deleteItem: handleDeleteItem,
readFile: handleReadFile,
readFileAsBlob: handleReadFileAsBlob,
writeFile: handleWriteFile,
downloadFile: handleDownloadFile,
uploadFiles: handleUploadFiles,
uploadState,
extractFile: handleExtractFile,
refresh,
basePath: instanceRoot,
openInFolder: (path: string) => highlightInFolder(path),
downloadButtonLabel: formatMessage(messages.saveAs),
uploadingLabel: (completed: number, total: number) =>
formatMessage(messages.addingFiles, { completed, total }),
})
</script>
<template>
<FilePageLayout :show-refresh-button="true" />
</template>

View File

@@ -3,6 +3,7 @@
<div class="p-6 pr-2 pb-4" @contextmenu.prevent.stop="(event) => handleRightClick(event)"> <div class="p-6 pr-2 pb-4" @contextmenu.prevent.stop="(event) => handleRightClick(event)">
<ExportModal ref="exportModal" :instance="instance" /> <ExportModal ref="exportModal" :instance="instance" />
<InstanceSettingsModal <InstanceSettingsModal
:key="instance.path"
ref="settingsModal" ref="settingsModal"
:instance="instance" :instance="instance"
:offline="offline" :offline="offline"
@@ -36,27 +37,6 @@
</template> </template>
<template v-else> Never played </template> <template v-else> Never played </template>
</div> </div>
<div v-if="linkedProjectV3" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div
v-if="linkedProjectV3"
class="flex gap-1.5 items-center font-medium text-primary"
>
Linked to
<Avatar
:src="linkedProjectV3.icon_url"
:alt="linkedProjectV3.name"
:tint-by="instance.path"
size="24px"
/>
<router-link
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
class="hover:underline text-primary truncate"
>
{{ linkedProjectV3.name }}
</router-link>
</div>
</template> </template>
<template v-else> <template v-else>
@@ -285,6 +265,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client' import type { Labrinth } from '@modrinth/api-client'
import { import {
BoxesIcon,
CheckCircleIcon, CheckCircleIcon,
ClipboardCopyIcon, ClipboardCopyIcon,
DownloadIcon, DownloadIcon,
@@ -302,6 +283,7 @@ import {
ServerIcon, ServerIcon,
SettingsIcon, SettingsIcon,
StopCircleIcon, StopCircleIcon,
TerminalSquareIcon,
UpdatedIcon, UpdatedIcon,
UserPlusIcon, UserPlusIcon,
XIcon, XIcon,
@@ -312,6 +294,7 @@ import {
ContentPageHeader, ContentPageHeader,
injectNotificationManager, injectNotificationManager,
LoadingIndicator, LoadingIndicator,
NavTabs,
OverflowMenu, OverflowMenu,
ServerOnlinePlayers, ServerOnlinePlayers,
ServerPing, ServerPing,
@@ -329,7 +312,6 @@ import ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue' import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue' import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue' import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js' import { get_project_v3 } from '@/helpers/cache.js'
import { process_listener, profile_listener } from '@/helpers/events' import { process_listener, profile_listener } from '@/helpers/events'
@@ -451,14 +433,22 @@ const tabs = computed(() => [
{ {
label: 'Content', label: 'Content',
href: `${basePath.value}`, href: `${basePath.value}`,
icon: BoxesIcon,
},
{
label: 'Files',
href: `${basePath.value}/files`,
icon: FolderOpenIcon,
}, },
{ {
label: 'Worlds', label: 'Worlds',
href: `${basePath.value}/worlds`, href: `${basePath.value}/worlds`,
icon: GlobeIcon,
}, },
{ {
label: 'Logs', label: 'Logs',
href: `${basePath.value}/logs`, href: `${basePath.value}/logs`,
icon: TerminalSquareIcon,
}, },
]) ])

View File

@@ -13,6 +13,7 @@
:modpack-icon-url="linkedModpackProject?.icon_url ?? undefined" :modpack-icon-url="linkedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance" :enable-toggle="!props.isServerInstance"
:get-overflow-options="getOverflowOptions" :get-overflow-options="getOverflowOptions"
:switch-version="handleSwitchVersion"
@update:enabled="handleModpackContentToggle" @update:enabled="handleModpackContentToggle"
@bulk:enable="handleModpackContentBulkToggle" @bulk:enable="handleModpackContentBulkToggle"
@bulk:disable="handleModpackContentBulkToggle" @bulk:disable="handleModpackContentBulkToggle"
@@ -47,7 +48,7 @@
" "
:project-name=" :project-name="
updatingModpack updatingModpack
? (linkedModpackProject?.title ?? formatMessage(messages.modpackFallback)) ? (linkedModpackProject?.title ?? formatMessage(commonMessages.modpackLabel))
: (updatingProject?.project?.title ?? updatingProject?.file_name) : (updatingProject?.project?.title ?? updatingProject?.file_name)
" "
:loading="loadingVersions" :loading="loadingVersions"
@@ -65,7 +66,9 @@
import type { Labrinth } from '@modrinth/api-client' import type { Labrinth } from '@modrinth/api-client'
import { ClipboardCopyIcon, FolderOpenIcon } from '@modrinth/assets' import { ClipboardCopyIcon, FolderOpenIcon } from '@modrinth/assets'
import { import {
commonMessages,
ConfirmModpackUpdateModal, ConfirmModpackUpdateModal,
ContentCardLayout as ContentPageLayout,
type ContentItem, type ContentItem,
type ContentModpackCardCategory, type ContentModpackCardCategory,
type ContentModpackCardProject, type ContentModpackCardProject,
@@ -82,7 +85,6 @@ import {
useDebugLogger, useDebugLogger,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { ContentCardLayout as ContentPageLayout } from '@modrinth/ui'
import { getCurrentWebview } from '@tauri-apps/api/webview' import { getCurrentWebview } from '@tauri-apps/api/webview'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
@@ -125,10 +127,6 @@ const messages = defineMessages({
id: 'app.instance.mods.share-text', id: 'app.instance.mods.share-text',
defaultMessage: "Check out the projects I'm using in my modpack!", defaultMessage: "Check out the projects I'm using in my modpack!",
}, },
modpackFallback: {
id: 'app.instance.mods.modpack-fallback',
defaultMessage: 'Modpack',
},
successfullyUploaded: { successfullyUploaded: {
id: 'app.instance.mods.successfully-uploaded', id: 'app.instance.mods.successfully-uploaded',
defaultMessage: 'Successfully uploaded', defaultMessage: 'Successfully uploaded',
@@ -141,30 +139,10 @@ const messages = defineMessages({
id: 'app.instance.mods.projects-were-added', id: 'app.instance.mods.projects-were-added',
defaultMessage: '{count} projects were added', defaultMessage: '{count} projects were added',
}, },
updating: {
id: 'app.instance.mods.updating',
defaultMessage: 'Updating...',
},
installing: {
id: 'app.instance.mods.installing',
defaultMessage: 'Installing...',
},
contentTypeProject: { contentTypeProject: {
id: 'app.instance.mods.content-type-project', id: 'app.instance.mods.content-type-project',
defaultMessage: 'project', defaultMessage: 'project',
}, },
unknownVersion: {
id: 'app.instance.mods.unknown-version',
defaultMessage: 'Unknown',
},
showFile: {
id: 'app.instance.mods.show-file',
defaultMessage: 'Show file',
},
copyLink: {
id: 'app.instance.mods.copy-link',
defaultMessage: 'Copy link',
},
}) })
let savedModalState: ModpackContentModalState | null = null let savedModalState: ModpackContentModalState | null = null
@@ -283,7 +261,7 @@ async function handleUploadFiles() {
} }
} }
async function _toggleDisableMod(mod: ContentItem) { async function toggleDisableMod(mod: ContentItem) {
try { try {
mod.file_path = await toggle_disable_project(props.instance.path, mod.file_path!) mod.file_path = await toggle_disable_project(props.instance.path, mod.file_path!)
mod.enabled = !mod.enabled mod.enabled = !mod.enabled
@@ -301,7 +279,7 @@ async function _toggleDisableMod(mod: ContentItem) {
} }
} }
const toggleDisableMod = useDebounceFn(_toggleDisableMod, 20) const toggleDisableDebounced = useDebounceFn(toggleDisableMod, 20)
async function removeMod(mod: ContentItem) { async function removeMod(mod: ContentItem) {
await remove_project(props.instance.path, mod.file_path!).catch(handleError) await remove_project(props.instance.path, mod.file_path!).catch(handleError)
@@ -354,6 +332,12 @@ async function updateProject(mod: ContentItem) {
} }
async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) { async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) {
isBulkOperating.value = true
mod.installing = true
if (mod.version) {
mod.version.id = version.id
mod.version.version_number = version.version_number
}
try { try {
await remove_project(props.instance.path, mod.file_path!) await remove_project(props.instance.path, mod.file_path!)
const newPath = await add_project_from_version(props.instance.path, version.id) const newPath = await add_project_from_version(props.instance.path, version.id)
@@ -364,20 +348,12 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
} }
mod.file_path = newPath mod.file_path = newPath
if (mod.version) {
mod.version.id = version.id
mod.version.version_number = version.version_number
}
trackEvent('InstanceProjectSwitchVersion', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) { } catch (err) {
handleError(err as Error) handleError(err as Error)
} finally {
mod.installing = false
isBulkOperating.value = false
await initProjects()
} }
} }
@@ -468,11 +444,11 @@ async function handleSwitchVersion(item: ContentItem) {
} }
async function handleModpackContentToggle(item: ContentItem) { async function handleModpackContentToggle(item: ContentItem) {
await toggleDisableMod(item) await toggleDisableDebounced(item)
} }
async function handleModpackContentBulkToggle(items: ContentItem[]) { async function handleModpackContentBulkToggle(items: ContentItem[]) {
await Promise.all(items.map((item) => _toggleDisableMod(item))) await Promise.all(items.map((item) => toggleDisableMod(item)))
} }
async function handleModpackContent() { async function handleModpackContent() {
@@ -539,7 +515,7 @@ async function fetchAndSpliceVersion(
async function handleVersionSelect(version: Labrinth.Versions.v2.Version) { async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
if (version.changelog != null) return if (version.changelog != null) return
loadingChangelog.value = true loadingChangelog.value = true
await fetchAndSpliceVersion(version.id, 'must_revalidate', handleError) await fetchAndSpliceVersion(version.id, 'must_revalidate', handleError as (err: unknown) => void)
loadingChangelog.value = false loadingChangelog.value = false
} }
@@ -660,14 +636,14 @@ function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
const options: OverflowMenuOption[] = [] const options: OverflowMenuOption[] = []
options.push({ options.push({
id: formatMessage(messages.showFile), id: formatMessage(commonMessages.showFileButton),
icon: FolderOpenIcon, icon: FolderOpenIcon,
action: () => highlightModInProfile(props.instance.path, item.file_path), action: () => highlightModInProfile(props.instance.path, item.file_path),
}) })
if (item.project?.slug) { if (item.project?.slug) {
options.push({ options.push({
id: formatMessage(messages.copyLink), id: formatMessage(commonMessages.copyLinkButton),
icon: ClipboardCopyIcon, icon: ClipboardCopyIcon,
action: async () => { action: async () => {
await navigator.clipboard.writeText( await navigator.clipboard.writeText(
@@ -718,18 +694,13 @@ async function initProjects(cacheBehaviour?: CacheBehaviour) {
if (allCategories && modpackInfo.project.categories) { if (allCategories && modpackInfo.project.categories) {
const seen = new Set<string>() const seen = new Set<string>()
linkedModpackCategories.value = allCategories linkedModpackCategories.value = allCategories.filter((cat: { name: string }) => {
.filter((cat: { name: string }) => {
if (modpackInfo.project.categories.includes(cat.name) && !seen.has(cat.name)) { if (modpackInfo.project.categories.includes(cat.name) && !seen.has(cat.name)) {
seen.add(cat.name) seen.add(cat.name)
return true return true
} }
return false return false
}) })
.map((cat: { name: string }) => ({
...cat,
name: cat.name.charAt(0).toUpperCase() + cat.name.slice(1),
}))
} else { } else {
linkedModpackCategories.value = [] linkedModpackCategories.value = []
} }
@@ -799,8 +770,8 @@ provideContentManager({
hasUpdate: linkedModpackHasUpdate.value, hasUpdate: linkedModpackHasUpdate.value,
disabled: isModpackUpdating.value, disabled: isModpackUpdating.value,
disabledText: isModpackUpdating.value disabledText: isModpackUpdating.value
? formatMessage(messages.updating) ? formatMessage(commonMessages.updatingLabel)
: formatMessage(messages.installing), : formatMessage(commonMessages.installingLabel),
} }
: null, : null,
), ),
@@ -808,13 +779,18 @@ provideContentManager({
isBusy: isInstanceBusy, isBusy: isInstanceBusy,
isBulkOperating, isBulkOperating,
contentTypeLabel: ref(formatMessage(messages.contentTypeProject)), contentTypeLabel: ref(formatMessage(messages.contentTypeProject)),
toggleEnabled: toggleDisableMod, toggleEnabled: toggleDisableDebounced,
bulkEnableItems: (items) => bulkEnableItems: (items: ContentItem[]) =>
Promise.all(items.map((item) => _toggleDisableMod(item))).then(() => {}), Promise.all(items.filter((item) => !item.enabled).map((item) => toggleDisableMod(item))).then(
bulkDisableItems: (items) => () => {},
Promise.all(items.map((item) => _toggleDisableMod(item))).then(() => {}), ),
bulkDisableItems: (items: ContentItem[]) =>
Promise.all(items.filter((item) => item.enabled).map((item) => toggleDisableMod(item))).then(
() => {},
),
deleteItem: removeMod, deleteItem: removeMod,
bulkDeleteItems: (items) => Promise.all(items.map((item) => removeMod(item))).then(() => {}), bulkDeleteItems: (items: ContentItem[]) =>
Promise.all(items.map((item) => removeMod(item))).then(() => {}),
refresh: () => initProjects('must_revalidate'), refresh: () => initProjects('must_revalidate'),
browse: handleBrowseContent, browse: handleBrowseContent,
uploadFiles: handleUploadFiles, uploadFiles: handleUploadFiles,
@@ -830,7 +806,7 @@ provideContentManager({
showContentHint, showContentHint,
dismissContentHint, dismissContentHint,
shareItems: handleShareItems, shareItems: handleShareItems,
mapToTableItem: (item) => ({ mapToTableItem: (item: ContentItem) => ({
id: item.id, id: item.id,
project: item.project ?? { project: item.project ?? {
id: item.file_name, id: item.file_name,
@@ -843,7 +819,7 @@ provideContentManager({
: undefined, : undefined,
version: item.version ?? { version: item.version ?? {
id: item.file_name, id: item.file_name,
version_number: formatMessage(messages.unknownVersion), version_number: formatMessage(commonMessages.unknownLabel),
file_name: item.file_name, file_name: item.file_name,
}, },
versionLink: versionLink:
@@ -860,6 +836,7 @@ provideContentManager({
} }
: undefined, : undefined,
enabled: item.enabled, enabled: item.enabled,
installing: item.installing,
}), }),
filterPersistKey: props.instance.path, filterPersistKey: props.instance.path,
}) })

View File

@@ -15,48 +15,88 @@
<EditWorldModal ref="editWorldModal" :instance="instance" @submit="editWorld" /> <EditWorldModal ref="editWorldModal" :instance="instance" @submit="editWorld" />
<ConfirmModalWrapper <ConfirmModalWrapper
ref="removeServerModal" ref="removeServerModal"
:title="`Are you sure you want to remove ${serverToRemove?.name ?? 'this server'}?`" :title="
:description="`'${serverToRemove?.name}'${serverToRemove?.address === serverToRemove?.name ? ' ' : ` (${serverToRemove?.address})`} will be removed from your list, including in-game, and there will be no way to recover it.`" formatMessage(messages.removeServerTitle, {
name: serverToRemove?.name ?? formatMessage(messages.thisServer),
})
"
:description="
serverToRemove?.address === serverToRemove?.name
? formatMessage(messages.removeServerDescription, { name: serverToRemove?.name })
: formatMessage(messages.removeServerDescriptionWithAddress, {
name: serverToRemove?.name,
address: serverToRemove?.address,
})
"
:markdown="false" :markdown="false"
@proceed="proceedRemoveServer" @proceed="proceedRemoveServer"
/> />
<ConfirmModalWrapper <ConfirmModalWrapper
ref="deleteWorldModal" ref="deleteWorldModal"
:title="`Are you sure you want to permanently delete this world?`" :title="formatMessage(messages.deleteWorldTitle)"
:description="`'${worldToDelete?.name}' will be **permanently deleted**, and there will be no way to recover it.`" :description="formatMessage(messages.deleteWorldDescription, { name: worldToDelete?.name })"
@proceed="proceedDeleteWorld" @proceed="proceedDeleteWorld"
/> />
<div v-if="dedupedWorlds.length > 0" class="flex flex-col gap-4"> <div v-if="dedupedWorlds.length > 0" class="flex flex-col gap-4">
<div class="flex flex-wrap gap-2 items-center"> <div class="flex flex-wrap items-center gap-2">
<StyledInput <StyledInput
v-model="searchFilter" v-model="searchFilter"
:icon="SearchIcon" :icon="SearchIcon"
type="text" type="text"
placeholder="Search worlds..."
autocomplete="off" autocomplete="off"
:spellcheck="false"
input-class="!h-10"
wrapper-class="flex-1 min-w-0"
clearable clearable
wrapper-class="flex-grow" :placeholder="
formatMessage(messages.searchWorldsPlaceholder, { count: dedupedWorlds.length })
"
/> />
<ButtonStyled> <div class="flex gap-2">
<button :disabled="refreshingAll" @click="refreshAllWorlds"> <ButtonStyled type="outlined">
<template v-if="refreshingAll"> <button class="!h-10 !border-button-bg !border-[1px]" @click="addServerModal?.show()">
<SpinnerIcon class="animate-spin" /> <PlusIcon class="size-5" />
Refreshing... {{ formatMessage(messages.addServer) }}
</template>
<template v-else>
<UpdatedIcon />
Refresh
</template>
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled color="brand">
<button @click="addServerModal?.show()"> <button
<PlusIcon /> class="!h-10 flex items-center gap-2"
Add a server @click="
router.push({ path: '/browse/server', query: { i: instance.path, from: 'worlds' } })
"
>
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseServers) }}</span>
</button>
</ButtonStyled>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<FilterIcon class="size-5 text-secondary" />
<button
:class="filterPillClass(selectedFilters.length === 0)"
@click="selectedFilters = []"
>
{{ formatMessage(commonMessages.allProjectType) }}
</button>
<button
v-for="option in filterOptions"
:key="option.id"
:class="filterPillClass(selectedFilters.includes(option.id))"
@click="toggleFilter(option.id)"
>
{{ option.label }}
</button>
</div>
<ButtonStyled type="transparent" hover-color-fill="none">
<button :disabled="refreshingAll" @click="refreshAllWorlds">
<RefreshCwIcon :class="refreshingAll ? 'animate-spin' : ''" />
{{ formatMessage(commonMessages.refreshButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<FilterBar v-model="filters" :options="filterOptions" show-all-options />
<div class="flex flex-col w-full gap-2"> <div class="flex flex-col w-full gap-2">
<WorldItem <WorldItem
v-for="world in filteredWorlds" v-for="world in filteredWorlds"
@@ -92,51 +132,49 @@
/> />
</div> </div>
</div> </div>
<div v-else class="w-full max-w-[48rem] mx-auto flex flex-col mt-6"> <EmptyState
<RadialHeader class=""> v-else
<div class="flex items-center gap-6 w-[32rem] mx-auto"> type="empty-inbox"
<img src="@/assets/sad-modrinth-bot.webp" alt="" aria-hidden="true" class="h-24" /> :heading="formatMessage(messages.noWorldsHeading)"
<span class="text-contrast font-bold text-xl"> You don't have any worlds yet. </span> :description="formatMessage(messages.noWorldsDescription)"
</div> >
</RadialHeader> <template #actions>
<div class="flex gap-2 mt-4 mx-auto"> <ButtonStyled type="outlined">
<ButtonStyled> <button class="!h-10 !border-button-bg !border-[1px]" @click="addServerModal?.show()">
<button @click="addServerModal?.show()"> <PlusIcon class="size-5" />
<PlusIcon aria-hidden="true" /> {{ formatMessage(messages.addServer) }}
Add a server
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled color="brand">
<button :disabled="refreshingAll" @click="refreshAllWorlds"> <button
<template v-if="refreshingAll"> class="!h-10 flex items-center gap-2"
<SpinnerIcon aria-hidden="true" class="animate-spin" /> @click="
Refreshing... router.push({ path: '/browse/server', query: { i: instance.path, from: 'worlds' } })
</template> "
<template v-else> >
<UpdatedIcon aria-hidden="true" /> <CompassIcon class="size-5" />
Refresh <span>{{ formatMessage(messages.browseServers) }}</span>
</template>
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </template>
</div> </EmptyState>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { PlusIcon, SearchIcon, SpinnerIcon, UpdatedIcon } from '@modrinth/assets' import { CompassIcon, FilterIcon, PlusIcon, RefreshCwIcon, SearchIcon } from '@modrinth/assets'
import { import {
ButtonStyled, ButtonStyled,
commonMessages,
defineMessages, defineMessages,
FilterBar, EmptyState,
type FilterBarOption,
GAME_MODES, GAME_MODES,
type GameVersion, type GameVersion,
injectNotificationManager, injectNotificationManager,
RadialHeader,
StyledInput, StyledInput,
useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import type ContextMenu from '@/components/ui/ContextMenu.vue' import type ContextMenu from '@/components/ui/ContextMenu.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue' import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
@@ -176,11 +214,80 @@ import {
type World, type World,
} from '@/helpers/worlds.ts' } from '@/helpers/worlds.ts'
import { injectServerInstall } from '@/providers/server-install' import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { ensureManagedServerWorldExists, getServerAddress } from '@/store/install' import { ensureManagedServerWorldExists, getServerAddress } from '@/store/install'
const messages = defineMessages({
removeServerTitle: {
id: 'app.instance.worlds.remove-server-title',
defaultMessage: 'Are you sure you want to remove {name}?',
},
removeServerDescription: {
id: 'app.instance.worlds.remove-server-description',
defaultMessage:
"'{name}' will be removed from your list, including in-game, and there will be no way to recover it.",
},
removeServerDescriptionWithAddress: {
id: 'app.instance.worlds.remove-server-description-with-address',
defaultMessage:
"'{name}' ({address}) will be removed from your list, including in-game, and there will be no way to recover it.",
},
deleteWorldTitle: {
id: 'app.instance.worlds.delete-world-title',
defaultMessage: 'Are you sure you want to permanently delete this world?',
},
deleteWorldDescription: {
id: 'app.instance.worlds.delete-world-description',
defaultMessage:
"'{name}' will be **permanently deleted**, and there will be no way to recover it.",
},
searchWorldsPlaceholder: {
id: 'app.instance.worlds.search-worlds-placeholder',
defaultMessage: 'Search {count} worlds...',
},
addServer: {
id: 'app.instance.worlds.add-server',
defaultMessage: 'Add server',
},
browseServers: {
id: 'app.instance.worlds.browse-servers',
defaultMessage: 'Browse servers',
},
noWorldsHeading: {
id: 'app.instance.worlds.no-worlds-heading',
defaultMessage: 'No servers or worlds added',
},
noWorldsDescription: {
id: 'app.instance.worlds.no-worlds-description',
defaultMessage: 'Add a server or browse to get started',
},
thisServer: {
id: 'app.instance.worlds.this-server',
defaultMessage: 'this server',
},
vanillaFilter: {
id: 'app.instance.worlds.filter-vanilla',
defaultMessage: 'Vanilla',
},
moddedFilter: {
id: 'app.instance.worlds.filter-modded',
defaultMessage: 'Modded',
},
onlineFilter: {
id: 'app.instance.worlds.filter-online',
defaultMessage: 'Online',
},
offlineFilter: {
id: 'app.instance.worlds.filter-offline',
defaultMessage: 'Offline',
},
})
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const { playServerProject } = injectServerInstall() const { playServerProject } = injectServerInstall()
const route = useRoute() const route = useRoute()
const router = useRouter()
const addServerModal = ref<InstanceType<typeof AddServerModal>>() const addServerModal = ref<InstanceType<typeof AddServerModal>>()
const editServerModal = ref<InstanceType<typeof EditServerModal>>() const editServerModal = ref<InstanceType<typeof EditServerModal>>()
@@ -211,9 +318,32 @@ function play(world: World) {
emit('play', world) emit('play', world)
} }
const filters = ref<string[]>([]) const selectedFilters = ref<string[]>([])
const searchFilter = ref('') const searchFilter = ref('')
function filterPillClass(isActive: boolean) {
return [
'cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]',
isActive
? 'border-green bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5',
]
}
function toggleFilter(id: string) {
const idx = selectedFilters.value.indexOf(id)
if (idx >= 0) {
selectedFilters.value.splice(idx, 1)
} else {
selectedFilters.value.push(id)
if (id === 'singleplayer') {
selectedFilters.value = selectedFilters.value.filter((f) => f !== 'online' && f !== 'offline')
} else if (id === 'online' || id === 'offline') {
selectedFilters.value = selectedFilters.value.filter((f) => f !== 'singleplayer')
}
}
}
const refreshingAll = ref(false) const refreshingAll = ref(false)
const hadNoWorlds = ref(true) const hadNoWorlds = ref(true)
const startingInstance = ref(false) const startingInstance = ref(false)
@@ -371,6 +501,12 @@ async function editServer(server: ServerWorld) {
async function removeServer(server: ServerWorld) { async function removeServer(server: ServerWorld) {
await remove_server_from_profile(instance.value.path, server.index).catch(handleError) await remove_server_from_profile(instance.value.path, server.index).catch(handleError)
worlds.value = worlds.value.filter((w) => w.type !== 'server' || w.index !== server.index) worlds.value = worlds.value.filter((w) => w.type !== 'server' || w.index !== server.index)
let serverIdx = 0
for (const w of worlds.value) {
if (w.type === 'server') {
w.index = serverIdx++
}
}
} }
async function editWorld(path: string, name: string, removeIcon: boolean) { async function editWorld(path: string, name: string, removeIcon: boolean) {
@@ -393,7 +529,7 @@ async function deleteWorld(world: SingleplayerWorld) {
} }
function handleJoinError(err: Error) { function handleJoinError(err: Error) {
handleError(err) handleSevereError(err, { profilePath: instance.value.path })
startingInstance.value = false startingInstance.value = false
worldPlaying.value = undefined worldPlaying.value = undefined
} }
@@ -498,58 +634,83 @@ const dedupedWorlds = computed(() => {
}) })
const filterOptions = computed(() => { const filterOptions = computed(() => {
const options: FilterBarOption[] = [] const options: { id: string; label: string }[] = []
const hasSingleplayer = dedupedWorlds.value.some((x) => x.type === 'singleplayer')
const hasServer = dedupedWorlds.value.some((x) => x.type === 'server') const hasServer = dedupedWorlds.value.some((x) => x.type === 'server')
if (dedupedWorlds.value.some((x) => x.type === 'singleplayer') && hasServer) { const hasStatusFilter =
options.push({ selectedFilters.value.includes('online') || selectedFilters.value.includes('offline')
id: 'singleplayer',
message: messages.singleplayer, if (hasSingleplayer && hasServer && !hasStatusFilter) {
}) options.push({ id: 'singleplayer', label: formatMessage(commonMessages.singleplayerLabel) })
options.push({
id: 'server',
message: messages.server,
})
} }
if (hasServer) { if (hasServer) {
// add available filter if there's any offline ("unavailable") servers AND there's any singleplayer worlds or available servers const servers = dedupedWorlds.value.filter((x) => x.type === 'server')
if ( const hasVanilla = servers.some((x) => x.content_kind !== 'modpack')
dedupedWorlds.value.some( const hasModded = servers.some((x) => x.content_kind === 'modpack')
(x) => if (hasVanilla && hasModded) {
x.type === 'server' && options.push({ id: 'vanilla', label: formatMessage(messages.vanillaFilter) })
!serverData.value[x.address]?.status && options.push({ id: 'modded', label: formatMessage(messages.moddedFilter) })
!serverData.value[x.address]?.refreshing, }
) && if (!selectedFilters.value.includes('singleplayer')) {
dedupedWorlds.value.some( const hasOnline = servers.some((x) => !!serverData.value[x.address]?.status)
(x) => const hasOffline = servers.some((x) => !serverData.value[x.address]?.status)
x.type === 'singleplayer' || if (hasOnline && hasOffline) {
(x.type === 'server' && options.push({ id: 'online', label: formatMessage(messages.onlineFilter) })
serverData.value[x.address]?.status && options.push({ id: 'offline', label: formatMessage(messages.offlineFilter) })
!serverData.value[x.address]?.refreshing), }
)
) {
options.push({
id: 'available',
message: messages.available,
})
} }
} }
return options return options
}) })
watch(filterOptions, (options) => {
const validIds = new Set(options.map((opt) => opt.id))
const cleaned = selectedFilters.value.filter((f) => validIds.has(f))
if (cleaned.length !== selectedFilters.value.length) {
selectedFilters.value = cleaned
}
})
const filteredWorlds = computed(() => const filteredWorlds = computed(() =>
dedupedWorlds.value.filter((x) => { dedupedWorlds.value.filter((x) => {
const availableFilter = filters.value.includes('available') if (searchFilter.value && !x.name.toLowerCase().includes(searchFilter.value.toLowerCase())) {
const typeFilter = filters.value.includes('server') || filters.value.includes('singleplayer') return false
}
return ( if (selectedFilters.value.length === 0) return true
(!typeFilter || filters.value.includes(x.type)) &&
(!availableFilter || x.type !== 'server' || serverData.value[x.address]?.status) && const hasSingleplayerFilter = selectedFilters.value.includes('singleplayer')
(!searchFilter.value || x.name.toLowerCase().includes(searchFilter.value.toLowerCase())) const typeFilters = selectedFilters.value.filter((f) => f === 'vanilla' || f === 'modded')
) const statusFilters = selectedFilters.value.filter((f) => f === 'online' || f === 'offline')
if (x.type === 'singleplayer') {
return hasSingleplayerFilter || (typeFilters.length === 0 && statusFilters.length === 0)
}
if (hasSingleplayerFilter && typeFilters.length === 0 && statusFilters.length === 0) {
return false
}
let passesType = true
if (typeFilters.length > 0) {
const isModded = x.content_kind === 'modpack'
passesType =
(typeFilters.includes('modded') && isModded) ||
(typeFilters.includes('vanilla') && !isModded)
}
let passesStatus = true
if (statusFilters.length > 0) {
const isOnline = !!serverData.value[x.address]?.status
passesStatus =
(statusFilters.includes('online') && isOnline) ||
(statusFilters.includes('offline') && !isOnline)
}
return passesType && passesStatus
}), }),
) )
@@ -588,19 +749,4 @@ async function proceedDeleteWorld() {
onUnmounted(() => { onUnmounted(() => {
unlistenProfile() unlistenProfile()
}) })
const messages = defineMessages({
singleplayer: {
id: 'instance.worlds.type.singleplayer',
defaultMessage: 'Singleplayer',
},
server: {
id: 'instance.worlds.type.server',
defaultMessage: 'Server',
},
available: {
id: 'instance.worlds.filter.available',
defaultMessage: 'Available',
},
})
</script> </script>

View File

@@ -1,7 +1,8 @@
import Files from './Files.vue'
import Index from './Index.vue' import Index from './Index.vue'
import Logs from './Logs.vue' import Logs from './Logs.vue'
import Mods from './Mods.vue' import Mods from './Mods.vue'
import Overview from './Overview.vue' import Overview from './Overview.vue'
import Worlds from './Worlds.vue' import Worlds from './Worlds.vue'
export { Index, Logs, Mods, Overview, Worlds } export { Files, Index, Logs, Mods, Overview, Worlds }

View File

@@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { PlusIcon } from '@modrinth/assets' import { PlusIcon } from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui' import { Button, injectNotificationManager, NavTabs } from '@modrinth/ui'
import { inject, onUnmounted, ref, shallowRef } from 'vue' import { inject, onUnmounted, ref, shallowRef } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { NewInstanceImage } from '@/assets/icons' import { NewInstanceImage } from '@/assets/icons'
import NavTabs from '@/components/ui/NavTabs.vue'
import { profile_listener } from '@/helpers/events.js' import { profile_listener } from '@/helpers/events.js'
import { list } from '@/helpers/profile.js' import { list } from '@/helpers/profile.js'
import { useBreadcrumbs } from '@/store/breadcrumbs.js' import { useBreadcrumbs } from '@/store/breadcrumbs.js'

View File

@@ -170,10 +170,7 @@
}, },
{ {
label: 'Versions', label: 'Versions',
href: { href: versionsHref,
path: `/project/${$route.params.id}/versions`,
query: instanceFilters,
},
subpages: ['version'], subpages: ['version'],
shown: projectV3?.minecraft_server == null, shown: projectV3?.minecraft_server == null,
}, },
@@ -224,6 +221,7 @@ import {
import { import {
ButtonStyled, ButtonStyled,
injectNotificationManager, injectNotificationManager,
NavTabs,
OverflowMenu, OverflowMenu,
ProjectBackgroundGradient, ProjectBackgroundGradient,
ProjectHeader, ProjectHeader,
@@ -242,7 +240,6 @@ import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue' import ContextMenu from '@/components/ui/ContextMenu.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue' import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { import {
get_organization, get_organization,
get_project, get_project,
@@ -318,6 +315,21 @@ const instanceFilters = computed(() => {
return { l: loaders, g: instance.value.game_version } return { l: loaders, g: instance.value.game_version }
}) })
const versionsHref = computed(() => {
const base = `/project/${route.params.id}/versions`
const filters = instanceFilters.value
const params = new URLSearchParams()
for (const [key, val] of Object.entries(filters)) {
if (Array.isArray(val)) {
for (const v of val) params.append(key, v)
} else if (val) {
params.append(key, String(val))
}
}
const qs = params.toString()
return qs ? `${base}?${qs}` : base
})
const [allLoaders, allGameVersions] = await Promise.all([ const [allLoaders, allGameVersions] = await Promise.all([
get_loaders().catch(handleError).then(ref), get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref), get_game_versions().catch(handleError).then(ref),

View File

@@ -133,6 +133,8 @@ export function createContentInstall(opts: {
title: string title: string
icon_url?: string | null icon_url?: string | null
project_type?: string project_type?: string
organization?: string | null
team?: string
}, },
version?: Labrinth.Versions.v2.Version, version?: Labrinth.Versions.v2.Version,
) { ) {
@@ -164,6 +166,60 @@ export function createContentInstall(opts: {
if (items.some((i) => i.file_name === placeholder.file_name)) return if (items.some((i) => i.file_name === placeholder.file_name)) return
next.set(instancePath, [...items, placeholder]) next.set(instancePath, [...items, placeholder])
installingItems.value = next installingItems.value = next
if (project.organization) {
get_organization(project.organization)
.then((org: { id: string; slug: string; name: string; icon_url?: string }) => {
updateInstallingItem(instancePath, placeholder.file_name, {
owner: {
id: org.id,
name: org.name,
avatar_url: org.icon_url,
type: 'organization',
},
})
})
.catch(() => {})
} else if (project.team) {
get_team(project.team)
.then(
(
members: {
user: { id: string; username: string; avatar_url?: string }
is_owner: boolean
}[],
) => {
const owner = members.find((m) => m.is_owner)
if (owner) {
updateInstallingItem(instancePath, placeholder.file_name, {
owner: {
id: owner.user.id,
name: owner.user.username,
avatar_url: owner.user.avatar_url,
type: 'user',
},
})
}
},
)
.catch(() => {})
}
}
function updateInstallingItem(
instancePath: string,
fileName: string,
updates: Partial<ContentItem>,
) {
const next = new Map(installingItems.value)
const items = next.get(instancePath)
if (!items) return
const index = items.findIndex((i) => i.file_name === fileName)
if (index === -1) return
const updated = [...items]
updated[index] = { ...updated[index], ...updates }
next.set(instancePath, updated)
installingItems.value = next
} }
function removeInstallingItems(instancePath: string, projectIds: string[]) { function removeInstallingItems(instancePath: string, projectIds: string[]) {

View File

@@ -1,10 +1,10 @@
import { createContext } from '@modrinth/ui' import { createContext } from '@modrinth/ui'
import type { Ref } from 'vue' import type { ComputedRef, Ref } from 'vue'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
export interface InstanceSettingsContext { export interface InstanceSettingsContext {
instance: GameInstance instance: ComputedRef<GameInstance>
offline?: boolean offline?: boolean
isMinecraftServer: Ref<boolean> isMinecraftServer: Ref<boolean>
onUnlinked: () => void onUnlinked: () => void

View File

@@ -41,7 +41,8 @@ export default new createRouter({
name: 'Discover content', name: 'Discover content',
component: Pages.Browse, component: Pages.Browse,
meta: { meta: {
breadcrumb: [{ name: 'Discover content' }], useContext: true,
breadcrumb: [{ name: '?BrowseTitle' }],
}, },
}, },
{ {
@@ -178,6 +179,15 @@ export default new createRouter({
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }], breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
}, },
}, },
{
path: 'files',
name: 'Files',
component: Instance.Files,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Files' }],
},
},
{ {
path: 'logs', path: 'logs',
name: 'Logs', name: 'Logs',

View File

@@ -8,6 +8,7 @@ repository = "https://github.com/modrinth/code/apps/app/"
license = "GPL-3.0-only" license = "GPL-3.0-only"
[dependencies] [dependencies]
async_zip = { workspace = true, features = ["deflate", "tokio-fs"] }
chrono = { workspace = true } chrono = { workspace = true }
daedalus = { workspace = true } daedalus = { workspace = true }
dashmap = { workspace = true } dashmap = { workspace = true }
@@ -28,6 +29,7 @@ tauri = { workspace = true, features = [
] } ] }
tauri-plugin-deep-link = { workspace = true } tauri-plugin-deep-link = { workspace = true }
tauri-plugin-dialog = { workspace = true } tauri-plugin-dialog = { workspace = true }
tauri-plugin-fs = { workspace = true }
tauri-plugin-http = { workspace = true } tauri-plugin-http = { workspace = true }
tauri-plugin-opener = { workspace = true } tauri-plugin-opener = { workspace = true }
tauri-plugin-os = { workspace = true } tauri-plugin-os = { workspace = true }

View File

@@ -263,6 +263,14 @@ fn main() {
DefaultPermissionRule::AllowAllCommands, DefaultPermissionRule::AllowAllCommands,
), ),
) )
.plugin(
"files",
InlinedPlugin::new()
.commands(&["file_extract_zip", "file_save_as"])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin( .plugin(
"friends", "friends",
InlinedPlugin::new() InlinedPlugin::new()

View File

@@ -25,6 +25,32 @@
"allow": [{ "url": "https://modrinth.com/*" }, { "url": "https://*.modrinth.com/*" }] "allow": [{ "url": "https://modrinth.com/*" }, { "url": "https://*.modrinth.com/*" }]
}, },
"dialog:allow-save",
"fs:allow-read-dir",
"fs:allow-read-file",
"fs:allow-read-text-file",
"fs:allow-write-file",
"fs:allow-write-text-file",
"fs:allow-create",
"fs:allow-remove",
"fs:allow-rename",
"fs:allow-copy-file",
"fs:allow-stat",
"fs:allow-exists",
"fs:allow-mkdir",
{
"identifier": "fs:scope",
"allow": [
{ "path": "$APPDATA/profiles" },
{ "path": "$APPDATA/profiles/**" },
{ "path": "$APPCONFIG/profiles" },
{ "path": "$APPCONFIG/profiles/**" },
{ "path": "$CONFIG/profiles" },
{ "path": "$CONFIG/profiles/**" }
]
},
"auth:default", "auth:default",
"import:default", "import:default",
"jre:default", "jre:default",
@@ -37,6 +63,7 @@
"process:default", "process:default",
"profile:default", "profile:default",
"cache:default", "cache:default",
"files:default",
"settings:default", "settings:default",
"tags:default", "tags:default",
"utils:default", "utils:default",

164
apps/app/src/api/files.rs Normal file
View File

@@ -0,0 +1,164 @@
use crate::api::Result;
use async_zip::base::read::seek::ZipFileReader;
use serde::Serialize;
use std::io::Cursor;
use tauri::Runtime;
use tauri_plugin_dialog::DialogExt;
use theseus::profile::get_full_path;
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("files")
.invoke_handler(tauri::generate_handler![
file_extract_zip,
file_save_as,
])
.build()
}
#[derive(Serialize)]
pub struct ExtractDryRunResult {
modpack_name: Option<String>,
conflicting_files: Vec<String>,
}
#[tauri::command]
pub async fn file_extract_zip(
instance_path: &str,
file_path: &str,
override_conflicts: bool,
dry_run: bool,
) -> Result<Option<ExtractDryRunResult>> {
let base = get_full_path(instance_path).await?;
let zip_path = base.join(file_path);
let canonical_zip = tokio::fs::canonicalize(&zip_path).await?;
let canonical_base = tokio::fs::canonicalize(&base).await?;
if !canonical_zip.starts_with(&canonical_base) {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"file_path escapes the instance directory".to_string(),
))
.into());
}
let extract_dir = zip_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| base.clone());
let file_bytes = tokio::fs::read(&zip_path).await?;
let reader = Cursor::new(file_bytes);
let zip_reader = ZipFileReader::with_tokio(reader).await.map_err(|e| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to read zip file: {e}"
)))
})?;
let entries: Vec<(usize, String)> = zip_reader
.file()
.entries()
.iter()
.enumerate()
.filter_map(|(i, entry)| {
let name = entry.filename().as_str().ok()?.to_string();
if name.ends_with('/') {
None
} else {
Some((i, name))
}
})
.collect();
if dry_run {
let mut conflicting_files = Vec::new();
let canonical_extract = tokio::fs::canonicalize(&extract_dir).await?;
for (_, name) in &entries {
let target = extract_dir.join(name);
if let Some(parent) = target.parent() {
let normalized = parent
.canonicalize()
.unwrap_or_else(|_| extract_dir.join(parent));
if !normalized.starts_with(&canonical_extract) {
continue;
}
}
if target.exists() {
conflicting_files.push(name.clone());
}
}
return Ok(Some(ExtractDryRunResult {
modpack_name: None,
conflicting_files,
}));
}
let canonical_extract_dir = tokio::fs::canonicalize(&extract_dir).await?;
let mut zip_reader = zip_reader;
for (index, name) in &entries {
let target = extract_dir.join(name);
if !override_conflicts && target.exists() {
continue;
}
if let Some(parent) = target.parent() {
tokio::fs::create_dir_all(parent).await?;
let canonical_parent = tokio::fs::canonicalize(parent).await?;
if !canonical_parent.starts_with(&canonical_extract_dir) {
continue;
}
}
let mut file_bytes = Vec::new();
let mut entry_reader =
zip_reader.reader_with_entry(*index).await.map_err(|e| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to read zip entry: {e}"
)))
})?;
entry_reader
.read_to_end_checked(&mut file_bytes)
.await
.map_err(|e| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to extract zip entry: {e}"
)))
})?;
tokio::fs::write(&target, &file_bytes).await?;
}
Ok(None)
}
#[tauri::command]
pub async fn file_save_as<R: Runtime>(
app: tauri::AppHandle<R>,
instance_path: &str,
file_path: &str,
) -> Result<()> {
let base = get_full_path(instance_path).await?;
let source = base.join(file_path);
let file_name = source
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.set_file_name(&file_name)
.save_file(|path| {
let _ = tx.send(path);
});
if let Some(dest) = rx.await.unwrap_or(None) {
let dest_path = std::path::PathBuf::try_from(dest).map_err(|e| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Invalid save path: {e}"
)))
})?;
tokio::fs::copy(&source, &dest_path).await?;
}
Ok(())
}

View File

@@ -19,6 +19,7 @@ pub mod utils;
pub mod ads; pub mod ads;
pub mod cache; pub mod cache;
pub mod files;
pub mod friends; pub mod friends;
pub mod worlds; pub mod worlds;

View File

@@ -151,12 +151,20 @@ pub async fn add_server_to_profile(
name: String, name: String,
address: String, address: String,
pack_status: ServerPackStatus, pack_status: ServerPackStatus,
project_id: Option<String>,
content_kind: Option<String>,
) -> Result<usize> { ) -> Result<usize> {
let path = get_full_path(path).await?; let full_path = get_full_path(path).await?;
Ok( Ok(worlds::add_server_to_profile(
worlds::add_server_to_profile(&path, name, address, pack_status) &full_path,
.await?, path,
name,
address,
pack_status,
project_id,
content_kind,
) )
.await?)
} }
#[tauri::command] #[tauri::command]

View File

@@ -152,6 +152,7 @@ fn main() {
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin( .plugin(
tauri_plugin_window_state::Builder::default() tauri_plugin_window_state::Builder::default()
@@ -229,6 +230,7 @@ fn main() {
.plugin(api::tags::init()) .plugin(api::tags::init())
.plugin(api::utils::init()) .plugin(api::utils::init())
.plugin(api::cache::init()) .plugin(api::cache::init())
.plugin(api::files::init())
.plugin(api::ads::init()) .plugin(api::ads::init())
.plugin(api::friends::init()) .plugin(api::friends::init())
.plugin(api::worlds::init()) .plugin(api::worlds::init())

View File

@@ -446,13 +446,6 @@ textarea {
} }
} }
button,
input[type='button'] {
cursor: pointer;
border: none;
outline: 2px solid transparent;
}
kbd { kbd {
background-color: var(--color-code-bg); background-color: var(--color-code-bg);
color: var(--color-code-text); color: var(--color-code-text);

View File

@@ -30,6 +30,7 @@ import {
type OverflowMenuOption, type OverflowMenuOption,
useFormatDateTime, useFormatDateTime,
} from '@modrinth/ui' } from '@modrinth/ui'
import { NavTabs } from '@modrinth/ui'
import { import {
capitalizeString, capitalizeString,
formatProjectType, formatProjectType,
@@ -41,7 +42,6 @@ import dayjs from 'dayjs'
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import type { UnsafeFile } from '~/components/ui/moderation/MaliciousSummaryModal.vue' import type { UnsafeFile } from '~/components/ui/moderation/MaliciousSummaryModal.vue'
import NavTabs from '~/components/ui/NavTabs.vue'
import ThreadView from '~/components/ui/thread/ThreadView.vue' import ThreadView from '~/components/ui/thread/ThreadView.vue'
const auth = await useAuth() const auth = await useAuth()

View File

@@ -51,14 +51,14 @@
</NewModal> </NewModal>
<div class="flex flex-row items-center gap-2 rounded-lg"> <div class="flex flex-row items-center gap-2 rounded-lg">
<ButtonStyled v-if="isInstalling" type="standard" color="brand"> <ButtonStyled v-if="isInstalling" type="standard" color="brand" size="large">
<button disabled class="flex-shrink-0"> <button disabled class="flex-shrink-0">
<PanelSpinner class="size-5" /> Installing... <PanelSpinner class="size-5" /> Installing...
</button> </button>
</ButtonStyled> </ButtonStyled>
<template v-else> <template v-else>
<ButtonStyled v-if="showStopButton" type="transparent"> <ButtonStyled v-if="showStopButton" type="transparent" size="large">
<button :disabled="!canTakeAction" @click="initiateAction('Stop')"> <button :disabled="!canTakeAction" @click="initiateAction('Stop')">
<div class="flex gap-1"> <div class="flex gap-1">
<StopCircleIcon class="h-5 w-5" /> <StopCircleIcon class="h-5 w-5" />
@@ -67,7 +67,7 @@
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled type="standard" color="brand"> <ButtonStyled type="standard" color="brand" size="large">
<button v-tooltip="busyReason" :disabled="!canTakeAction" @click="handlePrimaryAction"> <button v-tooltip="busyReason" :disabled="!canTakeAction" @click="handlePrimaryAction">
<div v-if="isTransitionState" class="grid place-content-center"> <div v-if="isTransitionState" class="grid place-content-center">
<LoadingIcon /> <LoadingIcon />
@@ -77,7 +77,7 @@
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled circular type="transparent"> <ButtonStyled circular type="transparent" size="large">
<TeleportOverflowMenu :options="[...menuOptions]"> <TeleportOverflowMenu :options="[...menuOptions]">
<MoreVerticalIcon aria-hidden="true" /> <MoreVerticalIcon aria-hidden="true" />
<template #kill> <template #kill>

View File

@@ -1076,6 +1076,7 @@ import {
injectModrinthClient, injectModrinthClient,
injectNotificationManager, injectNotificationManager,
IntlFormatted, IntlFormatted,
NavTabs,
NewModal, NewModal,
OpenInAppModal, OpenInAppModal,
OverflowMenu, OverflowMenu,
@@ -1115,7 +1116,6 @@ import AutomaticAccordion from '~/components/ui/AutomaticAccordion.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue' import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
import MessageBanner from '~/components/ui/MessageBanner.vue' import MessageBanner from '~/components/ui/MessageBanner.vue'
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue' import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue'
import NavTabs from '~/components/ui/NavTabs.vue'
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue' import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
import { saveFeatureFlags } from '~/composables/featureFlags.ts' import { saveFeatureFlags } from '~/composables/featureFlags.ts'
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project' import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'

View File

@@ -1,5 +1,6 @@
<template> <template>
<div> <div>
<ConfirmLeaveModal ref="confirmLeaveModal" />
<div class="universal-card"> <div class="universal-card">
<div class="markdown-disclaimer"> <div class="markdown-disclaimer">
<h2>Description</h2> <h2>Description</h2>
@@ -41,9 +42,11 @@
import { TriangleAlertIcon } from '@modrinth/assets' import { TriangleAlertIcon } from '@modrinth/assets'
import { countText, MIN_DESCRIPTION_CHARS } from '@modrinth/moderation' import { countText, MIN_DESCRIPTION_CHARS } from '@modrinth/moderation'
import { import {
ConfirmLeaveModal,
injectProjectPageContext, injectProjectPageContext,
MarkdownEditor, MarkdownEditor,
UnsavedChangesPopup, UnsavedChangesPopup,
usePageLeaveSafety,
useSavable, useSavable,
} from '@modrinth/ui' } from '@modrinth/ui'
import { TeamMemberPermission } from '@modrinth/utils' import { TeamMemberPermission } from '@modrinth/utils'
@@ -53,13 +56,15 @@ import { useImageUpload } from '~/composables/image-upload.ts'
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext() const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
const { saved, current, saving, reset, save } = useSavable( const { saved, current, saving, hasChanges, reset, save } = useSavable(
() => ({ description: project.value.body }), () => ({ description: project.value.body }),
async ({ description }) => { async ({ description }) => {
await patchProject({ body: description }) await patchProject({ body: description })
}, },
) )
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
const descriptionWarning = computed(() => { const descriptionWarning = computed(() => {
const text = current.value.description?.trim() || '' const text = current.value.description?.trim() || ''
const charCount = countText(text) const charCount = countText(text)

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
ConfirmLeaveModal,
defineMessages, defineMessages,
IconSelect, IconSelect,
injectProjectPageContext, injectProjectPageContext,
@@ -7,6 +8,7 @@ import {
SettingsLabel, SettingsLabel,
StyledInput, StyledInput,
UnsavedChangesPopup, UnsavedChangesPopup,
usePageLeaveSafety,
useSavable, useSavable,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
@@ -15,7 +17,7 @@ const { formatMessage } = useVIntl()
const { projectV2: project, patchProject } = injectProjectPageContext() const { projectV2: project, patchProject } = injectProjectPageContext()
const { saved, current, saving, reset, save } = useSavable( const { saved, current, saving, hasChanges, reset, save } = useSavable(
() => ({ () => ({
title: project.value.title, title: project.value.title,
tagline: project.value.description, tagline: project.value.description,
@@ -31,6 +33,8 @@ const { saved, current, saving, reset, save } = useSavable(
}, },
) )
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
const messages = defineMessages({ const messages = defineMessages({
nameTitle: { nameTitle: {
id: 'project.settings.general.name.title', id: 'project.settings.general.name.title',
@@ -117,6 +121,7 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
</script> </script>
<template> <template>
<div> <div>
<ConfirmLeaveModal ref="confirmLeaveModal" />
<UnsavedChangesPopup <UnsavedChangesPopup
:original="saved" :original="saved"
:modified="current" :modified="current"

View File

@@ -302,6 +302,7 @@
@reset="resetChanges" @reset="resetChanges"
@save="handleSave" @save="handleSave"
/> />
<ConfirmLeaveModal ref="confirmLeaveModal" />
</div> </div>
</template> </template>
@@ -319,12 +320,14 @@ import { MIN_SUMMARY_CHARS } from '@modrinth/moderation'
import { import {
Avatar, Avatar,
Combobox, Combobox,
ConfirmLeaveModal,
ConfirmModal, ConfirmModal,
injectModrinthClient, injectModrinthClient,
injectNotificationManager, injectNotificationManager,
injectProjectPageContext, injectProjectPageContext,
StyledInput, StyledInput,
UnsavedChangesPopup, UnsavedChangesPopup,
usePageLeaveSafety,
} from '@modrinth/ui' } from '@modrinth/ui'
import { fileIsValid, formatProjectStatus, formatProjectType } from '@modrinth/utils' import { fileIsValid, formatProjectStatus, formatProjectType } from '@modrinth/utils'
@@ -480,6 +483,12 @@ const modified = computed(() => ({
deletedBanner: deletedBanner.value, deletedBanner: deletedBanner.value,
})) }))
const hasChanges = computed(() =>
Object.keys(modified.value).some((key) => original.value[key] !== modified.value[key]),
)
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
function resetChanges() { function resetChanges() {
name.value = project.value.title name.value = project.value.title
slug.value = project.value.slug slug.value = project.value.slug

View File

@@ -1,5 +1,6 @@
<template> <template>
<div> <div>
<ConfirmLeaveModal ref="confirmLeaveModal" />
<section class="universal-card"> <section class="universal-card">
<h2 class="label__title size-card-header">License</h2> <h2 class="label__title size-card-header">License</h2>
<p class="label__description"> <p class="label__description">
@@ -154,10 +155,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
Checkbox, Checkbox,
ConfirmLeaveModal,
DropdownSelect, DropdownSelect,
injectProjectPageContext, injectProjectPageContext,
StyledInput, StyledInput,
UnsavedChangesPopup, UnsavedChangesPopup,
usePageLeaveSafety,
useSavable, useSavable,
} from '@modrinth/ui' } from '@modrinth/ui'
import { import {
@@ -194,7 +197,7 @@ function getInitialLicense() {
) )
} }
const { saved, current, saving, reset, save } = useSavable( const { saved, current, saving, hasChanges, reset, save } = useSavable(
() => ({ () => ({
license: getInitialLicense(), license: getInitialLicense(),
licenseUrl: project.value.license.url ?? '', licenseUrl: project.value.license.url ?? '',
@@ -219,6 +222,8 @@ const { saved, current, saving, reset, save } = useSavable(
}, },
) )
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
const hasPermission = computed(() => { const hasPermission = computed(() => {
return (currentMember.value?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS return (currentMember.value?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS
}) })

View File

@@ -1,5 +1,6 @@
<template> <template>
<div> <div>
<ConfirmLeaveModal ref="confirmLeaveModal" />
<section class="universal-card"> <section class="universal-card">
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="text-2xl font-semibold text-contrast">Server details</div> <div class="text-2xl font-semibold text-contrast">Server details</div>
@@ -161,6 +162,7 @@ import { InfoIcon, RefreshCwIcon, SpinnerIcon } from '@modrinth/assets'
import { import {
ButtonStyled, ButtonStyled,
Combobox, Combobox,
ConfirmLeaveModal,
injectModrinthClient, injectModrinthClient,
injectNotificationManager, injectNotificationManager,
injectProjectPageContext, injectProjectPageContext,
@@ -169,6 +171,7 @@ import {
SERVER_REGIONS, SERVER_REGIONS,
StyledInput, StyledInput,
UnsavedChangesPopup, UnsavedChangesPopup,
usePageLeaveSafety,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
@@ -364,6 +367,19 @@ const modified = computed(() => ({
languages: languages.value, languages: languages.value,
})) }))
const hasChanges = computed(() =>
Object.keys(original.value).some((key) => {
const a = original.value[key]
const b = modified.value[key]
if (Array.isArray(a) && Array.isArray(b)) {
return a.length !== b.length || a.some((v, i) => v !== b[i])
}
return a !== b
}),
)
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
function resetChanges() { function resetChanges() {
javaAddress.value = projectV3.value?.minecraft_java_server?.address ?? '' javaAddress.value = projectV3.value?.minecraft_java_server?.address ?? ''
bedrockAddress.value = projectV3.value?.minecraft_bedrock_server?.address ?? '' bedrockAddress.value = projectV3.value?.minecraft_bedrock_server?.address ?? ''

View File

@@ -1,5 +1,6 @@
<template> <template>
<div> <div>
<ConfirmLeaveModal ref="confirmLeaveModal" />
<section class="universal-card"> <section class="universal-card">
<div class="label"> <div class="label">
<h3> <h3>
@@ -143,11 +144,13 @@ import {
} from '@modrinth/assets' } from '@modrinth/assets'
import { import {
Checkbox, Checkbox,
ConfirmLeaveModal,
formatCategory, formatCategory,
formatCategoryHeader, formatCategoryHeader,
FormattedTag, FormattedTag,
injectProjectPageContext, injectProjectPageContext,
UnsavedChangesPopup, UnsavedChangesPopup,
usePageLeaveSafety,
useSavable, useSavable,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
@@ -187,7 +190,7 @@ const matchesProjectType = (x: Category) => {
} }
} }
const { saved, current, saving, reset, save } = useSavable( const { saved, current, saving, hasChanges, reset, save } = useSavable(
() => ({ () => ({
selectedTags: sortedCategories(tags.value, formatCategoryName, locale.value).filter( selectedTags: sortedCategories(tags.value, formatCategoryName, locale.value).filter(
(x: Category) => (x: Category) =>
@@ -237,6 +240,8 @@ const { saved, current, saving, reset, save } = useSavable(
}, },
) )
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
const categoryLists = computed(() => { const categoryLists = computed(() => {
const lists: Record<string, Category[]> = {} const lists: Record<string, Category[]> = {}
sortedCategories(tags.value, formatCategoryName, locale.value).forEach((x: Category) => { sortedCategories(tags.value, formatCategoryName, locale.value).forEach((x: Category) => {

View File

@@ -397,6 +397,7 @@ import {
injectModrinthClient, injectModrinthClient,
injectNotificationManager, injectNotificationManager,
IntlFormatted, IntlFormatted,
NavTabs,
NewModal, NewModal,
normalizeChildren, normalizeChildren,
NormalPage, NormalPage,
@@ -417,7 +418,6 @@ import { useQuery } from '@tanstack/vue-query'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue' import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import NavTabs from '~/components/ui/NavTabs.vue'
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const api = injectModrinthClient() const api = injectModrinthClient()

View File

@@ -1,7 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { commonProjectTypeCategoryMessages, useVIntl } from '@modrinth/ui' import { commonProjectTypeCategoryMessages, NavTabs, useVIntl } from '@modrinth/ui'
import NavTabs from '~/components/ui/NavTabs.vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()

View File

@@ -103,35 +103,76 @@
: `linear-gradient(180deg, rgba(153,153,153,1) 0%, rgba(87,87,87,1) 100%)`, : `linear-gradient(180deg, rgba(153,153,153,1) 0%, rgba(87,87,87,1) 100%)`,
}" }"
> >
<div class="border-0 border-b border-solid border-divider pb-4">
<NuxtLink to="/hosting/manage" class="breadcrumb goto-link flex w-fit items-center"> <NuxtLink to="/hosting/manage" class="breadcrumb goto-link flex w-fit items-center">
<LeftArrowIcon /> <LeftArrowIcon />
All servers All servers
</NuxtLink> </NuxtLink>
<div class="flex w-full min-w-0 select-none flex-col items-center gap-4 pt-4 sm:flex-row"> <ContentPageHeader>
<template #icon>
<ServerIcon <ServerIcon
:image=" :image="
serverData.is_medal ? 'https://cdn-raw.modrinth.com/medal_icon.webp' : serverImage serverData.is_medal ? 'https://cdn-raw.modrinth.com/medal_icon.webp' : serverImage
" "
class="drop-shadow-lg sm:drop-shadow-none"
/> />
<div </template>
class="flex min-w-0 flex-1 flex-col-reverse items-center gap-2 sm:flex-col sm:items-start" <template #title>
>
<div class="flex w-full flex-col items-center gap-4 sm:flex-row">
<h1
class="m-0 w-screen flex-shrink gap-3 truncate px-3 text-center text-2xl font-bold text-contrast sm:w-full sm:p-0 sm:text-left"
>
{{ serverData.name }} {{ serverData.name }}
</h1> </template>
<template #stats>
<div <div
v-if="isConnected" v-if="serverData.flows?.intro"
data-pyro-server-action-buttons class="flex items-center gap-2 font-semibold text-secondary"
class="server-action-buttons-anim flex w-fit flex-shrink-0"
> >
<SettingsIcon /> Configuring server...
</div>
<div v-else class="flex flex-wrap items-center gap-2">
<div v-if="serverData.loader" class="flex items-center gap-2 font-medium capitalize">
<LoaderIcon :loader="serverData.loader" class="flex shrink-0 [&&]:size-5" />
{{ serverData.loader }} {{ serverData.mc_version }}
</div>
<div
v-if="serverData.loader && serverData.net?.domain"
class="h-1.5 w-1.5 rounded-full bg-surface-5"
></div>
<div
v-if="serverData.net?.domain"
v-tooltip="'Copy server address'"
class="flex cursor-pointer items-center gap-2 font-medium hover:underline"
@click="copyServerAddress"
>
<LinkIcon class="flex size-5 shrink-0" />
{{ serverData.net.domain }}.modrinth.gg
</div>
<div v-if="uptimeSeconds" class="h-1.5 w-1.5 rounded-full bg-surface-5"></div>
<div v-if="uptimeSeconds" class="flex items-center gap-2 font-medium">
<TimerIcon class="flex size-5 shrink-0" />
{{ formattedUptime }}
</div>
<div
v-if="serverProject && (serverData.loader || serverData.net?.domain || uptimeSeconds)"
class="h-1.5 w-1.5 rounded-full bg-surface-5"
></div>
<div v-if="serverProject" class="flex items-center gap-1.5 font-medium text-primary">
Linked to
<Avatar :src="serverProject.icon_url" :alt="serverProject.title" size="24px" />
<NuxtLink
:to="`/project/${serverProject.slug ?? serverProject.id}`"
class="truncate text-primary hover:underline"
>
{{ serverProject.title }}
</NuxtLink>
</div>
</div>
</template>
<template #actions>
<div v-if="isConnected && !serverData.flows?.intro" class="flex gap-2">
<PanelServerActionButton <PanelServerActionButton
v-if="!serverData.flows?.intro"
class="flex-shrink-0"
:is-online="isServerRunning" :is-online="isServerRunning"
:is-actioning="isActioning" :is-actioning="isActioning"
:is-installing="serverData.status === 'installing'" :is-installing="serverData.status === 'installing'"
@@ -139,32 +180,12 @@
:server-name="serverData.name" :server-name="serverData.name"
:server-data="serverData" :server-data="serverData"
:uptime-seconds="uptimeSeconds" :uptime-seconds="uptimeSeconds"
:busy-reason=" :busy-reason="busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined"
busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined
"
@action="sendPowerAction" @action="sendPowerAction"
/> />
</div> </div>
</div> </template>
</ContentPageHeader>
<div
v-if="serverData.flows?.intro"
class="flex items-center gap-2 font-semibold text-secondary"
>
<SettingsIcon /> Configuring server...
</div>
<ServerInfoLabels
v-else
:server-data="serverData"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:uptime-seconds="uptimeSeconds"
:linked="true"
class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
/>
</div>
</div>
</div>
<ServerOnboardingPanelPage v-if="serverData.flows?.intro" /> <ServerOnboardingPanelPage v-if="serverData.flows?.intro" />
@@ -351,24 +372,29 @@ import {
IssuesIcon, IssuesIcon,
LayoutTemplateIcon, LayoutTemplateIcon,
LeftArrowIcon, LeftArrowIcon,
LinkIcon,
LockIcon, LockIcon,
RightArrowIcon, RightArrowIcon,
SettingsIcon, SettingsIcon,
TimerIcon,
TransferIcon, TransferIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import type { BusyReason } from '@modrinth/ui' import type { BusyReason } from '@modrinth/ui'
import { import {
Avatar,
BackupProgressAdmonitions, BackupProgressAdmonitions,
ButtonStyled, ButtonStyled,
ContentPageHeader,
defineMessage, defineMessage,
ErrorInformationCard, ErrorInformationCard,
formatLoaderLabel, formatLoaderLabel,
injectModrinthClient, injectModrinthClient,
injectNotificationManager, injectNotificationManager,
InstallingBanner, InstallingBanner,
LoaderIcon,
NavTabs,
provideModrinthServerContext, provideModrinthServerContext,
ServerIcon, ServerIcon,
ServerInfoLabels,
ServerNotice, ServerNotice,
ServerOnboardingPanelPage, ServerOnboardingPanelPage,
useDebugLogger, useDebugLogger,
@@ -381,7 +407,6 @@ import DOMPurify from 'dompurify'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { reloadNuxtApp } from '#app' import { reloadNuxtApp } from '#app'
import NavTabs from '~/components/ui/NavTabs.vue'
import PanelErrorIcon from '~/components/ui/servers/icons/PanelErrorIcon.vue' import PanelErrorIcon from '~/components/ui/servers/icons/PanelErrorIcon.vue'
import MedalServerCountdown from '~/components/ui/servers/marketing/MedalServerCountdown.vue' import MedalServerCountdown from '~/components/ui/servers/marketing/MedalServerCountdown.vue'
import PanelServerActionButton from '~/components/ui/servers/PanelServerActionButton.vue' import PanelServerActionButton from '~/components/ui/servers/PanelServerActionButton.vue'
@@ -589,6 +614,30 @@ provideModrinthServerContext({
}) })
const uptimeSeconds = ref(0) const uptimeSeconds = ref(0)
const formattedUptime = computed(() => {
const days = Math.floor(uptimeSeconds.value / (24 * 3600))
const hours = Math.floor((uptimeSeconds.value % (24 * 3600)) / 3600)
const minutes = Math.floor((uptimeSeconds.value % 3600) / 60)
const seconds = uptimeSeconds.value % 60
let formatted = ''
if (days > 0) formatted += `${days}d `
if (hours > 0 || days > 0) formatted += `${hours}h `
formatted += `${minutes}m ${seconds}s`
return formatted.trim()
})
function copyServerAddress() {
if (!serverData.value?.net?.domain) return
navigator.clipboard.writeText(serverData.value.net.domain + '.modrinth.gg')
addNotification({
title: 'Server address copied',
text: "Your server's address has been copied to your clipboard.",
type: 'success',
})
}
const copied = ref(false) const copied = ref(false)
const error = ref<Error | null>(null) const error = ref<Error | null>(null)
@@ -628,9 +677,6 @@ const stats = ref<Stats>({
}, },
}) })
const showGameLabel = computed(() => !!serverData.value?.game)
const showLoaderLabel = computed(() => !!serverData.value?.loader)
const navLinks = [ const navLinks = [
{ {
label: 'Overview', label: 'Overview',

View File

@@ -18,9 +18,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { FolderIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets' import { FolderIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
import { Chips, defineMessages, useVIntl } from '@modrinth/ui' import { Chips, defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
import NavTabs from '@/components/ui/NavTabs.vue'
definePageMeta({ definePageMeta({
middleware: 'auth', middleware: 'auth',

View File

@@ -1,10 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { getChangelog, type Product } from '@modrinth/blog' import { getChangelog, type Product } from '@modrinth/blog'
import { ChangelogEntry } from '@modrinth/ui' import { ChangelogEntry, NavTabs } from '@modrinth/ui'
import Timeline from '@modrinth/ui/src/components/base/Timeline.vue' import Timeline from '@modrinth/ui/src/components/base/Timeline.vue'
import NavTabs from '~/components/ui/NavTabs.vue'
const route = useRoute() const route = useRoute()
const filter = ref<Product | undefined>(undefined) const filter = ref<Product | undefined>(undefined)

View File

@@ -299,6 +299,7 @@ import {
commonMessages, commonMessages,
ContentPageHeader, ContentPageHeader,
injectModrinthClient, injectModrinthClient,
NavTabs,
OverflowMenu, OverflowMenu,
ProjectCard, ProjectCard,
ProjectCardList, ProjectCardList,
@@ -313,7 +314,6 @@ import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue' import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue' import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
import NavStack from '~/components/ui/NavStack.vue' import NavStack from '~/components/ui/NavStack.vue'
import NavTabs from '~/components/ui/NavTabs.vue'
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js' import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
import { import {
OrganizationContext, OrganizationContext,

View File

@@ -495,6 +495,7 @@ import {
injectModrinthClient, injectModrinthClient,
injectNotificationManager, injectNotificationManager,
IntlFormatted, IntlFormatted,
NavTabs,
NewModal, NewModal,
OverflowMenu, OverflowMenu,
ProjectCard, ProjectCard,
@@ -521,7 +522,6 @@ import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue' import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue' import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue' import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
import NavTabs from '~/components/ui/NavTabs.vue'
import { reportUser } from '~/utils/report-helpers.ts' import { reportUser } from '~/utils/report-helpers.ts'
const data = useNuxtApp() const data = useNuxtApp()

View File

@@ -11,4 +11,10 @@ export type { ClientConfig, RequestHooks } from './client'
export type { ApiErrorData, ModrinthErrorResponse } from './errors' export type { ApiErrorData, ModrinthErrorResponse } from './errors'
export { isModrinthErrorResponse } from './errors' export { isModrinthErrorResponse } from './errors'
export type { HttpMethod, RequestContext, RequestOptions, ResponseData } from './request' export type { HttpMethod, RequestContext, RequestOptions, ResponseData } from './request'
export type { UploadHandle, UploadMetadata, UploadProgress, UploadRequestOptions } from './upload' export type {
UploadHandle,
UploadMetadata,
UploadProgress,
UploadRequestOptions,
UploadState,
} from './upload'

View File

@@ -86,3 +86,16 @@ export interface UploadHandle<T> {
/** Cancel the upload */ /** Cancel the upload */
cancel: () => void cancel: () => void
} }
/**
* State of a batch file upload operation
*/
export interface UploadState {
isUploading: boolean
currentFileName: string | null
currentFileProgress: number
uploadedBytes: number
totalBytes: number
completedFiles: number
totalFiles: number
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT world_type, world_id, display_status\n FROM attached_world_data\n WHERE profile_path = $1\n ", "query": "\n SELECT world_type, world_id, display_status, project_id, content_kind\n FROM attached_world_data\n WHERE profile_path = $1\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -17,6 +17,16 @@
"name": "display_status", "name": "display_status",
"ordinal": 2, "ordinal": 2,
"type_info": "Text" "type_info": "Text"
},
{
"name": "project_id",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "content_kind",
"ordinal": 4,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -25,8 +35,10 @@
"nullable": [ "nullable": [
false, false,
false, false,
false false,
true,
true
] ]
}, },
"hash": "fd834e256e142820f25305ccffaf07f736c5772045b973dcc10573b399111344" "hash": "4735f82db7b281e1380ea7c08ed715d25e0ca23a6c190c4bf14332033f4583db"
} }

View File

@@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT data as \"data?: sqlx::types::Json<CacheValue>\"\n FROM cache\n WHERE data_type = $1 AND id = $2\n ",
"describe": {
"columns": [
{
"name": "data?: sqlx::types::Json<CacheValue>",
"ordinal": 0,
"type_info": "Null"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true
]
},
"hash": "4d66e2bfedb7a31244d24858acf9b73a6289c1a90fb0988c4f5f5f64be01c4ec"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO attached_world_data (profile_path, world_type, world_id, project_id)\nVALUES ($1, $2, $3, $4)\nON CONFLICT (profile_path, world_type, world_id) DO UPDATE\n SET project_id = $4",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "53c45c036387a8dc8d978a6e4d28524a852b8dec409891cf2165876fb7ff0314"
}

View File

@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "\n SELECT display_status, project_id, content_kind\n FROM attached_world_data\n WHERE profile_path = $1 and world_type = $2 and world_id = $3\n ",
"describe": {
"columns": [
{
"name": "display_status",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "project_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "content_kind",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
true,
true
]
},
"hash": "613192379e1fb8fd1becf2f6330365bb5bc2a8f0be01f6e4eef708474f38a3d0"
}

View File

@@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT display_status\n FROM attached_world_data\n WHERE profile_path = $1 and world_type = $2 and world_id = $3\n ",
"describe": {
"columns": [
{
"name": "display_status",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false
]
},
"hash": "a2184fc5d62570aec0a15c0a8d628a597e90c2bf7ce5dc1b39edb6977e2f6da6"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO attached_world_data (profile_path, world_type, world_id, content_kind)\nVALUES ($1, $2, $3, $4)\nON CONFLICT (profile_path, world_type, world_id) DO UPDATE\n SET content_kind = $4",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "dcf7340800c1d6ca82de2092b477a41a9622ce891732300f029f34545954128e"
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE attached_world_data ADD COLUMN project_id TEXT;
ALTER TABLE attached_world_data ADD COLUMN content_kind TEXT;

View File

@@ -68,10 +68,23 @@ pub async fn get_importable_instances(
.await .await
.unwrap_or_else(|| "instances".to_string()), .unwrap_or_else(|| "instances".to_string()),
ImportLauncherType::Unknown => { ImportLauncherType::Unknown => {
return Err(crate::ErrorKind::InputError( let types = [
"Launcher type Unknown".to_string(), ImportLauncherType::MultiMC,
) ImportLauncherType::PrismLauncher,
.into()); ImportLauncherType::ATLauncher,
ImportLauncherType::GDLauncher,
ImportLauncherType::Curseforge,
];
for lt in types {
if let Ok(instances) =
Box::pin(get_importable_instances(lt, base_path.clone()))
.await
&& !instances.is_empty()
{
return Ok(instances);
}
}
return Ok(Vec::new());
} }
}; };
@@ -144,11 +157,40 @@ pub async fn import_instance(
.await .await
} }
ImportLauncherType::Unknown => { ImportLauncherType::Unknown => {
let types = [
ImportLauncherType::MultiMC,
ImportLauncherType::PrismLauncher,
ImportLauncherType::ATLauncher,
ImportLauncherType::GDLauncher,
ImportLauncherType::Curseforge,
];
let mut matched = false;
for lt in types {
if let Ok(instances) =
Box::pin(get_importable_instances(lt, base_path.clone()))
.await
&& instances.contains(&instance_folder)
{
matched = true;
Box::pin(import_instance(
profile_path,
lt,
base_path,
instance_folder,
))
.await?;
break;
}
}
if !matched {
return Err(crate::ErrorKind::InputError( return Err(crate::ErrorKind::InputError(
"Launcher type Unknown".to_string(), "Could not determine launcher type for the given path"
.to_string(),
) )
.into()); .into());
} }
return Ok(());
}
}; };
// If import failed, delete the profile // If import failed, delete the profile

View File

@@ -149,12 +149,32 @@ pub async fn install_zipped_mrpack_files(
file_hashes.push(hash); file_hashes.push(hash);
} }
let project_ids: Vec<String> = pack
.files
.iter()
.filter_map(|f| {
f.downloads.iter().find_map(|url| {
let parts: Vec<&str> = url.split('/').collect();
let data_idx = parts.iter().position(|&p| p == "data")?;
parts.get(data_idx + 1).map(|s| s.to_string())
})
})
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
tracing::info!( tracing::info!(
"Caching {} modpack file hashes for version {}", "Caching {} modpack file hashes and {} project IDs for version {}",
file_hashes.len(), file_hashes.len(),
project_ids.len(),
version_id version_id
); );
CachedEntry::cache_modpack_files(version_id, file_hashes, &state.pool) CachedEntry::cache_modpack_files(
version_id,
file_hashes,
project_ids,
&state.pool,
)
.await?; .await?;
} else { } else {
tracing::warn!( tracing::warn!(

View File

@@ -142,6 +142,10 @@ pub enum WorldDetails {
index: usize, index: usize,
address: String, address: String,
pack_status: ServerPackStatus, pack_status: ServerPackStatus,
#[serde(skip_serializing_if = "Option::is_none")]
project_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content_kind: Option<String>,
}, },
} }
@@ -426,6 +430,8 @@ async fn get_server_worlds_in_profile(
index, index,
address: server.ip, address: server.ip,
pack_status: server.accept_textures.into(), pack_status: server.accept_textures.into(),
project_id: None,
content_kind: None,
}, },
}; };
worlds.push(world); worlds.push(world);
@@ -460,6 +466,15 @@ async fn get_server_worlds_in_profile(
fn attach_world_data_to_world(world: &mut World, data: &AttachedWorldData) { fn attach_world_data_to_world(world: &mut World, data: &AttachedWorldData) {
world.display_status = data.display_status; world.display_status = data.display_status;
if let WorldDetails::Server {
project_id,
content_kind,
..
} = &mut world.details
{
*project_id = data.project_id.clone();
*content_kind = data.content_kind.clone();
}
} }
pub async fn set_world_display_status( pub async fn set_world_display_status(
@@ -712,9 +727,12 @@ async fn try_get_world_session_lock(
pub async fn add_server_to_profile( pub async fn add_server_to_profile(
profile_path: &Path, profile_path: &Path,
profile_path_id: &str,
name: String, name: String,
address: String, address: String,
pack_status: ServerPackStatus, pack_status: ServerPackStatus,
project_id: Option<String>,
content_kind: Option<String>,
) -> Result<usize> { ) -> Result<usize> {
let mut servers = servers_data::read(profile_path).await?; let mut servers = servers_data::read(profile_path).await?;
let insert_index = servers let insert_index = servers
@@ -725,13 +743,38 @@ pub async fn add_server_to_profile(
insert_index, insert_index,
servers_data::ServerData { servers_data::ServerData {
name, name,
ip: address, ip: address.clone(),
accept_textures: pack_status.into(), accept_textures: pack_status.into(),
hidden: false, hidden: false,
icon: None, icon: None,
}, },
); );
servers_data::write(profile_path, &servers).await?; servers_data::write(profile_path, &servers).await?;
if project_id.is_some() || content_kind.is_some() {
let state = State::get().await?;
if let Some(project_id) = &project_id {
attached_world_data::set_project_id(
profile_path_id,
WorldType::Server,
&address,
project_id,
&state.pool,
)
.await?;
}
if let Some(content_kind) = &content_kind {
attached_world_data::set_content_kind(
profile_path_id,
WorldType::Server,
&address,
content_kind,
&state.pool,
)
.await?;
}
}
Ok(insert_index) Ok(insert_index)
} }

View File

@@ -5,6 +5,8 @@ use std::collections::HashMap;
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct AttachedWorldData { pub struct AttachedWorldData {
pub display_status: DisplayStatus, pub display_status: DisplayStatus,
pub project_id: Option<String>,
pub content_kind: Option<String>,
} }
impl AttachedWorldData { impl AttachedWorldData {
@@ -18,7 +20,7 @@ impl AttachedWorldData {
let attached_data = sqlx::query!( let attached_data = sqlx::query!(
" "
SELECT display_status SELECT display_status, project_id, content_kind
FROM attached_world_data FROM attached_world_data
WHERE profile_path = $1 and world_type = $2 and world_id = $3 WHERE profile_path = $1 and world_type = $2 and world_id = $3
", ",
@@ -31,6 +33,8 @@ impl AttachedWorldData {
Ok(attached_data.map(|x| AttachedWorldData { Ok(attached_data.map(|x| AttachedWorldData {
display_status: DisplayStatus::from_string(&x.display_status), display_status: DisplayStatus::from_string(&x.display_status),
project_id: x.project_id,
content_kind: x.content_kind,
})) }))
} }
@@ -40,7 +44,7 @@ impl AttachedWorldData {
) -> crate::Result<HashMap<(WorldType, String), Self>> { ) -> crate::Result<HashMap<(WorldType, String), Self>> {
let attached_data = sqlx::query!( let attached_data = sqlx::query!(
" "
SELECT world_type, world_id, display_status SELECT world_type, world_id, display_status, project_id, content_kind
FROM attached_world_data FROM attached_world_data
WHERE profile_path = $1 WHERE profile_path = $1
", ",
@@ -57,7 +61,11 @@ impl AttachedWorldData {
DisplayStatus::from_string(&x.display_status); DisplayStatus::from_string(&x.display_status);
( (
(world_type, x.world_id), (world_type, x.world_id),
AttachedWorldData { display_status }, AttachedWorldData {
display_status,
project_id: x.project_id,
content_kind: x.content_kind,
},
) )
}) })
.collect()) .collect())
@@ -120,3 +128,5 @@ macro_rules! attached_data_setter {
} }
attached_data_setter!(display_status: DisplayStatus, "display_status" => display_status.as_str()); attached_data_setter!(display_status: DisplayStatus, "display_status" => display_status.as_str());
attached_data_setter!(project_id: &str, "project_id");
attached_data_setter!(content_kind: &str, "content_kind");

View File

@@ -148,6 +148,8 @@ impl CacheValueType {
pub struct CachedModpackFiles { pub struct CachedModpackFiles {
pub version_id: String, pub version_id: String,
pub file_hashes: Vec<String>, pub file_hashes: Vec<String>,
#[serde(default)]
pub project_ids: Vec<String>,
} }
/// Cached list of versions for a project (without changelogs for fast loading) /// Cached list of versions for a project (without changelogs for fast loading)
@@ -1831,11 +1833,13 @@ impl CachedEntry {
pub async fn cache_modpack_files( pub async fn cache_modpack_files(
version_id: &str, version_id: &str,
file_hashes: Vec<String>, file_hashes: Vec<String>,
project_ids: Vec<String>,
pool: &SqlitePool, pool: &SqlitePool,
) -> crate::Result<()> { ) -> crate::Result<()> {
let data = CachedModpackFiles { let data = CachedModpackFiles {
version_id: version_id.to_string(), version_id: version_id.to_string(),
file_hashes, file_hashes,
project_ids,
}; };
let entry = CachedEntry { let entry = CachedEntry {

View File

@@ -308,49 +308,52 @@ pub async fn get_content_items(
.get_projects(cache_behaviour, pool, fetch_semaphore) .get_projects(cache_behaviour, pool, fetch_semaphore)
.await?; .await?;
let modpack_hashes: HashSet<String> = if let Some(ref linked_data) = let modpack_ids = if let Some(ref linked_data) = profile.linked_data {
profile.linked_data
{
if linked_data.version_id.is_empty() { if linked_data.version_id.is_empty() {
HashSet::new() None
} else { } else {
tracing::info!( tracing::info!(
"Fetching modpack file hashes for version_id={}, project_id={}", "Fetching modpack identifiers for version_id={}, project_id={}",
linked_data.version_id, linked_data.version_id,
linked_data.project_id linked_data.project_id
); );
match get_modpack_file_hashes( match get_modpack_identifiers(
&linked_data.version_id, &linked_data.version_id,
pool, pool,
fetch_semaphore, fetch_semaphore,
) )
.await .await
{ {
Ok(hashes) => { Ok(ids) => {
tracing::info!( tracing::info!(
"Got {} modpack file hashes for version {}", "Got {} modpack file hashes, {} project IDs for version {}",
hashes.len(), ids.hashes.len(),
ids.project_ids.len(),
linked_data.version_id linked_data.version_id
); );
hashes Some(ids)
} }
Err(e) => { Err(e) => {
tracing::error!( tracing::error!(
"Failed to fetch modpack file hashes for version {}: {}", "Failed to fetch modpack identifiers for version {}: {}",
linked_data.version_id, linked_data.version_id,
e e
); );
HashSet::new() None
} }
} }
} }
} else { } else {
HashSet::new() None
}; };
let user_files: Vec<(String, ProfileFile)> = all_files let user_files: Vec<(String, ProfileFile)> = all_files
.into_iter() .into_iter()
.filter(|(_, file)| !modpack_hashes.contains(&file.hash)) .filter(|(_, file)| {
modpack_ids
.as_ref()
.is_none_or(|ids| !ids.is_modpack_file(file))
})
.collect(); .collect();
profile_files_to_content_items( profile_files_to_content_items(
@@ -633,16 +636,16 @@ pub async fn get_linked_modpack_content(
.get_projects(cache_behaviour, pool, fetch_semaphore) .get_projects(cache_behaviour, pool, fetch_semaphore)
.await?; .await?;
let modpack_hashes: HashSet<String> = match get_modpack_file_hashes( let modpack_ids = match get_modpack_identifiers(
&linked_data.version_id, &linked_data.version_id,
pool, pool,
fetch_semaphore, fetch_semaphore,
) )
.await .await
{ {
Ok(hashes) => hashes, Ok(ids) => ids,
Err(e) => { Err(e) => {
tracing::warn!("Failed to fetch modpack file hashes: {}", e); tracing::warn!("Failed to fetch modpack identifiers: {}", e);
return Ok(Vec::new()); return Ok(Vec::new());
} }
}; };
@@ -650,7 +653,7 @@ pub async fn get_linked_modpack_content(
// Inverse of get_content_items: keep only modpack-bundled files // Inverse of get_content_items: keep only modpack-bundled files
let modpack_files: Vec<(String, ProfileFile)> = all_files let modpack_files: Vec<(String, ProfileFile)> = all_files
.into_iter() .into_iter()
.filter(|(_, file)| modpack_hashes.contains(&file.hash)) .filter(|(_, file)| modpack_ids.is_modpack_file(file))
.collect(); .collect();
profile_files_to_content_items( profile_files_to_content_items(
@@ -778,23 +781,78 @@ pub async fn dependencies_to_content_items(
Ok(items) Ok(items)
} }
/// Gets SHA1 hashes of all files in a modpack version. /// Modpack file identifiers: hashes for exact matching and project IDs for
/// matching files whose version was switched by the user.
struct ModpackIdentifiers {
hashes: HashSet<String>,
project_ids: HashSet<String>,
}
impl ModpackIdentifiers {
fn is_modpack_file(&self, file: &ProfileFile) -> bool {
self.hashes.contains(&file.hash)
|| file
.metadata
.as_ref()
.is_some_and(|m| self.project_ids.contains(&m.project_id))
}
}
/// Gets SHA1 hashes and project IDs of all files in a modpack version.
/// Checks cache first, falls back to downloading mrpack if not cached. /// Checks cache first, falls back to downloading mrpack if not cached.
async fn get_modpack_file_hashes( async fn get_modpack_identifiers(
version_id: &str, version_id: &str,
pool: &SqlitePool, pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore, fetch_semaphore: &FetchSemaphore,
) -> crate::Result<HashSet<String>> { ) -> crate::Result<ModpackIdentifiers> {
if let Some(cached) = if let Some(cached) =
CachedEntry::get_modpack_files(version_id, pool, fetch_semaphore) CachedEntry::get_modpack_files(version_id, pool, fetch_semaphore)
.await? .await?
{ {
if !cached.project_ids.is_empty() {
tracing::info!( tracing::info!(
"Cache hit: {} modpack file hashes for version {}", "Cache hit: {} modpack file hashes, {} project IDs for version {}",
cached.file_hashes.len(), cached.file_hashes.len(),
cached.project_ids.len(),
version_id version_id
); );
return Ok(cached.file_hashes.into_iter().collect()); return Ok(ModpackIdentifiers {
hashes: cached.file_hashes.into_iter().collect(),
project_ids: cached.project_ids.into_iter().collect(),
});
}
// Legacy cache entry without project_ids — resolve via hash lookup API
tracing::info!(
"Legacy cache entry without project IDs, resolving via API for version {}",
version_id
);
let hash_refs: Vec<&str> =
cached.file_hashes.iter().map(|s| s.as_str()).collect();
let files =
CachedEntry::get_file_many(&hash_refs, None, pool, fetch_semaphore)
.await?;
let project_ids: Vec<String> = files
.iter()
.map(|f| f.project_id.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect();
// Update cache with project_ids for next time
CachedEntry::cache_modpack_files(
version_id,
cached.file_hashes.clone(),
project_ids.clone(),
pool,
)
.await?;
return Ok(ModpackIdentifiers {
hashes: cached.file_hashes.into_iter().collect(),
project_ids: project_ids.into_iter().collect(),
});
} }
tracing::warn!( tracing::warn!(
@@ -863,6 +921,20 @@ async fn get_modpack_file_hashes(
.filter_map(|f| f.hashes.get(&PackFileHash::Sha1).cloned()) .filter_map(|f| f.hashes.get(&PackFileHash::Sha1).cloned())
.collect(); .collect();
let project_ids: Vec<String> = pack
.files
.iter()
.filter_map(|f| {
f.downloads.iter().find_map(|url| {
let parts: Vec<&str> = url.split('/').collect();
let data_idx = parts.iter().position(|&p| p == "data")?;
parts.get(data_idx + 1).map(|s| s.to_string())
})
})
.collect::<HashSet<_>>()
.into_iter()
.collect();
// Also hash files from overrides folders (these aren't in modrinth.index.json) // Also hash files from overrides folders (these aren't in modrinth.index.json)
let override_entries: Vec<usize> = zip_reader let override_entries: Vec<usize> = zip_reader
.file() .file()
@@ -888,7 +960,16 @@ async fn get_modpack_file_hashes(
hashes.push(hash); hashes.push(hash);
} }
CachedEntry::cache_modpack_files(version_id, hashes.clone(), pool).await?; CachedEntry::cache_modpack_files(
version_id,
hashes.clone(),
project_ids.clone(),
pool,
)
.await?;
Ok(hashes.into_iter().collect()) Ok(ModpackIdentifiers {
hashes: hashes.into_iter().collect(),
project_ids: project_ids.into_iter().collect(),
})
} }

View File

@@ -203,6 +203,7 @@ import _SendIcon from './icons/send.svg?component'
import _ServerIcon from './icons/server.svg?component' import _ServerIcon from './icons/server.svg?component'
import _ServerPlusIcon from './icons/server-plus.svg?component' import _ServerPlusIcon from './icons/server-plus.svg?component'
import _SettingsIcon from './icons/settings.svg?component' import _SettingsIcon from './icons/settings.svg?component'
import _Settings2Icon from './icons/settings-2.svg?component'
import _ShareIcon from './icons/share.svg?component' import _ShareIcon from './icons/share.svg?component'
import _ShieldIcon from './icons/shield.svg?component' import _ShieldIcon from './icons/shield.svg?component'
import _ShieldAlertIcon from './icons/shield-alert.svg?component' import _ShieldAlertIcon from './icons/shield-alert.svg?component'
@@ -589,6 +590,7 @@ export const SendIcon = _SendIcon
export const ServerIcon = _ServerIcon export const ServerIcon = _ServerIcon
export const ServerPlusIcon = _ServerPlusIcon export const ServerPlusIcon = _ServerPlusIcon
export const SettingsIcon = _SettingsIcon export const SettingsIcon = _SettingsIcon
export const Settings2Icon = _Settings2Icon
export const ShareIcon = _ShareIcon export const ShareIcon = _ShareIcon
export const ShieldIcon = _ShieldIcon export const ShieldIcon = _ShieldIcon
export const ShieldAlertIcon = _ShieldAlertIcon export const ShieldAlertIcon = _ShieldAlertIcon

View File

@@ -0,0 +1,18 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-settings-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 17H5" />
<path d="M19 7h-9" />
<circle cx="17" cy="17" r="3" />
<circle cx="7" cy="7" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 406 B

View File

@@ -104,18 +104,11 @@ svg {
} }
} }
input, button,
button { input[type='button'] {
&:disabled { cursor: pointer;
cursor: not-allowed !important; border: none;
} outline: 2px solid transparent;
}
@media (prefers-reduced-motion) {
.button-animation,
button {
transform: none !important;
}
} }
input, input,

View File

@@ -68,6 +68,10 @@ CSS custom properties are defined in `packages/assets/styles/variables.scss` wit
**Color palette** (each with shades 50950): red, orange, green, blue, purple, gray. Platform-specific colors also exist (fabric, forge, quilt, neoforge, etc.). **Color palette** (each with shades 50950): red, orange, green, blue, purple, gray. Platform-specific colors also exist (fabric, forge, quilt, neoforge, etc.).
## Storybook
When modifying a component in `src/components/`, you must also update its corresponding Storybook story in `src/stories/` to reflect the changes. If a story file doesn't exist yet, create one. Stories should cover the component's key states and variants.
## Dependency Injection ## Dependency Injection
This package defines the DI layer using `createContext` from `src/providers/index.ts`. See the `dependency-injection` skill (`.claude/skills/dependency-injection/SKILL.md`) for full documentation. This package defines the DI layer using `createContext` from `src/providers/index.ts`. See the `dependency-injection` skill (`.claude/skills/dependency-injection/SKILL.md`) for full documentation.

View File

@@ -5,25 +5,18 @@
typeClasses[type], typeClasses[type],
]" ]"
> >
<ButtonStyled <div class="flex items-start gap-2">
v-if="dismissible"
circular
type="highlight-colored-text"
:color="buttonColors[type]"
>
<button aria-label="Dismiss" class="absolute top-3 right-3" @click="$emit('dismiss')">
<XIcon class="h-4 w-4" />
</button>
</ButtonStyled>
<div <div
:class="[ :class="[
'flex gap-2 items-start', 'flex flex-1 gap-2',
(header || $slots.header) && 'flex-col', header || $slots.header ? 'flex-col items-start' : 'items-center',
dismissible && 'pr-8', (dismissible || $slots['top-right-actions']) && 'pr-8',
]" ]"
> >
<div class="flex gap-2 items-start" :class="header || $slots.header ? 'w-full' : 'contents'"> <div
class="flex gap-2 items-start"
:class="header || $slots.header ? 'w-full' : 'contents'"
>
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]"> <slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
<component <component
:is="getSeverityIcon(type)" :is="getSeverityIcon(type)"
@@ -34,10 +27,27 @@
<slot name="header">{{ header }}</slot> <slot name="header">{{ header }}</slot>
</div> </div>
</div> </div>
<div class="font-normal text-base" :class="!(header || $slots.header) && 'flex-1'"> <div class="font-normal text-contrast/80" :class="!(header || $slots.header) && 'flex-1'">
<slot>{{ body }}</slot> <slot>{{ body }}</slot>
</div> </div>
</div> </div>
<div v-if="$slots['top-right-actions']" class="flex shrink-0 items-center gap-2">
<slot name="top-right-actions" />
</div>
<ButtonStyled
v-else-if="dismissible"
circular
type="highlight-colored-text"
:color="buttonColors[type]"
>
<button aria-label="Dismiss" class="absolute top-3 right-3" @click="$emit('dismiss')">
<XIcon class="h-4 w-4" />
</button>
</ButtonStyled>
</div>
<div v-if="$slots.progress">
<slot name="progress" />
</div>
<div v-if="showActionsUnderneath || $slots.actions"> <div v-if="showActionsUnderneath || $slots.actions">
<slot name="actions" /> <slot name="actions" />
</div> </div>

View File

@@ -2,11 +2,11 @@
<nav <nav
v-if="filteredLinks.length > 1" v-if="filteredLinks.length > 1"
ref="scrollContainer" ref="scrollContainer"
class="experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold" class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
:class="{ 'card-shadow': mode === 'navigation' }" :class="{ 'shadow-sm': mode === 'navigation' }"
> >
<template v-if="mode === 'navigation'"> <template v-if="mode === 'navigation'">
<NuxtLink <RouterLink
v-for="(link, index) in filteredLinks" v-for="(link, index) in filteredLinks"
v-show="link.shown ?? true" v-show="link.shown ?? true"
:key="link.href" :key="link.href"
@@ -22,7 +22,7 @@
<span class="text-nowrap" :class="getLabelClasses(index)"> <span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }} {{ link.label }}
</span> </span>
</NuxtLink> </RouterLink>
</template> </template>
<template v-else> <template v-else>
@@ -57,9 +57,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Component } from 'vue' import type { Component } from 'vue'
import { computed, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
const route = useNativeRoute() const route = useRoute()
interface Tab { interface Tab {
label: string label: string
@@ -104,8 +105,8 @@ const currentActiveIndex = ref(-1)
const subpageSelected = ref(false) const subpageSelected = ref(false)
// SSR state // SSR state
const sliderReady = ref(false) // Slider is positioned and should be visible const sliderReady = ref(false)
const transitionsEnabled = ref(false) // CSS transitions should apply (after first paint) const transitionsEnabled = ref(false)
// Stagger delays for the trailing edges of the slider animation // Stagger delays for the trailing edges of the slider animation
const sliderDelays = ref({ left: '0ms', top: '0ms', right: '0ms', bottom: '0ms' }) const sliderDelays = ref({ left: '0ms', top: '0ms', right: '0ms', bottom: '0ms' })
@@ -165,8 +166,8 @@ function computeActiveIndex(): { index: number; isSubpage: boolean } {
for (let i = filteredLinks.value.length - 1; i >= 0; i--) { for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i] const link = filteredLinks.value[i]
const decodedPath = decodeURIComponent(route.path) const decodedPath = decodeURIComponent(route.path)
const decodedHref = decodeURIComponent(link.href.split('?')[0])
// Query-based matching
if (props.query) { if (props.query) {
const queryValue = route.query[props.query] const queryValue = route.query[props.query]
if (queryValue === link.href || (!queryValue && !link.href)) { if (queryValue === link.href || (!queryValue && !link.href)) {
@@ -175,14 +176,13 @@ function computeActiveIndex(): { index: number; isSubpage: boolean } {
continue continue
} }
// Exact path match if (decodedPath === decodedHref) {
if (decodedPath === link.href) {
return { index: i, isSubpage: false } return { index: i, isSubpage: false }
} }
// Subpage match
const isSubpageMatch = const isSubpageMatch =
decodedPath.includes(link.href) || (decodedPath.startsWith(decodedHref) &&
(decodedPath.length === decodedHref.length || decodedPath[decodedHref.length] === '/')) ||
link.subpages?.some((subpage) => decodedPath.includes(subpage)) link.subpages?.some((subpage) => decodedPath.includes(subpage))
if (isSubpageMatch) { if (isSubpageMatch) {
@@ -204,9 +204,6 @@ function getTabElement(index: number): HTMLElement | null {
if (!element) return null if (!element) return null
// In navigation mode, elements are NuxtLinks, but since we used querySelectorAll,
// we already have the raw HTMLElement ($el), so no further conversion is needed.
// In local mode, elements are already plain divs.
return element return element
} }
@@ -225,7 +222,6 @@ function positionSlider() {
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4 const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
if (isInitialPosition) { if (isInitialPosition) {
// Initial positioning: set position instantly, no animation
sliderLeft.value = newPosition.left sliderLeft.value = newPosition.left
sliderRight.value = newPosition.right sliderRight.value = newPosition.right
sliderTop.value = newPosition.top sliderTop.value = newPosition.top
@@ -233,7 +229,6 @@ function positionSlider() {
sliderReady.value = true sliderReady.value = true
// enable transitions after slider is painted, so future changes animate
requestAnimationFrame(() => { requestAnimationFrame(() => {
transitionsEnabled.value = true transitionsEnabled.value = true
}) })
@@ -250,7 +245,6 @@ function animateSliderTo(newPosition: {
}) { }) {
const STAGGER_DELAY = '200ms' const STAGGER_DELAY = '200ms'
// Set stagger delays: leading edge moves immediately, trailing edge is delayed
sliderDelays.value = { sliderDelays.value = {
left: newPosition.left < sliderLeft.value ? '0ms' : STAGGER_DELAY, left: newPosition.left < sliderLeft.value ? '0ms' : STAGGER_DELAY,
right: newPosition.left < sliderLeft.value ? STAGGER_DELAY : '0ms', right: newPosition.left < sliderLeft.value ? STAGGER_DELAY : '0ms',
@@ -321,8 +315,4 @@ watch(
bottom 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(bottomDelay), bottom 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(bottomDelay),
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms; opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
} }
.card-shadow {
box-shadow: var(--shadow-card);
}
</style> </style>

View File

@@ -6,7 +6,6 @@
:placement="placement" :placement="placement"
:class="dropdownClass" :class="dropdownClass"
@apply-hide="focusTrigger" @apply-hide="focusTrigger"
@apply-show="focusMenuChild"
> >
<button ref="trigger" v-bind="$attrs" v-tooltip="tooltip"> <button ref="trigger" v-bind="$attrs" v-tooltip="tooltip">
<slot></slot> <slot></slot>
@@ -52,14 +51,6 @@ defineProps({
}, },
}) })
function focusMenuChild() {
setTimeout(() => {
if (menu.value && menu.value.children && menu.value.children.length > 0) {
menu.value.children[0].focus()
}
}, 50)
}
function hideAndFocusTrigger(hide) { function hideAndFocusTrigger(hide) {
hide() hide()
focusTrigger() focusTrigger()

View File

@@ -45,6 +45,7 @@ export type { MultiSelectOption } from './MultiSelect.vue'
export { default as MultiSelect } from './MultiSelect.vue' export { default as MultiSelect } from './MultiSelect.vue'
export type { MaybeCtxFn, StageButtonConfig, StageConfigInput } from './MultiStageModal.vue' export type { MaybeCtxFn, StageButtonConfig, StageConfigInput } from './MultiStageModal.vue'
export { default as MultiStageModal, resolveCtxFn } from './MultiStageModal.vue' export { default as MultiStageModal, resolveCtxFn } from './MultiStageModal.vue'
export { default as NavTabs } from './NavTabs.vue'
export { default as OptionGroup } from './OptionGroup.vue' export { default as OptionGroup } from './OptionGroup.vue'
export type { Option as OverflowMenuOption } from './OverflowMenu.vue' export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
export { default as OverflowMenu } from './OverflowMenu.vue' export { default as OverflowMenu } from './OverflowMenu.vue'

View File

@@ -98,7 +98,7 @@
import { ChevronRightIcon, FolderSearchIcon, SearchIcon } from '@modrinth/assets' import { ChevronRightIcon, FolderSearchIcon, SearchIcon } from '@modrinth/assets'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { injectInstanceImport } from '../../../../providers' import { injectInstanceImport, injectNotificationManager } from '../../../../providers'
import type { ImportableLauncher } from '../../../../providers/instance-import' import type { ImportableLauncher } from '../../../../providers/instance-import'
import ButtonStyled from '../../../base/ButtonStyled.vue' import ButtonStyled from '../../../base/ButtonStyled.vue'
import Checkbox from '../../../base/Checkbox.vue' import Checkbox from '../../../base/Checkbox.vue'
@@ -108,6 +108,7 @@ import { injectCreationFlowContext } from '../creation-flow-context'
const ctx = injectCreationFlowContext() const ctx = injectCreationFlowContext()
const importProvider = injectInstanceImport() const importProvider = injectInstanceImport()
const { addNotification } = injectNotificationManager()
const loading = ref(false) const loading = ref(false)
const expandedLaunchers = ref(new Set<string>()) const expandedLaunchers = ref(new Set<string>())
@@ -257,6 +258,14 @@ async function addLauncherPath() {
try { try {
const instances = await importProvider.getImportableInstances('Custom', path) const instances = await importProvider.getImportableInstances('Custom', path)
if (instances.length === 0) {
addNotification({
type: 'error',
title: 'No instances found',
text: `No importable instances were found at the specified path.`,
})
return
}
const launcher: ImportableLauncher = { const launcher: ImportableLauncher = {
name: `Custom (${path.split(/[\\/]/).pop() || path})`, name: `Custom (${path.split(/[\\/]/).pop() || path})`,
path, path,
@@ -266,13 +275,12 @@ async function addLauncherPath() {
expandedLaunchers.value.add(launcher.name) expandedLaunchers.value.add(launcher.name)
expandedLaunchers.value = new Set(expandedLaunchers.value) expandedLaunchers.value = new Set(expandedLaunchers.value)
} catch { } catch {
// Failed to load — still add with empty instances addNotification({
const launcher: ImportableLauncher = { type: 'error',
name: `Custom (${path.split(/[\\/]/).pop() || path})`, title: 'No instances found',
path, text: `No importable instances were found at the specified path.`,
instances: [], })
} return
ctx.importLaunchers.value = [...ctx.importLaunchers.value, launcher]
} }
newLauncherPath.value = '' newLauncherPath.value = ''

View File

@@ -0,0 +1,106 @@
<template>
<NewModal ref="modal" :header="localizeIfPossible(title)" fade="warning" max-width="500px">
<div class="flex flex-col gap-6">
<Admonition :type="admonitionType" :header="localizeIfPossible(header)">
{{ localizeIfPossible(body) }}
</Admonition>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="cancel">
<XIcon />
{{ localizeIfPossible(stayLabel) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="leave">
<RightArrowIcon />
{{ localizeIfPossible(leaveLabel) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { RightArrowIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { defineMessage, type MessageDescriptor, useVIntl } from '#ui/composables/i18n'
import NewModal from './NewModal.vue'
const { formatMessage } = useVIntl()
withDefaults(
defineProps<{
title?: MessageDescriptor | string
header?: MessageDescriptor | string
body?: MessageDescriptor | string
stayLabel?: MessageDescriptor | string
leaveLabel?: MessageDescriptor | string
admonitionType?: 'warning' | 'critical' | 'info'
}>(),
{
title: () =>
defineMessage({
id: 'ui.confirm-leave-modal.title',
defaultMessage: 'Leave page?',
}),
header: () =>
defineMessage({
id: 'ui.confirm-leave-modal.header',
defaultMessage: 'You have unsaved changes',
}),
body: () =>
defineMessage({
id: 'ui.confirm-leave-modal.body',
defaultMessage: 'You have unsaved changes that will be lost if you leave this page.',
}),
stayLabel: () =>
defineMessage({
id: 'ui.confirm-leave-modal.stay',
defaultMessage: 'Stay on page',
}),
leaveLabel: () =>
defineMessage({
id: 'ui.confirm-leave-modal.leave',
defaultMessage: 'Leave page',
}),
admonitionType: 'critical',
},
)
function localizeIfPossible(message: MessageDescriptor | string) {
return typeof message === 'string' ? message : formatMessage(message)
}
const modal = ref<InstanceType<typeof NewModal>>()
let resolvePromise: ((value: boolean) => void) | null = null
function prompt(): Promise<boolean> {
return new Promise((resolve) => {
resolvePromise = resolve
modal.value?.show()
})
}
function leave() {
modal.value?.hide()
resolvePromise?.(true)
resolvePromise = null
}
function cancel() {
modal.value?.hide()
resolvePromise?.(false)
resolvePromise = null
}
defineExpose({ prompt })
</script>

View File

@@ -1,3 +1,4 @@
export { default as ConfirmLeaveModal } from './ConfirmLeaveModal.vue'
export { default as ConfirmModal } from './ConfirmModal.vue' export { default as ConfirmModal } from './ConfirmModal.vue'
export { default as InstallToPlayModal } from './InstallToPlayModal.vue' export { default as InstallToPlayModal } from './InstallToPlayModal.vue'
export { default as Modal } from './Modal.vue' export { default as Modal } from './Modal.vue'

View File

@@ -49,7 +49,7 @@
:show-game-label="showGameLabel" :show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel" :show-loader-label="showLoaderLabel"
:linked="false" :linked="false"
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex" class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-2 text-secondary *:hidden sm:flex-row sm:*:flex"
/> />
</div> </div>
</div> </div>

View File

@@ -154,7 +154,11 @@ const messages = defineMessages({
<template> <template>
<div <div
class="grid items-center gap-4 rounded-2xl bg-bg-raised p-4 shadow-md" class="grid items-center gap-4 rounded-2xl bg-bg-raised p-4 shadow-md"
:class="preview ? 'grid-cols-1' : 'grid-cols-[auto_1fr_auto] md:grid-cols-[1fr_400px_1fr]'" :class="
preview
? 'grid-cols-1'
: 'grid-cols-[auto_1fr_auto] md:grid-cols-[minmax(0,1fr)_400px_minmax(0,1fr)]'
"
> >
<div class="flex flex-row gap-4 items-center"> <div class="flex flex-row gap-4 items-center">
<div <div

View File

@@ -1,221 +0,0 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import {
CheckCircleIcon,
ClockIcon,
InfoIcon,
RotateCounterClockwiseIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import { computed } from 'vue'
import { useRelativeTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import ProgressBar from '../../base/ProgressBar.vue'
const { formatMessage } = useVIntl()
const relativeTime = useRelativeTime()
const props = withDefaults(
defineProps<{
type: 'create' | 'restore'
state: Archon.Backups.v1.BackupState
progress: number
backupName?: string
createdAt?: string
}>(),
{
backupName: undefined,
createdAt: undefined,
},
)
const emit = defineEmits<{
(e: 'cancel' | 'retry' | 'dismiss'): void
}>()
const isQueued = computed(() => props.state === 'ongoing' && props.progress === 0)
const isInProgress = computed(() => props.state === 'ongoing' && props.progress > 0)
const isFailed = computed(() => props.state === 'failed')
const isSuccess = computed(() => props.state === 'done')
const showCancel = computed(() => isQueued.value || isInProgress.value)
const showRetry = computed(() => isFailed.value)
const showDismiss = computed(() => isFailed.value || isSuccess.value)
const showProgress = computed(() => isInProgress.value)
const colorClasses = computed(() => {
if (isFailed.value) return 'border-brand-red bg-bg-red'
if (isSuccess.value) return 'border-brand-green bg-bg-green'
return 'border-brand-blue bg-bg-blue'
})
const icon = computed(() => {
if (isFailed.value) return TriangleAlertIcon
if (isSuccess.value) return CheckCircleIcon
return InfoIcon
})
const iconClass = computed(() => {
if (isFailed.value) return 'text-brand-red'
if (isSuccess.value) return 'text-brand-green'
return 'text-brand-blue'
})
const buttonColor = computed<'red' | 'green' | 'blue'>(() => {
if (isFailed.value) return 'red'
if (isSuccess.value) return 'green'
return 'blue'
})
const name = computed(() => props.backupName ?? formatMessage(messages.fallbackName))
const title = computed(() => {
if (props.type === 'create') {
if (isQueued.value) return formatMessage(messages.backupQueuedTitle)
if (isInProgress.value) return formatMessage(messages.creatingBackupTitle)
if (isFailed.value) return formatMessage(messages.backupFailedTitle)
}
if (isQueued.value) return formatMessage(messages.restoreQueuedTitle)
if (isInProgress.value) return formatMessage(messages.restoringBackupTitle)
if (isSuccess.value) return formatMessage(messages.restoreSuccessfulTitle)
if (isFailed.value) return formatMessage(messages.restoreFailedTitle)
return ''
})
const description = computed(() => {
if (props.type === 'create') {
if (isQueued.value)
return formatMessage(messages.backupQueuedDescription, { backupName: name.value })
if (isInProgress.value)
return formatMessage(messages.creatingBackupDescription, { backupName: name.value })
if (isFailed.value)
return formatMessage(messages.backupFailedDescription, { backupName: name.value })
}
if (isQueued.value)
return formatMessage(messages.restoreQueuedDescription, { backupName: name.value })
if (isInProgress.value)
return formatMessage(messages.restoringBackupDescription, { backupName: name.value })
if (isSuccess.value)
return formatMessage(messages.restoreSuccessfulDescription, { backupName: name.value })
if (isFailed.value)
return formatMessage(messages.restoreFailedDescription, { backupName: name.value })
return ''
})
const messages = defineMessages({
fallbackName: {
id: 'servers.backups.admonition.fallback-name',
defaultMessage: 'Your backup',
},
backupQueuedTitle: {
id: 'servers.backups.admonition.backup-queued.title',
defaultMessage: 'Backup queued',
},
backupQueuedDescription: {
id: 'servers.backups.admonition.backup-queued.description',
defaultMessage: '{backupName} is queued and will start shortly.',
},
creatingBackupTitle: {
id: 'servers.backups.admonition.creating-backup.title',
defaultMessage: 'Creating backup',
},
creatingBackupDescription: {
id: 'servers.backups.admonition.creating-backup.description',
defaultMessage:
'Saving world data and server configuration for {backupName}. This can take a few minutes.',
},
backupFailedTitle: {
id: 'servers.backups.admonition.backup-failed.title',
defaultMessage: 'Backup failed',
},
backupFailedDescription: {
id: 'servers.backups.admonition.backup-failed.description',
defaultMessage:
'Something went wrong while creating {backupName}. Please try again or contact support if the issue continues.',
},
restoreQueuedTitle: {
id: 'servers.backups.admonition.restore-queued.title',
defaultMessage: 'Restoring from backup queued',
},
restoreQueuedDescription: {
id: 'servers.backups.admonition.restore-queued.description',
defaultMessage: 'Restoring from {backupName} is queued and will start shortly.',
},
restoringBackupTitle: {
id: 'servers.backups.admonition.restoring-backup.title',
defaultMessage: 'Restoring from backup',
},
restoringBackupDescription: {
id: 'servers.backups.admonition.restoring-backup.description',
defaultMessage: 'Restoring your server from {backupName}. This may take a couple of minutes.',
},
restoreSuccessfulTitle: {
id: 'servers.backups.admonition.restore-successful.title',
defaultMessage: 'Restoring from backup successful',
},
restoreSuccessfulDescription: {
id: 'servers.backups.admonition.restore-successful.description',
defaultMessage: 'Your server has been restored to {backupName} and is ready to start.',
},
restoreFailedTitle: {
id: 'servers.backups.admonition.restore-failed.title',
defaultMessage: 'Restoring from backup failed',
},
restoreFailedDescription: {
id: 'servers.backups.admonition.restore-failed.description',
defaultMessage:
'Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues.',
},
})
</script>
<template>
<div :class="['flex flex-col rounded-2xl border border-solid p-4', colorClasses]">
<div class="flex items-start gap-2">
<div class="flex flex-1 gap-3 items-start">
<component :is="icon" :class="['size-6 shrink-0', iconClass]" />
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="font-semibold text-contrast">{{ title }}</span>
<div v-if="createdAt" class="flex items-center gap-1.5 text-secondary">
<ClockIcon class="size-4" />
<span class="font-medium">{{ relativeTime(createdAt) }}</span>
</div>
</div>
<span class="text-contrast opacity-80">{{ description }}</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<ButtonStyled v-if="showCancel" type="outlined" color="blue">
<button class="!border" @click="emit('cancel')">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="showRetry" color="red">
<button @click="emit('retry')">
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
<ButtonStyled
v-if="showDismiss"
circular
type="transparent"
hover-color-fill="background"
:color="buttonColor"
>
<button @click="emit('dismiss')">
<XIcon />
</button>
</ButtonStyled>
</div>
</div>
<div v-if="showProgress" class="mt-4 pl-9">
<ProgressBar :progress="progress" color="blue" :waiting="progress === 0" full-width />
</div>
</div>
</template>

View File

@@ -1,12 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Archon } from '@modrinth/api-client' import type { Archon } from '@modrinth/api-client'
import {
CheckCircleIcon,
ClockIcon,
InfoIcon,
RotateCounterClockwiseIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query' import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, reactive, watch } from 'vue' import { computed, reactive, watch } from 'vue'
import { useRelativeTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { injectModrinthClient, injectModrinthServerContext } from '../../../providers' import { injectModrinthClient, injectModrinthServerContext } from '../../../providers'
import type { BackupProgressEntry } from '../../../providers/server-context' import type { BackupProgressEntry } from '../../../providers/server-context'
import BackupProgressAdmonition from './BackupProgressAdmonition.vue' import { commonMessages } from '../../../utils'
import Admonition from '../../base/Admonition.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import ProgressBar from '../../base/ProgressBar.vue'
const { formatMessage } = useVIntl()
const relativeTime = useRelativeTime()
const client = injectModrinthClient() const client = injectModrinthClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { serverId, worldId, backupsState, markBackupCancelled } = injectModrinthServerContext() const { serverId, worldId, backupsState, markBackupCancelled } = injectModrinthServerContext()
@@ -81,7 +96,6 @@ const admonitions = computed<AdmonitionEntry[]>(() => {
const result: AdmonitionEntry[] = [] const result: AdmonitionEntry[] = []
const seenIds = new Set<string>() const seenIds = new Set<string>()
// 1. Active WS entries (real-time progress from backupsState)
for (const [id, entry] of backupsState.entries()) { for (const [id, entry] of backupsState.entries()) {
const backup = findBackup(id) const backup = findBackup(id)
seenIds.add(id) seenIds.add(id)
@@ -115,7 +129,6 @@ const admonitions = computed<AdmonitionEntry[]>(() => {
} }
} }
// 2. REST-based entries for pending/in_progress backups without WS data yet
if (backupsList.value) { if (backupsList.value) {
for (const backup of backupsList.value) { for (const backup of backupsList.value) {
if (seenIds.has(backup.id)) continue if (seenIds.has(backup.id)) continue
@@ -136,7 +149,6 @@ const admonitions = computed<AdmonitionEntry[]>(() => {
} }
} }
// 3. Terminal entries (snapshotted before cleanup)
for (const [key, entry] of terminalEntries.entries()) { for (const [key, entry] of terminalEntries.entries()) {
if (dismissedIds.has(key)) continue if (dismissedIds.has(key)) continue
if (result.some((r) => r.key === key)) continue if (result.some((r) => r.key === key)) continue
@@ -177,6 +189,128 @@ function handleDismiss(key: string) {
dismissedIds.add(key) dismissedIds.add(key)
terminalEntries.delete(key) terminalEntries.delete(key)
} }
function getAdmonitionType(state: Archon.Backups.v1.BackupState): 'info' | 'critical' | 'success' {
if (state === 'failed') return 'critical'
if (state === 'done') return 'success'
return 'info'
}
function getIcon(state: Archon.Backups.v1.BackupState) {
if (state === 'failed') return TriangleAlertIcon
if (state === 'done') return CheckCircleIcon
return InfoIcon
}
function getButtonColor(state: Archon.Backups.v1.BackupState): 'red' | 'green' | 'blue' {
if (state === 'failed') return 'red'
if (state === 'done') return 'green'
return 'blue'
}
function isQueued(item: AdmonitionEntry) {
return item.state === 'ongoing' && item.progress === 0
}
function isInProgress(item: AdmonitionEntry) {
return item.state === 'ongoing' && item.progress > 0
}
function getTitle(item: AdmonitionEntry) {
if (item.type === 'create') {
if (isQueued(item)) return formatMessage(messages.backupQueuedTitle)
if (isInProgress(item)) return formatMessage(messages.creatingBackupTitle)
if (item.state === 'failed') return formatMessage(messages.backupFailedTitle)
}
if (isQueued(item)) return formatMessage(messages.restoreQueuedTitle)
if (isInProgress(item)) return formatMessage(messages.restoringBackupTitle)
if (item.state === 'done') return formatMessage(messages.restoreSuccessfulTitle)
if (item.state === 'failed') return formatMessage(messages.restoreFailedTitle)
return ''
}
function getDescription(item: AdmonitionEntry) {
const backupName = item.name ?? formatMessage(messages.fallbackName)
if (item.type === 'create') {
if (isQueued(item)) return formatMessage(messages.backupQueuedDescription, { backupName })
if (isInProgress(item)) return formatMessage(messages.creatingBackupDescription, { backupName })
if (item.state === 'failed')
return formatMessage(messages.backupFailedDescription, { backupName })
}
if (isQueued(item)) return formatMessage(messages.restoreQueuedDescription, { backupName })
if (isInProgress(item)) return formatMessage(messages.restoringBackupDescription, { backupName })
if (item.state === 'done')
return formatMessage(messages.restoreSuccessfulDescription, { backupName })
if (item.state === 'failed')
return formatMessage(messages.restoreFailedDescription, { backupName })
return ''
}
const messages = defineMessages({
fallbackName: {
id: 'servers.backups.admonition.fallback-name',
defaultMessage: 'Your backup',
},
backupQueuedTitle: {
id: 'servers.backups.admonition.backup-queued.title',
defaultMessage: 'Backup queued',
},
backupQueuedDescription: {
id: 'servers.backups.admonition.backup-queued.description',
defaultMessage: '{backupName} is queued and will start shortly.',
},
creatingBackupTitle: {
id: 'servers.backups.admonition.creating-backup.title',
defaultMessage: 'Creating backup',
},
creatingBackupDescription: {
id: 'servers.backups.admonition.creating-backup.description',
defaultMessage:
'Saving world data and server configuration for {backupName}. This can take a few minutes.',
},
backupFailedTitle: {
id: 'servers.backups.admonition.backup-failed.title',
defaultMessage: 'Backup failed',
},
backupFailedDescription: {
id: 'servers.backups.admonition.backup-failed.description',
defaultMessage:
'Something went wrong while creating {backupName}. Please try again or contact support if the issue continues.',
},
restoreQueuedTitle: {
id: 'servers.backups.admonition.restore-queued.title',
defaultMessage: 'Restoring from backup queued',
},
restoreQueuedDescription: {
id: 'servers.backups.admonition.restore-queued.description',
defaultMessage: 'Restoring from {backupName} is queued and will start shortly.',
},
restoringBackupTitle: {
id: 'servers.backups.admonition.restoring-backup.title',
defaultMessage: 'Restoring from backup',
},
restoringBackupDescription: {
id: 'servers.backups.admonition.restoring-backup.description',
defaultMessage: 'Restoring your server from {backupName}. This may take a couple of minutes.',
},
restoreSuccessfulTitle: {
id: 'servers.backups.admonition.restore-successful.title',
defaultMessage: 'Restoring from backup successful',
},
restoreSuccessfulDescription: {
id: 'servers.backups.admonition.restore-successful.description',
defaultMessage: 'Your server has been restored to {backupName} and is ready to start.',
},
restoreFailedTitle: {
id: 'servers.backups.admonition.restore-failed.title',
defaultMessage: 'Restoring from backup failed',
},
restoreFailedDescription: {
id: 'servers.backups.admonition.restore-failed.description',
defaultMessage:
'Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues.',
},
})
</script> </script>
<template> <template>
@@ -186,18 +320,55 @@ function handleDismiss(key: string) {
tag="div" tag="div"
class="flex flex-col gap-3" class="flex flex-col gap-3"
> >
<BackupProgressAdmonition <Admonition v-for="item in admonitions" :key="item.key" :type="getAdmonitionType(item.state)">
v-for="item in admonitions" <template #icon="{ iconClass }">
:key="item.key" <component :is="getIcon(item.state)" :class="iconClass" />
:type="item.type" </template>
:state="item.state" <template #header>
<div class="flex items-center gap-2">
<span>{{ getTitle(item) }}</span>
<div v-if="item.createdAt" class="flex items-center gap-1.5 text-secondary">
<ClockIcon class="size-4" />
<span class="font-medium">{{ relativeTime(item.createdAt) }}</span>
</div>
</div>
</template>
{{ getDescription(item) }}
<template #top-right-actions>
<ButtonStyled v-if="isQueued(item) || isInProgress(item)" type="outlined" color="blue">
<button class="!border" @click="handleCancel(item.backupId)">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="item.state === 'failed'" color="red">
<button @click="handleRetry(item.backupId, item.key)">
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
<ButtonStyled
v-if="item.state === 'failed' || item.state === 'done'"
circular
type="transparent"
hover-color-fill="background"
:color="getButtonColor(item.state)"
>
<button @click="handleDismiss(item.key)">
<XIcon />
</button>
</ButtonStyled>
</template>
<template v-if="isInProgress(item)" #progress>
<div class="pl-9">
<ProgressBar
:progress="item.progress" :progress="item.progress"
:backup-name="item.name" color="blue"
:created-at="item.createdAt" :waiting="item.progress === 0"
@cancel="handleCancel(item.backupId)" full-width
@retry="handleRetry(item.backupId, item.key)"
@dismiss="handleDismiss(item.key)"
/> />
</div>
</template>
</Admonition>
</TransitionGroup> </TransitionGroup>
</template> </template>

View File

@@ -1,7 +1,6 @@
export { default as BackupCreateModal } from './BackupCreateModal.vue' export { default as BackupCreateModal } from './BackupCreateModal.vue'
export { default as BackupDeleteModal } from './BackupDeleteModal.vue' export { default as BackupDeleteModal } from './BackupDeleteModal.vue'
export { default as BackupItem } from './BackupItem.vue' export { default as BackupItem } from './BackupItem.vue'
export { default as BackupProgressAdmonition } from './BackupProgressAdmonition.vue'
export { default as BackupProgressAdmonitions } from './BackupProgressAdmonitions.vue' export { default as BackupProgressAdmonitions } from './BackupProgressAdmonitions.vue'
export { default as BackupRenameModal } from './BackupRenameModal.vue' export { default as BackupRenameModal } from './BackupRenameModal.vue'
export { default as BackupRestoreModal } from './BackupRestoreModal.vue' export { default as BackupRestoreModal } from './BackupRestoreModal.vue'

View File

@@ -1,251 +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('navigateHome')"
@mouseenter="$emit('prefetchHome')"
>
<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 breadcrumbs"
: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="
!isEditing && index === breadcrumbs.length - 1 ? 'location' : undefined
"
:class="{
'!text-contrast': !isEditing && index === breadcrumbs.length - 1,
}"
@click="$emit('navigate', index)"
>
{{ segment || '' }}
</button>
</ButtonStyled>
<ChevronRightIcon
v-if="index < breadcrumbs.length - 1 || isEditing"
class="size-4 flex-shrink-0 text-secondary"
aria-hidden="true"
/>
</div>
</li>
</TransitionGroup>
<li v-if="isEditing && editingFileName" class="flex items-center px-3 text-sm">
<span class="font-semibold !text-contrast" aria-current="location">
{{ editingFileName }}
</span>
</li>
</ol>
</li>
</ol>
</nav>
<div v-if="!isEditing" 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 v-if="showRefreshButton" type="outlined">
<button
type="button"
class="flex h-10 items-center gap-2 !border-[1px] !border-surface-5"
@click="$emit('refresh')"
>
<RefreshCwIcon aria-hidden="true" class="h-5 w-5" />
Refresh
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<OverflowMenu
:dropdown-id="`create-new-${baseId}`"
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') },
{ id: 'directory', action: () => $emit('create', 'directory') },
{ id: 'upload', action: () => $emit('upload') },
{ divider: true },
{ id: 'upload-zip', shown: false, action: () => $emit('uploadZip') },
{ id: 'install-from-url', action: () => $emit('unzipFromUrl', false) },
{ id: 'install-cf-pack', action: () => $emit('unzipFromUrl', 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>
<div v-else-if="!isEditingImage" 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
aria-label="Save file"
:options="[
{ id: 'save', action: () => $emit('save') },
{ id: 'save-as', action: () => $emit('saveAs') },
{ id: 'save-restart', action: () => $emit('saveRestart') },
]"
>
<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>
<RefreshCwIcon aria-hidden="true" />
Save & restart
</template>
</TeleportOverflowMenu>
</ButtonStyled>
</div>
</header>
</template>
<script setup lang="ts">
import {
BoxIcon,
ChevronRightIcon,
CurseForgeIcon,
DropdownIcon,
FileArchiveIcon,
FolderOpenIcon,
HomeIcon,
LinkIcon,
PlusIcon,
RefreshCwIcon,
SaveIcon,
SearchIcon,
ShareIcon,
UploadIcon,
} from '@modrinth/assets'
import { Button, ButtonStyled, OverflowMenu, StyledInput } from '@modrinth/ui'
import { computed } from 'vue'
import TeleportOverflowMenu from './explorer/TeleportOverflowMenu.vue'
const props = defineProps<{
breadcrumbs: string[]
isEditing: boolean
editingFileName?: string
editingFilePath?: string
isEditingImage?: boolean
searchQuery: string
showRefreshButton?: boolean
baseId: string
disabled?: boolean
disabledTooltip?: string
}>()
defineEmits<{
navigate: [index: number]
navigateHome: []
prefetchHome: []
'update:searchQuery': [value: string]
create: [type: 'file' | 'directory']
upload: []
uploadZip: []
unzipFromUrl: [cf: boolean]
refresh: []
save: []
saveAs: []
saveRestart: []
share: []
}>()
const isLogFile = computed(() => {
return (
props.editingFilePath?.startsWith('logs') ||
props.editingFilePath?.startsWith('crash-reports') ||
props.editingFilePath?.endsWith('.log')
)
})
</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,242 +0,0 @@
<template>
<div class="flex h-full w-full flex-col gap-4">
<div class="flex flex-col overflow-hidden rounded-[20px] 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"
/>
<FileImageViewer 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 { type Component, computed, inject, onMounted, onUnmounted, ref, watch } from 'vue'
import FileImageViewer from './FileImageViewer.vue'
interface MclogsResponse {
success: boolean
url?: string
error?: string
}
const props = defineProps<{
file: { name: string; type: string; path: string } | null
editorComponent: Component | null
}>()
const emit = defineEmits<{
close: []
}>()
const notifications = injectNotificationManager()
const { addNotification } = notifications
const client = injectModrinthClient()
const serverContext = injectModrinthServerContext()
const { serverId } = serverContext
const queryClient = useQueryClient()
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
const fileContent = ref('')
const isEditingImage = ref(false)
const imagePreview = ref<Blob | null>(null)
const isLoading = ref(false)
const editorInstance = ref<unknown>(null)
const editorLanguage = computed(() => {
const ext = getFileExtension(props.file?.name ?? '')
return getEditorLanguage(ext)
})
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)
const normalizedPath = file.path.startsWith('/') ? file.path : `/${file.path}`
if (file.type === 'file' && isImageFile(extension)) {
const content = await client.kyros.files_v0.downloadFile(normalizedPath)
isEditingImage.value = true
imagePreview.value = content
} else {
isEditingImage.value = false
const cachedContent = queryClient.getQueryData<string>([
'file-content',
serverId,
normalizedPath,
])
if (cachedContent) {
fileContent.value = cachedContent
} else {
const content = await client.kyros.files_v0.downloadFile(normalizedPath)
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
}
function onEditorInit(editor: {
commands: {
addCommand: (cmd: {
name: string
bindKey: { win: string; mac: string }
exec: () => void
}) => void
}
}) {
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 {
const normalizedPath = props.file.path.startsWith('/') ? props.file.path : `/${props.file.path}`
await client.kyros.files_v0.updateFile(normalizedPath, 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 saveAndRestart() {
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 shareToMclogs() {
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 }),
})
const data = (await response.json()) as MclogsResponse
if (data.success && data.url) {
await navigator.clipboard.writeText(data.url)
addNotification({
title: 'Log URL copied',
text: 'Your log file URL has been copied to your clipboard.',
type: 'success',
})
} else {
throw new Error(data.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 close() {
resetState()
emit('close')
}
onMounted(async () => {
if (modulesLoaded) {
await modulesLoaded
}
})
onUnmounted(() => {
editorInstance.value = null
resetState()
})
defineExpose({
saveFileContent,
saveAndRestart,
shareToMclogs,
close,
isEditingImage,
fileContent,
})
</script>

View File

@@ -1,178 +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"
>
<TriangleAlertIcon class="size-8 text-red" />
<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 { TriangleAlertIcon, ZoomInIcon, ZoomOutIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref, watch } from '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,2 +0,0 @@
export { default as FileEditor } from './FileEditor.vue'
export { default as FileImageViewer } from './FileImageViewer.vue'

View File

@@ -1,360 +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">
<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,
FolderCogIcon,
FolderOpenIcon,
GlobeIcon,
MoreHorizontalIcon,
PackageOpenIcon,
PaletteIcon,
RightArrowIcon,
TrashIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
getFileExtension,
getFileExtensionIcon,
isEditableFile as isEditableFileExt,
isImageFile,
useFormatDateTime,
} from '@modrinth/ui'
import { computed, ref, shallowRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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
writeDisabled?: boolean
writeDisabledTooltip?: string
}
const props = defineProps<FileItemProps>()
const emit = defineEmits<{
rename: [item: { name: string; type: string; path: string }]
move: [item: { name: string; type: string; path: string }]
download: [item: { name: string; type: string; path: string }]
delete: [item: { name: string; type: string; path: string }]
edit: [item: { name: string; type: string; path: string }]
extract: [item: { name: string; type: string; path: string }]
hover: [item: { name: string; type: string; path: string }]
moveDirectTo: [item: { name: string; type: string; path: string; destination: string }]
contextmenu: [x: number, y: number]
'toggle-select': []
}>()
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.selected ? 'bg-surface-3' : props.index % 2 === 0 ? 'bg-surface-2' : 'file-row-alt',
props.isLast ? 'rounded-b-[20px] border-b' : '',
isEditableFile.value ? 'cursor-pointer' : props.type === 'directory' ? 'cursor-pointer' : '',
isDragOver.value ? '!bg-brand-highlight' : '',
'transition-colors duration-100 hover:!bg-surface-4 hover:!brightness-100 focus:!bg-surface-4 focus:!brightness-100',
])
const fileExtension = computed(() => getFileExtension(props.name))
const isZip = computed(() => fileExtension.value === 'zip')
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') {
if (props.name === 'config') return FolderCogIcon
if (props.name === 'world') return GlobeIcon
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)
}
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 nameSpan = document.createElement('span')
nameSpan.className = 'font-bold truncate text-contrast'
nameSpan.textContent = props.name
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>
<style scoped>
.file-row-alt {
background: color-mix(in srgb, var(--surface-2), black 3%);
}
:global(.dark-mode) .file-row-alt,
:global(.dark) .file-row-alt,
:global(.oled-mode) .file-row-alt {
background: color-mix(in srgb, var(--surface-2), black 10%);
}
</style>

View File

@@ -1,100 +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 class="ml-2">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 class="ml-2">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 class="ml-2">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 { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
import { Checkbox } from '@modrinth/ui'
defineProps<{
sortField: string
sortDesc: boolean
allSelected: boolean
someSelected: boolean
isStuck: boolean
}>()
defineEmits<{
sort: [field: string]
'toggle-all': []
}>()
</script>

View File

@@ -1,86 +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)"
:write-disabled="writeDisabled"
:write-disabled-tooltip="writeDisabledTooltip"
@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 type { Kyros } from '@modrinth/api-client'
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<{
delete: [item: Kyros.Files.v0.DirectoryItem]
rename: [item: Kyros.Files.v0.DirectoryItem]
download: [item: Kyros.Files.v0.DirectoryItem]
move: [item: Kyros.Files.v0.DirectoryItem]
edit: [item: Kyros.Files.v0.DirectoryItem]
moveDirectTo: [item: { name: string; type: string; path: string; destination: string }]
extract: [item: Kyros.Files.v0.DirectoryItem]
hover: [item: Kyros.Files.v0.DirectoryItem]
contextmenu: [item: Kyros.Files.v0.DirectoryItem, x: number, y: number]
loadMore: []
'toggle-select': [path: string]
}>()
const { listContainer, totalHeight, visibleRange, visibleTop, visibleItems } = useVirtualScroll(
toRef(props, 'items'),
{
itemHeight: 61,
bufferSize: 5,
onNearEnd: () => emit('loadMore'),
},
)
</script>

View File

@@ -1,5 +0,0 @@
export { default as FileItem } from './FileItem.vue'
export { default as FileLabelBar } from './FileLabelBar.vue'
export { default as FileManagerError } from './FileManagerError.vue'
export { default as FileVirtualList } from './FileVirtualList.vue'
export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue'

View File

@@ -1,5 +0,0 @@
export * from './editor'
export * from './explorer'
export { default as FileNavbar } from './FileNavbar.vue'
export * from './modals'
export * from './upload'

View File

@@ -1,93 +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"
:placeholder="`e.g. ${type === 'file' ? 'config.yml' : 'plugins'}`"
wrapper-class="w-full"
/>
<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<{
create: [name: string]
}>()
const modal = ref<InstanceType<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,78 +0,0 @@
<template>
<NewModal ref="modal" fade="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'
import { ref } from 'vue'
defineProps<{
item: {
name: string
type: string
count?: number
size?: number
} | null
}>()
const emit = defineEmits<{
delete: []
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const handleSubmit = () => {
emit('delete')
hide()
}
const show = () => {
modal.value?.show()
}
const hide = () => {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -1,79 +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"
placeholder="e.g. /mods/modname"
wrapper-class="w-full"
/>
</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<{
move: [destination: string]
}>()
const modal = ref<InstanceType<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,87 +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" wrapper-class="w-full" />
<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<{
rename: [newName: string]
}>()
const modal = ref<InstanceType<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>

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