chore: fix frontend lint issues

This commit is contained in:
Calum H. (IMB11) 2025-08-01 15:03:49 +01:00 committed by Calum H.
parent f7fc208b15
commit 34e65ace1e
253 changed files with 12665 additions and 12701 deletions

View File

@ -1,6 +1,6 @@
project_id: 518556
preserve_hierarchy: true
commit_message: "[ci skip]"
commit_message: '[ci skip]'
files:
- source: /locales/en-US/*

View File

@ -1,8 +1,10 @@
import config from '@modrinth/tooling-config/eslint/nuxt.mjs'
export default config.append([{
export default config.append([
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
"import/no-unresolved": "off",
'no-undef': 'off'
}
}])
'@typescript-eslint/no-explicit-any': 'off',
'import/no-unresolved': 'off',
'no-undef': 'off',
},
},
])

View File

@ -6,6 +6,6 @@
</NuxtLayout>
</template>
<script setup lang="ts">
import ModrinthLoadingIndicator from "~/components/ui/modrinth-loading-indicator.ts";
import Notifications from "~/components/ui/Notifications.vue";
import ModrinthLoadingIndicator from '~/components/ui/modrinth-loading-indicator.ts'
import Notifications from '~/components/ui/Notifications.vue'
</script>

View File

@ -415,7 +415,7 @@
}
&:disabled,
&[disabled="true"] {
&[disabled='true'] {
cursor: not-allowed;
filter: grayscale(50%);
opacity: 0.5;
@ -461,7 +461,7 @@ tr.button-transparent {
}
&:disabled > *,
&[disabled="true"] > * {
&[disabled='true'] > * {
cursor: not-allowed;
filter: grayscale(50%);
opacity: 0.5;
@ -492,7 +492,7 @@ tr.button-transparent {
box-shadow: none;
&disabled,
&[disabled="true"] {
&[disabled='true'] {
cursor: not-allowed;
box-shadow: none;
}
@ -678,7 +678,7 @@ tr.button-transparent {
background: var(--color-button-bg);
&:after {
content: "";
content: '';
position: absolute;
top: 7px;
left: 7px;

View File

@ -125,8 +125,8 @@ html {
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
--landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing-light.webp");
--landing-maze-gradient-bg: url("https://cdn.modrinth.com/landing-new/landing-lower-light.webp");
--landing-maze-bg: url('https://cdn.modrinth.com/landing-new/landing-light.webp');
--landing-maze-gradient-bg: url('https://cdn.modrinth.com/landing-new/landing-lower-light.webp');
--landing-maze-outer-bg: linear-gradient(180deg, #f0f0f0 0%, #ffffff 100%);
--landing-color-heading: #000;
@ -259,10 +259,10 @@ html {
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
--landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp");
--landing-maze-bg: url('https://cdn.modrinth.com/landing-new/landing.webp');
--landing-maze-gradient-bg:
linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
url("https://cdn.modrinth.com/landing-new/landing-lower.webp");
url('https://cdn.modrinth.com/landing-new/landing-lower.webp');
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
--landing-color-heading: #fff;
@ -492,7 +492,7 @@ textarea {
}
&:disabled,
&[disabled="true"] {
&[disabled='true'] {
opacity: 0.6;
pointer-events: none;
cursor: not-allowed;
@ -509,7 +509,7 @@ textarea {
}
button,
input[type="button"] {
input[type='button'] {
cursor: pointer;
border: none;
outline: 2px solid transparent;
@ -525,13 +525,13 @@ kbd {
font-size: 0.85em !important;
}
@import "~/assets/styles/layout.scss";
@import "~/assets/styles/utils.scss";
@import "~/assets/styles/components.scss";
@import '~/assets/styles/layout.scss';
@import '~/assets/styles/utils.scss';
@import '~/assets/styles/components.scss';
button:focus-visible,
a:focus-visible,
[tabindex="0"]:focus-visible {
[tabindex='0']:focus-visible {
outline: 0.25rem solid #ea80ff;
border-radius: 0.25rem;
}

View File

@ -42,9 +42,9 @@
padding: 0 1.5rem;
grid-template:
"sidebar"
"content"
"info"
'sidebar'
'content'
'info'
/ 100%;
@media screen and (max-width: 1024px) {
@ -81,25 +81,25 @@
column-gap: 0.75rem;
grid-template:
"sidebar content" auto
"info content" auto
"dummy content" 1fr
'sidebar content' auto
'info content' auto
'dummy content' 1fr
/ 18.75rem 1fr;
&.alt-layout {
grid-template:
"content sidebar" auto
"content info" auto
"content dummy" 1fr
'content sidebar' auto
'content info' auto
'content dummy' 1fr
/ 1fr 18.75rem;
}
&.no-sidebar {
grid-template:
"header header" auto
"content content" auto
"info info" auto
"dummy dummy" 1fr
'header header' auto
'content content' auto
'info info' auto
'dummy dummy' 1fr
/ 1fr 1fr;
.normal-page__content {
@ -129,9 +129,9 @@
padding-bottom: 1.5rem;
grid-template:
"header"
"content"
"sidebar"
'header'
'content'
'sidebar'
/ 100%;
.normal-page__ultimate-sidebar {
@ -152,16 +152,16 @@
@media screen and (min-width: 1024px) {
&.sidebar {
grid-template:
"header header" auto
"content sidebar" auto
"content dummy" 1fr
'header header' auto
'content sidebar' auto
'content dummy' 1fr
/ 1fr 18.75rem;
&.alt-layout {
grid-template:
"header header" auto
"sidebar content" auto
"dummy content" 1fr
'header header' auto
'sidebar content' auto
'dummy content' 1fr
/ 18.75rem 1fr;
}
}
@ -177,9 +177,9 @@
max-width: calc(80rem + 0.75rem + 600px);
grid-template:
"header header ultimate-sidebar" auto
"content sidebar ultimate-sidebar" auto
"content dummy ultimate-sidebar" 1fr
'header header ultimate-sidebar' auto
'content sidebar ultimate-sidebar' auto
'content dummy ultimate-sidebar' 1fr
/ 1fr 18.75rem auto;
.normal-page__header {
@ -203,9 +203,9 @@
&.alt-layout {
grid-template:
"ultimate-sidebar header header" auto
"ultimate-sidebar sidebar content" auto
"ultimate-sidebar dummy content" 1fr
'ultimate-sidebar header header' auto
'ultimate-sidebar sidebar content' auto
'ultimate-sidebar dummy content' 1fr
/ auto 18.75rem 1fr;
}
}

View File

@ -54,21 +54,21 @@
</template>
<script setup>
const loading = useLoading();
const loading = useLoading()
const config = useRuntimeConfig();
const config = useRuntimeConfig()
const api = computed(() => {
const apiUrl = config.public.apiBaseUrl;
if (apiUrl.startsWith("https://api.modrinth.com")) {
return "prod";
} else if (apiUrl.startsWith("https://staging-api.modrinth.com")) {
return "staging";
} else if (apiUrl.startsWith("localhost") || apiUrl.startsWith("127.0.0.1")) {
return "localhost";
const apiUrl = config.public.apiBaseUrl
if (apiUrl.startsWith('https://api.modrinth.com')) {
return 'prod'
} else if (apiUrl.startsWith('https://staging-api.modrinth.com')) {
return 'staging'
} else if (apiUrl.startsWith('localhost') || apiUrl.startsWith('127.0.0.1')) {
return 'localhost'
}
return "foreign";
});
return 'foreign'
})
</script>
<style lang="scss" scoped>

View File

@ -19,43 +19,43 @@
</template>
<script setup lang="ts">
import { DropdownIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { DropdownIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
const props = withDefaults(
defineProps<{
openByDefault?: boolean;
type?: "standard" | "outlined" | "transparent";
openByDefault?: boolean
type?: 'standard' | 'outlined' | 'transparent'
}>(),
{
type: "standard",
type: 'standard',
openByDefault: false,
},
);
)
const isOpen = ref(props.openByDefault);
const emit = defineEmits(["onOpen", "onClose"]);
const isOpen = ref(props.openByDefault)
const emit = defineEmits(['onOpen', 'onClose'])
const slots = useSlots();
const slots = useSlots()
function open() {
isOpen.value = true;
emit("onOpen");
isOpen.value = true
emit('onOpen')
}
function close() {
isOpen.value = false;
emit("onClose");
isOpen.value = false
emit('onClose')
}
defineExpose({
open,
close,
isOpen,
});
})
defineOptions({
inheritAttrs: false,
});
})
</script>
<style scoped>
.accordion-content {

View File

@ -31,53 +31,53 @@ useHead({
// },
{
// Aditude
src: "https://dn0qt3r0xannq.cloudfront.net/modrinth-7JfmkEIXEp/modrinth-longform/prebid-load.js",
src: 'https://dn0qt3r0xannq.cloudfront.net/modrinth-7JfmkEIXEp/modrinth-longform/prebid-load.js',
async: true,
},
{
// Optima
src: "https://bservr.com/o.js?uid=8118d1fdb2e0d6f32180bd27",
src: 'https://bservr.com/o.js?uid=8118d1fdb2e0d6f32180bd27',
async: true,
},
{
src: "/inmobi.js",
src: '/inmobi.js',
async: true,
},
],
link: [
{
rel: "preload",
as: "script",
href: "https://www.googletagservices.com/tag/js/gpt.js",
rel: 'preload',
as: 'script',
href: 'https://www.googletagservices.com/tag/js/gpt.js',
},
],
});
})
onMounted(() => {
window.tude = window.tude || { cmd: [] };
window.Raven = window.Raven || { cmd: [] };
window.tude = window.tude || { cmd: [] }
window.Raven = window.Raven || { cmd: [] }
window.Raven.cmd.push(({ config }) => {
config.setCustom({
param1: "web",
});
});
param1: 'web',
})
})
tude.cmd.push(function () {
tude.refreshAdsViaDivMappings([
{
divId: "modrinth-rail-1",
baseDivId: "pb-slot-square-2",
divId: 'modrinth-rail-1',
baseDivId: 'pb-slot-square-2',
targeting: {
location: "web",
location: 'web',
},
},
]);
});
});
])
})
})
</script>
<style>
iframe[id^="google_ads_iframe"] {
iframe[id^='google_ads_iframe'] {
color-scheme: normal;
background: transparent;
}
@ -96,21 +96,21 @@ iframe[id^="google_ads_iframe"] {
background: var(--color-raised-bg);
}
#qc-cmp2-ui button[mode="primary"] {
#qc-cmp2-ui button[mode='primary'] {
background: var(--color-brand);
color: var(--color-accent-contrast);
border-radius: var(--radius-lg);
border: none;
}
#qc-cmp2-ui button[mode="secondary"] {
#qc-cmp2-ui button[mode='secondary'] {
background: var(--color-button-bg);
color: var(--color-base);
border-radius: var(--radius-lg);
border: none;
}
#qc-cmp2-ui button[mode="link"] {
#qc-cmp2-ui button[mode='link'] {
color: var(--color-link);
}
@ -129,7 +129,7 @@ iframe[id^="google_ads_iframe"] {
font-family: var(--font-standard);
}
#qc-cmp2-ui .qc-cmp2-toggle[aria-checked="true"] {
#qc-cmp2-ui .qc-cmp2-toggle[aria-checked='true'] {
background-color: var(--color-brand);
border: 1px solid var(--color-brand);
}

View File

@ -13,35 +13,35 @@
<script setup lang="ts">
defineOptions({
inheritAttrs: false,
});
})
const slotContainer = ref();
const slotContainer = ref()
const hasContent = ref(false);
const hasContent = ref(false)
const mutationObserver = ref<MutationObserver | null>(null);
const mutationObserver = ref<MutationObserver | null>(null)
function updateContent() {
if (!slotContainer.value) return false;
if (!slotContainer.value) return false
hasContent.value = slotContainer.value ? slotContainer.value.children.length > 0 : false;
hasContent.value = slotContainer.value ? slotContainer.value.children.length > 0 : false
}
onMounted(() => {
mutationObserver.value = new MutationObserver(updateContent);
mutationObserver.value = new MutationObserver(updateContent)
mutationObserver.value.observe(slotContainer.value, {
childList: true,
});
})
updateContent();
});
updateContent()
})
onUnmounted(() => {
if (mutationObserver.value) {
mutationObserver.value.disconnect();
mutationObserver.value.disconnect()
}
});
})
</script>
<style scoped>
.accordion-content {

View File

@ -15,7 +15,7 @@
</template>
<script setup>
import { ChevronRightIcon } from "@modrinth/assets";
import { ChevronRightIcon } from '@modrinth/assets'
defineProps({
linkStack: {
@ -26,7 +26,7 @@ defineProps({
type: String,
required: true,
},
});
})
</script>
<style lang="scss" scoped>

View File

@ -25,7 +25,7 @@
</template>
<script>
import { CheckIcon, DropdownIcon } from "@modrinth/assets";
import { CheckIcon, DropdownIcon } from '@modrinth/assets'
export default {
components: {
@ -36,7 +36,7 @@ export default {
label: {
type: String,
required: false,
default: "",
default: '',
},
disabled: {
type: Boolean,
@ -60,15 +60,15 @@ export default {
default: false,
},
},
emits: ["update:modelValue"],
emits: ['update:modelValue'],
methods: {
toggle() {
if (!this.disabled) {
this.$emit("update:modelValue", !this.modelValue);
this.$emit('update:modelValue', !this.modelValue)
}
},
},
};
}
</script>
<style lang="scss" scoped>

View File

@ -14,7 +14,7 @@
</template>
<script>
import { CheckIcon } from "@modrinth/assets";
import { CheckIcon } from '@modrinth/assets'
export default {
components: {
@ -42,32 +42,32 @@ export default {
default: true,
},
},
emits: ["update:modelValue"],
emits: ['update:modelValue'],
computed: {
selected: {
get() {
return this.modelValue;
return this.modelValue
},
set(value) {
this.$emit("update:modelValue", value);
this.$emit('update:modelValue', value)
},
},
},
created() {
if (this.items.length > 0 && this.neverEmpty) {
this.selected = this.items[0];
this.selected = this.items[0]
}
},
methods: {
toggleItem(item) {
if (this.selected === item && !this.neverEmpty) {
this.selected = null;
this.selected = null
} else {
this.selected = item;
this.selected = item
}
},
},
};
}
</script>
<style lang="scss" scoped>

View File

@ -28,8 +28,8 @@
</div>
<p class="m-0 max-w-[30rem]">
Your new collection will be created as a public collection with
{{ projectIds.length > 0 ? projectIds.length : "no" }}
{{ projectIds.length !== 1 ? "projects" : "project" }}.
{{ projectIds.length > 0 ? projectIds.length : 'no' }}
{{ projectIds.length !== 1 ? 'projects' : 'project' }}.
</p>
<div class="flex gap-2">
<ButtonStyled color="brand">
@ -49,61 +49,61 @@
</NewModal>
</template>
<script setup>
import { PlusIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
const router = useNativeRouter();
const router = useNativeRouter()
const name = ref("");
const description = ref("");
const name = ref('')
const description = ref('')
const modal = ref();
const modal = ref()
const props = defineProps({
projectIds: {
type: Array,
default() {
return [];
return []
},
},
});
})
async function create() {
startLoading();
startLoading()
try {
const result = await useBaseFetch("collection", {
method: "POST",
const result = await useBaseFetch('collection', {
method: 'POST',
body: {
name: name.value.trim(),
description: description.value.trim() || undefined,
projects: props.projectIds,
},
apiVersion: 3,
});
})
await initUserCollections();
await initUserCollections()
modal.value.hide();
await router.push(`/collection/${result.id}`);
modal.value.hide()
await router.push(`/collection/${result.id}`)
} catch (err) {
addNotification({
group: "main",
title: "An error occurred",
group: 'main',
title: 'An error occurred',
text: err?.data?.description || err?.message || err,
type: "error",
});
type: 'error',
})
}
stopLoading();
stopLoading()
}
function show(event) {
name.value = "";
description.value = "";
modal.value.show(event);
name.value = ''
description.value = ''
modal.value.show(event)
}
defineExpose({
show,
});
})
</script>
<style scoped lang="scss">

View File

@ -17,5 +17,5 @@ defineProps({
type: Boolean,
required: true,
},
});
})
</script>

View File

@ -4,10 +4,10 @@
class="drop-area"
@drop.stop.prevent="
(event) => {
$refs.drop_area.style.visibility = 'hidden';
$refs.drop_area.style.visibility = 'hidden'
if (event.dataTransfer && event.dataTransfer.files && fileAllowed) {
$emit('change', event.dataTransfer.files);
$emit('change', event.dataTransfer.files)
}
}
"
@ -22,45 +22,45 @@ export default {
props: {
accept: {
type: String,
default: "",
default: '',
},
},
emits: ["change"],
emits: ['change'],
data() {
return {
fileAllowed: false,
};
}
},
mounted() {
document.addEventListener("dragenter", this.allowDrag);
document.addEventListener('dragenter', this.allowDrag)
},
methods: {
allowDrag(event) {
const file = event.dataTransfer?.items[0];
const file = event.dataTransfer?.items[0]
if (
file &&
this.accept
.split(",")
.reduce((acc, t) => acc || file.type.startsWith(t) || file.type === t || t === "*", false)
.split(',')
.reduce((acc, t) => acc || file.type.startsWith(t) || file.type === t || t === '*', false)
) {
this.fileAllowed = true;
event.dataTransfer.dropEffect = "copy";
event.preventDefault();
this.fileAllowed = true
event.dataTransfer.dropEffect = 'copy'
event.preventDefault()
if (this.$refs.drop_area) {
this.$refs.drop_area.style.visibility = "visible";
this.$refs.drop_area.style.visibility = 'visible'
}
} else {
this.fileAllowed = false;
this.fileAllowed = false
if (this.$refs.drop_area) {
this.$refs.drop_area.style.visibility = "hidden";
this.$refs.drop_area.style.visibility = 'hidden'
}
}
},
},
};
}
</script>
<style lang="scss" scoped>
@ -81,7 +81,7 @@ export default {
&::before {
--indent: 4rem;
content: " ";
content: ' ';
position: relative;
top: var(--indent);
left: var(--indent);

View File

@ -49,22 +49,22 @@
</template>
<script setup>
import { ClientIcon, GlobeIcon, InfoIcon, ServerIcon } from "@modrinth/assets";
import { ClientIcon, GlobeIcon, InfoIcon, ServerIcon } from '@modrinth/assets'
defineProps({
type: {
type: String,
default: "mod",
default: 'mod',
},
serverSide: {
type: String,
required: false,
default: "",
default: '',
},
clientSide: {
type: String,
required: false,
default: "",
default: '',
},
typeOnly: {
type: Boolean,
@ -85,12 +85,12 @@ defineProps({
type: Array,
required: false,
default() {
return [];
return []
},
},
});
})
const tags = useTags();
const tags = useTags()
</script>
<style lang="scss" scoped>
.environment {

View File

@ -18,14 +18,14 @@
</template>
<script>
import { fileIsValid } from "~/helpers/fileUtils.js";
import { fileIsValid } from '~/helpers/fileUtils.js'
export default {
components: {},
props: {
prompt: {
type: String,
default: "Select file",
default: 'Select file',
},
multiple: {
type: Boolean,
@ -59,33 +59,33 @@ export default {
default: false,
},
},
emits: ["change"],
emits: ['change'],
data() {
return {
files: [],
};
}
},
methods: {
addFiles(files, shouldNotReset) {
if (!shouldNotReset || this.shouldAlwaysReset) {
this.files = files;
this.files = files
}
const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true };
this.files = [...this.files].filter((file) => fileIsValid(file, validationOptions));
const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true }
this.files = [...this.files].filter((file) => fileIsValid(file, validationOptions))
if (this.files.length > 0) {
this.$emit("change", this.files);
this.$emit('change', this.files)
}
},
handleDrop(e) {
this.addFiles(e.dataTransfer.files);
this.addFiles(e.dataTransfer.files)
},
handleChange(e) {
this.addFiles(e.target.files);
this.addFiles(e.target.files)
},
},
};
}
</script>
<style lang="scss" scoped>

View File

@ -1,40 +1,40 @@
<script setup>
const token = defineModel();
const id = ref(null);
const token = defineModel()
const id = ref(null)
function hCaptchaUpdateToken(newToken) {
token.value = newToken;
token.value = newToken
}
function hCaptchaReady() {
window.hCaptchaUpdateToken = hCaptchaUpdateToken;
id.value = window.hcaptcha.render("h-captcha");
window.hCaptchaUpdateToken = hCaptchaUpdateToken
id.value = window.hcaptcha.render('h-captcha')
}
onMounted(() => {
if (window.hcaptcha) {
hCaptchaReady();
hCaptchaReady()
} else {
window.hCaptchaReady = hCaptchaReady;
window.hCaptchaReady = hCaptchaReady
useHead({
script: [
{
src: "https://js.hcaptcha.com/1/api.js?render=explicit&onload=hCaptchaReady",
src: 'https://js.hcaptcha.com/1/api.js?render=explicit&onload=hCaptchaReady',
async: true,
defer: true,
},
],
});
})
}
});
})
defineExpose({
reset: () => {
token.value = null;
window.hcaptcha.reset(id.value);
token.value = null
window.hcaptcha.reset(id.value)
},
});
})
</script>
<template>

View File

@ -7,12 +7,12 @@
</template>
<script lang="ts" setup>
type MessageType = "information" | "warning";
type MessageType = 'information' | 'warning'
const props = withDefaults(defineProps<{ messageType?: MessageType }>(), {
messageType: "information",
});
const cardClassByType = computed(() => `message-banner__content_${props.messageType}`);
const ariaLabelByType = computed(() => `Banner with ${props.messageType} message`);
messageType: 'information',
})
const cardClassByType = computed(() => `message-banner__content_${props.messageType}`)
const ariaLabelByType = computed(() => `Banner with ${props.messageType} message`)
</script>
<style lang="css" scoped>

View File

@ -26,7 +26,7 @@
</template>
<script>
import { XIcon } from "@modrinth/assets";
import { XIcon } from '@modrinth/assets'
export default {
components: {
@ -39,31 +39,31 @@ export default {
},
},
setup() {
const cosmetics = useCosmetics();
const cosmetics = useCosmetics()
return { cosmetics };
return { cosmetics }
},
data() {
return {
shown: false,
actuallyShown: false,
};
}
},
methods: {
show() {
this.shown = true;
this.shown = true
setTimeout(() => {
this.actuallyShown = true;
}, 50);
this.actuallyShown = true
}, 50)
},
hide() {
this.actuallyShown = false;
this.actuallyShown = false
setTimeout(() => {
this.shown = false;
}, 300);
this.shown = false
}, 300)
},
},
};
}
</script>
<style lang="scss" scoped>

View File

@ -84,11 +84,11 @@
</template>
<script setup>
import { PlusIcon,XIcon } from "@modrinth/assets";
import { ButtonStyled, DropdownSelect,NewModal } from "@modrinth/ui";
import { PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, DropdownSelect, NewModal } from '@modrinth/ui'
const router = useRouter();
const app = useNuxtApp();
const router = useRouter()
const app = useNuxtApp()
const props = defineProps({
organizationId: {
@ -96,120 +96,120 @@ const props = defineProps({
required: false,
default: null,
},
});
})
const modal = ref();
const modal = ref()
const name = ref("");
const slug = ref("");
const description = ref("");
const manualSlug = ref(false);
const name = ref('')
const slug = ref('')
const description = ref('')
const manualSlug = ref(false)
const visibilities = ref([
{
actual: "approved",
display: "Public",
actual: 'approved',
display: 'Public',
},
{
actual: "unlisted",
display: "Unlisted",
actual: 'unlisted',
display: 'Unlisted',
},
{
actual: "private",
display: "Private",
actual: 'private',
display: 'Private',
},
]);
])
const visibility = ref({
actual: "approved",
display: "Public",
});
actual: 'approved',
display: 'Public',
})
const cancel = () => {
modal.value.hide();
};
modal.value.hide()
}
async function createProject() {
startLoading();
startLoading()
const formData = new FormData();
const formData = new FormData()
const auth = await useAuth();
const auth = await useAuth()
const projectData = {
title: name.value.trim(),
project_type: "mod",
project_type: 'mod',
slug: slug.value,
description: description.value.trim(),
body: "",
body: '',
requested_status: visibility.value.actual,
initial_versions: [],
team_members: [
{
user_id: auth.value.user.id,
name: auth.value.user.username,
role: "Owner",
role: 'Owner',
},
],
categories: [],
client_side: "required",
server_side: "required",
license_id: "LicenseRef-Unknown",
client_side: 'required',
server_side: 'required',
license_id: 'LicenseRef-Unknown',
is_draft: true,
};
}
if (props.organizationId) {
projectData.organization_id = props.organizationId;
projectData.organization_id = props.organizationId
}
formData.append("data", JSON.stringify(projectData));
formData.append('data', JSON.stringify(projectData))
try {
await useBaseFetch("project", {
method: "POST",
await useBaseFetch('project', {
method: 'POST',
body: formData,
headers: {
"Content-Disposition": formData,
'Content-Disposition': formData,
},
});
})
modal.value.hide();
modal.value.hide()
await router.push({
name: "type-id",
name: 'type-id',
params: {
type: "project",
type: 'project',
id: slug.value,
},
});
})
} catch (err) {
app.$notify({
group: "main",
title: "An error occurred",
group: 'main',
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: "error",
});
type: 'error',
})
}
stopLoading();
stopLoading()
}
function show(event) {
name.value = "";
slug.value = "";
description.value = "";
manualSlug.value = false;
modal.value.show(event);
name.value = ''
slug.value = ''
description.value = ''
manualSlug.value = false
modal.value.show(event)
}
defineExpose({
show,
});
})
function updatedName() {
if (!manualSlug.value) {
slug.value = name.value
.trim()
.toLowerCase()
.replaceAll(" ", "-")
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, "")
.replaceAll(/--+/gm, "-");
.replaceAll(' ', '-')
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, '')
.replaceAll(/--+/gm, '-')
}
}
</script>

View File

@ -24,7 +24,7 @@
</template>
<script setup>
const route = useNativeRoute();
const route = useNativeRoute()
const props = defineProps({
links: {
@ -35,59 +35,59 @@ const props = defineProps({
default: null,
type: String,
},
});
})
const sliderPositionX = ref(0);
const sliderPositionY = ref(18);
const selectedElementWidth = ref(0);
const activeIndex = ref(-1);
const oldIndex = ref(-1);
const sliderPositionX = ref(0)
const sliderPositionY = ref(18)
const selectedElementWidth = ref(0)
const activeIndex = ref(-1)
const oldIndex = ref(-1)
const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
);
const positionToMoveX = computed(() => `${sliderPositionX.value}px`);
const positionToMoveY = computed(() => `${sliderPositionY.value}px`);
const sliderWidth = computed(() => `${selectedElementWidth.value}px`);
)
const positionToMoveX = computed(() => `${sliderPositionX.value}px`)
const positionToMoveY = computed(() => `${sliderPositionY.value}px`)
const sliderWidth = computed(() => `${selectedElementWidth.value}px`)
function pickLink() {
activeIndex.value = props.query
? filteredLinks.value.findIndex(
(x) => (x.href === "" ? undefined : x.href) === route.path[props.query],
(x) => (x.href === '' ? undefined : x.href) === route.path[props.query],
)
: filteredLinks.value.findIndex((x) => x.href === decodeURIComponent(route.path));
: filteredLinks.value.findIndex((x) => x.href === decodeURIComponent(route.path))
if (activeIndex.value !== -1) {
startAnimation();
startAnimation()
} else {
oldIndex.value = -1;
sliderPositionX.value = 0;
selectedElementWidth.value = 0;
oldIndex.value = -1
sliderPositionX.value = 0
selectedElementWidth.value = 0
}
}
const rowLinkElements = ref();
const rowLinkElements = ref()
function startAnimation() {
const el = rowLinkElements.value[activeIndex.value].$el;
const el = rowLinkElements.value[activeIndex.value].$el
if (!el || !el.offsetParent) return;
if (!el || !el.offsetParent) return
sliderPositionX.value = el.offsetLeft;
sliderPositionY.value = el.offsetTop + el.offsetHeight;
selectedElementWidth.value = el.offsetWidth;
sliderPositionX.value = el.offsetLeft
sliderPositionY.value = el.offsetTop + el.offsetHeight
selectedElementWidth.value = el.offsetWidth
}
onMounted(() => {
window.addEventListener("resize", pickLink);
pickLink();
});
window.addEventListener('resize', pickLink)
pickLink()
})
onUnmounted(() => {
window.removeEventListener("resize", pickLink);
});
window.removeEventListener('resize', pickLink)
})
watch(route, () => pickLink());
watch(route, () => pickLink())
</script>
<style lang="scss" scoped>

View File

@ -7,7 +7,7 @@
</template>
<script>
export default {};
export default {}
</script>
<style lang="scss" scoped>

View File

@ -23,7 +23,7 @@
</template>
<script>
import { ChevronRightIcon } from "@modrinth/assets";
import { ChevronRightIcon } from '@modrinth/assets'
export default {
components: {
@ -55,7 +55,7 @@ export default {
type: Boolean,
},
},
};
}
</script>
<style lang="scss" scoped>

View File

@ -35,129 +35,129 @@
</template>
<script setup lang="ts">
import { computed, onMounted,ref, watch } from "vue";
import { computed, onMounted, ref, watch } from 'vue'
const route = useNativeRoute();
const route = useNativeRoute()
interface Tab {
label: string;
href: string;
shown?: boolean;
icon?: string;
subpages?: string[];
label: string
href: string
shown?: boolean
icon?: string
subpages?: string[]
}
const props = defineProps<{
links: Tab[];
query?: string;
}>();
links: Tab[]
query?: string
}>()
const scrollContainer = ref<HTMLElement | null>(null);
const scrollContainer = ref<HTMLElement | null>(null)
const sliderLeft = ref(4);
const sliderTop = ref(4);
const sliderRight = ref(4);
const sliderBottom = ref(4);
const activeIndex = ref(-1);
const subpageSelected = ref(false);
const sliderLeft = ref(4)
const sliderTop = ref(4)
const sliderRight = ref(4)
const sliderBottom = ref(4)
const activeIndex = ref(-1)
const subpageSelected = ref(false)
const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
);
const sliderLeftPx = computed(() => `${sliderLeft.value}px`);
const sliderTopPx = computed(() => `${sliderTop.value}px`);
const sliderRightPx = computed(() => `${sliderRight.value}px`);
const sliderBottomPx = computed(() => `${sliderBottom.value}px`);
)
const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
const sliderTopPx = computed(() => `${sliderTop.value}px`)
const sliderRightPx = computed(() => `${sliderRight.value}px`)
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
const tabLinkElements = ref();
const tabLinkElements = ref()
function pickLink() {
let index = -1;
subpageSelected.value = false;
let index = -1
subpageSelected.value = false
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i];
const link = filteredLinks.value[i]
if (props.query) {
if (route.query[props.query] === link.href || (!route.query[props.query] && !link.href)) {
index = i;
break;
index = i
break
}
} else if (decodeURIComponent(route.path) === link.href) {
index = i;
break;
index = i
break
} else if (
decodeURIComponent(route.path).includes(link.href) ||
(link.subpages &&
link.subpages.some((subpage) => decodeURIComponent(route.path).includes(subpage)))
) {
index = i;
subpageSelected.value = true;
break;
index = i
subpageSelected.value = true
break
}
}
activeIndex.value = index;
activeIndex.value = index
if (activeIndex.value !== -1) {
startAnimation();
startAnimation()
} else {
sliderLeft.value = 0;
sliderRight.value = 0;
sliderLeft.value = 0
sliderRight.value = 0
}
}
function startAnimation() {
const el = tabLinkElements.value[activeIndex.value]?.$el;
const el = tabLinkElements.value[activeIndex.value]?.$el
if (!el || !el.offsetParent) return;
if (!el || !el.offsetParent) return
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
};
}
if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left;
sliderRight.value = newValues.right;
sliderTop.value = newValues.top;
sliderBottom.value = newValues.bottom;
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
} else {
const delay = 200;
const delay = 200
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left;
sliderLeft.value = newValues.left
setTimeout(() => {
sliderRight.value = newValues.right;
}, delay);
sliderRight.value = newValues.right
}, delay)
} else {
sliderRight.value = newValues.right;
sliderRight.value = newValues.right
setTimeout(() => {
sliderLeft.value = newValues.left;
}, delay);
sliderLeft.value = newValues.left
}, delay)
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top;
sliderTop.value = newValues.top
setTimeout(() => {
sliderBottom.value = newValues.bottom;
}, delay);
sliderBottom.value = newValues.bottom
}, delay)
} else {
sliderBottom.value = newValues.bottom;
sliderBottom.value = newValues.bottom
setTimeout(() => {
sliderTop.value = newValues.top;
}, delay);
sliderTop.value = newValues.top
}, delay)
}
}
}
onMounted(() => {
pickLink();
});
pickLink()
})
watch(
() => [route.path, route.query],
() => pickLink(),
);
)
</script>
<style scoped>

View File

@ -211,8 +211,8 @@
class="iconified-button square-button brand-button button-transparent"
@click="
() => {
acceptTeamInvite(notification.body.team_id);
read();
acceptTeamInvite(notification.body.team_id)
read()
}
"
>
@ -223,8 +223,8 @@
class="iconified-button square-button danger-button button-transparent"
@click="
() => {
removeSelfFromTeam(notification.body.team_id);
read();
removeSelfFromTeam(notification.body.team_id)
read()
}
"
>
@ -249,8 +249,8 @@
class="iconified-button brand-button"
@click="
() => {
acceptTeamInvite(notification.body.team_id);
read();
acceptTeamInvite(notification.body.team_id)
read()
}
"
>
@ -261,8 +261,8 @@
class="iconified-button danger-button"
@click="
() => {
removeSelfFromTeam(notification.body.team_id);
read();
removeSelfFromTeam(notification.body.team_id)
read()
}
"
>
@ -329,22 +329,22 @@ import {
UserPlusIcon,
VersionIcon,
XIcon,
} from "@modrinth/assets";
import { Avatar, CopyCode, ProjectStatusBadge, useRelativeTime } from "@modrinth/ui";
import { renderString } from "@modrinth/utils";
} from '@modrinth/assets'
import { Avatar, CopyCode, ProjectStatusBadge, useRelativeTime } from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import DoubleIcon from "~/components/ui/DoubleIcon.vue";
import Categories from "~/components/ui/search/Categories.vue";
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
import { markAsRead } from "~/helpers/notifications.ts";
import { getProjectLink, getVersionLink } from "~/helpers/projects.js";
import { acceptTeamInvite, removeSelfFromTeam } from "~/helpers/teams.js";
import { getUserLink } from "~/helpers/users.js";
import DoubleIcon from '~/components/ui/DoubleIcon.vue'
import Categories from '~/components/ui/search/Categories.vue'
import ThreadSummary from '~/components/ui/thread/ThreadSummary.vue'
import { markAsRead } from '~/helpers/notifications.ts'
import { getProjectLink, getVersionLink } from '~/helpers/projects.js'
import { acceptTeamInvite, removeSelfFromTeam } from '~/helpers/teams.js'
import { getUserLink } from '~/helpers/users.js'
const app = useNuxtApp();
const emit = defineEmits(["update:notifications"]);
const app = useNuxtApp()
const emit = defineEmits(['update:notifications'])
const formatRelativeTime = useRelativeTime();
const formatRelativeTime = useRelativeTime()
const props = defineProps({
notification: {
@ -367,34 +367,34 @@ const props = defineProps({
type: Object,
required: true,
},
});
})
const flags = useFeatureFlags();
const tags = useTags();
const flags = useFeatureFlags()
const tags = useTags()
const type = computed(() =>
!props.notification.body || props.notification.body.type === "legacy_markdown"
!props.notification.body || props.notification.body.type === 'legacy_markdown'
? null
: props.notification.body.type,
);
const thread = computed(() => props.notification.extra_data.thread);
const report = computed(() => props.notification.extra_data.report);
const project = computed(() => props.notification.extra_data.project);
const version = computed(() => props.notification.extra_data.version);
const user = computed(() => props.notification.extra_data.user);
const organization = computed(() => props.notification.extra_data.organization);
const invitedBy = computed(() => props.notification.extra_data.invited_by);
)
const thread = computed(() => props.notification.extra_data.thread)
const report = computed(() => props.notification.extra_data.report)
const project = computed(() => props.notification.extra_data.project)
const version = computed(() => props.notification.extra_data.version)
const user = computed(() => props.notification.extra_data.user)
const organization = computed(() => props.notification.extra_data.organization)
const invitedBy = computed(() => props.notification.extra_data.invited_by)
const threadLink = computed(() => {
if (report.value) {
return `/dashboard/report/${report.value.id}`;
return `/dashboard/report/${report.value.id}`
} else if (project.value) {
return `${getProjectLink(project.value)}/moderation#messages`;
return `${getProjectLink(project.value)}/moderation#messages`
}
return "#";
});
return '#'
})
const hasBody = computed(() => !type.value || thread.value || type.value === "project_update");
const hasBody = computed(() => !type.value || thread.value || type.value === 'project_update')
async function read() {
try {
@ -403,54 +403,54 @@ async function read() {
...(props.notification.grouped_notifs
? props.notification.grouped_notifs.map((notif) => notif.id)
: []),
];
const updateNotifs = await markAsRead(ids);
const newNotifs = updateNotifs(props.notifications);
emit("update:notifications", newNotifs);
]
const updateNotifs = await markAsRead(ids)
const newNotifs = updateNotifs(props.notifications)
emit('update:notifications', newNotifs)
} catch (err) {
app.$notify({
group: "main",
title: "Error marking notification as read",
group: 'main',
title: 'Error marking notification as read',
text: err.data ? err.data.description : err,
type: "error",
});
type: 'error',
})
}
}
async function performAction(notification, actionIndex) {
startLoading();
startLoading()
try {
await read();
await read()
if (actionIndex !== null) {
await useBaseFetch(`${notification.actions[actionIndex].action_route[1]}`, {
method: notification.actions[actionIndex].action_route[0].toUpperCase(),
});
})
}
} catch (err) {
app.$notify({
group: "main",
title: "An error occurred",
group: 'main',
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: "error",
});
type: 'error',
})
}
stopLoading();
stopLoading()
}
function getMessages() {
const messages = [];
const messages = []
if (props.notification.body.message_id) {
messages.push(props.notification.body.message_id);
messages.push(props.notification.body.message_id)
}
if (props.notification.grouped_notifs) {
for (const notif of props.notification.grouped_notifs) {
if (notif.body.message_id) {
messages.push(notif.body.message_id);
messages.push(notif.body.message_id)
}
}
}
return messages;
return messages
}
</script>
@ -458,35 +458,35 @@ function getMessages() {
.notification {
display: grid;
grid-template:
"icon title"
"actions actions"
"date date";
'icon title'
'actions actions'
'date date';
grid-template-columns: min-content 1fr;
grid-template-rows: min-content min-content min-content;
gap: var(--spacing-card-sm);
&.compact {
grid-template:
"icon title actions"
"date date date";
'icon title actions'
'date date date';
grid-template-columns: min-content 1fr auto;
grid-template-rows: auto min-content;
}
&.has-body {
grid-template:
"icon title"
"body body"
"actions actions"
"date date";
'icon title'
'body body'
'actions actions'
'date date';
grid-template-columns: min-content 1fr;
grid-template-rows: min-content auto auto min-content;
&.compact {
grid-template:
"icon title actions"
"body body body"
"date date date";
'icon title actions'
'body body body'
'date date date';
grid-template-columns: min-content 1fr auto;
grid-template-rows: min-content auto min-content;
}

View File

@ -31,88 +31,88 @@
</template>
<script setup lang="ts" generic="T">
import { computed, onMounted,ref } from "vue";
import { computed, onMounted, ref } from 'vue'
const modelValue = defineModel<T>({ required: true });
const modelValue = defineModel<T>({ required: true })
const props = defineProps<{
options: T[];
}>();
options: T[]
}>()
const scrollContainer = ref<HTMLElement | null>(null);
const scrollContainer = ref<HTMLElement | null>(null)
const sliderLeft = ref(4);
const sliderTop = ref(4);
const sliderRight = ref(4);
const sliderBottom = ref(4);
const sliderLeft = ref(4)
const sliderTop = ref(4)
const sliderRight = ref(4)
const sliderBottom = ref(4)
const sliderLeftPx = computed(() => `${sliderLeft.value}px`);
const sliderTopPx = computed(() => `${sliderTop.value}px`);
const sliderRightPx = computed(() => `${sliderRight.value}px`);
const sliderBottomPx = computed(() => `${sliderBottom.value}px`);
const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
const sliderTopPx = computed(() => `${sliderTop.value}px`)
const sliderRightPx = computed(() => `${sliderRight.value}px`)
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
const optionButtons = ref();
const optionButtons = ref()
const initialized = ref(false);
const initialized = ref(false)
function setOption(option: T) {
modelValue.value = option;
modelValue.value = option
}
watch(modelValue, () => {
startAnimation(props.options.indexOf(modelValue.value));
});
startAnimation(props.options.indexOf(modelValue.value))
})
function startAnimation(index: number) {
const el = optionButtons.value[index];
const el = optionButtons.value[index]
if (!el || !el.offsetParent) return;
if (!el || !el.offsetParent) return
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
};
}
if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left;
sliderRight.value = newValues.right;
sliderTop.value = newValues.top;
sliderBottom.value = newValues.bottom;
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
} else {
const delay = 200;
const delay = 200
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left;
sliderLeft.value = newValues.left
setTimeout(() => {
sliderRight.value = newValues.right;
}, delay);
sliderRight.value = newValues.right
}, delay)
} else {
sliderRight.value = newValues.right;
sliderRight.value = newValues.right
setTimeout(() => {
sliderLeft.value = newValues.left;
}, delay);
sliderLeft.value = newValues.left
}, delay)
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top;
sliderTop.value = newValues.top
setTimeout(() => {
sliderBottom.value = newValues.bottom;
}, delay);
sliderBottom.value = newValues.bottom
}, delay)
} else {
sliderBottom.value = newValues.bottom;
sliderBottom.value = newValues.bottom
setTimeout(() => {
sliderTop.value = newValues.top;
}, delay);
sliderTop.value = newValues.top
}, delay)
}
}
initialized.value = true;
initialized.value = true
}
onMounted(() => {
startAnimation(props.options.indexOf(modelValue.value));
});
startAnimation(props.options.indexOf(modelValue.value))
})
</script>
<style scoped>

View File

@ -71,51 +71,51 @@
</NewModal>
</template>
<script setup>
import { PlusIcon,XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
const router = useNativeRouter();
const router = useNativeRouter()
const name = ref("");
const slug = ref("");
const description = ref("");
const manualSlug = ref(false);
const name = ref('')
const slug = ref('')
const description = ref('')
const manualSlug = ref(false)
const modal = ref();
const modal = ref()
async function createOrganization() {
startLoading();
startLoading()
try {
const value = {
name: name.value.trim(),
description: description.value.trim(),
slug: slug.value.trim().replace(/ +/g, ""),
};
slug: slug.value.trim().replace(/ +/g, ''),
}
const result = await useBaseFetch("organization", {
method: "POST",
const result = await useBaseFetch('organization', {
method: 'POST',
body: JSON.stringify(value),
apiVersion: 3,
});
})
modal.value.hide();
modal.value.hide()
await router.push(`/organization/${result.slug}`);
await router.push(`/organization/${result.slug}`)
} catch (err) {
console.error(err);
console.error(err)
addNotification({
group: "main",
title: "An error occurred",
group: 'main',
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: "error",
});
type: 'error',
})
}
stopLoading();
stopLoading()
}
function show(event) {
name.value = "";
description.value = "";
modal.value.show(event);
name.value = ''
description.value = ''
modal.value.show(event)
}
function updateSlug() {
@ -123,15 +123,15 @@ function updateSlug() {
slug.value = name.value
.trim()
.toLowerCase()
.replaceAll(" ", "-")
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, "")
.replaceAll(/--+/gm, "-");
.replaceAll(' ', '-')
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, '')
.replaceAll(/--+/gm, '-')
}
}
defineExpose({
show,
});
})
</script>
<style scoped lang="scss">

View File

@ -59,7 +59,7 @@
<span>{{
formatProjectType(
$getProjectTypeForDisplay(
project.project_types?.[0] ?? "project",
project.project_types?.[0] ?? 'project',
project.loaders,
),
)
@ -88,13 +88,13 @@
<span>
{{
selectedProjects.length === props.projects.length
? "All"
? 'All'
: selectedProjects.length
}}
</span>
<span>
{{ " " }}
{{ selectedProjects.length === 1 ? "project" : "projects" }}
{{ ' ' }}
{{ selectedProjects.length === 1 ? 'project' : 'projects' }}
</span>
</span>
</Button>
@ -109,40 +109,40 @@
</template>
<script setup>
import { BoxIcon, SettingsIcon, TransferIcon, XIcon } from "@modrinth/assets";
import { Avatar,Button, Checkbox, CopyCode, Modal } from "@modrinth/ui";
import { formatProjectType } from "@modrinth/utils";
import { BoxIcon, SettingsIcon, TransferIcon, XIcon } from '@modrinth/assets'
import { Avatar, Button, Checkbox, CopyCode, Modal } from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
const modalOpen = ref(null);
const modalOpen = ref(null)
const props = defineProps({
projects: {
type: Array,
required: true,
},
});
})
// define emit for submission
const emit = defineEmits(["submit"]);
const emit = defineEmits(['submit'])
const selectedProjects = ref([]);
const selectedProjects = ref([])
const toggleSelectedProjects = () => {
if (selectedProjects.value.length === props.projects.length) {
selectedProjects.value = [];
selectedProjects.value = []
} else {
selectedProjects.value = props.projects;
selectedProjects.value = props.projects
}
};
}
const onSubmitHandler = () => {
if (selectedProjects.value.length === 0) {
return;
return
}
emit("submit", selectedProjects.value);
selectedProjects.value = [];
modalOpen.value?.hide();
};
emit('submit', selectedProjects.value)
selectedProjects.value = []
modalOpen.value?.hide()
}
</script>
<style lang="scss" scoped>
@ -176,7 +176,7 @@ const onSubmitHandler = () => {
.table-row {
display: grid;
grid-template: "checkbox icon name type settings" "checkbox icon id type settings";
grid-template: 'checkbox icon name type settings' 'checkbox icon id type settings';
grid-template-columns:
min-content min-content minmax(min-content, 2fr)
minmax(min-content, 1fr) min-content;
@ -208,7 +208,7 @@ const onSubmitHandler = () => {
}
.table-head {
grid-template: "checkbox settings";
grid-template: 'checkbox settings';
grid-template-columns: min-content minmax(min-content, 1fr);
:nth-child(2),
@ -223,7 +223,7 @@ const onSubmitHandler = () => {
@media screen and (max-width: 560px) {
.table-row {
display: grid;
grid-template: "checkbox icon name settings" "checkbox icon id settings" "checkbox icon type settings" "checkbox icon status settings";
grid-template: 'checkbox icon name settings' 'checkbox icon id settings' 'checkbox icon type settings' 'checkbox icon status settings';
grid-template-columns: min-content min-content minmax(min-content, 1fr) min-content;
:nth-child(5) {
@ -232,7 +232,7 @@ const onSubmitHandler = () => {
}
.table-head {
grid-template: "checkbox settings";
grid-template: 'checkbox settings';
grid-template-columns: min-content minmax(min-content, 1fr);
}
}

View File

@ -90,11 +90,11 @@
</template>
<script>
import { CalendarIcon, DownloadIcon, HeartIcon,UpdatedIcon } from "@modrinth/assets";
import { Avatar, ProjectStatusBadge, useRelativeTime } from "@modrinth/ui";
import { CalendarIcon, DownloadIcon, HeartIcon, UpdatedIcon } from '@modrinth/assets'
import { Avatar, ProjectStatusBadge, useRelativeTime } from '@modrinth/ui'
import EnvironmentIndicator from "~/components/ui/EnvironmentIndicator.vue";
import Categories from "~/components/ui/search/Categories.vue";
import EnvironmentIndicator from '~/components/ui/EnvironmentIndicator.vue'
import Categories from '~/components/ui/search/Categories.vue'
export default {
components: {
@ -110,15 +110,15 @@ export default {
props: {
id: {
type: String,
default: "modrinth-0",
default: 'modrinth-0',
},
type: {
type: String,
default: "mod",
default: 'mod',
},
name: {
type: String,
default: "Project Name",
default: 'Project Name',
},
author: {
type: String,
@ -126,11 +126,11 @@ export default {
},
description: {
type: String,
default: "A _type description",
default: 'A _type description',
},
iconUrl: {
type: String,
default: "#",
default: '#',
required: false,
},
downloads: {
@ -145,7 +145,7 @@ export default {
},
createdAt: {
type: String,
default: "0000-00-00",
default: '0000-00-00',
},
updatedAt: {
type: String,
@ -154,7 +154,7 @@ export default {
categories: {
type: Array,
default() {
return [];
return []
},
},
status: {
@ -168,12 +168,12 @@ export default {
serverSide: {
type: String,
required: false,
default: "",
default: '',
},
clientSide: {
type: String,
required: false,
default: "",
default: '',
},
moderation: {
type: Boolean,
@ -212,26 +212,26 @@ export default {
},
},
setup() {
const tags = useTags();
const formatRelativeTime = useRelativeTime();
const tags = useTags()
const formatRelativeTime = useRelativeTime()
return { tags, formatRelativeTime };
return { tags, formatRelativeTime }
},
computed: {
projectTypeDisplay() {
return this.$getProjectTypeForDisplay(this.type, this.categories);
return this.$getProjectTypeForDisplay(this.type, this.categories)
},
toColor() {
let color = this.color;
let color = this.color
color >>>= 0;
const b = color & 0xff;
const g = (color & 0xff00) >>> 8;
const r = (color & 0xff0000) >>> 16;
return "rgba(" + [r, g, b, 1].join(",") + ")";
color >>>= 0
const b = color & 0xff
const g = (color & 0xff00) >>> 8
const r = (color & 0xff0000) >>> 16
return 'rgba(' + [r, g, b, 1].join(',') + ')'
},
},
};
}
</script>
<style lang="scss" scoped>
@ -244,9 +244,9 @@ export default {
.display-mode--list .project-card {
grid-template:
"icon title stats"
"icon description stats"
"icon tags stats";
'icon title stats'
'icon description stats'
'icon tags stats';
grid-template-columns: min-content 1fr auto;
grid-template-rows: min-content 1fr min-content;
column-gap: var(--spacing-card-md);
@ -255,20 +255,20 @@ export default {
@media screen and (max-width: 750px) {
grid-template:
"icon title"
"icon description"
"icon tags"
"stats stats";
'icon title'
'icon description'
'icon tags'
'stats stats';
grid-template-columns: min-content auto;
grid-template-rows: min-content 1fr min-content min-content;
}
@media screen and (max-width: 550px) {
grid-template:
"icon title"
"icon description"
"tags tags"
"stats stats";
'icon title'
'icon description'
'tags tags'
'stats stats';
grid-template-columns: min-content auto;
grid-template-rows: min-content 1fr min-content min-content;
}
@ -277,7 +277,7 @@ export default {
.display-mode--gallery .project-card,
.display-mode--grid .project-card {
padding: 0 0 var(--spacing-card-bg) 0;
grid-template: "gallery gallery" "icon title" "description description" "tags tags" "stats stats";
grid-template: 'gallery gallery' 'icon title' 'description description' 'tags tags' 'stats stats';
grid-template-columns: min-content 1fr;
grid-template-rows: min-content min-content 1fr min-content min-content;
row-gap: var(--spacing-card-sm);
@ -501,10 +501,10 @@ export default {
.small-mode {
@media screen and (min-width: 750px) {
grid-template:
"icon title"
"icon description"
"icon tags"
"stats stats" !important;
'icon title'
'icon description'
'icon tags'
'stats stats' !important;
grid-template-columns: min-content auto !important;
grid-template-rows: min-content 1fr min-content min-content !important;

View File

@ -117,10 +117,10 @@ import {
ScaleIcon,
SendIcon,
XIcon,
} from "@modrinth/assets";
import { formatProjectType } from "@modrinth/utils";
} from '@modrinth/assets'
import { formatProjectType } from '@modrinth/utils'
import { acceptTeamInvite, removeTeamMember } from "~/helpers/teams.js";
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
const props = defineProps({
project: {
@ -130,7 +130,7 @@ const props = defineProps({
versions: {
type: Array,
default() {
return [];
return []
},
},
currentMember: {
@ -151,7 +151,7 @@ const props = defineProps({
},
routeName: {
type: String,
default: "",
default: '',
},
auth: {
type: Object,
@ -166,12 +166,12 @@ const props = defineProps({
default() {
return () => {
addNotification({
group: "main",
title: "An error occurred",
text: "setProcessing function not found",
type: "error",
});
};
group: 'main',
title: 'An error occurred',
text: 'setProcessing function not found',
type: 'error',
})
}
},
},
toggleCollapsed: {
@ -179,12 +179,12 @@ const props = defineProps({
default() {
return () => {
addNotification({
group: "main",
title: "An error occurred",
text: "toggleCollapsed function not found",
type: "error",
});
};
group: 'main',
title: 'An error occurred',
text: 'toggleCollapsed function not found',
type: 'error',
})
}
},
},
updateMembers: {
@ -192,81 +192,81 @@ const props = defineProps({
default() {
return () => {
addNotification({
group: "main",
title: "An error occurred",
text: "updateMembers function not found",
type: "error",
});
};
group: 'main',
title: 'An error occurred',
text: 'updateMembers function not found',
type: 'error',
})
}
},
},
});
})
const featuredGalleryImage = computed(() => props.project.gallery.find((img) => img.featured));
const featuredGalleryImage = computed(() => props.project.gallery.find((img) => img.featured))
const nags = computed(() => [
{
condition: props.versions.length < 1,
title: "Upload a version",
id: "upload-version",
description: "At least one version is required for a project to be submitted for review.",
status: "required",
title: 'Upload a version',
id: 'upload-version',
description: 'At least one version is required for a project to be submitted for review.',
status: 'required',
link: {
path: "versions",
title: "Visit versions page",
hide: props.routeName === "type-id-versions",
path: 'versions',
title: 'Visit versions page',
hide: props.routeName === 'type-id-versions',
},
},
{
condition:
props.project.body === "" || props.project.body.startsWith("# Placeholder description"),
title: "Add a description",
id: "add-description",
props.project.body === '' || props.project.body.startsWith('# Placeholder description'),
title: 'Add a description',
id: 'add-description',
description:
"A description that clearly describes the project's purpose and function is required.",
status: "required",
status: 'required',
link: {
path: "settings/description",
title: "Visit description settings",
hide: props.routeName === "type-id-settings-description",
path: 'settings/description',
title: 'Visit description settings',
hide: props.routeName === 'type-id-settings-description',
},
},
{
condition: !props.project.icon_url,
title: "Add an icon",
id: "add-icon",
title: 'Add an icon',
id: 'add-icon',
description:
"Your project should have a nice-looking icon to uniquely identify your project at a glance.",
status: "suggestion",
'Your project should have a nice-looking icon to uniquely identify your project at a glance.',
status: 'suggestion',
link: {
path: "settings",
title: "Visit general settings",
hide: props.routeName === "type-id-settings",
path: 'settings',
title: 'Visit general settings',
hide: props.routeName === 'type-id-settings',
},
},
{
condition: props.project.gallery.length === 0 || !featuredGalleryImage.value,
title: "Feature a gallery image",
id: "feature-gallery-image",
description: "Featured gallery images may be the first impression of many users.",
status: "suggestion",
title: 'Feature a gallery image',
id: 'feature-gallery-image',
description: 'Featured gallery images may be the first impression of many users.',
status: 'suggestion',
link: {
path: "gallery",
title: "Visit gallery page",
hide: props.routeName === "type-id-gallery",
path: 'gallery',
title: 'Visit gallery page',
hide: props.routeName === 'type-id-gallery',
},
},
{
hide: props.project.versions.length === 0,
condition: props.project.categories.length < 1,
title: "Select tags",
id: "select-tags",
description: "Select all tags that apply to your project.",
status: "suggestion",
title: 'Select tags',
id: 'select-tags',
description: 'Select all tags that apply to your project.',
status: 'suggestion',
link: {
path: "settings/tags",
title: "Visit tag settings",
hide: props.routeName === "type-id-settings-tags",
path: 'settings/tags',
title: 'Visit tag settings',
hide: props.routeName === 'type-id-settings-tags',
},
},
{
@ -277,111 +277,111 @@ const nags = computed(() => [
props.project.discord_url ||
props.project.donation_urls.length > 0
),
title: "Add external links",
id: "add-links",
title: 'Add external links',
id: 'add-links',
description:
"Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite.",
status: "suggestion",
'Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite.',
status: 'suggestion',
link: {
path: "settings/links",
title: "Visit links settings",
hide: props.routeName === "type-id-settings-links",
path: 'settings/links',
title: 'Visit links settings',
hide: props.routeName === 'type-id-settings-links',
},
},
{
hide:
props.project.versions.length === 0 ||
props.project.project_type === "resourcepack" ||
props.project.project_type === "plugin" ||
props.project.project_type === "shader" ||
props.project.project_type === "datapack",
props.project.project_type === 'resourcepack' ||
props.project.project_type === 'plugin' ||
props.project.project_type === 'shader' ||
props.project.project_type === 'datapack',
condition:
props.project.client_side === "unknown" ||
props.project.server_side === "unknown" ||
(props.project.client_side === "unsupported" && props.project.server_side === "unsupported"),
title: "Select supported environments",
id: "select-environments",
props.project.client_side === 'unknown' ||
props.project.server_side === 'unknown' ||
(props.project.client_side === 'unsupported' && props.project.server_side === 'unsupported'),
title: 'Select supported environments',
id: 'select-environments',
description: `Select if the ${formatProjectType(
props.project.project_type,
).toLowerCase()} functions on the client-side and/or server-side.`,
status: "required",
status: 'required',
link: {
path: "settings",
title: "Visit general settings",
hide: props.routeName === "type-id-settings",
path: 'settings',
title: 'Visit general settings',
hide: props.routeName === 'type-id-settings',
},
},
{
condition: props.project.license.id === "LicenseRef-Unknown",
title: "Select license",
id: "select-license",
condition: props.project.license.id === 'LicenseRef-Unknown',
title: 'Select license',
id: 'select-license',
description: `Select the license your ${formatProjectType(
props.project.project_type,
).toLowerCase()} is distributed under.`,
status: "required",
status: 'required',
link: {
path: "settings/license",
title: "Visit license settings",
hide: props.routeName === "type-id-settings-license",
path: 'settings/license',
title: 'Visit license settings',
hide: props.routeName === 'type-id-settings-license',
},
},
{
condition: props.project.status === "draft",
title: "Submit for review",
id: "submit-for-review",
condition: props.project.status === 'draft',
title: 'Submit for review',
id: 'submit-for-review',
description:
"Your project is only viewable by members of the project. It must be reviewed by moderators in order to be published.",
status: "review",
'Your project is only viewable by members of the project. It must be reviewed by moderators in order to be published.',
status: 'review',
link: null,
action: {
onClick: submitForReview,
title: "Submit for review",
disabled: () => nags.value.filter((x) => x.condition && x.status === "required").length > 0,
title: 'Submit for review',
disabled: () => nags.value.filter((x) => x.condition && x.status === 'required').length > 0,
},
},
{
hide: props.project.stats === "draft",
hide: props.project.stats === 'draft',
condition: props.tags.rejectedStatuses.includes(props.project.status),
title: "Resubmit for review",
id: "resubmit-for-review",
title: 'Resubmit for review',
id: 'resubmit-for-review',
description: `Your project has been ${props.project.status} by
Modrinth's staff. In most cases, you can resubmit for review after
addressing the staff's message.`,
status: "review",
status: 'review',
link: {
path: "moderation",
title: "Visit moderation page",
hide: props.routeName === "type-id-moderation",
path: 'moderation',
title: 'Visit moderation page',
hide: props.routeName === 'type-id-moderation',
},
},
]);
])
const showInvitation = computed(() => {
if (props.allMembers && props.auth) {
const member = props.allMembers.find((x) => x.user.id === props.auth.user.id);
return member && !member.accepted;
const member = props.allMembers.find((x) => x.user.id === props.auth.user.id)
return member && !member.accepted
}
return false;
});
return false
})
const acceptInvite = () => {
acceptTeamInvite(props.project.team);
props.updateMembers();
};
acceptTeamInvite(props.project.team)
props.updateMembers()
}
const declineInvite = () => {
removeTeamMember(props.project.team, props.auth.user.id);
props.updateMembers();
};
removeTeamMember(props.project.team, props.auth.user.id)
props.updateMembers()
}
const submitForReview = async () => {
if (
!props.acknowledgedMessage ||
nags.value.filter((x) => x.condition && x.status === "required").length === 0
nags.value.filter((x) => x.condition && x.status === 'required').length === 0
) {
await props.setProcessing();
await props.setProcessing()
}
};
}
</script>
<style lang="scss" scoped>

View File

@ -64,23 +64,23 @@ import {
MailIcon,
MastodonIcon,
TwitterIcon,
} from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
} from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
const props = defineProps<{
title?: string;
url: string;
}>();
title?: string
url: string
}>()
const copied = ref(false);
const encodedUrl = computed(() => encodeURIComponent(props.url));
const encodedTitle = computed(() => (props.title ? encodeURIComponent(props.title) : undefined));
const copied = ref(false)
const encodedUrl = computed(() => encodeURIComponent(props.url))
const encodedTitle = computed(() => (props.title ? encodeURIComponent(props.title) : undefined))
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text);
copied.value = true;
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false;
}, 3000);
copied.value = false
}, 3000)
}
</script>

View File

@ -1,7 +1,7 @@
<script setup>
import { formatMoney,formatNumber } from "@modrinth/utils";
import dayjs from "dayjs";
import VueApexCharts from "vue3-apexcharts";
import { formatMoney, formatNumber } from '@modrinth/utils'
import dayjs from 'dayjs'
import VueApexCharts from 'vue3-apexcharts'
const props = defineProps({
name: {
@ -18,7 +18,7 @@ const props = defineProps({
},
formatLabels: {
type: Function,
default: (label) => dayjs(label).format("MMM D"),
default: (label) => dayjs(label).format('MMM D'),
},
colors: {
type: Array,
@ -26,11 +26,11 @@ const props = defineProps({
},
prefix: {
type: String,
default: "",
default: '',
},
suffix: {
type: String,
default: "",
default: '',
},
hideToolbar: {
type: Boolean,
@ -46,7 +46,7 @@ const props = defineProps({
},
type: {
type: String,
default: "bar",
default: 'bar',
},
hideTotal: {
type: Boolean,
@ -58,11 +58,11 @@ const props = defineProps({
},
legendPosition: {
type: String,
default: "right",
default: 'right',
},
xAxisType: {
type: String,
default: "datetime",
default: 'datetime',
},
percentStacked: {
type: Boolean,
@ -76,14 +76,14 @@ const props = defineProps({
type: Boolean,
default: false,
},
});
})
function formatTooltipValue(value, props) {
return props.isMoney ? formatMoney(value, false) : formatNumber(value, false);
return props.isMoney ? formatMoney(value, false) : formatNumber(value, false)
}
function generateListEntry(value, index, _, w, props) {
const color = w.globals.colors?.[index];
const color = w.globals.colors?.[index]
return `<div class="list-entry">
<span class="circle" style="background-color: ${color}"></span>
@ -93,35 +93,35 @@ function generateListEntry(value, index, _, w, props) {
<div class="value">
${props.prefix}${formatTooltipValue(value, props)}${props.suffix}
</div>
</div>`;
</div>`
}
function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
const label = w.globals.lastXAxis.categories?.[dataPointIndex];
const label = w.globals.lastXAxis.categories?.[dataPointIndex]
const formattedLabel = props.formatLabels(label);
const formattedLabel = props.formatLabels(label)
let tooltip = `<div class="bar-tooltip">
<div class="seperated-entry title">
<div class="label">${formattedLabel}</div>`;
<div class="label">${formattedLabel}</div>`
// Logic for total and percent stacked
if (!props.hideTotal) {
if (props.percentStacked) {
const total = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0);
const percentValue = (100 * series[seriesIndex][dataPointIndex]) / total;
const total = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
const percentValue = (100 * series[seriesIndex][dataPointIndex]) / total
tooltip += `<div class="value">${props.prefix}${formatNumber(percentValue)}%${
props.suffix
}</div>`;
}</div>`
} else {
const totalValue = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0);
const totalValue = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
tooltip += `<div class="value">${props.prefix}${formatTooltipValue(totalValue, props)}${
props.suffix
}</div>`;
}</div>`
}
}
tooltip += '</div><hr class="card-divider" />';
tooltip += '</div><hr class="card-divider" />'
// Logic for generating list entries
if (props.percentStacked) {
@ -131,9 +131,9 @@ function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
seriesIndex,
w,
props,
);
)
} else {
const returnTopN = 15;
const returnTopN = 15
const listEntries = series
.map((value, index) => [
@ -144,13 +144,13 @@ function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
.sort((a, b) => b[0] - a[0])
.slice(0, returnTopN) // Return only the top X entries
.map((value) => value[1])
.join("");
.join('')
tooltip += listEntries;
tooltip += listEntries
}
tooltip += "</div>";
return tooltip;
tooltip += '</div>'
return tooltip
}
const chartOptions = computed(() => {
@ -158,19 +158,19 @@ const chartOptions = computed(() => {
chart: {
id: props.name,
fontFamily:
"Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif",
foreColor: "var(--color-base)",
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
foreColor: 'var(--color-base)',
selection: {
enabled: true,
fill: {
color: "var(--color-brand)",
color: 'var(--color-brand)',
},
},
toolbar: {
show: false,
},
stacked: props.stacked,
stackType: props.percentStacked ? "100%" : "normal",
stackType: props.percentStacked ? '100%' : 'normal',
zoom: {
autoScaleYaxis: true,
},
@ -183,7 +183,7 @@ const chartOptions = computed(() => {
categories: props.labels,
labels: {
style: {
borderRadius: "var(--radius-sm)",
borderRadius: 'var(--radius-sm)',
},
},
axisTicks: {
@ -207,8 +207,8 @@ const chartOptions = computed(() => {
},
},
grid: {
borderColor: "var(--color-button-bg)",
tickColor: "var(--color-button-bg)",
borderColor: 'var(--color-button-bg)',
tickColor: 'var(--color-button-bg)',
},
legend: {
show: !props.hideLegend,
@ -216,16 +216,16 @@ const chartOptions = computed(() => {
showForZeroSeries: false,
showForSingleSeries: false,
showForNullSeries: false,
fontSize: "var(--font-size-nm)",
fontSize: 'var(--font-size-nm)',
fontFamily:
"Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif",
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
onItemClick: {
toggleDataSeries: true,
},
},
markers: {
size: 0,
strokeColor: "var(--color-contrast)",
strokeColor: 'var(--color-contrast)',
strokeWidth: 3,
strokeOpacity: 1,
fillOpacity: 1,
@ -236,29 +236,29 @@ const chartOptions = computed(() => {
plotOptions: {
bar: {
horizontal: props.horizontalBar,
columnWidth: "80%",
endingShape: "rounded",
columnWidth: '80%',
endingShape: 'rounded',
borderRadius: 5,
borderRadiusApplication: "end",
borderRadiusWhenStacked: "last",
borderRadiusApplication: 'end',
borderRadiusWhenStacked: 'last',
},
},
stroke: {
curve: "smooth",
curve: 'smooth',
width: 2,
},
tooltip: {
custom: (d) => generateTooltip(d, props),
},
fill:
props.type === "area"
props.type === 'area'
? {
colors: props.colors,
type: "gradient",
type: 'gradient',
opacity: 1,
gradient: {
shade: "light",
type: "vertical",
shade: 'light',
type: 'vertical',
shadeIntensity: 0,
gradientToColors: props.colors,
inverseColors: true,
@ -269,40 +269,40 @@ const chartOptions = computed(() => {
},
}
: {},
};
});
}
})
const chart = ref(null);
const chart = ref(null)
const legendValues = ref(
[...props.data].map((project, index) => {
return { name: project.name, visible: true, color: props.colors[index] };
return { name: project.name, visible: true, color: props.colors[index] }
}),
);
)
const flipLegend = (legend, newVal) => {
legend.visible = newVal;
chart.value.toggleSeries(legend.name);
};
legend.visible = newVal
chart.value.toggleSeries(legend.name)
}
const resetChart = () => {
if (!chart.value) return;
chart.value.updateSeries([...props.data]);
if (!chart.value) return
chart.value.updateSeries([...props.data])
chart.value.updateOptions({
xaxis: {
categories: props.labels,
},
});
chart.value.resetSeries();
})
chart.value.resetSeries()
legendValues.value.forEach((legend) => {
legend.visible = true;
});
};
legend.visible = true
})
}
defineExpose({
resetChart,
flipLegend,
});
})
</script>
<template>

View File

@ -304,28 +304,28 @@
</template>
<script setup lang="ts">
import { DownloadIcon,UpdatedIcon } from "@modrinth/assets";
import { Button, Card, DropdownSelect } from "@modrinth/ui";
import { formatCategoryHeader,formatMoney, formatNumber } from "@modrinth/utils";
import dayjs from "dayjs";
import { computed } from "vue";
import { DownloadIcon, UpdatedIcon } from '@modrinth/assets'
import { Button, Card, DropdownSelect } from '@modrinth/ui'
import { formatCategoryHeader, formatMoney, formatNumber } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { UiChartsChart as Chart,UiChartsCompactChart as CompactChart } from "#components";
import PaletteIcon from "~/assets/icons/palette.svg?component";
import { analyticsSetToCSVString, intToRgba } from "~/utils/analytics.js";
import { UiChartsChart as Chart, UiChartsCompactChart as CompactChart } from '#components'
import PaletteIcon from '~/assets/icons/palette.svg?component'
import { analyticsSetToCSVString, intToRgba } from '~/utils/analytics.js'
const router = useNativeRouter();
const theme = useTheme();
const router = useNativeRouter()
const theme = useTheme()
const props = withDefaults(
defineProps<{
projects?: any[];
projects?: any[]
/**
* @deprecated Use `ranges` instead
*/
resoloutions?: Record<string, number>;
ranges?: RangeObject[];
personal?: boolean;
resoloutions?: Record<string, number>
ranges?: RangeObject[]
personal?: boolean
}>(),
{
projects: undefined,
@ -333,19 +333,19 @@ const props = withDefaults(
ranges: () => defaultRanges,
personal: false,
},
);
)
const projects = ref(props.projects || []);
const projects = ref(props.projects || [])
// const selectedChart = ref('downloads')
const selectedChart = computed({
get: () => {
const id = (router.currentRoute.value.query?.chart as string | undefined) || "downloads";
const id = (router.currentRoute.value.query?.chart as string | undefined) || 'downloads'
// if the id is anything but the 3 charts we have or undefined, throw an error
if (!["downloads", "views", "revenue"].includes(id)) {
throw new Error(`Unknown chart ${id}`);
if (!['downloads', 'views', 'revenue'].includes(id)) {
throw new Error(`Unknown chart ${id}`)
}
return id;
return id
},
set: (chart) => {
router.push({
@ -353,119 +353,119 @@ const selectedChart = computed({
...router.currentRoute.value.query,
chart,
},
});
})
},
});
})
// Chart refs
const downloadsChart = ref();
const viewsChart = ref();
const revenueChart = ref();
const tinyDownloadChart = ref();
const tinyViewChart = ref();
const tinyRevenueChart = ref();
const downloadsChart = ref()
const viewsChart = ref()
const revenueChart = ref()
const tinyDownloadChart = ref()
const tinyViewChart = ref()
const tinyRevenueChart = ref()
const selectedDisplayProjects = ref(props.projects || []);
const selectedDisplayProjects = ref(props.projects || [])
const removeProjectFromDisplay = (id: string) => {
selectedDisplayProjects.value = selectedDisplayProjects.value.filter((p) => p.id !== id);
};
selectedDisplayProjects.value = selectedDisplayProjects.value.filter((p) => p.id !== id)
}
const addProjectToDisplay = (id: string) => {
selectedDisplayProjects.value = [
...selectedDisplayProjects.value,
props.projects?.find((p) => p.id === id),
].filter(Boolean);
};
].filter(Boolean)
}
const projectIsOnDisplay = (id: string) => {
return selectedDisplayProjects.value?.some((p) => p.id === id) ?? false;
};
return selectedDisplayProjects.value?.some((p) => p.id === id) ?? false
}
const resetCharts = () => {
downloadsChart.value?.resetChart();
viewsChart.value?.resetChart();
revenueChart.value?.resetChart();
downloadsChart.value?.resetChart()
viewsChart.value?.resetChart()
revenueChart.value?.resetChart()
tinyDownloadChart.value?.resetChart();
tinyViewChart.value?.resetChart();
tinyRevenueChart.value?.resetChart();
};
tinyDownloadChart.value?.resetChart()
tinyViewChart.value?.resetChart()
tinyRevenueChart.value?.resetChart()
}
const isUsingProjectColors = computed({
get: () => {
return (
router.currentRoute.value.query?.colors === "true" ||
router.currentRoute.value.query?.colors === 'true' ||
router.currentRoute.value.query?.colors === undefined
);
)
},
set: (newValue) => {
router.push({
query: {
...router.currentRoute.value.query,
colors: newValue ? "true" : "false",
colors: newValue ? 'true' : 'false',
},
});
})
},
});
})
const startDate = ref(dayjs().startOf("day"));
const endDate = ref(dayjs().endOf("day"));
const timeResolution = ref(30);
const startDate = ref(dayjs().startOf('day'))
const endDate = ref(dayjs().endOf('day'))
const timeResolution = ref(30)
onBeforeMount(() => {
// Load cached data and range from localStorage - cache.
if (import.meta.client) {
const rangeLabel = localStorage.getItem("analyticsSelectedRange");
const rangeLabel = localStorage.getItem('analyticsSelectedRange')
if (rangeLabel) {
const range = props.ranges.find((r) => r.getLabel([dayjs(), dayjs()]) === rangeLabel)!;
const range = props.ranges.find((r) => r.getLabel([dayjs(), dayjs()]) === rangeLabel)!
if (range !== undefined) {
internalRange.value = range;
const ranges = range.getDates(dayjs());
timeResolution.value = range.timeResolution;
startDate.value = ranges.startDate;
endDate.value = ranges.endDate;
internalRange.value = range
const ranges = range.getDates(dayjs())
timeResolution.value = range.timeResolution
startDate.value = ranges.startDate
endDate.value = ranges.endDate
}
}
}
});
})
onMounted(() => {
if (internalRange.value === null) {
internalRange.value = props.ranges.find(
(r) => r.getLabel([dayjs(), dayjs()]) === "Previous 30 days",
)!;
(r) => r.getLabel([dayjs(), dayjs()]) === 'Previous 30 days',
)!
}
const ranges = selectedRange.value.getDates(dayjs());
startDate.value = ranges.startDate;
endDate.value = ranges.endDate;
timeResolution.value = selectedRange.value.timeResolution;
});
const ranges = selectedRange.value.getDates(dayjs())
startDate.value = ranges.startDate
endDate.value = ranges.endDate
timeResolution.value = selectedRange.value.timeResolution
})
const internalRange: Ref<RangeObject> = ref(null as unknown as RangeObject);
const internalRange: Ref<RangeObject> = ref(null as unknown as RangeObject)
const selectedRange = computed({
get: () => {
return internalRange.value;
return internalRange.value
},
set: (newRange) => {
const ranges = newRange.getDates(dayjs());
startDate.value = ranges.startDate;
endDate.value = ranges.endDate;
timeResolution.value = newRange.timeResolution;
const ranges = newRange.getDates(dayjs())
startDate.value = ranges.startDate
endDate.value = ranges.endDate
timeResolution.value = newRange.timeResolution
internalRange.value = newRange;
internalRange.value = newRange
if (import.meta.client) {
localStorage.setItem(
"analyticsSelectedRange",
internalRange.value?.getLabel([dayjs(), dayjs()]) ?? "Previous 30 days",
);
'analyticsSelectedRange',
internalRange.value?.getLabel([dayjs(), dayjs()]) ?? 'Previous 30 days',
)
}
},
});
})
const analytics = useFetchAllAnalytics(
resetCharts,
@ -475,53 +475,53 @@ const analytics = useFetchAllAnalytics(
startDate,
endDate,
timeResolution,
);
)
const formattedCategorySubtitle = computed(() => {
return (
selectedRange.value?.getLabel([dayjs(startDate.value), dayjs(endDate.value)]) ?? "Loading..."
);
});
selectedRange.value?.getLabel([dayjs(startDate.value), dayjs(endDate.value)]) ?? 'Loading...'
)
})
const selectedDataSet = computed(() => {
switch (selectedChart.value) {
case "downloads":
return analytics.totalData.value.downloads;
case "views":
return analytics.totalData.value.views;
case "revenue":
return analytics.totalData.value.revenue;
case 'downloads':
return analytics.totalData.value.downloads
case 'views':
return analytics.totalData.value.views
case 'revenue':
return analytics.totalData.value.revenue
default:
throw new Error(`Unknown chart ${selectedChart.value}`);
throw new Error(`Unknown chart ${selectedChart.value}`)
}
});
})
const selectedDataSetProjects = computed(() => {
return selectedDataSet.value.projectIds
.map((id) => props.projects?.find((p) => p?.id === id))
.filter(Boolean);
});
.filter(Boolean)
})
const downloadSelectedSetAsCSV = () => {
const selectedChartName = selectedChart.value;
const selectedChartName = selectedChart.value
const csv = analyticsSetToCSVString(selectedDataSet.value);
const csv = analyticsSetToCSVString(selectedDataSet.value)
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", `${selectedChartName}-data.csv`);
link.style.visibility = "hidden";
document.body.appendChild(link);
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `${selectedChartName}-data.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click();
};
link.click()
}
const onDownloadSetAsCSV = useClientTry(async () => await downloadSelectedSetAsCSV());
const onDownloadSetAsCSV = useClientTry(async () => await downloadSelectedSetAsCSV())
const onToggleColors = () => {
isUsingProjectColors.value = !isUsingProjectColors.value;
};
isUsingProjectColors.value = !isUsingProjectColors.value
}
</script>
<script lang="ts">
@ -529,177 +529,177 @@ const onToggleColors = () => {
* @deprecated Use `ranges` instead
*/
const defaultResoloutions: Record<string, number> = {
"5 minutes": 5,
"30 minutes": 30,
"An hour": 60,
"12 hours": 720,
"A day": 1440,
"A week": 10080,
};
'5 minutes': 5,
'30 minutes': 30,
'An hour': 60,
'12 hours': 720,
'A day': 1440,
'A week': 10080,
}
type DateRange = { startDate: dayjs.Dayjs; endDate: dayjs.Dayjs };
type DateRange = { startDate: dayjs.Dayjs; endDate: dayjs.Dayjs }
type RangeObject = {
getLabel: (dateRange: [dayjs.Dayjs, dayjs.Dayjs]) => string;
getDates: (currentDate: dayjs.Dayjs) => DateRange;
getLabel: (dateRange: [dayjs.Dayjs, dayjs.Dayjs]) => string
getDates: (currentDate: dayjs.Dayjs) => DateRange
// A time resolution in minutes.
timeResolution: number;
};
timeResolution: number
}
const defaultRanges: RangeObject[] = [
{
getLabel: () => "Previous 30 minutes",
getLabel: () => 'Previous 30 minutes',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(30, "minute"),
startDate: dayjs(currentDate).subtract(30, 'minute'),
endDate: currentDate,
}),
timeResolution: 1,
},
{
getLabel: () => "Previous hour",
getLabel: () => 'Previous hour',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(1, "hour"),
startDate: dayjs(currentDate).subtract(1, 'hour'),
endDate: currentDate,
}),
timeResolution: 5,
},
{
getLabel: () => "Previous 12 hours",
getLabel: () => 'Previous 12 hours',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(12, "hour"),
startDate: dayjs(currentDate).subtract(12, 'hour'),
endDate: currentDate,
}),
timeResolution: 12,
},
{
getLabel: () => "Previous 24 hours",
getLabel: () => 'Previous 24 hours',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(1, "day"),
startDate: dayjs(currentDate).subtract(1, 'day'),
endDate: currentDate,
}),
timeResolution: 30,
},
{
getLabel: () => "Today",
getLabel: () => 'Today',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).startOf("day"),
startDate: dayjs(currentDate).startOf('day'),
endDate: currentDate,
}),
timeResolution: 30,
},
{
getLabel: () => "Yesterday",
getLabel: () => 'Yesterday',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(1, "day").startOf("day"),
endDate: dayjs(currentDate).startOf("day").subtract(1, "second"),
startDate: dayjs(currentDate).subtract(1, 'day').startOf('day'),
endDate: dayjs(currentDate).startOf('day').subtract(1, 'second'),
}),
timeResolution: 30,
},
{
getLabel: () => "This week",
getLabel: () => 'This week',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).startOf("week").add(1, "hour"),
startDate: dayjs(currentDate).startOf('week').add(1, 'hour'),
endDate: currentDate,
}),
timeResolution: 360,
},
{
getLabel: () => "Last week",
getLabel: () => 'Last week',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(1, "week").startOf("week").add(1, "hour"),
endDate: dayjs(currentDate).startOf("week").subtract(1, "second"),
startDate: dayjs(currentDate).subtract(1, 'week').startOf('week').add(1, 'hour'),
endDate: dayjs(currentDate).startOf('week').subtract(1, 'second'),
}),
timeResolution: 1440,
},
{
getLabel: () => "Previous 7 days",
getLabel: () => 'Previous 7 days',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).startOf("day").subtract(7, "day").add(1, "hour"),
endDate: currentDate.startOf("day"),
startDate: dayjs(currentDate).startOf('day').subtract(7, 'day').add(1, 'hour'),
endDate: currentDate.startOf('day'),
}),
timeResolution: 720,
},
{
getLabel: () => "This month",
getLabel: () => 'This month',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).startOf("month").add(1, "hour"),
startDate: dayjs(currentDate).startOf('month').add(1, 'hour'),
endDate: currentDate,
}),
timeResolution: 1440,
},
{
getLabel: () => "Last month",
getLabel: () => 'Last month',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(1, "month").startOf("month").add(1, "hour"),
endDate: dayjs(currentDate).startOf("month").subtract(1, "second"),
startDate: dayjs(currentDate).subtract(1, 'month').startOf('month').add(1, 'hour'),
endDate: dayjs(currentDate).startOf('month').subtract(1, 'second'),
}),
timeResolution: 1440,
},
{
getLabel: () => "Previous 30 days",
getLabel: () => 'Previous 30 days',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).startOf("day").subtract(30, "day").add(1, "hour"),
endDate: currentDate.startOf("day"),
startDate: dayjs(currentDate).startOf('day').subtract(30, 'day').add(1, 'hour'),
endDate: currentDate.startOf('day'),
}),
timeResolution: 1440,
},
{
getLabel: () => "This quarter",
getLabel: () => 'This quarter',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).startOf("quarter").add(1, "hour"),
startDate: dayjs(currentDate).startOf('quarter').add(1, 'hour'),
endDate: currentDate,
}),
timeResolution: 1440,
},
{
getLabel: () => "Last quarter",
getLabel: () => 'Last quarter',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(1, "quarter").startOf("quarter").add(1, "hour"),
endDate: dayjs(currentDate).startOf("quarter").subtract(1, "second"),
startDate: dayjs(currentDate).subtract(1, 'quarter').startOf('quarter').add(1, 'hour'),
endDate: dayjs(currentDate).startOf('quarter').subtract(1, 'second'),
}),
timeResolution: 1440,
},
{
getLabel: () => "This year",
getLabel: () => 'This year',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).startOf("year"),
startDate: dayjs(currentDate).startOf('year'),
endDate: currentDate,
}),
timeResolution: 20160,
},
{
getLabel: () => "Last year",
getLabel: () => 'Last year',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(1, "year").startOf("year"),
endDate: dayjs(currentDate).startOf("year").subtract(1, "second"),
startDate: dayjs(currentDate).subtract(1, 'year').startOf('year'),
endDate: dayjs(currentDate).startOf('year').subtract(1, 'second'),
}),
timeResolution: 20160,
},
{
getLabel: () => "Previous year",
getLabel: () => 'Previous year',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(1, "year"),
startDate: dayjs(currentDate).subtract(1, 'year'),
endDate: dayjs(currentDate),
}),
timeResolution: 40320,
},
{
getLabel: () => "Previous two years",
getLabel: () => 'Previous two years',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(currentDate).subtract(2, "year"),
startDate: dayjs(currentDate).subtract(2, 'year'),
endDate: currentDate,
}),
timeResolution: 40320,
},
{
getLabel: () => "All Time",
getLabel: () => 'All Time',
getDates: (currentDate: dayjs.Dayjs) => ({
startDate: dayjs(0),
endDate: currentDate,
}),
timeResolution: 40320,
},
];
]
</script>
<style scoped lang="scss">
@ -885,7 +885,7 @@ const defaultRanges: RangeObject[] = [
.country-value {
display: grid;
grid-template-areas: "flag text bar";
grid-template-areas: 'flag text bar';
grid-template-columns: auto 1fr 10rem;
align-items: center;
justify-content: space-between;

View File

@ -1,6 +1,6 @@
<script setup>
import { Card } from "@modrinth/ui";
import VueApexCharts from "vue3-apexcharts";
import { Card } from '@modrinth/ui'
import VueApexCharts from 'vue3-apexcharts'
// let VueApexCharts
// if (import.meta.client) {
@ -10,11 +10,11 @@ import VueApexCharts from "vue3-apexcharts";
const props = defineProps({
value: {
type: String,
default: "",
default: '',
},
title: {
type: String,
default: "",
default: '',
},
data: {
type: Array,
@ -26,11 +26,11 @@ const props = defineProps({
},
prefix: {
type: String,
default: "",
default: '',
},
suffix: {
type: String,
default: "",
default: '',
},
isMoney: {
type: Boolean,
@ -38,17 +38,17 @@ const props = defineProps({
},
color: {
type: String,
default: "var(--color-brand)",
default: 'var(--color-brand)',
},
});
})
// no grid lines, no toolbar, no legend, no data labels
const chartOptions = {
chart: {
id: props.title,
fontFamily:
"Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif",
foreColor: "var(--color-base)",
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
foreColor: 'var(--color-base)',
toolbar: {
show: false,
},
@ -61,16 +61,16 @@ const chartOptions = {
parentHeightOffset: 0,
},
stroke: {
curve: "smooth",
curve: 'smooth',
width: 2,
},
fill: {
colors: [props.color],
type: "gradient",
type: 'gradient',
opacity: 1,
gradient: {
shade: "light",
type: "vertical",
shade: 'light',
type: 'vertical',
shadeIntensity: 0,
gradientToColors: [props.color],
inverseColors: true,
@ -91,7 +91,7 @@ const chartOptions = {
enabled: false,
},
xaxis: {
type: "datetime",
type: 'datetime',
categories: props.labels,
labels: {
show: false,
@ -120,23 +120,23 @@ const chartOptions = {
tooltip: {
enabled: false,
},
};
}
const chart = ref(null);
const chart = ref(null)
const resetChart = () => {
chart.value?.updateSeries([...props.data]);
chart.value?.updateSeries([...props.data])
chart.value?.updateOptions({
xaxis: {
categories: props.labels,
},
});
chart.value?.resetSeries();
};
})
chart.value?.resetSeries()
}
defineExpose({
resetChart,
});
})
</script>
<template>

View File

@ -47,7 +47,7 @@
<span class="max-w-[200px] truncate font-mono text-xs sm:max-w-none">
{{
report.version.files.find((file) => file.primary)?.filename ||
"Unknown primary file"
'Unknown primary file'
}}
</span>
</div>
@ -124,56 +124,56 @@ import {
EyeIcon,
LinkIcon,
OrganizationIcon,
} from "@modrinth/assets";
import type { ExtendedDelphiReport } from "@modrinth/moderation";
} from '@modrinth/assets'
import type { ExtendedDelphiReport } from '@modrinth/moderation'
import {
Avatar,
ButtonStyled,
OverflowMenu,
type OverflowMenuOption,
useRelativeTime,
} from "@modrinth/ui";
import dayjs from "dayjs";
} from '@modrinth/ui'
import dayjs from 'dayjs'
const props = defineProps<{
report: ExtendedDelphiReport;
}>();
report: ExtendedDelphiReport
}>()
const formatRelativeTime = useRelativeTime();
const isPending = computed(() => props.report.status === "pending");
const formatRelativeTime = useRelativeTime()
const isPending = computed(() => props.report.status === 'pending')
const quickActions: OverflowMenuOption[] = [
{
id: "copy-link",
id: 'copy-link',
action: () => {
const base = window.location.origin;
const reviewUrl = `${base}/moderation/tech-reviews?q=${props.report.version.id}`;
const base = window.location.origin
const reviewUrl = `${base}/moderation/tech-reviews?q=${props.report.version.id}`
navigator.clipboard.writeText(reviewUrl).then(() => {
addNotification({
type: "success",
title: "Tech review link copied",
text: "The link to this tech review has been copied to your clipboard.",
});
});
type: 'success',
title: 'Tech review link copied',
text: 'The link to this tech review has been copied to your clipboard.',
})
})
},
},
{
id: "copy-id",
id: 'copy-id',
action: () => {
navigator.clipboard.writeText(props.report.version.id).then(() => {
addNotification({
type: "success",
title: "Version ID copied",
text: "The ID of this version has been copied to your clipboard.",
});
});
type: 'success',
title: 'Version ID copied',
text: 'The ID of this version has been copied to your clipboard.',
})
})
},
},
];
]
const versionUrl = computed(() => {
return `/${props.report.project.project_type}/${props.report.project.slug}/versions/${props.report.version.id}`;
});
return `/${props.report.project.project_type}/${props.report.project.slug}/versions/${props.report.version.id}`
})
</script>
<style lang="scss" scoped></style>

View File

@ -29,56 +29,56 @@
</template>
<script setup lang="ts">
import { type KeybindListener, keybinds, normalizeKeybind } from "@modrinth/moderation";
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
import { ref } from "vue";
import { type KeybindListener, keybinds, normalizeKeybind } from '@modrinth/moderation'
import NewModal from '@modrinth/ui/src/components/modal/NewModal.vue'
import { ref } from 'vue'
const modal = ref<InstanceType<typeof NewModal>>();
const modal = ref<InstanceType<typeof NewModal>>()
function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
const keybinds = Array.isArray(keybind) ? keybind : [keybind];
const normalized = keybinds[0];
const def = normalizeKeybind(normalized);
function parseKeybindDisplay(keybind: KeybindListener['keybind']): string[] {
const keybinds = Array.isArray(keybind) ? keybind : [keybind]
const normalized = keybinds[0]
const def = normalizeKeybind(normalized)
const keys = [];
const keys = []
if (def.ctrl || def.meta) {
keys.push(isMac() ? "CMD" : "CTRL");
keys.push(isMac() ? 'CMD' : 'CTRL')
}
if (def.shift) keys.push("SHIFT");
if (def.alt) keys.push("ALT");
if (def.shift) keys.push('SHIFT')
if (def.alt) keys.push('ALT')
const mainKey = def.key
.replace("ArrowLeft", "←")
.replace("ArrowRight", "→")
.replace("ArrowUp", "↑")
.replace("ArrowDown", "↓")
.replace("Enter", "↵")
.replace("Space", "SPACE")
.replace("Escape", "ESC")
.toUpperCase();
.replace('ArrowLeft', '←')
.replace('ArrowRight', '→')
.replace('ArrowUp', '↑')
.replace('ArrowDown', '↓')
.replace('Enter', '↵')
.replace('Space', 'SPACE')
.replace('Escape', 'ESC')
.toUpperCase()
keys.push(mainKey);
keys.push(mainKey)
return keys;
return keys
}
function isMac() {
return navigator.platform.toUpperCase().includes("MAC");
return navigator.platform.toUpperCase().includes('MAC')
}
function show(event?: MouseEvent) {
modal.value?.show(event);
modal.value?.show(event)
}
function hide() {
modal.value?.hide();
modal.value?.hide()
}
defineExpose({
show,
hide,
});
})
</script>
<style scoped lang="scss">

View File

@ -326,9 +326,14 @@ import {
XIcon,
} from '@modrinth/assets'
import {
type Action,
type ButtonAction,
checklist,
type ConditionalButtonAction,
deserializeActionStates,
type DropdownAction,
expandVariables,
finalPermissionMessages,
findMatchingVariant,
flattenProjectVariables,
getActionIdForStage,
@ -338,16 +343,9 @@ import {
initializeActionState,
kebabToTitleCase,
keybinds,
type MultiSelectChipsAction,
processMessage,
serializeActionStates,
} from '@modrinth/moderation'
import {
type Action,
type ButtonAction,
type ConditionalButtonAction,
type DropdownAction,
finalPermissionMessages,
type MultiSelectChipsAction,
type Stage,
type ToggleAction,
} from '@modrinth/moderation'
@ -363,8 +361,8 @@ import {
import {
type ModerationJudgements,
type ModerationModpackItem,
type ProjectStatus,
type Project,
type ProjectStatus,
renderHighlightedString,
} from '@modrinth/utils'
import { computedAsync, useLocalStorage } from '@vueuse/core'

View File

@ -137,7 +137,7 @@
<ButtonStyled v-if="modPackData && currentIndex < modPackData.length" color="blue">
<button :disabled="!canGoNext" @click="goToNext">
<RightArrowIcon aria-hidden="true" />
{{ currentIndex + 1 >= modPackData.length ? "Complete" : "Next" }}
{{ currentIndex + 1 >= modPackData.length ? 'Complete' : 'Next' }}
</button>
</ButtonStyled>
</div>
@ -145,8 +145,8 @@
</template>
<script setup lang="ts">
import { LeftArrowIcon, RightArrowIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { LeftArrowIcon, RightArrowIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import type {
ModerationFlameModpackItem,
ModerationJudgements,
@ -155,19 +155,19 @@ import type {
ModerationModpackResponse,
ModerationPermissionType,
ModerationUnknownModpackItem,
} from "@modrinth/utils";
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
import { computed, onMounted,ref, watch } from "vue";
} from '@modrinth/utils'
import { useLocalStorage, useSessionStorage } from '@vueuse/core'
import { computed, onMounted, ref, watch } from 'vue'
const props = defineProps<{
projectId: string;
modelValue?: ModerationJudgements;
}>();
projectId: string
modelValue?: ModerationJudgements
}>()
const emit = defineEmits<{
complete: [];
"update:modelValue": [judgements: ModerationJudgements];
}>();
complete: []
'update:modelValue': [judgements: ModerationJudgements]
}>()
const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
`modpack-permissions-${props.projectId}`,
@ -178,9 +178,9 @@ const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
write: (v: any) => JSON.stringify(v),
},
},
);
)
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0)
const modPackData = useSessionStorage<ModerationModpackItem[] | null>(
`modpack-permissions-data-${props.projectId}`,
@ -191,7 +191,7 @@ const modPackData = useSessionStorage<ModerationModpackItem[] | null>(
write: (v: any) => JSON.stringify(v),
},
},
);
)
const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>(
`modpack-permissions-permanent-no-${props.projectId}`,
[],
@ -201,110 +201,110 @@ const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>(
write: (v: any) => JSON.stringify(v),
},
},
);
const currentIndex = ref(0);
)
const currentIndex = ref(0)
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
{
id: "yes",
name: "Yes",
id: 'yes',
name: 'Yes',
},
{
id: "with-attribution-and-source",
name: "With attribution and source",
id: 'with-attribution-and-source',
name: 'With attribution and source',
},
{
id: "with-attribution",
name: "With attribution",
id: 'with-attribution',
name: 'With attribution',
},
{
id: "no",
name: "No",
id: 'no',
name: 'No',
},
{
id: "permanent-no",
name: "Permanent no",
id: 'permanent-no',
name: 'Permanent no',
},
{
id: "unidentified",
name: "Unidentified",
id: 'unidentified',
name: 'Unidentified',
},
];
]
const filePermissionTypes: ModerationPermissionType[] = [
{ id: "yes", name: "Yes" },
{ id: "no", name: "No" },
];
{ id: 'yes', name: 'Yes' },
{ id: 'no', name: 'No' },
]
function persistAll() {
persistedModPackData.value = modPackData.value;
persistedIndex.value = currentIndex.value;
persistedModPackData.value = modPackData.value
persistedIndex.value = currentIndex.value
}
watch(
modPackData,
(newValue) => {
persistedModPackData.value = newValue;
persistedModPackData.value = newValue
},
{ deep: true },
);
)
watch(currentIndex, (newValue) => {
persistedIndex.value = newValue;
});
persistedIndex.value = newValue
})
function loadPersistedData(): void {
if (persistedModPackData.value) {
modPackData.value = persistedModPackData.value;
modPackData.value = persistedModPackData.value
}
currentIndex.value = persistedIndex.value;
currentIndex.value = persistedIndex.value
}
function clearPersistedData(): void {
persistedModPackData.value = null;
persistedIndex.value = 0;
persistedModPackData.value = null
persistedIndex.value = 0
}
async function fetchModPackData(): Promise<void> {
try {
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
internal: true,
})) as ModerationModpackResponse;
})) as ModerationModpackResponse
const permanentNoItems: ModerationModpackItem[] = Object.entries(data.identified || {})
.filter(([_, file]) => file.status === "permanent-no")
.filter(([_, file]) => file.status === 'permanent-no')
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: "identified",
type: 'identified',
status: file.status,
approved: null,
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name));
.sort((a, b) => a.file_name.localeCompare(b.file_name))
permanentNoFiles.value = permanentNoItems;
permanentNoFiles.value = permanentNoItems
const sortedData: ModerationModpackItem[] = [
...Object.entries(data.identified || {})
.filter(
([_, file]) =>
file.status !== "yes" &&
file.status !== "with-attribution-and-source" &&
file.status !== "permanent-no",
file.status !== 'yes' &&
file.status !== 'with-attribution-and-source' &&
file.status !== 'permanent-no',
)
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: "identified",
type: 'identified',
status: file.status,
approved: null,
...(file.status === "unidentified" && {
proof: "",
url: "",
title: "",
...(file.status === 'unidentified' && {
proof: '',
url: '',
title: '',
}),
}),
)
@ -314,12 +314,12 @@ async function fetchModPackData(): Promise<void> {
([sha1, fileName]): ModerationUnknownModpackItem => ({
sha1,
file_name: fileName,
type: "unknown",
type: 'unknown',
status: null,
approved: null,
proof: "",
url: "",
title: "",
proof: '',
url: '',
title: '',
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
@ -328,7 +328,7 @@ async function fetchModPackData(): Promise<void> {
([sha1, info]): ModerationFlameModpackItem => ({
sha1,
file_name: info.file_name,
type: "flame",
type: 'flame',
status: null,
approved: null,
id: info.id,
@ -337,166 +337,166 @@ async function fetchModPackData(): Promise<void> {
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
];
]
if (modPackData.value) {
const existingMap = new Map(modPackData.value.map((item) => [item.sha1, item]));
const existingMap = new Map(modPackData.value.map((item) => [item.sha1, item]))
sortedData.forEach((item) => {
const existing = existingMap.get(item.sha1);
const existing = existingMap.get(item.sha1)
if (existing) {
Object.assign(item, {
status: existing.status,
approved: existing.approved,
...(item.type === "unknown" && {
proof: (existing as ModerationUnknownModpackItem).proof || "",
url: (existing as ModerationUnknownModpackItem).url || "",
title: (existing as ModerationUnknownModpackItem).title || "",
...(item.type === 'unknown' && {
proof: (existing as ModerationUnknownModpackItem).proof || '',
url: (existing as ModerationUnknownModpackItem).url || '',
title: (existing as ModerationUnknownModpackItem).title || '',
}),
...(item.type === "flame" && {
...(item.type === 'flame' && {
url: (existing as ModerationFlameModpackItem).url || item.url,
title: (existing as ModerationFlameModpackItem).title || item.title,
}),
});
})
}
});
})
}
modPackData.value = sortedData;
persistAll();
modPackData.value = sortedData
persistAll()
} catch (error) {
console.error("Failed to fetch modpack data:", error);
modPackData.value = [];
permanentNoFiles.value = [];
persistAll();
console.error('Failed to fetch modpack data:', error)
modPackData.value = []
permanentNoFiles.value = []
persistAll()
}
}
function goToPrevious(): void {
if (currentIndex.value > 0) {
currentIndex.value--;
persistAll();
currentIndex.value--
persistAll()
}
}
watch(
modPackData,
(newValue) => {
persistedModPackData.value = newValue;
persistedModPackData.value = newValue
},
{ deep: true },
);
)
function goToNext(): void {
if (modPackData.value && currentIndex.value < modPackData.value.length) {
currentIndex.value++;
currentIndex.value++
if (currentIndex.value >= modPackData.value.length) {
const judgements = getJudgements();
emit("update:modelValue", judgements);
emit("complete");
clearPersistedData();
const judgements = getJudgements()
emit('update:modelValue', judgements)
emit('complete')
clearPersistedData()
} else {
persistAll();
persistAll()
}
}
}
function setStatus(index: number, status: ModerationModpackPermissionApprovalType["id"]): void {
function setStatus(index: number, status: ModerationModpackPermissionApprovalType['id']): void {
if (modPackData.value && modPackData.value[index]) {
modPackData.value[index].status = status;
modPackData.value[index].approved = null;
persistAll();
emit("update:modelValue", getJudgements());
modPackData.value[index].status = status
modPackData.value[index].approved = null
persistAll()
emit('update:modelValue', getJudgements())
}
}
function setApproval(index: number, approved: ModerationPermissionType["id"]): void {
function setApproval(index: number, approved: ModerationPermissionType['id']): void {
if (modPackData.value && modPackData.value[index]) {
modPackData.value[index].approved = approved;
persistAll();
emit("update:modelValue", getJudgements());
modPackData.value[index].approved = approved
persistAll()
emit('update:modelValue', getJudgements())
}
}
const canGoNext = computed(() => {
if (!modPackData.value || !modPackData.value[currentIndex.value]) return false;
const current = modPackData.value[currentIndex.value];
return current.status !== null;
});
if (!modPackData.value || !modPackData.value[currentIndex.value]) return false
const current = modPackData.value[currentIndex.value]
return current.status !== null
})
function getJudgements(): ModerationJudgements {
if (!modPackData.value) return {};
if (!modPackData.value) return {}
const judgements: ModerationJudgements = {};
const judgements: ModerationJudgements = {}
modPackData.value.forEach((item) => {
if (item.type === "flame") {
if (item.type === 'flame') {
judgements[item.sha1] = {
type: "flame",
type: 'flame',
id: item.id,
status: item.status,
link: item.url,
title: item.title,
file_name: item.file_name,
};
} else if (item.type === "unknown") {
}
} else if (item.type === 'unknown') {
judgements[item.sha1] = {
type: "unknown",
type: 'unknown',
status: item.status,
proof: item.proof,
link: item.url,
title: item.title,
file_name: item.file_name,
};
}
}
});
})
return judgements;
return judgements
}
onMounted(() => {
loadPersistedData();
loadPersistedData()
if (!modPackData.value) {
fetchModPackData();
fetchModPackData()
}
});
})
watch(
modPackData,
(newValue) => {
if (newValue && newValue.length === 0) {
emit("complete");
clearPersistedData();
emit('complete')
clearPersistedData()
}
},
{ immediate: true },
);
)
watch(
() => props.projectId,
() => {
clearPersistedData();
loadPersistedData();
clearPersistedData()
loadPersistedData()
if (!modPackData.value) {
fetchModPackData();
fetchModPackData()
}
},
);
)
function getModpackFiles(): {
interactive: ModerationModpackItem[];
permanentNo: ModerationModpackItem[];
interactive: ModerationModpackItem[]
permanentNo: ModerationModpackItem[]
} {
return {
interactive: modPackData.value || [],
permanentNo: permanentNoFiles.value,
};
}
}
defineExpose({
getModpackFiles,
});
})
</script>
<style scoped>

View File

@ -1,9 +1,9 @@
import { computed, defineComponent, h, onBeforeUnmount, ref, watch } from "vue";
import { computed, defineComponent, h, onBeforeUnmount, ref, watch } from 'vue'
import { startLoading, stopLoading, useNuxtApp } from "#imports";
import { startLoading, stopLoading, useNuxtApp } from '#imports'
export default defineComponent({
name: "ModrinthLoadingIndicator",
name: 'ModrinthLoadingIndicator',
props: {
throttle: {
type: Number,
@ -20,115 +20,115 @@ export default defineComponent({
color: {
type: [String, Boolean],
default:
"repeating-linear-gradient(to right, var(--color-green) 0%, var(--landing-green-label) 100%)",
'repeating-linear-gradient(to right, var(--color-green) 0%, var(--landing-green-label) 100%)',
},
},
setup(props, { slots }) {
const indicator = useLoadingIndicator({
duration: props.duration,
throttle: props.throttle,
});
})
const nuxtApp = useNuxtApp();
nuxtApp.hook("page:start", () => {
startLoading();
indicator.start();
});
nuxtApp.hook("page:finish", () => {
stopLoading();
indicator.finish();
});
onBeforeUnmount(() => indicator.clear);
const nuxtApp = useNuxtApp()
nuxtApp.hook('page:start', () => {
startLoading()
indicator.start()
})
nuxtApp.hook('page:finish', () => {
stopLoading()
indicator.finish()
})
onBeforeUnmount(() => indicator.clear)
const loading = useLoading();
const loading = useLoading()
watch(loading, (newValue) => {
if (newValue) {
indicator.start();
indicator.start()
} else {
indicator.finish();
indicator.finish()
}
});
})
return () =>
h(
"div",
'div',
{
class: "nuxt-loading-indicator",
class: 'nuxt-loading-indicator',
style: {
position: "fixed",
position: 'fixed',
top: 0,
right: 0,
left: 0,
pointerEvents: "none",
pointerEvents: 'none',
width: `${indicator.progress.value}%`,
height: `${props.height}px`,
opacity: indicator.isLoading.value ? 1 : 0,
background: props.color || undefined,
backgroundSize: `${(100 / indicator.progress.value) * 100}% auto`,
transition: "width 0.1s, height 0.4s, opacity 0.4s",
transition: 'width 0.1s, height 0.4s, opacity 0.4s',
zIndex: 999999,
},
},
slots,
);
)
},
});
})
function useLoadingIndicator(opts: { duration: number; throttle: number }) {
const progress = ref(0);
const isLoading = ref(false);
const step = computed(() => 10000 / opts.duration);
const progress = ref(0)
const isLoading = ref(false)
const step = computed(() => 10000 / opts.duration)
let _timer: any = null;
let _throttle: any = null;
let _timer: any = null
let _throttle: any = null
function start() {
clear();
progress.value = 0;
clear()
progress.value = 0
if (opts.throttle && import.meta.client) {
_throttle = setTimeout(() => {
isLoading.value = true;
_startTimer();
}, opts.throttle);
isLoading.value = true
_startTimer()
}, opts.throttle)
} else {
isLoading.value = true;
_startTimer();
isLoading.value = true
_startTimer()
}
}
function finish() {
progress.value = 100;
_hide();
progress.value = 100
_hide()
}
function clear() {
clearInterval(_timer);
clearTimeout(_throttle);
_timer = null;
_throttle = null;
clearInterval(_timer)
clearTimeout(_throttle)
_timer = null
_throttle = null
}
function _increase(num: number) {
progress.value = Math.min(100, progress.value + num);
progress.value = Math.min(100, progress.value + num)
}
function _hide() {
clear();
clear()
if (import.meta.client) {
setTimeout(() => {
isLoading.value = false;
isLoading.value = false
setTimeout(() => {
progress.value = 0;
}, 400);
}, 500);
progress.value = 0
}, 400)
}, 500)
}
}
function _startTimer() {
if (import.meta.client) {
_timer = setInterval(() => {
_increase(step.value);
}, 100);
_increase(step.value)
}, 100)
}
}
@ -138,5 +138,5 @@ function useLoadingIndicator(opts: { duration: number; throttle: number }) {
start,
finish,
clear,
};
}
}

View File

@ -25,10 +25,10 @@
</template>
<script setup lang="ts">
import { NewspaperIcon } from "@modrinth/assets";
import { articles as rawArticles } from "@modrinth/blog";
import { ButtonStyled, NewsArticleCard } from "@modrinth/ui";
import { computed,ref } from "vue";
import { NewspaperIcon } from '@modrinth/assets'
import { articles as rawArticles } from '@modrinth/blog'
import { ButtonStyled, NewsArticleCard } from '@modrinth/ui'
import { computed, ref } from 'vue'
const articles = ref(
rawArticles
@ -43,7 +43,7 @@ const articles = ref(
date: article.date,
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
);
)
const latestArticles = computed(() => articles.value.slice(0, 3));
const latestArticles = computed(() => articles.value.slice(0, 3))
</script>

View File

@ -103,15 +103,15 @@
</template>
<script setup>
import { ReportIcon, UnknownIcon, VersionIcon } from "@modrinth/assets";
import { Avatar, Badge, CopyCode, useRelativeTime } from "@modrinth/ui";
import { formatProjectType } from "@modrinth/utils";
import { ReportIcon, UnknownIcon, VersionIcon } from '@modrinth/assets'
import { Avatar, Badge, CopyCode, useRelativeTime } from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
import { renderHighlightedString } from "~/helpers/highlight.js";
import { getProjectTypeForUrl } from "~/helpers/projects.js";
import ThreadSummary from '~/components/ui/thread/ThreadSummary.vue'
import { renderHighlightedString } from '~/helpers/highlight.js'
import { getProjectTypeForUrl } from '~/helpers/projects.js'
const formatRelativeTime = useRelativeTime();
const formatRelativeTime = useRelativeTime()
defineProps({
report: {
@ -138,9 +138,9 @@ defineProps({
type: Object,
required: true,
},
});
})
const flags = useFeatureFlags();
const flags = useFeatureFlags()
</script>
<style lang="scss" scoped>

View File

@ -21,10 +21,10 @@
</div>
</template>
<script setup>
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
import ConversationThread from "~/components/ui/thread/ConversationThread.vue";
import { addReportMessage } from "~/helpers/threads.js";
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
import ReportInfo from '~/components/ui/report/ReportInfo.vue'
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
import { addReportMessage } from '~/helpers/threads.js'
const props = defineProps({
reportId: {
@ -39,76 +39,76 @@ const props = defineProps({
type: Object,
required: true,
},
});
})
const report = ref(null);
const report = ref(null)
await fetchReport().then((result) => {
report.value = result;
});
report.value = result
})
const { data: rawThread } = await useAsyncData(`thread/${report.value.thread_id}`, () =>
useBaseFetch(`thread/${report.value.thread_id}`),
);
const thread = computed(() => addReportMessage(rawThread.value, report.value));
)
const thread = computed(() => addReportMessage(rawThread.value, report.value))
async function updateThread(newThread) {
rawThread.value = newThread;
report.value = await fetchReport();
rawThread.value = newThread
report.value = await fetchReport()
}
async function fetchReport() {
const { data: rawReport } = await useAsyncData(`report/${props.reportId}`, () =>
useBaseFetch(`report/${props.reportId}`),
);
rawReport.value.item_id = rawReport.value.item_id.replace(/"/g, "");
)
rawReport.value.item_id = rawReport.value.item_id.replace(/"/g, '')
const userIds = [];
userIds.push(rawReport.value.reporter);
if (rawReport.value.item_type === "user") {
userIds.push(rawReport.value.item_id);
const userIds = []
userIds.push(rawReport.value.reporter)
if (rawReport.value.item_type === 'user') {
userIds.push(rawReport.value.item_id)
}
const versionId = rawReport.value.item_type === "version" ? rawReport.value.item_id : null;
const versionId = rawReport.value.item_type === 'version' ? rawReport.value.item_id : null
let users = [];
let users = []
if (userIds.length > 0) {
const { data: usersVal } = await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
useBaseFetch(`users?ids=${encodeURIComponent(JSON.stringify(userIds))}`),
);
users = usersVal.value;
)
users = usersVal.value
}
let version = null;
let version = null
if (versionId) {
const { data: versionVal } = await useAsyncData(`version/${versionId}`, () =>
useBaseFetch(`version/${versionId}`),
);
version = versionVal.value;
)
version = versionVal.value
}
const projectId = version
? version.project_id
: rawReport.value.item_type === "project"
: rawReport.value.item_type === 'project'
? rawReport.value.item_id
: null;
: null
let project = null;
let project = null
if (projectId) {
const { data: projectVal } = await useAsyncData(`project/${projectId}`, () =>
useBaseFetch(`project/${projectId}`),
);
project = projectVal.value;
)
project = projectVal.value
}
const reportData = rawReport.value;
reportData.project = project;
reportData.version = version;
reportData.reporterUser = users.find((user) => user.id === rawReport.value.reporter);
if (rawReport.value.item_type === "user") {
reportData.user = users.find((user) => user.id === rawReport.value.item_id);
const reportData = rawReport.value
reportData.project = project
reportData.version = version
reportData.reporterUser = users.find((user) => user.id === rawReport.value.reporter)
if (rawReport.value.item_type === 'user') {
reportData.user = users.find((user) => user.id === rawReport.value.item_id)
}
return reportData;
return reportData
}
</script>
<style lang="scss" scoped>

View File

@ -24,11 +24,11 @@
<p v-if="reports.length === 0">You don't have any active reports.</p>
</template>
<script setup>
import { Chips } from "@modrinth/ui";
import { Chips } from '@modrinth/ui'
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
import { addReportMessage } from "~/helpers/threads.js";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
import ReportInfo from '~/components/ui/report/ReportInfo.vue'
import { addReportMessage } from '~/helpers/threads.js'
import { asEncodedJsonArray, fetchSegmented } from '~/utils/fetch-helpers.ts'
const props = defineProps({
moderation: {
@ -39,34 +39,34 @@ const props = defineProps({
type: Object,
required: true,
},
});
})
const viewMode = ref("open");
const reasonFilter = ref("All");
const reports = ref([]);
const viewMode = ref('open')
const reasonFilter = ref('All')
const reports = ref([])
const MAX_REPORTS = 1500;
const MAX_REPORTS = 1500
let { data: rawReports } = await useAsyncData("report", () =>
let { data: rawReports } = await useAsyncData('report', () =>
useBaseFetch(`report?count=${MAX_REPORTS}`),
);
)
rawReports = rawReports.value.map((report) => {
report.item_id = report.item_id.replace(/"/g, "");
return report;
});
report.item_id = report.item_id.replace(/"/g, '')
return report
})
const reporterUsers = rawReports.map((report) => report.reporter);
const reporterUsers = rawReports.map((report) => report.reporter)
const reportedUsers = rawReports
.filter((report) => report.item_type === "user")
.map((report) => report.item_id);
const versionReports = rawReports.filter((report) => report.item_type === "version");
const versionIds = [...new Set(versionReports.map((report) => report.item_id))];
const userIds = [...new Set(reporterUsers.concat(reportedUsers))];
.filter((report) => report.item_type === 'user')
.map((report) => report.item_id)
const versionReports = rawReports.filter((report) => report.item_type === 'version')
const versionIds = [...new Set(versionReports.map((report) => report.item_id))]
const userIds = [...new Set(reporterUsers.concat(reportedUsers))]
const threadIds = [
...new Set(rawReports.filter((report) => report.thread_id).map((report) => report.thread_id)),
];
const reasons = ["All", ...new Set(rawReports.map((report) => report.report_type))];
]
const reasons = ['All', ...new Set(rawReports.map((report) => report.report_type))]
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
@ -78,44 +78,44 @@ const [{ data: users }, { data: versions }, { data: threads }] = await Promise.a
await useAsyncData(`threads?ids=${JSON.stringify(threadIds)}`, () =>
fetchSegmented(threadIds, (ids) => `threads?ids=${asEncodedJsonArray(ids)}`),
),
]);
])
const reportedProjects = rawReports
.filter((report) => report.item_type === "project")
.map((report) => report.item_id);
const versionProjects = versions.value.map((version) => version.project_id);
const projectIds = [...new Set(reportedProjects.concat(versionProjects))];
.filter((report) => report.item_type === 'project')
.map((report) => report.item_id)
const versionProjects = versions.value.map((version) => version.project_id)
const projectIds = [...new Set(reportedProjects.concat(versionProjects))]
const { data: projects } = await useAsyncData(`projects?ids=${JSON.stringify(projectIds)}`, () =>
fetchSegmented(projectIds, (ids) => `projects?ids=${asEncodedJsonArray(ids)}`),
);
)
reports.value = rawReports.map((report) => {
report.reporterUser = users.value.find((user) => user.id === report.reporter);
if (report.item_type === "user") {
report.user = users.value.find((user) => user.id === report.item_id);
} else if (report.item_type === "project") {
report.project = projects.value.find((project) => project.id === report.item_id);
} else if (report.item_type === "version") {
report.version = versions.value.find((version) => version.id === report.item_id);
report.project = projects.value.find((project) => project.id === report.version.project_id);
report.reporterUser = users.value.find((user) => user.id === report.reporter)
if (report.item_type === 'user') {
report.user = users.value.find((user) => user.id === report.item_id)
} else if (report.item_type === 'project') {
report.project = projects.value.find((project) => project.id === report.item_id)
} else if (report.item_type === 'version') {
report.version = versions.value.find((version) => version.id === report.item_id)
report.project = projects.value.find((project) => project.id === report.version.project_id)
}
if (report.thread_id) {
report.thread = addReportMessage(
threads.value.find((thread) => report.thread_id === thread.id),
report,
);
)
}
report.open = true;
return report;
});
report.open = true
return report
})
const filteredReports = computed(() =>
reports.value?.filter(
(x) =>
(props.moderation || x.reporterUser.id === props.auth.user.id) &&
(viewMode.value === "open" ? x.open : !x.open) &&
(reasonFilter.value === "All" || reasonFilter.value === x.report_type),
(viewMode.value === 'open' ? x.open : !x.open) &&
(reasonFilter.value === 'All' || reasonFilter.value === x.report_type),
),
);
)
</script>

View File

@ -10,14 +10,14 @@
</template>
<script>
import { formatCategory } from "@modrinth/utils";
import { formatCategory } from '@modrinth/utils'
export default {
props: {
categories: {
type: Array,
default() {
return [];
return []
},
},
type: {
@ -26,9 +26,9 @@ export default {
},
},
setup() {
const tags = useTags();
const tags = useTags()
return { tags };
return { tags }
},
computed: {
categoriesFiltered() {
@ -37,11 +37,11 @@ export default {
.filter(
(x) =>
this.categories.includes(x.name) && (!x.project_type || x.project_type === this.type),
);
)
},
},
methods: { formatCategory },
};
}
</script>
<style lang="scss" scoped>

View File

@ -16,7 +16,7 @@
</template>
<script>
import Checkbox from "~/components/ui/Checkbox.vue";
import Checkbox from '~/components/ui/Checkbox.vue'
export default {
components: {
@ -25,30 +25,30 @@ export default {
props: {
facetName: {
type: String,
default: "",
default: '',
},
displayName: {
type: String,
default: "",
default: '',
},
icon: {
type: String,
default: "",
default: '',
},
activeFilters: {
type: Array,
default() {
return [];
return []
},
},
},
emits: ["toggle"],
emits: ['toggle'],
methods: {
toggle() {
this.$emit("toggle", this.facetName);
this.$emit('toggle', this.facetName)
},
},
};
}
</script>
<style lang="scss" scoped>

View File

@ -42,83 +42,83 @@
</template>
<script setup lang="ts">
import { IssuesIcon, PlusIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { ModrinthServersFetchError, type ServerBackup } from "@modrinth/utils";
import { computed,nextTick, ref } from "vue";
import { IssuesIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
import { ModrinthServersFetchError, type ServerBackup } from '@modrinth/utils'
import { computed, nextTick, ref } from 'vue'
import type { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const modal = ref<InstanceType<typeof NewModal>>();
const input = ref<HTMLInputElement>();
const isCreating = ref(false);
const isRateLimited = ref(false);
const backupName = ref("");
const modal = ref<InstanceType<typeof NewModal>>()
const input = ref<HTMLInputElement>()
const isCreating = ref(false)
const isRateLimited = ref(false)
const backupName = ref('')
const newBackupAmount = computed(() =>
props.server.backups?.data?.length === undefined ? 1 : props.server.backups?.data?.length + 1,
);
)
const trimmedName = computed(() => backupName.value.trim());
const trimmedName = computed(() => backupName.value.trim())
const nameExists = computed(() => {
if (!props.server.backups?.data) return false;
if (!props.server.backups?.data) return false
return props.server.backups.data.some(
(backup: ServerBackup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
);
});
)
})
const focusInput = () => {
nextTick(() => {
setTimeout(() => {
input.value?.focus();
}, 100);
});
};
input.value?.focus()
}, 100)
})
}
function show() {
backupName.value = "";
isCreating.value = false;
modal.value?.show();
backupName.value = ''
isCreating.value = false
modal.value?.show()
}
const hideModal = () => {
modal.value?.hide();
};
modal.value?.hide()
}
const createBackup = async () => {
if (backupName.value.trim().length === 0) {
backupName.value = `Backup #${newBackupAmount.value}`;
backupName.value = `Backup #${newBackupAmount.value}`
}
isCreating.value = true;
isRateLimited.value = false;
isCreating.value = true
isRateLimited.value = false
try {
await props.server.backups?.create(trimmedName.value);
hideModal();
await props.server.refresh();
await props.server.backups?.create(trimmedName.value)
hideModal()
await props.server.refresh()
} catch (error) {
if (error instanceof ModrinthServersFetchError && error?.statusCode === 429) {
isRateLimited.value = true;
isRateLimited.value = true
addNotification({
type: "error",
title: "Error creating backup",
type: 'error',
title: 'Error creating backup',
text: "You're creating backups too fast.",
});
})
} else {
const message = error instanceof Error ? error.message : String(error);
addNotification({ type: "error", title: "Error creating backup", text: message });
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Error creating backup', text: message })
}
} finally {
isCreating.value = false;
isCreating.value = false
}
};
}
defineExpose({
show,
hide: hideModal,
});
})
</script>

View File

@ -18,25 +18,25 @@
</template>
<script setup lang="ts">
import { ConfirmModal } from "@modrinth/ui";
import type { Backup } from "@modrinth/utils";
import { ref } from "vue";
import { ConfirmModal } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { ref } from 'vue'
import BackupItem from "~/components/ui/servers/BackupItem.vue";
import BackupItem from '~/components/ui/servers/BackupItem.vue'
const emit = defineEmits<{
(e: "delete", backup: Backup | undefined): void;
}>();
(e: 'delete', backup: Backup | undefined): void
}>()
const modal = ref<InstanceType<typeof ConfirmModal>>();
const currentBackup = ref<Backup | undefined>(undefined);
const modal = ref<InstanceType<typeof ConfirmModal>>()
const currentBackup = ref<Backup | undefined>(undefined)
function show(backup: Backup) {
currentBackup.value = backup;
modal.value?.show();
currentBackup.value = backup
modal.value?.show()
}
defineExpose({
show,
});
})
</script>

View File

@ -12,160 +12,160 @@ import {
SpinnerIcon,
TrashIcon,
XIcon,
} from "@modrinth/assets";
import { ButtonStyled, commonMessages, OverflowMenu, ProgressBar } from "@modrinth/ui";
import type { Backup } from "@modrinth/utils";
import { defineMessages, useVIntl } from "@vintl/vintl";
import dayjs from "dayjs";
import { computed,ref } from "vue";
} from '@modrinth/assets'
import { ButtonStyled, commonMessages, OverflowMenu, ProgressBar } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
const flags = useFeatureFlags();
const { formatMessage } = useVIntl();
const flags = useFeatureFlags()
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: "prepare" | "download" | "rename" | "restore" | "lock" | "retry"): void;
(e: "delete", skipConfirmation?: boolean): void;
}>();
(e: 'prepare' | 'download' | 'rename' | 'restore' | 'lock' | 'retry'): void
(e: 'delete', skipConfirmation?: boolean): void
}>()
const props = withDefaults(
defineProps<{
backup: Backup;
preview?: boolean;
kyrosUrl?: string;
jwt?: string;
backup: Backup
preview?: boolean
kyrosUrl?: string
jwt?: string
}>(),
{
preview: false,
kyrosUrl: undefined,
jwt: undefined,
},
);
)
const backupQueued = computed(
() =>
props.backup.task?.create?.progress === 0 ||
(props.backup.ongoing && !props.backup.task?.create),
);
const automated = computed(() => props.backup.automated);
const failedToCreate = computed(() => props.backup.interrupted);
)
const automated = computed(() => props.backup.automated)
const failedToCreate = computed(() => props.backup.interrupted)
const preparedDownloadStates = ["ready", "done"];
const inactiveStates = ["failed", "cancelled"];
const preparedDownloadStates = ['ready', 'done']
const inactiveStates = ['failed', 'cancelled']
const hasPreparedDownload = computed(() => {
const fileState = props.backup.task?.file?.state ?? "";
return preparedDownloadStates.includes(fileState);
});
const fileState = props.backup.task?.file?.state ?? ''
return preparedDownloadStates.includes(fileState)
})
const creating = computed(() => {
const task = props.backup.task?.create;
const task = props.backup.task?.create
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task;
return task
}
if (props.backup.ongoing) {
return {
progress: 0,
state: "ongoing",
};
state: 'ongoing',
}
}
return undefined;
});
return undefined
})
const restoring = computed(() => {
const task = props.backup.task?.restore;
const task = props.backup.task?.restore
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task;
return task
}
return undefined;
});
return undefined
})
const initiatedPrepare = ref(false);
const initiatedPrepare = ref(false)
const preparingFile = computed(() => {
if (hasPreparedDownload.value) {
return false;
return false
}
const task = props.backup.task?.file;
const task = props.backup.task?.file
return (
(!task && initiatedPrepare.value) ||
(task && task.progress < 1 && !inactiveStates.includes(task.state))
);
});
)
})
const failedToRestore = computed(() => props.backup.task?.restore?.state === "failed");
const failedToPrepareFile = computed(() => props.backup.task?.file?.state === "failed");
const failedToRestore = computed(() => props.backup.task?.restore?.state === 'failed')
const failedToPrepareFile = computed(() => props.backup.task?.file?.state === 'failed')
const messages = defineMessages({
locked: {
id: "servers.backups.item.locked",
defaultMessage: "Locked",
id: 'servers.backups.item.locked',
defaultMessage: 'Locked',
},
lock: {
id: "servers.backups.item.lock",
defaultMessage: "Lock",
id: 'servers.backups.item.lock',
defaultMessage: 'Lock',
},
unlock: {
id: "servers.backups.item.unlock",
defaultMessage: "Unlock",
id: 'servers.backups.item.unlock',
defaultMessage: 'Unlock',
},
restore: {
id: "servers.backups.item.restore",
defaultMessage: "Restore",
id: 'servers.backups.item.restore',
defaultMessage: 'Restore',
},
rename: {
id: "servers.backups.item.rename",
defaultMessage: "Rename",
id: 'servers.backups.item.rename',
defaultMessage: 'Rename',
},
queuedForBackup: {
id: "servers.backups.item.queued-for-backup",
defaultMessage: "Queued for backup",
id: 'servers.backups.item.queued-for-backup',
defaultMessage: 'Queued for backup',
},
preparingDownload: {
id: "servers.backups.item.preparing-download",
defaultMessage: "Preparing download...",
id: 'servers.backups.item.preparing-download',
defaultMessage: 'Preparing download...',
},
prepareDownload: {
id: "servers.backups.item.prepare-download",
defaultMessage: "Prepare download",
id: 'servers.backups.item.prepare-download',
defaultMessage: 'Prepare download',
},
prepareDownloadAgain: {
id: "servers.backups.item.prepare-download-again",
defaultMessage: "Try preparing again",
id: 'servers.backups.item.prepare-download-again',
defaultMessage: 'Try preparing again',
},
alreadyPreparing: {
id: "servers.backups.item.already-preparing",
defaultMessage: "Already preparing backup for download",
id: 'servers.backups.item.already-preparing',
defaultMessage: 'Already preparing backup for download',
},
creatingBackup: {
id: "servers.backups.item.creating-backup",
defaultMessage: "Creating backup...",
id: 'servers.backups.item.creating-backup',
defaultMessage: 'Creating backup...',
},
restoringBackup: {
id: "servers.backups.item.restoring-backup",
defaultMessage: "Restoring from backup...",
id: 'servers.backups.item.restoring-backup',
defaultMessage: 'Restoring from backup...',
},
failedToCreateBackup: {
id: "servers.backups.item.failed-to-create-backup",
defaultMessage: "Failed to create backup",
id: 'servers.backups.item.failed-to-create-backup',
defaultMessage: 'Failed to create backup',
},
failedToRestoreBackup: {
id: "servers.backups.item.failed-to-restore-backup",
defaultMessage: "Failed to restore from backup",
id: 'servers.backups.item.failed-to-restore-backup',
defaultMessage: 'Failed to restore from backup',
},
failedToPrepareFile: {
id: "servers.backups.item.failed-to-prepare-backup",
defaultMessage: "Failed to prepare download",
id: 'servers.backups.item.failed-to-prepare-backup',
defaultMessage: 'Failed to prepare download',
},
automated: {
id: "servers.backups.item.automated",
defaultMessage: "Automated",
id: 'servers.backups.item.automated',
defaultMessage: 'Automated',
},
retry: {
id: "servers.backups.item.retry",
defaultMessage: "Retry",
id: 'servers.backups.item.retry',
defaultMessage: 'Retry',
},
});
})
</script>
<template>
<div
@ -239,7 +239,7 @@ const messages = defineMessages({
</div>
<template v-else>
<div class="col-span-2">
{{ dayjs(backup.created_at).format("MMMM D, YYYY [at] h:mm A") }}
{{ dayjs(backup.created_at).format('MMMM D, YYYY [at] h:mm A') }}
</div>
<div v-if="false">{{ 245 }} MiB</div>
</template>
@ -285,8 +285,8 @@ const messages = defineMessages({
:disabled="!!preparingFile"
@click="
() => {
initiatedPrepare = true;
emit('prepare');
initiatedPrepare = true
emit('prepare')
}
"
>

View File

@ -45,98 +45,98 @@
</template>
<script setup lang="ts">
import { IssuesIcon,SaveIcon, SpinnerIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import type { Backup } from "@modrinth/utils";
import { computed,nextTick, ref } from "vue";
import { IssuesIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { computed, nextTick, ref } from 'vue'
import type { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const modal = ref<InstanceType<typeof NewModal>>();
const input = ref<HTMLInputElement>();
const backupName = ref("");
const originalName = ref("");
const isRenaming = ref(false);
const modal = ref<InstanceType<typeof NewModal>>()
const input = ref<HTMLInputElement>()
const backupName = ref('')
const originalName = ref('')
const isRenaming = ref(false)
const currentBackup = ref<Backup | null>(null);
const currentBackup = ref<Backup | null>(null)
const trimmedName = computed(() => backupName.value.trim());
const trimmedName = computed(() => backupName.value.trim())
const nameExists = computed(() => {
if (!props.server.backups?.data || trimmedName.value === originalName.value || isRenaming.value) {
return false;
return false
}
return props.server.backups.data.some(
(backup: Backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
);
});
)
})
const backupNumber = computed(
() => (props.server.backups?.data?.findIndex((b) => b.id === currentBackup.value?.id) ?? 0) + 1,
);
)
const focusInput = () => {
nextTick(() => {
setTimeout(() => {
input.value?.focus();
}, 100);
});
};
input.value?.focus()
}, 100)
})
}
function show(backup: Backup) {
currentBackup.value = backup;
backupName.value = backup.name;
originalName.value = backup.name;
isRenaming.value = false;
modal.value?.show();
currentBackup.value = backup
backupName.value = backup.name
originalName.value = backup.name
isRenaming.value = false
modal.value?.show()
}
function hide() {
modal.value?.hide();
modal.value?.hide()
}
const renameBackup = async () => {
if (!currentBackup.value) {
addNotification({
type: "error",
title: "Error renaming backup",
text: "Current backup is null",
});
return;
type: 'error',
title: 'Error renaming backup',
text: 'Current backup is null',
})
return
}
if (trimmedName.value === originalName.value) {
hide();
return;
hide()
return
}
isRenaming.value = true;
isRenaming.value = true
try {
let newName = trimmedName.value;
let newName = trimmedName.value
if (newName.length === 0) {
newName = `Backup #${backupNumber.value}`;
newName = `Backup #${backupNumber.value}`
}
await props.server.backups?.rename(currentBackup.value.id, newName);
hide();
await props.server.refresh();
await props.server.backups?.rename(currentBackup.value.id, newName)
hide()
await props.server.refresh()
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
addNotification({ type: "error", title: "Error renaming backup", text: message });
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Error renaming backup', text: message })
} finally {
hide();
isRenaming.value = false;
hide()
isRenaming.value = false
}
};
}
defineExpose({
show,
hide,
});
})
</script>

View File

@ -17,45 +17,45 @@
</template>
<script setup lang="ts">
import type { NewModal } from "@modrinth/ui";
import { ConfirmModal } from "@modrinth/ui";
import type { Backup } from "@modrinth/utils";
import { ref } from "vue";
import type { NewModal } from '@modrinth/ui'
import { ConfirmModal } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { ref } from 'vue'
import BackupItem from "~/components/ui/servers/BackupItem.vue";
import type { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import BackupItem from '~/components/ui/servers/BackupItem.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const modal = ref<InstanceType<typeof NewModal>>();
const currentBackup = ref<Backup | null>(null);
const modal = ref<InstanceType<typeof NewModal>>()
const currentBackup = ref<Backup | null>(null)
function show(backup: Backup) {
currentBackup.value = backup;
modal.value?.show();
currentBackup.value = backup
modal.value?.show()
}
const restoreBackup = async () => {
if (!currentBackup.value) {
addNotification({
type: "error",
title: "Failed to restore backup",
text: "Current backup is null",
});
return;
type: 'error',
title: 'Failed to restore backup',
text: 'Current backup is null',
})
return
}
try {
await props.server.backups?.restore(currentBackup.value.id);
await props.server.backups?.restore(currentBackup.value.id)
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
addNotification({ type: "error", title: "Failed to restore backup", text: message });
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Failed to restore backup', text: message })
}
};
}
defineExpose({
show,
});
})
</script>

View File

@ -40,7 +40,7 @@
<ButtonStyled color="brand">
<button :disabled="!hasChanges || isSaving" @click="saveSettings">
<SaveIcon class="h-5 w-5" />
{{ isSaving ? "Saving..." : "Save changes" }}
{{ isSaving ? 'Saving...' : 'Save changes' }}
</button>
</ButtonStyled>
<ButtonStyled>
@ -56,114 +56,114 @@
</template>
<script setup lang="ts">
import { SaveIcon,XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { computed,ref } from "vue";
import { SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
import { computed, ref } from 'vue'
import type { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const modal = ref<InstanceType<typeof NewModal>>();
const modal = ref<InstanceType<typeof NewModal>>()
const initialSettings = ref<{ interval: number; enabled: boolean } | null>(null);
const autoBackupEnabled = ref(false);
const isLoadingSettings = ref(true);
const isSaving = ref(false);
const initialSettings = ref<{ interval: number; enabled: boolean } | null>(null)
const autoBackupEnabled = ref(false)
const isLoadingSettings = ref(true)
const isSaving = ref(false)
const backupIntervals = {
"Every 3 hours": 3,
"Every 6 hours": 6,
"Every 12 hours": 12,
'Every 3 hours': 3,
'Every 6 hours': 6,
'Every 12 hours': 12,
Daily: 24,
};
}
const backupIntervalsLabel = ref<keyof typeof backupIntervals>("Every 6 hours");
const backupIntervalsLabel = ref<keyof typeof backupIntervals>('Every 6 hours')
const autoBackupInterval = computed({
get: () => backupIntervals[backupIntervalsLabel.value],
set: (value) => {
const [label] =
Object.entries(backupIntervals).find(([_, interval]) => interval === value) || [];
if (label) backupIntervalsLabel.value = label as keyof typeof backupIntervals;
Object.entries(backupIntervals).find(([_, interval]) => interval === value) || []
if (label) backupIntervalsLabel.value = label as keyof typeof backupIntervals
},
});
})
const hasChanges = computed(() => {
if (!initialSettings.value) return false;
if (!initialSettings.value) return false
return (
autoBackupEnabled.value !== initialSettings.value.enabled ||
(initialSettings.value.enabled && autoBackupInterval.value !== initialSettings.value.interval)
);
});
)
})
const fetchSettings = async () => {
isLoadingSettings.value = true;
isLoadingSettings.value = true
try {
const settings = await props.server.backups?.getAutoBackup();
initialSettings.value = settings as { interval: number; enabled: boolean };
autoBackupEnabled.value = settings?.enabled ?? false;
autoBackupInterval.value = settings?.interval || 6;
return true;
const settings = await props.server.backups?.getAutoBackup()
initialSettings.value = settings as { interval: number; enabled: boolean }
autoBackupEnabled.value = settings?.enabled ?? false
autoBackupInterval.value = settings?.interval || 6
return true
} catch (error) {
console.error("Error fetching backup settings:", error);
console.error('Error fetching backup settings:', error)
addNotification({
group: "server",
title: "Error",
text: "Failed to load backup settings",
type: "error",
});
return false;
group: 'server',
title: 'Error',
text: 'Failed to load backup settings',
type: 'error',
})
return false
} finally {
isLoadingSettings.value = false;
isLoadingSettings.value = false
}
};
}
const saveSettings = async () => {
isSaving.value = true;
isSaving.value = true
try {
await props.server.backups?.updateAutoBackup(
autoBackupEnabled.value ? "enable" : "disable",
autoBackupEnabled.value ? 'enable' : 'disable',
autoBackupInterval.value,
);
)
initialSettings.value = {
enabled: autoBackupEnabled.value,
interval: autoBackupInterval.value,
};
}
addNotification({
group: "server",
title: "Success",
text: "Backup settings updated successfully",
type: "success",
});
group: 'server',
title: 'Success',
text: 'Backup settings updated successfully',
type: 'success',
})
modal.value?.hide();
modal.value?.hide()
} catch (error) {
console.error("Error saving backup settings:", error);
console.error('Error saving backup settings:', error)
addNotification({
group: "server",
title: "Error",
text: "Failed to save backup settings",
type: "error",
});
group: 'server',
title: 'Error',
text: 'Failed to save backup settings',
type: 'error',
})
} finally {
isSaving.value = false;
isSaving.value = false
}
};
}
defineExpose({
show: async () => {
const success = await fetchSettings();
const success = await fetchSettings()
if (success) {
modal.value?.show();
modal.value?.show()
}
},
});
})
</script>
<style scoped>

View File

@ -89,8 +89,8 @@
noCompatibleVersions
? `No compatible versions of this ${type.toLowerCase()} were found`
: versionFilter
? "Game version and platform is provided by the server"
: "Incompatible game version and platform versions are unlocked"
? 'Game version and platform is provided by the server'
: 'Incompatible game version and platform versions are unlocked'
}}
</span>
</p>
@ -133,8 +133,8 @@
<div class="w-full truncate text-left">
{{
filtersRef?.selectedPlatforms.length === 0
? "All platforms"
: filtersRef?.selectedPlatforms.map((x) => formatCategory(x)).join(", ")
? 'All platforms'
: filtersRef?.selectedPlatforms.map((x) => formatCategory(x)).join(', ')
}}
</div>
</template>
@ -143,8 +143,8 @@
<div class="w-full truncate text-left">
{{
filtersRef?.selectedGameVersions.length === 0
? "All game versions"
: filtersRef?.selectedGameVersions.join(", ")
? 'All game versions'
: filtersRef?.selectedGameVersions.join(', ')
}}
</div>
</template>
@ -156,19 +156,19 @@
:disabled="gameVersions.length < 2 && platforms.length < 2"
@click="
() => {
versionFilter = !versionFilter;
setInitialFilters();
updateFiltersToUi();
versionFilter = !versionFilter
setInitialFilters()
updateFiltersToUi()
}
"
>
<LockOpenIcon />
{{
gameVersions.length < 2 && platforms.length < 2
? "No other platforms or versions available"
? 'No other platforms or versions available'
: versionFilter
? "Unlock"
: "Return to compatibility"
? 'Unlock'
: 'Return to compatibility'
}}
</button>
</ButtonStyled>
@ -235,81 +235,81 @@ import {
GameIcon,
LockOpenIcon,
XIcon,
} from "@modrinth/assets";
import { Admonition, Avatar, ButtonStyled, CopyCode, NewModal } from "@modrinth/ui";
import TagItem from "@modrinth/ui/src/components/base/TagItem.vue";
import { formatCategory, formatVersionsForDisplay, type Mod, type Version } from "@modrinth/utils";
import { computed,ref } from "vue";
} from '@modrinth/assets'
import { Admonition, Avatar, ButtonStyled, CopyCode, NewModal } from '@modrinth/ui'
import TagItem from '@modrinth/ui/src/components/base/TagItem.vue'
import { formatCategory, formatVersionsForDisplay, type Mod, type Version } from '@modrinth/utils'
import { computed, ref } from 'vue'
import Accordion from "~/components/ui/Accordion.vue";
import Checkbox from "~/components/ui/Checkbox.vue";
import Accordion from '~/components/ui/Accordion.vue'
import Checkbox from '~/components/ui/Checkbox.vue'
import ContentVersionFilter, {
type ListedGameVersion,
type ListedPlatform,
} from "~/components/ui/servers/ContentVersionFilter.vue";
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
} from '~/components/ui/servers/ContentVersionFilter.vue'
import LoaderIcon from '~/components/ui/servers/icons/LoaderIcon.vue'
const props = defineProps<{
type: "Mod" | "Plugin";
loader: string;
gameVersion: string;
modPack: boolean;
serverId: string;
}>();
type: 'Mod' | 'Plugin'
loader: string
gameVersion: string
modPack: boolean
serverId: string
}>()
interface ContentItem extends Mod {
changing?: boolean;
changing?: boolean
}
interface EditVersion extends Version {
installed: boolean;
upgrade?: boolean;
installed: boolean
upgrade?: boolean
}
const modModal = ref();
const modDetails = ref<ContentItem>();
const currentVersions = ref<EditVersion[] | null>(null);
const versionsLoading = ref(false);
const versionsError = ref("");
const showBetaAlphaReleases = ref(false);
const unlockFilterAccordion = ref();
const versionFilter = ref(true);
const tags = useTags();
const noCompatibleVersions = ref(false);
const modModal = ref()
const modDetails = ref<ContentItem>()
const currentVersions = ref<EditVersion[] | null>(null)
const versionsLoading = ref(false)
const versionsError = ref('')
const showBetaAlphaReleases = ref(false)
const unlockFilterAccordion = ref()
const versionFilter = ref(true)
const tags = useTags()
const noCompatibleVersions = ref(false)
const { pluginLoaders, modLoaders } = tags.value.loaders.reduce(
(acc, tag) => {
if (tag.supported_project_types.includes("plugin")) {
acc.pluginLoaders.push(tag.name);
if (tag.supported_project_types.includes('plugin')) {
acc.pluginLoaders.push(tag.name)
}
if (tag.supported_project_types.includes("mod")) {
acc.modLoaders.push(tag.name);
if (tag.supported_project_types.includes('mod')) {
acc.modLoaders.push(tag.name)
}
return acc;
return acc
},
{ pluginLoaders: [] as string[], modLoaders: [] as string[] },
);
)
const selectedVersion = ref();
const filtersRef: Ref<InstanceType<typeof ContentVersionFilter> | null> = ref(null);
const selectedVersion = ref()
const filtersRef: Ref<InstanceType<typeof ContentVersionFilter> | null> = ref(null)
interface SelectedContentFilters {
selectedGameVersions: string[];
selectedPlatforms: string[];
selectedGameVersions: string[]
selectedPlatforms: string[]
}
const selectedFilters = ref<SelectedContentFilters>({
selectedGameVersions: [],
selectedPlatforms: [],
});
})
const backwardCompatPlatformMap = {
purpur: ["purpur", "paper", "spigot", "bukkit"],
paper: ["paper", "spigot", "bukkit"],
spigot: ["spigot", "bukkit"],
};
purpur: ['purpur', 'paper', 'spigot', 'bukkit'],
paper: ['paper', 'spigot', 'bukkit'],
spigot: ['spigot', 'bukkit'],
}
const platforms = ref<ListedPlatform[]>([]);
const gameVersions = ref<ListedGameVersion[]>([]);
const initPlatform = ref<string>("");
const platforms = ref<ListedPlatform[]>([])
const gameVersions = ref<ListedGameVersion[]>([])
const initPlatform = ref<string>('')
const setInitialFilters = () => {
selectedFilters.value = {
@ -319,29 +319,29 @@ const setInitialFilters = () => {
gameVersions.value[0]?.name,
],
selectedPlatforms: [initPlatform.value],
};
};
}
}
const updateFiltersToUi = () => {
if (!filtersRef.value) return;
filtersRef.value.selectedGameVersions = selectedFilters.value.selectedGameVersions;
filtersRef.value.selectedPlatforms = selectedFilters.value.selectedPlatforms;
if (!filtersRef.value) return
filtersRef.value.selectedGameVersions = selectedFilters.value.selectedGameVersions
filtersRef.value.selectedPlatforms = selectedFilters.value.selectedPlatforms
selectedVersion.value = filteredVersions.value[0];
};
selectedVersion.value = filteredVersions.value[0]
}
const updateFiltersFromUi = (event: { g: string[]; l: string[] }) => {
selectedFilters.value = {
selectedGameVersions: event.g,
selectedPlatforms: event.l,
};
};
}
}
const filteredVersions = computed(() => {
if (!currentVersions.value) return [];
if (!currentVersions.value) return []
const versionsWithoutReleaseFilter = currentVersions.value.filter((version: EditVersion) => {
if (version.installed) return true;
if (version.installed) return true
return (
filtersRef.value?.selectedPlatforms.every((platform) =>
(
@ -353,42 +353,40 @@ const filteredVersions = computed(() => {
filtersRef.value?.selectedGameVersions.every((gameVersion) =>
version.game_versions.includes(gameVersion),
)
);
});
)
})
const versionTypes = new Set(
versionsWithoutReleaseFilter.map((v: EditVersion) => v.version_type),
);
const releaseVersions = versionTypes.has("release");
const betaVersions = versionTypes.has("beta");
const alphaVersions = versionTypes.has("alpha");
const versionTypes = new Set(versionsWithoutReleaseFilter.map((v: EditVersion) => v.version_type))
const releaseVersions = versionTypes.has('release')
const betaVersions = versionTypes.has('beta')
const alphaVersions = versionTypes.has('alpha')
const versions = versionsWithoutReleaseFilter.filter((version: EditVersion) => {
if (showBetaAlphaReleases.value || version.installed) return true;
if (showBetaAlphaReleases.value || version.installed) return true
return releaseVersions
? version.version_type === "release"
? version.version_type === 'release'
: betaVersions
? version.version_type === "beta"
? version.version_type === 'beta'
: alphaVersions
? version.version_type === "alpha"
: false;
});
? version.version_type === 'alpha'
: false
})
return versions.map((version: EditVersion) => {
let suffix = "";
let suffix = ''
if (version.version_type === "alpha" && releaseVersions && betaVersions) {
suffix += " (alpha)";
} else if (version.version_type === "beta" && releaseVersions) {
suffix += " (beta)";
if (version.version_type === 'alpha' && releaseVersions && betaVersions) {
suffix += ' (alpha)'
} else if (version.version_type === 'beta' && releaseVersions) {
suffix += ' (beta)'
}
return {
...version,
version_number: version.version_number + suffix,
};
});
});
}
})
})
const formattedVersions = computed(() => {
return {
@ -400,134 +398,134 @@ const formattedVersions = computed(() => {
.sort((firstLoader: string, secondLoader: string) => {
const loaderList = backwardCompatPlatformMap[
props.loader as keyof typeof backwardCompatPlatformMap
] || [props.loader];
] || [props.loader]
const firstLoaderPosition = loaderList.indexOf(firstLoader.toLowerCase());
const secondLoaderPosition = loaderList.indexOf(secondLoader.toLowerCase());
const firstLoaderPosition = loaderList.indexOf(firstLoader.toLowerCase())
const secondLoaderPosition = loaderList.indexOf(secondLoader.toLowerCase())
if (firstLoaderPosition === -1 && secondLoaderPosition === -1) return 0;
if (firstLoaderPosition === -1) return 1;
if (secondLoaderPosition === -1) return -1;
return firstLoaderPosition - secondLoaderPosition;
if (firstLoaderPosition === -1 && secondLoaderPosition === -1) return 0
if (firstLoaderPosition === -1) return 1
if (secondLoaderPosition === -1) return -1
return firstLoaderPosition - secondLoaderPosition
})
.map((loader: string) => formatCategory(loader)),
};
});
}
})
async function show(mod: ContentItem) {
versionFilter.value = true;
modModal.value.show();
versionsLoading.value = true;
modDetails.value = mod;
versionsError.value = "";
currentVersions.value = null;
versionFilter.value = true
modModal.value.show()
versionsLoading.value = true
modDetails.value = mod
versionsError.value = ''
currentVersions.value = null
try {
const result = await useBaseFetch(`project/${mod.project_id}/version`, {}, false);
const result = await useBaseFetch(`project/${mod.project_id}/version`, {}, false)
if (
Array.isArray(result) &&
result.every(
(item) =>
"id" in item &&
"version_number" in item &&
"version_type" in item &&
"loaders" in item &&
"game_versions" in item,
'id' in item &&
'version_number' in item &&
'version_type' in item &&
'loaders' in item &&
'game_versions' in item,
)
) {
currentVersions.value = result as EditVersion[];
currentVersions.value = result as EditVersion[]
} else {
throw new Error("Invalid version data received.");
throw new Error('Invalid version data received.')
}
// find the installed version and move it to the top of the list
const currentModIndex = currentVersions.value.findIndex(
(item: { id: string }) => item.id === mod.version_id,
);
)
if (currentModIndex === -1) {
currentVersions.value[currentModIndex] = {
...currentVersions.value[currentModIndex],
installed: true,
version_number: `${mod.version_number} (current) (external)`,
};
}
} else {
currentVersions.value[currentModIndex].version_number = `${mod.version_number} (current)`;
currentVersions.value[currentModIndex].installed = true;
currentVersions.value[currentModIndex].version_number = `${mod.version_number} (current)`
currentVersions.value[currentModIndex].installed = true
}
// initially filter the platform and game versions for the server config
const platformSet = new Set<string>();
const gameVersionSet = new Set<string>();
const platformSet = new Set<string>()
const gameVersionSet = new Set<string>()
for (const version of currentVersions.value) {
for (const loader of version.loaders) {
platformSet.add(loader);
platformSet.add(loader)
}
for (const gameVersion of version.game_versions) {
gameVersionSet.add(gameVersion);
gameVersionSet.add(gameVersion)
}
}
if (gameVersionSet.size > 0) {
const filteredGameVersions = tags.value.gameVersions.filter((x) =>
gameVersionSet.has(x.version),
);
)
gameVersions.value = filteredGameVersions.map((x) => ({
name: x.version,
release: x.version_type === "release",
}));
release: x.version_type === 'release',
}))
}
if (platformSet.size > 0) {
const tempPlatforms = Array.from(platformSet).map((platform) => ({
name: platform,
isType:
props.type === "Plugin"
props.type === 'Plugin'
? pluginLoaders.includes(platform)
: props.type === "Mod"
: props.type === 'Mod'
? modLoaders.includes(platform)
: false,
}));
platforms.value = tempPlatforms;
}))
platforms.value = tempPlatforms
}
// set default platform
const defaultPlatform = Array.from(platformSet)[0];
const defaultPlatform = Array.from(platformSet)[0]
initPlatform.value = platformSet.has(props.loader)
? props.loader
: props.loader in backwardCompatPlatformMap
? backwardCompatPlatformMap[props.loader as keyof typeof backwardCompatPlatformMap].find(
(p) => platformSet.has(p),
) || defaultPlatform
: defaultPlatform;
: defaultPlatform
// check if there's nothing compatible with the server config
noCompatibleVersions.value =
!platforms.value.some((p) => p.isType) ||
!gameVersions.value.some((v) => v.name === props.gameVersion);
!gameVersions.value.some((v) => v.name === props.gameVersion)
if (noCompatibleVersions.value) {
unlockFilterAccordion.value.open();
versionFilter.value = false;
unlockFilterAccordion.value.open()
versionFilter.value = false
}
setInitialFilters();
versionsLoading.value = false;
setInitialFilters()
versionsLoading.value = false
} catch (error) {
console.error("Error loading versions:", error);
versionsError.value = error instanceof Error ? error.message : "Unknown";
console.error('Error loading versions:', error)
versionsError.value = error instanceof Error ? error.message : 'Unknown'
}
}
const emit = defineEmits<{
changeVersion: [string];
}>();
changeVersion: [string]
}>()
function emitChangeModVersion() {
if (!selectedVersion.value) return;
emit("changeVersion", selectedVersion.value.id.toString());
if (!selectedVersion.value) return
emit('changeVersion', selectedVersion.value.id.toString())
}
defineExpose({
show,
hide: () => modModal.value.hide(),
});
})
</script>

View File

@ -57,114 +57,114 @@
</template>
<script setup lang="ts">
import { FilterIcon } from "@modrinth/assets";
import Checkbox from "@modrinth/ui/src/components/base/Checkbox.vue";
import ManySelect from "@modrinth/ui/src/components/base/ManySelect.vue";
import { formatCategory, type GameVersionTag,type Version } from "@modrinth/utils";
import { computed,ref } from "vue";
import { useRoute } from "vue-router";
import { FilterIcon } from '@modrinth/assets'
import Checkbox from '@modrinth/ui/src/components/base/Checkbox.vue'
import ManySelect from '@modrinth/ui/src/components/base/ManySelect.vue'
import { formatCategory, type GameVersionTag, type Version } from '@modrinth/utils'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
export type ListedGameVersion = {
name: string;
release: boolean;
};
name: string
release: boolean
}
export type ListedPlatform = {
name: string;
isType: boolean;
};
name: string
isType: boolean
}
const props = defineProps<{
versions: Version[];
gameVersions: GameVersionTag[];
listedGameVersions: ListedGameVersion[];
listedPlatforms: ListedPlatform[];
baseId?: string;
type: "Mod" | "Plugin";
versions: Version[]
gameVersions: GameVersionTag[]
listedGameVersions: ListedGameVersion[]
listedPlatforms: ListedPlatform[]
baseId?: string
type: 'Mod' | 'Plugin'
platformTags: {
name: string;
supported_project_types: string[];
}[];
disabled?: boolean;
}>();
name: string
supported_project_types: string[]
}[]
disabled?: boolean
}>()
const emit = defineEmits(["update:query"]);
const route = useRoute();
const emit = defineEmits(['update:query'])
const route = useRoute()
const showSnapshots = ref(false);
const showSnapshots = ref(false)
const hasAnySnapshots = computed(() => {
return props.versions.some((x) =>
props.gameVersions.some(
(y) => y.version_type !== "release" && x.game_versions.includes(y.version),
(y) => y.version_type !== 'release' && x.game_versions.includes(y.version),
),
);
});
)
})
const hasOnlySnapshots = computed(() => {
return props.versions.every((version) => {
return version.game_versions.every((gv) => {
const matched = props.gameVersions.find((tag) => tag.version === gv);
return matched && matched.version_type !== "release";
});
});
});
const matched = props.gameVersions.find((tag) => tag.version === gv)
return matched && matched.version_type !== 'release'
})
})
})
const hasAnyUnsupportedPlatforms = computed(() => {
return props.listedPlatforms.some((x) => !x.isType);
});
return props.listedPlatforms.some((x) => !x.isType)
})
const hasOnlyUnsupportedPlatforms = computed(() => {
return props.listedPlatforms.every((x) => !x.isType);
});
return props.listedPlatforms.every((x) => !x.isType)
})
const showSupportedPlatformsOnly = ref(true);
const showSupportedPlatformsOnly = ref(true)
const filterOptions = computed(() => {
const filters: Record<"gameVersion" | "platform", string[]> = {
const filters: Record<'gameVersion' | 'platform', string[]> = {
gameVersion: [],
platform: [],
};
}
filters.gameVersion = props.listedGameVersions
.filter((x) => {
return showSnapshots.value || hasOnlySnapshots.value ? true : x.release;
return showSnapshots.value || hasOnlySnapshots.value ? true : x.release
})
.map((x) => x.name);
.map((x) => x.name)
filters.platform = props.listedPlatforms
.filter((x) => {
return !showSupportedPlatformsOnly.value || hasOnlyUnsupportedPlatforms.value
? true
: x.isType;
: x.isType
})
.map((x) => x.name);
.map((x) => x.name)
return filters;
});
return filters
})
const selectedGameVersions = ref<string[]>([]);
const selectedPlatforms = ref<string[]>([]);
const selectedGameVersions = ref<string[]>([])
const selectedPlatforms = ref<string[]>([])
selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : [];
selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : [];
selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : []
selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : []
function updateFilters() {
emit("update:query", {
emit('update:query', {
g: selectedGameVersions.value,
l: selectedPlatforms.value,
});
})
}
defineExpose({
selectedGameVersions,
selectedPlatforms,
});
})
function getArrayOrString(x: string | (string | null)[]): string[] {
if (typeof x === "string") {
return [x];
if (typeof x === 'string') {
return [x]
} else {
return x.filter((item): item is string => item !== null);
return x.filter((item): item is string => item !== null)
}
}
</script>

View File

@ -75,11 +75,11 @@ import {
PackageOpenIcon,
RightArrowIcon,
TrashIcon,
} from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { computed, ref,shallowRef } from "vue";
import { renderToString } from "vue/server-renderer";
import { useRoute,useRouter } from "vue-router";
} from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { computed, ref, shallowRef } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { useRoute, useRouter } from 'vue-router'
import {
UiServersIconsCodeFileIcon,
@ -87,327 +87,324 @@ import {
UiServersIconsEarthIcon,
UiServersIconsImageFileIcon,
UiServersIconsTextFileIcon,
} from "#components";
import PaletteIcon from "~/assets/icons/palette.svg?component";
} from '#components'
import PaletteIcon from '~/assets/icons/palette.svg?component'
interface FileItemProps {
name: string;
type: "directory" | "file";
size?: number;
count?: number;
modified: number;
created: number;
path: string;
name: string
type: 'directory' | 'file'
size?: number
count?: number
modified: number
created: number
path: string
}
const props = defineProps<FileItemProps>();
const props = defineProps<FileItemProps>()
const emit = defineEmits<{
(
e: "rename" | "move" | "download" | "delete" | "edit" | "extract",
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: "contextmenu", x: number, y: number): void;
}>();
): void
(e: 'moveDirectTo', item: { name: string; type: string; path: string; destination: string }): void
(e: 'contextmenu', x: number, y: number): void
}>()
const isDragOver = ref(false);
const isDragging = ref(false);
const isDragOver = ref(false)
const isDragging = ref(false)
const codeExtensions = Object.freeze([
"json",
"json5",
"jsonc",
"java",
"kt",
"kts",
"sh",
"bat",
"ps1",
"yml",
"yaml",
"toml",
"js",
"ts",
"py",
"rb",
"php",
"html",
"css",
"cpp",
"c",
"h",
"rs",
"go",
]);
'json',
'json5',
'jsonc',
'java',
'kt',
'kts',
'sh',
'bat',
'ps1',
'yml',
'yaml',
'toml',
'js',
'ts',
'py',
'rb',
'php',
'html',
'css',
'cpp',
'c',
'h',
'rs',
'go',
])
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 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());
const router = useRouter();
const route = shallowRef(useRoute())
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" : "",
]);
'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() || "");
const fileExtension = computed(() => props.name.split('.').pop()?.toLowerCase() || '')
const isZip = computed(() => fileExtension.value === "zip");
const isZip = computed(() => fileExtension.value === 'zip')
const menuOptions = computed(() => [
{
id: "extract",
id: 'extract',
shown: isZip.value,
action: () => emit("extract", { name: props.name, type: props.type, path: props.path }),
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 }),
id: 'rename',
action: () => emit('rename', { name: props.name, type: props.type, path: props.path }),
},
{
id: "move",
action: () => emit("move", { name: props.name, type: props.type, path: props.path }),
id: 'move',
action: () => emit('move', { name: props.name, type: props.type, path: props.path }),
},
{
id: "download",
action: () => emit("download", { name: props.name, type: props.type, path: props.path }),
shown: props.type !== "directory",
id: 'download',
action: () => emit('download', { name: props.name, type: props.type, path: props.path }),
shown: props.type !== 'directory',
},
{
id: "delete",
action: () => emit("delete", { name: props.name, type: props.type, path: props.path }),
color: "red" as const,
id: 'delete',
action: () => emit('delete', { name: props.name, type: props.type, path: props.path }),
color: 'red' as const,
},
]);
])
const iconComponent = computed(() => {
if (props.type === "directory") {
if (props.name === "config") return UiServersIconsCogFolderIcon;
if (props.name === "world") return UiServersIconsEarthIcon;
if (props.name === "resourcepacks") return PaletteIcon;
return FolderOpenIcon;
if (props.type === 'directory') {
if (props.name === 'config') return UiServersIconsCogFolderIcon
if (props.name === 'world') return UiServersIconsEarthIcon
if (props.name === 'resourcepacks') return PaletteIcon
return FolderOpenIcon
}
const ext = fileExtension.value;
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;
});
const ext = fileExtension.value
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
})
const subText = computed(() => {
if (props.type === "directory") {
return `${props.count} ${props.count === 1 ? "item" : "items"}`;
if (props.type === 'directory') {
return `${props.count} ${props.count === 1 ? 'item' : 'items'}`
}
return formattedSize.value;
});
return formattedSize.value
})
const formattedModifiedDate = computed(() => {
const date = new Date(props.modified * 1000);
return `${date.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "2-digit",
})}, ${date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "numeric",
const date = new Date(props.modified * 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 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",
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;
if (props.type === 'file') {
const ext = fileExtension.value
return (
!props.name.includes(".") ||
!props.name.includes('.') ||
textExtensions.includes(ext) ||
codeExtensions.includes(ext) ||
imageExtensions.includes(ext)
);
)
}
return false;
});
return false
})
const formattedSize = computed(() => {
if (props.size === undefined) return "";
const bytes = props.size;
if (bytes === 0) return "0 B";
if (props.size === undefined) return ''
const bytes = props.size
if (bytes === 0) return '0 B'
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const size = (bytes / Math.pow(1024, exponent)).toFixed(2);
return `${size} ${units[exponent]}`;
});
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const size = (bytes / Math.pow(1024, exponent)).toFixed(2)
return `${size} ${units[exponent]}`
})
const openContextMenu = (event: MouseEvent) => {
event.preventDefault();
emit("contextmenu", event.clientX, event.clientY);
};
event.preventDefault()
emit('contextmenu', event.clientX, event.clientY)
}
const navigateToFolder = () => {
const currentPath = route.value.query.path?.toString() || "";
const newPath = currentPath.endsWith("/")
const currentPath = route.value.query.path?.toString() || ''
const newPath = currentPath.endsWith('/')
? `${currentPath}${props.name}`
: `${currentPath}/${props.name}`;
router.push({ query: { path: newPath, page: 1 } });
};
: `${currentPath}/${props.name}`
router.push({ query: { path: newPath, page: 1 } })
}
const isNavigating = ref(false);
const isNavigating = ref(false)
const selectItem = () => {
if (isNavigating.value) return;
isNavigating.value = true;
if (isNavigating.value) return
isNavigating.value = true
if (props.type === "directory") {
navigateToFolder();
} else if (props.type === "file" && isEditableFile.value) {
emit("edit", { name: props.name, type: props.type, path: props.path });
if (props.type === 'directory') {
navigateToFolder()
} else if (props.type === 'file' && isEditableFile.value) {
emit('edit', { name: props.name, type: props.type, path: props.path })
}
setTimeout(() => {
isNavigating.value = false;
}, 500);
};
isNavigating.value = false
}, 500)
}
const getDragIcon = async () => {
let iconToUse;
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;
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;
iconToUse = FolderOpenIcon
}
} else {
const ext = fileExtension.value;
const ext = fileExtension.value
if (codeExtensions.includes(ext)) {
iconToUse = UiServersIconsCodeFileIcon;
iconToUse = UiServersIconsCodeFileIcon
} else if (textExtensions.includes(ext)) {
iconToUse = UiServersIconsTextFileIcon;
iconToUse = UiServersIconsTextFileIcon
} else if (imageExtensions.includes(ext)) {
iconToUse = UiServersIconsImageFileIcon;
iconToUse = UiServersIconsImageFileIcon
} else {
iconToUse = FileIcon;
iconToUse = FileIcon
}
}
return await renderToString(h(iconToUse));
};
return await renderToString(h(iconToUse))
}
const handleDragStart = async (event: DragEvent) => {
if (!event.dataTransfer) return;
isDragging.value = true;
if (!event.dataTransfer) return
isDragging.value = true
const dragGhost = document.createElement("div");
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";
'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 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 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;
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);
dragGhost.appendChild(iconContainer)
dragGhost.appendChild(nameSpan)
document.body.appendChild(dragGhost)
event.dataTransfer.setDragImage(dragGhost, 0, 0);
event.dataTransfer.setDragImage(dragGhost, 0, 0)
requestAnimationFrame(() => {
document.body.removeChild(dragGhost);
});
document.body.removeChild(dragGhost)
})
event.dataTransfer.setData(
"application/pyro-file-move",
'application/pyro-file-move',
JSON.stringify({
name: props.name,
type: props.type,
path: props.path,
}),
);
event.dataTransfer.effectAllowed = "move";
};
)
event.dataTransfer.effectAllowed = 'move'
}
const isChildPath = (parentPath: string, childPath: string) => {
return childPath.startsWith(parentPath + "/");
};
return childPath.startsWith(parentPath + '/')
}
const handleDragEnd = () => {
isDragging.value = false;
};
isDragging.value = false
}
const handleDragEnter = () => {
if (props.type !== "directory") return;
isDragOver.value = true;
};
if (props.type !== 'directory') return
isDragOver.value = true
}
const handleDragOver = (event: DragEvent) => {
if (props.type !== "directory" || !event.dataTransfer) return;
event.dataTransfer.dropEffect = "move";
};
if (props.type !== 'directory' || !event.dataTransfer) return
event.dataTransfer.dropEffect = 'move'
}
const handleDragLeave = () => {
isDragOver.value = false;
};
isDragOver.value = false
}
const handleDrop = (event: DragEvent) => {
isDragOver.value = false;
if (props.type !== "directory" || !event.dataTransfer) return;
isDragOver.value = false
if (props.type !== 'directory' || !event.dataTransfer) return
try {
const dragData = JSON.parse(event.dataTransfer.getData("application/pyro-file-move"));
const dragData = JSON.parse(event.dataTransfer.getData('application/pyro-file-move'))
if (dragData.path === props.path) return;
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;
if (dragData.type === 'directory' && isChildPath(dragData.path, props.path)) {
console.error('Cannot move a folder into its own subfolder')
return
}
emit("moveDirectTo", {
emit('moveDirectTo', {
name: dragData.name,
type: dragData.type,
path: dragData.path,
destination: props.path,
});
})
} catch (error) {
console.error("Error handling file drop:", error);
console.error('Error handling file drop:', error)
}
};
}
</script>

View File

@ -43,79 +43,79 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted,ref } from "vue";
import { computed, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps<{
items: any[];
}>();
items: any[]
}>()
const emit = defineEmits<{
(
e: "delete" | "rename" | "download" | "move" | "edit" | "moveDirectTo" | "extract",
e: 'delete' | 'rename' | 'download' | 'move' | 'edit' | 'moveDirectTo' | 'extract',
item: any,
): void;
(e: "contextmenu", item: any, x: number, y: number): void;
(e: "loadMore"): void;
}>();
): void
(e: 'contextmenu', item: any, x: number, y: number): void
(e: 'loadMore'): void
}>()
const ITEM_HEIGHT = 61;
const BUFFER_SIZE = 5;
const ITEM_HEIGHT = 61
const BUFFER_SIZE = 5
const listContainer = ref<HTMLElement | null>(null);
const windowScrollY = ref(0);
const windowHeight = ref(0);
const listContainer = ref<HTMLElement | null>(null)
const windowScrollY = ref(0)
const windowHeight = ref(0)
const totalHeight = computed(() => props.items.length * ITEM_HEIGHT);
const totalHeight = computed(() => props.items.length * ITEM_HEIGHT)
const visibleRange = computed(() => {
if (!listContainer.value) return { start: 0, end: 0 };
if (!listContainer.value) return { start: 0, end: 0 }
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY;
const relativeScrollTop = Math.max(0, windowScrollY.value - containerTop);
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY
const relativeScrollTop = Math.max(0, windowScrollY.value - containerTop)
const start = Math.floor(relativeScrollTop / ITEM_HEIGHT);
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT);
const start = Math.floor(relativeScrollTop / ITEM_HEIGHT)
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT)
return {
start: Math.max(0, start - BUFFER_SIZE),
end: Math.min(props.items.length, start + visibleCount + BUFFER_SIZE * 2),
};
});
}
})
const visibleTop = computed(() => {
return visibleRange.value.start * ITEM_HEIGHT;
});
return visibleRange.value.start * ITEM_HEIGHT
})
const visibleItems = computed(() => {
return props.items.slice(visibleRange.value.start, visibleRange.value.end);
});
return props.items.slice(visibleRange.value.start, visibleRange.value.end)
})
const handleScroll = () => {
windowScrollY.value = window.scrollY;
windowScrollY.value = window.scrollY
if (!listContainer.value) return;
if (!listContainer.value) return
const containerBottom = listContainer.value.getBoundingClientRect().bottom;
const remainingScroll = containerBottom - window.innerHeight;
const containerBottom = listContainer.value.getBoundingClientRect().bottom
const remainingScroll = containerBottom - window.innerHeight
if (remainingScroll < windowHeight.value * 0.2) {
emit("loadMore");
emit('loadMore')
}
};
}
const handleResize = () => {
windowHeight.value = window.innerHeight;
};
windowHeight.value = window.innerHeight
}
onMounted(() => {
windowHeight.value = window.innerHeight;
window.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleResize, { passive: true });
handleScroll();
});
windowHeight.value = window.innerHeight
window.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', handleResize, { passive: true })
handleScroll()
})
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleResize);
});
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
})
</script>

View File

@ -33,64 +33,64 @@
</template>
<script setup lang="ts">
import { PlusIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { computed, nextTick,ref } from "vue";
import { PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
const props = defineProps<{
type: "file" | "directory";
}>();
type: 'file' | 'directory'
}>()
const emit = defineEmits<{
(e: "create", name: string): void;
}>();
(e: 'create', name: string): void
}>()
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);
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)
const error = computed(() => {
if (!itemName.value) {
return "Name is required.";
return 'Name is required.'
}
if (props.type === "file") {
const validPattern = /^[a-zA-Z0-9-_.\s]+$/;
if (props.type === 'file') {
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
if (!validPattern.test(itemName.value)) {
return "Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.";
return 'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.'
}
} else {
const validPattern = /^[a-zA-Z0-9-_\s]+$/;
const validPattern = /^[a-zA-Z0-9-_\s]+$/
if (!validPattern.test(itemName.value)) {
return "Name must contain only alphanumeric characters, dashes, underscores, or spaces.";
return 'Name must contain only alphanumeric characters, dashes, underscores, or spaces.'
}
}
return "";
});
return ''
})
const handleSubmit = () => {
submitted.value = true;
submitted.value = true
if (!error.value) {
emit("create", itemName.value);
hide();
emit('create', itemName.value)
hide()
}
};
}
const show = () => {
itemName.value = "";
submitted.value = false;
modal.value?.show();
itemName.value = ''
submitted.value = false
modal.value?.show()
nextTick(() => {
setTimeout(() => {
createInput.value?.focus();
}, 100);
});
};
createInput.value?.focus()
}, 100)
})
}
const hide = () => {
modal.value?.hide();
};
modal.value?.hide()
}
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>

View File

@ -42,36 +42,36 @@
</template>
<script setup lang="ts">
import { FileIcon, FolderOpenIcon, TrashIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { FileIcon, FolderOpenIcon, TrashIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
defineProps<{
item: {
name: string;
type: string;
count?: number;
size?: number;
} | null;
}>();
name: string
type: string
count?: number
size?: number
} | null
}>()
const emit = defineEmits<{
(e: "delete"): void;
}>();
(e: 'delete'): void
}>()
const modal = ref<typeof NewModal>();
const modal = ref<typeof NewModal>()
const handleSubmit = () => {
emit("delete");
hide();
};
emit('delete')
hide()
}
const show = () => {
modal.value?.show();
};
modal.value?.show()
}
const hide = () => {
modal.value?.hide();
};
modal.value?.hide()
}
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>

View File

@ -15,7 +15,7 @@
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>
<p class="m-0">{{ state.errorMessage || 'Invalid or empty image file.' }}</p>
</div>
<img
v-show="isReady"
@ -53,20 +53,20 @@
</template>
<script setup lang="ts">
import { ZoomInIcon, ZoomOutIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { ZoomInIcon, ZoomOutIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 5;
const ZOOM_IN_FACTOR = 1.2;
const ZOOM_OUT_FACTOR = 0.8;
const INITIAL_SCALE = 0.5;
const MAX_IMAGE_DIMENSION = 4096;
const 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;
}>();
imageBlob: Blob
}>()
const state = ref({
scale: INITIAL_SCALE,
@ -77,102 +77,102 @@ const state = ref({
startY: 0,
isLoading: false,
hasError: false,
errorMessage: "",
});
errorMessage: '',
})
const imageRef = ref<HTMLImageElement | null>(null);
const container = ref<HTMLElement | null>(null);
const imageObjectUrl = ref("");
const rafId = ref(0);
const imageRef = ref<HTMLImageElement | null>(null)
const container = ref<HTMLElement | null>(null)
const imageObjectUrl = ref('')
const rafId = ref(0)
const isReady = computed(() => !state.value.isLoading && !state.value.hasError);
const 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",
}));
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;
state.value.hasError = true
state.value.errorMessage = `Image too large to view (max ${MAX_IMAGE_DIMENSION}x${MAX_IMAGE_DIMENSION} pixels)`
return false
}
return true;
};
return true
}
const updateImageUrl = (blob: Blob) => {
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value);
imageObjectUrl.value = URL.createObjectURL(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
return
}
state.value.isLoading = false;
reset();
};
state.value.isLoading = false
reset()
}
const handleImageError = () => {
state.value.isLoading = false;
state.value.hasError = true;
state.value.errorMessage = "Failed to load image";
};
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 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;
};
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;
};
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);
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;
});
};
state.value.translateX = e.clientX - state.value.startX
state.value.translateY = e.clientY - state.value.startY
})
}
const stopPan = () => {
state.value.isPanning = false;
};
state.value.isPanning = false
}
const handleWheel = (e: WheelEvent) => {
const delta = e.deltaY * -0.001;
const factor = 1 + delta;
zoom(factor);
};
const delta = e.deltaY * -0.001
const factor = 1 + delta
zoom(factor)
}
watch(
() => props.imageBlob,
(newBlob) => {
if (!newBlob) return;
state.value.isLoading = true;
state.value.hasError = false;
updateImageUrl(newBlob);
if (!newBlob) return
state.value.isLoading = true
state.value.hasError = false
updateImageUrl(newBlob)
},
);
)
onMounted(() => {
if (props.imageBlob) updateImageUrl(props.imageBlob);
});
if (props.imageBlob) updateImageUrl(props.imageBlob)
})
onUnmounted(() => {
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value);
cancelAnimationFrame(rafId.value);
});
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
cancelAnimationFrame(rafId.value)
})
</script>

View File

@ -51,15 +51,15 @@
</template>
<script setup lang="ts">
import ChevronDownIcon from "./icons/ChevronDownIcon.vue";
import ChevronUpIcon from "./icons/ChevronUpIcon.vue";
import ChevronDownIcon from './icons/ChevronDownIcon.vue'
import ChevronUpIcon from './icons/ChevronUpIcon.vue'
defineProps<{
sortField: string;
sortDesc: boolean;
}>();
sortField: string
sortDesc: boolean
}>()
defineEmits<{
(e: "sort", field: string): void;
}>();
(e: 'sort', field: string): void
}>()
</script>

View File

@ -37,46 +37,46 @@
</template>
<script setup lang="ts">
import { ArrowBigUpDashIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { computed,nextTick, ref } from "vue";
import { ArrowBigUpDashIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
const destinationInput = ref<HTMLInputElement | null>(null);
const destinationInput = ref<HTMLInputElement | null>(null)
const props = defineProps<{
item: { name: string } | null;
currentPath: string;
}>();
item: { name: string } | null
currentPath: string
}>()
const emit = defineEmits<{
(e: "move", destination: string): void;
}>();
(e: 'move', destination: string): void
}>()
const modal = ref<typeof NewModal>();
const destination = ref("");
const modal = ref<typeof NewModal>()
const destination = ref('')
const newpath = computed(() => {
const path = destination.value.replace("//", "/");
return path.startsWith("/") ? path : `/${path}`;
});
const path = destination.value.replace('//', '/')
return path.startsWith('/') ? path : `/${path}`
})
const handleSubmit = () => {
emit("move", newpath.value);
hide();
};
emit('move', newpath.value)
hide()
}
const show = () => {
destination.value = props.currentPath;
modal.value?.show();
destination.value = props.currentPath
modal.value?.show()
nextTick(() => {
setTimeout(() => {
destinationInput.value?.focus();
}, 100);
});
};
destinationInput.value?.focus()
}, 100)
})
}
const hide = () => {
modal.value?.hide();
};
modal.value?.hide()
}
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>

View File

@ -32,63 +32,63 @@
</template>
<script setup lang="ts">
import { EditIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { computed, nextTick,ref } from "vue";
import { EditIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
const props = defineProps<{
item: { name: string; type: string } | null;
}>();
item: { name: string; type: string } | null
}>()
const emit = defineEmits<{
(e: "rename", newName: string): void;
}>();
(e: 'rename', newName: string): void
}>()
const modal = ref<typeof NewModal>();
const renameInput = ref<HTMLInputElement | null>(null);
const itemName = ref("");
const submitted = ref(false);
const modal = ref<typeof NewModal>()
const renameInput = ref<HTMLInputElement | null>(null)
const itemName = ref('')
const submitted = ref(false)
const error = computed(() => {
if (!itemName.value) {
return "Name is required.";
return 'Name is required.'
}
if (props.item?.type === "file") {
const validPattern = /^[a-zA-Z0-9-_.\s]+$/;
if (props.item?.type === 'file') {
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
if (!validPattern.test(itemName.value)) {
return "Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.";
return 'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.'
}
} else {
const validPattern = /^[a-zA-Z0-9-_\s]+$/;
const validPattern = /^[a-zA-Z0-9-_\s]+$/
if (!validPattern.test(itemName.value)) {
return "Name must contain only alphanumeric characters, dashes, underscores, or spaces.";
return 'Name must contain only alphanumeric characters, dashes, underscores, or spaces.'
}
}
return "";
});
return ''
})
const handleSubmit = () => {
submitted.value = true;
submitted.value = true
if (!error.value) {
emit("rename", itemName.value);
hide();
emit('rename', itemName.value)
hide()
}
};
}
const show = (item: { name: string; type: string }) => {
itemName.value = item.name;
submitted.value = false;
modal.value?.show();
itemName.value = item.name
submitted.value = false
modal.value?.show()
nextTick(() => {
setTimeout(() => {
renameInput.value?.focus();
}, 100);
});
};
renameInput.value?.focus()
}, 100)
})
}
const hide = () => {
modal.value?.hide();
};
modal.value?.hide()
}
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>

View File

@ -27,30 +27,30 @@
</template>
<script setup lang="ts">
import { CheckIcon,XIcon } from "@modrinth/assets";
import { ConfirmModal } from "@modrinth/ui";
import { ref } from "vue";
import { CheckIcon, XIcon } from '@modrinth/assets'
import { ConfirmModal } from '@modrinth/ui'
import { ref } from 'vue'
const path = ref("");
const files = ref<string[]>([]);
const path = ref('')
const files = ref<string[]>([])
const emit = defineEmits<{
(e: "proceed", path: string): void;
}>();
(e: 'proceed', path: string): void
}>()
const modal = ref<typeof ConfirmModal>();
const modal = ref<typeof ConfirmModal>()
const hasMany = computed(() => files.value.length > 100);
const hasMany = computed(() => files.value.length > 100)
const show = (zipPath: string, conflictingFiles: string[]) => {
path.value = zipPath;
files.value = conflictingFiles;
modal.value?.show();
};
path.value = zipPath
files.value = conflictingFiles
modal.value?.show()
}
const proceed = () => {
emit("proceed", path.value);
};
emit('proceed', path.value)
}
defineExpose({ show });
defineExpose({ show })
</script>

View File

@ -16,7 +16,7 @@
<div class="text-center">
<UploadIcon class="mx-auto h-16 w-16" />
<p class="mt-2 text-xl">
Drop {{ type ? type.toLocaleLowerCase() : "file" }}s here to upload
Drop {{ type ? type.toLocaleLowerCase() : 'file' }}s here to upload
</p>
</div>
</div>
@ -24,52 +24,52 @@
</template>
<script setup lang="ts">
import { UploadIcon } from "@modrinth/assets";
import { ref } from "vue";
import { UploadIcon } from '@modrinth/assets'
import { ref } from 'vue'
const emit = defineEmits<{
(event: "filesDropped", files: File[]): void;
}>();
(event: 'filesDropped', files: File[]): void
}>()
defineProps<{
overlayClass?: string;
type?: string;
}>();
overlayClass?: string
type?: string
}>()
const isDragging = ref(false);
const dragCounter = ref(0);
const isDragging = ref(false)
const dragCounter = ref(0)
const handleDragEnter = (event: DragEvent) => {
event.preventDefault();
if (!event.dataTransfer?.types.includes("application/pyro-file-move")) {
dragCounter.value++;
isDragging.value = true;
event.preventDefault()
if (!event.dataTransfer?.types.includes('application/pyro-file-move')) {
dragCounter.value++
isDragging.value = true
}
};
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
};
event.preventDefault()
}
const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
dragCounter.value--;
event.preventDefault()
dragCounter.value--
if (dragCounter.value === 0) {
isDragging.value = false;
isDragging.value = false
}
};
}
const handleDrop = (event: DragEvent) => {
event.preventDefault();
isDragging.value = false;
dragCounter.value = 0;
event.preventDefault()
isDragging.value = false
dragCounter.value = 0
const isInternalMove = event.dataTransfer?.types.includes("application/pyro-file-move");
if (isInternalMove) return;
const isInternalMove = event.dataTransfer?.types.includes('application/pyro-file-move')
if (isInternalMove) return
const files = event.dataTransfer?.files;
const files = event.dataTransfer?.files
if (files) {
emit("filesDropped", Array.from(files));
emit('filesDropped', Array.from(files))
}
};
}
</script>

View File

@ -12,9 +12,9 @@
<FolderOpenIcon class="size-4" />
<span>
<span class="capitalize">
{{ props.fileType ? props.fileType : "File" }} uploads
{{ props.fileType ? props.fileType : 'File' }} uploads
</span>
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : '' }}</span>
</span>
</div>
</div>
@ -59,7 +59,7 @@
</template>
<template v-else-if="item.status === 'error-generic'">
<span class="text-red"
>Failed - {{ item.error?.message || "An unexpected error occured." }}</span
>Failed - {{ item.error?.message || 'An unexpected error occured.' }}</span
>
</template>
<template v-else-if="item.status === 'incorrect-type'">
@ -101,118 +101,118 @@
</template>
<script setup lang="ts">
import { CheckCircleIcon, FolderOpenIcon, XCircleIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { computed, nextTick,ref, watch } from "vue";
import { CheckCircleIcon, FolderOpenIcon, XCircleIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { computed, nextTick, ref, watch } from 'vue'
import type { FSModule } from "~/composables/servers/modules/fs.ts";
import type { FSModule } from '~/composables/servers/modules/fs.ts'
interface UploadItem {
file: File;
progress: number;
file: File
progress: number
status:
| "pending"
| "uploading"
| "completed"
| "error-file-exists"
| "error-generic"
| "cancelled"
| "incorrect-type";
size: string;
uploader?: any;
error?: Error;
| 'pending'
| 'uploading'
| 'completed'
| 'error-file-exists'
| 'error-generic'
| 'cancelled'
| 'incorrect-type'
size: string
uploader?: any
error?: Error
}
interface Props {
currentPath: string;
fileType?: string;
marginBottom?: number;
acceptedTypes?: Array<string>;
fs: FSModule;
currentPath: string
fileType?: string
marginBottom?: number
acceptedTypes?: Array<string>
fs: FSModule
}
defineOptions({
inheritAttrs: false,
});
})
const props = defineProps<Props>();
const props = defineProps<Props>()
const emit = defineEmits<{
(e: "uploadComplete"): void;
}>();
(e: 'uploadComplete'): void
}>()
const uploadStatusRef = ref<HTMLElement | null>(null);
const statusContentRef = ref<HTMLElement | null>(null);
const uploadQueue = ref<UploadItem[]>([]);
const uploadStatusRef = ref<HTMLElement | null>(null)
const statusContentRef = ref<HTMLElement | null>(null)
const uploadQueue = ref<UploadItem[]>([])
const isUploading = computed(() => uploadQueue.value.length > 0);
const isUploading = computed(() => uploadQueue.value.length > 0)
const activeUploads = computed(() =>
uploadQueue.value.filter((item) => item.status === "pending" || item.status === "uploading"),
);
uploadQueue.value.filter((item) => item.status === 'pending' || item.status === 'uploading'),
)
const onUploadStatusEnter = (el: Element) => {
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0);
(el as HTMLElement).style.height = "0";
void (el as HTMLElement).offsetHeight;
(el as HTMLElement).style.height = `${height}px`;
};
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0)
;(el as HTMLElement).style.height = '0'
void (el as HTMLElement).offsetHeight
;(el as HTMLElement).style.height = `${height}px`
}
const onUploadStatusLeave = (el: Element) => {
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0);
(el as HTMLElement).style.height = `${height}px`;
void (el as HTMLElement).offsetHeight;
(el as HTMLElement).style.height = "0";
};
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0)
;(el as HTMLElement).style.height = `${height}px`
void (el as HTMLElement).offsetHeight
;(el as HTMLElement).style.height = '0'
}
watch(
uploadQueue,
() => {
if (!uploadStatusRef.value) return;
const el = uploadStatusRef.value;
const itemsHeight = uploadQueue.value.length * 32;
const headerHeight = 12;
const gap = 8;
const padding = 32;
const totalHeight = padding + headerHeight + gap + itemsHeight + (props.marginBottom || 0);
el.style.height = `${totalHeight}px`;
if (!uploadStatusRef.value) return
const el = uploadStatusRef.value
const itemsHeight = uploadQueue.value.length * 32
const headerHeight = 12
const gap = 8
const padding = 32
const totalHeight = padding + headerHeight + gap + itemsHeight + (props.marginBottom || 0)
el.style.height = `${totalHeight}px`
},
{ deep: true },
);
)
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + " KB";
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + " MB";
return (bytes / 1024 ** 3).toFixed(1) + " GB";
};
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + ' KB'
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + ' MB'
return (bytes / 1024 ** 3).toFixed(1) + ' GB'
}
const cancelUpload = (item: UploadItem) => {
if (item.uploader && item.status === "uploading") {
item.uploader.cancel();
item.status = "cancelled";
if (item.uploader && item.status === 'uploading') {
item.uploader.cancel()
item.status = 'cancelled'
setTimeout(async () => {
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name);
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name)
if (index !== -1) {
uploadQueue.value.splice(index, 1);
await nextTick();
uploadQueue.value.splice(index, 1)
await nextTick()
}
}, 5000);
}, 5000)
}
};
}
const badFileTypeMsg = "Upload had incorrect file type";
const badFileTypeMsg = 'Upload had incorrect file type'
const uploadFile = async (file: File) => {
const uploadItem: UploadItem = {
file,
progress: 0,
status: "pending",
status: 'pending',
size: formatFileSize(file.size),
};
}
uploadQueue.value.push(uploadItem);
uploadQueue.value.push(uploadItem)
try {
if (
@ -220,82 +220,82 @@ const uploadFile = async (file: File) => {
!props.acceptedTypes.includes(file.type) &&
!props.acceptedTypes.some((type) => file.name.endsWith(type))
) {
throw new Error(badFileTypeMsg);
throw new Error(badFileTypeMsg)
}
uploadItem.status = "uploading";
const filePath = `${props.currentPath}/${file.name}`.replace("//", "/");
const uploader = await props.fs.uploadFile(filePath, file);
uploadItem.uploader = uploader;
uploadItem.status = 'uploading'
const filePath = `${props.currentPath}/${file.name}`.replace('//', '/')
const uploader = await props.fs.uploadFile(filePath, file)
uploadItem.uploader = uploader
if (uploader?.onProgress) {
uploader.onProgress(({ progress }: { progress: number }) => {
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (index !== -1) {
uploadQueue.value[index].progress = Math.round(progress);
uploadQueue.value[index].progress = Math.round(progress)
}
});
})
}
await uploader?.promise;
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
uploadQueue.value[index].status = "completed";
uploadQueue.value[index].progress = 100;
await uploader?.promise
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
uploadQueue.value[index].status = 'completed'
uploadQueue.value[index].progress = 100
}
await nextTick();
await nextTick()
setTimeout(async () => {
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (removeIndex !== -1) {
uploadQueue.value.splice(removeIndex, 1);
await nextTick();
uploadQueue.value.splice(removeIndex, 1)
await nextTick()
}
}, 5000);
}, 5000)
emit("uploadComplete");
emit('uploadComplete')
} catch (error) {
console.error("Error uploading file:", error);
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
const target = uploadQueue.value[index];
console.error('Error uploading file:', error)
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
const target = uploadQueue.value[index]
if (error instanceof Error) {
if (error.message === badFileTypeMsg) {
target.status = "incorrect-type";
} else if (target.progress === 100 && error.message.includes("401")) {
target.status = "error-file-exists";
target.status = 'incorrect-type'
} else if (target.progress === 100 && error.message.includes('401')) {
target.status = 'error-file-exists'
} else {
target.status = "error-generic";
target.error = error;
target.status = 'error-generic'
target.error = error
}
}
}
setTimeout(async () => {
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (removeIndex !== -1) {
uploadQueue.value.splice(removeIndex, 1);
await nextTick();
uploadQueue.value.splice(removeIndex, 1)
await nextTick()
}
}, 5000);
}, 5000)
if (error instanceof Error && error.message !== "Upload cancelled") {
if (error instanceof Error && error.message !== 'Upload cancelled') {
addNotification({
group: "files",
title: "Upload failed",
group: 'files',
title: 'Upload failed',
text: `Failed to upload ${file.name}`,
type: "error",
});
type: 'error',
})
}
}
};
}
defineExpose({
uploadFile,
cancelUpload,
});
})
</script>
<style scoped>

View File

@ -6,7 +6,7 @@
<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" }}
{{ 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>
@ -58,13 +58,13 @@
<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" }}
{{ submitted ? 'Installing...' : 'Install' }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
{{ submitted ? "Close" : "Cancel" }}
{{ submitted ? 'Close' : 'Cancel' }}
</button>
</ButtonStyled>
</div>
@ -73,84 +73,84 @@
</template>
<script setup lang="ts">
import { DownloadIcon, ExternalIcon, SpinnerIcon, XIcon } from "@modrinth/assets";
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
import { ModrinthServersFetchError } from "@modrinth/utils";
import { computed, nextTick,ref } from "vue";
import { DownloadIcon, ExternalIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { BackupWarning, ButtonStyled, NewModal } from '@modrinth/ui'
import { ModrinthServersFetchError } from '@modrinth/utils'
import { computed, nextTick, ref } from 'vue'
import type { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { handleError } from "~/composables/servers/modrinth-servers.ts";
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { handleError } from '~/composables/servers/modrinth-servers.ts'
const cf = ref(false);
const cf = ref(false)
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const modal = ref<typeof NewModal>();
const urlInput = ref<HTMLInputElement | null>(null);
const url = ref("");
const submitted = ref(false);
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 trimmedUrl = computed(() => url.value.trim())
const regex = /https:\/\/(www\.)?curseforge\.com\/minecraft\/modpacks\/[^/]+\/files\/\d+/;
const regex = /https:\/\/(www\.)?curseforge\.com\/minecraft\/modpacks\/[^/]+\/files\/\d+/
const error = computed(() => {
if (trimmedUrl.value.length === 0) {
return "URL is required.";
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 'URL must be a CurseForge modpack version URL.'
} else if (!cf.value && !trimmedUrl.value.includes('/')) {
return 'URL must be valid.'
}
return "";
});
return ''
})
const handleSubmit = async () => {
submitted.value = true;
submitted.value = true
if (!error.value) {
// hide();
try {
const dry = await props.server.fs.extractFile(trimmedUrl.value, true, true);
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();
await props.server.fs.extractFile(trimmedUrl.value, true, false, true)
hide()
} else {
submitted.value = false;
submitted.value = false
handleError(
new ModrinthServersFetchError(
"Could not find CurseForge modpack at that URL.",
'Could not find CurseForge modpack at that URL.',
404,
new Error(`No modpack found at ${url.value}`),
),
);
)
}
} catch (error) {
submitted.value = false;
console.error("Error installing:", error);
handleError(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();
cf.value = isCf
url.value = ''
submitted.value = false
modal.value?.show()
nextTick(() => {
setTimeout(() => {
urlInput.value?.focus();
}, 100);
});
};
urlInput.value?.focus()
}, 100)
})
}
const hide = () => {
modal.value?.hide();
};
modal.value?.hide()
}
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>

View File

@ -42,20 +42,20 @@
</template>
<script setup>
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { onMounted, onUnmounted,ref } from "vue";
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { onMounted, onUnmounted, ref } from 'vue'
const container = ref(null);
const showLabels = ref(false);
const container = ref(null)
const showLabels = ref(false)
const locations = ref([
// Active locations
{ name: "New York", lat: 40.7128, lng: -74.006, active: true, clicked: false },
{ name: "Los Angeles", lat: 34.0522, lng: -118.2437, active: true, clicked: false },
{ name: "Miami", lat: 25.7617, lng: -80.1918, active: true, clicked: false },
{ name: "Spokane", lat: 47.667309, lng: -117.411922, active: true, clicked: false },
{ name: "Dallas", lat: 32.78372, lng: -96.7947, active: true, clicked: false },
{ name: 'New York', lat: 40.7128, lng: -74.006, active: true, clicked: false },
{ name: 'Los Angeles', lat: 34.0522, lng: -118.2437, active: true, clicked: false },
{ name: 'Miami', lat: 25.7617, lng: -80.1918, active: true, clicked: false },
{ name: 'Spokane', lat: 47.667309, lng: -117.411922, active: true, clicked: false },
{ name: 'Dallas', lat: 32.78372, lng: -96.7947, active: true, clicked: false },
// Future Locations
// { name: "London", lat: 51.5074, lng: -0.1278, active: false, clicked: false },
// { name: "Frankfurt", lat: 50.1109, lng: 8.6821, active: false, clicked: false },
@ -66,60 +66,60 @@ const locations = ref([
// { name: "Sydney", lat: -33.8688, lng: 151.2093, active: false, clicked: false },
// { name: "São Paulo", lat: -23.5505, lng: -46.6333, active: false, clicked: false },
// { name: "Toronto", lat: 43.6532, lng: -79.3832, active: false, clicked: false },
]);
])
const isLocationVisible = (location) => {
if (!location.screenPosition || !globe) return false;
if (!location.screenPosition || !globe) return false
const vector = latLngToVector3(location.lat, location.lng).clone();
vector.applyMatrix4(globe.matrixWorld);
const vector = latLngToVector3(location.lat, location.lng).clone()
vector.applyMatrix4(globe.matrixWorld)
const cameraVector = new THREE.Vector3();
camera.getWorldPosition(cameraVector);
const cameraVector = new THREE.Vector3()
camera.getWorldPosition(cameraVector)
const viewVector = vector.clone().sub(cameraVector).normalize();
const viewVector = vector.clone().sub(cameraVector).normalize()
const normal = vector.clone().normalize();
const normal = vector.clone().normalize()
const dotProduct = normal.dot(viewVector);
const dotProduct = normal.dot(viewVector)
return dotProduct < -0.15;
};
return dotProduct < -0.15
}
const toggleLocationClicked = (location) => {
console.log("clicked", location.name);
locations.value.find((loc) => loc.name === location.name).clicked = !location.clicked;
};
console.log('clicked', location.name)
locations.value.find((loc) => loc.name === location.name).clicked = !location.clicked
}
let scene, camera, renderer, globe, controls;
let animationFrame;
let scene, camera, renderer, globe, controls
let animationFrame
const init = () => {
scene = new THREE.Scene();
scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera(
45,
container.value.clientWidth / container.value.clientHeight,
0.1,
1000,
);
)
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: "low-power",
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(container.value.clientWidth, container.value.clientHeight);
container.value.appendChild(renderer.domElement);
powerPreference: 'low-power',
})
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setSize(container.value.clientWidth, container.value.clientHeight)
container.value.appendChild(renderer.domElement)
const geometry = new THREE.SphereGeometry(5, 64, 64);
const outlineTexture = new THREE.TextureLoader().load("/earth-outline.png");
outlineTexture.minFilter = THREE.LinearFilter;
outlineTexture.magFilter = THREE.LinearFilter;
const geometry = new THREE.SphereGeometry(5, 64, 64)
const outlineTexture = new THREE.TextureLoader().load('/earth-outline.png')
outlineTexture.minFilter = THREE.LinearFilter
outlineTexture.magFilter = THREE.LinearFilter
const material = new THREE.ShaderMaterial({
uniforms: {
outlineTexture: { value: outlineTexture },
globeColor: { value: new THREE.Color("#60fbb5") },
globeColor: { value: new THREE.Color('#60fbb5') },
},
vertexShader: `
varying vec2 vUv;
@ -141,17 +141,17 @@ const init = () => {
`,
transparent: true,
side: THREE.FrontSide,
});
})
globe = new THREE.Mesh(geometry, material);
scene.add(globe);
globe = new THREE.Mesh(geometry, material)
scene.add(globe)
const atmosphereGeometry = new THREE.SphereGeometry(5.2, 64, 64);
const atmosphereGeometry = new THREE.SphereGeometry(5.2, 64, 64)
const atmosphereMaterial = new THREE.ShaderMaterial({
transparent: true,
side: THREE.BackSide,
uniforms: {
color: { value: new THREE.Color("#56f690") },
color: { value: new THREE.Color('#56f690') },
viewVector: { value: camera.position },
},
vertexShader: `
@ -171,92 +171,92 @@ const init = () => {
gl_FragColor = vec4(color, intensity * 0.4);
}
`,
});
})
const atmosphere = new THREE.Mesh(atmosphereGeometry, atmosphereMaterial);
scene.add(atmosphere);
const atmosphere = new THREE.Mesh(atmosphereGeometry, atmosphereMaterial)
scene.add(atmosphere)
const ambientLight = new THREE.AmbientLight(0x404040, 0.5);
scene.add(ambientLight);
const ambientLight = new THREE.AmbientLight(0x404040, 0.5)
scene.add(ambientLight)
camera.position.z = 15;
camera.position.z = 15
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.rotateSpeed = 0.3;
controls.enableZoom = false;
controls.enablePan = false;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.05;
controls.minPolarAngle = Math.PI * 0.3;
controls.maxPolarAngle = Math.PI * 0.7;
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.rotateSpeed = 0.3
controls.enableZoom = false
controls.enablePan = false
controls.autoRotate = true
controls.autoRotateSpeed = 0.05
controls.minPolarAngle = Math.PI * 0.3
controls.maxPolarAngle = Math.PI * 0.7
globe.rotation.y = Math.PI * 1.9;
globe.rotation.x = Math.PI * 0.15;
};
globe.rotation.y = Math.PI * 1.9
globe.rotation.x = Math.PI * 0.15
}
const animate = () => {
animationFrame = requestAnimationFrame(animate);
controls.update();
animationFrame = requestAnimationFrame(animate)
controls.update()
locations.value.forEach((location) => {
const position = latLngToVector3(location.lat, location.lng);
const vector = position.clone();
vector.applyMatrix4(globe.matrixWorld);
const position = latLngToVector3(location.lat, location.lng)
const vector = position.clone()
vector.applyMatrix4(globe.matrixWorld)
const coords = vector.project(camera);
const coords = vector.project(camera)
const screenPosition = {
x: (coords.x * 0.5 + 0.5) * container.value.clientWidth,
y: (-coords.y * 0.5 + 0.5) * container.value.clientHeight,
};
location.screenPosition = screenPosition;
});
}
location.screenPosition = screenPosition
})
renderer.render(scene, camera);
};
renderer.render(scene, camera)
}
const latLngToVector3 = (lat, lng) => {
const phi = (90 - lat) * (Math.PI / 180);
const theta = (lng + 180) * (Math.PI / 180);
const radius = 5;
const phi = (90 - lat) * (Math.PI / 180)
const theta = (lng + 180) * (Math.PI / 180)
const radius = 5
return new THREE.Vector3(
-radius * Math.sin(phi) * Math.cos(theta),
radius * Math.cos(phi),
radius * Math.sin(phi) * Math.sin(theta),
);
};
)
}
const handleResize = () => {
if (!container.value) return;
camera.aspect = container.value.clientWidth / container.value.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.value.clientWidth, container.value.clientHeight);
};
if (!container.value) return
camera.aspect = container.value.clientWidth / container.value.clientHeight
camera.updateProjectionMatrix()
renderer.setSize(container.value.clientWidth, container.value.clientHeight)
}
onMounted(() => {
init();
animate();
window.addEventListener("resize", handleResize);
init()
animate()
window.addEventListener('resize', handleResize)
setTimeout(() => {
showLabels.value = true;
}, 1000);
});
showLabels.value = true
}, 1000)
})
onUnmounted(() => {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
cancelAnimationFrame(animationFrame)
}
window.removeEventListener("resize", handleResize);
window.removeEventListener('resize', handleResize)
if (renderer) {
renderer.dispose();
renderer.dispose()
}
if (container.value) {
container.value.innerHTML = "";
container.value.innerHTML = ''
}
});
})
</script>
<style scoped>

View File

@ -14,31 +14,31 @@
</template>
<script setup lang="ts">
import { onMounted, onUnmounted,ref } from "vue";
import { onMounted, onUnmounted, ref } from 'vue'
const msgs = [
"Organizing files...",
"Downloading mods...",
"Configuring server...",
"Setting up environment...",
"Adding Java...",
];
'Organizing files...',
'Downloading mods...',
'Configuring server...',
'Setting up environment...',
'Adding Java...',
]
const currentIndex = ref(0);
const currentIndex = ref(0)
let intervalId: NodeJS.Timeout | null = null;
let intervalId: NodeJS.Timeout | null = null
onMounted(() => {
intervalId = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % msgs.length;
}, 3000);
});
currentIndex.value = (currentIndex.value + 1) % msgs.length
}, 3000)
})
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId);
clearInterval(intervalId)
}
});
})
</script>
<style scoped>

View File

@ -60,36 +60,36 @@
<script setup lang="ts">
const props = defineProps<{
data: {
loader: string | null;
loader_version: string | null;
};
ignoreCurrentInstallation?: boolean;
isInstalling?: boolean;
}>();
loader: string | null
loader_version: string | null
}
ignoreCurrentInstallation?: boolean
isInstalling?: boolean
}>()
const emit = defineEmits<{
(e: "selectLoader", loader: string): void;
}>();
(e: 'selectLoader', loader: string): void
}>()
const vanillaLoaders = [{ 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" },
];
{ 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" },
];
{ name: 'Paper' as const, displayName: 'Paper' },
{ name: 'Purpur' as const, displayName: 'Purpur' },
]
const isCurrentLoader = (loaderName: string) => {
return props.data.loader?.toLowerCase() === loaderName.toLowerCase();
};
return props.data.loader?.toLowerCase() === loaderName.toLowerCase()
}
const selectLoader = (loader: string) => {
emit("selectLoader", loader);
};
emit('selectLoader', loader)
}
</script>

View File

@ -33,39 +33,39 @@
<ButtonStyled>
<button :disabled="isInstalling" @click="onSelect">
<DownloadIcon class="h-5 w-5" />
{{ isCurrentLoader ? "Reinstall" : "Install" }}
{{ isCurrentLoader ? 'Reinstall' : 'Install' }}
</button>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { CheckIcon, DownloadIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { CheckIcon, DownloadIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
interface LoaderInfo {
name: "Vanilla" | "Fabric" | "Forge" | "Quilt" | "Paper" | "NeoForge" | "Purpur";
displayName: string;
name: 'Vanilla' | 'Fabric' | 'Forge' | 'Quilt' | 'Paper' | 'NeoForge' | 'Purpur'
displayName: string
}
interface Props {
loader: LoaderInfo;
currentLoader: string | null;
loaderVersion: string | null;
isInstalling?: boolean;
loader: LoaderInfo
currentLoader: string | null
loaderVersion: string | null
isInstalling?: boolean
}
const props = defineProps<Props>();
const props = defineProps<Props>()
const emit = defineEmits<{
(e: "select", loader: string): void;
}>();
(e: 'select', loader: string): void
}>()
const isCurrentLoader = computed(() => {
return props.currentLoader?.toLowerCase() === props.loader.name.toLowerCase();
});
return props.currentLoader?.toLowerCase() === props.loader.name.toLowerCase()
})
const onSelect = () => {
emit("select", props.loader.name);
};
emit('select', props.loader.name)
}
</script>

View File

@ -19,54 +19,54 @@
</template>
<script setup lang="ts">
import Convert from "ansi-to-html";
import DOMPurify from "dompurify";
import { computed, onMounted, onUnmounted,ref } from "vue";
import Convert from 'ansi-to-html'
import DOMPurify from 'dompurify'
import { computed, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps<{
log: string;
}>();
log: string
}>()
defineEmits<{
"show-full-log": [log: string];
}>();
'show-full-log': [log: string]
}>()
const logContent = ref<HTMLElement | null>(null);
const isOverflowing = ref(false);
const logContent = ref<HTMLElement | null>(null)
const isOverflowing = ref(false)
const checkOverflow = () => {
if (logContent.value && !isOverflowing.value) {
isOverflowing.value = logContent.value.scrollWidth > logContent.value.clientWidth;
isOverflowing.value = logContent.value.scrollWidth > logContent.value.clientWidth
}
};
}
const convert = new Convert({
fg: "#FFF",
bg: "#000",
fg: '#FFF',
bg: '#000',
newline: false,
escapeXML: true,
stream: false,
});
})
const sanitizedLog = computed(() =>
DOMPurify.sanitize(convert.toHtml(props.log), {
ALLOWED_TAGS: ["span"],
ALLOWED_ATTR: ["style"],
ALLOWED_TAGS: ['span'],
ALLOWED_ATTR: ['style'],
USE_PROFILES: { html: true },
}),
);
)
const preventSelection = (e: MouseEvent) => {
e.preventDefault();
};
e.preventDefault()
}
onMounted(() => {
logContent.value?.addEventListener("mousedown", preventSelection);
});
logContent.value?.addEventListener('mousedown', preventSelection)
})
onUnmounted(() => {
logContent.value?.removeEventListener("mousedown", preventSelection);
});
logContent.value?.removeEventListener('mousedown', preventSelection)
})
</script>
<style scoped>

View File

@ -62,7 +62,7 @@
<button :disabled="!canTakeAction" @click="initiateAction('Stop')">
<div class="flex gap-1">
<StopCircleIcon class="h-5 w-5" />
<span>{{ isStoppingState ? "Stopping..." : "Stop" }}</span>
<span>{{ isStoppingState ? 'Stopping...' : 'Stop' }}</span>
</div>
</button>
</ButtonStyled>
@ -115,177 +115,177 @@ import {
StopCircleIcon,
UpdatedIcon,
XIcon,
} from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import type { PowerAction as ServerPowerAction, ServerState } from "@modrinth/utils";
import { useStorage } from "@vueuse/core";
import { computed,ref } from "vue";
import { useRouter } from "vue-router";
} from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
import type { PowerAction as ServerPowerAction, ServerState } from '@modrinth/utils'
import { useStorage } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
const flags = useFeatureFlags();
const flags = useFeatureFlags()
interface PowerAction {
action: ServerPowerAction;
nextState: ServerState;
action: ServerPowerAction
nextState: ServerState
}
const props = defineProps<{
isOnline: boolean;
isActioning: boolean;
isInstalling: boolean;
disabled: boolean;
serverName?: string;
serverData: object;
uptimeSeconds: number;
}>();
isOnline: boolean
isActioning: boolean
isInstalling: boolean
disabled: boolean
serverName?: string
serverData: object
uptimeSeconds: number
}>()
const emit = defineEmits<{
(e: "action", action: ServerPowerAction): void;
}>();
(e: 'action', action: ServerPowerAction): void
}>()
const router = useRouter();
const serverId = router.currentRoute.value.params.id;
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
const router = useRouter()
const serverId = router.currentRoute.value.params.id
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null)
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null)
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
powerDontAskAgain: false,
});
})
const serverState = ref<ServerState>(props.isOnline ? "running" : "stopped");
const powerAction = ref<PowerAction | null>(null);
const dontAskAgain = ref(false);
const startingDelay = ref(false);
const serverState = ref<ServerState>(props.isOnline ? 'running' : 'stopped')
const powerAction = ref<PowerAction | null>(null)
const dontAskAgain = ref(false)
const startingDelay = ref(false)
const canTakeAction = computed(
() => !props.isActioning && !startingDelay.value && !isTransitionState.value,
);
const isRunning = computed(() => serverState.value === "running");
)
const isRunning = computed(() => serverState.value === 'running')
const isTransitionState = computed(() =>
["starting", "stopping", "restarting"].includes(serverState.value),
);
const isStoppingState = computed(() => serverState.value === "stopping");
const showStopButton = computed(() => isRunning.value || isStoppingState.value);
['starting', 'stopping', 'restarting'].includes(serverState.value),
)
const isStoppingState = computed(() => serverState.value === 'stopping')
const showStopButton = computed(() => isRunning.value || isStoppingState.value)
const primaryActionText = computed(() => {
const states: Partial<Record<ServerState, string>> = {
starting: "Starting...",
restarting: "Restarting...",
running: "Restart",
stopping: "Stopping...",
stopped: "Start",
};
return states[serverState.value];
});
starting: 'Starting...',
restarting: 'Restarting...',
running: 'Restart',
stopping: 'Stopping...',
stopped: 'Start',
}
return states[serverState.value]
})
const confirmActionText = computed(() => {
if (!powerAction.value) return "";
return powerAction.value.action.charAt(0).toUpperCase() + powerAction.value.action.slice(1);
});
if (!powerAction.value) return ''
return powerAction.value.action.charAt(0).toUpperCase() + powerAction.value.action.slice(1)
})
const menuOptions = computed(() => [
...(props.isInstalling
? []
: [
{
id: "kill",
label: "Kill server",
id: 'kill',
label: 'Kill server',
icon: SlashIcon,
action: () => initiateAction("Kill"),
action: () => initiateAction('Kill'),
},
]),
{
id: "allServers",
label: "All servers",
id: 'allServers',
label: 'All servers',
icon: ServerIcon,
action: () => router.push("/servers/manage"),
action: () => router.push('/servers/manage'),
},
{
id: "details",
label: "Details",
id: 'details',
label: 'Details',
icon: InfoIcon,
action: () => detailsModal.value?.show(),
},
{
id: "copy-id",
label: "Copy ID",
id: 'copy-id',
label: 'Copy ID',
icon: ClipboardCopyIcon,
action: () => copyId(),
shown: flags.value.developerMode,
},
]);
])
async function copyId() {
await navigator.clipboard.writeText(serverId as string);
await navigator.clipboard.writeText(serverId as string)
}
function initiateAction(action: ServerPowerAction) {
if (!canTakeAction.value) return;
if (!canTakeAction.value) return
const stateMap: Record<ServerPowerAction, ServerState> = {
Start: "starting",
Stop: "stopping",
Restart: "restarting",
Kill: "stopping",
};
if (action === "Start") {
emit("action", action);
serverState.value = stateMap[action];
startingDelay.value = true;
setTimeout(() => (startingDelay.value = false), 5000);
return;
Start: 'starting',
Stop: 'stopping',
Restart: 'restarting',
Kill: 'stopping',
}
powerAction.value = { action, nextState: stateMap[action] };
if (action === 'Start') {
emit('action', action)
serverState.value = stateMap[action]
startingDelay.value = true
setTimeout(() => (startingDelay.value = false), 5000)
return
}
powerAction.value = { action, nextState: stateMap[action] }
if (userPreferences.value.powerDontAskAgain) {
executePowerAction();
executePowerAction()
} else {
confirmActionModal.value?.show();
confirmActionModal.value?.show()
}
}
function handlePrimaryAction() {
initiateAction(isRunning.value ? "Restart" : "Start");
initiateAction(isRunning.value ? 'Restart' : 'Start')
}
function executePowerAction() {
if (!powerAction.value) return;
if (!powerAction.value) return
const { action, nextState } = powerAction.value;
emit("action", action);
serverState.value = nextState;
const { action, nextState } = powerAction.value
emit('action', action)
serverState.value = nextState
if (dontAskAgain.value) {
userPreferences.value.powerDontAskAgain = true;
userPreferences.value.powerDontAskAgain = true
}
if (action === "Start") {
startingDelay.value = true;
setTimeout(() => (startingDelay.value = false), 5000);
if (action === 'Start') {
startingDelay.value = true
setTimeout(() => (startingDelay.value = false), 5000)
}
resetPowerAction();
resetPowerAction()
}
function resetPowerAction() {
confirmActionModal.value?.hide();
powerAction.value = null;
dontAskAgain.value = false;
confirmActionModal.value?.hide()
powerAction.value = null
dontAskAgain.value = false
}
function closeDetailsModal() {
detailsModal.value?.hide();
detailsModal.value?.hide()
}
watch(
() => props.isOnline,
(online) => (serverState.value = online ? "running" : "stopped"),
);
(online) => (serverState.value = online ? 'running' : 'stopped'),
)
watch(
() => router.currentRoute.value.fullPath,
() => closeDetailsModal(),
);
)
</script>

View File

@ -39,37 +39,37 @@
</template>
<script setup lang="ts">
import type { ServerState } from "@modrinth/utils";
import { ref } from "vue";
import type { ServerState } from '@modrinth/utils'
import { ref } from 'vue'
const STATUS_CLASSES = {
running: { main: "bg-brand", bg: "bg-bg-green" },
stopped: { main: "", bg: "" },
crashed: { main: "bg-brand-red", bg: "bg-bg-red" },
unknown: { main: "", bg: "" },
} as const;
running: { main: 'bg-brand', bg: 'bg-bg-green' },
stopped: { main: '', bg: '' },
crashed: { main: 'bg-brand-red', bg: 'bg-bg-red' },
unknown: { main: '', bg: '' },
} as const
const STATUS_TEXTS: Partial<Record<ServerState, string>> = {
running: "Running",
stopped: "",
crashed: "Crashed",
unknown: "Unknown",
} as const;
running: 'Running',
stopped: '',
crashed: 'Crashed',
unknown: 'Unknown',
} as const
defineProps<{
state: ServerState;
}>();
state: ServerState
}>()
const isExpanded = ref(false);
const isExpanded = ref(false)
function getStatusClass(state: ServerState) {
if (state in STATUS_CLASSES) {
return STATUS_CLASSES[state as keyof typeof STATUS_CLASSES];
return STATUS_CLASSES[state as keyof typeof STATUS_CLASSES]
}
return STATUS_CLASSES.unknown;
return STATUS_CLASSES.unknown
}
function getStatusText(state: ServerState) {
return STATUS_TEXTS[state] ?? STATUS_TEXTS.unknown;
return STATUS_TEXTS[state] ?? STATUS_TEXTS.unknown
}
</script>

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@
<div class="flex flex-col gap-4 md:w-[600px]">
<div class="flex flex-col gap-2">
<p class="m-0">
Select the version of {{ props.project?.title || "the modpack" }} you want to install on
Select the version of {{ props.project?.title || 'the modpack' }} you want to install on
your server.
</p>
<p v-if="props.currentVersion" class="m-0 text-sm text-secondary">
@ -51,7 +51,7 @@
@click="handleReinstall"
>
<DownloadIcon class="size-4" />
{{ isLoading ? "Installing..." : hardReset ? "Erase and install" : "Install" }}
{{ isLoading ? 'Installing...' : hardReset ? 'Erase and install' : 'Install' }}
</button>
</ButtonStyled>
<ButtonStyled>
@ -67,38 +67,38 @@
</template>
<script setup lang="ts">
import { DownloadIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { ModrinthServersFetchError } from "@modrinth/utils";
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
import { ModrinthServersFetchError } from '@modrinth/utils'
import type { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const props = defineProps<{
server: ModrinthServer;
project: any;
versions: any[];
currentVersion?: any;
currentVersionId?: string;
serverStatus?: string;
}>();
server: ModrinthServer
project: any
versions: any[]
currentVersion?: any
currentVersionId?: string
serverStatus?: string
}>()
const emit = defineEmits<{
reinstall: [any?];
}>();
reinstall: [any?]
}>()
const modal = ref();
const hardReset = ref(false);
const isLoading = ref(false);
const selectedVersion = ref("");
const modal = ref()
const hardReset = ref(false)
const isLoading = ref(false)
const selectedVersion = ref('')
const versionOptions = computed(() => props.versions?.map((v) => v.version_number) || []);
const versionOptions = computed(() => props.versions?.map((v) => v.version_number) || [])
const handleReinstall = async () => {
if (!selectedVersion.value || !props.project?.id) return;
if (!selectedVersion.value || !props.project?.id) return
isLoading.value = true;
isLoading.value = true
try {
const versionId = props.versions.find((v) => v.version_number === selectedVersion.value)?.id;
const versionId = props.versions.find((v) => v.version_number === selectedVersion.value)?.id
await props.server.general.reinstall(
false,
@ -106,56 +106,56 @@ const handleReinstall = async () => {
versionId,
undefined,
hardReset.value,
);
)
emit("reinstall");
hide();
emit('reinstall')
hide()
} catch (error) {
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
addNotification({
group: "server",
title: "Cannot reinstall server",
text: "You are being rate limited. Please try again later.",
type: "error",
});
group: 'server',
title: 'Cannot reinstall server',
text: 'You are being rate limited. Please try again later.',
type: 'error',
})
} else {
addNotification({
group: "server",
title: "Reinstall Failed",
text: "An unexpected error occurred while reinstalling. Please try again later.",
type: "error",
});
group: 'server',
title: 'Reinstall Failed',
text: 'An unexpected error occurred while reinstalling. Please try again later.',
type: 'error',
})
}
} finally {
isLoading.value = false;
isLoading.value = false
}
};
}
watch(
() => props.serverStatus,
(newStatus) => {
if (newStatus === "installing") {
hide();
if (newStatus === 'installing') {
hide()
}
},
);
)
const onShow = () => {
hardReset.value = false;
hardReset.value = false
selectedVersion.value =
props.currentVersion?.version_number ?? props.versions?.[0]?.version_number ?? "";
};
props.currentVersion?.version_number ?? props.versions?.[0]?.version_number ?? ''
}
const onHide = () => {
hardReset.value = false;
selectedVersion.value = "";
isLoading.value = false;
};
hardReset.value = false
selectedVersion.value = ''
isLoading.value = false
}
const show = () => modal.value?.show();
const hide = () => modal.value?.hide();
const show = () => modal.value?.show()
const hide = () => modal.value?.hide()
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>
<style scoped>

View File

@ -110,12 +110,12 @@
<RightArrowIcon />
{{
isMrpackModalSecondPhase
? "Erase and install"
? 'Erase and install'
: loadingServerCheck
? "Loading..."
? 'Loading...'
: isDangerous
? "Erase and install"
: "Install"
? 'Erase and install'
: 'Install'
}}
</button>
</ButtonStyled>
@ -125,15 +125,15 @@
@click="
() => {
if (isMrpackModalSecondPhase) {
isMrpackModalSecondPhase = false;
isMrpackModalSecondPhase = false
} else {
hide();
hide()
}
}
"
>
<XIcon />
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }}
{{ isMrpackModalSecondPhase ? 'Go back' : 'Cancel' }}
</button>
</ButtonStyled>
</div>
@ -150,200 +150,200 @@ import {
ServerIcon,
UploadIcon,
XIcon,
} from "@modrinth/assets";
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
import { formatBytes, ModrinthServersFetchError } from "@modrinth/utils";
import { onMounted, onUnmounted } from "vue";
} from '@modrinth/assets'
import { BackupWarning, ButtonStyled, NewModal } from '@modrinth/ui'
import { formatBytes, ModrinthServersFetchError } from '@modrinth/utils'
import { onMounted, onUnmounted } from 'vue'
import type { ModrinthServer } from "~/composables/servers/modrinth-servers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import type { ModrinthServer } from '~/composables/servers/modrinth-servers'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (isLoading.value) {
event.preventDefault();
return "Upload in progress. Are you sure you want to leave?";
event.preventDefault()
return 'Upload in progress. Are you sure you want to leave?'
}
};
}
onMounted(() => {
window.addEventListener("beforeunload", handleBeforeUnload);
});
window.addEventListener('beforeunload', handleBeforeUnload)
})
onUnmounted(() => {
window.removeEventListener("beforeunload", handleBeforeUnload);
});
window.removeEventListener('beforeunload', handleBeforeUnload)
})
const props = defineProps<{
server: ModrinthServer;
backupInProgress?: BackupInProgressReason;
}>();
server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
const emit = defineEmits<{
reinstall: [any?];
}>();
reinstall: [any?]
}>()
const mrpackModal = ref();
const isMrpackModalSecondPhase = ref(false);
const hardReset = ref(false);
const isLoading = ref(false);
const loadingServerCheck = ref(false);
const mrpackFile = ref<File | null>(null);
const uploadProgress = ref(0);
const uploadedBytes = ref(0);
const totalBytes = ref(0);
const mrpackModal = ref()
const isMrpackModalSecondPhase = ref(false)
const hardReset = ref(false)
const isLoading = ref(false)
const loadingServerCheck = ref(false)
const mrpackFile = ref<File | null>(null)
const uploadProgress = ref(0)
const uploadedBytes = ref(0)
const totalBytes = ref(0)
const uploadPhrases = [
"Removing Herobrine...",
"Feeding parrots...",
"Teaching villagers new trades...",
"Convincing creepers to be friendly...",
"Polishing diamonds...",
"Training wolves to fetch...",
"Building pixel art...",
"Explaining redstone to beginners...",
"Collecting all the cats...",
"Negotiating with endermen...",
"Planting suspicious stew ingredients...",
"Calibrating TNT blast radius...",
"Teaching chickens to fly...",
"Sorting inventory alphabetically...",
"Convincing iron golems to smile...",
];
'Removing Herobrine...',
'Feeding parrots...',
'Teaching villagers new trades...',
'Convincing creepers to be friendly...',
'Polishing diamonds...',
'Training wolves to fetch...',
'Building pixel art...',
'Explaining redstone to beginners...',
'Collecting all the cats...',
'Negotiating with endermen...',
'Planting suspicious stew ingredients...',
'Calibrating TNT blast radius...',
'Teaching chickens to fly...',
'Sorting inventory alphabetically...',
'Convincing iron golems to smile...',
]
const currentPhrase = ref("Uploading...");
let phraseInterval: NodeJS.Timeout | null = null;
const usedPhrases = ref(new Set<number>());
const currentPhrase = ref('Uploading...')
let phraseInterval: NodeJS.Timeout | null = null
const usedPhrases = ref(new Set<number>())
const getNextPhrase = () => {
if (usedPhrases.value.size >= uploadPhrases.length) {
const currentPhraseIndex = uploadPhrases.indexOf(currentPhrase.value);
usedPhrases.value.clear();
const currentPhraseIndex = uploadPhrases.indexOf(currentPhrase.value)
usedPhrases.value.clear()
if (currentPhraseIndex !== -1) {
usedPhrases.value.add(currentPhraseIndex);
usedPhrases.value.add(currentPhraseIndex)
}
}
const availableIndices = uploadPhrases
.map((_, index) => index)
.filter((index) => !usedPhrases.value.has(index));
.filter((index) => !usedPhrases.value.has(index))
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)];
usedPhrases.value.add(randomIndex);
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)]
usedPhrases.value.add(randomIndex)
return uploadPhrases[randomIndex];
};
return uploadPhrases[randomIndex]
}
const isDangerous = computed(() => hardReset.value);
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
const isDangerous = computed(() => hardReset.value)
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value)
const uploadMrpack = (event: Event) => {
const target = event.target as HTMLInputElement;
const target = event.target as HTMLInputElement
if (!target.files || target.files.length === 0) {
return;
return
}
mrpackFile.value = target.files[0];
};
mrpackFile.value = target.files[0]
}
const handleReinstall = async () => {
if (hardReset.value && !isMrpackModalSecondPhase.value) {
isMrpackModalSecondPhase.value = true;
return;
isMrpackModalSecondPhase.value = true
return
}
if (!mrpackFile.value) {
addNotification({
group: "server",
title: "No file selected",
text: "Choose a .mrpack file before installing.",
type: "error",
});
return;
group: 'server',
title: 'No file selected',
text: 'Choose a .mrpack file before installing.',
type: 'error',
})
return
}
isLoading.value = true;
uploadProgress.value = 0;
uploadProgress.value = 0;
uploadedBytes.value = 0;
totalBytes.value = mrpackFile.value.size;
isLoading.value = true
uploadProgress.value = 0
uploadProgress.value = 0
uploadedBytes.value = 0
totalBytes.value = mrpackFile.value.size
currentPhrase.value = getNextPhrase();
currentPhrase.value = getNextPhrase()
phraseInterval = setInterval(() => {
currentPhrase.value = getNextPhrase();
}, 4500);
currentPhrase.value = getNextPhrase()
}, 4500)
const { onProgress, promise } = props.server.general.reinstallFromMrpack(
mrpackFile.value,
hardReset.value,
);
)
onProgress(({ loaded, total, progress }) => {
uploadProgress.value = progress;
uploadedBytes.value = loaded;
totalBytes.value = total;
uploadProgress.value = progress
uploadedBytes.value = loaded
totalBytes.value = total
if (phraseInterval && progress >= 100) {
clearInterval(phraseInterval);
phraseInterval = null;
currentPhrase.value = "Installing modpack...";
clearInterval(phraseInterval)
phraseInterval = null
currentPhrase.value = 'Installing modpack...'
}
});
})
try {
await promise;
await promise
emit("reinstall", {
loader: "mrpack",
lVersion: "",
mVersion: "",
});
emit('reinstall', {
loader: 'mrpack',
lVersion: '',
mVersion: '',
})
await nextTick();
window.scrollTo(0, 0);
hide();
await nextTick()
window.scrollTo(0, 0)
hide()
} catch (error) {
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
addNotification({
group: "server",
title: "Cannot upload and install modpack to server",
text: "You are being rate limited. Please try again later.",
type: "error",
});
group: 'server',
title: 'Cannot upload and install modpack to server',
text: 'You are being rate limited. Please try again later.',
type: 'error',
})
} else {
addNotification({
group: "server",
title: "Modpack upload and install failed",
text: "An unexpected error occurred while uploading/installing. Please try again later.",
type: "error",
});
group: 'server',
title: 'Modpack upload and install failed',
text: 'An unexpected error occurred while uploading/installing. Please try again later.',
type: 'error',
})
}
} finally {
isLoading.value = false;
isLoading.value = false
if (phraseInterval) {
clearInterval(phraseInterval);
phraseInterval = null;
clearInterval(phraseInterval)
phraseInterval = null
}
}
};
}
const onShow = () => {
hardReset.value = false;
isMrpackModalSecondPhase.value = false;
loadingServerCheck.value = false;
isLoading.value = false;
mrpackFile.value = null;
uploadProgress.value = 0;
uploadedBytes.value = 0;
totalBytes.value = 0;
currentPhrase.value = "Uploading...";
usedPhrases.value.clear();
hardReset.value = false
isMrpackModalSecondPhase.value = false
loadingServerCheck.value = false
isLoading.value = false
mrpackFile.value = null
uploadProgress.value = 0
uploadedBytes.value = 0
totalBytes.value = 0
currentPhrase.value = 'Uploading...'
usedPhrases.value.clear()
if (phraseInterval) {
clearInterval(phraseInterval);
phraseInterval = null;
clearInterval(phraseInterval)
phraseInterval = null
}
};
}
const show = () => mrpackModal.value?.show();
const hide = () => mrpackModal.value?.hide();
const show = () => mrpackModal.value?.show()
const hide = () => mrpackModal.value?.hide()
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>
<style scoped>

View File

@ -20,7 +20,7 @@
}"
>
{{
"This will reinstall your server and erase all data. Are you sure you want to continue?"
'This will reinstall your server and erase all data. Are you sure you want to continue?'
}}
</p>
<div v-if="!isSecondPhase" class="flex flex-col gap-4">
@ -165,12 +165,12 @@
<RightArrowIcon />
{{
isLoading
? "Installing..."
? 'Installing...'
: isSecondPhase
? "Erase and install"
? 'Erase and install'
: hardReset
? "Continue"
: "Install"
? 'Continue'
: 'Install'
}}
</button>
</ButtonStyled>
@ -180,15 +180,15 @@
@click="
() => {
if (isSecondPhase) {
isSecondPhase = false;
isSecondPhase = false
} else {
hide();
hide()
}
}
"
>
<XIcon />
{{ isSecondPhase ? "Go back" : "Cancel" }}
{{ isSecondPhase ? 'Go back' : 'Cancel' }}
</button>
</ButtonStyled>
</div>
@ -197,170 +197,169 @@
</template>
<script setup lang="ts">
import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from "@modrinth/assets";
import { BackupWarning, ButtonStyled, NewModal, Toggle } from "@modrinth/ui";
import { type Loaders, ModrinthServersFetchError } from "@modrinth/utils";
import { $fetch } from "ofetch";
import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from '@modrinth/assets'
import { BackupWarning, ButtonStyled, NewModal, Toggle } from '@modrinth/ui'
import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
import { $fetch } from 'ofetch'
import type { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
const { formatMessage } = useVIntl();
const { formatMessage } = useVIntl()
interface LoaderVersion {
id: string;
stable: boolean;
id: string
stable: boolean
loaders: {
id: string;
url: string;
stable: boolean;
}[];
id: string
url: string
stable: boolean
}[]
}
type VersionMap = Record<string, LoaderVersion[]>;
type VersionCache = Record<string, any>;
type VersionMap = Record<string, LoaderVersion[]>
type VersionCache = Record<string, any>
const props = defineProps<{
server: ModrinthServer;
currentLoader: Loaders | undefined;
backupInProgress?: BackupInProgressReason;
initialSetup?: boolean;
}>();
server: ModrinthServer
currentLoader: Loaders | undefined
backupInProgress?: BackupInProgressReason
initialSetup?: boolean
}>()
const emit = defineEmits<{
reinstall: [any?];
}>();
reinstall: [any?]
}>()
const versionSelectModal = ref();
const isSecondPhase = ref(false);
const hardReset = ref(false);
const isLoading = ref(false);
const loadingServerCheck = ref(false);
const serverCheckError = ref("");
const showSnapshots = ref(false);
const versionSelectModal = ref()
const isSecondPhase = ref(false)
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("");
const selectedLoaderVersion = ref("");
const selectedLoader = ref<Loaders>('Vanilla')
const selectedMCVersion = ref('')
const selectedLoaderVersion = ref('')
const paperVersions = ref<Record<string, number[]>>({});
const purpurVersions = ref<Record<string, string[]>>({});
const loaderVersions = ref<VersionMap>({});
const cachedVersions = ref<VersionCache>({});
const paperVersions = ref<Record<string, number[]>>({})
const purpurVersions = ref<Record<string, string[]>>({})
const loaderVersions = ref<VersionMap>({})
const cachedVersions = ref<VersionCache>({})
const versionStrings = ["forge", "fabric", "quilt", "neo"] as const;
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;
const selected = tags.value.gameVersions.find((x) => x.version === selectedMCVersion.value)
if (selected?.version_type !== 'release') {
return true
}
}
return false;
});
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) => {
const runFetch = async (iterations: number) => {
if (iterations > 5) {
throw new Error("Failed to fetch loader versions");
throw new Error('Failed to fetch loader versions')
}
try {
const res = await getLoaderVersions(loader);
return { [loader]: (res as any).gameVersions };
const res = await getLoaderVersions(loader)
return { [loader]: (res as any).gameVersions }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_) {
return await runFetch(iterations + 1);
return await runFetch(iterations + 1)
}
};
}
try {
return await runFetch(0);
return await runFetch(0)
} catch (e) {
console.error(e);
return { [loader]: [] };
console.error(e)
return { [loader]: [] }
}
}),
);
)
loaderVersions.value = versions.reduce((acc, val) => ({ ...acc, ...val }), {});
};
loaderVersions.value = versions.reduce((acc, val) => ({ ...acc, ...val }), {})
}
const fetchPaperVersions = async (mcVersion: string) => {
try {
const res = await $fetch(`https://api.papermc.io/v2/projects/paper/versions/${mcVersion}`);
paperVersions.value[mcVersion] = (res as any).builds.sort((a: number, b: number) => b - a);
return res;
const res = await $fetch(`https://api.papermc.io/v2/projects/paper/versions/${mcVersion}`)
paperVersions.value[mcVersion] = (res as any).builds.sort((a: number, b: number) => b - a)
return res
} catch (e) {
console.error(e);
return null;
console.error(e)
return null
}
};
}
const fetchPurpurVersions = async (mcVersion: string) => {
try {
const res = await $fetch(`https://api.purpurmc.org/v2/purpur/${mcVersion}`);
const res = await $fetch(`https://api.purpurmc.org/v2/purpur/${mcVersion}`)
purpurVersions.value[mcVersion] = (res as any).builds.all.sort(
(a: string, b: string) => parseInt(b) - parseInt(a),
);
return res;
)
return res
} catch (e) {
console.error(e);
return null;
console.error(e)
return null
}
};
}
const selectedLoaderVersions = computed<string[]>(() => {
const loader = selectedLoader.value.toLowerCase();
const loader = selectedLoader.value.toLowerCase()
if (loader === "paper") {
return paperVersions.value[selectedMCVersion.value]?.map((x) => `${x}`) || [];
if (loader === 'paper') {
return paperVersions.value[selectedMCVersion.value]?.map((x) => `${x}`) || []
}
if (loader === "purpur") {
return purpurVersions.value[selectedMCVersion.value] || [];
if (loader === 'purpur') {
return purpurVersions.value[selectedMCVersion.value] || []
}
if (loader === "vanilla") {
return [];
if (loader === 'vanilla') {
return []
}
let apiLoader = loader;
if (loader === "neoforge") {
apiLoader = "neo";
let apiLoader = loader
if (loader === 'neoforge') {
apiLoader = 'neo'
}
const backwardsCompatibleVersion = loaderVersions.value[apiLoader]?.find(
(x) => x.id === "${modrinth.gameVersion}",
);
(x) => x.id === '${modrinth.gameVersion}',
)
if (backwardsCompatibleVersion) {
return backwardsCompatibleVersion.loaders.map((x) => x.id);
return backwardsCompatibleVersion.loaders.map((x) => x.id)
}
return (
loaderVersions.value[apiLoader]
?.find((x) => x.id === selectedMCVersion.value)
?.loaders.map((x) => x.id) || []
);
});
)
})
watch(selectedLoader, async () => {
if (selectedMCVersion.value) {
selectedLoaderVersion.value = "";
serverCheckError.value = "";
selectedLoaderVersion.value = ''
serverCheckError.value = ''
await checkVersionAvailability(selectedMCVersion.value);
await checkVersionAvailability(selectedMCVersion.value)
}
});
})
watch(
selectedLoaderVersions,
@ -369,161 +368,161 @@ watch(
newVersions.length > 0 &&
(!selectedLoaderVersion.value || !newVersions.includes(selectedLoaderVersion.value))
) {
selectedLoaderVersion.value = String(newVersions[0]);
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;
if (!version || version.trim().length < 3) return
isLoading.value = true;
loadingServerCheck.value = true;
isLoading.value = true
loadingServerCheck.value = true
try {
const mcRes = cachedVersions.value[version] || (await getLoaderVersion("minecraft", version));
const mcRes = cachedVersions.value[version] || (await getLoaderVersion('minecraft', version))
cachedVersions.value[version] = mcRes;
cachedVersions.value[version] = mcRes
if (!mcRes.downloads?.server) {
serverCheckError.value = "We couldn't find a server.jar for this version.";
return;
serverCheckError.value = "We couldn't find a server.jar for this version."
return
}
const loader = selectedLoader.value.toLowerCase();
if (loader === "paper" || loader === "purpur") {
const fetchFn = loader === "paper" ? fetchPaperVersions : fetchPurpurVersions;
const result = await fetchFn(version);
const loader = selectedLoader.value.toLowerCase()
if (loader === 'paper' || loader === 'purpur') {
const fetchFn = loader === 'paper' ? fetchPaperVersions : fetchPurpurVersions
const result = await fetchFn(version)
if (!result) {
serverCheckError.value = `This Minecraft version is not supported by ${loader}.`;
return;
serverCheckError.value = `This Minecraft version is not supported by ${loader}.`
return
}
}
serverCheckError.value = "";
serverCheckError.value = ''
} catch (error) {
console.error(error);
serverCheckError.value = "Failed to fetch versions.";
console.error(error)
serverCheckError.value = 'Failed to fetch versions.'
} finally {
loadingServerCheck.value = false;
isLoading.value = false;
loadingServerCheck.value = false
isLoading.value = false
}
};
}
watch(selectedMCVersion, checkVersionAvailability);
watch(selectedMCVersion, checkVersionAvailability)
onMounted(() => {
fetchLoaderVersions();
});
fetchLoaderVersions()
})
const tags = useTags();
const tags = useTags()
const mcVersions = computed(() =>
tags.value.gameVersions
.filter((x) =>
showSnapshots.value
? x.version_type === "snapshot" || x.version_type === "release"
: x.version_type === "release",
? x.version_type === 'snapshot' || x.version_type === 'release'
: x.version_type === 'release',
)
.map((x) => x.version),
);
)
const isDangerous = computed(() => hardReset.value);
const isDangerous = computed(() => hardReset.value)
const canInstall = computed(() => {
const conds =
!selectedMCVersion.value ||
isLoading.value ||
loadingServerCheck.value ||
serverCheckError.value.trim().length > 0;
serverCheckError.value.trim().length > 0
if (selectedLoader.value.toLowerCase() === "vanilla") {
return conds;
if (selectedLoader.value.toLowerCase() === 'vanilla') {
return conds
}
return conds || !selectedLoaderVersion.value;
});
return conds || !selectedLoaderVersion.value
})
const handleReinstall = async () => {
if (hardReset.value && !isSecondPhase.value) {
isSecondPhase.value = true;
return;
isSecondPhase.value = true
return
}
isLoading.value = true;
isLoading.value = true
try {
await props.server.general?.reinstall(
true,
selectedLoader.value,
selectedMCVersion.value,
selectedLoader.value === "Vanilla" ? "" : selectedLoaderVersion.value,
selectedLoader.value === 'Vanilla' ? '' : selectedLoaderVersion.value,
props.initialSetup ? true : hardReset.value,
);
)
emit("reinstall", {
emit('reinstall', {
loader: selectedLoader.value,
lVersion: selectedLoaderVersion.value,
mVersion: selectedMCVersion.value,
});
})
hide();
hide()
} catch (error) {
if (error instanceof ModrinthServersFetchError && (error as any)?.statusCode === 429) {
addNotification({
group: "server",
title: "Cannot reinstall server",
text: "You are being rate limited. Please try again later.",
type: "error",
});
group: 'server',
title: 'Cannot reinstall server',
text: 'You are being rate limited. Please try again later.',
type: 'error',
})
} else {
addNotification({
group: "server",
title: "Reinstall Failed",
text: "An unexpected error occurred while reinstalling. Please try again later.",
type: "error",
});
group: 'server',
title: 'Reinstall Failed',
text: 'An unexpected error occurred while reinstalling. Please try again later.',
type: 'error',
})
}
} finally {
isLoading.value = false;
isLoading.value = false
}
};
}
const onShow = () => {
selectedMCVersion.value = props.server.general?.mc_version || "";
selectedMCVersion.value = props.server.general?.mc_version || ''
if (isSnapshotSelected.value) {
showSnapshots.value = true;
showSnapshots.value = true
}
};
}
const onHide = () => {
hardReset.value = false;
isSecondPhase.value = false;
serverCheckError.value = "";
loadingServerCheck.value = false;
isLoading.value = false;
selectedMCVersion.value = "";
serverCheckError.value = "";
paperVersions.value = {};
purpurVersions.value = {};
};
hardReset.value = false
isSecondPhase.value = false
serverCheckError.value = ''
loadingServerCheck.value = false
isLoading.value = false
selectedMCVersion.value = ''
serverCheckError.value = ''
paperVersions.value = {}
purpurVersions.value = {}
}
const show = (loader: Loaders) => {
if (selectedLoader.value !== loader) {
selectedLoaderVersion.value = "";
selectedLoaderVersion.value = ''
}
selectedLoader.value = loader;
selectedMCVersion.value = props.server.general?.mc_version || "";
versionSelectModal.value?.show();
};
const hide = () => versionSelectModal.value?.hide();
selectedLoader.value = loader
selectedMCVersion.value = props.server.general?.mc_version || ''
versionSelectModal.value?.show()
}
const hide = () => versionSelectModal.value?.hide()
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>
<style scoped>

View File

@ -14,12 +14,12 @@
</ButtonStyled>
<ButtonStyled type="standard" :color="props.restart ? 'standard' : 'brand'">
<button :disabled="props.isUpdating" @click="props.save">
{{ props.isUpdating ? "Saving..." : "Save" }}
{{ props.isUpdating ? 'Saving...' : 'Save' }}
</button>
</ButtonStyled>
<ButtonStyled v-if="props.restart" type="standard" color="brand">
<button :disabled="props.isUpdating" @click="saveAndRestart">
{{ props.isUpdating ? "Saving..." : "Save & restart" }}
{{ props.isUpdating ? 'Saving...' : 'Save & restart' }}
</button>
</ButtonStyled>
</div>
@ -30,23 +30,23 @@
</template>
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { ButtonStyled } from '@modrinth/ui'
import type { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const props = defineProps<{
isUpdating: boolean;
restart?: boolean;
save: () => void;
reset: () => void;
isVisible: boolean;
server: ModrinthServer;
}>();
isUpdating: boolean
restart?: boolean
save: () => void
reset: () => void
isVisible: boolean
server: ModrinthServer
}>()
const saveAndRestart = async () => {
props.save();
await props.server.general?.power("Restart");
};
props.save()
await props.server.general?.power('Restart')
}
</script>
<style scoped>

View File

@ -26,14 +26,14 @@
</template>
<script setup lang="ts">
import { GameIcon } from "@modrinth/assets";
import { GameIcon } from '@modrinth/assets'
defineProps<{
game: string;
mcVersion: string;
isLink?: boolean;
}>();
game: string
mcVersion: string
isLink?: boolean
}>()
const route = useNativeRoute();
const serverId = route.params.id as string;
const route = useNativeRoute()
const serverId = route.params.id as string
</script>

View File

@ -21,6 +21,6 @@
<script setup lang="ts">
defineProps<{
image: string | undefined;
}>();
image: string | undefined
}>()
</script>

View File

@ -28,13 +28,13 @@
<script setup lang="ts">
interface ServerInfoLabelsProps {
serverData: Record<string, any>;
showGameLabel: boolean;
showLoaderLabel: boolean;
uptimeSeconds?: number;
column?: boolean;
linked?: boolean;
serverData: Record<string, any>
showGameLabel: boolean
showLoaderLabel: boolean
uptimeSeconds?: number
column?: boolean
linked?: boolean
}
defineProps<ServerInfoLabelsProps>();
defineProps<ServerInfoLabelsProps>()
</script>

View File

@ -39,7 +39,7 @@
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
alt="Server Icon"
/>
Using {{ projectData?.title || "Unknown" }}
Using {{ projectData?.title || 'Unknown' }}
</div>
<div v-else class="min-h-[20px]"></div>
@ -101,37 +101,37 @@
</template>
<script setup lang="ts">
import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets";
import { Avatar, CopyCode } from "@modrinth/ui";
import type { Project, Server } from "@modrinth/utils";
import { ChevronRightIcon, LockIcon, SparklesIcon } from '@modrinth/assets'
import { Avatar, CopyCode } from '@modrinth/ui'
import type { Project, Server } from '@modrinth/utils'
import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
const props = defineProps<Partial<Server>>();
const props = defineProps<Partial<Server>>()
if (props.server_id && props.status === "available") {
if (props.server_id && props.status === 'available') {
// Necessary only to get server icon
await useModrinthServers(props.server_id, ["general"]);
await useModrinthServers(props.server_id, ['general'])
}
const showGameLabel = computed(() => !!props.game);
const showLoaderLabel = computed(() => !!props.loader);
const showGameLabel = computed(() => !!props.game)
const showLoaderLabel = computed(() => !!props.loader)
let projectData: Ref<Project | null>;
let projectData: Ref<Project | null>
if (props.upstream) {
const { data } = await useAsyncData<Project>(
`server-project-${props.server_id}`,
async (): Promise<Project> => {
const result = await useBaseFetch(`project/${props.upstream?.project_id}`);
return result as Project;
const result = await useBaseFetch(`project/${props.upstream?.project_id}`)
return result as Project
},
);
projectData = data;
)
projectData = data
} else {
projectData = ref(null);
projectData = ref(null)
}
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined);
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
const isConfiguring = computed(() => props.flows?.intro);
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined)
const iconUrl = computed(() => projectData.value?.icon_url || undefined)
const isConfiguring = computed(() => props.flows?.intro)
</script>

View File

@ -35,12 +35,12 @@
<script setup lang="ts">
defineProps<{
noSeparator?: boolean;
loader?: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
loaderVersion?: string;
isLink?: boolean;
}>();
noSeparator?: boolean
loader?: 'Fabric' | 'Quilt' | 'Forge' | 'NeoForge' | 'Paper' | 'Spigot' | 'Bukkit' | 'Vanilla'
loaderVersion?: string
isLink?: boolean
}>()
const route = useNativeRoute();
const serverId = route.params.id as string;
const route = useNativeRoute()
const serverId = route.params.id as string
</script>

View File

@ -15,5 +15,5 @@
</template>
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { ButtonStyled } from '@modrinth/ui'
</script>

View File

@ -35,22 +35,22 @@
</template>
<script setup lang="ts">
import { RightArrowIcon } from "@modrinth/assets";
import type { RouteLocationNormalized } from "vue-router";
import { RightArrowIcon } from '@modrinth/assets'
import type { RouteLocationNormalized } from 'vue-router'
import type { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
const emit = defineEmits(["reinstall"]);
const emit = defineEmits(['reinstall'])
defineProps<{
navLinks: { label: string; href: string; icon: Component; external?: boolean; shown?: boolean }[];
route: RouteLocationNormalized;
server: ModrinthServer;
backupInProgress?: BackupInProgressReason;
}>();
navLinks: { label: string; href: string; icon: Component; external?: boolean; shown?: boolean }[]
route: RouteLocationNormalized
server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
const onReinstall = (...args: any[]) => {
emit("reinstall", ...args);
};
emit('reinstall', ...args)
}
</script>

View File

@ -57,7 +57,7 @@
>
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">
{{ loading ? "0 B" : formatBytes(stats.storage_usage_bytes) }}
{{ loading ? '0 B' : formatBytes(stats.storage_usage_bytes) }}
</h2>
</div>
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
@ -67,25 +67,25 @@
</template>
<script setup lang="ts">
import { CpuIcon, DatabaseIcon, FolderOpenIcon, IssuesIcon } from "@modrinth/assets";
import type { Stats } from "@modrinth/utils";
import { useStorage } from "@vueuse/core";
import { computed, ref, shallowRef } from "vue";
import { CpuIcon, DatabaseIcon, FolderOpenIcon, IssuesIcon } from '@modrinth/assets'
import type { Stats } from '@modrinth/utils'
import { useStorage } from '@vueuse/core'
import { computed, ref, shallowRef } from 'vue'
const flags = useFeatureFlags();
const route = useNativeRoute();
const serverId = route.params.id;
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
const flags = useFeatureFlags()
const route = useNativeRoute()
const serverId = route.params.id
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
const chartsReady = ref(new Set<number>());
const chartsReady = ref(new Set<number>())
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
ramAsNumber: false,
});
})
const props = withDefaults(defineProps<{ data?: Stats; loading?: boolean }>(), {
loading: false,
});
})
const stats = shallowRef(
props.data?.current || {
@ -94,76 +94,76 @@ const stats = shallowRef(
ram_total_bytes: 1, // Avoid division by zero
storage_usage_bytes: 0,
},
);
)
const onChartReady = (index: number) => {
chartsReady.value.add(index);
};
chartsReady.value.add(index)
}
const formatBytes = (bytes: number) => {
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
const units = ['B', 'KB', 'MB', 'GB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
value /= 1024
unit++
}
return `${Math.round(value * 10) / 10} ${units[unit]}`;
};
return `${Math.round(value * 10) / 10} ${units[unit]}`
}
const cpuData = ref<number[]>(Array(20).fill(0));
const ramData = ref<number[]>(Array(20).fill(0));
const cpuData = ref<number[]>(Array(20).fill(0))
const ramData = ref<number[]>(Array(20).fill(0))
const updateGraphData = (arr: number[], newValue: number) => {
arr.push(newValue);
arr.shift();
};
arr.push(newValue)
arr.shift()
}
const metrics = computed(() => {
if (props.loading) {
return [
{
title: "CPU usage",
value: "0.00%",
max: "100%",
title: 'CPU usage',
value: '0.00%',
max: '100%',
icon: CpuIcon,
data: cpuData.value,
showGraph: false,
warning: null,
},
{
title: "Memory usage",
value: "0.00%",
max: "100%",
title: 'Memory usage',
value: '0.00%',
max: '100%',
icon: DatabaseIcon,
data: ramData.value,
showGraph: false,
warning: null,
},
];
]
}
const ramPercent = Math.min(
(stats.value.ram_usage_bytes / stats.value.ram_total_bytes) * 100,
100,
);
const cpuPercent = Math.min(stats.value.cpu_percent, 100);
)
const cpuPercent = Math.min(stats.value.cpu_percent, 100)
updateGraphData(cpuData.value, cpuPercent);
updateGraphData(ramData.value, ramPercent);
updateGraphData(cpuData.value, cpuPercent)
updateGraphData(ramData.value, ramPercent)
return [
{
title: "CPU usage",
title: 'CPU usage',
value: `${cpuPercent.toFixed(2)}%`,
max: "100%",
max: '100%',
icon: CpuIcon,
data: cpuData.value,
showGraph: true,
warning: cpuPercent >= 90 ? "CPU usage is very high" : null,
warning: cpuPercent >= 90 ? 'CPU usage is very high' : null,
},
{
title: "Memory usage",
title: 'Memory usage',
value:
userPreferences.value.ramAsNumber || flags.developerMode
? formatBytes(stats.value.ram_usage_bytes)
@ -171,18 +171,18 @@ const metrics = computed(() => {
max:
userPreferences.value.ramAsNumber || flags.developerMode
? formatBytes(stats.value.ram_total_bytes)
: "100%",
: '100%',
icon: DatabaseIcon,
data: ramData.value,
showGraph: true,
warning: ramPercent >= 90 ? "Memory usage is very high" : null,
warning: ramPercent >= 90 ? 'Memory usage is very high' : null,
},
];
});
]
})
const getChartOptions = (hasWarning: string | null, index: number) => ({
chart: {
type: "area",
type: 'area',
animations: { enabled: false },
sparkline: { enabled: true },
toolbar: { show: false },
@ -197,9 +197,9 @@ const getChartOptions = (hasWarning: string | null, index: number) => ({
updated: () => onChartReady(index),
},
},
stroke: { curve: "smooth", width: 3 },
stroke: { curve: 'smooth', width: 3 },
fill: {
type: "gradient",
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.25,
@ -212,7 +212,7 @@ const getChartOptions = (hasWarning: string | null, index: number) => ({
xaxis: {
labels: { show: false },
axisBorder: { show: false },
type: "numeric",
type: 'numeric',
tickAmount: 20,
range: 20,
},
@ -222,20 +222,20 @@ const getChartOptions = (hasWarning: string | null, index: number) => ({
max: 100,
forceNiceScale: false,
},
colors: [hasWarning ? "var(--color-orange)" : "var(--color-brand)"],
colors: [hasWarning ? 'var(--color-orange)' : 'var(--color-brand)'],
dataLabels: {
enabled: false,
},
});
})
watch(
() => props.data?.current,
(newStats) => {
if (newStats) {
stats.value = newStats;
stats.value = newStats
}
},
);
)
</script>
<style scoped>

View File

@ -19,30 +19,30 @@
</template>
<script setup lang="ts">
import { LinkIcon } from "@modrinth/assets";
import { useStorage } from "@vueuse/core";
import { LinkIcon } from '@modrinth/assets'
import { useStorage } from '@vueuse/core'
const props = defineProps<{
subdomain: string;
noSeparator?: boolean;
}>();
subdomain: string
noSeparator?: boolean
}>()
const copySubdomain = () => {
navigator.clipboard.writeText(props.subdomain + ".modrinth.gg");
navigator.clipboard.writeText(props.subdomain + '.modrinth.gg')
addNotification({
group: "servers",
title: "Custom URL copied",
group: 'servers',
title: 'Custom URL copied',
text: "Your server's URL has been copied to your clipboard.",
type: "success",
});
};
type: 'success',
})
}
const route = useNativeRoute();
const serverId = computed(() => route.params.id as string);
const route = useNativeRoute()
const serverId = computed(() => route.params.id as string)
const userPreferences = useStorage(`pyro-server-${serverId.value}-preferences`, {
hideSubdomainLabel: false,
});
})
const isHidden = computed(() => userPreferences.value.hideSubdomainLabel);
const isHidden = computed(() => userPreferences.value.hideSubdomainLabel)
</script>

View File

@ -17,49 +17,49 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import { computed } from 'vue'
const props = defineProps<{
uptimeSeconds: number;
noSeparator?: boolean;
}>();
uptimeSeconds: number
noSeparator?: boolean
}>()
const formattedUptime = computed(() => {
const days = Math.floor(props.uptimeSeconds / (24 * 3600));
const hours = Math.floor((props.uptimeSeconds % (24 * 3600)) / 3600);
const minutes = Math.floor((props.uptimeSeconds % 3600) / 60);
const seconds = props.uptimeSeconds % 60;
const days = Math.floor(props.uptimeSeconds / (24 * 3600))
const hours = Math.floor((props.uptimeSeconds % (24 * 3600)) / 3600)
const minutes = Math.floor((props.uptimeSeconds % 3600) / 60)
const seconds = props.uptimeSeconds % 60
let formatted = "";
let formatted = ''
if (days > 0) {
formatted += `${days}d `;
formatted += `${days}d `
}
if (hours > 0 || days > 0) {
formatted += `${hours}h `;
formatted += `${hours}h `
}
formatted += `${minutes}m ${seconds}s`;
formatted += `${minutes}m ${seconds}s`
return formatted.trim();
});
return formatted.trim()
})
const verboseUptime = computed(() => {
const days = Math.floor(props.uptimeSeconds / (24 * 3600));
const hours = Math.floor((props.uptimeSeconds % (24 * 3600)) / 3600);
const minutes = Math.floor((props.uptimeSeconds % 3600) / 60);
const seconds = props.uptimeSeconds % 60;
const days = Math.floor(props.uptimeSeconds / (24 * 3600))
const hours = Math.floor((props.uptimeSeconds % (24 * 3600)) / 3600)
const minutes = Math.floor((props.uptimeSeconds % 3600) / 60)
const seconds = props.uptimeSeconds % 60
let verbose = "";
let verbose = ''
if (days > 0) {
verbose += `${days} day${days > 1 ? "s" : ""} `;
verbose += `${days} day${days > 1 ? 's' : ''} `
}
if (hours > 0) {
verbose += `${hours} hour${hours > 1 ? "s" : ""} `;
verbose += `${hours} hour${hours > 1 ? 's' : ''} `
}
if (minutes > 0) {
verbose += `${minutes} minute${minutes > 1 ? "s" : ""} `;
verbose += `${minutes} minute${minutes > 1 ? 's' : ''} `
}
verbose += `${seconds} second${seconds > 1 ? "s" : ""}`;
verbose += `${seconds} second${seconds > 1 ? 's' : ''}`
return verbose.trim();
});
return verbose.trim()
})
</script>

View File

@ -42,7 +42,7 @@
v-if="typeof option.action === 'function'"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
if (el) menuItemsRef[index] = el as HTMLElement
}
"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
@ -58,7 +58,7 @@
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
if (el) menuItemsRef[index] = el as HTMLElement
}
"
:to="option.action"
@ -75,7 +75,7 @@
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement;
if (el) menuItemsRef[index] = el as HTMLElement
}
"
:href="option.action"
@ -101,347 +101,347 @@
</template>
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { onClickOutside, useElementHover } from "@vueuse/core";
import { computed,nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { ButtonStyled } from '@modrinth/ui'
import { onClickOutside, useElementHover } from '@vueuse/core'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
interface Option {
id: string;
action?: (() => void) | string;
shown?: boolean;
color?: "standard" | "brand" | "red" | "orange" | "green" | "blue" | "purple";
id: string
action?: (() => void) | string
shown?: boolean
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
}
type Divider = {
divider: true;
shown?: boolean;
};
divider: true
shown?: boolean
}
type Item = Option | Divider;
type Item = Option | Divider
function isDivider(item: Item): item is Divider {
return (item as Divider).divider;
return (item as Divider).divider
}
const props = withDefaults(
defineProps<{
options: Item[];
hoverable?: boolean;
options: Item[]
hoverable?: boolean
}>(),
{
hoverable: false,
},
);
)
const emit = defineEmits<{
(e: "select", option: Option): void;
}>();
(e: 'select', option: Option): void
}>()
const isOpen = ref(false);
const selectedIndex = ref(-1);
const menuRef = ref<HTMLElement | null>(null);
const triggerRef = ref<HTMLElement | null>(null);
const isMouseDown = ref(false);
const typeAheadBuffer = ref("");
const typeAheadTimeout = ref<number | null>(null);
const menuItemsRef = ref<HTMLElement[]>([]);
const isOpen = ref(false)
const selectedIndex = ref(-1)
const menuRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const isMouseDown = ref(false)
const typeAheadBuffer = ref('')
const typeAheadTimeout = ref<number | null>(null)
const menuItemsRef = ref<HTMLElement[]>([])
const hoveringTrigger = useElementHover(triggerRef);
const hoveringMenu = useElementHover(menuRef);
const hoveringTrigger = useElementHover(triggerRef)
const hoveringMenu = useElementHover(menuRef)
const hovering = computed(() => hoveringTrigger.value || hoveringMenu.value);
const hovering = computed(() => hoveringTrigger.value || hoveringMenu.value)
const menuStyle = ref({
top: "0px",
left: "0px",
});
top: '0px',
left: '0px',
})
const filteredOptions = computed(() => props.options.filter((option) => option.shown !== false));
const filteredOptions = computed(() => props.options.filter((option) => option.shown !== false))
const calculateMenuPosition = () => {
if (!triggerRef.value || !menuRef.value) return { top: "0px", left: "0px" };
if (!triggerRef.value || !menuRef.value) return { top: '0px', left: '0px' }
const triggerRect = triggerRef.value.getBoundingClientRect();
const menuRect = menuRef.value.getBoundingClientRect();
const menuWidth = menuRect.width;
const menuHeight = menuRect.height;
const margin = 8;
const triggerRect = triggerRef.value.getBoundingClientRect()
const menuRect = menuRef.value.getBoundingClientRect()
const menuWidth = menuRect.width
const menuHeight = menuRect.height
const margin = 8
let top: number;
let left: number;
let top: number
let left: number
// okay gang lets calculate this shit
// from the top now yall
// y
if (triggerRect.bottom + menuHeight + margin <= window.innerHeight) {
top = triggerRect.bottom + margin;
top = triggerRect.bottom + margin
} else if (triggerRect.top - menuHeight - margin >= 0) {
top = triggerRect.top - menuHeight - margin;
top = triggerRect.top - menuHeight - margin
} else {
top = Math.max(margin, window.innerHeight - menuHeight - margin);
top = Math.max(margin, window.innerHeight - menuHeight - margin)
}
// x
if (triggerRect.left + menuWidth + margin <= window.innerWidth) {
left = triggerRect.left;
left = triggerRect.left
} else if (triggerRect.right - menuWidth - margin >= 0) {
left = triggerRect.right - menuWidth;
left = triggerRect.right - menuWidth
} else {
left = Math.max(margin, window.innerWidth - menuWidth - margin);
left = Math.max(margin, window.innerWidth - menuWidth - margin)
}
return {
top: `${top}px`,
left: `${left}px`,
};
};
}
}
const toggleMenu = (event: MouseEvent) => {
event.stopPropagation();
event.stopPropagation()
if (!props.hoverable) {
if (isOpen.value) {
closeMenu();
closeMenu()
} else {
openMenu();
openMenu()
}
}
};
}
const openMenu = () => {
isOpen.value = true;
disableBodyScroll();
isOpen.value = true
disableBodyScroll()
nextTick(() => {
menuStyle.value = calculateMenuPosition();
document.addEventListener("mousemove", handleMouseMove);
focusFirstMenuItem();
});
};
menuStyle.value = calculateMenuPosition()
document.addEventListener('mousemove', handleMouseMove)
focusFirstMenuItem()
})
}
const closeMenu = () => {
isOpen.value = false;
selectedIndex.value = -1;
enableBodyScroll();
document.removeEventListener("mousemove", handleMouseMove);
};
isOpen.value = false
selectedIndex.value = -1
enableBodyScroll()
document.removeEventListener('mousemove', handleMouseMove)
}
const selectOption = (option: Option) => {
emit("select", option);
if (typeof option.action === "function") {
option.action();
emit('select', option)
if (typeof option.action === 'function') {
option.action()
}
closeMenu();
};
closeMenu()
}
const handleMouseDown = (event: MouseEvent) => {
event.preventDefault();
isMouseDown.value = true;
};
event.preventDefault()
isMouseDown.value = true
}
const handleMouseEnter = () => {
if (props.hoverable) {
openMenu();
openMenu()
}
};
}
const handleMouseLeave = () => {
if (props.hoverable) {
setTimeout(() => {
if (!hovering.value) {
closeMenu();
closeMenu()
}
}, 250);
}, 250)
}
};
}
const handleMouseMove = (event: MouseEvent) => {
if (!isOpen.value || !isMouseDown.value) return;
if (!isOpen.value || !isMouseDown.value) return
const menuRect = menuRef.value?.getBoundingClientRect();
if (!menuRect) return;
const menuRect = menuRef.value?.getBoundingClientRect()
if (!menuRect) return
const menuItems = menuRef.value?.querySelectorAll('[role="menuitem"]');
if (!menuItems) return;
const menuItems = menuRef.value?.querySelectorAll('[role="menuitem"]')
if (!menuItems) return
for (let i = 0; i < menuItems.length; i++) {
const itemRect = (menuItems[i] as HTMLElement).getBoundingClientRect();
const itemRect = (menuItems[i] as HTMLElement).getBoundingClientRect()
if (
event.clientX >= itemRect.left &&
event.clientX <= itemRect.right &&
event.clientY >= itemRect.top &&
event.clientY <= itemRect.bottom
) {
selectedIndex.value = i;
break;
selectedIndex.value = i
break
}
}
};
}
const handleItemClick = (option: Option, index: number) => {
selectedIndex.value = index;
selectOption(option);
};
selectedIndex.value = index
selectOption(option)
}
const handleMouseOver = (index: number) => {
selectedIndex.value = index;
menuItemsRef.value[selectedIndex.value].focus?.();
};
selectedIndex.value = index
menuItemsRef.value[selectedIndex.value].focus?.()
}
// Scrolling is disabled for keyboard navigation
const disableBodyScroll = () => {
// Make opening not shift page when there's a vertical scrollbar
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth
if (scrollBarWidth > 0) {
document.body.style.paddingRight = `${scrollBarWidth}px`;
document.body.style.paddingRight = `${scrollBarWidth}px`
} else {
document.body.style.paddingRight = "";
document.body.style.paddingRight = ''
}
document.body.style.overflow = "hidden";
};
document.body.style.overflow = 'hidden'
}
const enableBodyScroll = () => {
document.body.style.paddingRight = "";
document.body.style.overflow = "";
};
document.body.style.paddingRight = ''
document.body.style.overflow = ''
}
const focusFirstMenuItem = () => {
if (menuItemsRef.value.length > 0) {
menuItemsRef.value[0].focus?.();
menuItemsRef.value[0].focus?.()
}
};
}
const handleKeydown = (event: KeyboardEvent) => {
if (!isOpen.value) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openMenu();
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openMenu()
}
return;
return
}
switch (event.key) {
case "ArrowDown":
event.preventDefault();
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length;
menuItemsRef.value[selectedIndex.value].focus?.();
break;
case "ArrowUp":
event.preventDefault();
case 'ArrowDown':
event.preventDefault()
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
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?.();
break;
case "Home":
event.preventDefault();
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
menuItemsRef.value[selectedIndex.value].focus?.()
break
case 'Home':
event.preventDefault()
if (menuItemsRef.value.length > 0) {
selectedIndex.value = 0;
menuItemsRef.value[selectedIndex.value].focus?.();
selectedIndex.value = 0
menuItemsRef.value[selectedIndex.value].focus?.()
}
break;
case "End":
event.preventDefault();
break
case 'End':
event.preventDefault()
if (menuItemsRef.value.length > 0) {
selectedIndex.value = filteredOptions.value.length - 1;
menuItemsRef.value[selectedIndex.value].focus?.();
selectedIndex.value = filteredOptions.value.length - 1
menuItemsRef.value[selectedIndex.value].focus?.()
}
break;
case "Enter":
case " ":
event.preventDefault();
break
case 'Enter':
case ' ':
event.preventDefault()
if (selectedIndex.value >= 0) {
const option = filteredOptions.value[selectedIndex.value];
if (isDivider(option)) break;
selectOption(option);
const option = filteredOptions.value[selectedIndex.value]
if (isDivider(option)) break
selectOption(option)
}
break;
case "Escape":
event.preventDefault();
closeMenu();
triggerRef.value?.focus?.();
break;
case "Tab":
event.preventDefault();
break
case 'Escape':
event.preventDefault()
closeMenu()
triggerRef.value?.focus?.()
break
case 'Tab':
event.preventDefault()
if (menuItemsRef.value.length > 0) {
if (event.shiftKey) {
selectedIndex.value =
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length;
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
} else {
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length;
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
}
menuItemsRef.value[selectedIndex.value].focus?.();
menuItemsRef.value[selectedIndex.value].focus?.()
}
break;
break
default:
if (event.key.length === 1) {
typeAheadBuffer.value += event.key.toLowerCase();
typeAheadBuffer.value += event.key.toLowerCase()
const matchIndex = filteredOptions.value.findIndex(
(option) =>
!isDivider(option) && option.id.toLowerCase().startsWith(typeAheadBuffer.value),
);
)
if (matchIndex !== -1) {
selectedIndex.value = matchIndex;
menuItemsRef.value[selectedIndex.value].focus?.();
selectedIndex.value = matchIndex
menuItemsRef.value[selectedIndex.value].focus?.()
}
if (typeAheadTimeout.value) {
clearTimeout(typeAheadTimeout.value);
clearTimeout(typeAheadTimeout.value)
}
typeAheadTimeout.value = setTimeout(() => {
typeAheadBuffer.value = "";
}, 1000) as unknown as number;
typeAheadBuffer.value = ''
}, 1000) as unknown as number
}
break;
break
}
};
}
const handleResizeOrScroll = () => {
if (isOpen.value) {
menuStyle.value = calculateMenuPosition();
menuStyle.value = calculateMenuPosition()
}
};
}
const throttle = (func: (...args: any[]) => void, limit: number): ((...args: any[]) => void) => {
let inThrottle: boolean;
let inThrottle: boolean
return function (...args: any[]) {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
func(...args)
inThrottle = true
setTimeout(() => (inThrottle = false), limit)
}
};
};
}
}
const throttledHandleResizeOrScroll = throttle(handleResizeOrScroll, 100);
const throttledHandleResizeOrScroll = throttle(handleResizeOrScroll, 100)
onMounted(() => {
triggerRef.value?.addEventListener("keydown", handleKeydown);
window.addEventListener("resize", throttledHandleResizeOrScroll);
window.addEventListener("scroll", throttledHandleResizeOrScroll);
});
triggerRef.value?.addEventListener('keydown', handleKeydown)
window.addEventListener('resize', throttledHandleResizeOrScroll)
window.addEventListener('scroll', throttledHandleResizeOrScroll)
})
onUnmounted(() => {
triggerRef.value?.removeEventListener("keydown", handleKeydown);
window.removeEventListener("resize", throttledHandleResizeOrScroll);
window.removeEventListener("scroll", throttledHandleResizeOrScroll);
document.removeEventListener("mousemove", handleMouseMove);
triggerRef.value?.removeEventListener('keydown', handleKeydown)
window.removeEventListener('resize', throttledHandleResizeOrScroll)
window.removeEventListener('scroll', throttledHandleResizeOrScroll)
document.removeEventListener('mousemove', handleMouseMove)
if (typeAheadTimeout.value) {
clearTimeout(typeAheadTimeout.value);
clearTimeout(typeAheadTimeout.value)
}
enableBodyScroll();
});
enableBodyScroll()
})
watch(isOpen, (newValue) => {
if (newValue) {
nextTick(() => {
menuRef.value?.addEventListener("keydown", handleKeydown);
});
menuRef.value?.addEventListener('keydown', handleKeydown)
})
} else {
menuRef.value?.removeEventListener("keydown", handleKeydown);
menuRef.value?.removeEventListener('keydown', handleKeydown)
}
});
})
onClickOutside(menuRef, (event) => {
if (!triggerRef.value?.contains(event.target as Node)) {
closeMenu();
closeMenu()
}
});
})
</script>

View File

@ -223,10 +223,10 @@
</template>
<script setup lang="ts">
import { LoaderIcon } from "@modrinth/assets";
import type { Loaders } from "@modrinth/utils";
import { LoaderIcon } from '@modrinth/assets'
import type { Loaders } from '@modrinth/utils'
defineProps<{
loader: Loaders;
}>();
loader: Loaders
}>()
</script>

View File

@ -1,95 +1,95 @@
<script setup lang="ts">
import { ButtonStyled, ServersSpecs } from "@modrinth/ui";
import { formatPrice } from "@modrinth/utils";
import type { MessageDescriptor } from "@vintl/vintl";
import { ButtonStyled, ServersSpecs } from '@modrinth/ui'
import { formatPrice } from '@modrinth/utils'
import type { MessageDescriptor } from '@vintl/vintl'
const { formatMessage, locale } = useVIntl();
const { formatMessage, locale } = useVIntl()
const emit = defineEmits<{
(e: "select" | "scroll-to-faq"): void;
}>();
(e: 'select' | 'scroll-to-faq'): void
}>()
type Plan = "small" | "medium" | "large";
type Plan = 'small' | 'medium' | 'large'
const plans: Record<
Plan,
{
buttonColor: "blue" | "green" | "purple";
accentText: string;
accentBg: string;
name: MessageDescriptor;
description: MessageDescriptor;
mostPopular: boolean;
buttonColor: 'blue' | 'green' | 'purple'
accentText: string
accentBg: string
name: MessageDescriptor
description: MessageDescriptor
mostPopular: boolean
}
> = {
small: {
buttonColor: "blue",
accentText: "text-blue",
accentBg: "bg-bg-blue",
buttonColor: 'blue',
accentText: 'text-blue',
accentBg: 'bg-bg-blue',
name: defineMessage({
id: "servers.plan.small.name",
defaultMessage: "Small",
id: 'servers.plan.small.name',
defaultMessage: 'Small',
}),
description: defineMessage({
id: "servers.plan.small.description",
defaultMessage: "Perfect for 15 friends with a few light mods.",
id: 'servers.plan.small.description',
defaultMessage: 'Perfect for 15 friends with a few light mods.',
}),
mostPopular: false,
},
medium: {
buttonColor: "green",
accentText: "text-green",
accentBg: "bg-bg-green",
buttonColor: 'green',
accentText: 'text-green',
accentBg: 'bg-bg-green',
name: defineMessage({
id: "servers.plan.medium.name",
defaultMessage: "Medium",
id: 'servers.plan.medium.name',
defaultMessage: 'Medium',
}),
description: defineMessage({
id: "servers.plan.medium.description",
defaultMessage: "Great for 615 players and multiple mods.",
id: 'servers.plan.medium.description',
defaultMessage: 'Great for 615 players and multiple mods.',
}),
mostPopular: true,
},
large: {
buttonColor: "purple",
accentText: "text-purple",
accentBg: "bg-bg-purple",
buttonColor: 'purple',
accentText: 'text-purple',
accentBg: 'bg-bg-purple',
name: defineMessage({
id: "servers.plan.large.name",
defaultMessage: "Large",
id: 'servers.plan.large.name',
defaultMessage: 'Large',
}),
description: defineMessage({
id: "servers.plan.large.description",
defaultMessage: "Ideal for 1525 players, modpacks, or heavy modding.",
id: 'servers.plan.large.description',
defaultMessage: 'Ideal for 1525 players, modpacks, or heavy modding.',
}),
mostPopular: false,
},
};
}
const props = defineProps<{
capacity?: number;
plan: Plan;
ram: number;
storage: number;
cpus: number;
price: number;
interval: "monthly" | "quarterly" | "yearly";
currency: string;
isUsa: boolean;
}>();
capacity?: number
plan: Plan
ram: number
storage: number
cpus: number
price: number
interval: 'monthly' | 'quarterly' | 'yearly'
currency: string
isUsa: boolean
}>()
const outOfStock = computed(() => {
return !props.capacity || props.capacity === 0;
});
return !props.capacity || props.capacity === 0
})
const billingMonths = computed(() => {
if (props.interval === "yearly") {
return 12;
} else if (props.interval === "quarterly") {
return 3;
if (props.interval === 'yearly') {
return 12
} else if (props.interval === 'quarterly') {
return 3
}
return 1;
});
return 1
})
</script>
<template>
@ -122,7 +122,7 @@ const billingMonths = computed(() => {
</div>
<span class="m-0 text-2xl font-bold text-contrast">
{{ formatPrice(locale, price / billingMonths, currency, true) }}
{{ isUsa ? "" : currency }}
{{ isUsa ? '' : currency }}
<span class="text-lg font-semibold text-secondary">
/ month<template v-if="interval !== 'monthly'">, billed {{ interval }}</template>
</span>

View File

@ -1,112 +1,112 @@
<script setup lang="ts">
import { PlusIcon, XIcon } from "@modrinth/assets";
import { Accordion, ButtonStyled, NewModal, ServerNotice, TagItem } from "@modrinth/ui";
import type { ServerNotice as ServerNoticeType } from "@modrinth/utils";
import { ref } from "vue";
import { PlusIcon, XIcon } from '@modrinth/assets'
import { Accordion, ButtonStyled, NewModal, ServerNotice, TagItem } from '@modrinth/ui'
import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
import { ref } from 'vue'
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const app = useNuxtApp() as unknown as { $notify: any };
const app = useNuxtApp() as unknown as { $notify: any }
const modal = ref<InstanceType<typeof NewModal>>();
const modal = ref<InstanceType<typeof NewModal>>()
const emit = defineEmits<{
(e: "close"): void;
}>();
(e: 'close'): void
}>()
const notice = ref<ServerNoticeType>();
const notice = ref<ServerNoticeType>()
const assigned = ref<ServerNoticeType["assigned"]>([]);
const assigned = ref<ServerNoticeType['assigned']>([])
const assignedServers = computed(() => assigned.value.filter((n) => n.kind === "server") ?? []);
const assignedNodes = computed(() => assigned.value.filter((n) => n.kind === "node") ?? []);
const assignedServers = computed(() => assigned.value.filter((n) => n.kind === 'server') ?? [])
const assignedNodes = computed(() => assigned.value.filter((n) => n.kind === 'node') ?? [])
const inputField = ref("");
const inputField = ref('')
async function refresh() {
await useServersFetch("notices").then((res) => {
const notices = res as ServerNoticeType[];
assigned.value = notices.find((n) => n.id === notice.value?.id)?.assigned ?? [];
});
await useServersFetch('notices').then((res) => {
const notices = res as ServerNoticeType[]
assigned.value = notices.find((n) => n.id === notice.value?.id)?.assigned ?? []
})
}
async function assign(server: boolean = true) {
const input = inputField.value.trim();
const input = inputField.value.trim()
if (input !== "" && notice.value) {
if (input !== '' && notice.value) {
await useServersFetch(
`notices/${notice.value.id}/assign?${server ? "server" : "node"}=${input}`,
`notices/${notice.value.id}/assign?${server ? 'server' : 'node'}=${input}`,
{
method: "PUT",
method: 'PUT',
},
).catch((err) => {
app.$notify({
group: "main",
title: "Error assigning notice",
group: 'main',
title: 'Error assigning notice',
text: err,
type: "error",
});
});
type: 'error',
})
})
} else {
app.$notify({
group: "main",
title: "Error assigning notice",
text: "No server or node specified",
type: "error",
});
group: 'main',
title: 'Error assigning notice',
text: 'No server or node specified',
type: 'error',
})
}
await refresh();
await refresh()
}
async function unassignDetect() {
const input = inputField.value.trim();
const input = inputField.value.trim()
const server = assignedServers.value.some((assigned) => assigned.id === input);
const node = assignedNodes.value.some((assigned) => assigned.id === input);
const server = assignedServers.value.some((assigned) => assigned.id === input)
const node = assignedNodes.value.some((assigned) => assigned.id === input)
if (!server && !node) {
app.$notify({
group: "main",
title: "Error unassigning notice",
text: "ID is not an assigned server or node",
type: "error",
});
return;
group: 'main',
title: 'Error unassigning notice',
text: 'ID is not an assigned server or node',
type: 'error',
})
return
}
await unassign(input, server);
await unassign(input, server)
}
async function unassign(id: string, server: boolean = true) {
if (notice.value) {
await useServersFetch(
`notices/${notice.value.id}/unassign?${server ? "server" : "node"}=${id}`,
`notices/${notice.value.id}/unassign?${server ? 'server' : 'node'}=${id}`,
{
method: "PUT",
method: 'PUT',
},
).catch((err) => {
app.$notify({
group: "main",
title: "Error unassigning notice",
group: 'main',
title: 'Error unassigning notice',
text: err,
type: "error",
});
});
type: 'error',
})
})
}
await refresh();
await refresh()
}
function show(currentNotice: ServerNoticeType) {
notice.value = currentNotice;
assigned.value = currentNotice?.assigned ?? [];
modal.value?.show();
notice.value = currentNotice
assigned.value = currentNotice?.assigned ?? []
modal.value?.show()
}
function hide() {
modal.value?.hide();
modal.value?.hide()
}
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>
<template>
<NewModal ref="modal" :on-hide="() => emit('close')">
@ -165,7 +165,7 @@ defineExpose({ show, hide });
:key="`node-${node.id}`"
:action="
() => {
unassign(node.id, false);
unassign(node.id, false)
}
"
>

View File

@ -102,11 +102,11 @@ import {
MoreHorizontalIcon,
ScaleIcon,
TrashIcon,
} from "@modrinth/assets";
import { AutoLink, Avatar, Badge, OverflowMenu, useRelativeTime } from "@modrinth/ui";
import { renderString } from "@modrinth/utils";
} from '@modrinth/assets'
import { AutoLink, Avatar, Badge, OverflowMenu, useRelativeTime } from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { isStaff } from "~/helpers/users.js";
import { isStaff } from '~/helpers/users.js'
const props = defineProps({
message: {
@ -137,34 +137,34 @@ const props = defineProps({
type: Object,
required: true,
},
});
})
const emit = defineEmits(["update-thread"]);
const emit = defineEmits(['update-thread'])
const formattedMessage = computed(() => {
const body = renderString(props.message.body.body);
const body = renderString(props.message.body.body)
if (props.forceCompact) {
const hasImage = body.includes("<img");
const noHtml = body.replace(/<\/?[^>]+(>|$)/g, "");
const hasImage = body.includes('<img')
const noHtml = body.replace(/<\/?[^>]+(>|$)/g, '')
if (noHtml.trim()) {
return noHtml;
return noHtml
} else if (hasImage) {
return "sent an image.";
return 'sent an image.'
} else {
return "sent a message.";
return 'sent a message.'
}
}
return body;
});
return body
})
const formatRelativeTime = useRelativeTime();
const timeSincePosted = ref(formatRelativeTime(props.message.created));
const formatRelativeTime = useRelativeTime()
const timeSincePosted = ref(formatRelativeTime(props.message.created))
async function deleteMessage() {
await useBaseFetch(`message/${props.message.id}`, {
method: "DELETE",
});
emit("update-thread");
method: 'DELETE',
})
emit('update-thread')
}
</script>
@ -189,9 +189,9 @@ async function deleteMessage() {
--gap-size: var(--spacing-card-sm);
display: grid;
grid-template:
"icon author actions"
"icon body actions"
"date date date";
'icon author actions'
'icon body actions'
'date date date';
grid-template-columns: min-content auto 1fr;
column-gap: var(--gap-size);
row-gap: var(--spacing-card-xs);
@ -307,9 +307,9 @@ a:active + .message__author a,
&.has-body {
grid-template:
"icon author actions"
"icon body actions"
"date date date";
'icon author actions'
'icon body actions'
'date date date';
grid-template-columns: min-content auto 1fr;
}
}
@ -322,8 +322,8 @@ a:active + .message__author a,
&.has-body {
grid-template:
"icon author date actions"
"icon body body actions";
'icon author date actions'
'icon body body actions';
grid-template-columns: min-content auto 1fr;
grid-template-rows: min-content 1fr auto;
}

View File

@ -24,9 +24,9 @@
</template>
<script setup>
import { ChevronRightIcon } from "@modrinth/assets";
import { ChevronRightIcon } from '@modrinth/assets'
import ThreadMessage from "~/components/ui/thread/ThreadMessage.vue";
import ThreadMessage from '~/components/ui/thread/ThreadMessage.vue'
const props = defineProps({
thread: {
@ -50,36 +50,36 @@ const props = defineProps({
type: Array,
required: false,
default() {
return [];
return []
},
},
auth: {
type: Object,
required: true,
},
});
})
const app = useNuxtApp();
const app = useNuxtApp()
const members = computed(() => {
const members = {};
const members = {}
for (const member of props.thread.members) {
members[member.id] = member;
members[member.id] = member
}
members[props.auth.user.id] = props.auth.user;
return members;
});
members[props.auth.user.id] = props.auth.user
return members
})
const displayMessages = computed(() => {
const sortedMessages = props.thread.messages
.slice()
.sort((a, b) => app.$dayjs(a.created) - app.$dayjs(b.created));
.sort((a, b) => app.$dayjs(a.created) - app.$dayjs(b.created))
if (props.messages.length > 0) {
return sortedMessages.filter((msg) => props.messages.includes(msg.id));
return sortedMessages.filter((msg) => props.messages.includes(msg.id))
} else {
return sortedMessages.length > 0 ? [sortedMessages[sortedMessages.length - 1]] : [];
return sortedMessages.length > 0 ? [sortedMessages[sortedMessages.length - 1]] : []
}
});
})
</script>
<style lang="scss" scoped>

View File

@ -1,71 +1,71 @@
export const useAuth = async (oldToken = null) => {
const auth = useState("auth", () => ({
const auth = useState('auth', () => ({
user: null,
token: "",
token: '',
headers: {},
}));
}))
if (!auth.value.user || oldToken) {
auth.value = await initAuth(oldToken);
auth.value = await initAuth(oldToken)
}
return auth;
};
return auth
}
export const initAuth = async (oldToken = null) => {
const auth = {
user: null,
token: "",
};
if (oldToken === "none") {
return auth;
token: '',
}
const route = useRoute();
const authCookie = useCookie("auth-token", {
if (oldToken === 'none') {
return auth
}
const route = useRoute()
const authCookie = useCookie('auth-token', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: "lax",
sameSite: 'lax',
secure: true,
httpOnly: false,
path: "/",
});
path: '/',
})
if (oldToken) {
authCookie.value = oldToken;
authCookie.value = oldToken
}
if (route.query.code && !route.fullPath.includes("new_account=true")) {
authCookie.value = route.query.code;
if (route.query.code && !route.fullPath.includes('new_account=true')) {
authCookie.value = route.query.code
}
if (route.fullPath.includes("new_account=true") && route.path !== "/auth/welcome") {
const redirect = route.path.startsWith("/auth/") ? null : route.fullPath;
if (route.fullPath.includes('new_account=true') && route.path !== '/auth/welcome') {
const redirect = route.path.startsWith('/auth/') ? null : route.fullPath
await navigateTo(
`/auth/welcome?authToken=${route.query.code}${
redirect ? `&redirect=${encodeURIComponent(redirect)}` : ""
redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''
}`,
);
)
}
if (authCookie.value) {
auth.token = authCookie.value;
auth.token = authCookie.value
if (!auth.token || !auth.token.startsWith("mra_")) {
return auth;
if (!auth.token || !auth.token.startsWith('mra_')) {
return auth
}
try {
auth.user = await useBaseFetch(
"user",
'user',
{
headers: {
Authorization: auth.token,
},
},
true,
);
)
} catch {
/* empty */
}
@ -74,67 +74,67 @@ export const initAuth = async (oldToken = null) => {
if (!auth.user && auth.token) {
try {
const session = await useBaseFetch(
"session/refresh",
'session/refresh',
{
method: "POST",
method: 'POST',
headers: {
Authorization: auth.token,
},
},
true,
);
)
auth.token = session.session;
authCookie.value = auth.token;
auth.token = session.session
authCookie.value = auth.token
auth.user = await useBaseFetch(
"user",
'user',
{
headers: {
Authorization: auth.token,
},
},
true,
);
)
} catch {
authCookie.value = null;
authCookie.value = null
}
}
return auth;
};
return auth
}
export const getAuthUrl = (provider, redirect = "/dashboard") => {
const config = useRuntimeConfig();
const route = useNativeRoute();
export const getAuthUrl = (provider, redirect = '/dashboard') => {
const config = useRuntimeConfig()
const route = useNativeRoute()
const fullURL = route.query.launcher
? "https://launcher-files.modrinth.com"
: `${config.public.siteUrl}/auth/sign-in?redirect=${redirect}`;
? 'https://launcher-files.modrinth.com'
: `${config.public.siteUrl}/auth/sign-in?redirect=${redirect}`
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(fullURL)}`;
};
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(fullURL)}`
}
export const removeAuthProvider = async (provider) => {
startLoading();
startLoading()
try {
const auth = await useAuth();
const auth = await useAuth()
await useBaseFetch("auth/provider", {
method: "DELETE",
await useBaseFetch('auth/provider', {
method: 'DELETE',
body: {
provider,
},
});
await useAuth(auth.value.token);
})
await useAuth(auth.value.token)
} catch (err) {
const data = useNuxtApp();
const data = useNuxtApp()
data.$notify({
group: "main",
title: "An error occurred",
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: "error",
});
type: 'error',
})
}
stopLoading();
};
stopLoading()
}

View File

@ -1,576 +1,576 @@
export const scopeMessages = defineMessages({
userReadEmailLabel: {
id: "scopes.userReadEmail.label",
defaultMessage: "Read user email",
id: 'scopes.userReadEmail.label',
defaultMessage: 'Read user email',
},
userReadEmailDescription: {
id: "scopes.userReadEmail.description",
defaultMessage: "Read your email",
id: 'scopes.userReadEmail.description',
defaultMessage: 'Read your email',
},
userReadLabel: {
id: "scopes.userRead.label",
defaultMessage: "Read user data",
id: 'scopes.userRead.label',
defaultMessage: 'Read user data',
},
userReadDescription: {
id: "scopes.userRead.description",
defaultMessage: "Access your public profile information",
id: 'scopes.userRead.description',
defaultMessage: 'Access your public profile information',
},
userWriteLabel: {
id: "scopes.userWrite.label",
defaultMessage: "Write user data",
id: 'scopes.userWrite.label',
defaultMessage: 'Write user data',
},
userWriteDescription: {
id: "scopes.userWrite.description",
defaultMessage: "Write to your profile",
id: 'scopes.userWrite.description',
defaultMessage: 'Write to your profile',
},
userDeleteLabel: {
id: "scopes.userDelete.label",
defaultMessage: "Delete your account",
id: 'scopes.userDelete.label',
defaultMessage: 'Delete your account',
},
userDeleteDescription: {
id: "scopes.userDelete.description",
defaultMessage: "Delete your account",
id: 'scopes.userDelete.description',
defaultMessage: 'Delete your account',
},
userAuthWriteLabel: {
id: "scopes.userAuthWrite.label",
defaultMessage: "Write auth data",
id: 'scopes.userAuthWrite.label',
defaultMessage: 'Write auth data',
},
userAuthWriteDescription: {
id: "scopes.userAuthWrite.description",
defaultMessage: "Modify your authentication data",
id: 'scopes.userAuthWrite.description',
defaultMessage: 'Modify your authentication data',
},
notificationReadLabel: {
id: "scopes.notificationRead.label",
defaultMessage: "Read notifications",
id: 'scopes.notificationRead.label',
defaultMessage: 'Read notifications',
},
notificationReadDescription: {
id: "scopes.notificationRead.description",
defaultMessage: "Read your notifications",
id: 'scopes.notificationRead.description',
defaultMessage: 'Read your notifications',
},
notificationWriteLabel: {
id: "scopes.notificationWrite.label",
defaultMessage: "Write notifications",
id: 'scopes.notificationWrite.label',
defaultMessage: 'Write notifications',
},
notificationWriteDescription: {
id: "scopes.notificationWrite.description",
defaultMessage: "Delete/View your notifications",
id: 'scopes.notificationWrite.description',
defaultMessage: 'Delete/View your notifications',
},
payoutsReadLabel: {
id: "scopes.payoutsRead.label",
defaultMessage: "Read payouts",
id: 'scopes.payoutsRead.label',
defaultMessage: 'Read payouts',
},
payoutsReadDescription: {
id: "scopes.payoutsRead.description",
defaultMessage: "Read your payouts data",
id: 'scopes.payoutsRead.description',
defaultMessage: 'Read your payouts data',
},
payoutsWriteLabel: {
id: "scopes.payoutsWrite.label",
defaultMessage: "Write payouts",
id: 'scopes.payoutsWrite.label',
defaultMessage: 'Write payouts',
},
payoutsWriteDescription: {
id: "scopes.payoutsWrite.description",
defaultMessage: "Withdraw money",
id: 'scopes.payoutsWrite.description',
defaultMessage: 'Withdraw money',
},
analyticsLabel: {
id: "scopes.analytics.label",
defaultMessage: "Read analytics",
id: 'scopes.analytics.label',
defaultMessage: 'Read analytics',
},
analyticsDescription: {
id: "scopes.analytics.description",
defaultMessage: "Access your analytics data",
id: 'scopes.analytics.description',
defaultMessage: 'Access your analytics data',
},
projectCreateLabel: {
id: "scopes.projectCreate.label",
defaultMessage: "Create projects",
id: 'scopes.projectCreate.label',
defaultMessage: 'Create projects',
},
projectCreateDescription: {
id: "scopes.projectCreate.description",
defaultMessage: "Create new projects",
id: 'scopes.projectCreate.description',
defaultMessage: 'Create new projects',
},
projectReadLabel: {
id: "scopes.projectRead.label",
defaultMessage: "Read projects",
id: 'scopes.projectRead.label',
defaultMessage: 'Read projects',
},
projectReadDescription: {
id: "scopes.projectRead.description",
defaultMessage: "Read all your projects",
id: 'scopes.projectRead.description',
defaultMessage: 'Read all your projects',
},
projectWriteLabel: {
id: "scopes.projectWrite.label",
defaultMessage: "Write projects",
id: 'scopes.projectWrite.label',
defaultMessage: 'Write projects',
},
projectWriteDescription: {
id: "scopes.projectWrite.description",
defaultMessage: "Write to project data",
id: 'scopes.projectWrite.description',
defaultMessage: 'Write to project data',
},
projectDeleteLabel: {
id: "scopes.projectDelete.label",
defaultMessage: "Delete projects",
id: 'scopes.projectDelete.label',
defaultMessage: 'Delete projects',
},
projectDeleteDescription: {
id: "scopes.projectDelete.description",
defaultMessage: "Delete your projects",
id: 'scopes.projectDelete.description',
defaultMessage: 'Delete your projects',
},
versionCreateLabel: {
id: "scopes.versionCreate.label",
defaultMessage: "Create versions",
id: 'scopes.versionCreate.label',
defaultMessage: 'Create versions',
},
versionCreateDescription: {
id: "scopes.versionCreate.description",
defaultMessage: "Create new versions",
id: 'scopes.versionCreate.description',
defaultMessage: 'Create new versions',
},
versionReadLabel: {
id: "scopes.versionRead.label",
defaultMessage: "Read versions",
id: 'scopes.versionRead.label',
defaultMessage: 'Read versions',
},
versionReadDescription: {
id: "scopes.versionRead.description",
defaultMessage: "Read all versions",
id: 'scopes.versionRead.description',
defaultMessage: 'Read all versions',
},
versionWriteLabel: {
id: "scopes.versionWrite.label",
defaultMessage: "Write versions",
id: 'scopes.versionWrite.label',
defaultMessage: 'Write versions',
},
versionWriteDescription: {
id: "scopes.versionWrite.description",
defaultMessage: "Write to version data",
id: 'scopes.versionWrite.description',
defaultMessage: 'Write to version data',
},
versionDeleteLabel: {
id: "scopes.versionDelete.label",
defaultMessage: "Delete versions",
id: 'scopes.versionDelete.label',
defaultMessage: 'Delete versions',
},
versionDeleteDescription: {
id: "scopes.versionDelete.description",
defaultMessage: "Delete a version",
id: 'scopes.versionDelete.description',
defaultMessage: 'Delete a version',
},
reportCreateLabel: {
id: "scopes.reportCreate.label",
defaultMessage: "Create reports",
id: 'scopes.reportCreate.label',
defaultMessage: 'Create reports',
},
reportCreateDescription: {
id: "scopes.reportCreate.description",
defaultMessage: "Create reports",
id: 'scopes.reportCreate.description',
defaultMessage: 'Create reports',
},
reportReadLabel: {
id: "scopes.reportRead.label",
defaultMessage: "Read reports",
id: 'scopes.reportRead.label',
defaultMessage: 'Read reports',
},
reportReadDescription: {
id: "scopes.reportRead.description",
defaultMessage: "Read reports",
id: 'scopes.reportRead.description',
defaultMessage: 'Read reports',
},
reportWriteLabel: {
id: "scopes.reportWrite.label",
defaultMessage: "Write reports",
id: 'scopes.reportWrite.label',
defaultMessage: 'Write reports',
},
reportWriteDescription: {
id: "scopes.reportWrite.description",
defaultMessage: "Edit reports",
id: 'scopes.reportWrite.description',
defaultMessage: 'Edit reports',
},
reportDeleteLabel: {
id: "scopes.reportDelete.label",
defaultMessage: "Delete reports",
id: 'scopes.reportDelete.label',
defaultMessage: 'Delete reports',
},
reportDeleteDescription: {
id: "scopes.reportDelete.description",
defaultMessage: "Delete reports",
id: 'scopes.reportDelete.description',
defaultMessage: 'Delete reports',
},
threadReadLabel: {
id: "scopes.threadRead.label",
defaultMessage: "Read threads",
id: 'scopes.threadRead.label',
defaultMessage: 'Read threads',
},
threadReadDescription: {
id: "scopes.threadRead.description",
defaultMessage: "Read threads",
id: 'scopes.threadRead.description',
defaultMessage: 'Read threads',
},
threadWriteLabel: {
id: "scopes.threadWrite.label",
defaultMessage: "Write threads",
id: 'scopes.threadWrite.label',
defaultMessage: 'Write threads',
},
threadWriteDescription: {
id: "scopes.threadWrite.description",
defaultMessage: "Write to threads",
id: 'scopes.threadWrite.description',
defaultMessage: 'Write to threads',
},
patCreateLabel: {
id: "scopes.patCreate.label",
defaultMessage: "Create PATs",
id: 'scopes.patCreate.label',
defaultMessage: 'Create PATs',
},
patCreateDescription: {
id: "scopes.patCreate.description",
defaultMessage: "Create personal API tokens",
id: 'scopes.patCreate.description',
defaultMessage: 'Create personal API tokens',
},
patReadLabel: {
id: "scopes.patRead.label",
defaultMessage: "Read PATs",
id: 'scopes.patRead.label',
defaultMessage: 'Read PATs',
},
patReadDescription: {
id: "scopes.patRead.description",
defaultMessage: "View created API tokens",
id: 'scopes.patRead.description',
defaultMessage: 'View created API tokens',
},
patWriteLabel: {
id: "scopes.patWrite.label",
defaultMessage: "Write PATs",
id: 'scopes.patWrite.label',
defaultMessage: 'Write PATs',
},
patWriteDescription: {
id: "scopes.patWrite.description",
defaultMessage: "Edit personal API tokens",
id: 'scopes.patWrite.description',
defaultMessage: 'Edit personal API tokens',
},
patDeleteLabel: {
id: "scopes.patDelete.label",
defaultMessage: "Delete PATs",
id: 'scopes.patDelete.label',
defaultMessage: 'Delete PATs',
},
patDeleteDescription: {
id: "scopes.patDelete.description",
defaultMessage: "Delete your personal API tokens",
id: 'scopes.patDelete.description',
defaultMessage: 'Delete your personal API tokens',
},
sessionReadLabel: {
id: "scopes.sessionRead.label",
defaultMessage: "Read sessions",
id: 'scopes.sessionRead.label',
defaultMessage: 'Read sessions',
},
sessionReadDescription: {
id: "scopes.sessionRead.description",
defaultMessage: "Read active sessions",
id: 'scopes.sessionRead.description',
defaultMessage: 'Read active sessions',
},
sessionDeleteLabel: {
id: "scopes.sessionDelete.label",
defaultMessage: "Delete sessions",
id: 'scopes.sessionDelete.label',
defaultMessage: 'Delete sessions',
},
sessionDeleteDescription: {
id: "scopes.sessionDelete.description",
defaultMessage: "Delete sessions",
id: 'scopes.sessionDelete.description',
defaultMessage: 'Delete sessions',
},
performAnalyticsLabel: {
id: "scopes.performAnalytics.label",
defaultMessage: "Perform analytics",
id: 'scopes.performAnalytics.label',
defaultMessage: 'Perform analytics',
},
performAnalyticsDescription: {
id: "scopes.performAnalytics.description",
defaultMessage: "Perform analytics actions",
id: 'scopes.performAnalytics.description',
defaultMessage: 'Perform analytics actions',
},
collectionCreateLabel: {
id: "scopes.collectionCreate.label",
defaultMessage: "Create collections",
id: 'scopes.collectionCreate.label',
defaultMessage: 'Create collections',
},
collectionCreateDescription: {
id: "scopes.collectionCreate.description",
defaultMessage: "Create collections",
id: 'scopes.collectionCreate.description',
defaultMessage: 'Create collections',
},
collectionReadLabel: {
id: "scopes.collectionRead.label",
defaultMessage: "Read collections",
id: 'scopes.collectionRead.label',
defaultMessage: 'Read collections',
},
collectionReadDescription: {
id: "scopes.collectionRead.description",
defaultMessage: "Read collections",
id: 'scopes.collectionRead.description',
defaultMessage: 'Read collections',
},
collectionWriteLabel: {
id: "scopes.collectionWrite.label",
defaultMessage: "Write collections",
id: 'scopes.collectionWrite.label',
defaultMessage: 'Write collections',
},
collectionWriteDescription: {
id: "scopes.collectionWrite.description",
defaultMessage: "Write to collections",
id: 'scopes.collectionWrite.description',
defaultMessage: 'Write to collections',
},
collectionDeleteLabel: {
id: "scopes.collectionDelete.label",
defaultMessage: "Delete collections",
id: 'scopes.collectionDelete.label',
defaultMessage: 'Delete collections',
},
collectionDeleteDescription: {
id: "scopes.collectionDelete.description",
defaultMessage: "Delete collections",
id: 'scopes.collectionDelete.description',
defaultMessage: 'Delete collections',
},
organizationCreateLabel: {
id: "scopes.organizationCreate.label",
defaultMessage: "Create organizations",
id: 'scopes.organizationCreate.label',
defaultMessage: 'Create organizations',
},
organizationCreateDescription: {
id: "scopes.organizationCreate.description",
defaultMessage: "Create organizations",
id: 'scopes.organizationCreate.description',
defaultMessage: 'Create organizations',
},
organizationReadLabel: {
id: "scopes.organizationRead.label",
defaultMessage: "Read organizations",
id: 'scopes.organizationRead.label',
defaultMessage: 'Read organizations',
},
organizationReadDescription: {
id: "scopes.organizationRead.description",
defaultMessage: "Read organizations",
id: 'scopes.organizationRead.description',
defaultMessage: 'Read organizations',
},
organizationWriteLabel: {
id: "scopes.organizationWrite.label",
defaultMessage: "Write organizations",
id: 'scopes.organizationWrite.label',
defaultMessage: 'Write organizations',
},
organizationWriteDescription: {
id: "scopes.organizationWrite.description",
defaultMessage: "Write to organizations",
id: 'scopes.organizationWrite.description',
defaultMessage: 'Write to organizations',
},
organizationDeleteLabel: {
id: "scopes.organizationDelete.label",
defaultMessage: "Delete organizations",
id: 'scopes.organizationDelete.label',
defaultMessage: 'Delete organizations',
},
organizationDeleteDescription: {
id: "scopes.organizationDelete.description",
defaultMessage: "Delete organizations",
id: 'scopes.organizationDelete.description',
defaultMessage: 'Delete organizations',
},
sessionAccessLabel: {
id: "scopes.sessionAccess.label",
defaultMessage: "Access sessions",
id: 'scopes.sessionAccess.label',
defaultMessage: 'Access sessions',
},
sessionAccessDescription: {
id: "scopes.sessionAccess.description",
defaultMessage: "Access modrinth-issued sessions",
id: 'scopes.sessionAccess.description',
defaultMessage: 'Access modrinth-issued sessions',
},
});
})
const scopeDefinitions = [
{
id: "USER_READ_EMAIL",
id: 'USER_READ_EMAIL',
value: BigInt(1) << BigInt(0),
label: scopeMessages.userReadEmailLabel,
desc: scopeMessages.userReadEmailDescription,
},
{
id: "USER_READ",
id: 'USER_READ',
value: BigInt(1) << BigInt(1),
label: scopeMessages.userReadLabel,
desc: scopeMessages.userReadDescription,
},
{
id: "USER_WRITE",
id: 'USER_WRITE',
value: BigInt(1) << BigInt(2),
label: scopeMessages.userWriteLabel,
desc: scopeMessages.userWriteDescription,
},
{
id: "USER_DELETE",
id: 'USER_DELETE',
value: BigInt(1) << BigInt(3),
label: scopeMessages.userDeleteLabel,
desc: scopeMessages.userDeleteDescription,
},
{
id: "USER_AUTH_WRITE",
id: 'USER_AUTH_WRITE',
value: BigInt(1) << BigInt(4),
label: scopeMessages.userAuthWriteLabel,
desc: scopeMessages.userAuthWriteDescription,
},
{
id: "NOTIFICATION_READ",
id: 'NOTIFICATION_READ',
value: BigInt(1) << BigInt(5),
label: scopeMessages.notificationReadLabel,
desc: scopeMessages.notificationReadDescription,
},
{
id: "NOTIFICATION_WRITE",
id: 'NOTIFICATION_WRITE',
value: BigInt(1) << BigInt(6),
label: scopeMessages.notificationWriteLabel,
desc: scopeMessages.notificationWriteDescription,
},
{
id: "PAYOUTS_READ",
id: 'PAYOUTS_READ',
value: BigInt(1) << BigInt(7),
label: scopeMessages.payoutsReadLabel,
desc: scopeMessages.payoutsReadDescription,
},
{
id: "PAYOUTS_WRITE",
id: 'PAYOUTS_WRITE',
value: BigInt(1) << BigInt(8),
label: scopeMessages.payoutsWriteLabel,
desc: scopeMessages.payoutsWriteDescription,
},
{
id: "ANALYTICS",
id: 'ANALYTICS',
value: BigInt(1) << BigInt(9),
label: scopeMessages.analyticsLabel,
desc: scopeMessages.analyticsDescription,
},
{
id: "PROJECT_CREATE",
id: 'PROJECT_CREATE',
value: BigInt(1) << BigInt(10),
label: scopeMessages.projectCreateLabel,
desc: scopeMessages.projectCreateDescription,
},
{
id: "PROJECT_READ",
id: 'PROJECT_READ',
value: BigInt(1) << BigInt(11),
label: scopeMessages.projectReadLabel,
desc: scopeMessages.projectReadDescription,
},
{
id: "PROJECT_WRITE",
id: 'PROJECT_WRITE',
value: BigInt(1) << BigInt(12),
label: scopeMessages.projectWriteLabel,
desc: scopeMessages.projectWriteDescription,
},
{
id: "PROJECT_DELETE",
id: 'PROJECT_DELETE',
value: BigInt(1) << BigInt(13),
label: scopeMessages.projectDeleteLabel,
desc: scopeMessages.projectDeleteDescription,
},
{
id: "VERSION_CREATE",
id: 'VERSION_CREATE',
value: BigInt(1) << BigInt(14),
label: scopeMessages.versionCreateLabel,
desc: scopeMessages.versionCreateDescription,
},
{
id: "VERSION_READ",
id: 'VERSION_READ',
value: BigInt(1) << BigInt(15),
label: scopeMessages.versionReadLabel,
desc: scopeMessages.versionReadDescription,
},
{
id: "VERSION_WRITE",
id: 'VERSION_WRITE',
value: BigInt(1) << BigInt(16),
label: scopeMessages.versionWriteLabel,
desc: scopeMessages.versionWriteDescription,
},
{
id: "VERSION_DELETE",
id: 'VERSION_DELETE',
value: BigInt(1) << BigInt(17),
label: scopeMessages.versionDeleteLabel,
desc: scopeMessages.versionDeleteDescription,
},
{
id: "REPORT_CREATE",
id: 'REPORT_CREATE',
value: BigInt(1) << BigInt(18),
label: scopeMessages.reportCreateLabel,
desc: scopeMessages.reportCreateDescription,
},
{
id: "REPORT_READ",
id: 'REPORT_READ',
value: BigInt(1) << BigInt(19),
label: scopeMessages.reportReadLabel,
desc: scopeMessages.reportReadDescription,
},
{
id: "REPORT_WRITE",
id: 'REPORT_WRITE',
value: BigInt(1) << BigInt(20),
label: scopeMessages.reportWriteLabel,
desc: scopeMessages.reportWriteDescription,
},
{
id: "REPORT_DELETE",
id: 'REPORT_DELETE',
value: BigInt(1) << BigInt(21),
label: scopeMessages.reportDeleteLabel,
desc: scopeMessages.reportDeleteDescription,
},
{
id: "THREAD_READ",
id: 'THREAD_READ',
value: BigInt(1) << BigInt(22),
label: scopeMessages.threadReadLabel,
desc: scopeMessages.threadReadDescription,
},
{
id: "THREAD_WRITE",
id: 'THREAD_WRITE',
value: BigInt(1) << BigInt(23),
label: scopeMessages.threadWriteLabel,
desc: scopeMessages.threadWriteDescription,
},
{
id: "PAT_CREATE",
id: 'PAT_CREATE',
value: BigInt(1) << BigInt(24),
label: scopeMessages.patCreateLabel,
desc: scopeMessages.patCreateDescription,
},
{
id: "PAT_READ",
id: 'PAT_READ',
value: BigInt(1) << BigInt(25),
label: scopeMessages.patReadLabel,
desc: scopeMessages.patReadDescription,
},
{
id: "PAT_WRITE",
id: 'PAT_WRITE',
value: BigInt(1) << BigInt(26),
label: scopeMessages.patWriteLabel,
desc: scopeMessages.patWriteDescription,
},
{
id: "PAT_DELETE",
id: 'PAT_DELETE',
value: BigInt(1) << BigInt(27),
label: scopeMessages.patDeleteLabel,
desc: scopeMessages.patDeleteDescription,
},
{
id: "SESSION_READ",
id: 'SESSION_READ',
value: BigInt(1) << BigInt(28),
label: scopeMessages.sessionReadLabel,
desc: scopeMessages.sessionReadDescription,
},
{
id: "SESSION_DELETE",
id: 'SESSION_DELETE',
value: BigInt(1) << BigInt(29),
label: scopeMessages.sessionDeleteLabel,
desc: scopeMessages.sessionDeleteDescription,
},
{
id: "PERFORM_ANALYTICS",
id: 'PERFORM_ANALYTICS',
value: BigInt(1) << BigInt(30),
label: scopeMessages.performAnalyticsLabel,
desc: scopeMessages.performAnalyticsDescription,
},
{
id: "COLLECTION_CREATE",
id: 'COLLECTION_CREATE',
value: BigInt(1) << BigInt(31),
label: scopeMessages.collectionCreateLabel,
desc: scopeMessages.collectionCreateDescription,
},
{
id: "COLLECTION_READ",
id: 'COLLECTION_READ',
value: BigInt(1) << BigInt(32),
label: scopeMessages.collectionReadLabel,
desc: scopeMessages.collectionReadDescription,
},
{
id: "COLLECTION_WRITE",
id: 'COLLECTION_WRITE',
value: BigInt(1) << BigInt(33),
label: scopeMessages.collectionWriteLabel,
desc: scopeMessages.collectionWriteDescription,
},
{
id: "COLLECTION_DELETE",
id: 'COLLECTION_DELETE',
value: BigInt(1) << BigInt(34),
label: scopeMessages.collectionDeleteLabel,
desc: scopeMessages.collectionDeleteDescription,
},
{
id: "ORGANIZATION_CREATE",
id: 'ORGANIZATION_CREATE',
value: BigInt(1) << BigInt(35),
label: scopeMessages.organizationCreateLabel,
desc: scopeMessages.organizationCreateDescription,
},
{
id: "ORGANIZATION_READ",
id: 'ORGANIZATION_READ',
value: BigInt(1) << BigInt(36),
label: scopeMessages.organizationReadLabel,
desc: scopeMessages.organizationReadDescription,
},
{
id: "ORGANIZATION_WRITE",
id: 'ORGANIZATION_WRITE',
value: BigInt(1) << BigInt(37),
label: scopeMessages.organizationWriteLabel,
desc: scopeMessages.organizationWriteDescription,
},
{
id: "ORGANIZATION_DELETE",
id: 'ORGANIZATION_DELETE',
value: BigInt(1) << BigInt(38),
label: scopeMessages.organizationDeleteLabel,
desc: scopeMessages.organizationDeleteDescription,
},
{
id: "SESSION_ACCESS",
id: 'SESSION_ACCESS',
value: BigInt(1) << BigInt(39),
label: scopeMessages.sessionAccessLabel,
desc: scopeMessages.sessionAccessDescription,
},
];
]
const Scopes = scopeDefinitions.reduce(
(acc, scope) => {
acc[scope.id] = scope.value;
return acc;
acc[scope.id] = scope.value
return acc
},
{} as Record<string, bigint>,
);
)
export const restrictedScopes = [
Scopes.PAT_READ,
@ -583,18 +583,18 @@ export const restrictedScopes = [
Scopes.USER_AUTH_WRITE,
Scopes.USER_DELETE,
Scopes.PERFORM_ANALYTICS,
];
]
export const scopeList = Object.entries(Scopes)
.filter(([_, value]) => !restrictedScopes.includes(value))
.map(([key, _]) => key);
.map(([key, _]) => key)
export const getScopeValue = (scope: string) => {
return Scopes[scope];
};
return Scopes[scope]
}
export const encodeScopes = (scopes: string[]) => {
let scopeFlag = BigInt(0);
let scopeFlag = BigInt(0)
// We iterate over the provided scopes
for (const scope of scopes) {
@ -602,77 +602,77 @@ export const encodeScopes = (scopes: string[]) => {
for (const [scopeName, scopeFlagValue] of Object.entries(Scopes)) {
// If the scope name is the same as the provided scope, add the scope flag to the scopeFlag variable
if (scopeName === scope) {
scopeFlag = scopeFlag | scopeFlagValue;
scopeFlag = scopeFlag | scopeFlagValue
}
}
}
return scopeFlag;
};
return scopeFlag
}
export const decodeScopes = (scopes: bigint | number) => {
if (typeof scopes === "number") {
scopes = BigInt(scopes);
if (typeof scopes === 'number') {
scopes = BigInt(scopes)
}
const authorizedScopes = [];
const authorizedScopes = []
// We iterate over the entries of the Scopes object
for (const [scopeName, scopeFlag] of Object.entries(Scopes)) {
// If the scope flag is present in the provided number, add the scope name to the list
if ((scopes & scopeFlag) === scopeFlag) {
authorizedScopes.push(scopeName);
authorizedScopes.push(scopeName)
}
}
return authorizedScopes;
};
return authorizedScopes
}
export const hasScope = (scopes: bigint, scope: string) => {
const authorizedScopes = decodeScopes(scopes);
return authorizedScopes.includes(scope);
};
const authorizedScopes = decodeScopes(scopes)
return authorizedScopes.includes(scope)
}
export const toggleScope = (scopes: bigint, scope: string) => {
const authorizedScopes = decodeScopes(scopes);
const authorizedScopes = decodeScopes(scopes)
if (authorizedScopes.includes(scope)) {
return encodeScopes(authorizedScopes.filter((authorizedScope) => authorizedScope !== scope));
return encodeScopes(authorizedScopes.filter((authorizedScope) => authorizedScope !== scope))
} else {
return encodeScopes([...authorizedScopes, scope]);
return encodeScopes([...authorizedScopes, scope])
}
};
}
export const useScopes = () => {
const { formatMessage } = useVIntl();
const { formatMessage } = useVIntl()
const scopesToDefinitions = (scopes: bigint) => {
const authorizedScopes = decodeScopes(scopes);
const authorizedScopes = decodeScopes(scopes)
return authorizedScopes.map((scope) => {
const scopeDefinition = scopeDefinitions.find(
(scopeDefinition) => scopeDefinition.id === scope,
);
)
if (!scopeDefinition) {
throw new Error(`Scope ${scope} not found`);
throw new Error(`Scope ${scope} not found`)
}
return formatMessage(scopeDefinition.desc);
});
};
return formatMessage(scopeDefinition.desc)
})
}
const scopesToLabels = (scopes: bigint) => {
const authorizedScopes = decodeScopes(scopes);
const authorizedScopes = decodeScopes(scopes)
return authorizedScopes.map((scope) => {
const scopeDefinition = scopeDefinitions.find(
(scopeDefinition) => scopeDefinition.id === scope,
);
)
if (!scopeDefinition) {
throw new Error(`Scope ${scope} not found`);
throw new Error(`Scope ${scope} not found`)
}
return formatMessage(scopeDefinition.label);
});
};
return formatMessage(scopeDefinition.label)
})
}
return {
scopesToDefinitions,
scopesToLabels,
};
};
}
}

View File

@ -1,26 +1,26 @@
const formatters = new WeakMap<object, Intl.NumberFormat>();
const formatters = new WeakMap<object, Intl.NumberFormat>()
export function useCompactNumber(truncate = false, fractionDigits = 2, locale?: string) {
const context = {};
const context = {}
let formatter = formatters.get(context);
let formatter = formatters.get(context)
if (!formatter) {
formatter = new Intl.NumberFormat(locale, {
notation: "compact",
notation: 'compact',
maximumFractionDigits: fractionDigits,
});
formatters.set(context, formatter);
})
formatters.set(context, formatter)
}
function format(value: number): string {
let formattedValue = value;
let formattedValue = value
if (truncate) {
const scale = Math.pow(10, fractionDigits);
formattedValue = Math.floor(value * scale) / scale;
const scale = Math.pow(10, fractionDigits)
formattedValue = Math.floor(value * scale) / scale
}
return formatter!.format(formattedValue);
return formatter!.format(formattedValue)
}
return format;
return format
}

View File

@ -1,37 +1,37 @@
import { useRequestHeaders,useState } from "#imports";
import { useRequestHeaders, useState } from '#imports'
export const useUserCountry = () => {
const country = useState<string>("userCountry", () => "US");
const fromServer = useState<boolean>("userCountryFromServer", () => false);
const country = useState<string>('userCountry', () => 'US')
const fromServer = useState<boolean>('userCountryFromServer', () => false)
if (import.meta.server) {
const headers = useRequestHeaders(["cf-ipcountry", "accept-language"]);
const cf = headers["cf-ipcountry"];
const headers = useRequestHeaders(['cf-ipcountry', 'accept-language'])
const cf = headers['cf-ipcountry']
if (cf) {
country.value = cf.toUpperCase();
fromServer.value = true;
country.value = cf.toUpperCase()
fromServer.value = true
} else {
const al = headers["accept-language"] || "";
const tag = al.split(",")[0];
const val = tag.split("-")[1]?.toLowerCase();
const al = headers['accept-language'] || ''
const tag = al.split(',')[0]
const val = tag.split('-')[1]?.toLowerCase()
if (val) {
country.value = val;
fromServer.value = true;
country.value = val
fromServer.value = true
}
}
}
if (import.meta.client) {
onMounted(() => {
if (fromServer.value) return;
if (fromServer.value) return
// @ts-expect-error - ignore TS not knowing about navigator.userLanguage
const lang = navigator.language || navigator.userLanguage || "";
const region = lang.split("-")[1];
const lang = navigator.language || navigator.userLanguage || ''
const region = lang.split('-')[1]
if (region) {
country.value = region.toUpperCase();
country.value = region.toUpperCase()
}
});
})
}
return country;
};
return country
}

View File

@ -1,81 +1,79 @@
const safeTags = new Map<string, string>();
const safeTags = new Map<string, string>()
function safeTagFor(locale: string) {
let safeTag = safeTags.get(locale);
let safeTag = safeTags.get(locale)
if (safeTag == null) {
safeTag = new Intl.Locale(locale).baseName;
safeTags.set(locale, safeTag);
safeTag = new Intl.Locale(locale).baseName
safeTags.set(locale, safeTag)
}
return safeTag;
return safeTag
}
type DisplayNamesWrapper = Intl.DisplayNames & {
of(tag: string): string | undefined;
};
of(tag: string): string | undefined
}
const displayNamesDicts = new Map<string, DisplayNamesWrapper>();
const displayNamesDicts = new Map<string, DisplayNamesWrapper>()
function getWrapperKey(locale: string, options: Intl.DisplayNamesOptions) {
return JSON.stringify({ ...options, locale });
return JSON.stringify({ ...options, locale })
}
export function createDisplayNames(
locale: string,
options: Intl.DisplayNamesOptions = { type: "language" },
options: Intl.DisplayNamesOptions = { type: 'language' },
) {
const wrapperKey = getWrapperKey(locale, options);
let wrapper = displayNamesDicts.get(wrapperKey);
const wrapperKey = getWrapperKey(locale, options)
let wrapper = displayNamesDicts.get(wrapperKey)
if (wrapper == null) {
const dict = new Intl.DisplayNames(locale, options);
const dict = new Intl.DisplayNames(locale, options)
const badTags: string[] = [];
const badTags: string[] = []
wrapper = {
resolvedOptions() {
return dict.resolvedOptions();
return dict.resolvedOptions()
},
of(tag: string) {
let attempt = 0;
let attempt = 0
lookupLoop: do {
let lookup: string;
let lookup: string
switch (attempt) {
case 0:
lookup = tag;
break;
lookup = tag
break
case 1:
lookup = safeTagFor(tag);
break;
lookup = safeTagFor(tag)
break
default:
break lookupLoop;
break lookupLoop
}
if (badTags.includes(lookup)) continue;
if (badTags.includes(lookup)) continue
try {
return dict.of(lookup);
return dict.of(lookup)
} catch {
console.warn(
`Failed to get display name for ${lookup} using dictionary for ${
this.resolvedOptions().locale
}`,
);
badTags.push(lookup);
continue;
)
badTags.push(lookup)
continue
}
} while (++attempt < 5);
} while (++attempt < 5)
return undefined;
return undefined
},
};
}
displayNamesDicts.set(wrapperKey, wrapper);
displayNamesDicts.set(wrapperKey, wrapper)
}
return wrapper;
return wrapper
}
export function useDisplayNames(
@ -85,8 +83,8 @@ export function useDisplayNames(
| (() => Intl.DisplayNamesOptions | undefined)
| Ref<Intl.DisplayNamesOptions | undefined>,
) {
const $locale = toRef(locale);
const $options = toRef(options);
const $locale = toRef(locale)
const $options = toRef(options)
return computed(() => createDisplayNames($locale.value, $options.value));
return computed(() => createDisplayNames($locale.value, $options.value))
}

Some files were not shown because too many files have changed in this diff Show More