You've already forked AstralRinth
forked from didirus/AstralRinth
Merge commit '74cf3f076eff43755bb4bef62f1c1bb3fc0e6c2a' into feature-clean
This commit is contained in:
@@ -1,126 +0,0 @@
|
||||
<template>
|
||||
<Modal ref="modal" :header="title">
|
||||
<div class="modal-delete">
|
||||
<div class="markdown-body" v-html="renderString(description)" />
|
||||
<label v-if="hasToType" for="confirmation" class="confirmation-label">
|
||||
<span>
|
||||
<strong>To verify, type</strong>
|
||||
<em class="confirmation-text">{{ confirmationText }}</em>
|
||||
<strong>below:</strong>
|
||||
</span>
|
||||
</label>
|
||||
<div class="confirmation-input">
|
||||
<input
|
||||
v-if="hasToType"
|
||||
id="confirmation"
|
||||
v-model="confirmation_typed"
|
||||
type="text"
|
||||
placeholder="Type here..."
|
||||
@input="type"
|
||||
/>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button class="iconified-button" @click="cancel">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button class="iconified-button danger-button" :disabled="action_disabled" @click="proceed">
|
||||
<TrashIcon />
|
||||
{{ proceedLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { renderString } from "@modrinth/utils";
|
||||
import { TrashIcon, XIcon } from "@modrinth/assets";
|
||||
import Modal from "~/components/ui/Modal.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
XIcon,
|
||||
TrashIcon,
|
||||
Modal,
|
||||
},
|
||||
props: {
|
||||
confirmationText: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
hasToType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "No title defined",
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: "No description defined",
|
||||
required: true,
|
||||
},
|
||||
proceedLabel: {
|
||||
type: String,
|
||||
default: "Proceed",
|
||||
},
|
||||
},
|
||||
emits: ["proceed"],
|
||||
data() {
|
||||
return {
|
||||
action_disabled: this.hasToType,
|
||||
confirmation_typed: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
renderString,
|
||||
cancel() {
|
||||
this.$refs.modal.hide();
|
||||
},
|
||||
proceed() {
|
||||
this.$refs.modal.hide();
|
||||
this.$emit("proceed");
|
||||
},
|
||||
type() {
|
||||
if (this.hasToType) {
|
||||
this.action_disabled =
|
||||
this.confirmation_typed.toLowerCase() !== this.confirmationText.toLowerCase();
|
||||
}
|
||||
},
|
||||
show() {
|
||||
this.$refs.modal.show();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-delete {
|
||||
padding: var(--spacing-card-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.markdown-body {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.confirmation-label {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.confirmation-text {
|
||||
padding-right: 0.25ch;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.confirmation-input {
|
||||
input {
|
||||
width: 20rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -256,7 +256,9 @@
|
||||
</p>
|
||||
<div class="options input-group">
|
||||
<button
|
||||
v-for="(option, index) in steps[currentStepIndex].options"
|
||||
v-for="(option, index) in steps[currentStepIndex].options.filter(
|
||||
(x) => x.shown !== false,
|
||||
)"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
@@ -426,6 +428,18 @@ const steps = computed(() =>
|
||||
resultingMessage: `## Misuse of Title
|
||||
Per section 5.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) we ask that you limit the title to just the name of your project. Additional information, such as themes, tags, supported versions or loaders, etc. should be saved for the Summary or Description. When changing your project title, remember to also ensure that your project slug (URL) matches and accurately represents your project.`,
|
||||
},
|
||||
{
|
||||
name: "Minecraft title",
|
||||
resultingMessage: `## Project Title
|
||||
Projects must not use Minecraft's branding or include "Minecraft" as a significant part of the title.
|
||||
The title of your project may be confusingly similar to the game, and we encourage you to change your title to avoid a potential violation of Minecraft's Usage Guidelines.
|
||||
Abbreviations like "MC" or elaborate titles that do not make the name Minecraft a significant portion of the name are okay.`,
|
||||
},
|
||||
{
|
||||
name: "Title similarities",
|
||||
resultingMessage: `## Project Branding
|
||||
Per section 1.8 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) we ask that you change your project title and other relevant branding to avoid causing confusion or implying association with existing projects.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -472,6 +486,12 @@ Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#m
|
||||
|
||||
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
|
||||
},
|
||||
{
|
||||
name: "Non-english",
|
||||
resultingMessage: `## No English Summary
|
||||
Per section 2.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#accessibility) a project's Summary and Description must be in English, unless meant exclusively for non-English use, such as translations.
|
||||
You may include your non-English Summary but we ask that you also add an English translation.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -628,11 +648,21 @@ For a brief rundown of how this works:
|
||||
{
|
||||
id: "gallery",
|
||||
navigate: `/${props.project.project_type}/${props.project.slug}/gallery`,
|
||||
question: `Are the project's gallery images relevant?`,
|
||||
shown: props.project.gallery.length > 0,
|
||||
question: `Are this project's gallery images sufficient?`,
|
||||
shown: true,
|
||||
options: [
|
||||
{
|
||||
name: "Insufficient",
|
||||
resultingMessage: `## Insufficient Gallery Images
|
||||
We ask that projects like yours show off their content using images in the Gallery, or optionally in the Description, in order to effectively and clearly inform users of its content per section 2.1 of [Modrinth's content rules](https://modrinth.com/legal/rules#general-expectations).
|
||||
Keep in mind that you should:
|
||||
- Set a featured image that best represents your project.
|
||||
- Ensure all your images have titles that accurately label the image, and optionally, details on the contents of the image in the images Description.
|
||||
- Upload any relevant images in your Description to your Gallery tab for best results.`,
|
||||
},
|
||||
{
|
||||
name: "Not relevant",
|
||||
shown: props.project.gallery.length > 0,
|
||||
resultingMessage: `## Unrelated Gallery Images
|
||||
Per section 5.5 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) any images in your project's Gallery must be relevant to the project and also include a Title.`,
|
||||
},
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
"
|
||||
class="date"
|
||||
>
|
||||
{{ fromNow(notif.extra_data.version.date_published) }}
|
||||
{{ formatRelativeTime(notif.extra_data.version.date_published) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -201,7 +201,7 @@
|
||||
v-tooltip="$dayjs(notification.created).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="inline-flex"
|
||||
>
|
||||
<CalendarIcon class="mr-1" /> Received {{ fromNow(notification.created) }}
|
||||
<CalendarIcon class="mr-1" /> Received {{ formatRelativeTime(notification.created) }}
|
||||
</span>
|
||||
</span>
|
||||
<div v-if="compact" class="notification__actions">
|
||||
@@ -331,11 +331,12 @@ import {
|
||||
XIcon,
|
||||
ExternalIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { useRelativeTime } from "@modrinth/ui";
|
||||
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
|
||||
import { getProjectLink, getVersionLink } from "~/helpers/projects.js";
|
||||
import { getUserLink } from "~/helpers/users.js";
|
||||
import { acceptTeamInvite, removeSelfFromTeam } from "~/helpers/teams.js";
|
||||
import { markAsRead } from "~/helpers/notifications.js";
|
||||
import { markAsRead } from "~/helpers/notifications.ts";
|
||||
import DoubleIcon from "~/components/ui/DoubleIcon.vue";
|
||||
import Avatar from "~/components/ui/Avatar.vue";
|
||||
import Badge from "~/components/ui/Badge.vue";
|
||||
@@ -345,6 +346,8 @@ import Categories from "~/components/ui/search/Categories.vue";
|
||||
const app = useNuxtApp();
|
||||
const emit = defineEmits(["update:notifications"]);
|
||||
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
|
||||
const props = defineProps({
|
||||
notification: {
|
||||
type: Object,
|
||||
|
||||
@@ -1,85 +1,140 @@
|
||||
<template>
|
||||
<div class="vue-notification-group">
|
||||
<div class="vue-notification-group experimental-styles-within">
|
||||
<transition-group name="notifs">
|
||||
<div
|
||||
v-for="(item, index) in notifications"
|
||||
:key="item.id"
|
||||
class="vue-notification-wrapper"
|
||||
@click="notifications.splice(index, 1)"
|
||||
@mouseenter="stopTimer(item)"
|
||||
@mouseleave="setNotificationTimer(item)"
|
||||
>
|
||||
<div class="vue-notification-template vue-notification" :class="{ [item.type]: true }">
|
||||
<div class="notification-title" v-html="item.title"></div>
|
||||
<div class="notification-content" v-html="item.text"></div>
|
||||
<div class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised shadow-xl">
|
||||
<div
|
||||
class="w-2"
|
||||
:class="{
|
||||
'bg-red': item.type === 'error',
|
||||
'bg-orange': item.type === 'warning',
|
||||
'bg-green': item.type === 'success',
|
||||
'bg-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
class="grid w-full grid-cols-[auto_1fr_auto] items-center gap-x-2 gap-y-1 py-2 pl-1 pr-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center"
|
||||
:class="{
|
||||
'text-red': item.type === 'error',
|
||||
'text-orange': item.type === 'warning',
|
||||
'text-green': item.type === 'success',
|
||||
'text-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
|
||||
}"
|
||||
>
|
||||
<IssuesIcon v-if="item.type === 'warning'" class="h-6 w-6" />
|
||||
<CheckCircleIcon v-else-if="item.type === 'success'" class="h-6 w-6" />
|
||||
<XCircleIcon v-else-if="item.type === 'error'" class="h-6 w-6" />
|
||||
<InfoIcon v-else class="h-6 w-6" />
|
||||
</div>
|
||||
<div class="m-0 text-wrap font-bold text-contrast" v-html="item.title"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div v-if="item.count && item.count > 1" class="text-xs font-bold text-contrast">
|
||||
x{{ item.count }}
|
||||
</div>
|
||||
<ButtonStyled circular size="small">
|
||||
<button v-tooltip="'Copy to clipboard'" @click="copyToClipboard(item)">
|
||||
<CheckIcon v-if="copied[createNotifText(item)]" />
|
||||
<CopyIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular size="small">
|
||||
<button v-tooltip="`Dismiss`" @click="notifications.splice(index, 1)">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div></div>
|
||||
<div class="col-span-2 text-sm text-primary" v-html="item.text"></div>
|
||||
<template v-if="item.errorCode">
|
||||
<div></div>
|
||||
<div
|
||||
class="m-0 text-wrap text-xs font-medium text-secondary"
|
||||
v-html="item.errorCode"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import {
|
||||
XCircleIcon,
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
InfoIcon,
|
||||
IssuesIcon,
|
||||
XIcon,
|
||||
CopyIcon,
|
||||
} from "@modrinth/assets";
|
||||
const notifications = useNotifications();
|
||||
|
||||
function stopTimer(notif) {
|
||||
clearTimeout(notif.timer);
|
||||
}
|
||||
|
||||
const copied = ref({});
|
||||
|
||||
const createNotifText = (notif) => {
|
||||
let text = "";
|
||||
if (notif.title) {
|
||||
text += notif.title;
|
||||
}
|
||||
if (notif.text) {
|
||||
if (text.length > 0) {
|
||||
text += "\n";
|
||||
}
|
||||
text += notif.text;
|
||||
}
|
||||
if (notif.errorCode) {
|
||||
if (text.length > 0) {
|
||||
text += "\n";
|
||||
}
|
||||
text += notif.errorCode;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
function copyToClipboard(notif) {
|
||||
const text = createNotifText(notif);
|
||||
|
||||
copied.value[text] = true;
|
||||
navigator.clipboard.writeText(text);
|
||||
setTimeout(() => {
|
||||
delete copied.value[text];
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.vue-notification {
|
||||
background: var(--color-blue) !important;
|
||||
border-left: 5px solid var(--color-blue) !important;
|
||||
color: var(--color-brand-inverted) !important;
|
||||
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
padding: 10px;
|
||||
margin: 0 5px 5px;
|
||||
|
||||
&.success {
|
||||
background: var(--color-green) !important;
|
||||
border-left-color: var(--color-green) !important;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
background: var(--color-orange) !important;
|
||||
border-left-color: var(--color-orange) !important;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: var(--color-red) !important;
|
||||
border-left-color: var(--color-red) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.vue-notification-group {
|
||||
position: fixed;
|
||||
right: 25px;
|
||||
bottom: 25px;
|
||||
z-index: 99999999;
|
||||
width: 300px;
|
||||
right: 1.5rem;
|
||||
bottom: 1.5rem;
|
||||
z-index: 200;
|
||||
width: 450px;
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
width: calc(100% - 0.75rem * 2);
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.vue-notification-wrapper {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.vue-notification-template {
|
||||
border-radius: var(--size-rounded-card);
|
||||
margin: 0;
|
||||
|
||||
.notification-title {
|
||||
font-size: var(--font-size-lg);
|
||||
margin-right: auto;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
margin-right: auto;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -98,10 +153,18 @@ function stopTimer(notif) {
|
||||
.notifs-enter-active,
|
||||
.notifs-leave-active,
|
||||
.notifs-move {
|
||||
transition: all 0.5s;
|
||||
transition: all 0.25s ease-in-out;
|
||||
}
|
||||
.notifs-enter-from,
|
||||
.notifs-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.notifs-enter-from {
|
||||
transform: translateY(100%) scale(0.8);
|
||||
}
|
||||
|
||||
.notifs-leave-to {
|
||||
transform: translateX(100%) scale(0.8);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
class="stat date"
|
||||
>
|
||||
<UpdatedIcon aria-hidden="true" />
|
||||
<span class="date-label">Updated </span>{{ fromNow(updatedAt) }}
|
||||
<span class="date-label">Updated </span>{{ formatRelativeTime(updatedAt) }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showCreatedDate"
|
||||
@@ -83,7 +83,7 @@
|
||||
class="stat date"
|
||||
>
|
||||
<CalendarIcon aria-hidden="true" />
|
||||
<span class="date-label">Published </span>{{ fromNow(createdAt) }}
|
||||
<span class="date-label">Published </span>{{ formatRelativeTime(createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -95,6 +95,7 @@ import Categories from "~/components/ui/search/Categories.vue";
|
||||
import Badge from "~/components/ui/Badge.vue";
|
||||
import EnvironmentIndicator from "~/components/ui/EnvironmentIndicator.vue";
|
||||
import Avatar from "~/components/ui/Avatar.vue";
|
||||
import { useRelativeTime } from "@modrinth/ui";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -213,8 +214,9 @@ export default {
|
||||
},
|
||||
setup() {
|
||||
const tags = useTags();
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
|
||||
return { tags };
|
||||
return { tags, formatRelativeTime };
|
||||
},
|
||||
computed: {
|
||||
projectTypeDisplay() {
|
||||
|
||||
@@ -86,8 +86,8 @@
|
||||
<UpdatedIcon />
|
||||
</Button>
|
||||
<DropdownSelect
|
||||
class="range-dropdown"
|
||||
v-model="selectedRange"
|
||||
class="range-dropdown"
|
||||
:options="ranges"
|
||||
name="Time range"
|
||||
:display-name="
|
||||
@@ -197,11 +197,11 @@
|
||||
>
|
||||
<div class="country-flag-container">
|
||||
<template v-if="name.toLowerCase() === 'xx' || !name">
|
||||
<img
|
||||
src="https://cdn.modrinth.com/placeholder-banner.svg"
|
||||
alt="Placeholder flag"
|
||||
class="country-flag"
|
||||
/>
|
||||
<div
|
||||
class="country-flag flex select-none items-center justify-center bg-bg-raised font-extrabold text-secondary"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<img
|
||||
@@ -213,7 +213,7 @@
|
||||
</div>
|
||||
<div class="country-text">
|
||||
<strong class="country-name"
|
||||
><template v-if="name.toLowerCase() === 'xx' || !name">Hidden</template>
|
||||
><template v-if="name.toLowerCase() === 'xx' || !name">Other</template>
|
||||
<template v-else>{{ countryCodeToName(name) }}</template>
|
||||
</strong>
|
||||
<span class="data-point">{{ formatNumber(count) }}</span>
|
||||
@@ -256,11 +256,11 @@
|
||||
>
|
||||
<div class="country-flag-container">
|
||||
<template v-if="name.toLowerCase() === 'xx' || !name">
|
||||
<img
|
||||
src="https://cdn.modrinth.com/placeholder-banner.svg"
|
||||
alt="Placeholder flag"
|
||||
class="country-flag"
|
||||
/>
|
||||
<div
|
||||
class="country-flag flex select-none items-center justify-center bg-bg-raised font-extrabold text-secondary"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<img
|
||||
@@ -272,7 +272,7 @@
|
||||
</div>
|
||||
<div class="country-text">
|
||||
<strong class="country-name">
|
||||
<template v-if="name.toLowerCase() === 'xx' || !name">Hidden</template>
|
||||
<template v-if="name.toLowerCase() === 'xx' || !name">Other</template>
|
||||
<template v-else>{{ countryCodeToName(name) }}</template>
|
||||
</strong>
|
||||
<span class="data-point">{{ formatNumber(count) }}</span>
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
</nuxt-link>
|
||||
<span> </span>
|
||||
<span v-tooltip="$dayjs(report.created).format('MMMM D, YYYY [at] h:mm A')">{{
|
||||
fromNow(report.created)
|
||||
formatRelativeTime(report.created)
|
||||
}}</span>
|
||||
<CopyCode v-if="flags.developerMode" :text="report.id" class="report-id" />
|
||||
</div>
|
||||
@@ -105,11 +105,14 @@
|
||||
<script setup>
|
||||
import { ReportIcon, UnknownIcon, VersionIcon } from "@modrinth/assets";
|
||||
import { renderHighlightedString } from "~/helpers/highlight.js";
|
||||
import { useRelativeTime } from "@modrinth/ui";
|
||||
import Avatar from "~/components/ui/Avatar.vue";
|
||||
import Badge from "~/components/ui/Badge.vue";
|
||||
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
|
||||
import CopyCode from "~/components/ui/CopyCode.vue";
|
||||
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
|
||||
defineProps({
|
||||
report: {
|
||||
type: Object,
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<ButtonStyled circular type="transparent">
|
||||
<UiServersTeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
|
||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||
<template #extract><PackageOpenIcon /> Extract</template>
|
||||
<template #rename><EditIcon /> Rename</template>
|
||||
<template #move><RightArrowIcon /> Move</template>
|
||||
<template #download><DownloadIcon /> Download</template>
|
||||
@@ -73,6 +74,8 @@ import {
|
||||
FolderOpenIcon,
|
||||
FileIcon,
|
||||
RightArrowIcon,
|
||||
PackageOpenIcon,
|
||||
FileArchiveIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { computed, shallowRef, ref } from "vue";
|
||||
import { renderToString } from "vue/server-renderer";
|
||||
@@ -99,15 +102,14 @@ interface FileItemProps {
|
||||
const props = defineProps<FileItemProps>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "rename", item: { name: string; type: string; path: string }): void;
|
||||
(e: "move", item: { name: string; type: string; path: string }): void;
|
||||
(
|
||||
e: "rename" | "move" | "download" | "delete" | "edit" | "extract",
|
||||
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;
|
||||
}>();
|
||||
|
||||
@@ -143,6 +145,7 @@ const codeExtensions = Object.freeze([
|
||||
|
||||
const textExtensions = Object.freeze(["txt", "md", "log", "cfg", "conf", "properties", "ini"]);
|
||||
const imageExtensions = Object.freeze(["png", "jpg", "jpeg", "gif", "svg", "webp"]);
|
||||
const supportedArchiveExtensions = Object.freeze(["zip"]);
|
||||
const units = Object.freeze(["B", "KB", "MB", "GB", "TB", "PB", "EB"]);
|
||||
|
||||
const route = shallowRef(useRoute());
|
||||
@@ -156,7 +159,18 @@ const containerClasses = computed(() => [
|
||||
|
||||
const fileExtension = computed(() => props.name.split(".").pop()?.toLowerCase() || "");
|
||||
|
||||
const isZip = computed(() => fileExtension.value === "zip");
|
||||
|
||||
const menuOptions = computed(() => [
|
||||
{
|
||||
id: "extract",
|
||||
shown: isZip.value,
|
||||
action: () => emit("extract", { name: props.name, type: props.type, path: props.path }),
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown: isZip.value,
|
||||
},
|
||||
{
|
||||
id: "rename",
|
||||
action: () => emit("rename", { name: props.name, type: props.type, path: props.path }),
|
||||
@@ -189,6 +203,7 @@ const iconComponent = computed(() => {
|
||||
if (codeExtensions.includes(ext)) return UiServersIconsCodeFileIcon;
|
||||
if (textExtensions.includes(ext)) return UiServersIconsTextFileIcon;
|
||||
if (imageExtensions.includes(ext)) return UiServersIconsImageFileIcon;
|
||||
if (supportedArchiveExtensions.includes(ext)) return FileArchiveIcon;
|
||||
return FileIcon;
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
:size="item.size"
|
||||
@delete="$emit('delete', item)"
|
||||
@rename="$emit('rename', item)"
|
||||
@extract="$emit('extract', item)"
|
||||
@download="$emit('download', item)"
|
||||
@move="$emit('move', item)"
|
||||
@move-direct-to="$emit('moveDirectTo', $event)"
|
||||
@@ -49,14 +50,12 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete", item: any): void;
|
||||
(e: "rename", item: any): void;
|
||||
(e: "download", item: any): void;
|
||||
(e: "move", item: any): void;
|
||||
(e: "edit", item: any): void;
|
||||
(
|
||||
e: "delete" | "rename" | "download" | "move" | "edit" | "moveDirectTo" | "extract",
|
||||
item: any,
|
||||
): void;
|
||||
(e: "contextmenu", item: any, x: number, y: number): void;
|
||||
(e: "loadMore"): void;
|
||||
(e: "moveDirectTo", item: any): void;
|
||||
}>();
|
||||
|
||||
const ITEM_HEIGHT = 61;
|
||||
|
||||
@@ -117,7 +117,8 @@
|
||||
</div>
|
||||
|
||||
<ButtonStyled type="transparent">
|
||||
<UiServersTeleportOverflowMenu
|
||||
<OverflowMenu
|
||||
:dropdown-id="`create-new-${baseId}`"
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Create new..."
|
||||
@@ -125,6 +126,10 @@
|
||||
{ id: 'file', action: () => $emit('create', 'file') },
|
||||
{ id: 'directory', action: () => $emit('create', 'directory') },
|
||||
{ id: 'upload', action: () => $emit('upload') },
|
||||
{ divider: true },
|
||||
{ id: 'upload-zip', shown: false, action: () => $emit('upload-zip') },
|
||||
{ id: 'install-from-url', action: () => $emit('unzip-from-url', false) },
|
||||
{ id: 'install-cf-pack', action: () => $emit('unzip-from-url', true) },
|
||||
]"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
@@ -132,7 +137,16 @@
|
||||
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
|
||||
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
|
||||
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
<template #upload-zip>
|
||||
<FileArchiveIcon aria-hidden="true" /> Upload from .zip file
|
||||
</template>
|
||||
<template #install-from-url>
|
||||
<LinkIcon aria-hidden="true" /> Upload from .zip URL
|
||||
</template>
|
||||
<template #install-cf-pack>
|
||||
<CurseForgeIcon aria-hidden="true" /> Install CurseForge pack
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</header>
|
||||
@@ -140,6 +154,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
LinkIcon,
|
||||
CurseForgeIcon,
|
||||
FileArchiveIcon,
|
||||
BoxIcon,
|
||||
PlusIcon,
|
||||
UploadIcon,
|
||||
@@ -150,7 +167,7 @@ import {
|
||||
ChevronRightIcon,
|
||||
FilterIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ButtonStyled, OverflowMenu } from "@modrinth/ui";
|
||||
import { ref, computed } from "vue";
|
||||
import { useIntersectionObserver } from "@vueuse/core";
|
||||
|
||||
@@ -158,12 +175,14 @@ const props = defineProps<{
|
||||
breadcrumbSegments: string[];
|
||||
searchQuery: string;
|
||||
currentFilter: string;
|
||||
baseId: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "navigate", index: number): void;
|
||||
(e: "create", type: "file" | "directory"): void;
|
||||
(e: "upload"): void;
|
||||
(e: "upload" | "upload-zip"): void;
|
||||
(e: "unzip-from-url", cf: boolean): void;
|
||||
(e: "update:searchQuery", value: string): void;
|
||||
(e: "filter", type: string): void;
|
||||
}>();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<ConfirmModal
|
||||
ref="modal"
|
||||
title="Do you want to overwrite these conflicting files?"
|
||||
:proceed-label="`Overwrite`"
|
||||
:proceed-icon="CheckIcon"
|
||||
@proceed="proceed"
|
||||
>
|
||||
<div class="flex max-w-[30rem] flex-col gap-4">
|
||||
<p class="m-0 font-semibold leading-normal">
|
||||
<template v-if="hasMany">
|
||||
Over 100 files will be overwritten if you proceed with extraction; here is just some of
|
||||
them:
|
||||
</template>
|
||||
<template v-else>
|
||||
The following {{ files.length }} files already exist on your server, and will be
|
||||
overwritten if you proceed with extraction:
|
||||
</template>
|
||||
</p>
|
||||
<ul class="m-0 max-h-80 list-none overflow-auto rounded-2xl bg-bg px-4 py-3">
|
||||
<li v-for="file in files" :key="file" class="flex items-center gap-1 py-1 font-medium">
|
||||
<XIcon class="shrink-0 text-red" /> {{ file }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ConfirmModal } from "@modrinth/ui";
|
||||
import { ref } from "vue";
|
||||
import { XIcon, CheckIcon } from "@modrinth/assets";
|
||||
|
||||
const path = ref("");
|
||||
const files = ref<string[]>([]);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "proceed", path: string): void;
|
||||
}>();
|
||||
|
||||
const modal = ref<typeof ConfirmModal>();
|
||||
|
||||
const hasMany = computed(() => files.value.length > 100);
|
||||
|
||||
const show = (zipPath: string, conflictingFiles: string[]) => {
|
||||
path.value = zipPath;
|
||||
files.value = conflictingFiles;
|
||||
modal.value?.show();
|
||||
};
|
||||
|
||||
const proceed = () => {
|
||||
emit("proceed", path.value);
|
||||
};
|
||||
|
||||
defineExpose({ show });
|
||||
</script>
|
||||
@@ -1,101 +1,105 @@
|
||||
<template>
|
||||
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
|
||||
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
|
||||
<div
|
||||
ref="statusContentRef"
|
||||
:class="['flex flex-col p-4 text-sm text-contrast', $attrs.class]"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 font-bold">
|
||||
<FolderOpenIcon class="size-4" />
|
||||
<span>
|
||||
<span class="capitalize">
|
||||
{{ props.fileType ? props.fileType : "File" }} Uploads
|
||||
<div>
|
||||
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
|
||||
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
|
||||
<div
|
||||
ref="statusContentRef"
|
||||
v-bind="$attrs"
|
||||
:class="['flex flex-col p-4 text-sm text-contrast']"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 font-bold">
|
||||
<FolderOpenIcon class="size-4" />
|
||||
<span>
|
||||
<span class="capitalize">
|
||||
{{ props.fileType ? props.fileType : "File" }} uploads
|
||||
</span>
|
||||
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
|
||||
</span>
|
||||
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
|
||||
</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' ||
|
||||
item.status === 'incorrect-type'
|
||||
"
|
||||
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-if="item.status === 'incorrect-type'">
|
||||
<span class="text-red">Failed - Incorrect file type</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>
|
||||
</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' ||
|
||||
item.status === 'incorrect-type'
|
||||
"
|
||||
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 === 'cancelled'">
|
||||
<span class="text-red">Cancelled</span>
|
||||
<template v-else-if="item.status === 'error'">
|
||||
<span class="text-red">Failed - File already exists</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'incorrect-type'">
|
||||
<span class="text-red">Failed - Incorrect file type</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 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>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
import type { FSModule } from "~/composables/pyroServers.ts";
|
||||
|
||||
interface UploadItem {
|
||||
file: File;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="cf ? `Installing a CurseForge pack` : `Uploading .zip contents from URL`"
|
||||
>
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-bold text-contrast">
|
||||
{{ cf ? `How to get the modpack version's URL` : "URL of .zip file" }}
|
||||
</div>
|
||||
<ol v-if="cf" class="mb-1 mt-0 flex flex-col gap-1 pl-8 leading-normal text-secondary">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.curseforge.com/minecraft/search?page=1&pageSize=40&sortBy=relevancy&class=modpacks"
|
||||
class="inline-flex font-semibold text-[#F16436] transition-all hover:underline active:brightness-[--hover-brightness]"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Find the CurseForge modpack
|
||||
<ExternalIcon class="ml-1 inline size-4" stroke-width="3" />
|
||||
</a>
|
||||
you'd like to install on your server.
|
||||
</li>
|
||||
<li>
|
||||
On the modpack's page, go to the
|
||||
<span class="font-semibold text-primary">"Files"</span> tab, and
|
||||
<span class="font-semibold text-primary">select the version</span> of the modpack you
|
||||
want to install.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-semibold text-primary">Copy the URL</span> of the version you want to
|
||||
install, and paste it in the box below.
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="mb-1 mt-0">Copy and paste the direct download URL of a .zip file.</p>
|
||||
<input
|
||||
ref="urlInput"
|
||||
v-model="url"
|
||||
autofocus
|
||||
:disabled="submitted"
|
||||
type="text"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-protonpass-ignore="true"
|
||||
required
|
||||
:placeholder="
|
||||
cf
|
||||
? 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
|
||||
: 'https://www.example.com/.../modpack-name-1.0.2.zip'
|
||||
"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
||||
</div>
|
||||
<BackupWarning :backup-link="`/servers/manage/${props.server.serverId}/backups`" />
|
||||
<div class="flex justify-start gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button v-tooltip="error" :disabled="submitted || !!error" type="submit">
|
||||
<SpinnerIcon v-if="submitted" class="animate-spin" />
|
||||
<DownloadIcon v-else class="h-5 w-5" />
|
||||
{{ submitted ? "Installing..." : "Install" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ submitted ? "Close" : "Cancel" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon, SpinnerIcon, DownloadIcon, XIcon } from "@modrinth/assets";
|
||||
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
import { handleError, type Server } from "~/composables/pyroServers.ts";
|
||||
|
||||
const cf = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const modal = ref<typeof NewModal>();
|
||||
const urlInput = ref<HTMLInputElement | null>(null);
|
||||
const url = ref("");
|
||||
const submitted = ref(false);
|
||||
|
||||
const trimmedUrl = computed(() => url.value.trim());
|
||||
|
||||
const regex = /https:\/\/(www\.)?curseforge\.com\/minecraft\/modpacks\/[^/]+\/files\/\d+/;
|
||||
|
||||
const error = computed(() => {
|
||||
if (trimmedUrl.value.length === 0) {
|
||||
return "URL is required.";
|
||||
}
|
||||
if (cf.value && !regex.test(trimmedUrl.value)) {
|
||||
return "URL must be a CurseForge modpack version URL.";
|
||||
} else if (!cf.value && !trimmedUrl.value.includes("/")) {
|
||||
return "URL must be valid.";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
submitted.value = true;
|
||||
if (!error.value) {
|
||||
// hide();
|
||||
try {
|
||||
const dry = await props.server.fs?.extractFile(trimmedUrl.value, true, true);
|
||||
|
||||
if (!cf.value || dry.modpack_name) {
|
||||
await props.server.fs?.extractFile(trimmedUrl.value, true, false, true);
|
||||
hide();
|
||||
} else {
|
||||
submitted.value = false;
|
||||
handleError(
|
||||
new ServersError(
|
||||
"Could not find CurseForge modpack at that URL.",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
context: "Error installing modpack",
|
||||
error: `url: ${url.value}`,
|
||||
description: "Could not find CurseForge modpack at that URL.",
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
submitted.value = false;
|
||||
console.error("Error installing:", error);
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const show = (isCf: boolean) => {
|
||||
cf.value = isCf;
|
||||
url.value = "";
|
||||
submitted.value = false;
|
||||
modal.value?.show();
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
urlInput.value?.focus();
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
defineExpose({ show, hide });
|
||||
</script>
|
||||
@@ -1,660 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-full overflow-hidden">
|
||||
<div class="mb-4">
|
||||
<div
|
||||
v-for="(line, lineIndex) in motd"
|
||||
:key="lineIndex"
|
||||
class="relative mb-2 rounded bg-button-bg p-2"
|
||||
>
|
||||
<div
|
||||
class="font-minecraft text-white"
|
||||
:contenteditable="true"
|
||||
spellcheck="false"
|
||||
@input="handleInput($event, lineIndex)"
|
||||
@keydown.enter.prevent
|
||||
@paste.prevent="handlePaste($event, lineIndex)"
|
||||
@mouseup="handleSelection(lineIndex)"
|
||||
v-html="renderLine(line)"
|
||||
></div>
|
||||
<div class="text-sm text-gray-400">
|
||||
{{ motd[lineIndex].reduce((sum, segment) => sum + segment.text.length, 0) }}/45
|
||||
characters
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="showPopup"
|
||||
:style="{ top: `${popupY}px`, left: `${popupX}px` }"
|
||||
class="fixed z-10 flex flex-col items-end gap-2 transition-all duration-300 ease-in-out"
|
||||
>
|
||||
<div class="rounded-xl border bg-table-alternateRow p-2 shadow-lg">
|
||||
<div class="flex space-x-2">
|
||||
<Button
|
||||
v-for="style in styles"
|
||||
:key="style.name"
|
||||
icon-only
|
||||
transparent
|
||||
@click="applyStyle({ [style.name]: !currentStyle[style.name] })"
|
||||
>
|
||||
<component :is="style.icon" class="h-4 w-4" />
|
||||
</Button>
|
||||
<div class="relative overflow-y-scroll">
|
||||
<Button icon-only transparent :class="colorPicker ?? 'hidden'" @click="pickColor">
|
||||
<PaintBrushIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="colorPicker"
|
||||
icon-only
|
||||
class="w-fit overflow-y-auto rounded-xl p-2 [&&]:bg-table-alternateRow"
|
||||
>
|
||||
<div :class="colorPicker ? `grid grid-flow-col grid-rows-4 gap-2` : '[&&]:hidden'">
|
||||
<button
|
||||
v-for="format in sortedFormatCodes()"
|
||||
:key="format.code"
|
||||
class="rounded-full p-3"
|
||||
:style="{ backgroundColor: format.color }"
|
||||
:title="format.description"
|
||||
@click="applyStyle({ color: format.color })"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ItalicIcon,
|
||||
BoldIcon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
PaintBrushIcon,
|
||||
ChevronLeftIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { Button } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps({
|
||||
server: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const formatCodes = [
|
||||
{ code: "§f", color: "white", description: "White" },
|
||||
{ code: "§7", color: "#AAAAAA", description: "Gray" },
|
||||
{ code: "§8", color: "#555555", description: "Dark Gray" },
|
||||
{ code: "§0", color: "#000000", description: "Black" },
|
||||
{ code: "§9", color: "#5555FF", description: "Blue" },
|
||||
{ code: "§1", color: "#0000AA", description: "Dark Blue" },
|
||||
{ code: "§b", color: "#55FFFF", description: "Aqua" },
|
||||
{ code: "§3", color: "#00AAAA", description: "Dark Aqua" },
|
||||
{ code: "§a", color: "#55FF55", description: "Green" },
|
||||
{ code: "§2", color: "#00AA00", description: "Dark Green" },
|
||||
{ code: "§e", color: "#FFFF55", description: "Yellow" },
|
||||
{ code: "§6", color: "#FFAA00", description: "Gold" },
|
||||
{ code: "§c", color: "#FF5555", description: "Red" },
|
||||
{ code: "§4", color: "#AA0000", description: "Dark Red" },
|
||||
{ code: "§d", color: "#FF55FF", description: "Light Purple" },
|
||||
{ code: "§5", color: "#AA00AA", description: "Dark Purple" },
|
||||
];
|
||||
|
||||
const sortedFormatCodes = () => {
|
||||
const colors = formatCodes;
|
||||
if (colors[0].description === "White") {
|
||||
colors.reverse();
|
||||
}
|
||||
return colors;
|
||||
};
|
||||
|
||||
const minecraftEmojis = [
|
||||
{ char: "☺", name: "SMILING FACE" },
|
||||
{ char: "☹", name: "FROWNING FACE" },
|
||||
{ char: "☠", name: "SKULL AND CROSSBONES" },
|
||||
{ char: "❣", name: "HEART EXCLAMATION" },
|
||||
{ char: "❤", name: "RED HEART" },
|
||||
{ char: "✌", name: "VICTORY HAND" },
|
||||
{ char: "☝", name: "INDEX POINTING UP" },
|
||||
{ char: "✍", name: "WRITING HAND" },
|
||||
{ char: "♨", name: "HOT SPRINGS" },
|
||||
{ char: "✈", name: "AIRPLANE" },
|
||||
{ char: "⌛", name: "HOURGLASS DONE" },
|
||||
{ char: "⌚", name: "WATCH" },
|
||||
{ char: "☀", name: "SUN" },
|
||||
{ char: "☁", name: "CLOUD" },
|
||||
{ char: "☂", name: "UMBRELLA" },
|
||||
{ char: "❄", name: "SNOWFLAKE" },
|
||||
{ char: "☃", name: "SNOWMAN" },
|
||||
{ char: "☄", name: "COMET" },
|
||||
{ char: "♠", name: "SPADE SUIT" },
|
||||
{ char: "♥", name: "HEART SUIT" },
|
||||
{ char: "♦", name: "DIAMOND SUIT" },
|
||||
{ char: "♣", name: "CLUB SUIT" },
|
||||
{ char: "♟", name: "CHESS PAWN" },
|
||||
{ char: "☎", name: "TELEPHONE" },
|
||||
{ char: "⌨", name: "KEYBOARD" },
|
||||
{ char: "✉", name: "ENVELOPE" },
|
||||
{ char: "✏", name: "PENCIL" },
|
||||
{ char: "✒", name: "BLACK PEN" },
|
||||
{ char: "✂", name: "SCISSORS" },
|
||||
{ char: "☢", name: "RADIOACTIVE" },
|
||||
{ char: "☣", name: "BIOHAZARD" },
|
||||
{ char: "⬆", name: "UP ARROW" },
|
||||
{ char: "⬇", name: "DOWN ARROW" },
|
||||
{ char: "➡", name: "RIGHT ARROW" },
|
||||
{ char: "⬅", name: "LEFT ARROW" },
|
||||
{ char: "↗", name: "UP-RIGHT ARROW" },
|
||||
{ char: "↘", name: "DOWN-RIGHT ARROW" },
|
||||
{ char: "↙", name: "DOWN-LEFT ARROW" },
|
||||
{ char: "↖", name: "UP-LEFT ARROW" },
|
||||
{ char: "↕", name: "UP-DOWN ARROW" },
|
||||
{ char: "↔", name: "LEFT-RIGHT ARROW" },
|
||||
{ char: "↩", name: "RIGHT ARROW CURVING LEFT" },
|
||||
{ char: "↪", name: "LEFT ARROW CURVING RIGHT" },
|
||||
{ char: "✡", name: "STAR OF DAVID" },
|
||||
{ char: "☸", name: "WHEEL OF DHARMA" },
|
||||
{ char: "☯", name: "YIN YANG" },
|
||||
{ char: "✝", name: "LATIN CROSS" },
|
||||
{ char: "☦", name: "ORTHODOX CROSS" },
|
||||
{ char: "☪", name: "STAR AND CRESCENT" },
|
||||
{ char: "☮", name: "PEACE SYMBOL" },
|
||||
{ char: "♈", name: "ARIES" },
|
||||
{ char: "♉", name: "TAURUS" },
|
||||
{ char: "♊", name: "GEMINI" },
|
||||
{ char: "♋", name: "CANCER" },
|
||||
{ char: "♌", name: "LEO" },
|
||||
{ char: "♍", name: "VIRGO" },
|
||||
{ char: "♎", name: "LIBRA" },
|
||||
{ char: "♏", name: "SCORPIO" },
|
||||
{ char: "♐", name: "SAGITTARIUS" },
|
||||
{ char: "♑", name: "CAPRICORN" },
|
||||
{ char: "♒", name: "AQUARIUS" },
|
||||
{ char: "♓", name: "PISCES" },
|
||||
{ char: "▶", name: "PLAY BUTTON" },
|
||||
{ char: "◀", name: "REVERSE BUTTON" },
|
||||
{ char: "♀", name: "FEMALE SIGN" },
|
||||
{ char: "♂", name: "MALE SIGN" },
|
||||
{ char: "✖", name: "MULTIPLY" },
|
||||
{ char: "‼", name: "DOUBLE EXCLAMATION MARK" },
|
||||
{ char: "〰", name: "WAVY DASH" },
|
||||
{ char: "☑", name: "CHECK BOX WITH CHECK" },
|
||||
{ char: "✔", name: "CHECK MARK" },
|
||||
{ char: "✳", name: "EIGHT-SPOKED ASTERISK" },
|
||||
{ char: "✴", name: "EIGHT-POINTED STAR" },
|
||||
{ char: "❇", name: "SPARKLE" },
|
||||
{ char: "©", name: "COPYRIGHT" },
|
||||
{ char: "®", name: "REGISTERED" },
|
||||
{ char: "™", name: "TRADE MARK" },
|
||||
{ char: "Ⓜ", name: "CIRCLED M" },
|
||||
{ char: "㊗", name: 'JAPANESE "CONGRATULATIONS" BUTTON' },
|
||||
{ char: "㊙", name: 'JAPANESE "SECRET" BUTTON' },
|
||||
{ char: "▪", name: "BLACK SMALL SQUARE" },
|
||||
{ char: "▫", name: "WHITE SMALL SQUARE" },
|
||||
{ char: "☷", name: "TRIGRAM FOR EARTH" },
|
||||
{ char: "☵", name: "TRIGRAM FOR WATER" },
|
||||
{ char: "☶", name: "TRIGRAM FOR MOUNTAIN" },
|
||||
{ char: "☋", name: "DESCENDING NODE" },
|
||||
{ char: "☌", name: "CONJUNCTION" },
|
||||
{ char: "♜", name: "BLACK CHESS ROOK" },
|
||||
{ char: "♕", name: "WHITE CHESS QUEEN" },
|
||||
{ char: "♡", name: "WHITE HEART SUIT" },
|
||||
{ char: "♬", name: "BEAMED SIXTEENTH NOTES" },
|
||||
{ char: "☚", name: "BLACK LEFT POINTING INDEX" },
|
||||
{ char: "♮", name: "MUSIC NATURAL SIGN" },
|
||||
{ char: "♝", name: "BLACK CHESS BISHOP" },
|
||||
{ char: "♯", name: "SHARP" },
|
||||
{ char: "☴", name: "TRIGRAM FOR WIND" },
|
||||
{ char: "♭", name: "FLAT" },
|
||||
{ char: "☓", name: "SALTIRE" },
|
||||
{ char: "☛", name: "BLACK RIGHT POINTING INDEX" },
|
||||
{ char: "☭", name: "HAMMER AND SICKLE" },
|
||||
{ char: "♢", name: "WHITE DIAMOND SUIT" },
|
||||
{ char: "✐", name: "UPPER RIGHT PENCIL" },
|
||||
{ char: "♖", name: "WHITE CHESS ROOK" },
|
||||
{ char: "☈", name: "THUNDERSTORM" },
|
||||
{ char: "☒", name: "BALLOT BOX WITH X" },
|
||||
{ char: "★", name: "BLACK STAR" },
|
||||
{ char: "♚", name: "BLACK CHESS KING" },
|
||||
{ char: "♛", name: "BLACK CHESS QUEEN" },
|
||||
{ char: "✎", name: "LOWER RIGHT PENCIL" },
|
||||
{ char: "♪", name: "EIGHTH NOTE" },
|
||||
{ char: "☰", name: "TRIGRAM FOR HEAVEN" },
|
||||
{ char: "☽", name: "FIRST QUARTER MOON" },
|
||||
{ char: "☡", name: "CAUTION SIGN" },
|
||||
{ char: "☼", name: "WHITE SUN WITH RAYS" },
|
||||
{ char: "♅", name: "URANUS" },
|
||||
{ char: "☐", name: "BALLOT BOX" },
|
||||
{ char: "☟", name: "WHITE DOWN POINTING INDEX" },
|
||||
{ char: "❦", name: "FLORAL HEART" },
|
||||
{ char: "☊", name: "ASCENDING NODE" },
|
||||
{ char: "☍", name: "OPPOSITION" },
|
||||
{ char: "☬", name: "ADI SHAKTI" },
|
||||
{ char: "♧", name: "WHITE CLUB SUIT" },
|
||||
{ char: "☫", name: "FARSI SYMBOL" },
|
||||
{ char: "☱", name: "TRIGRAM FOR LAKE" },
|
||||
{ char: "☾", name: "LAST QUARTER MOON" },
|
||||
{ char: "☤", name: "CADUCEUS" },
|
||||
{ char: "❧", name: "ROTATED FLORAL HEART BULLET" },
|
||||
{ char: "♄", name: "SATURN" },
|
||||
{ char: "♁", name: "EARTH" },
|
||||
{ char: "♔", name: "WHITE CHESS KING" },
|
||||
{ char: "❥", name: "ROTATED HEAVY BLACK HEART BULLET" },
|
||||
{ char: "☥", name: "ANKH" },
|
||||
{ char: "☻", name: "BLACK SMILING FACE" },
|
||||
{ char: "♤", name: "WHITE SPADE SUIT" },
|
||||
{ char: "♞", name: "BLACK CHESS KNIGHT" },
|
||||
{ char: "♆", name: "NEPTUNE" },
|
||||
{ char: "#", name: "HASH SIGN" },
|
||||
{ char: "♃", name: "JUPITER" },
|
||||
{ char: "♩", name: "QUARTER NOTE" },
|
||||
{ char: "☇", name: "LIGHTNING" },
|
||||
{ char: "☞", name: "WHITE RIGHT POINTING INDEX" },
|
||||
{ char: "♫", name: "BEAMED EIGHTH NOTES" },
|
||||
{ char: "☏", name: "WHITE TELEPHONE" },
|
||||
{ char: "♘", name: "WHITE CHESS KNIGHT" },
|
||||
{ char: "☧", name: "CHI RHO" },
|
||||
{ char: "☉", name: "SUN" },
|
||||
{ char: "♇", name: "PLUTO" },
|
||||
{ char: "☩", name: "CROSS OF JERUSALEM" },
|
||||
{ char: "♙", name: "WHITE CHESS PAWN" },
|
||||
{ char: "☜", name: "WHITE LEFT POINTING INDEX" },
|
||||
{ char: "☲", name: "TRIGRAM FOR FIRE" },
|
||||
{ char: "☨", name: "CROSS OF LORRAINE" },
|
||||
{ char: "♗", name: "WHITE CHESS BISHOP" },
|
||||
{ char: "☳", name: "TRIGRAM FOR THUNDER" },
|
||||
{ char: "⚔", name: "CROSSED SWORDS" },
|
||||
{ char: "⚀", name: "DICE ONE" },
|
||||
];
|
||||
|
||||
const rawMotd = ref(props.server.general?.motd ?? "");
|
||||
|
||||
const motd = computed(() => {
|
||||
const lines = rawMotd.value.split("\n");
|
||||
return lines.map((line) => {
|
||||
const segments = [];
|
||||
let currentSegment = { text: "", color: "White" };
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
if (line[i] === "§") {
|
||||
if (currentSegment.text) {
|
||||
segments.push({ ...currentSegment });
|
||||
currentSegment = { text: "", color: "White" };
|
||||
}
|
||||
const formatCode = line.substr(i, 2);
|
||||
const format = formatCodes.find((f) => f.code === formatCode);
|
||||
console.log(format);
|
||||
console.log(formatCode);
|
||||
if (format) {
|
||||
currentSegment.color = format.color;
|
||||
i += 2;
|
||||
continue;
|
||||
} else if (formatCode === "§l") {
|
||||
currentSegment.bold = true;
|
||||
i += 2;
|
||||
continue;
|
||||
} else if (formatCode === "§o") {
|
||||
currentSegment.italic = true;
|
||||
i += 2;
|
||||
continue;
|
||||
} else if (formatCode === "§n") {
|
||||
currentSegment.underline = true;
|
||||
i += 2;
|
||||
continue;
|
||||
} else if (formatCode === "§m") {
|
||||
currentSegment.strikethrough = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
currentSegment.text += line[i];
|
||||
i++;
|
||||
}
|
||||
if (currentSegment.text) {
|
||||
segments.push(currentSegment);
|
||||
}
|
||||
return segments;
|
||||
});
|
||||
});
|
||||
|
||||
const styles = [
|
||||
{
|
||||
name: "bold",
|
||||
icon: BoldIcon,
|
||||
},
|
||||
{
|
||||
name: "italic",
|
||||
icon: ItalicIcon,
|
||||
},
|
||||
{
|
||||
name: "underline",
|
||||
icon: UnderlineIcon,
|
||||
},
|
||||
{
|
||||
name: "strikethrough",
|
||||
icon: StrikethroughIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const showPopup = ref(false);
|
||||
const popupX = ref(0);
|
||||
const popupY = ref(0);
|
||||
const currentLineIndex = ref(0);
|
||||
const selectionStart = ref(0);
|
||||
const selectionEnd = ref(0);
|
||||
const colorPicker = ref(false);
|
||||
|
||||
const pickColor = () => {
|
||||
colorPicker.value = !colorPicker.value;
|
||||
};
|
||||
|
||||
const totalCharacters = computed(() => {
|
||||
return motd.value.reduce((sum, line) => {
|
||||
return Math.max(
|
||||
sum,
|
||||
line.reduce((lineSum, segment) => lineSum + segment.text.length, 0),
|
||||
);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const minecraftFormat = computed(() => {
|
||||
return motd.value
|
||||
.map((line) => {
|
||||
return line
|
||||
.map((segment) => {
|
||||
let format = getColorCode(segment.color);
|
||||
if (segment.bold) format += "§l";
|
||||
if (segment.italic) format += "§o";
|
||||
if (segment.underline) format += "§n";
|
||||
if (segment.strikethrough) format += "§m";
|
||||
return format + segment.text;
|
||||
})
|
||||
.join("");
|
||||
})
|
||||
.join("\n");
|
||||
});
|
||||
|
||||
const currentStyle = computed(() => {
|
||||
const line = motd.value[currentLineIndex.value];
|
||||
if (!line) return {};
|
||||
|
||||
let start = 0;
|
||||
for (const segment of line) {
|
||||
if (start + segment.text.length > selectionStart.value) {
|
||||
return {
|
||||
color: segment.color || "White",
|
||||
bold: segment.bold || false,
|
||||
italic: segment.italic || false,
|
||||
underline: segment.underline || false,
|
||||
strikethrough: segment.strikethrough || false,
|
||||
};
|
||||
}
|
||||
start += segment.text.length;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
function getColorCode(color) {
|
||||
const format = formatCodes.find((f) => f.description === color);
|
||||
return format ? format.code : "§f";
|
||||
}
|
||||
|
||||
function renderLine(line) {
|
||||
return line
|
||||
.map((segment) => {
|
||||
let style = `color: ${segment.color};`;
|
||||
if (segment.bold) style += "font-weight: 900;";
|
||||
if (segment.italic) style += "font-style: italic;";
|
||||
if (segment.underline) style += "text-decoration: underline;";
|
||||
if (segment.strikethrough) style += "text-decoration: line-through;";
|
||||
return `<span style="${style}">${segment.text}</span>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function handleSelection(lineIndex) {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
popupX.value = rect.left;
|
||||
popupY.value = rect.bottom;
|
||||
showPopup.value = true;
|
||||
currentLineIndex.value = lineIndex;
|
||||
|
||||
const lineElement = document.querySelectorAll("[contenteditable]")[lineIndex];
|
||||
const rangeClone = range.cloneRange();
|
||||
rangeClone.selectNodeContents(lineElement);
|
||||
rangeClone.setEnd(range.startContainer, range.startOffset);
|
||||
selectionStart.value = rangeClone.toString().length;
|
||||
selectionEnd.value = selectionStart.value + range.toString().length;
|
||||
} else {
|
||||
showPopup.value = false;
|
||||
colorPicker.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyStyle(newStyle) {
|
||||
const line = motd.value[currentLineIndex.value];
|
||||
const newLine = [];
|
||||
let currentPos = 0;
|
||||
|
||||
for (const segment of line) {
|
||||
if (currentPos + segment.text.length <= selectionStart.value) {
|
||||
newLine.push(segment);
|
||||
} else if (currentPos >= selectionEnd.value) {
|
||||
newLine.push(segment);
|
||||
} else {
|
||||
const beforeSelection = segment.text.slice(0, Math.max(0, selectionStart.value - currentPos));
|
||||
const inSelection = segment.text.slice(
|
||||
Math.max(0, selectionStart.value - currentPos),
|
||||
Math.min(segment.text.length, selectionEnd.value - currentPos),
|
||||
);
|
||||
const afterSelection = segment.text.slice(
|
||||
Math.min(segment.text.length, selectionEnd.value - currentPos),
|
||||
);
|
||||
console.log(beforeSelection);
|
||||
console.log(inSelection);
|
||||
console.log(afterSelection);
|
||||
|
||||
if (beforeSelection) newLine.push({ ...segment, text: beforeSelection });
|
||||
if (inSelection) {
|
||||
const mergedStyle = { ...segment, ...newStyle };
|
||||
for (const key in newStyle) {
|
||||
if (newStyle[key] === false) {
|
||||
delete mergedStyle[key];
|
||||
}
|
||||
}
|
||||
newLine.push({ ...mergedStyle, text: inSelection });
|
||||
}
|
||||
if (afterSelection) newLine.push({ ...segment, text: afterSelection });
|
||||
}
|
||||
currentPos += segment.text.length;
|
||||
}
|
||||
|
||||
motd.value[currentLineIndex.value] = newLine;
|
||||
showPopup.value = false;
|
||||
colorPicker.value = false;
|
||||
|
||||
// Rerender the line to reflect the changes
|
||||
nextTick(() => {
|
||||
const lineElement = document.querySelectorAll("[contenteditable]")[currentLineIndex.value];
|
||||
lineElement.innerHTML = renderLine(newLine);
|
||||
});
|
||||
}
|
||||
|
||||
function insertEmoji() {
|
||||
const emoji = "☺";
|
||||
if (totalCharacters.value + emoji.length <= 90) {
|
||||
applyStyle({ text: emoji });
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(event, lineIndex) {
|
||||
const newText = event.target.textContent;
|
||||
const oldText = motd.value[lineIndex].reduce((acc, segment) => acc + segment.text, "");
|
||||
const diff = newText.length - oldText.length;
|
||||
|
||||
if (newText.length <= 45) {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
const cursorOffset = getCursorOffset(event.target, range);
|
||||
|
||||
const newLine = [];
|
||||
let currentPos = 0;
|
||||
for (const segment of motd.value[lineIndex]) {
|
||||
const segmentEnd = currentPos + segment.text.length;
|
||||
const newSegmentText = newText.slice(currentPos, Math.min(segmentEnd, newText.length));
|
||||
if (newSegmentText) {
|
||||
newLine.push({ ...segment, text: newSegmentText });
|
||||
}
|
||||
currentPos = segmentEnd;
|
||||
if (currentPos >= newText.length) break;
|
||||
}
|
||||
if (currentPos < newText.length) {
|
||||
newLine.push({ text: newText.slice(currentPos), color: "White" });
|
||||
}
|
||||
motd.value[lineIndex] = newLine;
|
||||
|
||||
nextTick(() => {
|
||||
const lineElement = event.target;
|
||||
lineElement.innerHTML = renderLine(newLine);
|
||||
|
||||
const newRange = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
const { node, offset } = getCursorNodeAndOffset(lineElement, cursorOffset);
|
||||
|
||||
if (node) {
|
||||
newRange.setStart(node, offset);
|
||||
newRange.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(newRange);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
event.target.innerHTML = renderLine(motd.value[lineIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get cursor offset considering styled spans
|
||||
function getCursorOffset(element, range) {
|
||||
let offset = 0;
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
|
||||
let node;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node === range.startContainer) {
|
||||
return offset + range.startOffset;
|
||||
}
|
||||
offset += node.length;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
// Helper function to find the node and offset for cursor placement
|
||||
function getCursorNodeAndOffset(element, targetOffset) {
|
||||
let currentOffset = 0;
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
|
||||
let node;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
if (currentOffset + node.length >= targetOffset) {
|
||||
return { node, offset: targetOffset - currentOffset };
|
||||
}
|
||||
currentOffset += node.length;
|
||||
}
|
||||
|
||||
// If we've gone past the end, return the last possible position
|
||||
const lastTextNode = element.lastChild?.lastChild;
|
||||
return { node: lastTextNode, offset: lastTextNode?.length || 0 };
|
||||
}
|
||||
|
||||
function handlePaste(event, lineIndex) {
|
||||
event.preventDefault();
|
||||
const pastedText = (event.clipboardData || window.clipboardData).getData("text");
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
const startOffset = range.startOffset;
|
||||
|
||||
const currentText = motd.value[lineIndex].reduce((acc, segment) => acc + segment.text, "");
|
||||
const newText = currentText.slice(0, startOffset) + pastedText + currentText.slice(startOffset);
|
||||
|
||||
if (newText.length <= 45) {
|
||||
// Preserve existing styles by matching new text with old segments
|
||||
const newLine = [];
|
||||
let currentPos = 0;
|
||||
for (const segment of motd.value[lineIndex]) {
|
||||
if (currentPos < startOffset) {
|
||||
const segmentEnd = Math.min(currentPos + segment.text.length, startOffset);
|
||||
newLine.push({ ...segment, text: newText.slice(currentPos, segmentEnd) });
|
||||
currentPos = segmentEnd;
|
||||
} else if (currentPos >= startOffset + pastedText.length) {
|
||||
newLine.push({ ...segment, text: newText.slice(currentPos) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Insert pasted text as a new segment
|
||||
if (currentPos < startOffset + pastedText.length) {
|
||||
newLine.push({
|
||||
text: newText.slice(currentPos, startOffset + pastedText.length),
|
||||
color: "White",
|
||||
});
|
||||
}
|
||||
motd.value[lineIndex] = newLine;
|
||||
|
||||
nextTick(() => {
|
||||
const lineElement = document.querySelectorAll("[contenteditable]")[lineIndex];
|
||||
lineElement.innerHTML = renderLine(newLine);
|
||||
const newRange = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
newRange.setStart(lineElement.childNodes[0], startOffset + pastedText.length);
|
||||
newRange.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(newRange);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.minecraft-font {
|
||||
font-family: "Minecraft", monospace;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
[contenteditable] {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
@font-face {
|
||||
font-family: "Monocraft";
|
||||
src: url("/Monocraft.ttf") format("truetype");
|
||||
}
|
||||
|
||||
.font-minecraft {
|
||||
font-family: "Monocraft", monospace;
|
||||
}
|
||||
|
||||
.mcbg {
|
||||
background: url("@/assets/images/servers/minecraft-background-dark.png") repeat center center;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
}
|
||||
.fade-enter, .fade-leave-to /* .fade-leave-active in <2.1.8 */ {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -53,7 +53,7 @@
|
||||
</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>
|
||||
<div class="text-lg font-bold text-contrast">Minecraft version</div>
|
||||
<UiServersTeleportDropdownMenu
|
||||
v-model="selectedMCVersion"
|
||||
name="mcVersion"
|
||||
@@ -61,6 +61,20 @@
|
||||
class="w-full max-w-[100%]"
|
||||
placeholder="Select Minecraft version..."
|
||||
/>
|
||||
<div class="mt-2 flex items-center justify-between gap-2">
|
||||
<label for="toggle-snapshots" class="font-semibold"> Show snapshot versions </label>
|
||||
<div
|
||||
v-tooltip="
|
||||
isSnapshotSelected ? 'A snapshot version is currently selected.' : undefined
|
||||
"
|
||||
>
|
||||
<Toggle
|
||||
id="toggle-snapshots"
|
||||
v-model="showSnapshots"
|
||||
:disabled="isSnapshotSelected"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -74,7 +88,7 @@
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-contrast">{{ selectedLoader }} version</div>
|
||||
<div class="text-lg font-bold text-contrast">{{ selectedLoader }} version</div>
|
||||
|
||||
<template v-if="!selectedMCVersion">
|
||||
<div
|
||||
@@ -177,8 +191,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { RightArrowIcon, XIcon, ServerIcon, DropdownIcon } from "@modrinth/assets";
|
||||
import { BackupWarning, ButtonStyled, NewModal, Toggle } from "@modrinth/ui";
|
||||
import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from "@modrinth/assets";
|
||||
import { $fetch } from "ofetch";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { Loaders } from "~/types/servers";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
@@ -214,6 +229,7 @@ const hardReset = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const loadingServerCheck = ref(false);
|
||||
const serverCheckError = ref("");
|
||||
const showSnapshots = ref(false);
|
||||
|
||||
const selectedLoader = ref<Loaders>("Vanilla");
|
||||
const selectedMCVersion = ref("");
|
||||
@@ -226,6 +242,22 @@ const cachedVersions = ref<VersionCache>({});
|
||||
|
||||
const versionStrings = ["forge", "fabric", "quilt", "neo"] as const;
|
||||
|
||||
const isSnapshotSelected = computed(() => {
|
||||
if (selectedMCVersion.value) {
|
||||
const selected = tags.value.gameVersions.find((x) => x.version === selectedMCVersion.value);
|
||||
if (selected?.version_type !== "release") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const getLoaderVersions = async (loader: string) => {
|
||||
return await $fetch(
|
||||
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/manifest.json`,
|
||||
);
|
||||
};
|
||||
|
||||
const fetchLoaderVersions = async () => {
|
||||
const versions = await Promise.all(
|
||||
versionStrings.map(async (loader) => {
|
||||
@@ -234,7 +266,7 @@ const fetchLoaderVersions = async () => {
|
||||
throw new Error("Failed to fetch loader versions");
|
||||
}
|
||||
try {
|
||||
const res = await $fetch(`/loader-versions?loader=${loader}`);
|
||||
const res = await getLoaderVersions(loader);
|
||||
return { [loader]: (res as any).gameVersions };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (_) {
|
||||
@@ -277,11 +309,11 @@ const fetchPurpurVersions = async (mcVersion: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const selectedLoaderVersions = computed(() => {
|
||||
const selectedLoaderVersions = computed<string[]>(() => {
|
||||
const loader = selectedLoader.value.toLowerCase();
|
||||
|
||||
if (loader === "paper") {
|
||||
return paperVersions.value[selectedMCVersion.value] || [];
|
||||
return paperVersions.value[selectedMCVersion.value].map((x) => `${x}`) || [];
|
||||
}
|
||||
|
||||
if (loader === "purpur") {
|
||||
@@ -325,13 +357,22 @@ watch(selectedLoader, async () => {
|
||||
watch(
|
||||
selectedLoaderVersions,
|
||||
(newVersions) => {
|
||||
if (newVersions.length > 0 && !selectedLoaderVersion.value) {
|
||||
if (
|
||||
newVersions.length > 0 &&
|
||||
(!selectedLoaderVersion.value || !newVersions.includes(selectedLoaderVersion.value))
|
||||
) {
|
||||
selectedLoaderVersion.value = String(newVersions[0]);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const getLoaderVersion = async (loader: string, version: string) => {
|
||||
return await $fetch(
|
||||
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/versions/${version}.json`,
|
||||
);
|
||||
};
|
||||
|
||||
const checkVersionAvailability = async (version: string) => {
|
||||
if (!version || version.trim().length < 3) return;
|
||||
|
||||
@@ -339,9 +380,7 @@ const checkVersionAvailability = async (version: string) => {
|
||||
loadingServerCheck.value = true;
|
||||
|
||||
try {
|
||||
const mcRes =
|
||||
cachedVersions.value[version] ||
|
||||
(await $fetch(`/loader-versions?loader=minecraft&version=${version}`));
|
||||
const mcRes = cachedVersions.value[version] || (await getLoaderVersion("minecraft", version));
|
||||
|
||||
cachedVersions.value[version] = mcRes;
|
||||
|
||||
@@ -377,13 +416,15 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
const tags = useTags();
|
||||
const mcVersions = tags.value.gameVersions
|
||||
.filter((x) => x.version_type === "release")
|
||||
.map((x) => x.version)
|
||||
.filter((x) => {
|
||||
const segment = parseInt(x.split(".")[1], 10);
|
||||
return !isNaN(segment) && segment > 2;
|
||||
});
|
||||
const mcVersions = computed(() =>
|
||||
tags.value.gameVersions
|
||||
.filter((x) =>
|
||||
showSnapshots.value
|
||||
? x.version_type === "snapshot" || x.version_type === "release"
|
||||
: x.version_type === "release",
|
||||
)
|
||||
.map((x) => x.version),
|
||||
);
|
||||
|
||||
const isDangerous = computed(() => hardReset.value);
|
||||
const canInstall = computed(() => {
|
||||
@@ -448,6 +489,9 @@ const handleReinstall = async () => {
|
||||
|
||||
const onShow = () => {
|
||||
selectedMCVersion.value = props.server.general?.mc_version || "";
|
||||
if (isSnapshotSelected.value) {
|
||||
showSnapshots.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onHide = () => {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<template>
|
||||
<a
|
||||
href="https://pyro.host"
|
||||
target="_blank"
|
||||
class="mx-auto mt-8 flex select-none flex-row items-center gap-2 hover:underline"
|
||||
>
|
||||
<PyroIcon class="size-4 text-secondary" />
|
||||
<span class="text-sm text-secondary">Powered by Pyro</span>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PyroIcon } from "@modrinth/assets";
|
||||
</script>
|
||||
@@ -60,15 +60,7 @@
|
||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||
</div>
|
||||
<div
|
||||
v-if="status === 'suspended' && suspension_reason === 'support'"
|
||||
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"
|
||||
>
|
||||
<HammerIcon />
|
||||
You recently requested support for your server and we are actively working on it. It will be
|
||||
back online shortly.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'"
|
||||
v-else-if="status === 'suspended'"
|
||||
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
|
||||
@@ -32,68 +32,68 @@
|
||||
@mousedown.stop
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<ButtonStyled
|
||||
<template
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="option.id"
|
||||
type="transparent"
|
||||
role="menuitem"
|
||||
:color="option.color"
|
||||
:key="isDivider(option) ? `divider-${index}` : option.id"
|
||||
>
|
||||
<button
|
||||
v-if="typeof option.action === 'function'"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
||||
}
|
||||
"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</button>
|
||||
<nuxt-link
|
||||
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
||||
}
|
||||
"
|
||||
:to="option.action"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</nuxt-link>
|
||||
<a
|
||||
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
||||
}
|
||||
"
|
||||
:href="option.action"
|
||||
target="_blank"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</a>
|
||||
<span v-else>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</span>
|
||||
</ButtonStyled>
|
||||
<div v-if="isDivider(option)" class="h-px w-full bg-button-bg"></div>
|
||||
<ButtonStyled v-else type="transparent" role="menuitem" :color="option.color">
|
||||
<button
|
||||
v-if="typeof option.action === 'function'"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
||||
}
|
||||
"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</button>
|
||||
<nuxt-link
|
||||
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
||||
}
|
||||
"
|
||||
:to="option.action"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</nuxt-link>
|
||||
<a
|
||||
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
||||
}
|
||||
"
|
||||
:href="option.action"
|
||||
target="_blank"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</a>
|
||||
<span v-else>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</span>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
@@ -112,9 +112,20 @@ interface Option {
|
||||
color?: "standard" | "brand" | "red" | "orange" | "green" | "blue" | "purple";
|
||||
}
|
||||
|
||||
type Divider = {
|
||||
divider: true;
|
||||
shown?: boolean;
|
||||
};
|
||||
|
||||
type Item = Option | Divider;
|
||||
|
||||
function isDivider(item: Item): item is Divider {
|
||||
return (item as Divider).divider;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
options: Option[];
|
||||
options: Item[];
|
||||
hoverable?: boolean;
|
||||
}>(),
|
||||
{
|
||||
@@ -338,7 +349,9 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
if (selectedIndex.value >= 0) {
|
||||
selectOption(filteredOptions.value[selectedIndex.value]);
|
||||
const option = filteredOptions.value[selectedIndex.value];
|
||||
if (isDivider(option)) break;
|
||||
selectOption(option);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
@@ -361,8 +374,9 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
default:
|
||||
if (event.key.length === 1) {
|
||||
typeAheadBuffer.value += event.key.toLowerCase();
|
||||
const matchIndex = filteredOptions.value.findIndex((option) =>
|
||||
option.id.toLowerCase().startsWith(typeAheadBuffer.value),
|
||||
const matchIndex = filteredOptions.value.findIndex(
|
||||
(option) =>
|
||||
!isDivider(option) && option.id.toLowerCase().startsWith(typeAheadBuffer.value),
|
||||
);
|
||||
if (matchIndex !== -1) {
|
||||
selectedIndex.value = matchIndex;
|
||||
|
||||
@@ -3,6 +3,7 @@ import dayjs from "dayjs";
|
||||
import { ButtonStyled, commonMessages, CopyCode, ServerNotice, TagItem } from "@modrinth/ui";
|
||||
import { EditIcon, SettingsIcon, TrashIcon } from "@modrinth/assets";
|
||||
import { ServerNotice as ServerNoticeType } from "@modrinth/utils";
|
||||
import { useRelativeTime } from "@modrinth/ui";
|
||||
import {
|
||||
DISMISSABLE,
|
||||
getDismissableMetadata,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { useVIntl } from "@vintl/vintl";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
|
||||
const props = defineProps<{
|
||||
notice: ServerNoticeType;
|
||||
@@ -25,7 +27,7 @@ const props = defineProps<{
|
||||
<div class="text-sm">
|
||||
<span v-if="notice.announce_at">
|
||||
{{ dayjs(notice.announce_at).format("MMM D, YYYY [at] h:mm A") }} ({{
|
||||
dayjs(notice.announce_at).fromNow()
|
||||
formatRelativeTime(notice.announce_at)
|
||||
}})
|
||||
</span>
|
||||
<template v-else> Never begins </template>
|
||||
@@ -35,7 +37,7 @@ const props = defineProps<{
|
||||
v-if="notice.expires"
|
||||
v-tooltip="dayjs(notice.expires).format('MMMM D, YYYY [at] h:mm A')"
|
||||
>
|
||||
{{ dayjs(notice.expires).fromNow() }}
|
||||
{{ formatRelativeTime(notice.expires) }}
|
||||
</span>
|
||||
<template v-else> Never expires </template>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +103,7 @@ import {
|
||||
ModrinthIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { AutoLink, OverflowMenu } from "@modrinth/ui";
|
||||
import { AutoLink, OverflowMenu, useRelativeTime } from "@modrinth/ui";
|
||||
import { renderString } from "@modrinth/utils";
|
||||
import Avatar from "~/components/ui/Avatar.vue";
|
||||
import Badge from "~/components/ui/Badge.vue";
|
||||
|
||||
Reference in New Issue
Block a user