You've already forked AstralRinth
forked from didirus/AstralRinth
Merge commit '81ec068747a39e927c42273011252daaa58f1e14' into feature-clean
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
<ButtonStyled v-if="!!slots.title" :type="type">
|
||||
<button class="!w-full" @click="() => (isOpen ? close() : open())">
|
||||
<slot name="title" /><DropdownIcon
|
||||
class="ml-auto size-5 transition-transform duration-300"
|
||||
class="ml-auto size-5 text-contrast transition-transform duration-300"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
/>
|
||||
</button>
|
||||
@@ -62,6 +62,9 @@ defineOptions({
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease-in-out;
|
||||
animation: height-animate 500ms ease-in-out both;
|
||||
content-visibility: auto;
|
||||
animation-composition: replace;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
@@ -77,4 +80,13 @@ defineOptions({
|
||||
.accordion-content > div {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes height-animate {
|
||||
from {
|
||||
block-size: initial;
|
||||
}
|
||||
to {
|
||||
block-size: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
'badge flex items-center gap-1 font-semibold text-secondary ' + color + ' type--' + type
|
||||
"
|
||||
>
|
||||
<template v-if="color"> <span class="circle" /> {{ $capitalizeString(type) }}</template>
|
||||
<template v-if="color"> <span class="circle" /> {{ capitalizeString(type) }}</template>
|
||||
|
||||
<!-- User roles -->
|
||||
<template v-else-if="type === 'admin'"> <ModrinthIcon /> Modrinth Team</template>
|
||||
@@ -36,25 +36,28 @@
|
||||
<template v-else-if="type === 'closed'"> <CloseIcon /> Closed</template>
|
||||
|
||||
<!-- Other -->
|
||||
<template v-else> <span class="circle" /> {{ $capitalizeString(type) }} </template>
|
||||
<template v-else> <span class="circle" /> {{ capitalizeString(type) }} </template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { GlobeIcon, LinkIcon } from "@modrinth/assets";
|
||||
|
||||
import ModrinthIcon from "~/assets/images/logo.svg?component";
|
||||
import PlusIcon from "~/assets/images/utils/plus.svg?component";
|
||||
import ModeratorIcon from "~/assets/images/sidebar/admin.svg?component";
|
||||
import CreatorIcon from "~/assets/images/utils/box.svg?component";
|
||||
import DraftIcon from "~/assets/images/utils/file-text.svg?component";
|
||||
import CrossIcon from "~/assets/images/utils/x.svg?component";
|
||||
import ArchiveIcon from "~/assets/images/utils/archive.svg?component";
|
||||
import ProcessingIcon from "~/assets/images/utils/updated.svg?component";
|
||||
import CheckIcon from "~/assets/images/utils/check.svg?component";
|
||||
import LockIcon from "~/assets/images/utils/lock.svg?component";
|
||||
import CalendarIcon from "~/assets/images/utils/calendar.svg?component";
|
||||
import CloseIcon from "~/assets/images/utils/check-circle.svg?component";
|
||||
import {
|
||||
GlobeIcon,
|
||||
LinkIcon,
|
||||
ModrinthIcon,
|
||||
PlusIcon,
|
||||
ScaleIcon as ModeratorIcon,
|
||||
BoxIcon as CreatorIcon,
|
||||
FileTextIcon as DraftIcon,
|
||||
XIcon as CrossIcon,
|
||||
ArchiveIcon,
|
||||
UpdatedIcon as ProcessingIcon,
|
||||
CheckIcon,
|
||||
LockIcon,
|
||||
CalendarIcon,
|
||||
XCircleIcon as CloseIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { capitalizeString } from "@modrinth/utils";
|
||||
|
||||
defineProps({
|
||||
type: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<nav
|
||||
ref="scrollContainer"
|
||||
class="experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="(link, index) in filteredLinks"
|
||||
@@ -11,7 +11,7 @@
|
||||
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
|
||||
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
|
||||
:class="{
|
||||
'text-brand': activeIndex === index && !subpageSelected,
|
||||
'text-button-textSelected': activeIndex === index && !subpageSelected,
|
||||
'text-contrast': activeIndex === index && subpageSelected,
|
||||
}"
|
||||
>
|
||||
@@ -20,7 +20,7 @@
|
||||
</NuxtLink>
|
||||
<div
|
||||
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${
|
||||
subpageSelected ? 'bg-button-bg' : 'bg-brand-highlight'
|
||||
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'
|
||||
}`"
|
||||
:style="{
|
||||
left: sliderLeftPx,
|
||||
@@ -161,4 +161,8 @@ watch(
|
||||
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
|
||||
}
|
||||
|
||||
.card-shadow {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
<template>
|
||||
<div class="experimental-styles-within flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<ManySelect
|
||||
v-model="selectedPlatforms"
|
||||
:options="filterOptions.platform"
|
||||
@change="updateFilters"
|
||||
>
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
Platform
|
||||
<template #option="{ option }">
|
||||
{{ formatCategory(option) }}
|
||||
</template>
|
||||
</ManySelect>
|
||||
<ManySelect
|
||||
v-model="selectedGameVersions"
|
||||
:options="filterOptions.gameVersion"
|
||||
search
|
||||
@change="updateFilters"
|
||||
>
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
Game versions
|
||||
<template #footer>
|
||||
<Checkbox v-model="showSnapshots" class="mx-1" :label="`Show all versions`" />
|
||||
</template>
|
||||
</ManySelect>
|
||||
<ManySelect
|
||||
v-model="selectedChannels"
|
||||
:options="filterOptions.channel"
|
||||
@change="updateFilters"
|
||||
>
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
Channels
|
||||
<template #option="{ option }">
|
||||
{{ option === "release" ? "Release" : option === "beta" ? "Beta" : "Alpha" }}
|
||||
</template>
|
||||
</ManySelect>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1 empty:hidden">
|
||||
<button
|
||||
v-if="selectedChannels.length + selectedGameVersions.length + selectedPlatforms.length > 1"
|
||||
class="tag-list__item text-contrast transition-transform active:scale-[0.95]"
|
||||
@click="clearFilters"
|
||||
>
|
||||
<XCircleIcon />
|
||||
Clear all filters
|
||||
</button>
|
||||
<button
|
||||
v-for="channel in selectedChannels"
|
||||
:key="`remove-filter-${channel}`"
|
||||
class="tag-list__item transition-transform active:scale-[0.95]"
|
||||
:style="`--_color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'});--_bg-color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'}-highlight)`"
|
||||
@click="toggleFilter('channel', channel)"
|
||||
>
|
||||
<XIcon />
|
||||
{{ channel.slice(0, 1).toUpperCase() + channel.slice(1) }}
|
||||
</button>
|
||||
<button
|
||||
v-for="version in selectedGameVersions"
|
||||
:key="`remove-filter-${version}`"
|
||||
class="tag-list__item transition-transform active:scale-[0.95]"
|
||||
@click="toggleFilter('gameVersion', version)"
|
||||
>
|
||||
<XIcon />
|
||||
{{ version }}
|
||||
</button>
|
||||
<button
|
||||
v-for="platform in selectedPlatforms"
|
||||
:key="`remove-filter-${platform}`"
|
||||
class="tag-list__item transition-transform active:scale-[0.95]"
|
||||
:style="`--_color: var(--color-platform-${platform})`"
|
||||
@click="toggleFilter('platform', platform)"
|
||||
>
|
||||
<XIcon />
|
||||
{{ formatCategory(platform) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FilterIcon, XCircleIcon, XIcon } from "@modrinth/assets";
|
||||
import { ManySelect, Checkbox } from "@modrinth/ui";
|
||||
import { formatCategory } from "@modrinth/utils";
|
||||
import type { ModrinthVersion } from "@modrinth/utils";
|
||||
|
||||
const props = defineProps<{ versions: ModrinthVersion[] }>();
|
||||
|
||||
const emit = defineEmits(["switch-page"]);
|
||||
|
||||
const allChannels = ref(["release", "beta", "alpha"]);
|
||||
|
||||
const route = useNativeRoute();
|
||||
const router = useNativeRouter();
|
||||
|
||||
const tags = useTags();
|
||||
|
||||
const showSnapshots = ref(false);
|
||||
|
||||
type FilterType = "channel" | "gameVersion" | "platform";
|
||||
type Filter = string;
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const filters: Record<FilterType, Filter[]> = {
|
||||
channel: [],
|
||||
gameVersion: [],
|
||||
platform: [],
|
||||
};
|
||||
|
||||
const platformSet = new Set();
|
||||
const gameVersionSet = new Set();
|
||||
const channelSet = new Set();
|
||||
|
||||
for (const version of props.versions) {
|
||||
for (const loader of version.loaders) {
|
||||
platformSet.add(loader);
|
||||
}
|
||||
for (const gameVersion of version.game_versions) {
|
||||
gameVersionSet.add(gameVersion);
|
||||
}
|
||||
channelSet.add(version.version_type);
|
||||
}
|
||||
|
||||
if (channelSet.size > 0) {
|
||||
filters.channel = Array.from(channelSet) as Filter[];
|
||||
filters.channel.sort((a, b) => allChannels.value.indexOf(a) - allChannels.value.indexOf(b));
|
||||
}
|
||||
if (gameVersionSet.size > 0) {
|
||||
const gameVersions = tags.value.gameVersions.filter((x) => gameVersionSet.has(x.version));
|
||||
|
||||
filters.gameVersion = gameVersions
|
||||
.filter((x) => (showSnapshots.value ? true : x.version_type === "release"))
|
||||
.map((x) => x.version);
|
||||
}
|
||||
if (platformSet.size > 0) {
|
||||
filters.platform = Array.from(platformSet) as Filter[];
|
||||
}
|
||||
|
||||
return filters;
|
||||
});
|
||||
|
||||
const selectedChannels = ref<string[]>([]);
|
||||
const selectedGameVersions = ref<string[]>([]);
|
||||
const selectedPlatforms = ref<string[]>([]);
|
||||
|
||||
selectedChannels.value = route.query.c ? getArrayOrString(route.query.c) : [];
|
||||
selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : [];
|
||||
selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : [];
|
||||
|
||||
async function toggleFilters(type: FilterType, filters: Filter[]) {
|
||||
for (const filter of filters) {
|
||||
await toggleFilter(type, filter);
|
||||
}
|
||||
|
||||
await router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
c: selectedChannels.value,
|
||||
g: selectedGameVersions.value,
|
||||
l: selectedPlatforms.value,
|
||||
},
|
||||
});
|
||||
|
||||
emit("switch-page", 1);
|
||||
}
|
||||
|
||||
async function toggleFilter(type: FilterType, filter: Filter, skipRouter = false) {
|
||||
if (type === "channel") {
|
||||
selectedChannels.value = selectedChannels.value.includes(filter)
|
||||
? selectedChannels.value.filter((x) => x !== filter)
|
||||
: [...selectedChannels.value, filter];
|
||||
} else if (type === "gameVersion") {
|
||||
selectedGameVersions.value = selectedGameVersions.value.includes(filter)
|
||||
? selectedGameVersions.value.filter((x) => x !== filter)
|
||||
: [...selectedGameVersions.value, filter];
|
||||
} else if (type === "platform") {
|
||||
selectedPlatforms.value = selectedPlatforms.value.includes(filter)
|
||||
? selectedPlatforms.value.filter((x) => x !== filter)
|
||||
: [...selectedPlatforms.value, filter];
|
||||
}
|
||||
if (!skipRouter) {
|
||||
await updateFilters();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateFilters() {
|
||||
await router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
c: selectedChannels.value,
|
||||
g: selectedGameVersions.value,
|
||||
l: selectedPlatforms.value,
|
||||
},
|
||||
});
|
||||
|
||||
emit("switch-page", 1);
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
selectedChannels.value = [];
|
||||
selectedGameVersions.value = [];
|
||||
selectedPlatforms.value = [];
|
||||
|
||||
await router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
c: undefined,
|
||||
g: undefined,
|
||||
l: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
emit("switch-page", 1);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
toggleFilter,
|
||||
toggleFilters,
|
||||
});
|
||||
</script>
|
||||
@@ -1,46 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[min-content_auto_min-content_min-content] items-center gap-2 rounded-2xl border-[1px] border-button-bg bg-bg p-2"
|
||||
>
|
||||
<VersionChannelIndicator :channel="version.version_type" />
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h1 class="my-0 truncate text-nowrap text-base font-extrabold leading-none text-contrast">
|
||||
{{ version.version_number }}
|
||||
</h1>
|
||||
<p class="m-0 truncate text-nowrap text-xs font-semibold text-secondary">
|
||||
{{ version.name }}
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled color="brand">
|
||||
<a :href="downloadUrl" class="min-w-0" @click="emit('onDownload')">
|
||||
<DownloadIcon aria-hidden="true" /> Download
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<nuxt-link
|
||||
:to="`/project/${props.version.project_id}/version/${props.version.id}`"
|
||||
class="min-w-0"
|
||||
aria-label="Open project page"
|
||||
@click="emit('onNavigate')"
|
||||
>
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, VersionChannelIndicator } from "@modrinth/ui";
|
||||
import { DownloadIcon, ExternalIcon } from "@modrinth/assets";
|
||||
|
||||
const props = defineProps<{
|
||||
version: Version;
|
||||
}>();
|
||||
|
||||
const downloadUrl = computed(() => {
|
||||
const primary: VersionFile = props.version.files.find((x) => x.primary) || props.version.files[0];
|
||||
return primary.url;
|
||||
});
|
||||
|
||||
const emit = defineEmits(["onDownload", "onNavigate"]);
|
||||
</script>
|
||||
@@ -44,7 +44,7 @@ import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { PlusIcon, XIcon, InfoIcon } from "@modrinth/assets";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupCreated"]);
|
||||
|
||||
@@ -104,7 +104,7 @@ const modal = ref<InstanceType<typeof NewModal>>();
|
||||
|
||||
const initialSettings = ref<{ interval: number; enabled: boolean } | null>(null);
|
||||
const autoBackupEnabled = ref(false);
|
||||
const autoBackupInterval = ref(1);
|
||||
const autoBackupInterval = ref(6);
|
||||
const isLoadingSettings = ref(true);
|
||||
const isSaving = ref(false);
|
||||
|
||||
@@ -134,7 +134,7 @@ const fetchSettings = async () => {
|
||||
const settings = await props.server.backups?.getAutoBackup();
|
||||
initialSettings.value = settings as { interval: number; enabled: boolean };
|
||||
autoBackupEnabled.value = settings?.enabled ?? false;
|
||||
autoBackupInterval.value = settings?.interval || 1;
|
||||
autoBackupInterval.value = settings?.interval || 6;
|
||||
} catch (error) {
|
||||
console.error("Error fetching backup settings:", error);
|
||||
addNotification({
|
||||
|
||||
@@ -2,40 +2,61 @@
|
||||
<li
|
||||
role="button"
|
||||
data-pyro-file
|
||||
:class="containerClasses"
|
||||
:class="[
|
||||
containerClasses,
|
||||
isDragOver && type === 'directory' ? 'bg-brand-highlight' : '',
|
||||
isDragging ? 'opacity-50' : '',
|
||||
]"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
@click="selectItem"
|
||||
@contextmenu="openContextMenu"
|
||||
@keydown="(e) => e.key === 'Enter' && selectItem()"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<div data-pyro-file-metadata class="flex w-full items-center gap-4 truncate">
|
||||
<div
|
||||
data-pyro-file-metadata
|
||||
class="pointer-events-none flex w-full items-center gap-4 truncate"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 items-center justify-center rounded-full bg-bg-raised p-[6px] group-hover:bg-brand-highlight group-hover:text-brand group-focus:bg-brand-highlight group-focus:text-brand"
|
||||
class="pointer-events-none flex size-8 items-center justify-center rounded-full bg-bg-raised p-[6px] group-hover:bg-brand-highlight group-hover:text-brand group-focus:bg-brand-highlight group-focus:text-brand"
|
||||
:class="isEditableFile ? 'group-active:scale-[0.8]' : ''"
|
||||
>
|
||||
<component :is="iconComponent" class="size-6" />
|
||||
</div>
|
||||
<div class="flex w-full flex-col truncate">
|
||||
<div class="pointer-events-none flex w-full flex-col truncate">
|
||||
<span
|
||||
class="w-[98%] truncate font-bold group-hover:text-contrast group-focus:text-contrast"
|
||||
>{{ name }}</span
|
||||
class="pointer-events-none w-[98%] truncate font-bold group-hover:text-contrast group-focus:text-contrast"
|
||||
>
|
||||
<span class="text-xs text-secondary group-hover:text-primary">
|
||||
{{ name }}
|
||||
</span>
|
||||
<span class="pointer-events-none text-xs text-secondary group-hover:text-primary">
|
||||
{{ subText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-pyro-file-actions class="flex w-fit flex-shrink-0 items-center gap-4 md:gap-12">
|
||||
<span class="w-[160px] text-nowrap text-right font-mono text-sm text-secondary">{{
|
||||
formattedDate
|
||||
}}</span>
|
||||
<div
|
||||
data-pyro-file-actions
|
||||
class="pointer-events-auto flex w-fit flex-shrink-0 items-center gap-4 md:gap-12"
|
||||
>
|
||||
<span class="hidden w-[160px] text-nowrap font-mono text-sm text-secondary md:flex">
|
||||
{{ formattedCreationDate }}
|
||||
</span>
|
||||
<span class="w-[160px] text-nowrap font-mono text-sm text-secondary">
|
||||
{{ formattedModifiedDate }}
|
||||
</span>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<UiServersTeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
|
||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||
<template #rename> <EditIcon /> Rename </template>
|
||||
<template #move> <RightArrowIcon /> Move </template>
|
||||
<template #download> <DownloadIcon /> Download </template>
|
||||
<template #delete> <TrashIcon /> Delete </template>
|
||||
<template #rename><EditIcon /> Rename</template>
|
||||
<template #move><RightArrowIcon /> Move</template>
|
||||
<template #download><DownloadIcon /> Download</template>
|
||||
<template #delete><TrashIcon /> Delete</template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -54,6 +75,7 @@ import {
|
||||
RightArrowIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { computed, shallowRef, ref } from "vue";
|
||||
import { renderToString } from "@vue/server-renderer";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import {
|
||||
UiServersIconsCogFolderIcon,
|
||||
@@ -70,12 +92,27 @@ interface FileItemProps {
|
||||
size?: number;
|
||||
count?: number;
|
||||
modified: number;
|
||||
created: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const props = defineProps<FileItemProps>();
|
||||
|
||||
const emit = defineEmits(["rename", "download", "delete", "move", "edit", "contextmenu"]);
|
||||
const emit = defineEmits<{
|
||||
(e: "rename", item: { name: string; type: string; path: string }): void;
|
||||
(e: "move", item: { name: string; type: string; path: string }): void;
|
||||
(
|
||||
e: "moveDirectTo",
|
||||
item: { name: string; type: string; path: string; destination: string },
|
||||
): void;
|
||||
(e: "download", item: { name: string; type: string; path: string }): void;
|
||||
(e: "delete", item: { name: string; type: string; path: string }): void;
|
||||
(e: "edit", item: { name: string; type: string; path: string }): void;
|
||||
(e: "contextmenu", x: number, y: number): void;
|
||||
}>();
|
||||
|
||||
const isDragOver = ref(false);
|
||||
const isDragging = ref(false);
|
||||
|
||||
const codeExtensions = Object.freeze([
|
||||
"json",
|
||||
@@ -114,6 +151,7 @@ const router = useRouter();
|
||||
const containerClasses = computed(() => [
|
||||
"group m-0 p-0 focus:!outline-none flex w-full select-none items-center justify-between overflow-hidden border-0 border-b border-solid border-bg-raised p-3 last:border-none hover:bg-bg-raised focus:bg-bg-raised",
|
||||
isEditableFile.value ? "cursor-pointer" : props.type === "directory" ? "cursor-pointer" : "",
|
||||
isDragOver.value ? "bg-brand-highlight" : "",
|
||||
]);
|
||||
|
||||
const fileExtension = computed(() => props.name.split(".").pop()?.toLowerCase() || "");
|
||||
@@ -161,7 +199,7 @@ const subText = computed(() => {
|
||||
return formattedSize.value;
|
||||
});
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const formattedModifiedDate = computed(() => {
|
||||
const date = new Date(props.modified * 1000);
|
||||
return `${date.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
@@ -174,6 +212,19 @@ const formattedDate = computed(() => {
|
||||
})}`;
|
||||
});
|
||||
|
||||
const formattedCreationDate = computed(() => {
|
||||
const date = new Date(props.created * 1000);
|
||||
return `${date.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "2-digit",
|
||||
})}, ${date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
})}`;
|
||||
});
|
||||
|
||||
const isEditableFile = computed(() => {
|
||||
if (props.type === "file") {
|
||||
const ext = fileExtension.value;
|
||||
@@ -226,4 +277,121 @@ const selectItem = () => {
|
||||
isNavigating.value = false;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const getDragIcon = async () => {
|
||||
let iconToUse;
|
||||
|
||||
if (props.type === "directory") {
|
||||
if (props.name === "config") {
|
||||
iconToUse = UiServersIconsCogFolderIcon;
|
||||
} else if (props.name === "world") {
|
||||
iconToUse = UiServersIconsEarthIcon;
|
||||
} else if (props.name === "resourcepacks") {
|
||||
iconToUse = PaletteIcon;
|
||||
} else {
|
||||
iconToUse = FolderOpenIcon;
|
||||
}
|
||||
} else {
|
||||
const ext = fileExtension.value;
|
||||
if (codeExtensions.includes(ext)) {
|
||||
iconToUse = UiServersIconsCodeFileIcon;
|
||||
} else if (textExtensions.includes(ext)) {
|
||||
iconToUse = UiServersIconsTextFileIcon;
|
||||
} else if (imageExtensions.includes(ext)) {
|
||||
iconToUse = UiServersIconsImageFileIcon;
|
||||
} else {
|
||||
iconToUse = FileIcon;
|
||||
}
|
||||
}
|
||||
|
||||
return await renderToString(h(iconToUse));
|
||||
};
|
||||
|
||||
const handleDragStart = async (event: DragEvent) => {
|
||||
if (!event.dataTransfer) return;
|
||||
isDragging.value = true;
|
||||
|
||||
const dragGhost = document.createElement("div");
|
||||
dragGhost.className =
|
||||
"fixed left-0 top-0 flex items-center max-w-[500px] flex-row gap-3 rounded-lg bg-bg-raised p-3 shadow-lg pointer-events-none";
|
||||
|
||||
const iconContainer = document.createElement("div");
|
||||
iconContainer.className = "flex size-6 items-center justify-center";
|
||||
|
||||
const icon = document.createElement("div");
|
||||
icon.className = "size-4";
|
||||
icon.innerHTML = await getDragIcon();
|
||||
iconContainer.appendChild(icon);
|
||||
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.className = "font-bold truncate text-contrast";
|
||||
nameSpan.textContent = props.name;
|
||||
|
||||
dragGhost.appendChild(iconContainer);
|
||||
dragGhost.appendChild(nameSpan);
|
||||
document.body.appendChild(dragGhost);
|
||||
|
||||
event.dataTransfer.setDragImage(dragGhost, 0, 0);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
document.body.removeChild(dragGhost);
|
||||
});
|
||||
|
||||
event.dataTransfer.setData(
|
||||
"application/pyro-file-move",
|
||||
JSON.stringify({
|
||||
name: props.name,
|
||||
type: props.type,
|
||||
path: props.path,
|
||||
}),
|
||||
);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
const isChildPath = (parentPath: string, childPath: string) => {
|
||||
return childPath.startsWith(parentPath + "/");
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
isDragging.value = false;
|
||||
};
|
||||
|
||||
const handleDragEnter = () => {
|
||||
if (props.type !== "directory") return;
|
||||
isDragOver.value = true;
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
if (props.type !== "directory" || !event.dataTransfer) return;
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
isDragOver.value = false;
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
isDragOver.value = false;
|
||||
if (props.type !== "directory" || !event.dataTransfer) return;
|
||||
|
||||
try {
|
||||
const dragData = JSON.parse(event.dataTransfer.getData("application/pyro-file-move"));
|
||||
|
||||
if (dragData.path === props.path) return;
|
||||
|
||||
if (dragData.type === "directory" && isChildPath(dragData.path, props.path)) {
|
||||
console.error("Cannot move a folder into its own subfolder");
|
||||
return;
|
||||
}
|
||||
|
||||
emit("moveDirectTo", {
|
||||
name: dragData.name,
|
||||
type: dragData.type,
|
||||
path: dragData.path,
|
||||
destination: props.path,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error handling file drop:", error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
@rename="$emit('rename', item)"
|
||||
@download="$emit('download', item)"
|
||||
@move="$emit('move', item)"
|
||||
@move-direct-to="$emit('moveDirectTo', $event)"
|
||||
@edit="$emit('edit', item)"
|
||||
@contextmenu="(x, y) => $emit('contextmenu', item, x, y)"
|
||||
/>
|
||||
@@ -55,6 +56,7 @@ const emit = defineEmits<{
|
||||
(e: "edit", item: any): void;
|
||||
(e: "contextmenu", item: any, x: number, y: number): void;
|
||||
(e: "loadMore"): void;
|
||||
(e: "moveDirectTo", item: any): void;
|
||||
}>();
|
||||
|
||||
const ITEM_HEIGHT = 61;
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
:options="[
|
||||
{ id: 'normal', action: () => $emit('sort', 'default') },
|
||||
{ id: 'modified', action: () => $emit('sort', 'modified') },
|
||||
{ id: 'created', action: () => $emit('sort', 'created') },
|
||||
{ id: 'filesOnly', action: () => $emit('sort', 'filesOnly') },
|
||||
{ id: 'foldersOnly', action: () => $emit('sort', 'foldersOnly') },
|
||||
]"
|
||||
@@ -91,11 +92,12 @@
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #normal> Alphabetical </template>
|
||||
<template #modified> Date modified </template>
|
||||
<template #created> Date created </template>
|
||||
<template #filesOnly> Files only </template>
|
||||
<template #foldersOnly> Folders only </template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
<div class="mx-1 w-full text-sm sm:w-40">
|
||||
<div class="mx-1 w-full text-sm sm:w-48">
|
||||
<label for="search-folder" class="sr-only">Search folder</label>
|
||||
<div class="relative">
|
||||
<SearchIcon
|
||||
@@ -108,7 +110,7 @@
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
class="h-8 min-h-[unset] w-full border-[1px] border-solid border-button-bg bg-transparent py-2 pl-9"
|
||||
class="h-8 min-h-[unset] w-full border-[1px] border-solid border-divider bg-transparent py-2 pl-9"
|
||||
placeholder="Search..."
|
||||
@input="$emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
@@ -183,6 +185,8 @@ const sortMethodLabel = computed(() => {
|
||||
switch (props.sortMethod) {
|
||||
case "modified":
|
||||
return "Date modified";
|
||||
case "created":
|
||||
return "Date created";
|
||||
case "filesOnly":
|
||||
return "Files only";
|
||||
case "foldersOnly":
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
id="item-context-menu"
|
||||
ref="ctxRef"
|
||||
:style="{
|
||||
border: '1px solid var(--color-button-bg)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-divider)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
backgroundColor: 'var(--color-raised-bg)',
|
||||
padding: 'var(--gap-sm)',
|
||||
boxShadow: 'var(--shadow-floating)',
|
||||
@@ -31,7 +31,7 @@
|
||||
Rename
|
||||
</button>
|
||||
<button class="btn btn-transparent flex !w-full items-center" @click="$emit('move', item)">
|
||||
<ArrowBigUpDashIcon class="h-5 w-5" />
|
||||
<RightArrowIcon />
|
||||
Move
|
||||
</button>
|
||||
<button
|
||||
@@ -55,7 +55,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, ArrowBigUpDashIcon, DownloadIcon, TrashIcon } from "@modrinth/assets";
|
||||
import { EditIcon, DownloadIcon, TrashIcon, RightArrowIcon } from "@modrinth/assets";
|
||||
|
||||
interface FileItem {
|
||||
type: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="`Creating a ${type}`">
|
||||
<NewModal ref="modal" :header="`Creating a ${displayType}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
@@ -18,7 +18,7 @@
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error" type="submit">
|
||||
<PlusIcon class="h-5 w-5" />
|
||||
Create
|
||||
Create {{ displayType }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
@@ -46,6 +46,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const modal = ref<typeof NewModal>();
|
||||
const displayType = computed(() => (props.type === "directory" ? "folder" : props.type));
|
||||
const createInput = ref<HTMLInputElement | null>(null);
|
||||
const itemName = ref("");
|
||||
const submitted = ref(false);
|
||||
|
||||
@@ -6,15 +6,15 @@
|
||||
>
|
||||
<nav
|
||||
aria-label="Breadcrumb navigation"
|
||||
class="m-0 flex list-none items-center p-0 text-contrast"
|
||||
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
|
||||
>
|
||||
<ol class="m-0 flex list-none items-center p-0">
|
||||
<li class="-ml-1">
|
||||
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
|
||||
<li class="-ml-1 flex-shrink-0">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="'Back to home'"
|
||||
type="button"
|
||||
class="grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
class="mr-2 grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
@click="goHome"
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -1,159 +1,178 @@
|
||||
<template>
|
||||
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center bg-bg-raised">
|
||||
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center">
|
||||
<div
|
||||
ref="container"
|
||||
class="relative w-full flex-grow overflow-hidden bg-bg-raised"
|
||||
class="relative w-full flex-grow cursor-grab overflow-hidden rounded-b-2xl bg-black active:cursor-grabbing"
|
||||
@mousedown="startPan"
|
||||
@mousemove="pan"
|
||||
@mouseup="endPan"
|
||||
@mouseleave="endPan"
|
||||
@mousemove="handlePan"
|
||||
@mouseup="stopPan"
|
||||
@mouseleave="stopPan"
|
||||
@wheel.prevent="handleWheel"
|
||||
>
|
||||
<UiServersPyroLoading v-if="loading" />
|
||||
<div v-if="error" class="flex h-full w-full flex-col items-center justify-center gap-8">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-12"
|
||||
>
|
||||
<path d="M4 13c3.5-2 8-2 10 2a5.5 5.5 0 0 1 8 5" />
|
||||
<path
|
||||
d="M5.15 17.89c5.52-1.52 8.65-6.89 7-12C11.55 4 11.5 2 13 2c3.22 0 5 5.5 5 8 0 6.5-4.2 12-10.49 12C5.11 22 2 22 2 20c0-1.5 1.14-1.55 3.15-2.11Z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="m-0">Invalid or empty image file.</p>
|
||||
<UiServersPyroLoading v-if="state.isLoading" />
|
||||
<div
|
||||
v-if="state.hasError"
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-8"
|
||||
>
|
||||
<UiServersIconsPanelErrorIcon />
|
||||
<p class="m-0">{{ state.errorMessage || "Invalid or empty image file." }}</p>
|
||||
</div>
|
||||
<img
|
||||
v-show="!loading && !error"
|
||||
ref="image"
|
||||
:src="imageUrl"
|
||||
v-show="isReady"
|
||||
ref="imageRef"
|
||||
:src="imageObjectUrl"
|
||||
class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform"
|
||||
:style="{
|
||||
transform: `translate(-50%, -50%) scale(${scale}) translate(${translateX}px, ${translateY}px)`,
|
||||
transition: isPanning ? 'none' : 'transform 0.3s ease-out',
|
||||
}"
|
||||
:style="imageStyle"
|
||||
alt="Viewed image"
|
||||
@load="onImageLoad"
|
||||
@error="onImageError"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!error"
|
||||
class="absolute bottom-0 mb-2 flex w-fit justify-center space-x-4 rounded-xl bg-bg p-2"
|
||||
v-if="!state.hasError"
|
||||
class="absolute bottom-0 mb-2 flex w-fit justify-center gap-2 space-x-4 rounded-2xl bg-bg p-2"
|
||||
>
|
||||
<Button icon-only transparent @click="zoomIn">
|
||||
<ZoomInIcon />
|
||||
</Button>
|
||||
<Button icon-only transparent @click="resetZoom">
|
||||
<HomeIcon />
|
||||
</Button>
|
||||
<Button icon-only transparent @click="zoomOut">
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
<ButtonStyled type="transparent" @click="zoom(ZOOM_IN_FACTOR)">
|
||||
<button v-tooltip="'Zoom in'">
|
||||
<ZoomInIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent" @click="zoom(ZOOM_OUT_FACTOR)">
|
||||
<button v-tooltip="'Zoom out'">
|
||||
<ZoomOutIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent" @click="reset">
|
||||
<button>
|
||||
<span class="font-mono">{{ Math.round(state.scale * 100) }}%</span>
|
||||
<span class="ml-4 text-sm text-blue">Reset</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { HomeIcon, ZoomInIcon, ZoomOutIcon } from "@modrinth/assets";
|
||||
import { Button } from "@modrinth/ui";
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { ZoomInIcon, ZoomOutIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps({
|
||||
imageBlob: {
|
||||
type: Blob,
|
||||
required: true,
|
||||
},
|
||||
const ZOOM_MIN = 0.1;
|
||||
const ZOOM_MAX = 5;
|
||||
const ZOOM_IN_FACTOR = 1.2;
|
||||
const ZOOM_OUT_FACTOR = 0.8;
|
||||
const INITIAL_SCALE = 0.5;
|
||||
const MAX_IMAGE_DIMENSION = 4096;
|
||||
|
||||
const props = defineProps<{
|
||||
imageBlob: Blob;
|
||||
}>();
|
||||
|
||||
const state = ref({
|
||||
scale: INITIAL_SCALE,
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
isPanning: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
errorMessage: "",
|
||||
});
|
||||
|
||||
const container = ref(null);
|
||||
const image = ref(null);
|
||||
const scale = ref(1);
|
||||
const translateX = ref(0);
|
||||
const translateY = ref(0);
|
||||
const isPanning = ref(false);
|
||||
const startX = ref(0);
|
||||
const startY = ref(0);
|
||||
const imageUrl = ref("");
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
const imageRef = ref<HTMLImageElement | null>(null);
|
||||
const container = ref<HTMLElement | null>(null);
|
||||
const imageObjectUrl = ref("");
|
||||
const rafId = ref(0);
|
||||
|
||||
const createImageUrl = (blob) => {
|
||||
if (imageUrl.value) {
|
||||
URL.revokeObjectURL(imageUrl.value);
|
||||
const isReady = computed(() => !state.value.isLoading && !state.value.hasError);
|
||||
|
||||
const imageStyle = computed(() => ({
|
||||
transform: `translate(-50%, -50%) scale(${state.value.scale}) translate(${state.value.translateX}px, ${state.value.translateY}px)`,
|
||||
transition: state.value.isPanning ? "none" : "transform 0.3s ease-out",
|
||||
}));
|
||||
|
||||
const validateImageDimensions = (img: HTMLImageElement): boolean => {
|
||||
if (img.naturalWidth > MAX_IMAGE_DIMENSION || img.naturalHeight > MAX_IMAGE_DIMENSION) {
|
||||
state.value.hasError = true;
|
||||
state.value.errorMessage = `Image too large to view (max ${MAX_IMAGE_DIMENSION}x${MAX_IMAGE_DIMENSION} pixels)`;
|
||||
return false;
|
||||
}
|
||||
imageUrl.value = URL.createObjectURL(blob);
|
||||
return true;
|
||||
};
|
||||
|
||||
const updateImageUrl = (blob: Blob) => {
|
||||
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value);
|
||||
imageObjectUrl.value = URL.createObjectURL(blob);
|
||||
};
|
||||
|
||||
const handleImageLoad = () => {
|
||||
if (!imageRef.value || !validateImageDimensions(imageRef.value)) {
|
||||
state.value.isLoading = false;
|
||||
return;
|
||||
}
|
||||
state.value.isLoading = false;
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleImageError = () => {
|
||||
state.value.isLoading = false;
|
||||
state.value.hasError = true;
|
||||
state.value.errorMessage = "Failed to load image";
|
||||
};
|
||||
|
||||
const zoom = (factor: number) => {
|
||||
const newScale = state.value.scale * factor;
|
||||
state.value.scale = Math.max(ZOOM_MIN, Math.min(newScale, ZOOM_MAX));
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
state.value.scale = INITIAL_SCALE;
|
||||
state.value.translateX = 0;
|
||||
state.value.translateY = 0;
|
||||
};
|
||||
|
||||
const startPan = (e: MouseEvent) => {
|
||||
state.value.isPanning = true;
|
||||
state.value.startX = e.clientX - state.value.translateX;
|
||||
state.value.startY = e.clientY - state.value.translateY;
|
||||
};
|
||||
|
||||
const handlePan = (e: MouseEvent) => {
|
||||
if (!state.value.isPanning) return;
|
||||
cancelAnimationFrame(rafId.value);
|
||||
rafId.value = requestAnimationFrame(() => {
|
||||
state.value.translateX = e.clientX - state.value.startX;
|
||||
state.value.translateY = e.clientY - state.value.startY;
|
||||
});
|
||||
};
|
||||
|
||||
const stopPan = () => {
|
||||
state.value.isPanning = false;
|
||||
};
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
const delta = e.deltaY * -0.001;
|
||||
const factor = 1 + delta;
|
||||
zoom(factor);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.imageBlob,
|
||||
(newBlob) => {
|
||||
if (newBlob) {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
createImageUrl(newBlob);
|
||||
}
|
||||
if (!newBlob) return;
|
||||
state.value.isLoading = true;
|
||||
state.value.hasError = false;
|
||||
updateImageUrl(newBlob);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.imageBlob) {
|
||||
createImageUrl(props.imageBlob);
|
||||
}
|
||||
if (props.imageBlob) updateImageUrl(props.imageBlob);
|
||||
});
|
||||
|
||||
const onImageLoad = () => {
|
||||
loading.value = false;
|
||||
resetZoom();
|
||||
};
|
||||
|
||||
const onImageError = () => {
|
||||
loading.value = false;
|
||||
error.value = true;
|
||||
};
|
||||
|
||||
const zoomIn = () => {
|
||||
scale.value = Math.min(scale.value * 1.2, 5);
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
scale.value = Math.max(scale.value / 1.2, 0.1);
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
scale.value = 0.5;
|
||||
translateX.value = 0;
|
||||
translateY.value = 0;
|
||||
};
|
||||
|
||||
const startPan = (e) => {
|
||||
isPanning.value = true;
|
||||
startX.value = e.clientX - translateX.value;
|
||||
startY.value = e.clientY - translateY.value;
|
||||
};
|
||||
|
||||
const pan = (e) => {
|
||||
if (isPanning.value) {
|
||||
translateX.value = e.clientX - startX.value;
|
||||
translateY.value = e.clientY - startY.value;
|
||||
}
|
||||
};
|
||||
|
||||
const endPan = () => {
|
||||
isPanning.value = false;
|
||||
};
|
||||
|
||||
const handleWheel = (e) => {
|
||||
const delta = (e.deltaY * -0.01) / 10;
|
||||
const newScale = Math.max(0.1, Math.min(scale.value + delta, 5));
|
||||
|
||||
scale.value = newScale;
|
||||
};
|
||||
onUnmounted(() => {
|
||||
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value);
|
||||
cancelAnimationFrame(rafId.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
14
apps/frontend/src/components/ui/servers/FilesLabelBar.vue
Normal file
14
apps/frontend/src/components/ui/servers/FilesLabelBar.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="flex w-full select-none flex-row items-center border-0 border-b border-solid border-bg-raised px-3 py-2 text-xs font-bold uppercase"
|
||||
>
|
||||
<div class="min-w-[48px]"></div>
|
||||
<span class="flex w-full">Name</span>
|
||||
<div class="flex shrink-0 gap-4 text-right md:gap-12">
|
||||
<span class="hidden min-w-[160px] md:block">Created</span>
|
||||
<span class="mr-4 min-w-[160px]">Modified</span>
|
||||
<div class="min-w-[36px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,7 +8,7 @@
|
||||
autofocus
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
placeholder="e.g. mods/modname"
|
||||
placeholder="e.g. /mods/modname"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowBigUpDashIcon, XIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ref, nextTick } from "vue";
|
||||
import { ref, nextTick, computed } from "vue";
|
||||
|
||||
const destinationInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -55,11 +55,12 @@ const emit = defineEmits<{
|
||||
const modal = ref<typeof NewModal>();
|
||||
const destination = ref("");
|
||||
const newpath = computed(() => {
|
||||
return destination.value.replace("//", "/");
|
||||
const path = destination.value.replace("//", "/");
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit("move", destination.value);
|
||||
emit("move", newpath.value);
|
||||
hide();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,52 +1,60 @@
|
||||
<template>
|
||||
<div
|
||||
v-for="loader in loaders"
|
||||
:key="loader.name"
|
||||
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
|
||||
<div
|
||||
v-for="loader in vanillaLoaders"
|
||||
:key="loader.name"
|
||||
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
|
||||
>
|
||||
<UiServersLoaderSelectorCard
|
||||
:loader="loader"
|
||||
:is-current="isCurrentLoader(loader.name)"
|
||||
:loader-version="data.loader_version"
|
||||
:current-loader="data.loader"
|
||||
@select="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h2 class="mb-2 px-2 text-lg font-bold text-contrast">Mod loaders</h2>
|
||||
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
|
||||
<div
|
||||
class="grid size-10 place-content-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
:class="isCurrentLoader(loader.name) ? '[&&]:bg-bg-green' : ''"
|
||||
v-for="loader in modLoaders"
|
||||
:key="loader.name"
|
||||
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
|
||||
>
|
||||
<UiServersIconsLoaderIcon
|
||||
:loader="loader.name"
|
||||
class="[&&]:size-6"
|
||||
:class="isCurrentLoader(loader.name) ? 'text-brand' : ''"
|
||||
<UiServersLoaderSelectorCard
|
||||
:loader="loader"
|
||||
:is-current="isCurrentLoader(loader.name)"
|
||||
:loader-version="data.loader_version"
|
||||
:current-loader="data.loader"
|
||||
@select="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h1 class="m-0 text-xl font-bold leading-none text-contrast">
|
||||
{{ loader.displayName }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="isCurrentLoader(loader.name)"
|
||||
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
|
||||
>
|
||||
<CheckIcon class="h-4 w-4" />
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="isCurrentLoader(loader.name)" class="m-0 text-xs text-secondary">
|
||||
{{ data.loader_version }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h2 class="mb-2 px-2 text-lg font-bold text-contrast">Plugin loaders</h2>
|
||||
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
|
||||
<div
|
||||
v-for="loader in pluginLoaders"
|
||||
:key="loader.name"
|
||||
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
|
||||
>
|
||||
<UiServersLoaderSelectorCard
|
||||
:loader="loader"
|
||||
:is-current="isCurrentLoader(loader.name)"
|
||||
:loader-version="data.loader_version"
|
||||
:current-loader="data.loader"
|
||||
@select="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled>
|
||||
<button @click="selectLoader(loader.name)">
|
||||
<DownloadIcon class="h-5 w-5" />
|
||||
{{ isCurrentLoader(loader.name) ? "Reinstall" : "Install" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DownloadIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps<{
|
||||
data: {
|
||||
loader: string | null;
|
||||
@@ -58,14 +66,20 @@ const emit = defineEmits<{
|
||||
(e: "selectLoader", loader: string): void;
|
||||
}>();
|
||||
|
||||
const loaders = [
|
||||
{ name: "Vanilla" as const, displayName: "Vanilla" },
|
||||
const vanillaLoaders = [{ name: "Vanilla" as const, displayName: "Vanilla" }];
|
||||
|
||||
const modLoaders = [
|
||||
{ name: "Fabric" as const, displayName: "Fabric" },
|
||||
{ name: "Quilt" as const, displayName: "Quilt" },
|
||||
{ name: "Forge" as const, displayName: "Forge" },
|
||||
{ name: "NeoForge" as const, displayName: "NeoForge" },
|
||||
];
|
||||
|
||||
const pluginLoaders = [
|
||||
{ name: "Paper" as const, displayName: "Paper" },
|
||||
{ name: "Purpur" as const, displayName: "Purpur" },
|
||||
];
|
||||
|
||||
const isCurrentLoader = (loaderName: string) => {
|
||||
return props.data.loader?.toLowerCase() === loaderName.toLowerCase();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="grid size-10 place-content-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
:class="isCurrentLoader ? '[&&]:bg-bg-green' : ''"
|
||||
>
|
||||
<UiServersIconsLoaderIcon
|
||||
:loader="loader.name"
|
||||
class="[&&]:size-6"
|
||||
:class="isCurrentLoader ? 'text-brand' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h1 class="m-0 text-xl font-bold leading-none text-contrast">
|
||||
{{ loader.displayName }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="isCurrentLoader"
|
||||
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
|
||||
>
|
||||
<CheckIcon class="h-4 w-4" />
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="isCurrentLoader" class="m-0 text-xs text-secondary">
|
||||
{{ loaderVersion }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled>
|
||||
<button @click="onSelect">
|
||||
<DownloadIcon class="h-5 w-5" />
|
||||
{{ isCurrentLoader ? "Reinstall" : "Install" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DownloadIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
|
||||
interface LoaderInfo {
|
||||
name: "Vanilla" | "Fabric" | "Forge" | "Quilt" | "Paper" | "NeoForge" | "Purpur";
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
loader: LoaderInfo;
|
||||
currentLoader: string | null;
|
||||
loaderVersion: string | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "select", loader: string): void;
|
||||
}>();
|
||||
|
||||
const isCurrentLoader = computed(() => {
|
||||
return props.currentLoader?.toLowerCase() === props.loader.name.toLowerCase();
|
||||
});
|
||||
|
||||
const onSelect = () => {
|
||||
emit("select", props.loader.name);
|
||||
};
|
||||
</script>
|
||||
@@ -136,15 +136,18 @@
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon } from "@modrinth/assets";
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
import { usePyroConsole } from "~/store/console.ts";
|
||||
|
||||
const { $cosmetics } = useNuxtApp();
|
||||
const cosmetics = $cosmetics;
|
||||
|
||||
const props = defineProps<{
|
||||
consoleOutput: string[];
|
||||
fullScreen: boolean;
|
||||
}>();
|
||||
|
||||
const pyroConsole = usePyroConsole();
|
||||
const consoleOutput = pyroConsole.output;
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null);
|
||||
const itemHeights = ref<number[]>([]);
|
||||
const averageItemHeight = ref(36);
|
||||
@@ -170,7 +173,7 @@ const handleScrollEvent = () => {
|
||||
const totalHeight = computed(
|
||||
() =>
|
||||
itemHeights.value.reduce((sum, height) => sum + height, 0) ||
|
||||
props.consoleOutput.length * averageItemHeight.value,
|
||||
consoleOutput.value.length * averageItemHeight.value,
|
||||
);
|
||||
|
||||
watch(totalHeight, () => {
|
||||
@@ -223,7 +226,7 @@ const visibleStartIndex = computed(() => {
|
||||
let index = 0;
|
||||
let offset = 0;
|
||||
while (
|
||||
index < props.consoleOutput.length &&
|
||||
index < consoleOutput.value.length &&
|
||||
offset < scrollTop.value - bufferSize * averageItemHeight.value
|
||||
) {
|
||||
offset += itemHeights.value[index] || averageItemHeight.value;
|
||||
@@ -236,17 +239,17 @@ const visibleEndIndex = computed(() => {
|
||||
let index = visibleStartIndex.value;
|
||||
let offset = getItemOffset(index);
|
||||
while (
|
||||
index < props.consoleOutput.length &&
|
||||
index < consoleOutput.value.length &&
|
||||
offset < scrollTop.value + clientHeight.value + bufferSize * averageItemHeight.value
|
||||
) {
|
||||
offset += itemHeights.value[index] || averageItemHeight.value;
|
||||
index++;
|
||||
}
|
||||
return Math.min(props.consoleOutput.length - 1, index);
|
||||
return Math.min(consoleOutput.value.length - 1, index);
|
||||
});
|
||||
|
||||
const visibleItems = computed(() =>
|
||||
props.consoleOutput.slice(visibleStartIndex.value, visibleEndIndex.value + 1),
|
||||
consoleOutput.value.slice(visibleStartIndex.value, visibleEndIndex.value + 1),
|
||||
);
|
||||
|
||||
const offsetY = computed(() => getItemOffset(visibleStartIndex.value));
|
||||
@@ -280,7 +283,7 @@ const updateItemHeights = async () => {
|
||||
const index = visibleStartIndex.value + idx;
|
||||
const height = el.getBoundingClientRect().height;
|
||||
itemHeights.value[index] = height;
|
||||
const content = props.consoleOutput[index];
|
||||
const content = consoleOutput.value[index];
|
||||
if (content) {
|
||||
cachedHeights.value.set(content, height);
|
||||
}
|
||||
@@ -457,7 +460,7 @@ const initializeTerminal = async () => {
|
||||
|
||||
updateClientHeight();
|
||||
|
||||
const initialHeights = props.consoleOutput.map(
|
||||
const initialHeights = consoleOutput.value.map(
|
||||
(content) => cachedHeights.value.get(content) || averageItemHeight.value,
|
||||
);
|
||||
itemHeights.value = initialHeights;
|
||||
@@ -487,7 +490,7 @@ onUnmounted(() => {
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.consoleOutput,
|
||||
() => consoleOutput.value,
|
||||
async (newOutput) => {
|
||||
const newItemsCount = newOutput.length - itemHeights.value.length;
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</div>
|
||||
<div
|
||||
class="border-0 border-b border-solid"
|
||||
:class="danger ? 'border-[#cb2245] dark:border-[#612d38]' : 'border-button-bg'"
|
||||
:class="danger ? 'border-[#cb2245] dark:border-[#612d38]' : 'border-divider'"
|
||||
></div>
|
||||
<div class="mt-2 h-full w-full overflow-auto px-6">
|
||||
<slot />
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
<template>
|
||||
<NuxtLink class="contents" :to="`/servers/manage/${props.server_id}`">
|
||||
<NuxtLink
|
||||
class="contents"
|
||||
:to="status === 'suspended' ? '' : `/servers/manage/${props.server_id}`"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer flex-row items-center overflow-x-hidden rounded-3xl bg-bg-raised p-4 transition-transform duration-100 active:scale-95"
|
||||
v-tooltip="
|
||||
status === 'suspended'
|
||||
? suspension_reason === 'upgrading'
|
||||
? 'This server is being transferred to a new node. It will be unavailable until this process finishes.'
|
||||
: 'This server has been suspended. Please visit your billing settings or contact Modrinth Support for more information.'
|
||||
: ''
|
||||
"
|
||||
class="flex cursor-pointer flex-row items-center overflow-x-hidden rounded-3xl bg-bg-raised p-4 transition-transform duration-100"
|
||||
:class="status === 'suspended' ? '!rounded-b-none opacity-75' : 'active:scale-95'"
|
||||
data-pyro-server-listing
|
||||
:data-pyro-server-listing-id="server_id"
|
||||
>
|
||||
<UiServersServerIcon :image="image" />
|
||||
<UiServersServerIcon v-if="status !== 'suspended'" :image="image" />
|
||||
<div
|
||||
v-else
|
||||
class="bg-bg-secondary flex size-24 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<LockIcon class="size-20 text-secondary" />
|
||||
</div>
|
||||
<div class="ml-8 flex flex-col gap-2.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 text-xl font-bold text-contrast">{{ name }}</h2>
|
||||
@@ -36,11 +53,26 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="status === 'suspended' && suspension_reason === 'upgrading'"
|
||||
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-blue p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<UiServersPanelSpinner />
|
||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended'"
|
||||
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<UiServersIconsPanelErrorIcon class="!size-5" />
|
||||
Your server has been suspended due to a billing issue. Please visit your billing settings or
|
||||
contact Modrinth Support for more information.
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon } from "@modrinth/assets";
|
||||
import { ChevronRightIcon, LockIcon } from "@modrinth/assets";
|
||||
import type { Project, Server } from "~/types/servers";
|
||||
|
||||
const props = defineProps<Partial<Server>>();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="subdomain"
|
||||
v-tooltip="'Copy subdomain'"
|
||||
v-tooltip="'Copy custom URL'"
|
||||
class="flex min-w-0 flex-row items-center gap-4 truncate hover:cursor-pointer"
|
||||
>
|
||||
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
v-if="isOpen"
|
||||
ref="menuRef"
|
||||
data-pyro-telepopover-root
|
||||
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-button-bg bg-bg-raised p-2 shadow-lg"
|
||||
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-divider bg-bg-raised p-2 shadow-lg"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
@@ -272,7 +272,7 @@ const handleItemClick = (option: Option, index: number) => {
|
||||
|
||||
const handleMouseOver = (index: number) => {
|
||||
selectedIndex.value = index;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
menuItemsRef.value[selectedIndex.value].focus?.();
|
||||
};
|
||||
|
||||
// Scrolling is disabled for keyboard navigation
|
||||
@@ -295,7 +295,7 @@ const enableBodyScroll = () => {
|
||||
|
||||
const focusFirstMenuItem = () => {
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
menuItemsRef.value[0].focus();
|
||||
menuItemsRef.value[0].focus?.();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -312,26 +312,26 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
menuItemsRef.value[selectedIndex.value].focus?.();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
menuItemsRef.value[selectedIndex.value].focus?.();
|
||||
break;
|
||||
case "Home":
|
||||
event.preventDefault();
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
selectedIndex.value = 0;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
menuItemsRef.value[selectedIndex.value].focus?.();
|
||||
}
|
||||
break;
|
||||
case "End":
|
||||
event.preventDefault();
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
selectedIndex.value = filteredOptions.value.length - 1;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
menuItemsRef.value[selectedIndex.value].focus?.();
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
@@ -344,7 +344,7 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
triggerRef.value?.focus();
|
||||
triggerRef.value?.focus?.();
|
||||
break;
|
||||
case "Tab":
|
||||
event.preventDefault();
|
||||
@@ -355,7 +355,7 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
} else {
|
||||
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length;
|
||||
}
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
menuItemsRef.value[selectedIndex.value].focus?.();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
@@ -366,7 +366,7 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
);
|
||||
if (matchIndex !== -1) {
|
||||
selectedIndex.value = matchIndex;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
menuItemsRef.value[selectedIndex.value].focus?.();
|
||||
}
|
||||
if (typeAheadTimeout.value) {
|
||||
clearTimeout(typeAheadTimeout.value);
|
||||
|
||||
@@ -153,6 +153,65 @@
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="loader === 'Purpur'"
|
||||
xml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="1.5"
|
||||
clip-rule="evenodd"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<defs>
|
||||
<path
|
||||
id="purpur"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.68"
|
||||
d="m264 41.95 8-4v8l-8 4v-8Z"
|
||||
></path>
|
||||
</defs>
|
||||
<path fill="none" d="M0 0h24v24H0z"></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.77"
|
||||
d="m264 29.95-8 4 8 4.42 8-4.42-8-4Z"
|
||||
transform="matrix(1.125 0 0 1.1372 -285 -31.69)"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.77"
|
||||
d="m272 38.37-8 4.42-8-4.42"
|
||||
transform="matrix(1.125 0 0 1.1372 -285 -31.69)"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.77"
|
||||
d="m260 31.95 8 4.21V45"
|
||||
transform="matrix(1.125 0 0 1.1372 -285 -31.69)"
|
||||
></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.77"
|
||||
d="M260 45v-8.84l8-4.21"
|
||||
transform="matrix(1.125 0 0 1.1372 -285 -31.69)"
|
||||
></path>
|
||||
<use
|
||||
xlink:href="#purpur"
|
||||
stroke-width="1.68"
|
||||
transform="matrix(1.125 0 0 1.2569 -285 -40.78)"
|
||||
></use>
|
||||
<use
|
||||
xlink:href="#purpur"
|
||||
stroke-width="1.68"
|
||||
transform="matrix(-1.125 0 0 1.2569 309 -40.78)"
|
||||
></use>
|
||||
</svg>
|
||||
<svg v-else-if="loader === 'Vanilla'" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
@@ -165,8 +224,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LoaderIcon } from "@modrinth/assets";
|
||||
import type { Loaders } from "~/types/servers";
|
||||
|
||||
defineProps<{
|
||||
loader: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
|
||||
loader: Loaders;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
}"
|
||||
>
|
||||
<template v-if="members[message.author_id]">
|
||||
<ConditionalNuxtLink
|
||||
<AutoLink
|
||||
class="message__icon"
|
||||
:is-link="!noLinks"
|
||||
:to="`/user/${members[message.author_id].username}`"
|
||||
:to="noLinks ? '' : `/user/${members[message.author_id].username}`"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@@ -21,19 +20,16 @@
|
||||
circle
|
||||
:raised="raised"
|
||||
/>
|
||||
</ConditionalNuxtLink>
|
||||
</AutoLink>
|
||||
<span :class="`message__author role-${members[message.author_id].role}`">
|
||||
<LockIcon
|
||||
v-if="message.body.private"
|
||||
v-tooltip="'Only visible to moderators'"
|
||||
class="private-icon"
|
||||
/>
|
||||
<ConditionalNuxtLink
|
||||
:is-link="!noLinks"
|
||||
:to="`/user/${members[message.author_id].username}`"
|
||||
>
|
||||
<AutoLink :to="noLinks ? '' : `/user/${members[message.author_id].username}`">
|
||||
{{ members[message.author_id].username }}
|
||||
</ConditionalNuxtLink>
|
||||
</AutoLink>
|
||||
<ScaleIcon v-if="members[message.author_id].role === 'moderator'" v-tooltip="'Moderator'" />
|
||||
<ModrinthIcon
|
||||
v-else-if="members[message.author_id].role === 'admin'"
|
||||
@@ -107,7 +103,7 @@ import {
|
||||
ModrinthIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { OverflowMenu, ConditionalNuxtLink } from "@modrinth/ui";
|
||||
import { AutoLink, OverflowMenu } from "@modrinth/ui";
|
||||
import { renderString } from "@modrinth/utils";
|
||||
import Avatar from "~/components/ui/Avatar.vue";
|
||||
import Badge from "~/components/ui/Badge.vue";
|
||||
@@ -287,7 +283,7 @@ a:active + .message__author a,
|
||||
}
|
||||
|
||||
.moderation-color,
|
||||
role-moderator {
|
||||
.role-moderator {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user