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:
@@ -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
2
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<{
|
const props = withDefaults(
|
||||||
instance: Instance
|
defineProps<{
|
||||||
}>()
|
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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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: '/' })
|
||||||
|
|||||||
@@ -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 },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
•
|
•
|
||||||
|
|||||||
@@ -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,37 +84,33 @@ 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 />
|
||||||
</button>
|
{{ formatMessage(commonMessages.cancelButton) }}
|
||||||
</ButtonStyled>
|
</button>
|
||||||
<ButtonStyled>
|
</ButtonStyled>
|
||||||
<button :disabled="!address" @click="addServer(false)">
|
<ButtonStyled>
|
||||||
<PlusIcon />
|
<button :disabled="!address" @click="addServer(false)">
|
||||||
{{ formatMessage(messages.addServer) }}
|
<PlusIcon />
|
||||||
</button>
|
{{ formatMessage(messages.addServer) }}
|
||||||
</ButtonStyled>
|
</button>
|
||||||
<ButtonStyled>
|
</ButtonStyled>
|
||||||
<button @click="hide()">
|
<ButtonStyled color="brand">
|
||||||
<XIcon />
|
<button :disabled="!address" @click="addServer(true)">
|
||||||
{{ formatMessage(commonMessages.cancelButton) }}
|
<PlayIcon />
|
||||||
</button>
|
{{ formatMessage(messages.addAndPlay) }}
|
||||||
</ButtonStyled>
|
</button>
|
||||||
</div>
|
</ButtonStyled>
|
||||||
</ModalWrapper>
|
</div>
|
||||||
|
</template>
|
||||||
|
</NewModal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
<ButtonStyled color="brand">
|
<div class="flex gap-2 justify-end">
|
||||||
<button :disabled="!address" @click="saveServer">
|
<ButtonStyled type="outlined">
|
||||||
<SaveIcon />
|
<button class="!border !border-surface-4" @click="hide()">
|
||||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
<XIcon />
|
||||||
</button>
|
{{ formatMessage(commonMessages.cancelButton) }}
|
||||||
</ButtonStyled>
|
</button>
|
||||||
<ButtonStyled>
|
</ButtonStyled>
|
||||||
<button @click="hide()">
|
<ButtonStyled color="brand">
|
||||||
<XIcon />
|
<button :disabled="!address" @click="saveServer">
|
||||||
{{ formatMessage(commonMessages.cancelButton) }}
|
<SaveIcon />
|
||||||
</button>
|
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||||
</ButtonStyled>
|
</button>
|
||||||
</div>
|
</ButtonStyled>
|
||||||
</ModalWrapper>
|
</div>
|
||||||
|
</template>
|
||||||
|
</NewModal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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"
|
/>
|
||||||
/>
|
</label>
|
||||||
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
|
<label class="flex flex-col gap-2">
|
||||||
{{ formatMessage(messages.address) }}
|
<span class="font-semibold text-contrast">{{ formatMessage(messages.address) }}</span>
|
||||||
</h2>
|
<StyledInput
|
||||||
<StyledInput
|
v-model="address"
|
||||||
v-model="address"
|
:placeholder="formatMessage(messages.placeholderAddress)"
|
||||||
placeholder="example.modrinth.gg"
|
autocomplete="off"
|
||||||
autocomplete="off"
|
wrapper-class="w-full"
|
||||||
wrapper-class="w-full"
|
/>
|
||||||
/>
|
</label>
|
||||||
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
|
<label class="flex flex-col gap-2">
|
||||||
{{ formatMessage(messages.resourcePack) }}
|
<span class="font-semibold text-contrast">{{ formatMessage(messages.resourcePack) }}</span>
|
||||||
</h2>
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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') {
|
||||||
.then((ids) => {
|
get_profile_worlds(route.query.i as string)
|
||||||
debugLog('installedProjectIds loaded', { count: ids?.length })
|
.then((worlds) => {
|
||||||
installedProjectIds.value = ids
|
const serverProjectIds = worlds
|
||||||
})
|
.filter((w) => w.type === 'server' && 'project_id' in w && w.project_id)
|
||||||
.catch(handleError)
|
.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) => {
|
||||||
|
debugLog('installedProjectIds loaded', { count: ids?.length })
|
||||||
|
installedProjectIds.value = ids
|
||||||
|
})
|
||||||
|
.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,11 +307,28 @@ 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
|
||||||
showAddServerToInstanceModal(project.name, address)
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const unlistenProcesses = await process_listener(
|
const unlistenProcesses = await process_listener(
|
||||||
@@ -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) {
|
||||||
const links = [
|
params.from = route.query.from
|
||||||
{ label: 'Modpacks', href: `/browse/modpack`, shown: modpacks },
|
|
||||||
{ label: 'Mods', href: `/browse/mod`, shown: mods },
|
|
||||||
{ label: 'Resource Packs', href: `/browse/resourcepack` },
|
|
||||||
{ label: 'Data Packs', href: `/browse/datapack`, shown: dataPacks },
|
|
||||||
{ label: 'Shaders', href: `/browse/shader` },
|
|
||||||
{ label: 'Servers', href: `/browse/server`, shown: !instance.value },
|
|
||||||
]
|
|
||||||
|
|
||||||
if (params) {
|
|
||||||
return links.map((link) => {
|
|
||||||
return {
|
|
||||||
...link,
|
|
||||||
href: {
|
|
||||||
path: link.href,
|
|
||||||
query: params,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return links
|
const queryString = new URLSearchParams(params as Record<string, string>).toString()
|
||||||
})
|
const suffix = queryString ? `?${queryString}` : ''
|
||||||
|
|
||||||
const messages = defineMessages({
|
if (isFromWorlds.value) {
|
||||||
gameVersionProvidedByInstance: {
|
return [{ label: 'Servers', href: `/browse/server${suffix}` }]
|
||||||
id: 'search.filter.locked.instance-game-version.title',
|
}
|
||||||
defaultMessage: 'Game version is provided by the instance',
|
|
||||||
},
|
return [
|
||||||
gameVersionProvidedByServer: {
|
{ label: 'Modpacks', href: `/browse/modpack${suffix}`, shown: modpacks },
|
||||||
id: 'search.filter.locked.server-game-version.title',
|
{ label: 'Mods', href: `/browse/mod${suffix}`, shown: mods },
|
||||||
defaultMessage: 'Game version is provided by the server',
|
{ label: 'Resource Packs', href: `/browse/resourcepack${suffix}` },
|
||||||
},
|
{ label: 'Data Packs', href: `/browse/datapack${suffix}`, shown: dataPacks },
|
||||||
modLoaderProvidedByInstance: {
|
{ label: 'Shaders', href: `/browse/shader${suffix}` },
|
||||||
id: 'search.filter.locked.instance-loader.title',
|
{ label: 'Servers', href: `/browse/server${suffix}`, shown: !instance.value },
|
||||||
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,37 +1015,71 @@ 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">
|
||||||
<button
|
<ButtonStyled color="brand" type="outlined">
|
||||||
v-tooltip="'Add server to instance'"
|
<button
|
||||||
@click.stop="() => handleAddServerToInstance(project)"
|
:disabled="allInstalledIds.has(project.project_id)"
|
||||||
|
@click.stop="() => handleAddServerToInstance(project)"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled
|
||||||
|
v-if="runningServerProjects[project.project_id]"
|
||||||
|
color="red"
|
||||||
|
type="outlined"
|
||||||
>
|
>
|
||||||
<PlusIcon />
|
<button @click="() => handleStopServerProject(project.project_id)">
|
||||||
</button>
|
<StopCircleIcon />
|
||||||
</ButtonStyled>
|
{{ formatMessage(commonMessages.stopButton) }}
|
||||||
<ButtonStyled
|
</button>
|
||||||
v-if="runningServerProjects[project.project_id]"
|
</ButtonStyled>
|
||||||
color="red"
|
<ButtonStyled v-else color="brand" type="outlined">
|
||||||
type="outlined"
|
<button
|
||||||
>
|
:disabled="
|
||||||
<button @click="() => handleStopServerProject(project.project_id)">
|
(installingServerProjects as string[]).includes(project.project_id)
|
||||||
<StopCircleIcon />
|
"
|
||||||
Stop
|
@click="() => handlePlayServerProject(project.project_id)"
|
||||||
</button>
|
>
|
||||||
</ButtonStyled>
|
<PlayIcon />
|
||||||
<ButtonStyled v-else color="brand" type="outlined">
|
{{
|
||||||
<button
|
formatMessage(
|
||||||
:disabled="(installingServerProjects as string[]).includes(project.project_id)"
|
(installingServerProjects as string[]).includes(project.project_id)
|
||||||
@click="() => handlePlayServerProject(project.project_id)"
|
? commonMessages.installingLabel
|
||||||
>
|
: commonMessages.playButton,
|
||||||
<PlayIcon />
|
)
|
||||||
{{
|
}}
|
||||||
(installingServerProjects as string[]).includes(project.project_id)
|
</button>
|
||||||
? 'Installing...'
|
</ButtonStyled>
|
||||||
: 'Play'
|
</template>
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ProjectCard>
|
</ProjectCard>
|
||||||
|
|||||||
345
apps/app-frontend/src/pages/instance/Files.vue
Normal file
345
apps/app-frontend/src/pages/instance/Files.vue
Normal 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>
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
</button>
|
||||||
<template v-else>
|
</ButtonStyled>
|
||||||
<UpdatedIcon />
|
<ButtonStyled color="brand">
|
||||||
Refresh
|
<button
|
||||||
</template>
|
class="!h-10 flex items-center gap-2"
|
||||||
|
@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>
|
||||||
</ButtonStyled>
|
<button
|
||||||
<ButtonStyled>
|
v-for="option in filterOptions"
|
||||||
<button @click="addServerModal?.show()">
|
:key="option.id"
|
||||||
<PlusIcon />
|
:class="filterPillClass(selectedFilters.includes(option.id))"
|
||||||
Add a server
|
@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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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[]) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
164
apps/app/src/api/files.rs
Normal 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(())
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 ?? ''
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -103,68 +103,89 @@
|
|||||||
: `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>
|
<ContentPageHeader>
|
||||||
<div class="flex w-full min-w-0 select-none flex-col items-center gap-4 pt-4 sm:flex-row">
|
<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"
|
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
<template #title>
|
||||||
|
{{ serverData.name }}
|
||||||
|
</template>
|
||||||
|
<template #stats>
|
||||||
<div
|
<div
|
||||||
class="flex min-w-0 flex-1 flex-col-reverse items-center gap-2 sm:flex-col sm:items-start"
|
v-if="serverData.flows?.intro"
|
||||||
|
class="flex items-center gap-2 font-semibold text-secondary"
|
||||||
>
|
>
|
||||||
<div class="flex w-full flex-col items-center gap-4 sm:flex-row">
|
<SettingsIcon /> Configuring server...
|
||||||
<h1
|
</div>
|
||||||
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"
|
<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">
|
||||||
{{ serverData.name }}
|
<LoaderIcon :loader="serverData.loader" class="flex shrink-0 [&&]:size-5" />
|
||||||
</h1>
|
{{ serverData.loader }} {{ serverData.mc_version }}
|
||||||
<div
|
|
||||||
v-if="isConnected"
|
|
||||||
data-pyro-server-action-buttons
|
|
||||||
class="server-action-buttons-anim flex w-fit flex-shrink-0"
|
|
||||||
>
|
|
||||||
<PanelServerActionButton
|
|
||||||
v-if="!serverData.flows?.intro"
|
|
||||||
class="flex-shrink-0"
|
|
||||||
:is-online="isServerRunning"
|
|
||||||
:is-actioning="isActioning"
|
|
||||||
:is-installing="serverData.status === 'installing'"
|
|
||||||
:disabled="isActioning || !!error"
|
|
||||||
:server-name="serverData.name"
|
|
||||||
:server-data="serverData"
|
|
||||||
:uptime-seconds="uptimeSeconds"
|
|
||||||
:busy-reason="
|
|
||||||
busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined
|
|
||||||
"
|
|
||||||
@action="sendPowerAction"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="serverData.flows?.intro"
|
v-if="serverData.loader && serverData.net?.domain"
|
||||||
class="flex items-center gap-2 font-semibold text-secondary"
|
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"
|
||||||
>
|
>
|
||||||
<SettingsIcon /> Configuring server...
|
<LinkIcon class="flex size-5 shrink-0" />
|
||||||
|
{{ serverData.net.domain }}.modrinth.gg
|
||||||
</div>
|
</div>
|
||||||
<ServerInfoLabels
|
|
||||||
v-else
|
<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
|
||||||
|
:is-online="isServerRunning"
|
||||||
|
:is-actioning="isActioning"
|
||||||
|
:is-installing="serverData.status === 'installing'"
|
||||||
|
:disabled="isActioning || !!error"
|
||||||
|
:server-name="serverData.name"
|
||||||
:server-data="serverData"
|
:server-data="serverData"
|
||||||
:show-game-label="showGameLabel"
|
|
||||||
:show-loader-label="showLoaderLabel"
|
|
||||||
:uptime-seconds="uptimeSeconds"
|
:uptime-seconds="uptimeSeconds"
|
||||||
:linked="true"
|
:busy-reason="busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined"
|
||||||
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"
|
@action="sendPowerAction"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</ContentPageHeader>
|
||||||
|
|
||||||
<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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
12
packages/app-lib/.sqlx/query-53c45c036387a8dc8d978a6e4d28524a852b8dec409891cf2165876fb7ff0314.json
generated
Normal file
12
packages/app-lib/.sqlx/query-53c45c036387a8dc8d978a6e4d28524a852b8dec409891cf2165876fb7ff0314.json
generated
Normal 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"
|
||||||
|
}
|
||||||
32
packages/app-lib/.sqlx/query-613192379e1fb8fd1becf2f6330365bb5bc2a8f0be01f6e4eef708474f38a3d0.json
generated
Normal file
32
packages/app-lib/.sqlx/query-613192379e1fb8fd1becf2f6330365bb5bc2a8f0be01f6e4eef708474f38a3d0.json
generated
Normal 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"
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
12
packages/app-lib/.sqlx/query-dcf7340800c1d6ca82de2092b477a41a9622ce891732300f029f34545954128e.json
generated
Normal file
12
packages/app-lib/.sqlx/query-dcf7340800c1d6ca82de2092b477a41a9622ce891732300f029f34545954128e.json
generated
Normal 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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE attached_world_data ADD COLUMN project_id TEXT;
|
||||||
|
ALTER TABLE attached_world_data ADD COLUMN content_kind TEXT;
|
||||||
@@ -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,10 +157,39 @@ pub async fn import_instance(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
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,
|
||||||
|
];
|
||||||
|
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(
|
||||||
|
"Could not determine launcher type for the given path"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -149,13 +149,33 @@ 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(
|
||||||
.await?;
|
version_id,
|
||||||
|
file_hashes,
|
||||||
|
project_ids,
|
||||||
|
&state.pool,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"No version_id available, skipping modpack file hash caching"
|
"No version_id available, skipping modpack file hash caching"
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
"Cache hit: {} modpack file hashes, {} project IDs for version {}",
|
||||||
|
cached.file_hashes.len(),
|
||||||
|
cached.project_ids.len(),
|
||||||
|
version_id
|
||||||
|
);
|
||||||
|
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!(
|
tracing::info!(
|
||||||
"Cache hit: {} modpack file hashes for version {}",
|
"Legacy cache entry without project IDs, resolving via API for version {}",
|
||||||
cached.file_hashes.len(),
|
|
||||||
version_id
|
version_id
|
||||||
);
|
);
|
||||||
return Ok(cached.file_hashes.into_iter().collect());
|
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(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
18
packages/assets/icons/settings-2.svg
Normal file
18
packages/assets/icons/settings-2.svg
Normal 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 |
@@ -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,
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ CSS custom properties are defined in `packages/assets/styles/variables.scss` wit
|
|||||||
|
|
||||||
**Color palette** (each with shades 50–950): red, orange, green, blue, purple, gray. Platform-specific colors also exist (fabric, forge, quilt, neoforge, etc.).
|
**Color palette** (each with shades 50–950): 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.
|
||||||
|
|||||||
@@ -5,38 +5,48 @@
|
|||||||
typeClasses[type],
|
typeClasses[type],
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<ButtonStyled
|
<div class="flex items-start gap-2">
|
||||||
v-if="dismissible"
|
<div
|
||||||
circular
|
:class="[
|
||||||
type="highlight-colored-text"
|
'flex flex-1 gap-2',
|
||||||
:color="buttonColors[type]"
|
header || $slots.header ? 'flex-col items-start' : 'items-center',
|
||||||
>
|
(dismissible || $slots['top-right-actions']) && 'pr-8',
|
||||||
<button aria-label="Dismiss" class="absolute top-3 right-3" @click="$emit('dismiss')">
|
]"
|
||||||
<XIcon class="h-4 w-4" />
|
>
|
||||||
</button>
|
<div
|
||||||
</ButtonStyled>
|
class="flex gap-2 items-start"
|
||||||
|
:class="header || $slots.header ? 'w-full' : 'contents'"
|
||||||
<div
|
>
|
||||||
:class="[
|
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
|
||||||
'flex gap-2 items-start',
|
<component
|
||||||
(header || $slots.header) && 'flex-col',
|
:is="getSeverityIcon(type)"
|
||||||
dismissible && 'pr-8',
|
:class="['h-6 w-6 flex-none', iconClasses[type]]"
|
||||||
]"
|
/>
|
||||||
>
|
</slot>
|
||||||
<div class="flex gap-2 items-start" :class="header || $slots.header ? 'w-full' : 'contents'">
|
<div v-if="header || $slots.header" class="font-semibold text-base">
|
||||||
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
|
<slot name="header">{{ header }}</slot>
|
||||||
<component
|
</div>
|
||||||
:is="getSeverityIcon(type)"
|
</div>
|
||||||
:class="['h-6 w-6 flex-none', iconClasses[type]]"
|
<div class="font-normal text-contrast/80" :class="!(header || $slots.header) && 'flex-1'">
|
||||||
/>
|
<slot>{{ body }}</slot>
|
||||||
</slot>
|
|
||||||
<div v-if="header || $slots.header" class="font-semibold text-base">
|
|
||||||
<slot name="header">{{ header }}</slot>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="font-normal text-base" :class="!(header || $slots.header) && 'flex-1'">
|
<div v-if="$slots['top-right-actions']" class="flex shrink-0 items-center gap-2">
|
||||||
<slot>{{ body }}</slot>
|
<slot name="top-right-actions" />
|
||||||
</div>
|
</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>
|
||||||
<div v-if="showActionsUnderneath || $slots.actions">
|
<div v-if="showActionsUnderneath || $slots.actions">
|
||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 = ''
|
||||||
|
|||||||
106
packages/ui/src/components/modal/ConfirmLeaveModal.vue
Normal file
106
packages/ui/src/components/modal/ConfirmLeaveModal.vue
Normal 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>
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
:progress="item.progress"
|
<div class="flex items-center gap-2">
|
||||||
:backup-name="item.name"
|
<span>{{ getTitle(item) }}</span>
|
||||||
:created-at="item.createdAt"
|
<div v-if="item.createdAt" class="flex items-center gap-1.5 text-secondary">
|
||||||
@cancel="handleCancel(item.backupId)"
|
<ClockIcon class="size-4" />
|
||||||
@retry="handleRetry(item.backupId, item.key)"
|
<span class="font-medium">{{ relativeTime(item.createdAt) }}</span>
|
||||||
@dismiss="handleDismiss(item.key)"
|
</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"
|
||||||
|
color="blue"
|
||||||
|
:waiting="item.progress === 0"
|
||||||
|
full-width
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Admonition>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default as FileEditor } from './FileEditor.vue'
|
|
||||||
export { default as FileImageViewer } from './FileImageViewer.vue'
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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'
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export * from './editor'
|
|
||||||
export * from './explorer'
|
|
||||||
export { default as FileNavbar } from './FileNavbar.vue'
|
|
||||||
export * from './modals'
|
|
||||||
export * from './upload'
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
Reference in New Issue
Block a user