Merge commit '81ec068747a39e927c42273011252daaa58f1e14' into feature-clean

This commit is contained in:
2024-12-26 16:51:17 +03:00
361 changed files with 25873 additions and 23923 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1,4 @@
<template><slot id="main" /></template>
<style lang="scss">
@import "~/assets/styles/global.scss";
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -486,6 +486,7 @@ import {
PurchaseModal,
ButtonStyled,
CopyCode,
commonMessages,
} from "@modrinth/ui";
import {
PlusIcon,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
},
},
});
});

View File

@@ -1,6 +0,0 @@
import FloatingVue from "floating-vue";
import "floating-vue/dist/style.css";
export default defineNuxtPlugin(({ vueApp }) => {
vueApp.use(FloatingVue);
});

View 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,
};
});

View File

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

View File

@@ -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",
},
});