You've already forked AstralRinth
forked from didirus/AstralRinth
Merge commit '81ec068747a39e927c42273011252daaa58f1e14' into feature-clean
This commit is contained in:
@@ -399,22 +399,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.v-popper--theme-tooltip {
|
||||
.v-popper__inner {
|
||||
background: var(--color-tooltip-bg) !important;
|
||||
color: var(--color-tooltip-text) !important;
|
||||
padding: 5px 10px 4px !important;
|
||||
border-radius: var(--size-rounded-tooltip) !important;
|
||||
box-shadow: var(--shadow-floating) !important;
|
||||
font-size: 0.9rem !important;
|
||||
}
|
||||
|
||||
.v-popper__arrow-outer,
|
||||
.v-popper__arrow-inner {
|
||||
border-color: var(--color-tooltip-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.button-base {
|
||||
@extend .button-animation;
|
||||
font-weight: 500;
|
||||
@@ -1232,6 +1216,7 @@ svg.inline-svg {
|
||||
font-size: var(--text-18);
|
||||
font-weight: var(--weight-extrabold);
|
||||
color: var(--color-contrast);
|
||||
line-height: initial;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,92 +37,8 @@ html {
|
||||
--icon-20: 1.25rem; // used for icons in normal sized buttons
|
||||
--icon-24: 1.5rem; // used for icons that are used as a primary label or in large buttons
|
||||
--icon-32: 2rem;
|
||||
}
|
||||
|
||||
.experimental-styles-within {
|
||||
// Reset deprecated properties
|
||||
--color-icon: initial !important;
|
||||
--color-text: initial !important;
|
||||
--color-text-inactive: initial !important;
|
||||
--color-text-dark: initial !important;
|
||||
--color-heading: initial !important;
|
||||
--color-divider: initial !important;
|
||||
--color-divider-dark: initial !important;
|
||||
--color-text-inverted: initial !important;
|
||||
--color-bg-inverted: initial !important;
|
||||
|
||||
--color-brand: var(--color-green) !important;
|
||||
--color-brand-inverted: initial !important;
|
||||
|
||||
--tab-underline-hovered: initial !important;
|
||||
|
||||
--color-button-text: initial !important;
|
||||
--color-button-bg-hover: initial !important;
|
||||
--color-button-text-hover: initial !important;
|
||||
--color-button-bg-active: initial !important;
|
||||
--color-button-text-active: initial !important;
|
||||
|
||||
--color-grey-link: inherit !important;
|
||||
--color-grey-link-hover: inherit !important; // DEPRECATED, use filters in future
|
||||
--color-grey-link-active: inherit !important; // DEPRECATED, use filters in future
|
||||
--color-link: var(--color-blue) !important;
|
||||
--color-link-hover: var(--color-blue) !important; // DEPRECATED, use filters in future
|
||||
--color-link-active: var(--color-blue) !important; // DEPRECATED, use filters in future
|
||||
}
|
||||
|
||||
.light-mode,
|
||||
.light {
|
||||
.experimental-styles-within,
|
||||
&.experimental-styles-within {
|
||||
--color-bg: #ebebeb;
|
||||
--color-raised-bg: #ffffff;
|
||||
--color-button-bg: #f5f5f5;
|
||||
--color-base: #2c2e31;
|
||||
--color-secondary: #484d54;
|
||||
--color-accent-contrast: #ffffff;
|
||||
|
||||
--color-platform-fabric: #8a7b71;
|
||||
--color-platform-quilt: #8b61b4;
|
||||
--color-platform-forge: #5b6197;
|
||||
--color-platform-neoforge: #dc895c;
|
||||
--color-platform-liteloader: #4c90de;
|
||||
--color-platform-bukkit: #e78362;
|
||||
--color-platform-bungeecord: #c69e39;
|
||||
--color-platform-folia: #6aa54f;
|
||||
--color-platform-paper: #e67e7e;
|
||||
--color-platform-purpur: #7763a3;
|
||||
--color-platform-spigot: #cd7a21;
|
||||
--color-platform-velocity: #4b98b0;
|
||||
--color-platform-waterfall: #5f83cb;
|
||||
--color-platform-sponge: #c49528;
|
||||
|
||||
--color-button-border: rgba(161, 161, 161, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.dark-mode,
|
||||
.dark {
|
||||
.experimental-styles-within,
|
||||
&.experimental-styles-within {
|
||||
--color-button-bg: #33363d;
|
||||
|
||||
--color-platform-fabric: #dbb69b;
|
||||
--color-platform-quilt: #c796f9;
|
||||
--color-platform-forge: #959eef;
|
||||
--color-platform-neoforge: #f99e6b;
|
||||
--color-platform-liteloader: #7ab0ee;
|
||||
--color-platform-bukkit: #f6af7b;
|
||||
--color-platform-bungeecord: #d2c080;
|
||||
--color-platform-folia: #a5e388;
|
||||
--color-platform-paper: #eeaaaa;
|
||||
--color-platform-purpur: #c3abf7;
|
||||
--color-platform-spigot: #f1cc84;
|
||||
--color-platform-velocity: #83d5ef;
|
||||
--color-platform-waterfall: #78a4fb;
|
||||
--color-platform-sponge: #f9e580;
|
||||
|
||||
--color-button-border: rgba(193, 190, 209, 0.12);
|
||||
}
|
||||
interpolate-size: allow-keywords;
|
||||
}
|
||||
|
||||
.light-mode {
|
||||
@@ -159,9 +75,6 @@ html {
|
||||
--color-dropdown-bg: var(--color-button-bg);
|
||||
--color-dropdown-text: var(--color-button-text);
|
||||
|
||||
--color-tooltip-bg: var(--color-text);
|
||||
--color-tooltip-text: var(--color-bg);
|
||||
|
||||
--color-code-bg: var(--color-bg);
|
||||
--color-code-text: var(--color-text-dark);
|
||||
|
||||
@@ -179,12 +92,6 @@ html {
|
||||
--color-link-hover: #1a76e7;
|
||||
--color-link-active: #146fd7;
|
||||
|
||||
--color-red-bg: rgba(203, 34, 69, 0.1);
|
||||
--color-orange-bg: rgba(224, 131, 37, 0.1);
|
||||
--color-green-bg: rgba(0, 175, 92, 0.1);
|
||||
--color-blue-bg: rgba(31, 104, 192, 0.1);
|
||||
--color-purple-bg: rgba(142, 50, 243, 0.1);
|
||||
|
||||
--color-warning-bg: hsl(355, 70%, 88%);
|
||||
--color-warning-text: hsl(342, 70%, 35%);
|
||||
|
||||
@@ -275,12 +182,6 @@ html {
|
||||
--color-text-inverted: var(--color-bg);
|
||||
--color-bg-inverted: var(--color-text);
|
||||
|
||||
--color-red-bg: rgba(255, 73, 110, 0.2);
|
||||
--color-orange-bg: rgba(255, 163, 71, 0.2);
|
||||
--color-green-bg: rgba(27, 217, 106, 0.2);
|
||||
--color-blue-bg: rgba(79, 156, 255, 0.2);
|
||||
--color-purple-bg: rgba(199, 138, 255, 0.2);
|
||||
|
||||
--color-brand: var(--color-green);
|
||||
--color-brand-highlight: rgba(27, 217, 106, 0.25);
|
||||
--color-brand-shadow: rgba(27, 217, 106, 0.7);
|
||||
@@ -300,9 +201,6 @@ html {
|
||||
--color-dropdown-bg: var(--color-button-bg);
|
||||
--color-dropdown-text: var(--color-button-text);
|
||||
|
||||
--color-tooltip-bg: var(--color-button-bg);
|
||||
--color-tooltip-text: var(--color-text);
|
||||
|
||||
--color-code-bg: var(--color-button-bg);
|
||||
--color-code-text: var(--color-text-dark);
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
.normal-page {
|
||||
display: grid;
|
||||
padding: 0 0.75rem;
|
||||
padding: 0 1.5rem;
|
||||
|
||||
grid-template:
|
||||
"sidebar"
|
||||
@@ -115,7 +115,7 @@
|
||||
}
|
||||
|
||||
.normal-page__content {
|
||||
max-width: calc(80rem - 18.75rem - 0.75rem);
|
||||
max-width: calc(80rem - 18.75rem - 1.5rem);
|
||||
//overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@
|
||||
margin: 0 auto;
|
||||
max-width: 80rem;
|
||||
column-gap: 0.75rem;
|
||||
padding: 0 0.75rem;
|
||||
padding: 0 1.5rem;
|
||||
|
||||
grid-template:
|
||||
"header"
|
||||
@@ -162,7 +162,7 @@
|
||||
|
||||
.normal-page__content {
|
||||
grid-area: content;
|
||||
max-width: calc(80rem - 18.75rem - 0.75rem);
|
||||
max-width: calc(80rem - 18.75rem - 1.5rem);
|
||||
//overflow-x: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<ButtonStyled v-if="!!slots.title" :type="type">
|
||||
<button class="!w-full" @click="() => (isOpen ? close() : open())">
|
||||
<slot name="title" /><DropdownIcon
|
||||
class="ml-auto size-5 transition-transform duration-300"
|
||||
class="ml-auto size-5 text-contrast transition-transform duration-300"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
/>
|
||||
</button>
|
||||
@@ -62,6 +62,9 @@ defineOptions({
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease-in-out;
|
||||
animation: height-animate 500ms ease-in-out both;
|
||||
content-visibility: auto;
|
||||
animation-composition: replace;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
@@ -77,4 +80,13 @@ defineOptions({
|
||||
.accordion-content > div {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes height-animate {
|
||||
from {
|
||||
block-size: initial;
|
||||
}
|
||||
to {
|
||||
block-size: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
'badge flex items-center gap-1 font-semibold text-secondary ' + color + ' type--' + type
|
||||
"
|
||||
>
|
||||
<template v-if="color"> <span class="circle" /> {{ $capitalizeString(type) }}</template>
|
||||
<template v-if="color"> <span class="circle" /> {{ capitalizeString(type) }}</template>
|
||||
|
||||
<!-- User roles -->
|
||||
<template v-else-if="type === 'admin'"> <ModrinthIcon /> Modrinth Team</template>
|
||||
@@ -36,25 +36,28 @@
|
||||
<template v-else-if="type === 'closed'"> <CloseIcon /> Closed</template>
|
||||
|
||||
<!-- Other -->
|
||||
<template v-else> <span class="circle" /> {{ $capitalizeString(type) }} </template>
|
||||
<template v-else> <span class="circle" /> {{ capitalizeString(type) }} </template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { GlobeIcon, LinkIcon } from "@modrinth/assets";
|
||||
|
||||
import ModrinthIcon from "~/assets/images/logo.svg?component";
|
||||
import PlusIcon from "~/assets/images/utils/plus.svg?component";
|
||||
import ModeratorIcon from "~/assets/images/sidebar/admin.svg?component";
|
||||
import CreatorIcon from "~/assets/images/utils/box.svg?component";
|
||||
import DraftIcon from "~/assets/images/utils/file-text.svg?component";
|
||||
import CrossIcon from "~/assets/images/utils/x.svg?component";
|
||||
import ArchiveIcon from "~/assets/images/utils/archive.svg?component";
|
||||
import ProcessingIcon from "~/assets/images/utils/updated.svg?component";
|
||||
import CheckIcon from "~/assets/images/utils/check.svg?component";
|
||||
import LockIcon from "~/assets/images/utils/lock.svg?component";
|
||||
import CalendarIcon from "~/assets/images/utils/calendar.svg?component";
|
||||
import CloseIcon from "~/assets/images/utils/check-circle.svg?component";
|
||||
import {
|
||||
GlobeIcon,
|
||||
LinkIcon,
|
||||
ModrinthIcon,
|
||||
PlusIcon,
|
||||
ScaleIcon as ModeratorIcon,
|
||||
BoxIcon as CreatorIcon,
|
||||
FileTextIcon as DraftIcon,
|
||||
XIcon as CrossIcon,
|
||||
ArchiveIcon,
|
||||
UpdatedIcon as ProcessingIcon,
|
||||
CheckIcon,
|
||||
LockIcon,
|
||||
CalendarIcon,
|
||||
XCircleIcon as CloseIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { capitalizeString } from "@modrinth/utils";
|
||||
|
||||
defineProps({
|
||||
type: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<nav
|
||||
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="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="(link, index) in filteredLinks"
|
||||
@@ -11,7 +11,7 @@
|
||||
: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"
|
||||
:class="{
|
||||
'text-brand': activeIndex === index && !subpageSelected,
|
||||
'text-button-textSelected': activeIndex === index && !subpageSelected,
|
||||
'text-contrast': activeIndex === index && subpageSelected,
|
||||
}"
|
||||
>
|
||||
@@ -20,7 +20,7 @@
|
||||
</NuxtLink>
|
||||
<div
|
||||
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${
|
||||
subpageSelected ? 'bg-button-bg' : 'bg-brand-highlight'
|
||||
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'
|
||||
}`"
|
||||
:style="{
|
||||
left: sliderLeftPx,
|
||||
@@ -161,4 +161,8 @@ watch(
|
||||
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
|
||||
}
|
||||
|
||||
.card-shadow {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
<template>
|
||||
<div class="experimental-styles-within flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<ManySelect
|
||||
v-model="selectedPlatforms"
|
||||
:options="filterOptions.platform"
|
||||
@change="updateFilters"
|
||||
>
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
Platform
|
||||
<template #option="{ option }">
|
||||
{{ formatCategory(option) }}
|
||||
</template>
|
||||
</ManySelect>
|
||||
<ManySelect
|
||||
v-model="selectedGameVersions"
|
||||
:options="filterOptions.gameVersion"
|
||||
search
|
||||
@change="updateFilters"
|
||||
>
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
Game versions
|
||||
<template #footer>
|
||||
<Checkbox v-model="showSnapshots" class="mx-1" :label="`Show all versions`" />
|
||||
</template>
|
||||
</ManySelect>
|
||||
<ManySelect
|
||||
v-model="selectedChannels"
|
||||
:options="filterOptions.channel"
|
||||
@change="updateFilters"
|
||||
>
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
Channels
|
||||
<template #option="{ option }">
|
||||
{{ option === "release" ? "Release" : option === "beta" ? "Beta" : "Alpha" }}
|
||||
</template>
|
||||
</ManySelect>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1 empty:hidden">
|
||||
<button
|
||||
v-if="selectedChannels.length + selectedGameVersions.length + selectedPlatforms.length > 1"
|
||||
class="tag-list__item text-contrast transition-transform active:scale-[0.95]"
|
||||
@click="clearFilters"
|
||||
>
|
||||
<XCircleIcon />
|
||||
Clear all filters
|
||||
</button>
|
||||
<button
|
||||
v-for="channel in selectedChannels"
|
||||
:key="`remove-filter-${channel}`"
|
||||
class="tag-list__item transition-transform active:scale-[0.95]"
|
||||
:style="`--_color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'});--_bg-color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'}-highlight)`"
|
||||
@click="toggleFilter('channel', channel)"
|
||||
>
|
||||
<XIcon />
|
||||
{{ channel.slice(0, 1).toUpperCase() + channel.slice(1) }}
|
||||
</button>
|
||||
<button
|
||||
v-for="version in selectedGameVersions"
|
||||
:key="`remove-filter-${version}`"
|
||||
class="tag-list__item transition-transform active:scale-[0.95]"
|
||||
@click="toggleFilter('gameVersion', version)"
|
||||
>
|
||||
<XIcon />
|
||||
{{ version }}
|
||||
</button>
|
||||
<button
|
||||
v-for="platform in selectedPlatforms"
|
||||
:key="`remove-filter-${platform}`"
|
||||
class="tag-list__item transition-transform active:scale-[0.95]"
|
||||
:style="`--_color: var(--color-platform-${platform})`"
|
||||
@click="toggleFilter('platform', platform)"
|
||||
>
|
||||
<XIcon />
|
||||
{{ formatCategory(platform) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FilterIcon, XCircleIcon, XIcon } from "@modrinth/assets";
|
||||
import { ManySelect, Checkbox } from "@modrinth/ui";
|
||||
import { formatCategory } from "@modrinth/utils";
|
||||
import type { ModrinthVersion } from "@modrinth/utils";
|
||||
|
||||
const props = defineProps<{ versions: ModrinthVersion[] }>();
|
||||
|
||||
const emit = defineEmits(["switch-page"]);
|
||||
|
||||
const allChannels = ref(["release", "beta", "alpha"]);
|
||||
|
||||
const route = useNativeRoute();
|
||||
const router = useNativeRouter();
|
||||
|
||||
const tags = useTags();
|
||||
|
||||
const showSnapshots = ref(false);
|
||||
|
||||
type FilterType = "channel" | "gameVersion" | "platform";
|
||||
type Filter = string;
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const filters: Record<FilterType, Filter[]> = {
|
||||
channel: [],
|
||||
gameVersion: [],
|
||||
platform: [],
|
||||
};
|
||||
|
||||
const platformSet = new Set();
|
||||
const gameVersionSet = new Set();
|
||||
const channelSet = new Set();
|
||||
|
||||
for (const version of props.versions) {
|
||||
for (const loader of version.loaders) {
|
||||
platformSet.add(loader);
|
||||
}
|
||||
for (const gameVersion of version.game_versions) {
|
||||
gameVersionSet.add(gameVersion);
|
||||
}
|
||||
channelSet.add(version.version_type);
|
||||
}
|
||||
|
||||
if (channelSet.size > 0) {
|
||||
filters.channel = Array.from(channelSet) as Filter[];
|
||||
filters.channel.sort((a, b) => allChannels.value.indexOf(a) - allChannels.value.indexOf(b));
|
||||
}
|
||||
if (gameVersionSet.size > 0) {
|
||||
const gameVersions = tags.value.gameVersions.filter((x) => gameVersionSet.has(x.version));
|
||||
|
||||
filters.gameVersion = gameVersions
|
||||
.filter((x) => (showSnapshots.value ? true : x.version_type === "release"))
|
||||
.map((x) => x.version);
|
||||
}
|
||||
if (platformSet.size > 0) {
|
||||
filters.platform = Array.from(platformSet) as Filter[];
|
||||
}
|
||||
|
||||
return filters;
|
||||
});
|
||||
|
||||
const selectedChannels = ref<string[]>([]);
|
||||
const selectedGameVersions = ref<string[]>([]);
|
||||
const selectedPlatforms = ref<string[]>([]);
|
||||
|
||||
selectedChannels.value = route.query.c ? getArrayOrString(route.query.c) : [];
|
||||
selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : [];
|
||||
selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : [];
|
||||
|
||||
async function toggleFilters(type: FilterType, filters: Filter[]) {
|
||||
for (const filter of filters) {
|
||||
await toggleFilter(type, filter);
|
||||
}
|
||||
|
||||
await router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
c: selectedChannels.value,
|
||||
g: selectedGameVersions.value,
|
||||
l: selectedPlatforms.value,
|
||||
},
|
||||
});
|
||||
|
||||
emit("switch-page", 1);
|
||||
}
|
||||
|
||||
async function toggleFilter(type: FilterType, filter: Filter, skipRouter = false) {
|
||||
if (type === "channel") {
|
||||
selectedChannels.value = selectedChannels.value.includes(filter)
|
||||
? selectedChannels.value.filter((x) => x !== filter)
|
||||
: [...selectedChannels.value, filter];
|
||||
} else if (type === "gameVersion") {
|
||||
selectedGameVersions.value = selectedGameVersions.value.includes(filter)
|
||||
? selectedGameVersions.value.filter((x) => x !== filter)
|
||||
: [...selectedGameVersions.value, filter];
|
||||
} else if (type === "platform") {
|
||||
selectedPlatforms.value = selectedPlatforms.value.includes(filter)
|
||||
? selectedPlatforms.value.filter((x) => x !== filter)
|
||||
: [...selectedPlatforms.value, filter];
|
||||
}
|
||||
if (!skipRouter) {
|
||||
await updateFilters();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateFilters() {
|
||||
await router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
c: selectedChannels.value,
|
||||
g: selectedGameVersions.value,
|
||||
l: selectedPlatforms.value,
|
||||
},
|
||||
});
|
||||
|
||||
emit("switch-page", 1);
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
selectedChannels.value = [];
|
||||
selectedGameVersions.value = [];
|
||||
selectedPlatforms.value = [];
|
||||
|
||||
await router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
c: undefined,
|
||||
g: undefined,
|
||||
l: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
emit("switch-page", 1);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
toggleFilter,
|
||||
toggleFilters,
|
||||
});
|
||||
</script>
|
||||
@@ -1,46 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[min-content_auto_min-content_min-content] items-center gap-2 rounded-2xl border-[1px] border-button-bg bg-bg p-2"
|
||||
>
|
||||
<VersionChannelIndicator :channel="version.version_type" />
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h1 class="my-0 truncate text-nowrap text-base font-extrabold leading-none text-contrast">
|
||||
{{ version.version_number }}
|
||||
</h1>
|
||||
<p class="m-0 truncate text-nowrap text-xs font-semibold text-secondary">
|
||||
{{ version.name }}
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled color="brand">
|
||||
<a :href="downloadUrl" class="min-w-0" @click="emit('onDownload')">
|
||||
<DownloadIcon aria-hidden="true" /> Download
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<nuxt-link
|
||||
:to="`/project/${props.version.project_id}/version/${props.version.id}`"
|
||||
class="min-w-0"
|
||||
aria-label="Open project page"
|
||||
@click="emit('onNavigate')"
|
||||
>
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, VersionChannelIndicator } from "@modrinth/ui";
|
||||
import { DownloadIcon, ExternalIcon } from "@modrinth/assets";
|
||||
|
||||
const props = defineProps<{
|
||||
version: Version;
|
||||
}>();
|
||||
|
||||
const downloadUrl = computed(() => {
|
||||
const primary: VersionFile = props.version.files.find((x) => x.primary) || props.version.files[0];
|
||||
return primary.url;
|
||||
});
|
||||
|
||||
const emit = defineEmits(["onDownload", "onNavigate"]);
|
||||
</script>
|
||||
@@ -44,7 +44,7 @@ import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { PlusIcon, XIcon, InfoIcon } from "@modrinth/assets";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupCreated"]);
|
||||
|
||||
@@ -104,7 +104,7 @@ const modal = ref<InstanceType<typeof NewModal>>();
|
||||
|
||||
const initialSettings = ref<{ interval: number; enabled: boolean } | null>(null);
|
||||
const autoBackupEnabled = ref(false);
|
||||
const autoBackupInterval = ref(1);
|
||||
const autoBackupInterval = ref(6);
|
||||
const isLoadingSettings = ref(true);
|
||||
const isSaving = ref(false);
|
||||
|
||||
@@ -134,7 +134,7 @@ const fetchSettings = async () => {
|
||||
const settings = await props.server.backups?.getAutoBackup();
|
||||
initialSettings.value = settings as { interval: number; enabled: boolean };
|
||||
autoBackupEnabled.value = settings?.enabled ?? false;
|
||||
autoBackupInterval.value = settings?.interval || 1;
|
||||
autoBackupInterval.value = settings?.interval || 6;
|
||||
} catch (error) {
|
||||
console.error("Error fetching backup settings:", error);
|
||||
addNotification({
|
||||
|
||||
@@ -2,40 +2,61 @@
|
||||
<li
|
||||
role="button"
|
||||
data-pyro-file
|
||||
:class="containerClasses"
|
||||
: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()"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<div data-pyro-file-metadata class="flex w-full items-center gap-4 truncate">
|
||||
<div
|
||||
data-pyro-file-metadata
|
||||
class="pointer-events-none flex w-full items-center gap-4 truncate"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 items-center justify-center rounded-full bg-bg-raised p-[6px] group-hover:bg-brand-highlight group-hover:text-brand group-focus:bg-brand-highlight group-focus:text-brand"
|
||||
class="pointer-events-none flex size-8 items-center justify-center rounded-full bg-bg-raised p-[6px] group-hover:bg-brand-highlight group-hover:text-brand group-focus:bg-brand-highlight group-focus:text-brand"
|
||||
:class="isEditableFile ? 'group-active:scale-[0.8]' : ''"
|
||||
>
|
||||
<component :is="iconComponent" class="size-6" />
|
||||
</div>
|
||||
<div class="flex w-full flex-col truncate">
|
||||
<div class="pointer-events-none flex w-full flex-col truncate">
|
||||
<span
|
||||
class="w-[98%] truncate font-bold group-hover:text-contrast group-focus:text-contrast"
|
||||
>{{ name }}</span
|
||||
class="pointer-events-none w-[98%] truncate font-bold group-hover:text-contrast group-focus:text-contrast"
|
||||
>
|
||||
<span class="text-xs text-secondary group-hover:text-primary">
|
||||
{{ name }}
|
||||
</span>
|
||||
<span class="pointer-events-none text-xs text-secondary group-hover:text-primary">
|
||||
{{ subText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-pyro-file-actions class="flex w-fit flex-shrink-0 items-center gap-4 md:gap-12">
|
||||
<span class="w-[160px] text-nowrap text-right font-mono text-sm text-secondary">{{
|
||||
formattedDate
|
||||
}}</span>
|
||||
<div
|
||||
data-pyro-file-actions
|
||||
class="pointer-events-auto flex w-fit flex-shrink-0 items-center gap-4 md:gap-12"
|
||||
>
|
||||
<span class="hidden w-[160px] text-nowrap font-mono text-sm text-secondary md:flex">
|
||||
{{ formattedCreationDate }}
|
||||
</span>
|
||||
<span class="w-[160px] text-nowrap font-mono text-sm text-secondary">
|
||||
{{ formattedModifiedDate }}
|
||||
</span>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<UiServersTeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
|
||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||
<template #rename> <EditIcon /> Rename </template>
|
||||
<template #move> <RightArrowIcon /> Move </template>
|
||||
<template #download> <DownloadIcon /> Download </template>
|
||||
<template #delete> <TrashIcon /> Delete </template>
|
||||
<template #rename><EditIcon /> Rename</template>
|
||||
<template #move><RightArrowIcon /> Move</template>
|
||||
<template #download><DownloadIcon /> Download</template>
|
||||
<template #delete><TrashIcon /> Delete</template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -54,6 +75,7 @@ import {
|
||||
RightArrowIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { computed, shallowRef, ref } from "vue";
|
||||
import { renderToString } from "@vue/server-renderer";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import {
|
||||
UiServersIconsCogFolderIcon,
|
||||
@@ -70,12 +92,27 @@ interface FileItemProps {
|
||||
size?: number;
|
||||
count?: number;
|
||||
modified: number;
|
||||
created: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const props = defineProps<FileItemProps>();
|
||||
|
||||
const emit = defineEmits(["rename", "download", "delete", "move", "edit", "contextmenu"]);
|
||||
const emit = defineEmits<{
|
||||
(e: "rename", item: { name: string; type: string; path: string }): void;
|
||||
(e: "move", item: { name: string; type: string; path: string }): void;
|
||||
(
|
||||
e: "moveDirectTo",
|
||||
item: { name: string; type: string; path: string; destination: string },
|
||||
): void;
|
||||
(e: "download", item: { name: string; type: string; path: string }): void;
|
||||
(e: "delete", item: { name: string; type: string; path: string }): void;
|
||||
(e: "edit", item: { name: string; type: string; path: string }): void;
|
||||
(e: "contextmenu", x: number, y: number): void;
|
||||
}>();
|
||||
|
||||
const isDragOver = ref(false);
|
||||
const isDragging = ref(false);
|
||||
|
||||
const codeExtensions = Object.freeze([
|
||||
"json",
|
||||
@@ -114,6 +151,7 @@ const router = useRouter();
|
||||
const containerClasses = computed(() => [
|
||||
"group m-0 p-0 focus:!outline-none flex w-full select-none items-center justify-between overflow-hidden border-0 border-b border-solid border-bg-raised p-3 last:border-none hover:bg-bg-raised focus:bg-bg-raised",
|
||||
isEditableFile.value ? "cursor-pointer" : props.type === "directory" ? "cursor-pointer" : "",
|
||||
isDragOver.value ? "bg-brand-highlight" : "",
|
||||
]);
|
||||
|
||||
const fileExtension = computed(() => props.name.split(".").pop()?.toLowerCase() || "");
|
||||
@@ -161,7 +199,7 @@ const subText = computed(() => {
|
||||
return formattedSize.value;
|
||||
});
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const formattedModifiedDate = computed(() => {
|
||||
const date = new Date(props.modified * 1000);
|
||||
return `${date.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
@@ -174,6 +212,19 @@ const formattedDate = computed(() => {
|
||||
})}`;
|
||||
});
|
||||
|
||||
const formattedCreationDate = computed(() => {
|
||||
const date = new Date(props.created * 1000);
|
||||
return `${date.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "2-digit",
|
||||
})}, ${date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
})}`;
|
||||
});
|
||||
|
||||
const isEditableFile = computed(() => {
|
||||
if (props.type === "file") {
|
||||
const ext = fileExtension.value;
|
||||
@@ -226,4 +277,121 @@ const selectItem = () => {
|
||||
isNavigating.value = false;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const getDragIcon = async () => {
|
||||
let iconToUse;
|
||||
|
||||
if (props.type === "directory") {
|
||||
if (props.name === "config") {
|
||||
iconToUse = UiServersIconsCogFolderIcon;
|
||||
} else if (props.name === "world") {
|
||||
iconToUse = UiServersIconsEarthIcon;
|
||||
} else if (props.name === "resourcepacks") {
|
||||
iconToUse = PaletteIcon;
|
||||
} else {
|
||||
iconToUse = FolderOpenIcon;
|
||||
}
|
||||
} else {
|
||||
const ext = fileExtension.value;
|
||||
if (codeExtensions.includes(ext)) {
|
||||
iconToUse = UiServersIconsCodeFileIcon;
|
||||
} else if (textExtensions.includes(ext)) {
|
||||
iconToUse = UiServersIconsTextFileIcon;
|
||||
} else if (imageExtensions.includes(ext)) {
|
||||
iconToUse = UiServersIconsImageFileIcon;
|
||||
} else {
|
||||
iconToUse = FileIcon;
|
||||
}
|
||||
}
|
||||
|
||||
return await renderToString(h(iconToUse));
|
||||
};
|
||||
|
||||
const handleDragStart = async (event: DragEvent) => {
|
||||
if (!event.dataTransfer) return;
|
||||
isDragging.value = true;
|
||||
|
||||
const dragGhost = document.createElement("div");
|
||||
dragGhost.className =
|
||||
"fixed left-0 top-0 flex items-center max-w-[500px] flex-row gap-3 rounded-lg bg-bg-raised p-3 shadow-lg pointer-events-none";
|
||||
|
||||
const iconContainer = document.createElement("div");
|
||||
iconContainer.className = "flex size-6 items-center justify-center";
|
||||
|
||||
const icon = document.createElement("div");
|
||||
icon.className = "size-4";
|
||||
icon.innerHTML = await getDragIcon();
|
||||
iconContainer.appendChild(icon);
|
||||
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.className = "font-bold truncate text-contrast";
|
||||
nameSpan.textContent = props.name;
|
||||
|
||||
dragGhost.appendChild(iconContainer);
|
||||
dragGhost.appendChild(nameSpan);
|
||||
document.body.appendChild(dragGhost);
|
||||
|
||||
event.dataTransfer.setDragImage(dragGhost, 0, 0);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
document.body.removeChild(dragGhost);
|
||||
});
|
||||
|
||||
event.dataTransfer.setData(
|
||||
"application/pyro-file-move",
|
||||
JSON.stringify({
|
||||
name: props.name,
|
||||
type: props.type,
|
||||
path: props.path,
|
||||
}),
|
||||
);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
const isChildPath = (parentPath: string, childPath: string) => {
|
||||
return childPath.startsWith(parentPath + "/");
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
isDragging.value = false;
|
||||
};
|
||||
|
||||
const handleDragEnter = () => {
|
||||
if (props.type !== "directory") return;
|
||||
isDragOver.value = true;
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
if (props.type !== "directory" || !event.dataTransfer) return;
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
isDragOver.value = false;
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
isDragOver.value = false;
|
||||
if (props.type !== "directory" || !event.dataTransfer) return;
|
||||
|
||||
try {
|
||||
const dragData = JSON.parse(event.dataTransfer.getData("application/pyro-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>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
@rename="$emit('rename', item)"
|
||||
@download="$emit('download', item)"
|
||||
@move="$emit('move', item)"
|
||||
@move-direct-to="$emit('moveDirectTo', $event)"
|
||||
@edit="$emit('edit', item)"
|
||||
@contextmenu="(x, y) => $emit('contextmenu', item, x, y)"
|
||||
/>
|
||||
@@ -55,6 +56,7 @@ const emit = defineEmits<{
|
||||
(e: "edit", item: any): void;
|
||||
(e: "contextmenu", item: any, x: number, y: number): void;
|
||||
(e: "loadMore"): void;
|
||||
(e: "moveDirectTo", item: any): void;
|
||||
}>();
|
||||
|
||||
const ITEM_HEIGHT = 61;
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
:options="[
|
||||
{ id: 'normal', action: () => $emit('sort', 'default') },
|
||||
{ id: 'modified', action: () => $emit('sort', 'modified') },
|
||||
{ id: 'created', action: () => $emit('sort', 'created') },
|
||||
{ id: 'filesOnly', action: () => $emit('sort', 'filesOnly') },
|
||||
{ id: 'foldersOnly', action: () => $emit('sort', 'foldersOnly') },
|
||||
]"
|
||||
@@ -91,11 +92,12 @@
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #normal> Alphabetical </template>
|
||||
<template #modified> Date modified </template>
|
||||
<template #created> Date created </template>
|
||||
<template #filesOnly> Files only </template>
|
||||
<template #foldersOnly> Folders only </template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
<div class="mx-1 w-full text-sm sm:w-40">
|
||||
<div class="mx-1 w-full text-sm sm:w-48">
|
||||
<label for="search-folder" class="sr-only">Search folder</label>
|
||||
<div class="relative">
|
||||
<SearchIcon
|
||||
@@ -108,7 +110,7 @@
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
class="h-8 min-h-[unset] w-full border-[1px] border-solid border-button-bg bg-transparent py-2 pl-9"
|
||||
class="h-8 min-h-[unset] w-full border-[1px] border-solid border-divider bg-transparent py-2 pl-9"
|
||||
placeholder="Search..."
|
||||
@input="$emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
@@ -183,6 +185,8 @@ const sortMethodLabel = computed(() => {
|
||||
switch (props.sortMethod) {
|
||||
case "modified":
|
||||
return "Date modified";
|
||||
case "created":
|
||||
return "Date created";
|
||||
case "filesOnly":
|
||||
return "Files only";
|
||||
case "foldersOnly":
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
id="item-context-menu"
|
||||
ref="ctxRef"
|
||||
:style="{
|
||||
border: '1px solid var(--color-button-bg)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-divider)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
backgroundColor: 'var(--color-raised-bg)',
|
||||
padding: 'var(--gap-sm)',
|
||||
boxShadow: 'var(--shadow-floating)',
|
||||
@@ -31,7 +31,7 @@
|
||||
Rename
|
||||
</button>
|
||||
<button class="btn btn-transparent flex !w-full items-center" @click="$emit('move', item)">
|
||||
<ArrowBigUpDashIcon class="h-5 w-5" />
|
||||
<RightArrowIcon />
|
||||
Move
|
||||
</button>
|
||||
<button
|
||||
@@ -55,7 +55,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, ArrowBigUpDashIcon, DownloadIcon, TrashIcon } from "@modrinth/assets";
|
||||
import { EditIcon, DownloadIcon, TrashIcon, RightArrowIcon } from "@modrinth/assets";
|
||||
|
||||
interface FileItem {
|
||||
type: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="`Creating a ${type}`">
|
||||
<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>
|
||||
@@ -18,7 +18,7 @@
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error" type="submit">
|
||||
<PlusIcon class="h-5 w-5" />
|
||||
Create
|
||||
Create {{ displayType }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
@@ -46,6 +46,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const modal = ref<typeof NewModal>();
|
||||
const displayType = computed(() => (props.type === "directory" ? "folder" : props.type));
|
||||
const createInput = ref<HTMLInputElement | null>(null);
|
||||
const itemName = ref("");
|
||||
const submitted = ref(false);
|
||||
|
||||
@@ -6,15 +6,15 @@
|
||||
>
|
||||
<nav
|
||||
aria-label="Breadcrumb navigation"
|
||||
class="m-0 flex list-none items-center p-0 text-contrast"
|
||||
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
|
||||
>
|
||||
<ol class="m-0 flex list-none items-center p-0">
|
||||
<li class="-ml-1">
|
||||
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
|
||||
<li class="-ml-1 flex-shrink-0">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="'Back to home'"
|
||||
type="button"
|
||||
class="grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
class="mr-2 grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
@click="goHome"
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -1,159 +1,178 @@
|
||||
<template>
|
||||
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center bg-bg-raised">
|
||||
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center">
|
||||
<div
|
||||
ref="container"
|
||||
class="relative w-full flex-grow overflow-hidden bg-bg-raised"
|
||||
class="relative w-full flex-grow cursor-grab overflow-hidden rounded-b-2xl bg-black active:cursor-grabbing"
|
||||
@mousedown="startPan"
|
||||
@mousemove="pan"
|
||||
@mouseup="endPan"
|
||||
@mouseleave="endPan"
|
||||
@mousemove="handlePan"
|
||||
@mouseup="stopPan"
|
||||
@mouseleave="stopPan"
|
||||
@wheel.prevent="handleWheel"
|
||||
>
|
||||
<UiServersPyroLoading v-if="loading" />
|
||||
<div v-if="error" class="flex h-full w-full flex-col items-center justify-center gap-8">
|
||||
<svg
|
||||
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"
|
||||
class="size-12"
|
||||
>
|
||||
<path d="M4 13c3.5-2 8-2 10 2a5.5 5.5 0 0 1 8 5" />
|
||||
<path
|
||||
d="M5.15 17.89c5.52-1.52 8.65-6.89 7-12C11.55 4 11.5 2 13 2c3.22 0 5 5.5 5 8 0 6.5-4.2 12-10.49 12C5.11 22 2 22 2 20c0-1.5 1.14-1.55 3.15-2.11Z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="m-0">Invalid or empty image file.</p>
|
||||
<UiServersPyroLoading v-if="state.isLoading" />
|
||||
<div
|
||||
v-if="state.hasError"
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-8"
|
||||
>
|
||||
<UiServersIconsPanelErrorIcon />
|
||||
<p class="m-0">{{ state.errorMessage || "Invalid or empty image file." }}</p>
|
||||
</div>
|
||||
<img
|
||||
v-show="!loading && !error"
|
||||
ref="image"
|
||||
:src="imageUrl"
|
||||
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="{
|
||||
transform: `translate(-50%, -50%) scale(${scale}) translate(${translateX}px, ${translateY}px)`,
|
||||
transition: isPanning ? 'none' : 'transform 0.3s ease-out',
|
||||
}"
|
||||
:style="imageStyle"
|
||||
alt="Viewed image"
|
||||
@load="onImageLoad"
|
||||
@error="onImageError"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!error"
|
||||
class="absolute bottom-0 mb-2 flex w-fit justify-center space-x-4 rounded-xl bg-bg p-2"
|
||||
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"
|
||||
>
|
||||
<Button icon-only transparent @click="zoomIn">
|
||||
<ZoomInIcon />
|
||||
</Button>
|
||||
<Button icon-only transparent @click="resetZoom">
|
||||
<HomeIcon />
|
||||
</Button>
|
||||
<Button icon-only transparent @click="zoomOut">
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
<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>
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { HomeIcon, ZoomInIcon, ZoomOutIcon } from "@modrinth/assets";
|
||||
import { Button } from "@modrinth/ui";
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { ZoomInIcon, ZoomOutIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps({
|
||||
imageBlob: {
|
||||
type: Blob,
|
||||
required: true,
|
||||
},
|
||||
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 container = ref(null);
|
||||
const image = ref(null);
|
||||
const scale = ref(1);
|
||||
const translateX = ref(0);
|
||||
const translateY = ref(0);
|
||||
const isPanning = ref(false);
|
||||
const startX = ref(0);
|
||||
const startY = ref(0);
|
||||
const imageUrl = ref("");
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
const imageRef = ref<HTMLImageElement | null>(null);
|
||||
const container = ref<HTMLElement | null>(null);
|
||||
const imageObjectUrl = ref("");
|
||||
const rafId = ref(0);
|
||||
|
||||
const createImageUrl = (blob) => {
|
||||
if (imageUrl.value) {
|
||||
URL.revokeObjectURL(imageUrl.value);
|
||||
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;
|
||||
}
|
||||
imageUrl.value = URL.createObjectURL(blob);
|
||||
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) {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
createImageUrl(newBlob);
|
||||
}
|
||||
if (!newBlob) return;
|
||||
state.value.isLoading = true;
|
||||
state.value.hasError = false;
|
||||
updateImageUrl(newBlob);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.imageBlob) {
|
||||
createImageUrl(props.imageBlob);
|
||||
}
|
||||
if (props.imageBlob) updateImageUrl(props.imageBlob);
|
||||
});
|
||||
|
||||
const onImageLoad = () => {
|
||||
loading.value = false;
|
||||
resetZoom();
|
||||
};
|
||||
|
||||
const onImageError = () => {
|
||||
loading.value = false;
|
||||
error.value = true;
|
||||
};
|
||||
|
||||
const zoomIn = () => {
|
||||
scale.value = Math.min(scale.value * 1.2, 5);
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
scale.value = Math.max(scale.value / 1.2, 0.1);
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
scale.value = 0.5;
|
||||
translateX.value = 0;
|
||||
translateY.value = 0;
|
||||
};
|
||||
|
||||
const startPan = (e) => {
|
||||
isPanning.value = true;
|
||||
startX.value = e.clientX - translateX.value;
|
||||
startY.value = e.clientY - translateY.value;
|
||||
};
|
||||
|
||||
const pan = (e) => {
|
||||
if (isPanning.value) {
|
||||
translateX.value = e.clientX - startX.value;
|
||||
translateY.value = e.clientY - startY.value;
|
||||
}
|
||||
};
|
||||
|
||||
const endPan = () => {
|
||||
isPanning.value = false;
|
||||
};
|
||||
|
||||
const handleWheel = (e) => {
|
||||
const delta = (e.deltaY * -0.01) / 10;
|
||||
const newScale = Math.max(0.1, Math.min(scale.value + delta, 5));
|
||||
|
||||
scale.value = newScale;
|
||||
};
|
||||
onUnmounted(() => {
|
||||
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value);
|
||||
cancelAnimationFrame(rafId.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
14
apps/frontend/src/components/ui/servers/FilesLabelBar.vue
Normal file
14
apps/frontend/src/components/ui/servers/FilesLabelBar.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="flex w-full select-none flex-row items-center border-0 border-b border-solid border-bg-raised px-3 py-2 text-xs font-bold uppercase"
|
||||
>
|
||||
<div class="min-w-[48px]"></div>
|
||||
<span class="flex w-full">Name</span>
|
||||
<div class="flex shrink-0 gap-4 text-right md:gap-12">
|
||||
<span class="hidden min-w-[160px] md:block">Created</span>
|
||||
<span class="mr-4 min-w-[160px]">Modified</span>
|
||||
<div class="min-w-[36px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,7 +8,7 @@
|
||||
autofocus
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
placeholder="e.g. mods/modname"
|
||||
placeholder="e.g. /mods/modname"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowBigUpDashIcon, XIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ref, nextTick } from "vue";
|
||||
import { ref, nextTick, computed } from "vue";
|
||||
|
||||
const destinationInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -55,11 +55,12 @@ const emit = defineEmits<{
|
||||
const modal = ref<typeof NewModal>();
|
||||
const destination = ref("");
|
||||
const newpath = computed(() => {
|
||||
return destination.value.replace("//", "/");
|
||||
const path = destination.value.replace("//", "/");
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit("move", destination.value);
|
||||
emit("move", newpath.value);
|
||||
hide();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,52 +1,60 @@
|
||||
<template>
|
||||
<div
|
||||
v-for="loader in loaders"
|
||||
:key="loader.name"
|
||||
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
|
||||
<div
|
||||
v-for="loader in vanillaLoaders"
|
||||
:key="loader.name"
|
||||
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
|
||||
>
|
||||
<UiServersLoaderSelectorCard
|
||||
:loader="loader"
|
||||
:is-current="isCurrentLoader(loader.name)"
|
||||
:loader-version="data.loader_version"
|
||||
:current-loader="data.loader"
|
||||
@select="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h2 class="mb-2 px-2 text-lg font-bold text-contrast">Mod loaders</h2>
|
||||
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
|
||||
<div
|
||||
class="grid size-10 place-content-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
:class="isCurrentLoader(loader.name) ? '[&&]:bg-bg-green' : ''"
|
||||
v-for="loader in modLoaders"
|
||||
:key="loader.name"
|
||||
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
|
||||
>
|
||||
<UiServersIconsLoaderIcon
|
||||
:loader="loader.name"
|
||||
class="[&&]:size-6"
|
||||
:class="isCurrentLoader(loader.name) ? 'text-brand' : ''"
|
||||
<UiServersLoaderSelectorCard
|
||||
:loader="loader"
|
||||
:is-current="isCurrentLoader(loader.name)"
|
||||
:loader-version="data.loader_version"
|
||||
:current-loader="data.loader"
|
||||
@select="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h1 class="m-0 text-xl font-bold leading-none text-contrast">
|
||||
{{ loader.displayName }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="isCurrentLoader(loader.name)"
|
||||
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
|
||||
>
|
||||
<CheckIcon class="h-4 w-4" />
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="isCurrentLoader(loader.name)" class="m-0 text-xs text-secondary">
|
||||
{{ data.loader_version }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h2 class="mb-2 px-2 text-lg font-bold text-contrast">Plugin loaders</h2>
|
||||
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
|
||||
<div
|
||||
v-for="loader in pluginLoaders"
|
||||
:key="loader.name"
|
||||
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
|
||||
>
|
||||
<UiServersLoaderSelectorCard
|
||||
:loader="loader"
|
||||
:is-current="isCurrentLoader(loader.name)"
|
||||
:loader-version="data.loader_version"
|
||||
:current-loader="data.loader"
|
||||
@select="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled>
|
||||
<button @click="selectLoader(loader.name)">
|
||||
<DownloadIcon class="h-5 w-5" />
|
||||
{{ isCurrentLoader(loader.name) ? "Reinstall" : "Install" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DownloadIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps<{
|
||||
data: {
|
||||
loader: string | null;
|
||||
@@ -58,14 +66,20 @@ const emit = defineEmits<{
|
||||
(e: "selectLoader", loader: string): void;
|
||||
}>();
|
||||
|
||||
const loaders = [
|
||||
{ name: "Vanilla" as const, displayName: "Vanilla" },
|
||||
const vanillaLoaders = [{ name: "Vanilla" as const, displayName: "Vanilla" }];
|
||||
|
||||
const modLoaders = [
|
||||
{ name: "Fabric" as const, displayName: "Fabric" },
|
||||
{ name: "Quilt" as const, displayName: "Quilt" },
|
||||
{ name: "Forge" as const, displayName: "Forge" },
|
||||
{ name: "NeoForge" as const, displayName: "NeoForge" },
|
||||
];
|
||||
|
||||
const pluginLoaders = [
|
||||
{ name: "Paper" as const, displayName: "Paper" },
|
||||
{ name: "Purpur" as const, displayName: "Purpur" },
|
||||
];
|
||||
|
||||
const isCurrentLoader = (loaderName: string) => {
|
||||
return props.data.loader?.toLowerCase() === loaderName.toLowerCase();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="grid size-10 place-content-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
:class="isCurrentLoader ? '[&&]:bg-bg-green' : ''"
|
||||
>
|
||||
<UiServersIconsLoaderIcon
|
||||
:loader="loader.name"
|
||||
class="[&&]:size-6"
|
||||
:class="isCurrentLoader ? 'text-brand' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h1 class="m-0 text-xl font-bold leading-none text-contrast">
|
||||
{{ loader.displayName }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="isCurrentLoader"
|
||||
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
|
||||
>
|
||||
<CheckIcon class="h-4 w-4" />
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="isCurrentLoader" class="m-0 text-xs text-secondary">
|
||||
{{ loaderVersion }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled>
|
||||
<button @click="onSelect">
|
||||
<DownloadIcon class="h-5 w-5" />
|
||||
{{ isCurrentLoader ? "Reinstall" : "Install" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DownloadIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
|
||||
interface LoaderInfo {
|
||||
name: "Vanilla" | "Fabric" | "Forge" | "Quilt" | "Paper" | "NeoForge" | "Purpur";
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
loader: LoaderInfo;
|
||||
currentLoader: string | null;
|
||||
loaderVersion: string | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "select", loader: string): void;
|
||||
}>();
|
||||
|
||||
const isCurrentLoader = computed(() => {
|
||||
return props.currentLoader?.toLowerCase() === props.loader.name.toLowerCase();
|
||||
});
|
||||
|
||||
const onSelect = () => {
|
||||
emit("select", props.loader.name);
|
||||
};
|
||||
</script>
|
||||
@@ -136,15 +136,18 @@
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon } from "@modrinth/assets";
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
import { usePyroConsole } from "~/store/console.ts";
|
||||
|
||||
const { $cosmetics } = useNuxtApp();
|
||||
const cosmetics = $cosmetics;
|
||||
|
||||
const props = defineProps<{
|
||||
consoleOutput: string[];
|
||||
fullScreen: boolean;
|
||||
}>();
|
||||
|
||||
const pyroConsole = usePyroConsole();
|
||||
const consoleOutput = pyroConsole.output;
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null);
|
||||
const itemHeights = ref<number[]>([]);
|
||||
const averageItemHeight = ref(36);
|
||||
@@ -170,7 +173,7 @@ const handleScrollEvent = () => {
|
||||
const totalHeight = computed(
|
||||
() =>
|
||||
itemHeights.value.reduce((sum, height) => sum + height, 0) ||
|
||||
props.consoleOutput.length * averageItemHeight.value,
|
||||
consoleOutput.value.length * averageItemHeight.value,
|
||||
);
|
||||
|
||||
watch(totalHeight, () => {
|
||||
@@ -223,7 +226,7 @@ const visibleStartIndex = computed(() => {
|
||||
let index = 0;
|
||||
let offset = 0;
|
||||
while (
|
||||
index < props.consoleOutput.length &&
|
||||
index < consoleOutput.value.length &&
|
||||
offset < scrollTop.value - bufferSize * averageItemHeight.value
|
||||
) {
|
||||
offset += itemHeights.value[index] || averageItemHeight.value;
|
||||
@@ -236,17 +239,17 @@ const visibleEndIndex = computed(() => {
|
||||
let index = visibleStartIndex.value;
|
||||
let offset = getItemOffset(index);
|
||||
while (
|
||||
index < props.consoleOutput.length &&
|
||||
index < consoleOutput.value.length &&
|
||||
offset < scrollTop.value + clientHeight.value + bufferSize * averageItemHeight.value
|
||||
) {
|
||||
offset += itemHeights.value[index] || averageItemHeight.value;
|
||||
index++;
|
||||
}
|
||||
return Math.min(props.consoleOutput.length - 1, index);
|
||||
return Math.min(consoleOutput.value.length - 1, index);
|
||||
});
|
||||
|
||||
const visibleItems = computed(() =>
|
||||
props.consoleOutput.slice(visibleStartIndex.value, visibleEndIndex.value + 1),
|
||||
consoleOutput.value.slice(visibleStartIndex.value, visibleEndIndex.value + 1),
|
||||
);
|
||||
|
||||
const offsetY = computed(() => getItemOffset(visibleStartIndex.value));
|
||||
@@ -280,7 +283,7 @@ const updateItemHeights = async () => {
|
||||
const index = visibleStartIndex.value + idx;
|
||||
const height = el.getBoundingClientRect().height;
|
||||
itemHeights.value[index] = height;
|
||||
const content = props.consoleOutput[index];
|
||||
const content = consoleOutput.value[index];
|
||||
if (content) {
|
||||
cachedHeights.value.set(content, height);
|
||||
}
|
||||
@@ -457,7 +460,7 @@ const initializeTerminal = async () => {
|
||||
|
||||
updateClientHeight();
|
||||
|
||||
const initialHeights = props.consoleOutput.map(
|
||||
const initialHeights = consoleOutput.value.map(
|
||||
(content) => cachedHeights.value.get(content) || averageItemHeight.value,
|
||||
);
|
||||
itemHeights.value = initialHeights;
|
||||
@@ -487,7 +490,7 @@ onUnmounted(() => {
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.consoleOutput,
|
||||
() => consoleOutput.value,
|
||||
async (newOutput) => {
|
||||
const newItemsCount = newOutput.length - itemHeights.value.length;
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</div>
|
||||
<div
|
||||
class="border-0 border-b border-solid"
|
||||
:class="danger ? 'border-[#cb2245] dark:border-[#612d38]' : 'border-button-bg'"
|
||||
:class="danger ? 'border-[#cb2245] dark:border-[#612d38]' : 'border-divider'"
|
||||
></div>
|
||||
<div class="mt-2 h-full w-full overflow-auto px-6">
|
||||
<slot />
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
<template>
|
||||
<NuxtLink class="contents" :to="`/servers/manage/${props.server_id}`">
|
||||
<NuxtLink
|
||||
class="contents"
|
||||
:to="status === 'suspended' ? '' : `/servers/manage/${props.server_id}`"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer flex-row items-center overflow-x-hidden rounded-3xl bg-bg-raised p-4 transition-transform duration-100 active:scale-95"
|
||||
v-tooltip="
|
||||
status === 'suspended'
|
||||
? suspension_reason === 'upgrading'
|
||||
? 'This server is being transferred to a new node. It will be unavailable until this process finishes.'
|
||||
: 'This server has been suspended. Please visit your billing settings or contact Modrinth Support for more information.'
|
||||
: ''
|
||||
"
|
||||
class="flex cursor-pointer flex-row items-center overflow-x-hidden rounded-3xl bg-bg-raised p-4 transition-transform duration-100"
|
||||
:class="status === 'suspended' ? '!rounded-b-none opacity-75' : 'active:scale-95'"
|
||||
data-pyro-server-listing
|
||||
:data-pyro-server-listing-id="server_id"
|
||||
>
|
||||
<UiServersServerIcon :image="image" />
|
||||
<UiServersServerIcon v-if="status !== 'suspended'" :image="image" />
|
||||
<div
|
||||
v-else
|
||||
class="bg-bg-secondary flex size-24 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<LockIcon class="size-20 text-secondary" />
|
||||
</div>
|
||||
<div class="ml-8 flex flex-col gap-2.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 text-xl font-bold text-contrast">{{ name }}</h2>
|
||||
@@ -36,11 +53,26 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="status === 'suspended' && suspension_reason === 'upgrading'"
|
||||
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-blue p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<UiServersPanelSpinner />
|
||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended'"
|
||||
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<UiServersIconsPanelErrorIcon class="!size-5" />
|
||||
Your server has been suspended due to a billing issue. Please visit your billing settings or
|
||||
contact Modrinth Support for more information.
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon } from "@modrinth/assets";
|
||||
import { ChevronRightIcon, LockIcon } from "@modrinth/assets";
|
||||
import type { Project, Server } from "~/types/servers";
|
||||
|
||||
const props = defineProps<Partial<Server>>();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="subdomain"
|
||||
v-tooltip="'Copy subdomain'"
|
||||
v-tooltip="'Copy custom URL'"
|
||||
class="flex min-w-0 flex-row items-center gap-4 truncate hover:cursor-pointer"
|
||||
>
|
||||
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
v-if="isOpen"
|
||||
ref="menuRef"
|
||||
data-pyro-telepopover-root
|
||||
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-button-bg bg-bg-raised p-2 shadow-lg"
|
||||
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-divider bg-bg-raised p-2 shadow-lg"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
@@ -272,7 +272,7 @@ const handleItemClick = (option: Option, index: number) => {
|
||||
|
||||
const handleMouseOver = (index: number) => {
|
||||
selectedIndex.value = index;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
menuItemsRef.value[selectedIndex.value].focus?.();
|
||||
};
|
||||
|
||||
// Scrolling is disabled for keyboard navigation
|
||||
@@ -295,7 +295,7 @@ const enableBodyScroll = () => {
|
||||
|
||||
const focusFirstMenuItem = () => {
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
menuItemsRef.value[0].focus();
|
||||
menuItemsRef.value[0].focus?.();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -312,26 +312,26 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
menuItemsRef.value[selectedIndex.value].focus?.();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
menuItemsRef.value[selectedIndex.value].focus?.();
|
||||
break;
|
||||
case "Home":
|
||||
event.preventDefault();
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
selectedIndex.value = 0;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
menuItemsRef.value[selectedIndex.value].focus?.();
|
||||
}
|
||||
break;
|
||||
case "End":
|
||||
event.preventDefault();
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
selectedIndex.value = filteredOptions.value.length - 1;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
menuItemsRef.value[selectedIndex.value].focus?.();
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
@@ -344,7 +344,7 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
triggerRef.value?.focus();
|
||||
triggerRef.value?.focus?.();
|
||||
break;
|
||||
case "Tab":
|
||||
event.preventDefault();
|
||||
@@ -355,7 +355,7 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
} else {
|
||||
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length;
|
||||
}
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
menuItemsRef.value[selectedIndex.value].focus?.();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
@@ -366,7 +366,7 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
);
|
||||
if (matchIndex !== -1) {
|
||||
selectedIndex.value = matchIndex;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
menuItemsRef.value[selectedIndex.value].focus?.();
|
||||
}
|
||||
if (typeAheadTimeout.value) {
|
||||
clearTimeout(typeAheadTimeout.value);
|
||||
|
||||
@@ -153,6 +153,65 @@
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="loader === 'Purpur'"
|
||||
xml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="1.5"
|
||||
clip-rule="evenodd"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<defs>
|
||||
<path
|
||||
id="purpur"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.68"
|
||||
d="m264 41.95 8-4v8l-8 4v-8Z"
|
||||
></path>
|
||||
</defs>
|
||||
<path fill="none" d="M0 0h24v24H0z"></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.77"
|
||||
d="m264 29.95-8 4 8 4.42 8-4.42-8-4Z"
|
||||
transform="matrix(1.125 0 0 1.1372 -285 -31.69)"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.77"
|
||||
d="m272 38.37-8 4.42-8-4.42"
|
||||
transform="matrix(1.125 0 0 1.1372 -285 -31.69)"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.77"
|
||||
d="m260 31.95 8 4.21V45"
|
||||
transform="matrix(1.125 0 0 1.1372 -285 -31.69)"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.77"
|
||||
d="M260 45v-8.84l8-4.21"
|
||||
transform="matrix(1.125 0 0 1.1372 -285 -31.69)"
|
||||
></path>
|
||||
<use
|
||||
xlink:href="#purpur"
|
||||
stroke-width="1.68"
|
||||
transform="matrix(1.125 0 0 1.2569 -285 -40.78)"
|
||||
></use>
|
||||
<use
|
||||
xlink:href="#purpur"
|
||||
stroke-width="1.68"
|
||||
transform="matrix(-1.125 0 0 1.2569 309 -40.78)"
|
||||
></use>
|
||||
</svg>
|
||||
<svg v-else-if="loader === 'Vanilla'" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
@@ -165,8 +224,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LoaderIcon } from "@modrinth/assets";
|
||||
import type { Loaders } from "~/types/servers";
|
||||
|
||||
defineProps<{
|
||||
loader: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
|
||||
loader: Loaders;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
}"
|
||||
>
|
||||
<template v-if="members[message.author_id]">
|
||||
<ConditionalNuxtLink
|
||||
<AutoLink
|
||||
class="message__icon"
|
||||
:is-link="!noLinks"
|
||||
:to="`/user/${members[message.author_id].username}`"
|
||||
:to="noLinks ? '' : `/user/${members[message.author_id].username}`"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@@ -21,19 +20,16 @@
|
||||
circle
|
||||
:raised="raised"
|
||||
/>
|
||||
</ConditionalNuxtLink>
|
||||
</AutoLink>
|
||||
<span :class="`message__author role-${members[message.author_id].role}`">
|
||||
<LockIcon
|
||||
v-if="message.body.private"
|
||||
v-tooltip="'Only visible to moderators'"
|
||||
class="private-icon"
|
||||
/>
|
||||
<ConditionalNuxtLink
|
||||
:is-link="!noLinks"
|
||||
:to="`/user/${members[message.author_id].username}`"
|
||||
>
|
||||
<AutoLink :to="noLinks ? '' : `/user/${members[message.author_id].username}`">
|
||||
{{ members[message.author_id].username }}
|
||||
</ConditionalNuxtLink>
|
||||
</AutoLink>
|
||||
<ScaleIcon v-if="members[message.author_id].role === 'moderator'" v-tooltip="'Moderator'" />
|
||||
<ModrinthIcon
|
||||
v-else-if="members[message.author_id].role === 'admin'"
|
||||
@@ -107,7 +103,7 @@ import {
|
||||
ModrinthIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { OverflowMenu, ConditionalNuxtLink } from "@modrinth/ui";
|
||||
import { AutoLink, OverflowMenu } from "@modrinth/ui";
|
||||
import { renderString } from "@modrinth/utils";
|
||||
import Avatar from "~/components/ui/Avatar.vue";
|
||||
import Badge from "~/components/ui/Badge.vue";
|
||||
@@ -287,7 +283,7 @@ a:active + .message__author a,
|
||||
}
|
||||
|
||||
.moderation-color,
|
||||
role-moderator {
|
||||
.role-moderator {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
// Feature toggles
|
||||
projectTypesPrimaryNav: false,
|
||||
hidePlusPromoInUserMenu: false,
|
||||
oldProjectCards: true,
|
||||
newProjectCards: false,
|
||||
projectBackground: false,
|
||||
searchBackground: false,
|
||||
// advancedRendering: true,
|
||||
// externalLinksNewTab: true,
|
||||
// notUsingBlockers: false,
|
||||
|
||||
@@ -213,6 +213,7 @@ interface Backup {
|
||||
name: string;
|
||||
created_at: string;
|
||||
ongoing: boolean;
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
interface AutoBackupSettings {
|
||||
@@ -225,6 +226,23 @@ interface JWTAuth {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface DirectoryItem {
|
||||
name: string;
|
||||
type: "directory" | "file";
|
||||
count?: number;
|
||||
modified: number;
|
||||
created: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface DirectoryResponse {
|
||||
items: DirectoryItem[];
|
||||
total: number;
|
||||
current?: number;
|
||||
}
|
||||
|
||||
type ContentType = "Mod" | "Plugin";
|
||||
|
||||
const constructServerProperties = (properties: any): string => {
|
||||
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`;
|
||||
|
||||
@@ -483,13 +501,16 @@ const setMotd = async (motd: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ------------------ MODS ------------------ //
|
||||
// ------------------ CONTENT ------------------ //
|
||||
|
||||
const installMod = async (projectId: string, versionId: string) => {
|
||||
const installContent = async (contentType: ContentType, projectId: string, versionId: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods`, {
|
||||
method: "POST",
|
||||
body: { rinth_ids: { project_id: projectId, version_id: versionId } },
|
||||
body: {
|
||||
install_as: contentType,
|
||||
rinth_ids: { project_id: projectId, version_id: versionId },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error installing mod:", error);
|
||||
@@ -497,12 +518,13 @@ const installMod = async (projectId: string, versionId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const removeMod = async (modId: string) => {
|
||||
const removeContent = async (contentType: ContentType, contentId: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/deleteMod`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
path: modId,
|
||||
install_as: contentType,
|
||||
path: contentId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -511,11 +533,15 @@ const removeMod = async (modId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const reinstallMod = async (modId: string, versionId: string) => {
|
||||
const reinstallContent = async (
|
||||
contentType: ContentType,
|
||||
contentId: string,
|
||||
newContentId: string,
|
||||
) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods/${modId}`, {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods/${contentId}`, {
|
||||
method: "PUT",
|
||||
body: { version_id: versionId },
|
||||
body: { install_as: contentType, version_id: newContentId },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error reinstalling mod:", error);
|
||||
@@ -527,10 +553,11 @@ const reinstallMod = async (modId: string, versionId: string) => {
|
||||
|
||||
const createBackup = async (backupName: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups`, {
|
||||
const response = (await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups`, {
|
||||
method: "POST",
|
||||
body: { name: backupName },
|
||||
});
|
||||
})) as { id: string };
|
||||
return response.id;
|
||||
} catch (error) {
|
||||
console.error("Error creating backup:", error);
|
||||
throw error;
|
||||
@@ -604,6 +631,34 @@ const getAutoBackup = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const lockBackup = async (backupId: string) => {
|
||||
try {
|
||||
return await PyroFetch(
|
||||
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error locking backup:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const unlockBackup = async (backupId: string) => {
|
||||
try {
|
||||
return await PyroFetch(
|
||||
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error locking backup:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ------------------ NETWORK ------------------ //
|
||||
|
||||
const reserveAllocation = async (name: string): Promise<Allocation> => {
|
||||
@@ -698,6 +753,8 @@ const retryWithAuth = async (requestFn: () => Promise<any>) => {
|
||||
await internalServerRefrence.value.refresh(["fs"]);
|
||||
return await requestFn();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -723,21 +780,74 @@ const createFileOrFolder = (path: string, type: "file" | "directory") => {
|
||||
};
|
||||
|
||||
const uploadFile = (path: string, file: File) => {
|
||||
// eslint-disable-next-line require-await
|
||||
return retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
return await PyroFetch(`/create?path=${encodedPath}&type=file`, {
|
||||
method: "POST",
|
||||
contentType: "application/octet-stream",
|
||||
body: file,
|
||||
override: internalServerRefrence.value.fs.auth,
|
||||
const progressSubject = new EventTarget();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const uploadPromise = new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const progress = (e.loaded / e.total) * 100;
|
||||
progressSubject.dispatchEvent(
|
||||
new CustomEvent("progress", {
|
||||
detail: {
|
||||
loaded: e.loaded,
|
||||
total: e.total,
|
||||
progress,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.response);
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error("Upload failed"));
|
||||
xhr.onabort = () => reject(new Error("Upload cancelled"));
|
||||
|
||||
xhr.open(
|
||||
"POST",
|
||||
`https://${internalServerRefrence.value.fs.auth.url}/create?path=${encodedPath}&type=file`,
|
||||
);
|
||||
xhr.setRequestHeader("Authorization", `Bearer ${internalServerRefrence.value.fs.auth.token}`);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
xhr.send(file);
|
||||
|
||||
abortController.signal.addEventListener("abort", () => {
|
||||
xhr.abort();
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
promise: uploadPromise,
|
||||
onProgress: (
|
||||
callback: (progress: { loaded: number; total: number; progress: number }) => void,
|
||||
) => {
|
||||
progressSubject.addEventListener("progress", ((e: CustomEvent) => {
|
||||
callback(e.detail);
|
||||
}) as EventListener);
|
||||
},
|
||||
cancel: () => {
|
||||
abortController.abort();
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const renameFileOrFolder = (path: string, name: string) => {
|
||||
const pathName = path.split("/").slice(0, -1).join("/") + "/" + name;
|
||||
return retryWithAuth(async () => {
|
||||
return await PyroFetch(`/move`, {
|
||||
await PyroFetch(`/move`, {
|
||||
method: "POST",
|
||||
override: internalServerRefrence.value.fs.auth,
|
||||
body: {
|
||||
@@ -745,6 +855,7 @@ const renameFileOrFolder = (path: string, name: string) => {
|
||||
destination: pathName,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -858,7 +969,7 @@ const modules: any = {
|
||||
setMotd,
|
||||
fetchConfigFile,
|
||||
},
|
||||
mods: {
|
||||
content: {
|
||||
get: async (serverId: string) => {
|
||||
try {
|
||||
const mods = await PyroFetch<Mod[]>(`servers/${serverId}/mods`);
|
||||
@@ -873,9 +984,9 @@ const modules: any = {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
install: installMod,
|
||||
remove: removeMod,
|
||||
reinstall: reinstallMod,
|
||||
install: installContent,
|
||||
remove: removeContent,
|
||||
reinstall: reinstallContent,
|
||||
},
|
||||
backups: {
|
||||
get: async (serverId: string) => {
|
||||
@@ -893,6 +1004,8 @@ const modules: any = {
|
||||
download: downloadBackup,
|
||||
updateAutoBackup,
|
||||
getAutoBackup,
|
||||
lock: lockBackup,
|
||||
unlock: unlockBackup,
|
||||
},
|
||||
network: {
|
||||
get: async (serverId: string) => {
|
||||
@@ -1018,9 +1131,9 @@ type GeneralFunctions = {
|
||||
fetchConfigFile: (fileName: string) => Promise<any>;
|
||||
};
|
||||
|
||||
type ModFunctions = {
|
||||
type ContentFunctions = {
|
||||
/**
|
||||
* INTERNAL: Gets the mods of a server.
|
||||
* INTERNAL: Gets the list content of a server.
|
||||
* @param serverId - The ID of the server.
|
||||
* @returns
|
||||
*/
|
||||
@@ -1028,23 +1141,26 @@ type ModFunctions = {
|
||||
|
||||
/**
|
||||
* Installs a mod to a server.
|
||||
* @param contentType - The type of content to install.
|
||||
* @param projectId - The ID of the project.
|
||||
* @param versionId - The ID of the version.
|
||||
*/
|
||||
install: (projectId: string, versionId: string) => Promise<void>;
|
||||
install: (contentType: ContentType, projectId: string, versionId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Removes a mod from a server.
|
||||
* @param modId - The ID of the mod.
|
||||
* @param contentType - The type of content to remove.
|
||||
* @param contentId - The ID of the content.
|
||||
*/
|
||||
remove: (modId: string) => Promise<void>;
|
||||
remove: (contentType: ContentType, contentId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Reinstalls a mod to a server.
|
||||
* @param modId - The ID of the mod.
|
||||
* @param versionId - The ID of the version.
|
||||
* @param contentType - The type of content to reinstall.
|
||||
* @param contentId - The ID of the content.
|
||||
* @param newContentId - The ID of the new version.
|
||||
*/
|
||||
reinstall: (modId: string, versionId: string) => Promise<void>;
|
||||
reinstall: (contentType: ContentType, contentId: string, newContentId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type BackupFunctions = {
|
||||
@@ -1058,6 +1174,7 @@ type BackupFunctions = {
|
||||
/**
|
||||
* Creates a new backup for the server.
|
||||
* @param backupName - The name of the backup.
|
||||
* @returns The ID of the backup.
|
||||
*/
|
||||
create: (backupName: string) => Promise<void>;
|
||||
|
||||
@@ -1098,6 +1215,18 @@ type BackupFunctions = {
|
||||
* Gets the auto backup settings of the server.
|
||||
*/
|
||||
getAutoBackup: () => Promise<AutoBackupSettings>;
|
||||
|
||||
/**
|
||||
* Locks a backup for the server.
|
||||
* @param backupId - The ID of the backup.
|
||||
*/
|
||||
lock: (backupId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Unlocks a backup for the server.
|
||||
* @param backupId - The ID of the backup.
|
||||
*/
|
||||
unlock: (backupId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type NetworkFunctions = {
|
||||
@@ -1177,7 +1306,7 @@ type FSFunctions = {
|
||||
* @param pageSize - The page size to list.
|
||||
* @returns
|
||||
*/
|
||||
listDirContents: (path: string, page: number, pageSize: number) => Promise<any>;
|
||||
listDirContents: (path: string, page: number, pageSize: number) => Promise<DirectoryResponse>;
|
||||
|
||||
/**
|
||||
* @param path - The path to create the file or folder at.
|
||||
@@ -1231,7 +1360,7 @@ type FSFunctions = {
|
||||
};
|
||||
|
||||
type GeneralModule = General & GeneralFunctions;
|
||||
type ModsModule = { data: Mod[] } & ModFunctions;
|
||||
type ContentModule = { data: Mod[] } & ContentFunctions;
|
||||
type BackupsModule = { data: Backup[] } & BackupFunctions;
|
||||
type NetworkModule = { allocations: Allocation[] } & NetworkFunctions;
|
||||
type StartupModule = Startup & StartupFunctions;
|
||||
@@ -1239,7 +1368,7 @@ type FSModule = { auth: JWTAuth } & FSFunctions;
|
||||
|
||||
type ModulesMap = {
|
||||
general: GeneralModule;
|
||||
mods: ModsModule;
|
||||
content: ContentModule;
|
||||
backups: BackupsModule;
|
||||
network: NetworkModule;
|
||||
startup: StartupModule;
|
||||
@@ -1247,7 +1376,7 @@ type ModulesMap = {
|
||||
fs: FSModule;
|
||||
};
|
||||
|
||||
type avaliableModules = ("general" | "mods" | "backups" | "network" | "startup" | "ws" | "fs")[];
|
||||
type avaliableModules = ("general" | "content" | "backups" | "network" | "startup" | "ws" | "fs")[];
|
||||
|
||||
export type Server<T extends avaliableModules> = {
|
||||
[K in T[number]]?: ModulesMap[K];
|
||||
|
||||
@@ -403,6 +403,99 @@ export const inferVersionInfo = async function (rawFile, project, gameVersions)
|
||||
case 13:
|
||||
newGameVersions.push("1.19.4");
|
||||
break;
|
||||
case 14:
|
||||
newGameVersions = getRange("23w14a", "23w16a");
|
||||
break;
|
||||
case 15:
|
||||
newGameVersions = getRange("1.20", "1.20.1");
|
||||
break;
|
||||
case 16:
|
||||
newGameVersions.push("23w31a");
|
||||
break;
|
||||
case 17:
|
||||
newGameVersions = getRange("23w32a", "1.20.2-pre1");
|
||||
break;
|
||||
case 18:
|
||||
newGameVersions.push("1.20.2");
|
||||
break;
|
||||
case 19:
|
||||
newGameVersions.push("23w42a");
|
||||
break;
|
||||
case 20:
|
||||
newGameVersions = getRange("23w43a", "23w44a");
|
||||
break;
|
||||
case 21:
|
||||
newGameVersions = getRange("23w45a", "23w46a");
|
||||
break;
|
||||
case 22:
|
||||
newGameVersions = getRange("1.20.3", "1.20.4");
|
||||
break;
|
||||
case 24:
|
||||
newGameVersions = getRange("24w03a", "24w04a");
|
||||
break;
|
||||
case 25:
|
||||
newGameVersions = getRange("24w05a", "24w05b");
|
||||
break;
|
||||
case 26:
|
||||
newGameVersions = getRange("24w06a", "24w07a");
|
||||
break;
|
||||
case 28:
|
||||
newGameVersions = getRange("24w09a", "24w10a");
|
||||
break;
|
||||
case 29:
|
||||
newGameVersions.push("24w11a");
|
||||
break;
|
||||
case 30:
|
||||
newGameVersions.push("24w12a");
|
||||
break;
|
||||
case 31:
|
||||
newGameVersions = getRange("24w13a", "1.20.5-pre3");
|
||||
break;
|
||||
case 32:
|
||||
newGameVersions = getRange("1.20.5", "1.20.6");
|
||||
break;
|
||||
case 33:
|
||||
newGameVersions = getRange("24w18a", "24w20a");
|
||||
break;
|
||||
case 34:
|
||||
newGameVersions = getRange("1.21", "1.21.1");
|
||||
break;
|
||||
case 35:
|
||||
newGameVersions.push("24w33a");
|
||||
break;
|
||||
case 36:
|
||||
newGameVersions = getRange("24w34a", "24w35a");
|
||||
break;
|
||||
case 37:
|
||||
newGameVersions.push("24w36a");
|
||||
break;
|
||||
case 38:
|
||||
newGameVersions.push("24w37a");
|
||||
break;
|
||||
case 39:
|
||||
newGameVersions = getRange("24w38a", "24w39a");
|
||||
break;
|
||||
case 40:
|
||||
newGameVersions.push("24w40a");
|
||||
break;
|
||||
case 41:
|
||||
newGameVersions = getRange("1.21.2-pre1", "1.21.2-pre2");
|
||||
break;
|
||||
case 42:
|
||||
newGameVersions = getRange("1.21.2", "1.21.3");
|
||||
break;
|
||||
case 43:
|
||||
newGameVersions.push("24w44a");
|
||||
break;
|
||||
case 44:
|
||||
newGameVersions.push("24w45a");
|
||||
break;
|
||||
case 45:
|
||||
newGameVersions.push("24w46a");
|
||||
break;
|
||||
case 46:
|
||||
newGameVersions.push("1.21.4");
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<template>
|
||||
<div class="pointer-events-none fixed inset-0 z-[-1]">
|
||||
<div id="fixed-background-teleport" class="relative"></div>
|
||||
</div>
|
||||
<div class="pointer-events-none absolute inset-0 z-[-1]">
|
||||
<div id="absolute-background-teleport" class="relative"></div>
|
||||
</div>
|
||||
<div ref="main_page" class="layout" :class="{ 'expanded-mobile-nav': isBrowseMenuOpen }">
|
||||
<div
|
||||
v-if="auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email'"
|
||||
@@ -54,7 +60,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<header
|
||||
class="experimental-styles-within desktop-only relative z-[5] mx-auto grid max-w-[1280px] grid-cols-[1fr_auto] items-center gap-2 px-3 py-4 lg:grid-cols-[auto_1fr_auto]"
|
||||
class="experimental-styles-within desktop-only relative z-[5] mx-auto grid max-w-[1280px] grid-cols-[1fr_auto] items-center gap-2 px-6 py-4 lg:grid-cols-[auto_1fr_auto]"
|
||||
>
|
||||
<div>
|
||||
<NuxtLink to="/" aria-label="Modrinth home page">
|
||||
@@ -203,7 +209,10 @@
|
||||
|
||||
<ButtonStyled
|
||||
type="transparent"
|
||||
:highlighted="route.name.startsWith('servers')"
|
||||
:highlighted="
|
||||
route.name?.startsWith('servers') ||
|
||||
(route.name?.startsWith('search-') && route.query.sid)
|
||||
"
|
||||
:highlighted-style="
|
||||
route.name === 'servers' ? 'main-nav-primary' : 'main-nav-secondary'
|
||||
"
|
||||
@@ -229,6 +238,7 @@
|
||||
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
|
||||
position="bottom"
|
||||
direction="left"
|
||||
:dropdown-id="createPopoutId"
|
||||
aria-label="Create new..."
|
||||
:options="[
|
||||
{
|
||||
@@ -260,6 +270,7 @@
|
||||
</ButtonStyled>
|
||||
<OverflowMenu
|
||||
v-if="auth.user"
|
||||
:dropdown-id="userPopoutId"
|
||||
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
|
||||
:options="userMenuOptions"
|
||||
>
|
||||
@@ -588,15 +599,14 @@ import {
|
||||
GlassesIcon,
|
||||
PaintBrushIcon,
|
||||
PackageOpenIcon,
|
||||
XIcon as CrossIcon,
|
||||
ScaleIcon as ModerationIcon,
|
||||
BellIcon as NotificationIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { Button, ButtonStyled, OverflowMenu, Avatar } from "@modrinth/ui";
|
||||
import { Button, ButtonStyled, OverflowMenu, Avatar, commonMessages } from "@modrinth/ui";
|
||||
|
||||
import CrossIcon from "assets/images/utils/x.svg";
|
||||
import NotificationIcon from "assets/images/sidebar/notifications.svg";
|
||||
import ModerationIcon from "assets/images/sidebar/admin.svg";
|
||||
import ModalCreation from "~/components/ui/ModalCreation.vue";
|
||||
import { getProjectTypeMessage } from "~/utils/i18n-project-type.ts";
|
||||
import { commonMessages } from "~/utils/common-messages.ts";
|
||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||
import OrganizationCreateModal from "~/components/ui/OrganizationCreateModal.vue";
|
||||
import TeleportOverflowMenu from "~/components/ui/servers/TeleportOverflowMenu.vue";
|
||||
@@ -614,6 +624,9 @@ const config = useRuntimeConfig();
|
||||
const route = useNativeRoute();
|
||||
const link = config.public.siteUrl + route.path.replace(/\/+$/, "");
|
||||
|
||||
const createPopoutId = useId();
|
||||
const userPopoutId = useId();
|
||||
|
||||
const verifyEmailBannerMessages = defineMessages({
|
||||
title: {
|
||||
id: "layout.banner.verify-email.title",
|
||||
@@ -907,9 +920,13 @@ const userMenuOptions = computed(() => {
|
||||
return options;
|
||||
});
|
||||
|
||||
const isDiscovering = computed(() => route.name && route.name.startsWith("search-"));
|
||||
const isDiscovering = computed(
|
||||
() => route.name && route.name.startsWith("search-") && !route.query.sid,
|
||||
);
|
||||
|
||||
const isDiscoveringSubpage = computed(() => route.name && route.name.startsWith("type-id"));
|
||||
const isDiscoveringSubpage = computed(
|
||||
() => route.name && route.name.startsWith("type-id") && !route.query.sid,
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (window && import.meta.client) {
|
||||
@@ -1014,7 +1031,6 @@ function hideStagingBanner() {
|
||||
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
background-color: var(--color-bg);
|
||||
display: block;
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
@@ -1430,7 +1446,7 @@ function hideStagingBanner() {
|
||||
}
|
||||
|
||||
main {
|
||||
padding-top: 0.75rem;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
<template><slot id="main" /></template>
|
||||
<style lang="scss">
|
||||
@import "~/assets/styles/global.scss";
|
||||
</style>
|
||||
|
||||
@@ -167,39 +167,6 @@
|
||||
"auth.welcome.title": {
|
||||
"message": "Welcome"
|
||||
},
|
||||
"button.cancel": {
|
||||
"message": "Cancel"
|
||||
},
|
||||
"button.continue": {
|
||||
"message": "Continue"
|
||||
},
|
||||
"button.copy-id": {
|
||||
"message": "Copy ID"
|
||||
},
|
||||
"button.create-a-project": {
|
||||
"message": "Create a project"
|
||||
},
|
||||
"button.edit": {
|
||||
"message": "Edit"
|
||||
},
|
||||
"button.report": {
|
||||
"message": "Report"
|
||||
},
|
||||
"button.save": {
|
||||
"message": "Save"
|
||||
},
|
||||
"button.save-changes": {
|
||||
"message": "Save changes"
|
||||
},
|
||||
"button.sign-in": {
|
||||
"message": "Sign in"
|
||||
},
|
||||
"button.sign-out": {
|
||||
"message": "Sign out"
|
||||
},
|
||||
"button.upload-image": {
|
||||
"message": "Upload image"
|
||||
},
|
||||
"collection.button.delete-icon": {
|
||||
"message": "Delete icon"
|
||||
},
|
||||
@@ -248,9 +215,6 @@
|
||||
"collection.label.owner": {
|
||||
"message": "Owner"
|
||||
},
|
||||
"collection.label.private": {
|
||||
"message": "Private"
|
||||
},
|
||||
"collection.label.projects-count": {
|
||||
"message": "{count, plural, one {<stat>{count}</stat> project} other {<stat>{count}</stat> projects}}"
|
||||
},
|
||||
@@ -287,72 +251,6 @@
|
||||
"frog.title": {
|
||||
"message": "Frog"
|
||||
},
|
||||
"input.view.gallery": {
|
||||
"message": "Gallery view"
|
||||
},
|
||||
"input.view.grid": {
|
||||
"message": "Grid view"
|
||||
},
|
||||
"input.view.list": {
|
||||
"message": "Rows view"
|
||||
},
|
||||
"label.changes-saved": {
|
||||
"message": "Changes saved"
|
||||
},
|
||||
"label.collections": {
|
||||
"message": "Collections"
|
||||
},
|
||||
"label.created-ago": {
|
||||
"message": "Created {ago}"
|
||||
},
|
||||
"label.dashboard": {
|
||||
"message": "Dashboard"
|
||||
},
|
||||
"label.delete": {
|
||||
"message": "Delete"
|
||||
},
|
||||
"label.description": {
|
||||
"message": "Description"
|
||||
},
|
||||
"label.error": {
|
||||
"message": "Error"
|
||||
},
|
||||
"label.followed-projects": {
|
||||
"message": "Followed projects"
|
||||
},
|
||||
"label.moderation": {
|
||||
"message": "Moderation"
|
||||
},
|
||||
"label.notifications": {
|
||||
"message": "Notifications"
|
||||
},
|
||||
"label.password": {
|
||||
"message": "Password"
|
||||
},
|
||||
"label.public": {
|
||||
"message": "Public"
|
||||
},
|
||||
"label.rejected": {
|
||||
"message": "Rejected"
|
||||
},
|
||||
"label.scopes": {
|
||||
"message": "Scopes"
|
||||
},
|
||||
"label.settings": {
|
||||
"message": "Settings"
|
||||
},
|
||||
"label.title": {
|
||||
"message": "Title"
|
||||
},
|
||||
"label.unlisted": {
|
||||
"message": "Unlisted"
|
||||
},
|
||||
"label.visibility": {
|
||||
"message": "Visibility"
|
||||
},
|
||||
"label.visit-your-profile": {
|
||||
"message": "Visit your profile"
|
||||
},
|
||||
"layout.action.change-theme": {
|
||||
"message": "Change theme"
|
||||
},
|
||||
@@ -440,9 +338,6 @@
|
||||
"layout.nav.search": {
|
||||
"message": "Search"
|
||||
},
|
||||
"notification.error.title": {
|
||||
"message": "An error occurred"
|
||||
},
|
||||
"profile.button.manage-projects": {
|
||||
"message": "Manage projects"
|
||||
},
|
||||
@@ -491,9 +386,6 @@
|
||||
"profile.user-id": {
|
||||
"message": "User ID: {id}"
|
||||
},
|
||||
"project-type.all": {
|
||||
"message": "All"
|
||||
},
|
||||
"project-type.collection.plural": {
|
||||
"message": "Collections"
|
||||
},
|
||||
@@ -542,24 +434,6 @@
|
||||
"project-type.shader.singular": {
|
||||
"message": "Shader"
|
||||
},
|
||||
"project.about.compatibility.environments": {
|
||||
"message": "Supported environments"
|
||||
},
|
||||
"project.about.compatibility.game.minecraftJava": {
|
||||
"message": "Minecraft: Java Edition"
|
||||
},
|
||||
"project.about.compatibility.platforms": {
|
||||
"message": "Platforms"
|
||||
},
|
||||
"project.about.compatibility.title": {
|
||||
"message": "Compatibility"
|
||||
},
|
||||
"project.about.creators.owner": {
|
||||
"message": "Project owner"
|
||||
},
|
||||
"project.about.creators.title": {
|
||||
"message": "Creators"
|
||||
},
|
||||
"project.about.details.created": {
|
||||
"message": "Created {date}"
|
||||
},
|
||||
@@ -578,39 +452,6 @@
|
||||
"project.about.details.updated": {
|
||||
"message": "Updated {date}"
|
||||
},
|
||||
"project.about.links.discord": {
|
||||
"message": "Join Discord server"
|
||||
},
|
||||
"project.about.links.donate.bmac": {
|
||||
"message": "Buy Me a Coffee"
|
||||
},
|
||||
"project.about.links.donate.generic": {
|
||||
"message": "Donate"
|
||||
},
|
||||
"project.about.links.donate.github": {
|
||||
"message": "Sponsor on GitHub"
|
||||
},
|
||||
"project.about.links.donate.kofi": {
|
||||
"message": "Donate on Ko-fi"
|
||||
},
|
||||
"project.about.links.donate.patreon": {
|
||||
"message": "Donate on Patreon"
|
||||
},
|
||||
"project.about.links.donate.paypal": {
|
||||
"message": "Donate on PayPal"
|
||||
},
|
||||
"project.about.links.issues": {
|
||||
"message": "Report issues"
|
||||
},
|
||||
"project.about.links.source": {
|
||||
"message": "View source"
|
||||
},
|
||||
"project.about.links.title": {
|
||||
"message": "Links"
|
||||
},
|
||||
"project.about.links.wiki": {
|
||||
"message": "Visit wiki"
|
||||
},
|
||||
"project.description.title": {
|
||||
"message": "Description"
|
||||
},
|
||||
@@ -887,17 +728,17 @@
|
||||
"scopes.versionWrite.label": {
|
||||
"message": "Write versions"
|
||||
},
|
||||
"settings.account.title": {
|
||||
"message": "Account and security"
|
||||
"search.filter.locked.server": {
|
||||
"message": "Provided by the server"
|
||||
},
|
||||
"settings.appearance.title": {
|
||||
"message": "Appearance"
|
||||
"search.filter.locked.server-game-version.title": {
|
||||
"message": "Game version is provided by the server"
|
||||
},
|
||||
"settings.applications.title": {
|
||||
"message": "Your applications"
|
||||
"search.filter.locked.server-loader.title": {
|
||||
"message": "Loader is provided by the server"
|
||||
},
|
||||
"settings.authorized-apps.title": {
|
||||
"message": "Authorized apps"
|
||||
"search.filter.locked.server.sync": {
|
||||
"message": "Sync with server"
|
||||
},
|
||||
"settings.billing.modal.cancel.action": {
|
||||
"message": "Cancel subscription"
|
||||
@@ -977,15 +818,18 @@
|
||||
"settings.billing.payment_method_type.visa": {
|
||||
"message": "Visa"
|
||||
},
|
||||
"settings.billing.pyro_subscription.description": {
|
||||
"message": "Manage your Modrinth Server subscriptions."
|
||||
},
|
||||
"settings.billing.pyro_subscription.title": {
|
||||
"message": "Modrinth Server Subscriptions"
|
||||
},
|
||||
"settings.billing.subscription.description": {
|
||||
"message": "Manage your Modrinth subscriptions."
|
||||
},
|
||||
"settings.billing.subscription.title": {
|
||||
"message": "Subscriptions"
|
||||
},
|
||||
"settings.billing.title": {
|
||||
"message": "Billing and subscriptions"
|
||||
},
|
||||
"settings.display.banner.developer-mode.button": {
|
||||
"message": "Deactivate developer mode"
|
||||
},
|
||||
@@ -1058,30 +902,9 @@
|
||||
"settings.display.sidebar.right-aligned-filters-sidebar.title": {
|
||||
"message": "Right-aligned filters sidebar on search pages"
|
||||
},
|
||||
"settings.display.theme.dark": {
|
||||
"message": "Dark"
|
||||
},
|
||||
"settings.display.theme.description": {
|
||||
"message": "Select your preferred color theme for Modrinth on this device."
|
||||
},
|
||||
"settings.display.theme.light": {
|
||||
"message": "Light"
|
||||
},
|
||||
"settings.display.theme.oled": {
|
||||
"message": "OLED"
|
||||
},
|
||||
"settings.display.theme.preferred-dark-theme": {
|
||||
"message": "Preferred dark theme"
|
||||
},
|
||||
"settings.display.theme.preferred-light-theme": {
|
||||
"message": "Preferred light theme"
|
||||
},
|
||||
"settings.display.theme.retro": {
|
||||
"message": "Retro"
|
||||
},
|
||||
"settings.display.theme.system": {
|
||||
"message": "Sync with system"
|
||||
},
|
||||
"settings.display.theme.title": {
|
||||
"message": "Color theme"
|
||||
},
|
||||
@@ -1127,9 +950,6 @@
|
||||
"settings.language.languages.search.no-results": {
|
||||
"message": "No languages match your search."
|
||||
},
|
||||
"settings.language.title": {
|
||||
"message": "Language"
|
||||
},
|
||||
"settings.pats.action.create": {
|
||||
"message": "Create a PAT"
|
||||
},
|
||||
@@ -1163,9 +983,6 @@
|
||||
"settings.pats.modal.edit.title": {
|
||||
"message": "Edit personal access token"
|
||||
},
|
||||
"settings.pats.title": {
|
||||
"message": "Personal access tokens"
|
||||
},
|
||||
"settings.pats.token.action.edit": {
|
||||
"message": "Edit token"
|
||||
},
|
||||
@@ -1202,9 +1019,6 @@
|
||||
"settings.profile.profile-picture.title": {
|
||||
"message": "Profile picture"
|
||||
},
|
||||
"settings.profile.title": {
|
||||
"message": "Public profile"
|
||||
},
|
||||
"settings.profile.username.description": {
|
||||
"message": "A unique case-insensitive name to identify your profile."
|
||||
},
|
||||
@@ -1226,16 +1040,10 @@
|
||||
"settings.sessions.last-accessed-ago": {
|
||||
"message": "Last accessed {ago}"
|
||||
},
|
||||
"settings.sessions.title": {
|
||||
"message": "Sessions"
|
||||
},
|
||||
"settings.sessions.unknown-os": {
|
||||
"message": "Unknown OS"
|
||||
},
|
||||
"settings.sessions.unknown-platform": {
|
||||
"message": "Unknown platform"
|
||||
},
|
||||
"tooltip.date-at-time": {
|
||||
"message": "{date, date, long} at {time, time, short}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<template>
|
||||
<Teleport v-if="flags.projectBackground" to="#fixed-background-teleport">
|
||||
<ProjectBackgroundGradient :project="project" />
|
||||
</Teleport>
|
||||
<div v-if="route.name.startsWith('type-id-settings')" class="normal-page">
|
||||
<div class="normal-page__sidebar">
|
||||
<aside class="universal-card">
|
||||
@@ -430,47 +433,7 @@
|
||||
}"
|
||||
>
|
||||
<div class="normal-page__header relative my-4">
|
||||
<ContentPageHeader>
|
||||
<template #icon>
|
||||
<Avatar :src="project.icon_url" :alt="project.title" size="96px" />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ project.title }}
|
||||
</template>
|
||||
<template #title-suffix>
|
||||
<Badge v-if="auth.user && currentMember" :type="project.status" class="status-badge" />
|
||||
</template>
|
||||
<template #summary>
|
||||
{{ project.description }}
|
||||
</template>
|
||||
<template #stats>
|
||||
<div
|
||||
class="flex items-center gap-2 border-0 border-r border-solid border-button-bg pr-4 font-semibold"
|
||||
>
|
||||
<DownloadIcon class="h-6 w-6 text-secondary" />
|
||||
{{ $formatNumber(project.downloads) }}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 border-0 border-solid border-button-bg pr-4 md:border-r"
|
||||
>
|
||||
<HeartIcon class="h-6 w-6 text-secondary" />
|
||||
<span class="font-semibold">
|
||||
{{ $formatNumber(project.followers) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="hidden items-center gap-2 md:flex">
|
||||
<TagsIcon class="h-6 w-6 text-secondary" />
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="(category, index) in project.categories"
|
||||
:key="index"
|
||||
class="tag-list__item"
|
||||
>
|
||||
{{ formatCategory(category) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<ProjectHeader :project="project" :member="!!currentMember">
|
||||
<template #actions>
|
||||
<div class="hidden sm:contents">
|
||||
<ButtonStyled
|
||||
@@ -498,73 +461,104 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
size="large"
|
||||
circular
|
||||
:color="following ? 'red' : 'standard'"
|
||||
color-fill="none"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button
|
||||
v-if="auth.user"
|
||||
v-tooltip="following ? `Unfollow` : `Follow`"
|
||||
:aria-label="following ? `Unfollow` : `Follow`"
|
||||
@click="userFollowProject(project)"
|
||||
<ClientOnly>
|
||||
<ButtonStyled
|
||||
size="large"
|
||||
circular
|
||||
:color="following ? 'red' : 'standard'"
|
||||
color-fill="none"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<HeartIcon :fill="following ? 'currentColor' : 'none'" aria-hidden="true" />
|
||||
</button>
|
||||
<nuxt-link v-else v-tooltip="'Follow'" to="/auth/sign-in" aria-label="Follow">
|
||||
<HeartIcon aria-hidden="true" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" circular>
|
||||
<PopoutMenu v-if="auth.user" v-tooltip="'Save'" from="top-right" aria-label="Save">
|
||||
<BookmarkIcon
|
||||
aria-hidden="true"
|
||||
:fill="
|
||||
collections.some((x) => x.projects.includes(project.id))
|
||||
? 'currentColor'
|
||||
: 'none'
|
||||
<button
|
||||
v-if="auth.user"
|
||||
v-tooltip="following ? `Unfollow` : `Follow`"
|
||||
:aria-label="following ? `Unfollow` : `Follow`"
|
||||
@click="userFollowProject(project)"
|
||||
>
|
||||
<HeartIcon :fill="following ? 'currentColor' : 'none'" aria-hidden="true" />
|
||||
</button>
|
||||
<nuxt-link v-else v-tooltip="'Follow'" to="/auth/sign-in" aria-label="Follow">
|
||||
<HeartIcon aria-hidden="true" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" circular>
|
||||
<PopoutMenu
|
||||
v-if="auth.user"
|
||||
:tooltip="
|
||||
collections.some((x) => x.projects.includes(project.id)) ? 'Saved' : 'Save'
|
||||
"
|
||||
/>
|
||||
<template #menu>
|
||||
<input
|
||||
v-model="displayCollectionsSearch"
|
||||
type="text"
|
||||
placeholder="Search collections..."
|
||||
class="search-input menu-search"
|
||||
from="top-right"
|
||||
aria-label="Save"
|
||||
:dropdown-id="`${baseId}-save`"
|
||||
>
|
||||
<BookmarkIcon
|
||||
aria-hidden="true"
|
||||
:fill="
|
||||
collections.some((x) => x.projects.includes(project.id))
|
||||
? 'currentColor'
|
||||
: 'none'
|
||||
"
|
||||
/>
|
||||
<div v-if="collections.length > 0" class="collections-list">
|
||||
<Checkbox
|
||||
v-for="option in collections
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))"
|
||||
:key="option.id"
|
||||
:model-value="option.projects.includes(project.id)"
|
||||
class="popout-checkbox"
|
||||
@update:model-value="() => onUserCollectProject(option, project.id)"
|
||||
<template #menu>
|
||||
<input
|
||||
v-model="displayCollectionsSearch"
|
||||
type="text"
|
||||
placeholder="Search collections..."
|
||||
class="search-input menu-search"
|
||||
/>
|
||||
<div v-if="collections.length > 0" class="collections-list">
|
||||
<Checkbox
|
||||
v-for="option in collections
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))"
|
||||
:key="option.id"
|
||||
:model-value="option.projects.includes(project.id)"
|
||||
class="popout-checkbox"
|
||||
@update:model-value="() => onUserCollectProject(option, project.id)"
|
||||
>
|
||||
{{ option.name }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div v-else class="menu-text">
|
||||
<p class="popout-text">No collections found.</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn collection-button"
|
||||
@click="(event) => $refs.modal_collection.show(event)"
|
||||
>
|
||||
{{ option.name }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div v-else class="menu-text">
|
||||
<p class="popout-text">No collections found.</p>
|
||||
</div>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Create new collection
|
||||
</button>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
<nuxt-link v-else v-tooltip="'Save'" to="/auth/sign-in" aria-label="Save">
|
||||
<BookmarkIcon aria-hidden="true" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<template #fallback>
|
||||
<ButtonStyled size="large" circular>
|
||||
<button
|
||||
class="btn collection-button"
|
||||
@click="(event) => $refs.modal_collection.show(event)"
|
||||
v-if="auth.user"
|
||||
v-tooltip="`Follow`"
|
||||
:aria-label="`Follow`"
|
||||
@click="userFollowProject(project)"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Create new collection
|
||||
<HeartIcon aria-hidden="true" />
|
||||
</button>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
<nuxt-link v-else v-tooltip="'Save'" to="/auth/sign-in" aria-label="Save">
|
||||
<BookmarkIcon aria-hidden="true" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<nuxt-link v-else v-tooltip="'Follow'" to="/auth/sign-in" aria-label="Follow">
|
||||
<HeartIcon aria-hidden="true" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" circular>
|
||||
<nuxt-link v-tooltip="'Save'" to="/auth/sign-in" aria-label="Save">
|
||||
<BookmarkIcon aria-hidden="true" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
<ButtonStyled v-if="auth.user && currentMember" size="large" circular>
|
||||
<nuxt-link
|
||||
v-tooltip="'Settings'"
|
||||
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
|
||||
>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
@@ -572,6 +566,7 @@
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" circular type="transparent">
|
||||
<OverflowMenu
|
||||
:tooltip="'More options'"
|
||||
:options="[
|
||||
{
|
||||
id: 'analytics',
|
||||
@@ -611,6 +606,7 @@
|
||||
{ id: 'copy-id', action: () => copyId() },
|
||||
]"
|
||||
aria-label="More options"
|
||||
:dropdown-id="`${baseId}-more-options`"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #analytics>
|
||||
@@ -632,7 +628,7 @@
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ContentPageHeader>
|
||||
</ProjectHeader>
|
||||
<ProjectMemberHeader
|
||||
v-if="currentMember"
|
||||
:project="project"
|
||||
@@ -654,227 +650,37 @@
|
||||
</MessageBanner>
|
||||
</div>
|
||||
<div class="normal-page__sidebar">
|
||||
<div v-if="versions.length > 0" class="card flex-card experimental-styles-within">
|
||||
<h2>{{ formatMessage(compatibilityMessages.title) }}</h2>
|
||||
<section>
|
||||
<h3>{{ formatMessage(compatibilityMessages.minecraftJava) }}</h3>
|
||||
<div class="tag-list">
|
||||
<div
|
||||
v-for="version in getVersionsToDisplay(project)"
|
||||
:key="`version-tag-${version}`"
|
||||
class="tag-list__item"
|
||||
>
|
||||
{{ version }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="project.project_type !== 'resourcepack'">
|
||||
<h3>{{ formatMessage(compatibilityMessages.platforms) }}</h3>
|
||||
<div class="tag-list">
|
||||
<div
|
||||
v-for="platform in project.loaders"
|
||||
:key="`platform-tag-${platform}`"
|
||||
:class="`tag-list__item`"
|
||||
:style="`--_color: var(--color-platform-${platform})`"
|
||||
>
|
||||
<svg v-html="tags.loaders.find((x) => x.name === platform).icon"></svg>
|
||||
{{ formatCategory(platform) }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-if="
|
||||
(project.actualProjectType === 'mod' || project.project_type === 'modpack') &&
|
||||
!(project.client_side === 'unsupported' && project.server_side === 'unsupported') &&
|
||||
!(project.client_side === 'unknown' && project.server_side === 'unknown')
|
||||
"
|
||||
>
|
||||
<h3>{{ formatMessage(compatibilityMessages.environments) }}</h3>
|
||||
<div class="tag-list">
|
||||
<div
|
||||
v-if="
|
||||
(project.client_side === 'required' && project.server_side !== 'required') ||
|
||||
(project.client_side === 'optional' && project.server_side === 'optional')
|
||||
"
|
||||
class="tag-list__item"
|
||||
>
|
||||
<ClientIcon aria-hidden="true" />
|
||||
Client-side
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
(project.server_side === 'required' && project.client_side !== 'required') ||
|
||||
(project.client_side === 'optional' && project.server_side === 'optional')
|
||||
"
|
||||
class="tag-list__item"
|
||||
>
|
||||
<ServerIcon aria-hidden="true" />
|
||||
Server-side
|
||||
</div>
|
||||
<div v-if="false" class="tag-list__item">
|
||||
<UserIcon aria-hidden="true" />
|
||||
Singleplayer
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
project.project_type !== 'datapack' &&
|
||||
((project.client_side === 'required' && project.server_side === 'required') ||
|
||||
project.client_side === 'optional' ||
|
||||
(project.client_side === 'required' && project.server_side === 'optional') ||
|
||||
project.server_side === 'optional' ||
|
||||
(project.server_side === 'required' && project.client_side === 'optional'))
|
||||
"
|
||||
class="tag-list__item"
|
||||
>
|
||||
<MonitorSmartphoneIcon aria-hidden="true" />
|
||||
Client and server
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<ProjectSidebarCompatibility
|
||||
:project="project"
|
||||
:tags="tags"
|
||||
class="card flex-card experimental-styles-within"
|
||||
/>
|
||||
<AdPlaceholder
|
||||
v-if="
|
||||
(!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus) &&
|
||||
tags.approvedStatuses.includes(project.status)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
project.issues_url ||
|
||||
project.source_url ||
|
||||
project.wiki_url ||
|
||||
project.discord_url ||
|
||||
project.donation_urls.length > 0
|
||||
"
|
||||
<ProjectSidebarLinks
|
||||
:project="project"
|
||||
:link-target="$external()"
|
||||
class="card flex-card experimental-styles-within"
|
||||
>
|
||||
<h2>{{ formatMessage(linksMessages.title) }}</h2>
|
||||
<div class="links-list">
|
||||
<a
|
||||
v-if="project.issues_url"
|
||||
:href="project.issues_url"
|
||||
:target="$external()"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
<IssuesIcon aria-hidden="true" />
|
||||
{{ formatMessage(linksMessages.issues) }}
|
||||
<ExternalIcon aria-hidden="true" class="external-icon" />
|
||||
</a>
|
||||
<a
|
||||
v-if="project.source_url"
|
||||
:href="project.source_url"
|
||||
:target="$external()"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
<CodeIcon aria-hidden="true" />
|
||||
{{ formatMessage(linksMessages.source) }}
|
||||
<ExternalIcon aria-hidden="true" class="external-icon" />
|
||||
</a>
|
||||
<a
|
||||
v-if="project.wiki_url"
|
||||
:href="project.wiki_url"
|
||||
:target="$external()"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
<WikiIcon aria-hidden="true" />
|
||||
{{ formatMessage(linksMessages.wiki) }}
|
||||
<ExternalIcon aria-hidden="true" class="external-icon" />
|
||||
</a>
|
||||
<a
|
||||
v-if="project.discord_url"
|
||||
:href="project.discord_url"
|
||||
:target="$external()"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
<DiscordIcon class="shrink" aria-hidden="true" />
|
||||
{{ formatMessage(linksMessages.discord) }}
|
||||
<ExternalIcon aria-hidden="true" class="external-icon" />
|
||||
</a>
|
||||
<hr
|
||||
v-if="
|
||||
(project.issues_url ||
|
||||
project.source_url ||
|
||||
project.wiki_url ||
|
||||
project.discord_url) &&
|
||||
project.donation_urls.length > 0
|
||||
"
|
||||
/>
|
||||
<a
|
||||
v-for="(donation, index) in project.donation_urls"
|
||||
:key="index"
|
||||
:href="donation.url"
|
||||
:target="$external()"
|
||||
rel="noopener nofollow ugc"
|
||||
>
|
||||
<BuyMeACoffeeIcon v-if="donation.id === 'bmac'" aria-hidden="true" />
|
||||
<PatreonIcon v-else-if="donation.id === 'patreon'" aria-hidden="true" />
|
||||
<KoFiIcon v-else-if="donation.id === 'ko-fi'" aria-hidden="true" />
|
||||
<PayPalIcon v-else-if="donation.id === 'paypal'" aria-hidden="true" />
|
||||
<OpenCollectiveIcon
|
||||
v-else-if="donation.id === 'open-collective'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<HeartIcon v-else-if="donation.id === 'github'" />
|
||||
<CurrencyIcon v-else />
|
||||
<span v-if="donation.id === 'bmac'">{{
|
||||
formatMessage(linksMessages.donateBmac)
|
||||
}}</span>
|
||||
<span v-else-if="donation.id === 'patreon'">{{
|
||||
formatMessage(linksMessages.donatePatreon)
|
||||
}}</span>
|
||||
<span v-else-if="donation.id === 'paypal'">{{
|
||||
formatMessage(linksMessages.donatePayPal)
|
||||
}}</span>
|
||||
<span v-else-if="donation.id === 'ko-fi'">{{
|
||||
formatMessage(linksMessages.donateKoFi)
|
||||
}}</span>
|
||||
<span v-else-if="donation.id === 'github'">{{
|
||||
formatMessage(linksMessages.donateGithub)
|
||||
}}</span>
|
||||
<span v-else>{{ formatMessage(linksMessages.donateGeneric) }}</span>
|
||||
<ExternalIcon aria-hidden="true" class="external-icon" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card flex-card experimental-styles-within">
|
||||
<h2>{{ formatMessage(creatorsMessages.title) }}</h2>
|
||||
<div class="details-list">
|
||||
<template v-if="organization">
|
||||
<nuxt-link
|
||||
class="details-list__item details-list__item--type-large"
|
||||
:to="`/organization/${organization.slug}`"
|
||||
>
|
||||
<Avatar :src="organization.icon_url" :alt="organization.name" size="32px" />
|
||||
<div class="rows">
|
||||
<span>
|
||||
{{ organization.name }}
|
||||
</span>
|
||||
<span class="details-list__item__text--style-secondary">Organization</span>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<hr v-if="members.length > 0" />
|
||||
</template>
|
||||
<nuxt-link
|
||||
v-for="member in members"
|
||||
:key="`member-${member.id}`"
|
||||
class="details-list__item details-list__item--type-large"
|
||||
:to="'/user/' + member.user.username"
|
||||
>
|
||||
<Avatar :src="member.avatar_url" :alt="member.name" size="32px" circle />
|
||||
<div class="rows">
|
||||
<span class="flex items-center gap-1">
|
||||
{{ member.name }}
|
||||
<CrownIcon
|
||||
v-if="member.is_owner"
|
||||
v-tooltip="formatMessage(creatorsMessages.owner)"
|
||||
class="text-brand-orange"
|
||||
/>
|
||||
</span>
|
||||
<span class="details-list__item__text--style-secondary">{{ member.role }}</span>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
<ProjectSidebarCreators
|
||||
:organization="organization"
|
||||
:members="members"
|
||||
:org-link="(slug) => `/organization/${slug}`"
|
||||
:user-link="(username) => `/user/${username}`"
|
||||
class="card flex-card experimental-styles-within"
|
||||
/>
|
||||
<!-- TODO: Finish license modal and enable -->
|
||||
<ProjectSidebarDetails
|
||||
v-if="false"
|
||||
:project="project"
|
||||
:has-versions="versions.length > 0"
|
||||
:link-target="$external()"
|
||||
class="card flex-card experimental-styles-within"
|
||||
/>
|
||||
<div class="card flex-card experimental-styles-within">
|
||||
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
|
||||
<div class="details-list">
|
||||
@@ -1002,23 +808,8 @@ import {
|
||||
UsersIcon,
|
||||
VersionIcon,
|
||||
WrenchIcon,
|
||||
ClientIcon,
|
||||
BookTextIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
WikiIcon,
|
||||
DiscordIcon,
|
||||
CalendarIcon,
|
||||
KoFiIcon,
|
||||
BuyMeACoffeeIcon,
|
||||
IssuesIcon,
|
||||
UserIcon,
|
||||
PayPalIcon,
|
||||
ServerIcon,
|
||||
PatreonIcon,
|
||||
CrownIcon,
|
||||
OpenCollectiveIcon,
|
||||
CodeIcon,
|
||||
CurrencyIcon,
|
||||
} from "@modrinth/assets";
|
||||
import {
|
||||
Avatar,
|
||||
@@ -1028,10 +819,16 @@ import {
|
||||
OverflowMenu,
|
||||
PopoutMenu,
|
||||
ScrollablePanel,
|
||||
ContentPageHeader,
|
||||
ProjectHeader,
|
||||
ProjectSidebarCompatibility,
|
||||
ProjectSidebarCreators,
|
||||
ProjectSidebarLinks,
|
||||
ProjectSidebarDetails,
|
||||
ProjectBackgroundGradient,
|
||||
} from "@modrinth/ui";
|
||||
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
|
||||
import dayjs from "dayjs";
|
||||
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
|
||||
import Badge from "~/components/ui/Badge.vue";
|
||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
import NavStack from "~/components/ui/NavStack.vue";
|
||||
@@ -1045,9 +842,7 @@ import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
|
||||
import Accordion from "~/components/ui/Accordion.vue";
|
||||
import ModrinthIcon from "~/assets/images/utils/modrinth.svg?component";
|
||||
import VersionSummary from "~/components/ui/VersionSummary.vue";
|
||||
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
||||
import { getVersionsToDisplay } from "~/helpers/projects.js";
|
||||
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||
|
||||
const data = useNuxtApp();
|
||||
@@ -1074,6 +869,8 @@ const gameVersionFilterInput = ref();
|
||||
|
||||
const versionFilter = ref("");
|
||||
|
||||
const baseId = useId();
|
||||
|
||||
const currentGameVersion = computed(() => {
|
||||
return (
|
||||
userSelectedGameVersion.value ||
|
||||
@@ -1111,84 +908,6 @@ const getModrinthAppAccordion = ref();
|
||||
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
|
||||
const compatibilityMessages = defineMessages({
|
||||
title: {
|
||||
id: "project.about.compatibility.title",
|
||||
defaultMessage: "Compatibility",
|
||||
},
|
||||
minecraftJava: {
|
||||
id: "project.about.compatibility.game.minecraftJava",
|
||||
defaultMessage: "Minecraft: Java Edition",
|
||||
},
|
||||
platforms: {
|
||||
id: "project.about.compatibility.platforms",
|
||||
defaultMessage: "Platforms",
|
||||
},
|
||||
environments: {
|
||||
id: "project.about.compatibility.environments",
|
||||
defaultMessage: "Supported environments",
|
||||
},
|
||||
});
|
||||
const linksMessages = defineMessages({
|
||||
title: {
|
||||
id: "project.about.links.title",
|
||||
defaultMessage: "Links",
|
||||
},
|
||||
issues: {
|
||||
id: "project.about.links.issues",
|
||||
defaultMessage: "Report issues",
|
||||
},
|
||||
source: {
|
||||
id: "project.about.links.source",
|
||||
defaultMessage: "View source",
|
||||
},
|
||||
wiki: {
|
||||
id: "project.about.links.wiki",
|
||||
defaultMessage: "Visit wiki",
|
||||
},
|
||||
discord: {
|
||||
id: "project.about.links.discord",
|
||||
defaultMessage: "Join Discord server",
|
||||
},
|
||||
donateGeneric: {
|
||||
id: "project.about.links.donate.generic",
|
||||
defaultMessage: "Donate",
|
||||
},
|
||||
donateGitHub: {
|
||||
id: "project.about.links.donate.github",
|
||||
defaultMessage: "Sponsor on GitHub",
|
||||
},
|
||||
donateBmac: {
|
||||
id: "project.about.links.donate.bmac",
|
||||
defaultMessage: "Buy Me a Coffee",
|
||||
},
|
||||
donatePatreon: {
|
||||
id: "project.about.links.donate.patreon",
|
||||
defaultMessage: "Donate on Patreon",
|
||||
},
|
||||
donatePayPal: {
|
||||
id: "project.about.links.donate.paypal",
|
||||
defaultMessage: "Donate on PayPal",
|
||||
},
|
||||
donateKoFi: {
|
||||
id: "project.about.links.donate.kofi",
|
||||
defaultMessage: "Donate on Ko-fi",
|
||||
},
|
||||
donateGithub: {
|
||||
id: "project.about.links.donate.github",
|
||||
defaultMessage: "Sponsor on GitHub",
|
||||
},
|
||||
});
|
||||
const creatorsMessages = defineMessages({
|
||||
title: {
|
||||
id: "project.about.creators.title",
|
||||
defaultMessage: "Creators",
|
||||
},
|
||||
owner: {
|
||||
id: "project.about.creators.owner",
|
||||
defaultMessage: "Project owner",
|
||||
},
|
||||
});
|
||||
const detailsMessages = defineMessages({
|
||||
title: {
|
||||
id: "project.about.details.title",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<div class="mb-3 flex">
|
||||
<VersionFilterControl :versions="props.versions" @switch-page="switchPage" />
|
||||
<VersionFilterControl
|
||||
:versions="props.versions"
|
||||
:game-versions="tags.gameVersions"
|
||||
@update:query="updateQuery"
|
||||
/>
|
||||
<Pagination
|
||||
:page="currentPage"
|
||||
:count="Math.ceil(filteredVersions.length / 20)"
|
||||
@@ -72,8 +76,8 @@
|
||||
import { Pagination } from "@modrinth/ui";
|
||||
import { DownloadIcon } from "@modrinth/assets";
|
||||
|
||||
import VersionFilterControl from "@modrinth/ui/src/components/version/VersionFilterControl.vue";
|
||||
import { renderHighlightedString } from "~/helpers/highlight.js";
|
||||
import VersionFilterControl from "~/components/ui/VersionFilterControl.vue";
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
@@ -108,6 +112,7 @@ useSeoMeta({
|
||||
|
||||
const router = useNativeRouter();
|
||||
const route = useNativeRoute();
|
||||
const tags = useTags();
|
||||
|
||||
const currentPage = ref(Number(route.query.page ?? 1));
|
||||
const filteredVersions = computed(() => {
|
||||
@@ -138,6 +143,21 @@ function switchPage(page) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updateQuery(newQueries) {
|
||||
if (newQueries.page) {
|
||||
currentPage.value = Number(newQueries.page);
|
||||
} else if (newQueries.page === undefined) {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
...newQueries,
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<template>
|
||||
<section class="normal-page__content">
|
||||
<div
|
||||
v-if="project.body"
|
||||
class="markdown-body card"
|
||||
v-html="renderHighlightedString(project.body || '')"
|
||||
/>
|
||||
<div v-if="project.body" class="card">
|
||||
<ProjectPageDescription :description="project.body" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { renderHighlightedString } from "~/helpers/highlight.js";
|
||||
import { ProjectPageDescription } from "@modrinth/ui";
|
||||
|
||||
defineProps({
|
||||
project: {
|
||||
|
||||
@@ -19,282 +19,133 @@
|
||||
</span>
|
||||
<DropArea :accept="acceptFileFromProjectType(project.project_type)" @change="handleFiles" />
|
||||
</div>
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
<VersionFilterControl
|
||||
ref="versionFilters"
|
||||
:versions="props.versions"
|
||||
@switch-page="switchPage"
|
||||
/>
|
||||
<Pagination
|
||||
:page="currentPage"
|
||||
class="ml-auto mt-auto"
|
||||
:count="Math.ceil(filteredVersions.length / 20)"
|
||||
:link-function="(page) => `?page=${currentPage}`"
|
||||
@switch-page="switchPage"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="versions.length > 0"
|
||||
class="flex flex-col gap-4 rounded-2xl bg-bg-raised px-6 pb-8 pt-4 supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[1fr_min-content] sm:px-8 supports-[grid-template-columns:subgrid]:sm:grid-cols-[min-content_auto_auto_auto_min-content] supports-[grid-template-columns:subgrid]:xl:grid-cols-[min-content_auto_auto_auto_auto_auto_min-content]"
|
||||
<ProjectPageVersions
|
||||
:project="project"
|
||||
:versions="versions"
|
||||
:show-files="flags.showVersionFilesInTable"
|
||||
:current-member="!!currentMember"
|
||||
:loaders="tags.loaders"
|
||||
:game-versions="tags.gameVersions"
|
||||
:base-id="baseDropdownId"
|
||||
:version-link="
|
||||
(version) =>
|
||||
`/${project.project_type}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}/version/${encodeURI(version.displayUrlEnding)}`
|
||||
"
|
||||
>
|
||||
<div class="versions-grid-row">
|
||||
<div class="w-9 max-sm:hidden"></div>
|
||||
<div class="text-sm font-bold text-contrast max-sm:hidden">Name</div>
|
||||
<div
|
||||
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
|
||||
>
|
||||
Game version
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
|
||||
>
|
||||
Platforms
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
|
||||
>
|
||||
Published
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
|
||||
>
|
||||
Downloads
|
||||
</div>
|
||||
<div class="text-sm font-bold text-contrast max-sm:hidden xl:collapse xl:hidden">
|
||||
Compatibility
|
||||
</div>
|
||||
<div class="text-sm font-bold text-contrast max-sm:hidden xl:collapse xl:hidden">Stats</div>
|
||||
<div class="w-9 max-sm:hidden"></div>
|
||||
</div>
|
||||
<template
|
||||
v-for="(version, index) in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
|
||||
:key="index"
|
||||
>
|
||||
<div
|
||||
:class="`versions-grid-row h-px w-full bg-button-bg ${index === 0 ? `max-sm:!hidden` : ``}`"
|
||||
></div>
|
||||
<div class="versions-grid-row group relative">
|
||||
<nuxt-link
|
||||
class="absolute inset-[calc(-1rem-2px)_-2rem] before:absolute before:inset-0 before:transition-all before:content-[''] hover:before:backdrop-brightness-110"
|
||||
:to="`/${project.project_type}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}/version/${encodeURI(version.displayUrlEnding)}`"
|
||||
></nuxt-link>
|
||||
<div class="flex flex-col justify-center gap-2 sm:contents">
|
||||
<div class="flex flex-row items-center gap-2 sm:contents">
|
||||
<div class="self-center">
|
||||
<div class="relative z-[1] cursor-pointer">
|
||||
<VersionChannelIndicator
|
||||
v-tooltip="`Toggle filter for ${version.version_type}`"
|
||||
:channel="version.version_type"
|
||||
@click="versionFilters.toggleFilter('channel', version.version_type)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none relative z-[1] flex flex-col justify-center group-hover:underline"
|
||||
>
|
||||
<div class="font-bold text-contrast">{{ version.version_number }}</div>
|
||||
<div class="text-xs font-medium">{{ version.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center gap-2 sm:contents">
|
||||
<div class="flex flex-row flex-wrap items-center gap-1 xl:contents">
|
||||
<div class="flex items-center">
|
||||
<div class="tag-list">
|
||||
<div
|
||||
v-for="gameVersion in formatVersionsForDisplay(version.game_versions)"
|
||||
:key="`version-tag-${gameVersion}`"
|
||||
v-tooltip="`Toggle filter for ${gameVersion}`"
|
||||
class="tag-list__item z-[1] cursor-pointer hover:underline"
|
||||
@click="versionFilters.toggleFilters('gameVersion', version.game_versions)"
|
||||
>
|
||||
{{ gameVersion }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="tag-list">
|
||||
<div
|
||||
v-for="platform in version.loaders"
|
||||
:key="`platform-tag-${platform}`"
|
||||
v-tooltip="`Toggle filter for ${platform}`"
|
||||
:class="`tag-list__item z-[1] cursor-pointer hover:underline`"
|
||||
:style="`--_color: var(--color-platform-${platform})`"
|
||||
@click="versionFilters.toggleFilter('platform', platform)"
|
||||
>
|
||||
<svg v-html="tags.loaders.find((x) => x.name === platform).icon"></svg>
|
||||
{{ formatCategory(platform) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col justify-center gap-1 max-sm:flex-row max-sm:justify-start max-sm:gap-3 xl:contents"
|
||||
>
|
||||
<div
|
||||
v-tooltip="
|
||||
formatMessage(commonMessages.dateAtTimeTooltip, {
|
||||
date: new Date(version.date_published),
|
||||
time: new Date(version.date_published),
|
||||
})
|
||||
"
|
||||
class="z-[1] flex cursor-help items-center gap-1 text-nowrap font-medium xl:self-center"
|
||||
>
|
||||
<CalendarIcon class="xl:hidden" />
|
||||
{{ formatRelativeTime(version.date_published) }}
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none z-[1] flex items-center gap-1 font-medium xl:self-center"
|
||||
>
|
||||
<DownloadIcon class="xl:hidden" />
|
||||
{{ formatCompactNumber(version.downloads) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start justify-end gap-1 sm:items-center">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<a
|
||||
v-tooltip="`Download`"
|
||||
:href="getPrimaryFile(version).url"
|
||||
class="z-[1] group-hover:!bg-brand group-hover:!text-brand-inverted"
|
||||
aria-label="Download"
|
||||
@click="emits('onDownload')"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<OverflowMenu
|
||||
class="group-hover:!bg-button-bg"
|
||||
:options="[
|
||||
{
|
||||
id: 'download',
|
||||
color: 'primary',
|
||||
hoverFilled: true,
|
||||
link: getPrimaryFile(version).url,
|
||||
action: () => {
|
||||
emits('onDownload');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'new-tab',
|
||||
action: () => {},
|
||||
link: `/${project.project_type}/${
|
||||
<template #actions="{ version }">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<a
|
||||
v-tooltip="`Download`"
|
||||
:href="getPrimaryFile(version).url"
|
||||
class="group-hover:!bg-brand group-hover:[&>svg]:!text-brand-inverted"
|
||||
aria-label="Download"
|
||||
@click="emits('onDownload')"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<OverflowMenu
|
||||
class="group-hover:!bg-button-bg"
|
||||
:dropdown-id="`${baseDropdownId}-${version.id}`"
|
||||
:options="[
|
||||
{
|
||||
id: 'download',
|
||||
color: 'primary',
|
||||
hoverFilled: true,
|
||||
link: getPrimaryFile(version).url,
|
||||
action: () => {
|
||||
emits('onDownload');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'new-tab',
|
||||
action: () => {},
|
||||
link: `/${project.project_type}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}/version/${encodeURI(version.displayUrlEnding)}`,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: 'copy-link',
|
||||
action: () =>
|
||||
copyToClipboard(
|
||||
`https://modrinth.com/${project.project_type}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}/version/${encodeURI(version.displayUrlEnding)}`,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: 'copy-link',
|
||||
action: () =>
|
||||
copyToClipboard(
|
||||
`https://modrinth.com/${project.project_type}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}/version/${encodeURI(version.displayUrlEnding)}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'share',
|
||||
action: () => {},
|
||||
shown: false,
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
color: 'red',
|
||||
hoverFilled: true,
|
||||
action: () => reportVersion(version.id),
|
||||
shown: !currentMember,
|
||||
},
|
||||
{ divider: true, shown: currentMember },
|
||||
{
|
||||
id: 'edit',
|
||||
link: `/${project.project_type}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}/version/${encodeURI(version.displayUrlEnding)}/edit`,
|
||||
shown: currentMember,
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
color: 'red',
|
||||
hoverFilled: true,
|
||||
action: () => {},
|
||||
shown: currentMember && false,
|
||||
},
|
||||
]"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #download>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
Download
|
||||
</template>
|
||||
<template #new-tab>
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
Open in new tab
|
||||
</template>
|
||||
<template #copy-link>
|
||||
<LinkIcon aria-hidden="true" />
|
||||
Copy link
|
||||
</template>
|
||||
<template #share>
|
||||
<ShareIcon aria-hidden="true" />
|
||||
Share
|
||||
</template>
|
||||
<template #report>
|
||||
<ReportIcon aria-hidden="true" />
|
||||
Report
|
||||
</template>
|
||||
<template #edit>
|
||||
<EditIcon aria-hidden="true" />
|
||||
Edit
|
||||
</template>
|
||||
<template #delete>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
Delete
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div
|
||||
v-if="flags.showVersionFilesInTable"
|
||||
class="tag-list pointer-events-none relative z-[1] col-span-full"
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'share',
|
||||
action: () => {},
|
||||
shown: false,
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
color: 'red',
|
||||
hoverFilled: true,
|
||||
action: () => (auth.user ? reportVersion(version.id) : navigateTo('/auth/sign-in')),
|
||||
shown: !currentMember,
|
||||
},
|
||||
{ divider: true, shown: currentMember },
|
||||
{
|
||||
id: 'edit',
|
||||
link: `/${project.project_type}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}/version/${encodeURI(version.displayUrlEnding)}/edit`,
|
||||
shown: currentMember,
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
color: 'red',
|
||||
hoverFilled: true,
|
||||
action: () => {},
|
||||
shown: currentMember && false,
|
||||
},
|
||||
]"
|
||||
aria-label="More options"
|
||||
>
|
||||
<div
|
||||
v-for="(file, fileIdx) in version.files"
|
||||
:key="`platform-tag-${fileIdx}`"
|
||||
:class="`flex items-center gap-1 text-wrap rounded-full bg-button-bg px-2 py-0.5 text-xs font-medium ${file.primary || fileIdx === 0 ? 'bg-brand-highlight text-contrast' : 'text-primary'}`"
|
||||
>
|
||||
<StarIcon v-if="file.primary || fileIdx === 0" class="shrink-0" />
|
||||
{{ file.filename }} - {{ formatBytes(file.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #download>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
Download
|
||||
</template>
|
||||
<template #new-tab>
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
Open in new tab
|
||||
</template>
|
||||
<template #copy-link>
|
||||
<LinkIcon aria-hidden="true" />
|
||||
Copy link
|
||||
</template>
|
||||
<template #share>
|
||||
<ShareIcon aria-hidden="true" />
|
||||
Share
|
||||
</template>
|
||||
<template #report>
|
||||
<ReportIcon aria-hidden="true" />
|
||||
Report
|
||||
</template>
|
||||
<template #edit>
|
||||
<EditIcon aria-hidden="true" />
|
||||
Edit
|
||||
</template>
|
||||
<template #delete>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
Delete
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
<div class="my-3 flex justify-end">
|
||||
<Pagination
|
||||
:page="currentPage"
|
||||
:count="Math.ceil(filteredVersions.length / 20)"
|
||||
:link-function="(page) => `?page=${currentPage}`"
|
||||
@switch-page="switchPage"
|
||||
/>
|
||||
</div>
|
||||
</ProjectPageVersions>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ButtonStyled, OverflowMenu, FileInput, ProjectPageVersions } from "@modrinth/ui";
|
||||
import {
|
||||
ButtonStyled,
|
||||
OverflowMenu,
|
||||
Pagination,
|
||||
VersionChannelIndicator,
|
||||
FileInput,
|
||||
} from "@modrinth/ui";
|
||||
import {
|
||||
StarIcon,
|
||||
CalendarIcon,
|
||||
DownloadIcon,
|
||||
MoreVerticalIcon,
|
||||
TrashIcon,
|
||||
@@ -306,15 +157,9 @@ import {
|
||||
UploadIcon,
|
||||
InfoIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { formatBytes, formatCategory } from "@modrinth/utils";
|
||||
import { formatVersionsForDisplay } from "~/helpers/projects.js";
|
||||
import VersionFilterControl from "~/components/ui/VersionFilterControl.vue";
|
||||
import DropArea from "~/components/ui/DropArea.vue";
|
||||
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
|
||||
|
||||
const formatCompactNumber = useCompactNumber();
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
@@ -338,57 +183,18 @@ const props = defineProps({
|
||||
|
||||
const tags = useTags();
|
||||
const flags = useFeatureFlags();
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
const auth = await useAuth();
|
||||
|
||||
const emits = defineEmits(["onDownload"]);
|
||||
|
||||
const route = useNativeRoute();
|
||||
const router = useNativeRouter();
|
||||
|
||||
const currentPage = ref(route.query.page ?? 1);
|
||||
|
||||
function switchPage(page) {
|
||||
currentPage.value = page;
|
||||
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
page: currentPage.value !== 1 ? currentPage.value : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
const baseDropdownId = useId();
|
||||
|
||||
function getPrimaryFile(version) {
|
||||
return version.files.find((x) => x.primary) || version.files[0];
|
||||
}
|
||||
|
||||
const selectedGameVersions = computed(() => {
|
||||
return getArrayOrString(route.query.g) ?? [];
|
||||
});
|
||||
|
||||
const selectedPlatforms = computed(() => {
|
||||
return getArrayOrString(route.query.l) ?? [];
|
||||
});
|
||||
|
||||
const selectedVersionChannels = computed(() => {
|
||||
return getArrayOrString(route.query.c) ?? [];
|
||||
});
|
||||
|
||||
const versionFilters = ref(null);
|
||||
const filteredVersions = computed(() => {
|
||||
return props.versions.filter(
|
||||
(projectVersion) =>
|
||||
(selectedGameVersions.value.length === 0 ||
|
||||
selectedGameVersions.value.some((gameVersion) =>
|
||||
projectVersion.game_versions.includes(gameVersion),
|
||||
)) &&
|
||||
(selectedPlatforms.value.length === 0 ||
|
||||
selectedPlatforms.value.some((loader) => projectVersion.loaders.includes(loader))) &&
|
||||
(selectedVersionChannels.value.length === 0 ||
|
||||
selectedVersionChannels.value.includes(projectVersion.version_type)),
|
||||
);
|
||||
});
|
||||
|
||||
async function handleFiles(files) {
|
||||
await router.push({
|
||||
name: "type-id-version-version",
|
||||
@@ -407,8 +213,3 @@ async function copyToClipboard(text) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.versions-grid-row {
|
||||
@apply grid grid-cols-[1fr_min-content] gap-4 supports-[grid-template-columns:subgrid]:col-span-full supports-[grid-template-columns:subgrid]:!grid-cols-subgrid sm:grid-cols-[min-content_1fr_1fr_1fr_min-content] xl:grid-cols-[min-content_1fr_1fr_1fr_1fr_1fr_min-content];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -83,4 +83,20 @@ definePageMeta({
|
||||
gap: var(--gap-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.turnstile {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
border: 2px solid var(--color-button-bg);
|
||||
height: 65px;
|
||||
width: 100%;
|
||||
|
||||
> div {
|
||||
position: relative;
|
||||
top: -2px;
|
||||
min-width: calc(100% + 4px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button, Avatar } from "@modrinth/ui";
|
||||
import { Button, Avatar, commonMessages } from "@modrinth/ui";
|
||||
import { XIcon, CheckIcon } from "@modrinth/assets";
|
||||
import { useBaseFetch } from "@/composables/fetch.js";
|
||||
import { useAuth } from "@/composables/auth.js";
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { SendIcon, MailIcon, KeyIcon } from "@modrinth/assets";
|
||||
import { commonMessages } from "@modrinth/ui";
|
||||
import HCaptcha from "@/components/ui/HCaptcha.vue";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
@@ -134,6 +134,7 @@ import {
|
||||
KeyIcon,
|
||||
MailIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { commonMessages } from "@modrinth/ui";
|
||||
import HCaptcha from "@/components/ui/HCaptcha.vue";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
@@ -145,7 +145,7 @@ import {
|
||||
MailIcon,
|
||||
SSOGitLabIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { Checkbox } from "@modrinth/ui";
|
||||
import { Checkbox, commonMessages } from "@modrinth/ui";
|
||||
import HCaptcha from "@/components/ui/HCaptcha.vue";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Checkbox } from "@modrinth/ui";
|
||||
import { Checkbox, commonMessages } from "@modrinth/ui";
|
||||
import { RightArrowIcon } from "@modrinth/assets";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
@@ -380,7 +380,14 @@ import {
|
||||
LibraryIcon,
|
||||
BoxIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { PopoutMenu, FileInput, DropdownSelect, Avatar, Button } from "@modrinth/ui";
|
||||
import {
|
||||
PopoutMenu,
|
||||
FileInput,
|
||||
DropdownSelect,
|
||||
Avatar,
|
||||
Button,
|
||||
commonMessages,
|
||||
} from "@modrinth/ui";
|
||||
|
||||
import WorldIcon from "assets/images/utils/world.svg";
|
||||
import UpToDate from "assets/images/illustrations/up_to_date.svg";
|
||||
|
||||
@@ -42,17 +42,20 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { LibraryIcon, ChartIcon } from "@modrinth/assets";
|
||||
import {
|
||||
DashboardIcon,
|
||||
CurrencyIcon,
|
||||
ListIcon,
|
||||
ReportIcon,
|
||||
BellIcon as NotificationsIcon,
|
||||
OrganizationIcon,
|
||||
LibraryIcon,
|
||||
ChartIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { commonMessages } from "@modrinth/ui";
|
||||
import NavStack from "~/components/ui/NavStack.vue";
|
||||
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
||||
|
||||
import DashboardIcon from "~/assets/images/utils/dashboard.svg?component";
|
||||
import CurrencyIcon from "~/assets/images/utils/currency.svg?component";
|
||||
import ListIcon from "~/assets/images/utils/list.svg?component";
|
||||
import ReportIcon from "~/assets/images/utils/report.svg?component";
|
||||
import NotificationsIcon from "~/assets/images/utils/bell.svg?component";
|
||||
import OrganizationIcon from "~/assets/images/utils/organization.svg?component";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
definePageMeta({
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
<div class="collections-grid">
|
||||
<nuxt-link
|
||||
v-if="'followed projects'.includes(filterQuery)"
|
||||
v-if="'followed projects'.includes(filterQuery.toLowerCase())"
|
||||
:to="`/collection/following`"
|
||||
class="universal-card recessed collection"
|
||||
>
|
||||
@@ -95,7 +95,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { BoxIcon, SearchIcon, XIcon, PlusIcon, LinkIcon, LockIcon } from "@modrinth/assets";
|
||||
import { Avatar, Button } from "@modrinth/ui";
|
||||
import { Avatar, Button, commonMessages } from "@modrinth/ui";
|
||||
import WorldIcon from "~/assets/images/utils/world.svg?component";
|
||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||
|
||||
|
||||
@@ -301,6 +301,18 @@
|
||||
|
||||
<script>
|
||||
import { Multiselect } from "vue-multiselect";
|
||||
import {
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
XIcon as CrossIcon,
|
||||
IssuesIcon,
|
||||
EditIcon,
|
||||
SaveIcon,
|
||||
SortAscendingIcon as AscendingIcon,
|
||||
SortDescendingIcon as DescendingIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { commonMessages } from "@modrinth/ui";
|
||||
|
||||
import Badge from "~/components/ui/Badge.vue";
|
||||
import Checkbox from "~/components/ui/Checkbox.vue";
|
||||
@@ -309,16 +321,6 @@ import Avatar from "~/components/ui/Avatar.vue";
|
||||
import ModalCreation from "~/components/ui/ModalCreation.vue";
|
||||
import CopyCode from "~/components/ui/CopyCode.vue";
|
||||
|
||||
import SettingsIcon from "~/assets/images/utils/settings.svg?component";
|
||||
import TrashIcon from "~/assets/images/utils/trash.svg?component";
|
||||
import IssuesIcon from "~/assets/images/utils/issues.svg?component";
|
||||
import PlusIcon from "~/assets/images/utils/plus.svg?component";
|
||||
import CrossIcon from "~/assets/images/utils/x.svg?component";
|
||||
import EditIcon from "~/assets/images/utils/edit.svg?component";
|
||||
import SaveIcon from "~/assets/images/utils/save.svg?component";
|
||||
import AscendingIcon from "~/assets/images/utils/sort-asc.svg?component";
|
||||
import DescendingIcon from "~/assets/images/utils/sort-desc.svg?component";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
Avatar,
|
||||
@@ -371,6 +373,7 @@ export default defineNuxtComponent({
|
||||
clear: false,
|
||||
},
|
||||
},
|
||||
commonMessages,
|
||||
};
|
||||
},
|
||||
head: {
|
||||
|
||||
@@ -21,108 +21,110 @@
|
||||
within the last twelve (12) months:
|
||||
</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Examples</th>
|
||||
<th>Collected</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>A. Identifiers.</td>
|
||||
<td>
|
||||
A real name, alias, postal address, unique personal identifier, online identifier,
|
||||
Internet Protocol address, email address, account name, Social Security number, driver's
|
||||
license number, passport number, or other similar identifiers.
|
||||
</td>
|
||||
<td>YES</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
B. Personal information categories listed in the California Customer Records statute (Cal.
|
||||
Civ. Code § 1798.80(e)).
|
||||
</td>
|
||||
<td>
|
||||
A name, signature, Social Security number, physical characteristics or description,
|
||||
address, telephone number, passport number, driver's license or state identification card
|
||||
number, insurance policy number, education, employment, employment history, bank account
|
||||
number, credit card number, debit card number, or any other financial information, medical
|
||||
information, or health insurance information. <br /><br />
|
||||
Some personal information included in this category may overlap with other categories.
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>C. Protected classification characteristics.</td>
|
||||
<td>
|
||||
Age (40 years or older), race, color, ancestry, national origin, citizenship, religion or
|
||||
creed, marital status, medical condition, physical or mental disability, sex (including
|
||||
gender, gender identity, gender expression, pregnancy or childbirth and related medical
|
||||
conditions), sexual orientation, veteran or military status, genetic information
|
||||
(including familial genetic information).
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>D. Commercial information.</td>
|
||||
<td>
|
||||
Records of personal property, products or services purchased, obtained, or considered, or
|
||||
other purchasing or consuming histories or tendencies.
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>E. Biometric information.</td>
|
||||
<td>
|
||||
Genetic, physiological, behavioral, and biological characteristics, or activity patterns
|
||||
used to extract a template or other identifier or identifying information, such as,
|
||||
fingerprints, faceprints, and voiceprints, iris or retina scans, keystroke, gait, or other
|
||||
physical patterns, and sleep, health, or exercise data.
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>F. Internet or other similar network activity.</td>
|
||||
<td>
|
||||
Browsing history, search history, information on a consumer's interaction with a website,
|
||||
application, or advertisement.
|
||||
</td>
|
||||
<td>YES</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>G. Geolocation data.</td>
|
||||
<td>Physical location or movements.</td>
|
||||
<td>YES</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>H. Sensory data.</td>
|
||||
<td>Audio, electronic, visual, thermal, olfactory, or similar information.</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>I. Professional or employment-related information.</td>
|
||||
<td>Current or past job history or performance evaluations.</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
J. Non-public education information (per the Family Educational Rights and Privacy Act (20
|
||||
U.S.C. Section 1232g, 34 C.F.R. Part 99)).
|
||||
</td>
|
||||
<td>
|
||||
Education records directly related to a student maintained by an educational institution
|
||||
or party acting on its behalf, such as grades, transcripts, class lists, student
|
||||
schedules, student identification codes, student financial information, or student
|
||||
disciplinary records.
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>K. Inferences drawn from other personal information.</td>
|
||||
<td>
|
||||
Profile reflecting a person's preferences, characteristics, psychological trends,
|
||||
predispositions, behavior, attitudes, intelligence, abilities, and aptitudes.
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Examples</th>
|
||||
<th>Collected</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>A. Identifiers.</td>
|
||||
<td>
|
||||
A real name, alias, postal address, unique personal identifier, online identifier,
|
||||
Internet Protocol address, email address, account name, Social Security number, driver's
|
||||
license number, passport number, or other similar identifiers.
|
||||
</td>
|
||||
<td>YES</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
B. Personal information categories listed in the California Customer Records statute
|
||||
(Cal. Civ. Code § 1798.80(e)).
|
||||
</td>
|
||||
<td>
|
||||
A name, signature, Social Security number, physical characteristics or description,
|
||||
address, telephone number, passport number, driver's license or state identification
|
||||
card number, insurance policy number, education, employment, employment history, bank
|
||||
account number, credit card number, debit card number, or any other financial
|
||||
information, medical information, or health insurance information. <br /><br />
|
||||
Some personal information included in this category may overlap with other categories.
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>C. Protected classification characteristics.</td>
|
||||
<td>
|
||||
Age (40 years or older), race, color, ancestry, national origin, citizenship, religion
|
||||
or creed, marital status, medical condition, physical or mental disability, sex
|
||||
(including gender, gender identity, gender expression, pregnancy or childbirth and
|
||||
related medical conditions), sexual orientation, veteran or military status, genetic
|
||||
information (including familial genetic information).
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>D. Commercial information.</td>
|
||||
<td>
|
||||
Records of personal property, products or services purchased, obtained, or considered,
|
||||
or other purchasing or consuming histories or tendencies.
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>E. Biometric information.</td>
|
||||
<td>
|
||||
Genetic, physiological, behavioral, and biological characteristics, or activity patterns
|
||||
used to extract a template or other identifier or identifying information, such as,
|
||||
fingerprints, faceprints, and voiceprints, iris or retina scans, keystroke, gait, or
|
||||
other physical patterns, and sleep, health, or exercise data.
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>F. Internet or other similar network activity.</td>
|
||||
<td>
|
||||
Browsing history, search history, information on a consumer's interaction with a
|
||||
website, application, or advertisement.
|
||||
</td>
|
||||
<td>YES</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>G. Geolocation data.</td>
|
||||
<td>Physical location or movements.</td>
|
||||
<td>YES</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>H. Sensory data.</td>
|
||||
<td>Audio, electronic, visual, thermal, olfactory, or similar information.</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>I. Professional or employment-related information.</td>
|
||||
<td>Current or past job history or performance evaluations.</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
J. Non-public education information (per the Family Educational Rights and Privacy Act
|
||||
(20 U.S.C. Section 1232g, 34 C.F.R. Part 99)).
|
||||
</td>
|
||||
<td>
|
||||
Education records directly related to a student maintained by an educational institution
|
||||
or party acting on its behalf, such as grades, transcripts, class lists, student
|
||||
schedules, student identification codes, student financial information, or student
|
||||
disciplinary records.
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>K. Inferences drawn from other personal information.</td>
|
||||
<td>
|
||||
Profile reflecting a person's preferences, characteristics, psychological trends,
|
||||
predispositions, behavior, attitudes, intelligence, abilities, and aptitudes.
|
||||
</td>
|
||||
<td>NO</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Personal information does not include:</p>
|
||||
<ul>
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
<p>
|
||||
We aim to be as transparent as possible with creator revenue. All of our code is open source,
|
||||
including our
|
||||
<a href="https://github.com/modrinth/labrinth/blob/master/src/queue/payouts.rs#L561">
|
||||
<a href="https://github.com/modrinth/code/blob/main/apps/labrinth/src/queue/payouts.rs#L598">
|
||||
revenue distribution system </a
|
||||
>. We also have an
|
||||
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a> that allows users
|
||||
|
||||
@@ -178,9 +178,9 @@ if (projects.value) {
|
||||
|
||||
projects.value = projects.value.map((project) => {
|
||||
project.owner = members.value
|
||||
.flat()
|
||||
.find((x) => x.team_id === project.team_id && x.role === "Owner");
|
||||
project.org = orgs.value.find((x) => x.id === project.organization);
|
||||
? members.value.flat().find((x) => x.team_id === project.team_id && x.role === "Owner")
|
||||
: null;
|
||||
project.org = orgs.value ? orgs.value.find((x) => x.id === project.organization) : null;
|
||||
project.age = project.queued ? now - app.$dayjs(project.queued) : Number.MAX_VALUE;
|
||||
project.age_warning = "";
|
||||
if (project.age > TIME_24H * 2) {
|
||||
|
||||
@@ -84,14 +84,14 @@
|
||||
</template>
|
||||
<template #stats>
|
||||
<div
|
||||
class="flex items-center gap-2 border-0 border-r border-solid border-button-bg pr-4 font-semibold"
|
||||
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
|
||||
>
|
||||
<UsersIcon class="h-6 w-6 text-secondary" />
|
||||
{{ formatCompactNumber(acceptedMembers?.length || 0) }}
|
||||
members
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 border-0 border-r border-solid border-button-bg pr-4 font-semibold"
|
||||
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
|
||||
>
|
||||
<BoxIcon class="h-6 w-6 text-secondary" />
|
||||
{{ formatCompactNumber(projects?.length || 0) }}
|
||||
@@ -252,7 +252,14 @@ import {
|
||||
XIcon,
|
||||
ClipboardCopyIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { Avatar, ButtonStyled, Breadcrumbs, ContentPageHeader, OverflowMenu } from "@modrinth/ui";
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
Breadcrumbs,
|
||||
ContentPageHeader,
|
||||
OverflowMenu,
|
||||
commonMessages,
|
||||
} from "@modrinth/ui";
|
||||
import NavStack from "~/components/ui/NavStack.vue";
|
||||
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
||||
import ModalCreation from "~/components/ui/ModalCreation.vue";
|
||||
|
||||
@@ -311,7 +311,7 @@ import {
|
||||
SortAscendingIcon,
|
||||
SortDescendingIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { Button, Modal, Avatar, CopyCode, Badge, Checkbox } from "@modrinth/ui";
|
||||
import { Button, Modal, Avatar, CopyCode, Badge, Checkbox, commonMessages } from "@modrinth/ui";
|
||||
|
||||
import ModalCreation from "~/components/ui/ModalCreation.vue";
|
||||
import OrganizationProjectTransferModal from "~/components/ui/OrganizationProjectTransferModal.vue";
|
||||
|
||||
@@ -98,6 +98,13 @@ import { useImageUpload } from "~/composables/image-upload.ts";
|
||||
|
||||
const tags = useTags();
|
||||
const route = useNativeRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const auth = await useAuth();
|
||||
|
||||
if (!auth.value.user) {
|
||||
router.push("/auth/sign-in?redirect=" + encodeURIComponent(route.fullPath));
|
||||
}
|
||||
|
||||
const accessQuery = (id: string): string => {
|
||||
return route.query?.[id]?.toString() || "";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,53 @@
|
||||
<template>
|
||||
<div class="contents">
|
||||
<div
|
||||
v-if="server.error && server.error.message.includes('Forbidden')"
|
||||
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'upgrading'"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
|
||||
<TransferIcon class="size-12 text-blue" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server Upgrading</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="serverData?.status === 'suspended'"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<LockIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server Suspended</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
{{
|
||||
serverData.suspension_reason
|
||||
? `Your server has been suspended: ${serverData.suspension_reason}`
|
||||
: "Your server has been suspended."
|
||||
}}
|
||||
<br />
|
||||
This is most likely due to a billing issue. Please check your billing information and
|
||||
contact Modrinth support if you believe this is an error.
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
|
||||
<button class="mt-6 !w-full">Go to billing settings</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.error && server.error.message.includes('Forbidden')"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
@@ -58,10 +104,11 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SERVER START -->
|
||||
<div
|
||||
v-else-if="serverData"
|
||||
data-pyro-server-manager-root
|
||||
class="experimental-styles-within mobile-blurred-servericon relative mx-auto box-border flex min-h-screen w-full min-w-0 max-w-[1280px] flex-col gap-6 px-3 transition-all duration-300"
|
||||
class="experimental-styles-within mobile-blurred-servericon relative mx-auto box-border flex min-h-screen w-full min-w-0 max-w-[1280px] flex-col gap-6 px-6 transition-all duration-300"
|
||||
:style="{
|
||||
'--server-bg-image': serverData.image
|
||||
? `url(${serverData.image})`
|
||||
@@ -128,11 +175,11 @@
|
||||
class="mx-auto mb-4 flex justify-between gap-2 rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-4">
|
||||
<IssuesIcon class="hidden h-8 w-8 text-red sm:block" />
|
||||
<IssuesIcon class="hidden h-8 w-8 shrink-0 text-red sm:block" />
|
||||
<div class="flex flex-col gap-2 leading-[150%]">
|
||||
<div class="flex items-center gap-3">
|
||||
<IssuesIcon class="block h-8 w-8 text-red sm:hidden" />
|
||||
<div class="flex gap-2 text-xl font-bold">{{ errorTitle }}</div>
|
||||
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
|
||||
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -175,6 +222,14 @@
|
||||
reinstalling your server, and if the problem persists, please contact Modrinth
|
||||
support with your server's debug information.
|
||||
</div>
|
||||
<div
|
||||
v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'"
|
||||
>
|
||||
An error occurred while installing your server because Modrinth Servers does not
|
||||
support the version of Minecraft or the loader you specified. Try reinstalling
|
||||
your server with a different version or loader, and if the problem persists,
|
||||
please contact Modrinth support with your server's debug information.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorTitle === 'Installation error'"
|
||||
@@ -240,7 +295,6 @@
|
||||
:stats="stats"
|
||||
:server-power-state="serverPowerState"
|
||||
:power-state-details="powerStateDetails"
|
||||
:console-output="throttledConsoleOutput"
|
||||
:socket="socket"
|
||||
:server="server"
|
||||
@reinstall="onReinstall"
|
||||
@@ -262,12 +316,14 @@ import {
|
||||
CheckIcon,
|
||||
FileIcon,
|
||||
TransferIcon,
|
||||
LockIcon,
|
||||
} from "@modrinth/assets";
|
||||
import DOMPurify from "dompurify";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { refThrottled } from "@vueuse/core";
|
||||
import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
|
||||
import { reloadNuxtApp } from "#app";
|
||||
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
|
||||
import { usePyroConsole } from "~/store/console.ts";
|
||||
|
||||
const socket = ref<WebSocket | null>(null);
|
||||
const isReconnecting = ref(false);
|
||||
@@ -294,7 +350,7 @@ const router = useRouter();
|
||||
const serverId = route.params.id as string;
|
||||
const server = await usePyroServer(serverId, [
|
||||
"general",
|
||||
"mods",
|
||||
"content",
|
||||
"backups",
|
||||
"network",
|
||||
"startup",
|
||||
@@ -305,6 +361,7 @@ const server = await usePyroServer(serverId, [
|
||||
watch(
|
||||
() => server.error,
|
||||
(newError) => {
|
||||
if (server.general?.status === "suspended") return;
|
||||
if (newError && !newError.message.includes("Forbidden")) {
|
||||
startPolling();
|
||||
}
|
||||
@@ -319,9 +376,8 @@ const serverData = computed(() => server.general);
|
||||
const error = ref<Error | null>(null);
|
||||
const isConnected = ref(false);
|
||||
const isWSAuthIncorrect = ref(false);
|
||||
const maxConsoleOutput = 5000;
|
||||
const consoleOutput = ref<string[]>([]);
|
||||
const throttledConsoleOutput = refThrottled(consoleOutput, 200);
|
||||
const pyroConsole = usePyroConsole();
|
||||
console.log("||||||||||||||||||||||| console", pyroConsole.output);
|
||||
const cpuData = ref<number[]>([]);
|
||||
const ramData = ref<number[]>([]);
|
||||
const isActioning = ref(false);
|
||||
@@ -401,7 +457,7 @@ const connectWebSocket = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
consoleOutput.value = [];
|
||||
pyroConsole.clear();
|
||||
socket.value?.send(JSON.stringify({ event: "auth", jwt: wsAuth.value?.token }));
|
||||
isConnected.value = true;
|
||||
isReconnecting.value = false;
|
||||
@@ -409,7 +465,7 @@ const connectWebSocket = () => {
|
||||
|
||||
if (firstConnect.value) {
|
||||
for (let i = 0; i < initialConsoleMessage.length; i++) {
|
||||
consoleOutput.value.push(initialConsoleMessage[i]);
|
||||
pyroConsole.addLine(initialConsoleMessage[i]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,9 +488,7 @@ const connectWebSocket = () => {
|
||||
|
||||
socket.value.onclose = () => {
|
||||
if (isMounted.value) {
|
||||
consoleOutput.value.push(
|
||||
"\nSomething went wrong with the connection, we're reconnecting...",
|
||||
);
|
||||
pyroConsole.addLine("\nSomething went wrong with the connection, we're reconnecting...");
|
||||
isConnected.value = false;
|
||||
scheduleReconnect();
|
||||
}
|
||||
@@ -492,10 +546,7 @@ const handleWebSocketMessage = (data: WSEvent) => {
|
||||
case "log":
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const log = data.message.split("\n").filter((l) => l.trim());
|
||||
if (consoleOutput.value.length > maxConsoleOutput) {
|
||||
consoleOutput.value.shift();
|
||||
}
|
||||
consoleOutput.value.push(...log);
|
||||
pyroConsole.addLines(log);
|
||||
break;
|
||||
case "stats":
|
||||
updateStats(data);
|
||||
@@ -518,7 +569,7 @@ const handleWebSocketMessage = (data: WSEvent) => {
|
||||
handleInstallationResult(data);
|
||||
break;
|
||||
case "new-mod":
|
||||
server.refresh(["mods"]);
|
||||
server.refresh(["content"]);
|
||||
console.log("New mod:", data);
|
||||
break;
|
||||
case "auth-ok":
|
||||
@@ -562,17 +613,22 @@ const handleInstallationResult = async (data: WSInstallationResultEvent) => {
|
||||
errorMessage.value = data.reason ?? "Unknown error";
|
||||
error.value = new Error(data.reason ?? "Unknown error");
|
||||
let files = await server.fs?.listDirContents("/", 1, 100);
|
||||
if (files.total > 1) {
|
||||
for (let i = 1; i < files.total; i++) {
|
||||
files = await server.fs?.listDirContents("/", i, 100);
|
||||
if (files.items?.length === 0) break;
|
||||
if (files) {
|
||||
if (files.total > 1) {
|
||||
for (let i = 1; i < files.total; i++) {
|
||||
const nextFiles = await server.fs?.listDirContents("/", i, 100);
|
||||
if (nextFiles?.items?.length === 0) break;
|
||||
if (nextFiles) files = nextFiles;
|
||||
}
|
||||
}
|
||||
}
|
||||
const fileName = await files.items?.find((file: { name: string }) =>
|
||||
const fileName = files?.items?.find((file: { name: string }) =>
|
||||
file.name.startsWith("modrinth-installation"),
|
||||
)?.name;
|
||||
errorLogFile.value = fileName;
|
||||
errorLog.value = await server.fs?.downloadFile(fileName);
|
||||
errorLogFile.value = fileName ?? "";
|
||||
if (fileName) {
|
||||
errorLog.value = await server.fs?.downloadFile(fileName);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -585,7 +641,7 @@ const onReinstall = (potentialArgs: any) => {
|
||||
// serverData.value.loader_version = potentialArgs.lVersion;
|
||||
// serverData.value.mc_version = potentialArgs.mVersion;
|
||||
// if (potentialArgs?.loader) {
|
||||
// console.log("setting loader to", potentialArgs.loader);
|
||||
// console.log("setting loadeconsole
|
||||
// serverData.value.loader = potentialArgs.loader;
|
||||
// }
|
||||
// if (potentialArgs?.lVersion) {
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
v-tooltip="'Backup in progress'"
|
||||
class="size-6 animate-spin"
|
||||
/>
|
||||
<LockIcon v-else-if="backup.locked" class="size-8" />
|
||||
<BoxIcon v-else class="size-8" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
@@ -159,6 +160,16 @@
|
||||
},
|
||||
},
|
||||
{ id: 'download', action: () => initiateDownload(backup.id) },
|
||||
{
|
||||
id: 'lock',
|
||||
action: () => {
|
||||
if (backup.locked) {
|
||||
unlockBackup(backup.id);
|
||||
} else {
|
||||
lockBackup(backup.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
action: () => {
|
||||
@@ -172,6 +183,8 @@
|
||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||
<template #rename> <EditIcon /> Rename </template>
|
||||
<template #restore> <ClipboardCopyIcon /> Restore </template>
|
||||
<template v-if="backup.locked" #lock> <LockOpenIcon /> Unlock </template>
|
||||
<template v-else #lock> <LockIcon /> Lock </template>
|
||||
<template #download> <DownloadIcon /> Download </template>
|
||||
<template #delete> <TrashIcon /> Delete </template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
@@ -217,12 +230,14 @@ import {
|
||||
TrashIcon,
|
||||
SettingsIcon,
|
||||
BoxIcon,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ref, computed } from "vue";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
isServerRunning: boolean;
|
||||
}>();
|
||||
|
||||
@@ -335,6 +350,24 @@ const initiateDownload = async (backupId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const lockBackup = async (backupId: string) => {
|
||||
try {
|
||||
await props.server.backups?.lock(backupId);
|
||||
await props.server.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle lock:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const unlockBackup = async (backupId: string) => {
|
||||
try {
|
||||
await props.server.backups?.unlock(backupId);
|
||||
await props.server.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle lock:", error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
watchEffect(() => {
|
||||
const hasOngoingBackups = backups.value.some((backup) => backup.ongoing);
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Server } from "~/composables/pyroServers";
|
||||
const route = useNativeRoute();
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const data = computed(() => props.server.general);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<NewModal ref="modModal" header="Edit mod version">
|
||||
<NewModal ref="modModal" header="Editing mod version">
|
||||
<div>
|
||||
<div class="mb-4 flex flex-col gap-4">
|
||||
<div class="inline-flex flex-wrap items-center">
|
||||
@@ -19,7 +19,8 @@
|
||||
<InfoIcon class="hidden sm:block" />
|
||||
<span class="text-sm text-secondary">
|
||||
Your server was created from a modpack. Changing the mod version may cause unexpected
|
||||
issues.
|
||||
issues. You can update the modpack version in your server's Options > Platform
|
||||
settings.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,7 +73,7 @@
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
placeholder="Search mods..."
|
||||
:placeholder="`Search ${type.toLocaleLowerCase()}s...`"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
@@ -80,7 +81,7 @@
|
||||
<UiServersTeleportOverflowMenu
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Filter mods"
|
||||
:aria-label="`Filter ${type}s`"
|
||||
:options="[
|
||||
{ id: 'all', action: () => (filterMethod = 'all') },
|
||||
{ id: 'enabled', action: () => (filterMethod = 'enabled') },
|
||||
@@ -92,7 +93,7 @@
|
||||
</span>
|
||||
<FilterIcon aria-hidden="true" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #all> All mods </template>
|
||||
<template #all> All {{ type.toLocaleLowerCase() }}s </template>
|
||||
<template #enabled> Only enabled </template>
|
||||
<template #disabled> Only disabled </template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
@@ -101,10 +102,10 @@
|
||||
<ButtonStyled v-if="hasMods" color="brand" type="outlined">
|
||||
<nuxt-link
|
||||
class="w-full text-nowrap sm:w-fit"
|
||||
:to="`/mods?sid=${props.server.serverId}`"
|
||||
:to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
|
||||
>
|
||||
<PlusIcon />
|
||||
Add content
|
||||
Add {{ type.toLocaleLowerCase() }}
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -227,14 +228,50 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-4 flex h-full flex-col items-center justify-center text-center">
|
||||
<PackageClosedIcon class="size-24 text-neutral-500" />
|
||||
<p class="m-0 pb-2 pt-3 text-neutral-200">No mods found!</p>
|
||||
<p class="m-0 pb-3 text-neutral-400">Add some mods to your server to manage them here.</p>
|
||||
<ButtonStyled color="brand" class="mt-8">
|
||||
<nuxt-link :to="`/mods?sid=${props.server.serverId}`">Add content</nuxt-link>
|
||||
<!-- no mods has platform -->
|
||||
<div
|
||||
v-else-if="
|
||||
!hasMods &&
|
||||
props.server.general?.loader &&
|
||||
props.server.general?.loader.toLocaleLowerCase() !== 'vanilla'
|
||||
"
|
||||
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
||||
>
|
||||
<PackageClosedIcon class="size-24" />
|
||||
<p class="m-0 font-bold text-contrast">No {{ type.toLocaleLowerCase() }}s found!</p>
|
||||
<p class="m-0">
|
||||
Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here.
|
||||
</p>
|
||||
<ButtonStyled color="brand">
|
||||
<NuxtLink :to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`">
|
||||
<PlusIcon />
|
||||
Add {{ type.toLocaleLowerCase() }}
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-else class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center">
|
||||
<UiServersIconsLoaderIcon loader="Vanilla" class="size-24" />
|
||||
<p class="m-0 pt-3 font-bold text-contrast">Your server is running Vanilla Minecraft</p>
|
||||
<p class="m-0">
|
||||
Add content to your server by installing a modpack or choosing a different platform that
|
||||
supports {{ type }}s.
|
||||
</p>
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<ButtonStyled class="mt-8">
|
||||
<NuxtLink :to="`/modpacks?sid=${props.server.serverId}`">
|
||||
<CompassIcon />
|
||||
Find a modpack
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
<div>or</div>
|
||||
<ButtonStyled class="mt-8">
|
||||
<NuxtLink :to="`/servers/manage/${props.server.serverId}/options/loader`">
|
||||
<WrenchIcon />
|
||||
Change platform
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -251,15 +288,22 @@ import {
|
||||
XIcon,
|
||||
PlusIcon,
|
||||
MoreVerticalIcon,
|
||||
CompassIcon,
|
||||
WrenchIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const type = computed(() => {
|
||||
const loader = props.server.general?.loader?.toLowerCase();
|
||||
return loader === "paper" || loader === "purpur" ? "Plugin" : "Mod";
|
||||
});
|
||||
|
||||
interface Mod {
|
||||
name?: string;
|
||||
filename: string;
|
||||
@@ -291,7 +335,7 @@ const filterMethodLabel = computed(() => {
|
||||
case "enabled":
|
||||
return "Only enabled";
|
||||
default:
|
||||
return "All mods";
|
||||
return `All ${type.value.toLocaleLowerCase()}s`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -350,7 +394,7 @@ onUnmounted(() => {
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.server.mods?.data,
|
||||
() => props.server.content?.data,
|
||||
(newMods) => {
|
||||
if (newMods) {
|
||||
localMods.value = [...newMods];
|
||||
@@ -399,7 +443,7 @@ async function toggleMod(mod: Mod) {
|
||||
|
||||
await props.server.fs?.moveFileOrFolder(sourcePath, destinationPath);
|
||||
|
||||
await props.server.refresh(["general", "mods"]);
|
||||
await props.server.refresh(["general", "content"]);
|
||||
} catch (error) {
|
||||
mod.filename = originalFilename;
|
||||
mod.disabled = originalFilename.endsWith(".disabled");
|
||||
@@ -418,8 +462,11 @@ async function removeMod(mod: Mod) {
|
||||
mod.changing = true;
|
||||
|
||||
try {
|
||||
await props.server.mods?.remove(`/mods/${mod.filename}`);
|
||||
await props.server.refresh(["general", "mods"]);
|
||||
await props.server.content?.remove(
|
||||
type.value as "Mod" | "Plugin",
|
||||
`/${type.value.toLowerCase()}s/${mod.filename}`,
|
||||
);
|
||||
await props.server.refresh(["general", "content"]);
|
||||
} catch (error) {
|
||||
console.error("Error removing mod:", error);
|
||||
|
||||
@@ -439,7 +486,12 @@ const currentVersion = ref();
|
||||
|
||||
async function beginChangeModVersion(mod: Mod) {
|
||||
currentMod.value = mod;
|
||||
currentVersions.value = await useBaseFetch(`project/${mod.project_id}/version`, {}, false, true);
|
||||
currentVersions.value = await useBaseFetch(`project/${mod.project_id}/version`, {}, false);
|
||||
|
||||
currentVersions.value = currentVersions.value.filter((version: any) =>
|
||||
version.loaders.includes(props.server.general?.loader?.toLowerCase()),
|
||||
);
|
||||
|
||||
currentVersion.value = currentVersions.value.find(
|
||||
(version: any) => version.id === mod.version_id,
|
||||
);
|
||||
@@ -450,9 +502,12 @@ async function changeModVersion() {
|
||||
currentMod.value.changing = true;
|
||||
try {
|
||||
modModal.value.hide();
|
||||
await props.server.mods?.remove(`/mods/${currentMod.value.filename}`);
|
||||
await props.server.mods?.install(currentMod.value.project_id, currentVersion.value.id);
|
||||
await props.server.refresh(["general", "mods"]);
|
||||
await props.server.content?.reinstall(
|
||||
type.value,
|
||||
currentMod.value.version_id,
|
||||
currentVersion.value.id,
|
||||
);
|
||||
await props.server.refresh(["general", "content"]);
|
||||
} catch (error) {
|
||||
console.error("Error changing mod version:", error);
|
||||
}
|
||||
|
||||
@@ -33,17 +33,106 @@
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<div ref="mainContent" class="relative isolate flex w-full flex-col">
|
||||
<UiServersFilesBrowseNavbar
|
||||
v-if="!isEditing"
|
||||
:breadcrumb-segments="breadcrumbSegments"
|
||||
:search-query="searchQuery"
|
||||
:sort-method="sortMethod"
|
||||
@navigate="navigateToSegment"
|
||||
@sort="sortFiles"
|
||||
@create="showCreateModal"
|
||||
@upload="initiateFileUpload"
|
||||
@update:search-query="searchQuery = $event"
|
||||
/>
|
||||
<div v-if="!isEditing" class="contents">
|
||||
<UiServersFilesBrowseNavbar
|
||||
:breadcrumb-segments="breadcrumbSegments"
|
||||
:search-query="searchQuery"
|
||||
:sort-method="sortMethod"
|
||||
@navigate="navigateToSegment"
|
||||
@sort="sortFiles"
|
||||
@create="showCreateModal"
|
||||
@upload="initiateFileUpload"
|
||||
@update:search-query="searchQuery = $event"
|
||||
/>
|
||||
<Transition
|
||||
name="upload-status"
|
||||
@enter="onUploadStatusEnter"
|
||||
@leave="onUploadStatusLeave"
|
||||
>
|
||||
<div
|
||||
v-if="isUploading"
|
||||
ref="uploadStatusRef"
|
||||
class="upload-status rounded-b-xl border-0 border-t border-solid border-bg bg-table-alternateRow text-contrast"
|
||||
>
|
||||
<div class="flex flex-col p-4 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 font-bold">
|
||||
<FolderOpenIcon class="size-4" />
|
||||
<span>
|
||||
File Uploads{{
|
||||
activeUploads.length > 0 ? ` - ${activeUploads.length} left` : ""
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-2">
|
||||
<div
|
||||
v-for="item in uploadQueue"
|
||||
:key="item.file.name"
|
||||
class="flex h-6 items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-2 truncate">
|
||||
<transition-group name="status-icon" mode="out-in">
|
||||
<UiServersPanelSpinner
|
||||
v-show="item.status === 'uploading'"
|
||||
key="spinner"
|
||||
class="absolute !size-4"
|
||||
/>
|
||||
<CheckCircleIcon
|
||||
v-show="item.status === 'completed'"
|
||||
key="check"
|
||||
class="absolute size-4 text-green"
|
||||
/>
|
||||
<XCircleIcon
|
||||
v-show="item.status === 'error' || item.status === 'cancelled'"
|
||||
key="error"
|
||||
class="absolute size-4 text-red"
|
||||
/>
|
||||
</transition-group>
|
||||
<span class="ml-6 truncate">{{ item.file.name }}</span>
|
||||
<span class="text-secondary">{{ item.size }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[80px] items-center justify-end gap-2">
|
||||
<template v-if="item.status === 'completed'">
|
||||
<span>Done</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error'">
|
||||
<span class="text-red">Failed - File already exists</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="item.status === 'uploading'">
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
|
||||
<button>Cancel</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'cancelled'">
|
||||
<span class="text-red">Cancelled</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<UiServersFilesEditingNavbar
|
||||
v-else
|
||||
@@ -64,7 +153,20 @@
|
||||
:is="VAceEditor"
|
||||
v-if="!isEditingImage"
|
||||
v-model:value="fileContent"
|
||||
lang="json"
|
||||
:lang="
|
||||
(() => {
|
||||
const ext = editingFile?.name?.split('.')?.pop()?.toLowerCase() ?? '';
|
||||
return ext === 'json'
|
||||
? 'json'
|
||||
: ext === 'toml'
|
||||
? 'toml'
|
||||
: ext === 'sh'
|
||||
? 'sh'
|
||||
: ['yml', 'yaml'].includes(ext)
|
||||
? 'yaml'
|
||||
: 'text';
|
||||
})()
|
||||
"
|
||||
theme="one_dark"
|
||||
:print-margin="false"
|
||||
style="height: 750px; font-size: 1rem"
|
||||
@@ -73,13 +175,16 @@
|
||||
/>
|
||||
<UiServersFilesImageViewer v-else :image-blob="imagePreview" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
|
||||
<UiServersFilesLabelBar />
|
||||
<UiServersFileVirtualList
|
||||
:items="filteredItems"
|
||||
@delete="showDeleteModal"
|
||||
@rename="showRenameModal"
|
||||
@download="downloadFile"
|
||||
@move="showMoveModal"
|
||||
@move-direct-to="handleDirectMove"
|
||||
@edit="editFile"
|
||||
@contextmenu="showContextMenu"
|
||||
@load-more="handleLoadMore"
|
||||
@@ -87,35 +192,28 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!isLoading && items.length === 0"
|
||||
v-else-if="!isLoading && items.length === 0 && !loadError"
|
||||
class="flex h-full w-full items-center justify-center p-20"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<FolderOpenIcon class="h-16 w-16 text-secondary" />
|
||||
<h3 class="text-2xl font-bold text-contrast">This folder is empty</h3>
|
||||
<h3 class="m-0 text-2xl font-bold text-contrast">This folder is empty</h3>
|
||||
<p class="m-0 text-sm text-secondary">There are no files or folders.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LazyUiServersFileManagerError
|
||||
v-else-if="!isLoading"
|
||||
title="Unable to list files"
|
||||
message="Unfortunately, we were unable to list the files in this folder. If this issue persists, contact support."
|
||||
@refetch="refreshList"
|
||||
@home="navigateToSegment(-1)"
|
||||
/>
|
||||
|
||||
<LazyUiServersFileManagerError
|
||||
v-else-if="loadError"
|
||||
title="Unable to fetch files"
|
||||
message="This path is invalid or the server is not responding."
|
||||
title="Unable to load files"
|
||||
message="The folder may not exist."
|
||||
@refetch="refreshList"
|
||||
@home="navigateToSegment(-1)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isDragging"
|
||||
class="absolute inset-0 flex items-center justify-center rounded-xl bg-black bg-opacity-50 text-white"
|
||||
class="absolute inset-0 flex items-center justify-center rounded-2xl bg-black bg-opacity-50 text-white"
|
||||
>
|
||||
<div class="text-center">
|
||||
<UploadIcon class="mx-auto h-16 w-16" />
|
||||
@@ -140,11 +238,41 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useInfiniteScroll } from "@vueuse/core";
|
||||
import { UploadIcon, FolderOpenIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import { UploadIcon, FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import type { DirectoryResponse, DirectoryItem, Server } from "~/composables/pyroServers";
|
||||
|
||||
interface BaseOperation {
|
||||
type: "move" | "rename";
|
||||
itemType: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
interface MoveOperation extends BaseOperation {
|
||||
type: "move";
|
||||
sourcePath: string;
|
||||
destinationPath: string;
|
||||
}
|
||||
|
||||
interface RenameOperation extends BaseOperation {
|
||||
type: "rename";
|
||||
path: string;
|
||||
oldName: string;
|
||||
newName: string;
|
||||
}
|
||||
|
||||
type Operation = MoveOperation | RenameOperation;
|
||||
|
||||
interface UploadItem {
|
||||
file: File;
|
||||
progress: number;
|
||||
status: "pending" | "uploading" | "completed" | "error" | "cancelled";
|
||||
size: string;
|
||||
uploader?: any;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
@@ -154,6 +282,8 @@ const VAceEditor = ref();
|
||||
const mainContent = ref<HTMLElement | null>(null);
|
||||
const scrollContainer = ref<HTMLElement | null>(null);
|
||||
const contextMenu = ref();
|
||||
const operationHistory = ref<Operation[]>([]);
|
||||
const redoStack = ref<Operation[]>([]);
|
||||
|
||||
const searchQuery = ref("");
|
||||
const sortMethod = ref("default");
|
||||
@@ -184,13 +314,52 @@ const imagePreview = ref();
|
||||
const isDragging = ref(false);
|
||||
const dragCounter = ref(0);
|
||||
|
||||
const uploadStatusRef = ref<HTMLElement | null>(null);
|
||||
const isUploading = computed(() => uploadQueue.value.length > 0);
|
||||
const uploadQueue = ref<UploadItem[]>([]);
|
||||
|
||||
const activeUploads = computed(() =>
|
||||
uploadQueue.value.filter((item) => item.status === "pending" || item.status === "uploading"),
|
||||
);
|
||||
|
||||
const onUploadStatusEnter = (el: Element) => {
|
||||
const height = (el as HTMLElement).scrollHeight;
|
||||
(el as HTMLElement).style.height = "0";
|
||||
// eslint-disable-next-line no-void
|
||||
void (el as HTMLElement).offsetHeight;
|
||||
(el as HTMLElement).style.height = `${height}px`;
|
||||
};
|
||||
|
||||
const onUploadStatusLeave = (el: Element) => {
|
||||
const height = (el as HTMLElement).scrollHeight;
|
||||
(el as HTMLElement).style.height = `${height}px`;
|
||||
// eslint-disable-next-line no-void
|
||||
void (el as HTMLElement).offsetHeight;
|
||||
(el as HTMLElement).style.height = "0";
|
||||
};
|
||||
|
||||
watch(
|
||||
uploadQueue,
|
||||
() => {
|
||||
if (!uploadStatusRef.value) return;
|
||||
const el = uploadStatusRef.value;
|
||||
const itemsHeight = uploadQueue.value.length * 32;
|
||||
const headerHeight = 12;
|
||||
const gap = 8;
|
||||
const padding = 32;
|
||||
const totalHeight = padding + headerHeight + gap + itemsHeight;
|
||||
el.style.height = `${totalHeight}px`;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const data = computed(() => props.server.general);
|
||||
|
||||
useHead({
|
||||
title: computed(() => `Files - ${data.value?.name ?? "Server"} - Modrinth`),
|
||||
});
|
||||
|
||||
const fetchDirectoryContents = async (): Promise<{ items: any[]; total: number }> => {
|
||||
const fetchDirectoryContents = async (): Promise<DirectoryResponse> => {
|
||||
const path = Array.isArray(currentPath.value) ? currentPath.value.join("") : currentPath.value;
|
||||
try {
|
||||
const data = await props.server.fs?.listDirContents(path, currentPage.value, maxResults);
|
||||
@@ -240,16 +409,97 @@ const refreshList = () => {
|
||||
reset();
|
||||
};
|
||||
|
||||
const undoLastOperation = async () => {
|
||||
const lastOperation = operationHistory.value.pop();
|
||||
if (!lastOperation) return;
|
||||
|
||||
try {
|
||||
switch (lastOperation.type) {
|
||||
case "move":
|
||||
await props.server.fs?.moveFileOrFolder(
|
||||
`${lastOperation.destinationPath}/${lastOperation.fileName}`.replace("//", "/"),
|
||||
`${lastOperation.sourcePath}/${lastOperation.fileName}`.replace("//", "/"),
|
||||
);
|
||||
break;
|
||||
case "rename":
|
||||
await props.server.fs?.renameFileOrFolder(
|
||||
`${lastOperation.path}/${lastOperation.newName}`.replace("//", "/"),
|
||||
lastOperation.oldName,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
redoStack.value.push(lastOperation);
|
||||
|
||||
refreshList();
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: `${lastOperation.type === "move" ? "Move" : "Rename"} undone`,
|
||||
text: `${lastOperation.fileName} has been restored to its original ${lastOperation.type === "move" ? "location" : "name"}`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error undoing ${lastOperation.type}:`, error);
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Undo failed",
|
||||
text: `Failed to undo the last ${lastOperation.type} operation`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const redoLastOperation = async () => {
|
||||
const lastOperation = redoStack.value.pop();
|
||||
if (!lastOperation) return;
|
||||
|
||||
try {
|
||||
switch (lastOperation.type) {
|
||||
case "move":
|
||||
await props.server.fs?.moveFileOrFolder(
|
||||
`${lastOperation.sourcePath}/${lastOperation.fileName}`.replace("//", "/"),
|
||||
`${lastOperation.destinationPath}/${lastOperation.fileName}`.replace("//", "/"),
|
||||
);
|
||||
break;
|
||||
case "rename":
|
||||
await props.server.fs?.renameFileOrFolder(
|
||||
`${lastOperation.path}/${lastOperation.oldName}`.replace("//", "/"),
|
||||
lastOperation.newName,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
operationHistory.value.push(lastOperation);
|
||||
|
||||
refreshList();
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: `${lastOperation.type === "move" ? "Move" : "Rename"} redone`,
|
||||
text: `${lastOperation.fileName} has been ${lastOperation.type === "move" ? "moved" : "renamed"} again`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error redoing ${lastOperation.type}:`, error);
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Redo failed",
|
||||
text: `Failed to redo the last ${lastOperation.type} operation`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNewItem = async (name: string) => {
|
||||
try {
|
||||
const path = `${currentPath.value}/${name}`.replace("//", "/");
|
||||
await props.server.fs?.createFileOrFolder(path, newItemType.value);
|
||||
|
||||
refreshList();
|
||||
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "File created",
|
||||
text: "Your file has been created.",
|
||||
title: `${newItemType.value === "directory" ? "Folder" : "File"} created`,
|
||||
text: `New ${newItemType.value === "directory" ? "folder" : "file"} ${name} has been created.`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -262,6 +512,16 @@ const handleRenameItem = async (newName: string) => {
|
||||
const path = `${currentPath.value}/${selectedItem.value.name}`.replace("//", "/");
|
||||
await props.server.fs?.renameFileOrFolder(path, newName);
|
||||
|
||||
redoStack.value = [];
|
||||
operationHistory.value.push({
|
||||
type: "rename",
|
||||
itemType: selectedItem.value.type,
|
||||
fileName: selectedItem.value.name,
|
||||
path: currentPath.value,
|
||||
oldName: selectedItem.value.name,
|
||||
newName,
|
||||
});
|
||||
|
||||
refreshList();
|
||||
|
||||
if (closeEditor.value) {
|
||||
@@ -274,27 +534,90 @@ const handleRenameItem = async (newName: string) => {
|
||||
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "File renamed",
|
||||
text: "Your file has been renamed.",
|
||||
title: `${selectedItem.value.type === "directory" ? "Folder" : "File"} renamed`,
|
||||
text: `${selectedItem.value.name} has been renamed to ${newName}`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
handleRenameError(error);
|
||||
console.error("Error renaming item:", error);
|
||||
if (error instanceof PyroFetchError) {
|
||||
if (error.statusCode === 400) {
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Could not rename",
|
||||
text: `An item named "${newName}" already exists in this location`,
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Could not rename item",
|
||||
text: "An unexpected error occurred",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveItem = async (destination: string) => {
|
||||
try {
|
||||
const itemType = selectedItem.value.type;
|
||||
const sourcePath = currentPath.value;
|
||||
const newPath = `${destination}/${selectedItem.value.name}`.replace("//", "/");
|
||||
|
||||
await props.server.fs?.moveFileOrFolder(
|
||||
`${currentPath.value}/${selectedItem.value.name}`.replace("//", "/"),
|
||||
`${destination}/${selectedItem.value.name}`.replace("//", "/"),
|
||||
`${sourcePath}/${selectedItem.value.name}`.replace("//", "/"),
|
||||
newPath,
|
||||
);
|
||||
|
||||
redoStack.value = [];
|
||||
operationHistory.value.push({
|
||||
type: "move",
|
||||
sourcePath,
|
||||
destinationPath: destination,
|
||||
fileName: selectedItem.value.name,
|
||||
itemType,
|
||||
});
|
||||
|
||||
refreshList();
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "File moved",
|
||||
text: "Your file has been moved.",
|
||||
title: `${itemType === "directory" ? "Folder" : "File"} moved`,
|
||||
text: `${selectedItem.value.name} has been moved to ${newPath}`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error moving item:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDirectMove = async (moveData: {
|
||||
name: string;
|
||||
type: string;
|
||||
path: string;
|
||||
destination: string;
|
||||
}) => {
|
||||
try {
|
||||
const newPath = `${moveData.destination}/${moveData.name}`.replace("//", "/");
|
||||
const sourcePath = moveData.path.substring(0, moveData.path.lastIndexOf("/"));
|
||||
|
||||
await props.server.fs?.moveFileOrFolder(moveData.path, newPath);
|
||||
|
||||
redoStack.value = [];
|
||||
operationHistory.value.push({
|
||||
type: "move",
|
||||
sourcePath,
|
||||
destinationPath: moveData.destination,
|
||||
fileName: moveData.name,
|
||||
itemType: moveData.type,
|
||||
});
|
||||
|
||||
refreshList();
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: `${moveData.type === "directory" ? "Folder" : "File"} moved`,
|
||||
text: `${moveData.name} has been moved to ${newPath}`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -356,40 +679,18 @@ const handleCreateError = (error: any) => {
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Error creating item",
|
||||
text: "File already exists",
|
||||
text: "Something went wrong. The file may already exist.",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameError = (error: any) => {
|
||||
console.error("Error renaming item:", error);
|
||||
if (error instanceof PyroFetchError) {
|
||||
if (error.statusCode === 400) {
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Could not rename item",
|
||||
text: "This item already exists or is invalid.",
|
||||
type: "error",
|
||||
});
|
||||
} else if (error.statusCode === 500) {
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Could not rename item",
|
||||
text: "Invalid file",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const applyDefaultSort = (items: any[]) => {
|
||||
const applyDefaultSort = (items: DirectoryItem[]) => {
|
||||
return items.sort((a: any, b: any) => {
|
||||
if (a.type === "directory" && b.type !== "directory") return -1;
|
||||
if (a.type !== "directory" && b.type === "directory") return 1;
|
||||
if (a.count > b.count) return -1;
|
||||
if (a.count < b.count) return 1;
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
};
|
||||
@@ -406,6 +707,9 @@ const filteredItems = computed(() => {
|
||||
case "modified":
|
||||
result.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
||||
break;
|
||||
case "created":
|
||||
result.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
|
||||
break;
|
||||
case "filesOnly":
|
||||
result = result.filter((item) => item.type !== "directory");
|
||||
break;
|
||||
@@ -509,15 +813,31 @@ const editFile = async (item: { name: string; type: string; path: string }) => {
|
||||
onMounted(async () => {
|
||||
await import("ace-builds");
|
||||
await import("ace-builds/src-noconflict/mode-json");
|
||||
await import("ace-builds/src-noconflict/mode-yaml");
|
||||
await import("ace-builds/src-noconflict/mode-toml");
|
||||
await import("ace-builds/src-noconflict/mode-sh");
|
||||
await import("ace-builds/src-noconflict/theme-one_dark");
|
||||
await import("ace-builds/src-noconflict/ext-searchbox");
|
||||
VAceEditor.value = markRaw((await import("vue3-ace-editor")).VAceEditor);
|
||||
document.addEventListener("click", onAnywhereClicked);
|
||||
window.addEventListener("scroll", onScroll);
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === "z") {
|
||||
e.preventDefault();
|
||||
undoLastOperation();
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "z") {
|
||||
e.preventDefault();
|
||||
redoLastOperation();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", onAnywhereClicked);
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
document.removeEventListener("keydown", () => {});
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -600,8 +920,10 @@ const requestShareLink = async () => {
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
if (isEditing.value) return;
|
||||
event.preventDefault();
|
||||
dragCounter.value++;
|
||||
isDragging.value = true;
|
||||
if (!event.dataTransfer?.types.includes("application/pyro-file-move")) {
|
||||
dragCounter.value++;
|
||||
isDragging.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
@@ -618,43 +940,123 @@ const handleDragLeave = (event: DragEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line require-await
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
if (isEditing.value) return;
|
||||
event.preventDefault();
|
||||
isDragging.value = false;
|
||||
dragCounter.value = 0;
|
||||
|
||||
const isInternalMove = event.dataTransfer?.types.includes("application/pyro-file-move");
|
||||
if (isInternalMove) return;
|
||||
|
||||
const files = event.dataTransfer?.files;
|
||||
if (files) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
await uploadFile(files[i]);
|
||||
}
|
||||
Array.from(files).forEach((file) => {
|
||||
uploadFile(file);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||
};
|
||||
|
||||
const cancelUpload = (item: UploadItem) => {
|
||||
if (item.uploader && item.status === "uploading") {
|
||||
item.uploader.cancel();
|
||||
item.status = "cancelled";
|
||||
|
||||
setTimeout(async () => {
|
||||
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name);
|
||||
if (index !== -1) {
|
||||
uploadQueue.value.splice(index, 1);
|
||||
await nextTick();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
try {
|
||||
const filePath = `${currentPath.value}/${file.name}`.replace("//", "/");
|
||||
await props.server.fs?.uploadFile(filePath, file);
|
||||
refreshList();
|
||||
const uploadItem: UploadItem = {
|
||||
file,
|
||||
progress: 0,
|
||||
status: "pending",
|
||||
size: formatFileSize(file.size),
|
||||
};
|
||||
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "File uploaded",
|
||||
text: "Your file has been uploaded.",
|
||||
type: "success",
|
||||
});
|
||||
uploadQueue.value.push(uploadItem);
|
||||
|
||||
try {
|
||||
uploadItem.status = "uploading";
|
||||
const filePath = `${currentPath.value}/${file.name}`.replace("//", "/");
|
||||
const uploader = await props.server.fs?.uploadFile(filePath, file);
|
||||
uploadItem.uploader = uploader;
|
||||
|
||||
if (uploader?.onProgress) {
|
||||
uploader.onProgress(({ progress }: { progress: number }) => {
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1) {
|
||||
uploadQueue.value[index].progress = Math.round(progress);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await uploader?.promise;
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
||||
uploadQueue.value[index].status = "completed";
|
||||
uploadQueue.value[index].progress = 100;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
setTimeout(async () => {
|
||||
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (removeIndex !== -1) {
|
||||
uploadQueue.value.splice(removeIndex, 1);
|
||||
await nextTick();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
await refreshList();
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
||||
uploadQueue.value[index].status = "error";
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (removeIndex !== -1) {
|
||||
uploadQueue.value.splice(removeIndex, 1);
|
||||
await nextTick();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
if (error instanceof Error && error.message !== "Upload cancelled") {
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Upload failed",
|
||||
text: `Failed to upload ${file.name}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initiateFileUpload = () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
await uploadFile(file);
|
||||
input.multiple = true;
|
||||
input.onchange = () => {
|
||||
if (input.files) {
|
||||
Array.from(input.files).forEach((file) => {
|
||||
uploadFile(file);
|
||||
});
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
@@ -708,6 +1110,13 @@ const saveFileContent = async (exit: boolean = true) => {
|
||||
const saveFileContentRestart = async () => {
|
||||
await saveFileContent();
|
||||
await props.server.general?.power("Restart");
|
||||
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Server restarted",
|
||||
text: "Your server has been restarted.",
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const saveFileContentAs = async () => {
|
||||
@@ -734,3 +1143,38 @@ const onScroll = () => {
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-status {
|
||||
overflow: hidden;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-status-enter-active,
|
||||
.upload-status-leave-active {
|
||||
transition: height 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-status-enter-from,
|
||||
.upload-status-leave-to {
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.status-icon-enter-active,
|
||||
.status-icon-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.status-icon-enter-from,
|
||||
.status-icon-leave-to {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.status-icon-enter-to,
|
||||
.status-icon-leave-from {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<UiServersPanelServerStatus :state="serverPowerState" />
|
||||
</div>
|
||||
</div>
|
||||
<UiServersPanelTerminal :console-output="consoleOutput" :full-screen="fullScreen">
|
||||
<UiServersPanelTerminal :full-screen="fullScreen">
|
||||
<div class="relative w-full px-4 pt-4">
|
||||
<ul
|
||||
v-if="suggestions.length"
|
||||
@@ -192,14 +192,13 @@ type ServerProps = {
|
||||
isConnected: boolean;
|
||||
isWsAuthIncorrect: boolean;
|
||||
stats: Stats;
|
||||
consoleOutput: string[];
|
||||
serverPowerState: ServerState;
|
||||
powerStateDetails?: {
|
||||
oom_killed?: boolean;
|
||||
exit_code?: number;
|
||||
};
|
||||
isServerRunning: boolean;
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
};
|
||||
|
||||
const props = defineProps<ServerProps>();
|
||||
@@ -254,6 +253,7 @@ const inspectError = async () => {
|
||||
|
||||
mcError.value = response;
|
||||
|
||||
// @ts-ignore
|
||||
const analysis = (await $fetch(`https://api.mclo.gs/1/insights/${response.id}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -19,7 +19,7 @@ const route = useRoute();
|
||||
const serverId = route.params.id as string;
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
useHead({
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="card flex flex-col gap-4">
|
||||
<label for="server-name-field" class="flex flex-col gap-2">
|
||||
<span class="text-lg font-bold text-contrast">Server name</span>
|
||||
<span> Change the name of your server. This name is only visible on Modrinth.</span>
|
||||
<span> Change your server's name. This name is only visible on Modrinth.</span>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<input
|
||||
@@ -51,7 +51,7 @@
|
||||
/>
|
||||
.modrinth.gg
|
||||
</div>
|
||||
<div class="flex flex-col text-sm text-rose-400">
|
||||
<div v-if="!isValidSubdomain" class="flex flex-col text-sm text-rose-400">
|
||||
<span v-if="!isValidLengthSubdomain">
|
||||
Subdomain must be at least 5 characters long.
|
||||
</span>
|
||||
@@ -134,7 +134,7 @@ import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const data = computed(() => props.server.general);
|
||||
|
||||
@@ -123,7 +123,7 @@ const route = useNativeRoute();
|
||||
const serverId = route.params.id as string;
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const data = computed(() => props.server.general);
|
||||
|
||||
@@ -1,51 +1,152 @@
|
||||
<template>
|
||||
<NewModal ref="editModal" header="Select modpack">
|
||||
<UiServersProjectSelect type="modpack" @select="reinstallNew" />
|
||||
</NewModal>
|
||||
|
||||
<NewModal
|
||||
ref="versionSelectModal"
|
||||
:header="isSecondPhase ? 'Confirm reinstallation' : 'Select version'"
|
||||
:header="
|
||||
isSecondPhase
|
||||
? 'Confirming reinstallation'
|
||||
: `${data?.loader === selectedLoader ? 'Reinstalling' : 'Installing'}
|
||||
${selectedLoader.toLowerCase() === 'vanilla' ? 'Vanilla Minecraft' : selectedLoader}`
|
||||
"
|
||||
@hide="onHide"
|
||||
@show="onShow"
|
||||
>
|
||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||
<p
|
||||
v-if="isSecondPhase"
|
||||
:style="{
|
||||
lineHeight: isSecondPhase ? '1.5' : undefined,
|
||||
marginBottom: isSecondPhase ? '-12px' : '0',
|
||||
marginTop: isSecondPhase ? '-4px' : '-2px',
|
||||
}"
|
||||
>
|
||||
{{
|
||||
isSecondPhase
|
||||
? "This will reinstall your server and erase all data. You may want to back up your server before proceeding. Are you sure you want to continue?"
|
||||
: "Choose the version of Minecraft you want to use for this server."
|
||||
}}
|
||||
This will reinstall your server and erase all data. You may want to back up your server
|
||||
before proceeding. Are you sure you want to continue?
|
||||
</p>
|
||||
<div v-if="!isSecondPhase" class="flex flex-col gap-2">
|
||||
<UiServersTeleportDropdownMenu
|
||||
v-model="selectedMCVersion"
|
||||
name="mcVersion"
|
||||
:options="mcVersions"
|
||||
placeholder="Select Minecraft version..."
|
||||
/>
|
||||
<UiServersTeleportDropdownMenu
|
||||
v-if="selectedMCVersion && selectedLoader.toLowerCase() !== 'vanilla'"
|
||||
v-model="selectedLoaderVersion"
|
||||
name="loaderVersion"
|
||||
:options="selectedLoaderVersions"
|
||||
placeholder="Select loader version..."
|
||||
/>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
id="hard-reset"
|
||||
:checked="hardReset"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="hardReset = ($event.target as HTMLInputElement).checked"
|
||||
<div v-if="!isSecondPhase" class="flex flex-col gap-4">
|
||||
<div class="mx-auto flex flex-row items-center gap-4">
|
||||
<div
|
||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<UiServersIconsLoaderIcon class="size-10" :loader="selectedLoader" />
|
||||
</div>
|
||||
<svg
|
||||
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"
|
||||
class="size-10"
|
||||
>
|
||||
<path d="M5 9v6" />
|
||||
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
|
||||
</svg>
|
||||
<div
|
||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
|
||||
>
|
||||
<ServerIcon class="size-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="text-sm font-bold text-contrast">Minecraft version</div>
|
||||
<UiServersTeleportDropdownMenu
|
||||
v-model="selectedMCVersion"
|
||||
name="mcVersion"
|
||||
:options="mcVersions"
|
||||
class="w-full max-w-[100%]"
|
||||
placeholder="Select Minecraft version..."
|
||||
/>
|
||||
<label for="hard-reset">Clean reinstall</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedLoader.toLowerCase() !== 'vanilla'"
|
||||
class="flex w-full flex-col gap-2 rounded-2xl p-4"
|
||||
:class="{
|
||||
'bg-table-alternateRow':
|
||||
!selectedMCVersion || isLoading || selectedLoaderVersions.length > 0,
|
||||
'bg-highlight-red':
|
||||
selectedMCVersion && !isLoading && selectedLoaderVersions.length === 0,
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-contrast">{{ selectedLoader }} version</div>
|
||||
|
||||
<template v-if="!selectedMCVersion">
|
||||
<div
|
||||
class="relative flex h-9 w-full select-none items-center rounded-xl bg-button-bg px-4 opacity-50"
|
||||
>
|
||||
Select a Minecraft version to see available versions
|
||||
<DropdownIcon class="absolute right-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="isLoading">
|
||||
<div
|
||||
class="relative flex h-9 w-full items-center rounded-xl bg-button-bg px-4 opacity-50"
|
||||
>
|
||||
<UiServersIconsLoadingIcon class="mr-2 animate-spin" />
|
||||
Loading versions...
|
||||
<DropdownIcon class="absolute right-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="selectedLoaderVersions.length > 0">
|
||||
<UiServersTeleportDropdownMenu
|
||||
v-model="selectedLoaderVersion"
|
||||
name="loaderVersion"
|
||||
:options="selectedLoaderVersions"
|
||||
class="w-full max-w-[100%]"
|
||||
:placeholder="
|
||||
selectedLoader.toLowerCase() === 'paper' ||
|
||||
selectedLoader.toLowerCase() === 'purpur'
|
||||
? `Select build number...`
|
||||
: `Select loader version...`
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>No versions available for Minecraft {{ selectedMCVersion }}.</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
|
||||
Erase all data
|
||||
</label>
|
||||
<input
|
||||
id="hard-reset"
|
||||
:checked="hardReset"
|
||||
class="switch stylized-toggle shrink-0"
|
||||
type="checkbox"
|
||||
@change="hardReset = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
Removes all data on your server, including your worlds, mods, and configuration files,
|
||||
then reinstalls it with the selected version.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="backup-server">
|
||||
Backup server
|
||||
</label>
|
||||
<input
|
||||
id="backup-server"
|
||||
:checked="backupServer"
|
||||
class="switch stylized-toggle shrink-0"
|
||||
type="checkbox"
|
||||
@change="backupServer = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
Creates a backup of your server before proceeding with the installation or
|
||||
reinstallation.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
@@ -53,13 +154,15 @@
|
||||
<button :disabled="canInstall" @click="handleReinstall">
|
||||
<RightArrowIcon />
|
||||
{{
|
||||
isSecondPhase
|
||||
? "Erase and install"
|
||||
: loadingServerCheck
|
||||
? "Loading..."
|
||||
: isDangerous
|
||||
? "Erase and install"
|
||||
: "Install"
|
||||
isBackingUp
|
||||
? "Backing up..."
|
||||
: isSecondPhase
|
||||
? "Erase and install"
|
||||
: loadingServerCheck
|
||||
? "Loading..."
|
||||
: isDangerous
|
||||
? "Erase and install"
|
||||
: "Install"
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -75,51 +178,130 @@
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
{{ isSecondPhase ? "No" : "Cancel" }}
|
||||
{{ isSecondPhase ? "Go back" : "Cancel" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
|
||||
<NewModal ref="mrpackModal" header="Upload mrpack" @hide="onHide" @show="onShow">
|
||||
<div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
id="hard-reset"
|
||||
:checked="hardReset"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="hardReset = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
<label for="hard-reset">Clean reinstall</label>
|
||||
<NewModal ref="mrpackModal" header="Uploading mrpack" @hide="onHide" @show="onShow">
|
||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||
<p
|
||||
v-if="isSecondPhase"
|
||||
:style="{
|
||||
lineHeight: isSecondPhase ? '1.5' : undefined,
|
||||
marginBottom: isSecondPhase ? '-12px' : '0',
|
||||
marginTop: isSecondPhase ? '-4px' : '-2px',
|
||||
}"
|
||||
>
|
||||
This will reinstall your server and erase all data. You may want to back up your server
|
||||
before proceeding. Are you sure you want to continue?
|
||||
</p>
|
||||
<div v-if="!isSecondPhase" class="flex flex-col gap-4">
|
||||
<div class="mx-auto flex flex-row items-center gap-4">
|
||||
<div
|
||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<UploadIcon class="size-10" />
|
||||
</div>
|
||||
<svg
|
||||
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"
|
||||
class="size-10"
|
||||
>
|
||||
<path d="M5 9v6" />
|
||||
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
|
||||
</svg>
|
||||
<div
|
||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
|
||||
>
|
||||
<ServerIcon class="size-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".mrpack"
|
||||
class=""
|
||||
:disabled="isLoading"
|
||||
@change="uploadMrpack"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
|
||||
Erase all data
|
||||
</label>
|
||||
<input
|
||||
id="hard-reset"
|
||||
:checked="hardReset"
|
||||
class="switch stylized-toggle shrink-0"
|
||||
type="checkbox"
|
||||
@change="hardReset = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
Removes all data on your server, including your worlds, mods, and configuration files,
|
||||
then reinstalls it with the selected version.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="backup-server-mrpack">
|
||||
Backup server
|
||||
</label>
|
||||
<input
|
||||
id="backup-server-mrpack"
|
||||
:checked="backupServer"
|
||||
class="switch stylized-toggle shrink-0"
|
||||
type="checkbox"
|
||||
@change="backupServer = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
</div>
|
||||
<div>Creates a backup of your server before proceeding.</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".mrpack"
|
||||
class="mt-4"
|
||||
:disabled="isLoading"
|
||||
@change="uploadMrpack"
|
||||
/>
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||
<button :disabled="!mrpackFile || isLoading" @click="reinstallMrpack">
|
||||
<button :disabled="canInstallUpload" @click="handleReinstallUpload">
|
||||
<RightArrowIcon />
|
||||
{{
|
||||
isSecondPhase
|
||||
? "Erase and install"
|
||||
: loadingServerCheck
|
||||
? "Loading..."
|
||||
: isDangerous
|
||||
? "Erase and install"
|
||||
: "Install"
|
||||
isBackingUp
|
||||
? "Backing up..."
|
||||
: isSecondPhase
|
||||
? "Erase and install"
|
||||
: loadingServerCheck
|
||||
? "Loading..."
|
||||
: isDangerous
|
||||
? "Erase and install"
|
||||
: "Install"
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="isLoading" @click="mrpackModal?.hide">
|
||||
<button
|
||||
:disabled="isLoading"
|
||||
@click="
|
||||
if (isSecondPhase) {
|
||||
isSecondPhase = false;
|
||||
} else {
|
||||
mrpackModal?.hide();
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
Cancel
|
||||
{{ isSecondPhase ? "Go back" : "Cancel" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -148,48 +330,76 @@
|
||||
<UploadIcon class="size-4" /> Upload .mrpack file
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<DownloadIcon v-if="hasNewerVersion" color="brand">
|
||||
<button class="!w-full sm:!w-auto" @click="handleUpdateToLatest">
|
||||
<UploadIcon class="size-4" /> Update modpack
|
||||
</button>
|
||||
</DownloadIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="data.upstream"
|
||||
class="flex w-full justify-between gap-2 rounded-3xl bg-table-alternateRow p-4"
|
||||
>
|
||||
<div class="flex flex-col gap-4 sm:flex-row">
|
||||
<UiAvatar :src="data.project?.icon_url" size="120px" />
|
||||
|
||||
<div class="flex flex-col justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="m-0 flex gap-2 text-2xl font-extrabold leading-none text-contrast">
|
||||
{{ data.project?.title }}
|
||||
</h1>
|
||||
<span class="text-md text-secondary">
|
||||
{{
|
||||
data.project?.description && data.project.description.length > 150
|
||||
? data.project.description.substring(0, 150) + "..."
|
||||
: data.project?.description || ""
|
||||
}}
|
||||
</span>
|
||||
<div v-if="data.upstream" class="contents">
|
||||
<div class="flex w-full justify-between gap-2 rounded-3xl bg-table-alternateRow p-4">
|
||||
<div class="flex flex-col gap-4 sm:flex-row">
|
||||
<UiAvatar :src="data.project?.icon_url" size="120px" />
|
||||
<div class="flex flex-col justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="m-0 flex gap-2 text-2xl font-extrabold leading-none text-contrast">
|
||||
{{ data.project?.title }}
|
||||
</h1>
|
||||
<span class="text-md text-secondary">
|
||||
{{
|
||||
data.project?.description && data.project.description.length > 150
|
||||
? data.project.description.substring(0, 150) + "..."
|
||||
: data.project?.description || ""
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 flex w-full max-w-[24rem] flex-col items-center gap-2 sm:mt-0 sm:flex-row"
|
||||
>
|
||||
<UiServersTeleportDropdownMenu
|
||||
v-if="versions && Array.isArray(versions) && versions.length > 0"
|
||||
v-model="version"
|
||||
:options="options"
|
||||
placeholder="Change version"
|
||||
name="version"
|
||||
/>
|
||||
<ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<h2 class="m-0 text-lg font-bold text-contrast">Change modpack version</h2>
|
||||
<p class="m-0">
|
||||
Select the version of {{ data.project?.title || "the modpack" }} you want to install
|
||||
on your server.
|
||||
</p>
|
||||
|
||||
<div class="flex w-full flex-col items-center gap-2">
|
||||
<UiServersTeleportDropdownMenu
|
||||
v-if="versions && Array.isArray(versions) && versions.length > 0"
|
||||
v-model="version"
|
||||
:options="options"
|
||||
placeholder="Change version"
|
||||
name="version"
|
||||
class="w-full max-w-full"
|
||||
/>
|
||||
<div class="flex w-full flex-col rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="modpack-hard-reset">
|
||||
Erase all data
|
||||
</label>
|
||||
<input
|
||||
id="modpack-hard-reset"
|
||||
:checked="hardReset"
|
||||
class="switch stylized-toggle shrink-0"
|
||||
type="checkbox"
|
||||
@change="hardReset = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
If enabled, existing mods, worlds, and configurations, will be deleted before
|
||||
installing the new modpack version.
|
||||
</p>
|
||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||
<button
|
||||
:disabled="
|
||||
isLoading || (props.server.general?.status === 'installing' && isError)
|
||||
"
|
||||
class="!w-full sm:!w-auto"
|
||||
class="ml-auto"
|
||||
@click="reinstallCurrent"
|
||||
>
|
||||
<DownloadIcon class="size-4" />
|
||||
Reinstall
|
||||
{{ isDangerous ? "Erase and install" : "Install" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -199,7 +409,7 @@
|
||||
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
|
||||
<ButtonStyled>
|
||||
<nuxt-link class="!w-full sm:!w-auto" :to="`/modpacks?sid=${props.server.serverId}`">
|
||||
<DownloadIcon class="size-4" /> Install a modpack
|
||||
<CompassIcon class="size-4" /> Find a modpack
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<span class="hidden sm:block">or</span>
|
||||
@@ -213,18 +423,21 @@
|
||||
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="m-0 text-lg font-bold text-contrast">Mod loader</h2>
|
||||
<p class="m-0">Mod loaders allow you to run mods on your server.</p>
|
||||
<h2 class="m-0 text-lg font-bold text-contrast">Platform</h2>
|
||||
<p class="m-0">
|
||||
Your server's platform is the software that runs your server. Different platforms
|
||||
support different mods and plugins.
|
||||
</p>
|
||||
<div v-if="data.upstream" class="mt-2 flex items-center gap-2">
|
||||
<InfoIcon class="hidden sm:block" />
|
||||
<span class="text-sm text-secondary">
|
||||
Your server was installed from a modpack, which automatically chooses the appropriate
|
||||
mod loader.
|
||||
platform.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2"
|
||||
class="flex w-full flex-col gap-1 rounded-2xl"
|
||||
:class="{
|
||||
'pointer-events-none cursor-not-allowed select-none opacity-50':
|
||||
props.server.general?.status === 'installing' && isError,
|
||||
@@ -236,7 +449,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiServersPyroLoading v-else />
|
||||
<div v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -249,14 +462,18 @@ import {
|
||||
InfoIcon,
|
||||
RightArrowIcon,
|
||||
XIcon,
|
||||
CompassIcon,
|
||||
DropdownIcon,
|
||||
ServerIcon,
|
||||
} from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { Loaders } from "~/types/servers";
|
||||
|
||||
const route = useNativeRoute();
|
||||
const serverId = route.params.id as string;
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -272,7 +489,9 @@ const backupServer = ref(false);
|
||||
|
||||
const isError = computed(() => props.server.general?.status === "error");
|
||||
const isDangerous = computed(() => hardReset.value);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const isBackupLimited = computed(() => (props.server.backups?.data?.length || 0) >= 15);
|
||||
const isBackingUp = ref(false);
|
||||
|
||||
const versionStrings = ["forge", "fabric", "quilt", "neo"] as const;
|
||||
|
||||
@@ -311,14 +530,12 @@ const loaderVersions = (await Promise.all(
|
||||
}[]
|
||||
>;
|
||||
|
||||
const editModal = ref();
|
||||
const versionSelectModal = ref();
|
||||
const mrpackModal = ref();
|
||||
|
||||
const canInstall = computed(() => {
|
||||
const conds =
|
||||
!selectedMCVersion.value ||
|
||||
isBackupLimited.value ||
|
||||
isLoading.value ||
|
||||
loadingServerCheck.value ||
|
||||
serverCheckError.value.trim().length > 0;
|
||||
@@ -330,6 +547,16 @@ const canInstall = computed(() => {
|
||||
return conds || !selectedLoaderVersion.value;
|
||||
});
|
||||
|
||||
const canInstallUpload = computed(() => {
|
||||
const conds =
|
||||
!mrpackFile.value ||
|
||||
isLoading.value ||
|
||||
loadingServerCheck.value ||
|
||||
serverCheckError.value.trim().length > 0;
|
||||
|
||||
return conds;
|
||||
});
|
||||
|
||||
const mcVersions = tags.value.gameVersions
|
||||
.filter((x) => x.version_type === "release")
|
||||
.map((x) => x.version)
|
||||
@@ -343,27 +570,37 @@ const mcVersions = tags.value.gameVersions
|
||||
});
|
||||
|
||||
const selectedLoaderVersions = computed(() => {
|
||||
/*
|
||||
loaderVersions[
|
||||
selectedLoader.value.toLowerCase() === "neoforge" ? "neo" : selectedLoader.toLowerCase()
|
||||
]
|
||||
.find((x) => x.id === selectedMCVersion)
|
||||
?.loaders.map((x) => x.id) || []
|
||||
*/
|
||||
let loader = selectedLoader.value.toLowerCase();
|
||||
if (loader === "neoforge") {
|
||||
loader = "neo";
|
||||
const loader = selectedLoader.value.toLowerCase();
|
||||
|
||||
if (loader === "paper") {
|
||||
return paperVersions.value[selectedMCVersion.value] || [];
|
||||
}
|
||||
const backwardsCompatibleVersion = loaderVersions[loader].find(
|
||||
|
||||
if (loader === "purpur") {
|
||||
return purpurVersions.value[selectedMCVersion.value] || [];
|
||||
}
|
||||
|
||||
if (loader === "vanilla") {
|
||||
return [];
|
||||
}
|
||||
|
||||
let apiLoader = loader;
|
||||
if (loader === "neoforge") {
|
||||
apiLoader = "neo";
|
||||
}
|
||||
|
||||
const backwardsCompatibleVersion = loaderVersions[apiLoader]?.find(
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
(x) => x.id === "${modrinth.gameVersion}",
|
||||
);
|
||||
|
||||
if (backwardsCompatibleVersion) {
|
||||
return backwardsCompatibleVersion.loaders.map((x) => x.id);
|
||||
}
|
||||
|
||||
return (
|
||||
loaderVersions[loader]
|
||||
.find((x) => x.id === selectedMCVersion.value)
|
||||
loaderVersions[apiLoader]
|
||||
?.find((x) => x.id === selectedMCVersion.value)
|
||||
?.loaders.map((x) => x.id) || []
|
||||
);
|
||||
});
|
||||
@@ -395,7 +632,7 @@ const versionIds = computed(() =>
|
||||
const version = ref();
|
||||
const currentVersion = ref();
|
||||
|
||||
const selectedLoader = ref("");
|
||||
const selectedLoader = ref<Loaders>("Vanilla");
|
||||
const selectedMCVersion = ref("");
|
||||
const selectedLoaderVersion = ref("");
|
||||
const isSecondPhase = ref(false);
|
||||
@@ -409,8 +646,56 @@ const updateData = async () => {
|
||||
};
|
||||
updateData();
|
||||
|
||||
const latestVersion = computed(() => {
|
||||
if (!Array.isArray(versions?.value) || versions.value.length === 0) return null;
|
||||
return versions.value.reduce((latest: any, current: any) => {
|
||||
if (!latest) return current;
|
||||
return latest.version_number > current.version_number ? latest : current;
|
||||
}, null);
|
||||
});
|
||||
|
||||
const hasNewerVersion = computed(() => {
|
||||
if (!currentVersion.value?.version_number || !latestVersion.value?.version_number) return false;
|
||||
return latestVersion.value.version_number > currentVersion.value.version_number;
|
||||
});
|
||||
|
||||
const handleUpdateToLatest = async () => {
|
||||
if (!latestVersion.value) return;
|
||||
|
||||
version.value = latestVersion.value.version_number;
|
||||
hardReset.value = false;
|
||||
await reinstallCurrent();
|
||||
};
|
||||
|
||||
const paperVersions = ref<Record<string, number[]>>({});
|
||||
const purpurVersions = ref<Record<string, string[]>>({});
|
||||
|
||||
const fetchPaperVersions = async (mcVersion: string) => {
|
||||
try {
|
||||
const res = await $fetch(`https://api.papermc.io/v2/projects/paper/versions/${mcVersion}`);
|
||||
paperVersions.value[mcVersion] = (res as any).builds.sort((a: number, b: number) => b - a);
|
||||
return res;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPurpurVersions = async (mcVersion: string) => {
|
||||
try {
|
||||
const res = await $fetch(`https://api.purpurmc.org/v2/purpur/${mcVersion}`);
|
||||
purpurVersions.value[mcVersion] = (res as any).builds.all.sort(
|
||||
(a: string, b: string) => parseInt(b) - parseInt(a),
|
||||
);
|
||||
return res;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const selectLoader = (loader: string) => {
|
||||
selectedLoader.value = loader;
|
||||
selectedLoader.value = loader as Loaders;
|
||||
versionSelectModal.value.show();
|
||||
};
|
||||
|
||||
@@ -421,30 +706,55 @@ const cachedVersions: Record<string, any> = {};
|
||||
|
||||
watch(selectedMCVersion, async () => {
|
||||
if (selectedMCVersion.value.trim().length < 3) return;
|
||||
// const res = await fetch(
|
||||
// `/loader-versions?loader=minecraft&version=${selectedMCVersion.value}`,
|
||||
// ).then((r) => r.json());
|
||||
|
||||
isLoading.value = true;
|
||||
loadingServerCheck.value = true;
|
||||
const res =
|
||||
cachedVersions[selectedMCVersion.value] ||
|
||||
(await $fetch(`/loader-versions?loader=minecraft&version=${selectedMCVersion.value}`));
|
||||
|
||||
cachedVersions[selectedMCVersion.value] = res;
|
||||
try {
|
||||
// Check if Minecraft version exists
|
||||
const mcRes =
|
||||
cachedVersions[selectedMCVersion.value] ||
|
||||
(await $fetch(`/loader-versions?loader=minecraft&version=${selectedMCVersion.value}`));
|
||||
|
||||
loadingServerCheck.value = false;
|
||||
cachedVersions[selectedMCVersion.value] = mcRes;
|
||||
|
||||
if (!mcRes.downloads?.server) {
|
||||
serverCheckError.value =
|
||||
"We couldn't find a server.jar for this version. Please pick another one.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch Paper/Purpur versions if needed
|
||||
if (selectedLoader.value.toLowerCase() === "paper") {
|
||||
const paperRes = await fetchPaperVersions(selectedMCVersion.value);
|
||||
if (!paperRes) {
|
||||
serverCheckError.value = "This Minecraft version is not supported by Paper.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedLoader.value.toLowerCase() === "purpur") {
|
||||
const purpurRes = await fetchPurpurVersions(selectedMCVersion.value);
|
||||
if (!purpurRes) {
|
||||
serverCheckError.value = "This Minecraft version is not supported by Purpur.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (res.downloads.server) {
|
||||
serverCheckError.value = "";
|
||||
} else {
|
||||
serverCheckError.value =
|
||||
"We couldn't find a server.jar for this version. Please pick another one.";
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
serverCheckError.value = "Failed to fetch versions. Please try again.";
|
||||
} finally {
|
||||
loadingServerCheck.value = false;
|
||||
isLoading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const onShow = () => {
|
||||
selectedMCVersion.value = "";
|
||||
selectedMCVersion.value = props.server.general?.mc_version || "";
|
||||
selectedLoaderVersion.value = "";
|
||||
hardReset.value = false;
|
||||
};
|
||||
|
||||
const onHide = () => {
|
||||
@@ -483,7 +793,14 @@ const reinstallCurrent = async () => {
|
||||
const resolvedVersionIds = versionIds.value;
|
||||
const versionId = resolvedVersionIds.find((entry: any) => entry[version.value])?.[version.value];
|
||||
try {
|
||||
await props.server.general?.reinstall(serverId, false, projectId, versionId);
|
||||
await props.server.general?.reinstall(
|
||||
serverId,
|
||||
false,
|
||||
projectId,
|
||||
versionId,
|
||||
undefined,
|
||||
hardReset.value,
|
||||
);
|
||||
emit("reinstall");
|
||||
} catch (error) {
|
||||
handleReinstallError(error);
|
||||
@@ -510,7 +827,36 @@ const handleReinstall = async () => {
|
||||
});
|
||||
const backupName = `Reinstallation - ${format}`;
|
||||
isLoading.value = true;
|
||||
await props.server.backups?.create(backupName);
|
||||
const backupId = (await props.server.backups?.create(backupName)) as unknown as string;
|
||||
|
||||
isBackingUp.value = true;
|
||||
|
||||
let attempts = 0;
|
||||
|
||||
while (true) {
|
||||
attempts += 1;
|
||||
|
||||
if (attempts > 100) {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Backup Failed",
|
||||
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||
type: "error",
|
||||
});
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await props.server.refresh(["backups"]);
|
||||
const backups = await props.server.backups?.data;
|
||||
const backup = backupId ? backups?.find((x) => x.id === backupId) : undefined;
|
||||
if (backup && !backup.ongoing) {
|
||||
console.log("Backup Finished");
|
||||
isBackingUp.value = false;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
} catch {
|
||||
addNotification({
|
||||
group: "server",
|
||||
@@ -551,24 +897,6 @@ const handleReinstall = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const reinstallNew = async (project: any, versionNumber: string) => {
|
||||
editModal.value.hide();
|
||||
try {
|
||||
const versions = (await useBaseFetch(`project/${project.project_id}/version`)) as any;
|
||||
const version = versions.find((x: any) => x.version_number === versionNumber);
|
||||
|
||||
if (!version?.id) {
|
||||
throw new Error("Version not found");
|
||||
}
|
||||
await props.server.general?.reinstall(serverId, false, project.project_id, version.id);
|
||||
emit("reinstall");
|
||||
await nextTick();
|
||||
window.scrollTo(0, 0);
|
||||
} catch (error) {
|
||||
handleReinstallError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const mrpackFile = ref<File | null>(null);
|
||||
|
||||
const uploadMrpack = (event: Event) => {
|
||||
@@ -579,19 +907,86 @@ const uploadMrpack = (event: Event) => {
|
||||
mrpackFile.value = target.files[0];
|
||||
};
|
||||
|
||||
const reinstallMrpack = async () => {
|
||||
if (!mrpackFile.value) {
|
||||
const handleReinstallUpload = async () => {
|
||||
if (hardReset.value && !backupServer.value && !isSecondPhase.value) {
|
||||
isSecondPhase.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const mrpack = new File([mrpackFile.value], mrpackFile.value.name, {
|
||||
type: mrpackFile.value.type,
|
||||
});
|
||||
if (backupServer.value) {
|
||||
try {
|
||||
const date = new Date();
|
||||
const format = date.toLocaleString(navigator.language || "en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
const backupName = `Reinstallation - ${format}`;
|
||||
isLoading.value = true;
|
||||
const backupId = (await props.server.backups?.create(backupName)) as unknown as string;
|
||||
|
||||
isBackingUp.value = true;
|
||||
|
||||
let attempts = 0;
|
||||
|
||||
while (true) {
|
||||
attempts += 1;
|
||||
|
||||
if (attempts > 100) {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Backup Failed",
|
||||
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||
type: "error",
|
||||
});
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await props.server.refresh(["backups"]);
|
||||
const backups = await props.server.backups?.data;
|
||||
const backup = backupId ? backups?.find((x) => x.id === backupId) : undefined;
|
||||
if (backup && !backup.ongoing) {
|
||||
console.log("Backup Finished");
|
||||
isBackingUp.value = false;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
} catch {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Backup Failed",
|
||||
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||
type: "error",
|
||||
});
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
if (!mrpackFile.value) {
|
||||
throw new Error("No mrpack file selected");
|
||||
}
|
||||
const mrpack = new File([mrpackFile.value], mrpackFile.value.name, {
|
||||
type: mrpackFile.value.type,
|
||||
});
|
||||
|
||||
await props.server.general?.reinstallFromMrpack(mrpack, hardReset.value);
|
||||
emit("reinstall");
|
||||
|
||||
emit("reinstall", {
|
||||
loader: "mrpack",
|
||||
lVersion: "",
|
||||
mVersion: "",
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
window.scrollTo(0, 0);
|
||||
} catch (error) {
|
||||
|
||||
@@ -204,6 +204,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-row items-center gap-2 sm:w-auto">
|
||||
<UiCopyCode :text="`${serverIP}:${allocation.port}`" />
|
||||
<ButtonStyled icon-only>
|
||||
<button
|
||||
class="!w-full sm:!w-auto"
|
||||
@@ -252,7 +253,7 @@ import { ref, computed, nextTick } from "vue";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const isUpdating = ref(false);
|
||||
|
||||
@@ -49,7 +49,7 @@ const route = useNativeRoute();
|
||||
const serverId = route.params.id as string;
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const preferences = {
|
||||
|
||||
@@ -124,7 +124,7 @@ import Fuse from "fuse.js";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const tags = useTags();
|
||||
|
||||
@@ -90,7 +90,7 @@ import { ButtonStyled } from "@modrinth/ui";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const data = computed(() => props.server.general);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
data-pyro-server-list-root
|
||||
class="experimental-styles-within relative mx-auto flex min-h-screen w-full max-w-[1280px] flex-col px-3"
|
||||
class="experimental-styles-within relative mx-auto flex min-h-screen w-full max-w-[1280px] flex-col px-6"
|
||||
>
|
||||
<div
|
||||
v-if="serverList.length > 0 || isPollingForNewServers"
|
||||
|
||||
@@ -89,12 +89,11 @@ import {
|
||||
LanguagesIcon,
|
||||
CardIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { commonMessages, commonSettingsMessages } from "@modrinth/ui";
|
||||
import NavStack from "~/components/ui/NavStack.vue";
|
||||
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
||||
import MonitorSmartphoneIcon from "~/assets/images/utils/monitor-smartphone.svg?component";
|
||||
|
||||
import { commonMessages, commonSettingsMessages } from "~/utils/common-messages.ts";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
const route = useNativeRoute();
|
||||
|
||||
@@ -216,7 +216,15 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { UploadIcon, PlusIcon, XIcon, TrashIcon, EditIcon, SaveIcon } from "@modrinth/assets";
|
||||
import { CopyCode, ConfirmModal, Button, Checkbox, Avatar, FileInput } from "@modrinth/ui";
|
||||
import {
|
||||
CopyCode,
|
||||
ConfirmModal,
|
||||
Button,
|
||||
Checkbox,
|
||||
Avatar,
|
||||
FileInput,
|
||||
commonSettingsMessages,
|
||||
} from "@modrinth/ui";
|
||||
import Modal from "~/components/ui/Modal.vue";
|
||||
|
||||
import {
|
||||
@@ -226,7 +234,6 @@ import {
|
||||
useScopes,
|
||||
getScopeValue,
|
||||
} from "~/composables/auth/scopes.ts";
|
||||
import { commonSettingsMessages } from "~/utils/common-messages.ts";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
|
||||
@@ -88,9 +88,8 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, ConfirmModal, Avatar } from "@modrinth/ui";
|
||||
import { Button, ConfirmModal, Avatar, commonSettingsMessages } from "@modrinth/ui";
|
||||
import { TrashIcon, CheckIcon } from "@modrinth/assets";
|
||||
import { commonSettingsMessages } from "~/utils/common-messages.ts";
|
||||
import { useScopes } from "~/composables/auth/scopes.ts";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
@@ -486,6 +486,7 @@ import {
|
||||
PurchaseModal,
|
||||
ButtonStyled,
|
||||
CopyCode,
|
||||
commonMessages,
|
||||
} from "@modrinth/ui";
|
||||
import {
|
||||
PlusIcon,
|
||||
|
||||
@@ -16,38 +16,12 @@
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">{{ formatMessage(colorTheme.title) }}</h2>
|
||||
<p>{{ formatMessage(colorTheme.description) }}</p>
|
||||
<div class="theme-options mt-4">
|
||||
<button
|
||||
v-for="option in themeOptions"
|
||||
:key="option"
|
||||
class="preview-radio button-base"
|
||||
:class="{ selected: theme.preferred === option }"
|
||||
@click="() => updateColorTheme(option)"
|
||||
>
|
||||
<div class="preview" :class="`${option === 'system' ? systemTheme : option}-mode`">
|
||||
<div class="example-card card card">
|
||||
<div class="example-icon"></div>
|
||||
<div class="example-text-1"></div>
|
||||
<div class="example-text-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label">
|
||||
<RadioButtonChecked v-if="theme.preferred === option" class="radio" />
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
{{ colorTheme[option] ? formatMessage(colorTheme[option]) : option }}
|
||||
<SunIcon
|
||||
v-if="theme.preferences.light === option"
|
||||
v-tooltip="formatMessage(colorTheme.preferredLight)"
|
||||
class="theme-icon"
|
||||
/>
|
||||
<MoonIcon
|
||||
v-else-if="theme.preferences.dark === option"
|
||||
v-tooltip="formatMessage(colorTheme.preferredDark)"
|
||||
class="theme-icon"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<ThemeSelector
|
||||
:update-color-theme="updateColorTheme"
|
||||
:current-theme="theme.preferred"
|
||||
:theme-options="themeOptions"
|
||||
:system-theme-color="systemTheme"
|
||||
/>
|
||||
</section>
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">{{ formatMessage(projectListLayouts.title) }}</h2>
|
||||
@@ -224,8 +198,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CodeIcon, MoonIcon, RadioButtonChecked, RadioButtonIcon, SunIcon } from "@modrinth/assets";
|
||||
import { Button } from "@modrinth/ui";
|
||||
import { CodeIcon, RadioButtonChecked, RadioButtonIcon } from "@modrinth/assets";
|
||||
import { Button, ThemeSelector } from "@modrinth/ui";
|
||||
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
||||
import type { DisplayLocation } from "~/plugins/cosmetics";
|
||||
import { formatProjectType } from "~/plugins/shorthands.js";
|
||||
@@ -258,34 +232,6 @@ const colorTheme = defineMessages({
|
||||
id: "settings.display.theme.description",
|
||||
defaultMessage: "Select your preferred color theme for Modrinth on this device.",
|
||||
},
|
||||
system: {
|
||||
id: "settings.display.theme.system",
|
||||
defaultMessage: "Sync with system",
|
||||
},
|
||||
light: {
|
||||
id: "settings.display.theme.light",
|
||||
defaultMessage: "Light",
|
||||
},
|
||||
dark: {
|
||||
id: "settings.display.theme.dark",
|
||||
defaultMessage: "Dark",
|
||||
},
|
||||
oled: {
|
||||
id: "settings.display.theme.oled",
|
||||
defaultMessage: "OLED",
|
||||
},
|
||||
retro: {
|
||||
id: "settings.display.theme.retro",
|
||||
defaultMessage: "Retro",
|
||||
},
|
||||
preferredLight: {
|
||||
id: "settings.display.theme.preferred-light-theme",
|
||||
defaultMessage: "Preferred light theme",
|
||||
},
|
||||
preferredDark: {
|
||||
id: "settings.display.theme.preferred-dark-theme",
|
||||
defaultMessage: "Preferred dark theme",
|
||||
},
|
||||
});
|
||||
|
||||
const projectListLayouts = defineMessages({
|
||||
@@ -457,107 +403,6 @@ const listTypes = computed(() => {
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.preview-radio {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-divider);
|
||||
background-color: var(--color-button-bg);
|
||||
color: var(--color-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
outline: 2px solid transparent;
|
||||
|
||||
&.selected {
|
||||
color: var(--color-contrast);
|
||||
|
||||
.label {
|
||||
.radio {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
background-color: var(--color-bg);
|
||||
padding: 1.5rem;
|
||||
outline: 2px solid transparent;
|
||||
width: 100%;
|
||||
|
||||
.example-card {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
outline: 2px solid transparent;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
flex-grow: 1;
|
||||
padding: var(--gap-md) var(--gap-lg);
|
||||
|
||||
.radio {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
color: var(--color-secondary);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
.theme-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12.5rem, 1fr));
|
||||
gap: var(--gap-lg);
|
||||
|
||||
.preview .example-card {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
grid-template: "icon text1" "icon text2";
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem;
|
||||
outline: 2px solid transparent;
|
||||
|
||||
.example-icon {
|
||||
grid-area: icon;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
.example-text-1,
|
||||
.example-text-2 {
|
||||
height: 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
.example-text-1 {
|
||||
grid-area: text1;
|
||||
width: 100%;
|
||||
background-color: var(--color-base);
|
||||
}
|
||||
|
||||
.example-text-2 {
|
||||
grid-area: text2;
|
||||
width: 60%;
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.project-lists {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import Fuse from "fuse.js/dist/fuse.basic";
|
||||
import { commonSettingsMessages } from "@modrinth/ui";
|
||||
import RadioButtonIcon from "~/assets/images/utils/radio-button.svg?component";
|
||||
import RadioButtonCheckedIcon from "~/assets/images/utils/radio-button-checked.svg?component";
|
||||
import WarningIcon from "~/assets/images/utils/issues.svg?component";
|
||||
import { isModifierKeyDown } from "~/helpers/events.ts";
|
||||
import { commonSettingsMessages } from "~/utils/common-messages.ts";
|
||||
|
||||
const vintl = useVIntl();
|
||||
const { formatMessage } = vintl;
|
||||
|
||||
@@ -203,9 +203,8 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { PlusIcon, XIcon, TrashIcon, EditIcon, SaveIcon } from "@modrinth/assets";
|
||||
import { Checkbox, ConfirmModal } from "@modrinth/ui";
|
||||
import { Checkbox, ConfirmModal, commonSettingsMessages, commonMessages } from "@modrinth/ui";
|
||||
|
||||
import { commonSettingsMessages } from "~/utils/common-messages.ts";
|
||||
import {
|
||||
hasScope,
|
||||
scopeList,
|
||||
|
||||
@@ -87,8 +87,7 @@
|
||||
|
||||
<script setup>
|
||||
import { UserIcon, SaveIcon, UploadIcon, UndoIcon, XIcon } from "@modrinth/assets";
|
||||
import { Avatar, FileInput, Button } from "@modrinth/ui";
|
||||
import { commonMessages } from "~/utils/common-messages.ts";
|
||||
import { Avatar, FileInput, Button, commonMessages } from "@modrinth/ui";
|
||||
|
||||
useHead({
|
||||
title: "Profile settings - Modrinth",
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { XIcon } from "@modrinth/assets";
|
||||
import { commonSettingsMessages } from "~/utils/common-messages.ts";
|
||||
import { commonMessages, commonSettingsMessages } from "@modrinth/ui";
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
|
||||
@@ -22,14 +22,14 @@
|
||||
</template>
|
||||
<template #stats>
|
||||
<div
|
||||
class="flex items-center gap-2 border-0 border-r border-solid border-button-bg pr-4 font-semibold"
|
||||
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
|
||||
>
|
||||
<BoxIcon class="h-6 w-6 text-secondary" />
|
||||
{{ formatCompactNumber(projects?.length || 0) }}
|
||||
projects
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 border-0 border-r border-solid border-button-bg pr-4 font-semibold"
|
||||
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
|
||||
>
|
||||
<DownloadIcon class="h-6 w-6 text-secondary" />
|
||||
{{ formatCompactNumber(sumDownloads) }}
|
||||
@@ -68,7 +68,7 @@
|
||||
{ divider: true, shown: auth.user && auth.user.id === user.id },
|
||||
{
|
||||
id: 'report',
|
||||
action: () => reportUser(user.id),
|
||||
action: () => (auth.user ? reportUser(user.id) : navigateTo('/auth/sign-in')),
|
||||
color: 'red',
|
||||
hoverOnly: true,
|
||||
shown: auth.user?.id !== user.id,
|
||||
@@ -96,7 +96,7 @@
|
||||
</ContentPageHeader>
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<div v-if="navLinks.length > 2" class="mb-4 max-w-full overflow-x-auto">
|
||||
<div v-if="navLinks.length >= 2" class="mb-4 max-w-full overflow-x-auto">
|
||||
<NavTabs :links="navLinks" />
|
||||
</div>
|
||||
<div v-if="projects.length > 0">
|
||||
@@ -265,7 +265,7 @@ import {
|
||||
ClipboardCopyIcon,
|
||||
MoreVerticalIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { OverflowMenu, ButtonStyled, ContentPageHeader } from "@modrinth/ui";
|
||||
import { OverflowMenu, ButtonStyled, ContentPageHeader, commonMessages } from "@modrinth/ui";
|
||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
import ProjectCard from "~/components/ui/ProjectCard.vue";
|
||||
import { reportUser } from "~/utils/report-helpers.ts";
|
||||
|
||||
15
apps/frontend/src/plugins/floating-vue.js
Normal file
15
apps/frontend/src/plugins/floating-vue.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import FloatingVue from "floating-vue";
|
||||
import "floating-vue/dist/style.css";
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.use(FloatingVue, {
|
||||
themes: {
|
||||
"ribbit-popout": {
|
||||
$extend: "dropdown",
|
||||
placement: "bottom-end",
|
||||
instantMove: true,
|
||||
distance: 8,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import FloatingVue from "floating-vue";
|
||||
import "floating-vue/dist/style.css";
|
||||
|
||||
export default defineNuxtPlugin(({ vueApp }) => {
|
||||
vueApp.use(FloatingVue);
|
||||
});
|
||||
68
apps/frontend/src/store/console.ts
Normal file
68
apps/frontend/src/store/console.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createGlobalState } from "@vueuse/core";
|
||||
import { type Ref, ref } from "vue";
|
||||
|
||||
/**
|
||||
* Maximum number of console output lines to store
|
||||
* @type {number}
|
||||
*/
|
||||
const maxLines = 5000;
|
||||
|
||||
/**
|
||||
* Provides a global console output state management system
|
||||
* Allows adding, storing, and clearing console output with a maximum line limit
|
||||
*
|
||||
* @returns {Object} Console state management methods and reactive state
|
||||
* @property {Ref<string[]>} consoleOutput - Reactive array of console output lines
|
||||
* @property {function(string): void} addConsoleOutput - Method to add a new console output line
|
||||
* @property {function(): void} clear - Method to clear all console output
|
||||
*/
|
||||
export const usePyroConsole = createGlobalState(() => {
|
||||
/**
|
||||
* Reactive array storing console output lines
|
||||
* @type {Ref<string[]>}
|
||||
*/
|
||||
const output: Ref<string[]> = ref<string[]>([]);
|
||||
|
||||
/**
|
||||
* Adds a new output line to the console output
|
||||
* Automatically removes the oldest line if max output is exceeded
|
||||
*
|
||||
* @param {string} line - The console output line to add
|
||||
*/
|
||||
const addLine = (line: string): void => {
|
||||
output.value.push(line);
|
||||
|
||||
if (output.value.length > maxLines) {
|
||||
output.value.shift();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds multiple output lines to the console output
|
||||
* Automatically removes the oldest lines if max output is exceeded
|
||||
*
|
||||
* @param {string[]} lines - The console output lines to add
|
||||
* @returns {void}
|
||||
*/
|
||||
const addLines = (lines: string[]): void => {
|
||||
output.value.push(...lines);
|
||||
|
||||
if (output.value.length > maxLines) {
|
||||
output.value.splice(0, output.value.length - maxLines);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears all console output lines
|
||||
*/
|
||||
const clear = (): void => {
|
||||
output.value = [];
|
||||
};
|
||||
|
||||
return {
|
||||
output,
|
||||
addLine,
|
||||
addLines,
|
||||
clear,
|
||||
};
|
||||
});
|
||||
@@ -149,6 +149,17 @@ export type ServerState = "running" | "stopped" | "crashed";
|
||||
// state: ServerState;
|
||||
// }
|
||||
|
||||
export type Loaders =
|
||||
| "Fabric"
|
||||
| "Quilt"
|
||||
| "Forge"
|
||||
| "NeoForge"
|
||||
| "Paper"
|
||||
| "Spigot"
|
||||
| "Bukkit"
|
||||
| "Vanilla"
|
||||
| "Purpur";
|
||||
|
||||
export interface WSLogEvent {
|
||||
event: "log";
|
||||
message: string;
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
export const commonMessages = defineMessages({
|
||||
allProjectType: {
|
||||
id: "project-type.all",
|
||||
defaultMessage: "All",
|
||||
},
|
||||
cancelButton: {
|
||||
id: "button.cancel",
|
||||
defaultMessage: "Cancel",
|
||||
},
|
||||
collectionsLabel: {
|
||||
id: "label.collections",
|
||||
defaultMessage: "Collections",
|
||||
},
|
||||
continueButton: {
|
||||
id: "button.continue",
|
||||
defaultMessage: "Continue",
|
||||
},
|
||||
copyIdButton: {
|
||||
id: "button.copy-id",
|
||||
defaultMessage: "Copy ID",
|
||||
},
|
||||
changesSavedLabel: {
|
||||
id: "label.changes-saved",
|
||||
defaultMessage: "Changes saved",
|
||||
},
|
||||
createAProjectButton: {
|
||||
id: "button.create-a-project",
|
||||
defaultMessage: "Create a project",
|
||||
},
|
||||
createdAgoLabel: {
|
||||
id: "label.created-ago",
|
||||
defaultMessage: "Created {ago}",
|
||||
},
|
||||
dashboardLabel: {
|
||||
id: "label.dashboard",
|
||||
defaultMessage: "Dashboard",
|
||||
},
|
||||
dateAtTimeTooltip: {
|
||||
id: "tooltip.date-at-time",
|
||||
defaultMessage: "{date, date, long} at {time, time, short}",
|
||||
},
|
||||
deleteLabel: {
|
||||
id: "label.delete",
|
||||
defaultMessage: "Delete",
|
||||
},
|
||||
descriptionLabel: {
|
||||
id: "label.description",
|
||||
defaultMessage: "Description",
|
||||
},
|
||||
editButton: {
|
||||
id: "button.edit",
|
||||
defaultMessage: "Edit",
|
||||
},
|
||||
errorLabel: {
|
||||
id: "label.error",
|
||||
defaultMessage: "Error",
|
||||
},
|
||||
errorNotificationTitle: {
|
||||
id: "notification.error.title",
|
||||
defaultMessage: "An error occurred",
|
||||
},
|
||||
followedProjectsLabel: {
|
||||
id: "label.followed-projects",
|
||||
defaultMessage: "Followed projects",
|
||||
},
|
||||
galleryInputView: {
|
||||
id: "input.view.gallery",
|
||||
defaultMessage: "Gallery view",
|
||||
},
|
||||
gridInputView: {
|
||||
id: "input.view.grid",
|
||||
defaultMessage: "Grid view",
|
||||
},
|
||||
listInputView: {
|
||||
id: "input.view.list",
|
||||
defaultMessage: "Rows view",
|
||||
},
|
||||
moderationLabel: {
|
||||
id: "label.moderation",
|
||||
defaultMessage: "Moderation",
|
||||
},
|
||||
notificationsLabel: {
|
||||
id: "label.notifications",
|
||||
defaultMessage: "Notifications",
|
||||
},
|
||||
privateLabel: {
|
||||
id: "collection.label.private",
|
||||
defaultMessage: "Private",
|
||||
},
|
||||
publicLabel: {
|
||||
id: "label.public",
|
||||
defaultMessage: "Public",
|
||||
},
|
||||
rejectedLabel: {
|
||||
id: "label.rejected",
|
||||
defaultMessage: "Rejected",
|
||||
},
|
||||
reportButton: {
|
||||
id: "button.report",
|
||||
defaultMessage: "Report",
|
||||
},
|
||||
passwordLabel: {
|
||||
id: "label.password",
|
||||
defaultMessage: "Password",
|
||||
},
|
||||
saveButton: {
|
||||
id: "button.save",
|
||||
defaultMessage: "Save",
|
||||
},
|
||||
saveChangesButton: {
|
||||
id: "button.save-changes",
|
||||
defaultMessage: "Save changes",
|
||||
},
|
||||
scopesLabel: {
|
||||
id: "label.scopes",
|
||||
defaultMessage: "Scopes",
|
||||
},
|
||||
serversLabel: {
|
||||
id: "label.servers",
|
||||
defaultMessage: "Servers",
|
||||
},
|
||||
settingsLabel: {
|
||||
id: "label.settings",
|
||||
defaultMessage: "Settings",
|
||||
},
|
||||
signInButton: {
|
||||
id: "button.sign-in",
|
||||
defaultMessage: "Sign in",
|
||||
},
|
||||
signOutButton: {
|
||||
id: "button.sign-out",
|
||||
defaultMessage: "Sign out",
|
||||
},
|
||||
titleLabel: {
|
||||
id: "label.title",
|
||||
defaultMessage: "Title",
|
||||
},
|
||||
unlistedLabel: {
|
||||
id: "label.unlisted",
|
||||
defaultMessage: "Unlisted",
|
||||
},
|
||||
uploadImageButton: {
|
||||
id: "button.upload-image",
|
||||
defaultMessage: "Upload image",
|
||||
},
|
||||
visibilityLabel: {
|
||||
id: "label.visibility",
|
||||
defaultMessage: "Visibility",
|
||||
},
|
||||
visitYourProfile: {
|
||||
id: "label.visit-your-profile",
|
||||
defaultMessage: "Visit your profile",
|
||||
},
|
||||
});
|
||||
|
||||
export const commonSettingsMessages = defineMessages({
|
||||
appearance: {
|
||||
id: "settings.appearance.title",
|
||||
defaultMessage: "Appearance",
|
||||
},
|
||||
language: {
|
||||
id: "settings.language.title",
|
||||
defaultMessage: "Language",
|
||||
},
|
||||
profile: {
|
||||
id: "settings.profile.title",
|
||||
defaultMessage: "Public profile",
|
||||
},
|
||||
account: {
|
||||
id: "settings.account.title",
|
||||
defaultMessage: "Account and security",
|
||||
},
|
||||
authorizedApps: {
|
||||
id: "settings.authorized-apps.title",
|
||||
defaultMessage: "Authorized apps",
|
||||
},
|
||||
sessions: {
|
||||
id: "settings.sessions.title",
|
||||
defaultMessage: "Sessions",
|
||||
},
|
||||
pats: {
|
||||
id: "settings.pats.title",
|
||||
defaultMessage: "Personal access tokens",
|
||||
},
|
||||
applications: {
|
||||
id: "settings.applications.title",
|
||||
defaultMessage: "Your applications",
|
||||
},
|
||||
billing: {
|
||||
id: "settings.billing.title",
|
||||
defaultMessage: "Billing and subscriptions",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user