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 project_id: 518556
preserve_hierarchy: true preserve_hierarchy: true
commit_message: "[ci skip]" commit_message: '[ci skip]'
files: files:
- source: /locales/en-US/* - source: /locales/en-US/*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,35 +13,35 @@
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ defineOptions({
inheritAttrs: false, 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() { 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(() => { onMounted(() => {
mutationObserver.value = new MutationObserver(updateContent); mutationObserver.value = new MutationObserver(updateContent)
mutationObserver.value.observe(slotContainer.value, { mutationObserver.value.observe(slotContainer.value, {
childList: true, childList: true,
}); })
updateContent(); updateContent()
}); })
onUnmounted(() => { onUnmounted(() => {
if (mutationObserver.value) { if (mutationObserver.value) {
mutationObserver.value.disconnect(); mutationObserver.value.disconnect()
} }
}); })
</script> </script>
<style scoped> <style scoped>
.accordion-content { .accordion-content {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,40 +1,40 @@
<script setup> <script setup>
const token = defineModel(); const token = defineModel()
const id = ref(null); const id = ref(null)
function hCaptchaUpdateToken(newToken) { function hCaptchaUpdateToken(newToken) {
token.value = newToken; token.value = newToken
} }
function hCaptchaReady() { function hCaptchaReady() {
window.hCaptchaUpdateToken = hCaptchaUpdateToken; window.hCaptchaUpdateToken = hCaptchaUpdateToken
id.value = window.hcaptcha.render("h-captcha"); id.value = window.hcaptcha.render('h-captcha')
} }
onMounted(() => { onMounted(() => {
if (window.hcaptcha) { if (window.hcaptcha) {
hCaptchaReady(); hCaptchaReady()
} else { } else {
window.hCaptchaReady = hCaptchaReady; window.hCaptchaReady = hCaptchaReady
useHead({ useHead({
script: [ 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, async: true,
defer: true, defer: true,
}, },
], ],
}); })
} }
}); })
defineExpose({ defineExpose({
reset: () => { reset: () => {
token.value = null; token.value = null
window.hcaptcha.reset(id.value); window.hcaptcha.reset(id.value)
}, },
}); })
</script> </script>
<template> <template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -137,7 +137,7 @@
<ButtonStyled v-if="modPackData && currentIndex < modPackData.length" color="blue"> <ButtonStyled v-if="modPackData && currentIndex < modPackData.length" color="blue">
<button :disabled="!canGoNext" @click="goToNext"> <button :disabled="!canGoNext" @click="goToNext">
<RightArrowIcon aria-hidden="true" /> <RightArrowIcon aria-hidden="true" />
{{ currentIndex + 1 >= modPackData.length ? "Complete" : "Next" }} {{ currentIndex + 1 >= modPackData.length ? 'Complete' : 'Next' }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@ -145,8 +145,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { LeftArrowIcon, RightArrowIcon } from "@modrinth/assets"; import { LeftArrowIcon, RightArrowIcon } from '@modrinth/assets'
import { ButtonStyled } from "@modrinth/ui"; import { ButtonStyled } from '@modrinth/ui'
import type { import type {
ModerationFlameModpackItem, ModerationFlameModpackItem,
ModerationJudgements, ModerationJudgements,
@ -155,19 +155,19 @@ import type {
ModerationModpackResponse, ModerationModpackResponse,
ModerationPermissionType, ModerationPermissionType,
ModerationUnknownModpackItem, ModerationUnknownModpackItem,
} from "@modrinth/utils"; } from '@modrinth/utils'
import { useLocalStorage, useSessionStorage } from "@vueuse/core"; import { useLocalStorage, useSessionStorage } from '@vueuse/core'
import { computed, onMounted,ref, watch } from "vue"; import { computed, onMounted, ref, watch } from 'vue'
const props = defineProps<{ const props = defineProps<{
projectId: string; projectId: string
modelValue?: ModerationJudgements; modelValue?: ModerationJudgements
}>(); }>()
const emit = defineEmits<{ const emit = defineEmits<{
complete: []; complete: []
"update:modelValue": [judgements: ModerationJudgements]; 'update:modelValue': [judgements: ModerationJudgements]
}>(); }>()
const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>( const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
`modpack-permissions-${props.projectId}`, `modpack-permissions-${props.projectId}`,
@ -178,9 +178,9 @@ const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
write: (v: any) => JSON.stringify(v), 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>( const modPackData = useSessionStorage<ModerationModpackItem[] | null>(
`modpack-permissions-data-${props.projectId}`, `modpack-permissions-data-${props.projectId}`,
@ -191,7 +191,7 @@ const modPackData = useSessionStorage<ModerationModpackItem[] | null>(
write: (v: any) => JSON.stringify(v), write: (v: any) => JSON.stringify(v),
}, },
}, },
); )
const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>( const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>(
`modpack-permissions-permanent-no-${props.projectId}`, `modpack-permissions-permanent-no-${props.projectId}`,
[], [],
@ -201,110 +201,110 @@ const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>(
write: (v: any) => JSON.stringify(v), write: (v: any) => JSON.stringify(v),
}, },
}, },
); )
const currentIndex = ref(0); const currentIndex = ref(0)
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [ const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
{ {
id: "yes", id: 'yes',
name: "Yes", name: 'Yes',
}, },
{ {
id: "with-attribution-and-source", id: 'with-attribution-and-source',
name: "With attribution and source", name: 'With attribution and source',
}, },
{ {
id: "with-attribution", id: 'with-attribution',
name: "With attribution", name: 'With attribution',
}, },
{ {
id: "no", id: 'no',
name: "No", name: 'No',
}, },
{ {
id: "permanent-no", id: 'permanent-no',
name: "Permanent no", name: 'Permanent no',
}, },
{ {
id: "unidentified", id: 'unidentified',
name: "Unidentified", name: 'Unidentified',
}, },
]; ]
const filePermissionTypes: ModerationPermissionType[] = [ const filePermissionTypes: ModerationPermissionType[] = [
{ id: "yes", name: "Yes" }, { id: 'yes', name: 'Yes' },
{ id: "no", name: "No" }, { id: 'no', name: 'No' },
]; ]
function persistAll() { function persistAll() {
persistedModPackData.value = modPackData.value; persistedModPackData.value = modPackData.value
persistedIndex.value = currentIndex.value; persistedIndex.value = currentIndex.value
} }
watch( watch(
modPackData, modPackData,
(newValue) => { (newValue) => {
persistedModPackData.value = newValue; persistedModPackData.value = newValue
}, },
{ deep: true }, { deep: true },
); )
watch(currentIndex, (newValue) => { watch(currentIndex, (newValue) => {
persistedIndex.value = newValue; persistedIndex.value = newValue
}); })
function loadPersistedData(): void { function loadPersistedData(): void {
if (persistedModPackData.value) { if (persistedModPackData.value) {
modPackData.value = persistedModPackData.value; modPackData.value = persistedModPackData.value
} }
currentIndex.value = persistedIndex.value; currentIndex.value = persistedIndex.value
} }
function clearPersistedData(): void { function clearPersistedData(): void {
persistedModPackData.value = null; persistedModPackData.value = null
persistedIndex.value = 0; persistedIndex.value = 0
} }
async function fetchModPackData(): Promise<void> { async function fetchModPackData(): Promise<void> {
try { try {
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, { const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
internal: true, internal: true,
})) as ModerationModpackResponse; })) as ModerationModpackResponse
const permanentNoItems: ModerationModpackItem[] = Object.entries(data.identified || {}) const permanentNoItems: ModerationModpackItem[] = Object.entries(data.identified || {})
.filter(([_, file]) => file.status === "permanent-no") .filter(([_, file]) => file.status === 'permanent-no')
.map( .map(
([sha1, file]): ModerationModpackItem => ({ ([sha1, file]): ModerationModpackItem => ({
sha1, sha1,
file_name: file.file_name, file_name: file.file_name,
type: "identified", type: 'identified',
status: file.status, status: file.status,
approved: null, 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[] = [ const sortedData: ModerationModpackItem[] = [
...Object.entries(data.identified || {}) ...Object.entries(data.identified || {})
.filter( .filter(
([_, file]) => ([_, file]) =>
file.status !== "yes" && file.status !== 'yes' &&
file.status !== "with-attribution-and-source" && file.status !== 'with-attribution-and-source' &&
file.status !== "permanent-no", file.status !== 'permanent-no',
) )
.map( .map(
([sha1, file]): ModerationModpackItem => ({ ([sha1, file]): ModerationModpackItem => ({
sha1, sha1,
file_name: file.file_name, file_name: file.file_name,
type: "identified", type: 'identified',
status: file.status, status: file.status,
approved: null, approved: null,
...(file.status === "unidentified" && { ...(file.status === 'unidentified' && {
proof: "", proof: '',
url: "", url: '',
title: "", title: '',
}), }),
}), }),
) )
@ -314,12 +314,12 @@ async function fetchModPackData(): Promise<void> {
([sha1, fileName]): ModerationUnknownModpackItem => ({ ([sha1, fileName]): ModerationUnknownModpackItem => ({
sha1, sha1,
file_name: fileName, file_name: fileName,
type: "unknown", type: 'unknown',
status: null, status: null,
approved: null, approved: null,
proof: "", proof: '',
url: "", url: '',
title: "", title: '',
}), }),
) )
.sort((a, b) => a.file_name.localeCompare(b.file_name)), .sort((a, b) => a.file_name.localeCompare(b.file_name)),
@ -328,7 +328,7 @@ async function fetchModPackData(): Promise<void> {
([sha1, info]): ModerationFlameModpackItem => ({ ([sha1, info]): ModerationFlameModpackItem => ({
sha1, sha1,
file_name: info.file_name, file_name: info.file_name,
type: "flame", type: 'flame',
status: null, status: null,
approved: null, approved: null,
id: info.id, id: info.id,
@ -337,166 +337,166 @@ async function fetchModPackData(): Promise<void> {
}), }),
) )
.sort((a, b) => a.file_name.localeCompare(b.file_name)), .sort((a, b) => a.file_name.localeCompare(b.file_name)),
]; ]
if (modPackData.value) { 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) => { sortedData.forEach((item) => {
const existing = existingMap.get(item.sha1); const existing = existingMap.get(item.sha1)
if (existing) { if (existing) {
Object.assign(item, { Object.assign(item, {
status: existing.status, status: existing.status,
approved: existing.approved, approved: existing.approved,
...(item.type === "unknown" && { ...(item.type === 'unknown' && {
proof: (existing as ModerationUnknownModpackItem).proof || "", proof: (existing as ModerationUnknownModpackItem).proof || '',
url: (existing as ModerationUnknownModpackItem).url || "", url: (existing as ModerationUnknownModpackItem).url || '',
title: (existing as ModerationUnknownModpackItem).title || "", title: (existing as ModerationUnknownModpackItem).title || '',
}), }),
...(item.type === "flame" && { ...(item.type === 'flame' && {
url: (existing as ModerationFlameModpackItem).url || item.url, url: (existing as ModerationFlameModpackItem).url || item.url,
title: (existing as ModerationFlameModpackItem).title || item.title, title: (existing as ModerationFlameModpackItem).title || item.title,
}), }),
}); })
} }
}); })
} }
modPackData.value = sortedData; modPackData.value = sortedData
persistAll(); persistAll()
} catch (error) { } catch (error) {
console.error("Failed to fetch modpack data:", error); console.error('Failed to fetch modpack data:', error)
modPackData.value = []; modPackData.value = []
permanentNoFiles.value = []; permanentNoFiles.value = []
persistAll(); persistAll()
} }
} }
function goToPrevious(): void { function goToPrevious(): void {
if (currentIndex.value > 0) { if (currentIndex.value > 0) {
currentIndex.value--; currentIndex.value--
persistAll(); persistAll()
} }
} }
watch( watch(
modPackData, modPackData,
(newValue) => { (newValue) => {
persistedModPackData.value = newValue; persistedModPackData.value = newValue
}, },
{ deep: true }, { deep: true },
); )
function goToNext(): void { function goToNext(): void {
if (modPackData.value && currentIndex.value < modPackData.value.length) { if (modPackData.value && currentIndex.value < modPackData.value.length) {
currentIndex.value++; currentIndex.value++
if (currentIndex.value >= modPackData.value.length) { if (currentIndex.value >= modPackData.value.length) {
const judgements = getJudgements(); const judgements = getJudgements()
emit("update:modelValue", judgements); emit('update:modelValue', judgements)
emit("complete"); emit('complete')
clearPersistedData(); clearPersistedData()
} else { } 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]) { if (modPackData.value && modPackData.value[index]) {
modPackData.value[index].status = status; modPackData.value[index].status = status
modPackData.value[index].approved = null; modPackData.value[index].approved = null
persistAll(); persistAll()
emit("update:modelValue", getJudgements()); 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]) { if (modPackData.value && modPackData.value[index]) {
modPackData.value[index].approved = approved; modPackData.value[index].approved = approved
persistAll(); persistAll()
emit("update:modelValue", getJudgements()); emit('update:modelValue', getJudgements())
} }
} }
const canGoNext = computed(() => { const canGoNext = computed(() => {
if (!modPackData.value || !modPackData.value[currentIndex.value]) return false; if (!modPackData.value || !modPackData.value[currentIndex.value]) return false
const current = modPackData.value[currentIndex.value]; const current = modPackData.value[currentIndex.value]
return current.status !== null; return current.status !== null
}); })
function getJudgements(): ModerationJudgements { function getJudgements(): ModerationJudgements {
if (!modPackData.value) return {}; if (!modPackData.value) return {}
const judgements: ModerationJudgements = {}; const judgements: ModerationJudgements = {}
modPackData.value.forEach((item) => { modPackData.value.forEach((item) => {
if (item.type === "flame") { if (item.type === 'flame') {
judgements[item.sha1] = { judgements[item.sha1] = {
type: "flame", type: 'flame',
id: item.id, id: item.id,
status: item.status, status: item.status,
link: item.url, link: item.url,
title: item.title, title: item.title,
file_name: item.file_name, file_name: item.file_name,
}; }
} else if (item.type === "unknown") { } else if (item.type === 'unknown') {
judgements[item.sha1] = { judgements[item.sha1] = {
type: "unknown", type: 'unknown',
status: item.status, status: item.status,
proof: item.proof, proof: item.proof,
link: item.url, link: item.url,
title: item.title, title: item.title,
file_name: item.file_name, file_name: item.file_name,
};
} }
}); }
})
return judgements; return judgements
} }
onMounted(() => { onMounted(() => {
loadPersistedData(); loadPersistedData()
if (!modPackData.value) { if (!modPackData.value) {
fetchModPackData(); fetchModPackData()
} }
}); })
watch( watch(
modPackData, modPackData,
(newValue) => { (newValue) => {
if (newValue && newValue.length === 0) { if (newValue && newValue.length === 0) {
emit("complete"); emit('complete')
clearPersistedData(); clearPersistedData()
} }
}, },
{ immediate: true }, { immediate: true },
); )
watch( watch(
() => props.projectId, () => props.projectId,
() => { () => {
clearPersistedData(); clearPersistedData()
loadPersistedData(); loadPersistedData()
if (!modPackData.value) { if (!modPackData.value) {
fetchModPackData(); fetchModPackData()
} }
}, },
); )
function getModpackFiles(): { function getModpackFiles(): {
interactive: ModerationModpackItem[]; interactive: ModerationModpackItem[]
permanentNo: ModerationModpackItem[]; permanentNo: ModerationModpackItem[]
} { } {
return { return {
interactive: modPackData.value || [], interactive: modPackData.value || [],
permanentNo: permanentNoFiles.value, permanentNo: permanentNoFiles.value,
}; }
} }
defineExpose({ defineExpose({
getModpackFiles, getModpackFiles,
}); })
</script> </script>
<style scoped> <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({ export default defineComponent({
name: "ModrinthLoadingIndicator", name: 'ModrinthLoadingIndicator',
props: { props: {
throttle: { throttle: {
type: Number, type: Number,
@ -20,115 +20,115 @@ export default defineComponent({
color: { color: {
type: [String, Boolean], type: [String, Boolean],
default: 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 }) { setup(props, { slots }) {
const indicator = useLoadingIndicator({ const indicator = useLoadingIndicator({
duration: props.duration, duration: props.duration,
throttle: props.throttle, throttle: props.throttle,
}); })
const nuxtApp = useNuxtApp(); const nuxtApp = useNuxtApp()
nuxtApp.hook("page:start", () => { nuxtApp.hook('page:start', () => {
startLoading(); startLoading()
indicator.start(); indicator.start()
}); })
nuxtApp.hook("page:finish", () => { nuxtApp.hook('page:finish', () => {
stopLoading(); stopLoading()
indicator.finish(); indicator.finish()
}); })
onBeforeUnmount(() => indicator.clear); onBeforeUnmount(() => indicator.clear)
const loading = useLoading(); const loading = useLoading()
watch(loading, (newValue) => { watch(loading, (newValue) => {
if (newValue) { if (newValue) {
indicator.start(); indicator.start()
} else { } else {
indicator.finish(); indicator.finish()
} }
}); })
return () => return () =>
h( h(
"div", 'div',
{ {
class: "nuxt-loading-indicator", class: 'nuxt-loading-indicator',
style: { style: {
position: "fixed", position: 'fixed',
top: 0, top: 0,
right: 0, right: 0,
left: 0, left: 0,
pointerEvents: "none", pointerEvents: 'none',
width: `${indicator.progress.value}%`, width: `${indicator.progress.value}%`,
height: `${props.height}px`, height: `${props.height}px`,
opacity: indicator.isLoading.value ? 1 : 0, opacity: indicator.isLoading.value ? 1 : 0,
background: props.color || undefined, background: props.color || undefined,
backgroundSize: `${(100 / indicator.progress.value) * 100}% auto`, 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, zIndex: 999999,
}, },
}, },
slots, slots,
); )
}, },
}); })
function useLoadingIndicator(opts: { duration: number; throttle: number }) { function useLoadingIndicator(opts: { duration: number; throttle: number }) {
const progress = ref(0); const progress = ref(0)
const isLoading = ref(false); const isLoading = ref(false)
const step = computed(() => 10000 / opts.duration); const step = computed(() => 10000 / opts.duration)
let _timer: any = null; let _timer: any = null
let _throttle: any = null; let _throttle: any = null
function start() { function start() {
clear(); clear()
progress.value = 0; progress.value = 0
if (opts.throttle && import.meta.client) { if (opts.throttle && import.meta.client) {
_throttle = setTimeout(() => { _throttle = setTimeout(() => {
isLoading.value = true; isLoading.value = true
_startTimer(); _startTimer()
}, opts.throttle); }, opts.throttle)
} else { } else {
isLoading.value = true; isLoading.value = true
_startTimer(); _startTimer()
} }
} }
function finish() { function finish() {
progress.value = 100; progress.value = 100
_hide(); _hide()
} }
function clear() { function clear() {
clearInterval(_timer); clearInterval(_timer)
clearTimeout(_throttle); clearTimeout(_throttle)
_timer = null; _timer = null
_throttle = null; _throttle = null
} }
function _increase(num: number) { function _increase(num: number) {
progress.value = Math.min(100, progress.value + num); progress.value = Math.min(100, progress.value + num)
} }
function _hide() { function _hide() {
clear(); clear()
if (import.meta.client) { if (import.meta.client) {
setTimeout(() => { setTimeout(() => {
isLoading.value = false; isLoading.value = false
setTimeout(() => { setTimeout(() => {
progress.value = 0; progress.value = 0
}, 400); }, 400)
}, 500); }, 500)
} }
} }
function _startTimer() { function _startTimer() {
if (import.meta.client) { if (import.meta.client) {
_timer = setInterval(() => { _timer = setInterval(() => {
_increase(step.value); _increase(step.value)
}, 100); }, 100)
} }
} }
@ -138,5 +138,5 @@ function useLoadingIndicator(opts: { duration: number; throttle: number }) {
start, start,
finish, finish,
clear, clear,
}; }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@
class="flex h-full w-full flex-col items-center justify-center gap-8" class="flex h-full w-full flex-col items-center justify-center gap-8"
> >
<UiServersIconsPanelErrorIcon /> <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> </div>
<img <img
v-show="isReady" v-show="isReady"
@ -53,20 +53,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ZoomInIcon, ZoomOutIcon } from "@modrinth/assets"; import { ZoomInIcon, ZoomOutIcon } from '@modrinth/assets'
import { ButtonStyled } from "@modrinth/ui"; import { ButtonStyled } from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref, watch } from "vue"; import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const ZOOM_MIN = 0.1; const ZOOM_MIN = 0.1
const ZOOM_MAX = 5; const ZOOM_MAX = 5
const ZOOM_IN_FACTOR = 1.2; const ZOOM_IN_FACTOR = 1.2
const ZOOM_OUT_FACTOR = 0.8; const ZOOM_OUT_FACTOR = 0.8
const INITIAL_SCALE = 0.5; const INITIAL_SCALE = 0.5
const MAX_IMAGE_DIMENSION = 4096; const MAX_IMAGE_DIMENSION = 4096
const props = defineProps<{ const props = defineProps<{
imageBlob: Blob; imageBlob: Blob
}>(); }>()
const state = ref({ const state = ref({
scale: INITIAL_SCALE, scale: INITIAL_SCALE,
@ -77,102 +77,102 @@ const state = ref({
startY: 0, startY: 0,
isLoading: false, isLoading: false,
hasError: false, hasError: false,
errorMessage: "", errorMessage: '',
}); })
const imageRef = ref<HTMLImageElement | null>(null); const imageRef = ref<HTMLImageElement | null>(null)
const container = ref<HTMLElement | null>(null); const container = ref<HTMLElement | null>(null)
const imageObjectUrl = ref(""); const imageObjectUrl = ref('')
const rafId = ref(0); 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(() => ({ const imageStyle = computed(() => ({
transform: `translate(-50%, -50%) scale(${state.value.scale}) translate(${state.value.translateX}px, ${state.value.translateY}px)`, 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 => { const validateImageDimensions = (img: HTMLImageElement): boolean => {
if (img.naturalWidth > MAX_IMAGE_DIMENSION || img.naturalHeight > MAX_IMAGE_DIMENSION) { if (img.naturalWidth > MAX_IMAGE_DIMENSION || img.naturalHeight > MAX_IMAGE_DIMENSION) {
state.value.hasError = true; state.value.hasError = true
state.value.errorMessage = `Image too large to view (max ${MAX_IMAGE_DIMENSION}x${MAX_IMAGE_DIMENSION} pixels)`; state.value.errorMessage = `Image too large to view (max ${MAX_IMAGE_DIMENSION}x${MAX_IMAGE_DIMENSION} pixels)`
return false; return false
}
return true
} }
return true;
};
const updateImageUrl = (blob: Blob) => { const updateImageUrl = (blob: Blob) => {
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value); if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
imageObjectUrl.value = URL.createObjectURL(blob); imageObjectUrl.value = URL.createObjectURL(blob)
}; }
const handleImageLoad = () => { const handleImageLoad = () => {
if (!imageRef.value || !validateImageDimensions(imageRef.value)) { if (!imageRef.value || !validateImageDimensions(imageRef.value)) {
state.value.isLoading = false; state.value.isLoading = false
return; return
}
state.value.isLoading = false
reset()
} }
state.value.isLoading = false;
reset();
};
const handleImageError = () => { const handleImageError = () => {
state.value.isLoading = false; state.value.isLoading = false
state.value.hasError = true; state.value.hasError = true
state.value.errorMessage = "Failed to load image"; state.value.errorMessage = 'Failed to load image'
}; }
const zoom = (factor: number) => { const zoom = (factor: number) => {
const newScale = state.value.scale * factor; const newScale = state.value.scale * factor
state.value.scale = Math.max(ZOOM_MIN, Math.min(newScale, ZOOM_MAX)); state.value.scale = Math.max(ZOOM_MIN, Math.min(newScale, ZOOM_MAX))
}; }
const reset = () => { const reset = () => {
state.value.scale = INITIAL_SCALE; state.value.scale = INITIAL_SCALE
state.value.translateX = 0; state.value.translateX = 0
state.value.translateY = 0; state.value.translateY = 0
}; }
const startPan = (e: MouseEvent) => { const startPan = (e: MouseEvent) => {
state.value.isPanning = true; state.value.isPanning = true
state.value.startX = e.clientX - state.value.translateX; state.value.startX = e.clientX - state.value.translateX
state.value.startY = e.clientY - state.value.translateY; state.value.startY = e.clientY - state.value.translateY
}; }
const handlePan = (e: MouseEvent) => { const handlePan = (e: MouseEvent) => {
if (!state.value.isPanning) return; if (!state.value.isPanning) return
cancelAnimationFrame(rafId.value); cancelAnimationFrame(rafId.value)
rafId.value = requestAnimationFrame(() => { rafId.value = requestAnimationFrame(() => {
state.value.translateX = e.clientX - state.value.startX; state.value.translateX = e.clientX - state.value.startX
state.value.translateY = e.clientY - state.value.startY; state.value.translateY = e.clientY - state.value.startY
}); })
}; }
const stopPan = () => { const stopPan = () => {
state.value.isPanning = false; state.value.isPanning = false
}; }
const handleWheel = (e: WheelEvent) => { const handleWheel = (e: WheelEvent) => {
const delta = e.deltaY * -0.001; const delta = e.deltaY * -0.001
const factor = 1 + delta; const factor = 1 + delta
zoom(factor); zoom(factor)
}; }
watch( watch(
() => props.imageBlob, () => props.imageBlob,
(newBlob) => { (newBlob) => {
if (!newBlob) return; if (!newBlob) return
state.value.isLoading = true; state.value.isLoading = true
state.value.hasError = false; state.value.hasError = false
updateImageUrl(newBlob); updateImageUrl(newBlob)
}, },
); )
onMounted(() => { onMounted(() => {
if (props.imageBlob) updateImageUrl(props.imageBlob); if (props.imageBlob) updateImageUrl(props.imageBlob)
}); })
onUnmounted(() => { onUnmounted(() => {
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value); if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
cancelAnimationFrame(rafId.value); cancelAnimationFrame(rafId.value)
}); })
</script> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,7 @@
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit"> <form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="font-bold text-contrast"> <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> </div>
<ol v-if="cf" class="mb-1 mt-0 flex flex-col gap-1 pl-8 leading-normal text-secondary"> <ol v-if="cf" class="mb-1 mt-0 flex flex-col gap-1 pl-8 leading-normal text-secondary">
<li> <li>
@ -58,13 +58,13 @@
<button v-tooltip="error" :disabled="submitted || !!error" type="submit"> <button v-tooltip="error" :disabled="submitted || !!error" type="submit">
<SpinnerIcon v-if="submitted" class="animate-spin" /> <SpinnerIcon v-if="submitted" class="animate-spin" />
<DownloadIcon v-else class="h-5 w-5" /> <DownloadIcon v-else class="h-5 w-5" />
{{ submitted ? "Installing..." : "Install" }} {{ submitted ? 'Installing...' : 'Install' }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled>
<button type="button" @click="hide"> <button type="button" @click="hide">
<XIcon class="h-5 w-5" /> <XIcon class="h-5 w-5" />
{{ submitted ? "Close" : "Cancel" }} {{ submitted ? 'Close' : 'Cancel' }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@ -73,84 +73,84 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { DownloadIcon, ExternalIcon, SpinnerIcon, XIcon } from "@modrinth/assets"; import { DownloadIcon, ExternalIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui"; import { BackupWarning, ButtonStyled, NewModal } from '@modrinth/ui'
import { ModrinthServersFetchError } from "@modrinth/utils"; import { ModrinthServersFetchError } from '@modrinth/utils'
import { computed, nextTick,ref } from "vue"; import { computed, nextTick, ref } from 'vue'
import type { ModrinthServer } from "~/composables/servers/modrinth-servers.ts"; import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { handleError } 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<{ const props = defineProps<{
server: ModrinthServer; server: ModrinthServer
}>(); }>()
const modal = ref<typeof NewModal>(); const modal = ref<typeof NewModal>()
const urlInput = ref<HTMLInputElement | null>(null); const urlInput = ref<HTMLInputElement | null>(null)
const url = ref(""); const url = ref('')
const submitted = ref(false); 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(() => { const error = computed(() => {
if (trimmedUrl.value.length === 0) { if (trimmedUrl.value.length === 0) {
return "URL is required."; return 'URL is required.'
} }
if (cf.value && !regex.test(trimmedUrl.value)) { if (cf.value && !regex.test(trimmedUrl.value)) {
return "URL must be a CurseForge modpack version URL."; return 'URL must be a CurseForge modpack version URL.'
} else if (!cf.value && !trimmedUrl.value.includes("/")) { } else if (!cf.value && !trimmedUrl.value.includes('/')) {
return "URL must be valid."; return 'URL must be valid.'
} }
return ""; return ''
}); })
const handleSubmit = async () => { const handleSubmit = async () => {
submitted.value = true; submitted.value = true
if (!error.value) { if (!error.value) {
// hide(); // hide();
try { 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) { if (!cf.value || dry.modpack_name) {
await props.server.fs.extractFile(trimmedUrl.value, true, false, true); await props.server.fs.extractFile(trimmedUrl.value, true, false, true)
hide(); hide()
} else { } else {
submitted.value = false; submitted.value = false
handleError( handleError(
new ModrinthServersFetchError( new ModrinthServersFetchError(
"Could not find CurseForge modpack at that URL.", 'Could not find CurseForge modpack at that URL.',
404, 404,
new Error(`No modpack found at ${url.value}`), new Error(`No modpack found at ${url.value}`),
), ),
); )
} }
} catch (error) { } catch (error) {
submitted.value = false; submitted.value = false
console.error("Error installing:", error); console.error('Error installing:', error)
handleError(error); handleError(error)
}
} }
} }
};
const show = (isCf: boolean) => { const show = (isCf: boolean) => {
cf.value = isCf; cf.value = isCf
url.value = ""; url.value = ''
submitted.value = false; submitted.value = false
modal.value?.show(); modal.value?.show()
nextTick(() => { nextTick(() => {
setTimeout(() => { setTimeout(() => {
urlInput.value?.focus(); urlInput.value?.focus()
}, 100); }, 100)
}); })
}; }
const hide = () => { const hide = () => {
modal.value?.hide(); modal.value?.hide()
}; }
defineExpose({ show, hide }); defineExpose({ show, hide })
</script> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -39,37 +39,37 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ServerState } from "@modrinth/utils"; import type { ServerState } from '@modrinth/utils'
import { ref } from "vue"; import { ref } from 'vue'
const STATUS_CLASSES = { const STATUS_CLASSES = {
running: { main: "bg-brand", bg: "bg-bg-green" }, running: { main: 'bg-brand', bg: 'bg-bg-green' },
stopped: { main: "", bg: "" }, stopped: { main: '', bg: '' },
crashed: { main: "bg-brand-red", bg: "bg-bg-red" }, crashed: { main: 'bg-brand-red', bg: 'bg-bg-red' },
unknown: { main: "", bg: "" }, unknown: { main: '', bg: '' },
} as const; } as const
const STATUS_TEXTS: Partial<Record<ServerState, string>> = { const STATUS_TEXTS: Partial<Record<ServerState, string>> = {
running: "Running", running: 'Running',
stopped: "", stopped: '',
crashed: "Crashed", crashed: 'Crashed',
unknown: "Unknown", unknown: 'Unknown',
} as const; } as const
defineProps<{ defineProps<{
state: ServerState; state: ServerState
}>(); }>()
const isExpanded = ref(false); const isExpanded = ref(false)
function getStatusClass(state: ServerState) { function getStatusClass(state: ServerState) {
if (state in STATUS_CLASSES) { 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) { function getStatusText(state: ServerState) {
return STATUS_TEXTS[state] ?? STATUS_TEXTS.unknown; return STATUS_TEXTS[state] ?? STATUS_TEXTS.unknown
} }
</script> </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-4 md:w-[600px]">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<p class="m-0"> <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. your server.
</p> </p>
<p v-if="props.currentVersion" class="m-0 text-sm text-secondary"> <p v-if="props.currentVersion" class="m-0 text-sm text-secondary">
@ -51,7 +51,7 @@
@click="handleReinstall" @click="handleReinstall"
> >
<DownloadIcon class="size-4" /> <DownloadIcon class="size-4" />
{{ isLoading ? "Installing..." : hardReset ? "Erase and install" : "Install" }} {{ isLoading ? 'Installing...' : hardReset ? 'Erase and install' : 'Install' }}
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled>
@ -67,38 +67,38 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { DownloadIcon, XIcon } from "@modrinth/assets"; import { DownloadIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from "@modrinth/ui"; import { ButtonStyled, NewModal } from '@modrinth/ui'
import { ModrinthServersFetchError } from "@modrinth/utils"; 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<{ const props = defineProps<{
server: ModrinthServer; server: ModrinthServer
project: any; project: any
versions: any[]; versions: any[]
currentVersion?: any; currentVersion?: any
currentVersionId?: string; currentVersionId?: string
serverStatus?: string; serverStatus?: string
}>(); }>()
const emit = defineEmits<{ const emit = defineEmits<{
reinstall: [any?]; reinstall: [any?]
}>(); }>()
const modal = ref(); const modal = ref()
const hardReset = ref(false); const hardReset = ref(false)
const isLoading = ref(false); const isLoading = ref(false)
const selectedVersion = ref(""); 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 () => { const handleReinstall = async () => {
if (!selectedVersion.value || !props.project?.id) return; if (!selectedVersion.value || !props.project?.id) return
isLoading.value = true; isLoading.value = true
try { 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( await props.server.general.reinstall(
false, false,
@ -106,56 +106,56 @@ const handleReinstall = async () => {
versionId, versionId,
undefined, undefined,
hardReset.value, hardReset.value,
); )
emit("reinstall"); emit('reinstall')
hide(); hide()
} catch (error) { } catch (error) {
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) { if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
addNotification({ addNotification({
group: "server", group: 'server',
title: "Cannot reinstall server", title: 'Cannot reinstall server',
text: "You are being rate limited. Please try again later.", text: 'You are being rate limited. Please try again later.',
type: "error", type: 'error',
}); })
} else { } else {
addNotification({ addNotification({
group: "server", group: 'server',
title: "Reinstall Failed", title: 'Reinstall Failed',
text: "An unexpected error occurred while reinstalling. Please try again later.", text: 'An unexpected error occurred while reinstalling. Please try again later.',
type: "error", type: 'error',
}); })
} }
} finally { } finally {
isLoading.value = false; isLoading.value = false
}
} }
};
watch( watch(
() => props.serverStatus, () => props.serverStatus,
(newStatus) => { (newStatus) => {
if (newStatus === "installing") { if (newStatus === 'installing') {
hide(); hide()
} }
}, },
); )
const onShow = () => { const onShow = () => {
hardReset.value = false; hardReset.value = false
selectedVersion.value = selectedVersion.value =
props.currentVersion?.version_number ?? props.versions?.[0]?.version_number ?? ""; props.currentVersion?.version_number ?? props.versions?.[0]?.version_number ?? ''
}; }
const onHide = () => { const onHide = () => {
hardReset.value = false; hardReset.value = false
selectedVersion.value = ""; selectedVersion.value = ''
isLoading.value = false; isLoading.value = false
}; }
const show = () => modal.value?.show(); const show = () => modal.value?.show()
const hide = () => modal.value?.hide(); const hide = () => modal.value?.hide()
defineExpose({ show, hide }); defineExpose({ show, hide })
</script> </script>
<style scoped> <style scoped>

View File

@ -110,12 +110,12 @@
<RightArrowIcon /> <RightArrowIcon />
{{ {{
isMrpackModalSecondPhase isMrpackModalSecondPhase
? "Erase and install" ? 'Erase and install'
: loadingServerCheck : loadingServerCheck
? "Loading..." ? 'Loading...'
: isDangerous : isDangerous
? "Erase and install" ? 'Erase and install'
: "Install" : 'Install'
}} }}
</button> </button>
</ButtonStyled> </ButtonStyled>
@ -125,15 +125,15 @@
@click=" @click="
() => { () => {
if (isMrpackModalSecondPhase) { if (isMrpackModalSecondPhase) {
isMrpackModalSecondPhase = false; isMrpackModalSecondPhase = false
} else { } else {
hide(); hide()
} }
} }
" "
> >
<XIcon /> <XIcon />
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }} {{ isMrpackModalSecondPhase ? 'Go back' : 'Cancel' }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@ -150,200 +150,200 @@ import {
ServerIcon, ServerIcon,
UploadIcon, UploadIcon,
XIcon, XIcon,
} from "@modrinth/assets"; } from '@modrinth/assets'
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui"; import { BackupWarning, ButtonStyled, NewModal } from '@modrinth/ui'
import { formatBytes, ModrinthServersFetchError } from "@modrinth/utils"; import { formatBytes, ModrinthServersFetchError } from '@modrinth/utils'
import { onMounted, onUnmounted } from "vue"; import { onMounted, onUnmounted } from 'vue'
import type { ModrinthServer } from "~/composables/servers/modrinth-servers"; import type { ModrinthServer } from '~/composables/servers/modrinth-servers'
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue"; import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
const handleBeforeUnload = (event: BeforeUnloadEvent) => { const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (isLoading.value) { if (isLoading.value) {
event.preventDefault(); event.preventDefault()
return "Upload in progress. Are you sure you want to leave?"; return 'Upload in progress. Are you sure you want to leave?'
}
} }
};
onMounted(() => { onMounted(() => {
window.addEventListener("beforeunload", handleBeforeUnload); window.addEventListener('beforeunload', handleBeforeUnload)
}); })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener('beforeunload', handleBeforeUnload)
}); })
const props = defineProps<{ const props = defineProps<{
server: ModrinthServer; server: ModrinthServer
backupInProgress?: BackupInProgressReason; backupInProgress?: BackupInProgressReason
}>(); }>()
const emit = defineEmits<{ const emit = defineEmits<{
reinstall: [any?]; reinstall: [any?]
}>(); }>()
const mrpackModal = ref(); const mrpackModal = ref()
const isMrpackModalSecondPhase = ref(false); const isMrpackModalSecondPhase = ref(false)
const hardReset = ref(false); const hardReset = ref(false)
const isLoading = ref(false); const isLoading = ref(false)
const loadingServerCheck = ref(false); const loadingServerCheck = ref(false)
const mrpackFile = ref<File | null>(null); const mrpackFile = ref<File | null>(null)
const uploadProgress = ref(0); const uploadProgress = ref(0)
const uploadedBytes = ref(0); const uploadedBytes = ref(0)
const totalBytes = ref(0); const totalBytes = ref(0)
const uploadPhrases = [ const uploadPhrases = [
"Removing Herobrine...", 'Removing Herobrine...',
"Feeding parrots...", 'Feeding parrots...',
"Teaching villagers new trades...", 'Teaching villagers new trades...',
"Convincing creepers to be friendly...", 'Convincing creepers to be friendly...',
"Polishing diamonds...", 'Polishing diamonds...',
"Training wolves to fetch...", 'Training wolves to fetch...',
"Building pixel art...", 'Building pixel art...',
"Explaining redstone to beginners...", 'Explaining redstone to beginners...',
"Collecting all the cats...", 'Collecting all the cats...',
"Negotiating with endermen...", 'Negotiating with endermen...',
"Planting suspicious stew ingredients...", 'Planting suspicious stew ingredients...',
"Calibrating TNT blast radius...", 'Calibrating TNT blast radius...',
"Teaching chickens to fly...", 'Teaching chickens to fly...',
"Sorting inventory alphabetically...", 'Sorting inventory alphabetically...',
"Convincing iron golems to smile...", 'Convincing iron golems to smile...',
]; ]
const currentPhrase = ref("Uploading..."); const currentPhrase = ref('Uploading...')
let phraseInterval: NodeJS.Timeout | null = null; let phraseInterval: NodeJS.Timeout | null = null
const usedPhrases = ref(new Set<number>()); const usedPhrases = ref(new Set<number>())
const getNextPhrase = () => { const getNextPhrase = () => {
if (usedPhrases.value.size >= uploadPhrases.length) { if (usedPhrases.value.size >= uploadPhrases.length) {
const currentPhraseIndex = uploadPhrases.indexOf(currentPhrase.value); const currentPhraseIndex = uploadPhrases.indexOf(currentPhrase.value)
usedPhrases.value.clear(); usedPhrases.value.clear()
if (currentPhraseIndex !== -1) { if (currentPhraseIndex !== -1) {
usedPhrases.value.add(currentPhraseIndex); usedPhrases.value.add(currentPhraseIndex)
} }
} }
const availableIndices = uploadPhrases const availableIndices = uploadPhrases
.map((_, index) => index) .map((_, index) => index)
.filter((index) => !usedPhrases.value.has(index)); .filter((index) => !usedPhrases.value.has(index))
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)]; const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)]
usedPhrases.value.add(randomIndex); usedPhrases.value.add(randomIndex)
return uploadPhrases[randomIndex]; return uploadPhrases[randomIndex]
}; }
const isDangerous = computed(() => hardReset.value); const isDangerous = computed(() => hardReset.value)
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value); const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value)
const uploadMrpack = (event: Event) => { const uploadMrpack = (event: Event) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement
if (!target.files || target.files.length === 0) { if (!target.files || target.files.length === 0) {
return; return
}
mrpackFile.value = target.files[0]
} }
mrpackFile.value = target.files[0];
};
const handleReinstall = async () => { const handleReinstall = async () => {
if (hardReset.value && !isMrpackModalSecondPhase.value) { if (hardReset.value && !isMrpackModalSecondPhase.value) {
isMrpackModalSecondPhase.value = true; isMrpackModalSecondPhase.value = true
return; return
} }
if (!mrpackFile.value) { if (!mrpackFile.value) {
addNotification({ addNotification({
group: "server", group: 'server',
title: "No file selected", title: 'No file selected',
text: "Choose a .mrpack file before installing.", text: 'Choose a .mrpack file before installing.',
type: "error", type: 'error',
}); })
return; return
} }
isLoading.value = true; isLoading.value = true
uploadProgress.value = 0; uploadProgress.value = 0
uploadProgress.value = 0; uploadProgress.value = 0
uploadedBytes.value = 0; uploadedBytes.value = 0
totalBytes.value = mrpackFile.value.size; totalBytes.value = mrpackFile.value.size
currentPhrase.value = getNextPhrase(); currentPhrase.value = getNextPhrase()
phraseInterval = setInterval(() => { phraseInterval = setInterval(() => {
currentPhrase.value = getNextPhrase(); currentPhrase.value = getNextPhrase()
}, 4500); }, 4500)
const { onProgress, promise } = props.server.general.reinstallFromMrpack( const { onProgress, promise } = props.server.general.reinstallFromMrpack(
mrpackFile.value, mrpackFile.value,
hardReset.value, hardReset.value,
); )
onProgress(({ loaded, total, progress }) => { onProgress(({ loaded, total, progress }) => {
uploadProgress.value = progress; uploadProgress.value = progress
uploadedBytes.value = loaded; uploadedBytes.value = loaded
totalBytes.value = total; totalBytes.value = total
if (phraseInterval && progress >= 100) { if (phraseInterval && progress >= 100) {
clearInterval(phraseInterval); clearInterval(phraseInterval)
phraseInterval = null; phraseInterval = null
currentPhrase.value = "Installing modpack..."; currentPhrase.value = 'Installing modpack...'
} }
}); })
try { try {
await promise; await promise
emit("reinstall", { emit('reinstall', {
loader: "mrpack", loader: 'mrpack',
lVersion: "", lVersion: '',
mVersion: "", mVersion: '',
}); })
await nextTick(); await nextTick()
window.scrollTo(0, 0); window.scrollTo(0, 0)
hide(); hide()
} catch (error) { } catch (error) {
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) { if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
addNotification({ addNotification({
group: "server", group: 'server',
title: "Cannot upload and install modpack to server", title: 'Cannot upload and install modpack to server',
text: "You are being rate limited. Please try again later.", text: 'You are being rate limited. Please try again later.',
type: "error", type: 'error',
}); })
} else { } else {
addNotification({ addNotification({
group: "server", group: 'server',
title: "Modpack upload and install failed", title: 'Modpack upload and install failed',
text: "An unexpected error occurred while uploading/installing. Please try again later.", text: 'An unexpected error occurred while uploading/installing. Please try again later.',
type: "error", type: 'error',
}); })
} }
} finally { } finally {
isLoading.value = false; isLoading.value = false
if (phraseInterval) { if (phraseInterval) {
clearInterval(phraseInterval); clearInterval(phraseInterval)
phraseInterval = null; phraseInterval = null
}
} }
} }
};
const onShow = () => { const onShow = () => {
hardReset.value = false; hardReset.value = false
isMrpackModalSecondPhase.value = false; isMrpackModalSecondPhase.value = false
loadingServerCheck.value = false; loadingServerCheck.value = false
isLoading.value = false; isLoading.value = false
mrpackFile.value = null; mrpackFile.value = null
uploadProgress.value = 0; uploadProgress.value = 0
uploadedBytes.value = 0; uploadedBytes.value = 0
totalBytes.value = 0; totalBytes.value = 0
currentPhrase.value = "Uploading..."; currentPhrase.value = 'Uploading...'
usedPhrases.value.clear(); usedPhrases.value.clear()
if (phraseInterval) { if (phraseInterval) {
clearInterval(phraseInterval); clearInterval(phraseInterval)
phraseInterval = null; phraseInterval = null
}
} }
};
const show = () => mrpackModal.value?.show(); const show = () => mrpackModal.value?.show()
const hide = () => mrpackModal.value?.hide(); const hide = () => mrpackModal.value?.hide()
defineExpose({ show, hide }); defineExpose({ show, hide })
</script> </script>
<style scoped> <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> </p>
<div v-if="!isSecondPhase" class="flex flex-col gap-4"> <div v-if="!isSecondPhase" class="flex flex-col gap-4">
@ -165,12 +165,12 @@
<RightArrowIcon /> <RightArrowIcon />
{{ {{
isLoading isLoading
? "Installing..." ? 'Installing...'
: isSecondPhase : isSecondPhase
? "Erase and install" ? 'Erase and install'
: hardReset : hardReset
? "Continue" ? 'Continue'
: "Install" : 'Install'
}} }}
</button> </button>
</ButtonStyled> </ButtonStyled>
@ -180,15 +180,15 @@
@click=" @click="
() => { () => {
if (isSecondPhase) { if (isSecondPhase) {
isSecondPhase = false; isSecondPhase = false
} else { } else {
hide(); hide()
} }
} }
" "
> >
<XIcon /> <XIcon />
{{ isSecondPhase ? "Go back" : "Cancel" }} {{ isSecondPhase ? 'Go back' : 'Cancel' }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@ -197,170 +197,169 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from "@modrinth/assets"; import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from '@modrinth/assets'
import { BackupWarning, ButtonStyled, NewModal, Toggle } from "@modrinth/ui"; import { BackupWarning, ButtonStyled, NewModal, Toggle } from '@modrinth/ui'
import { type Loaders, ModrinthServersFetchError } from "@modrinth/utils"; import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
import { $fetch } from "ofetch"; import { $fetch } from 'ofetch'
import type { ModrinthServer } from "~/composables/servers/modrinth-servers.ts"; import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue"; import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
const { formatMessage } = useVIntl(); const { formatMessage } = useVIntl()
interface LoaderVersion { interface LoaderVersion {
id: string; id: string
stable: boolean; stable: boolean
loaders: { loaders: {
id: string; id: string
url: string; url: string
stable: boolean; stable: boolean
}[]; }[]
} }
type VersionMap = Record<string, LoaderVersion[]>; type VersionMap = Record<string, LoaderVersion[]>
type VersionCache = Record<string, any>; type VersionCache = Record<string, any>
const props = defineProps<{ const props = defineProps<{
server: ModrinthServer; server: ModrinthServer
currentLoader: Loaders | undefined; currentLoader: Loaders | undefined
backupInProgress?: BackupInProgressReason; backupInProgress?: BackupInProgressReason
initialSetup?: boolean; initialSetup?: boolean
}>(); }>()
const emit = defineEmits<{ const emit = defineEmits<{
reinstall: [any?]; reinstall: [any?]
}>(); }>()
const versionSelectModal = ref(); const versionSelectModal = ref()
const isSecondPhase = ref(false); const isSecondPhase = ref(false)
const hardReset = ref(false); const hardReset = ref(false)
const isLoading = ref(false); const isLoading = ref(false)
const loadingServerCheck = ref(false); const loadingServerCheck = ref(false)
const serverCheckError = ref(""); const serverCheckError = ref('')
const showSnapshots = ref(false); const showSnapshots = ref(false)
const selectedLoader = ref<Loaders>("Vanilla"); const selectedLoader = ref<Loaders>('Vanilla')
const selectedMCVersion = ref(""); const selectedMCVersion = ref('')
const selectedLoaderVersion = ref(""); const selectedLoaderVersion = ref('')
const paperVersions = ref<Record<string, number[]>>({}); const paperVersions = ref<Record<string, number[]>>({})
const purpurVersions = ref<Record<string, string[]>>({}); const purpurVersions = ref<Record<string, string[]>>({})
const loaderVersions = ref<VersionMap>({}); const loaderVersions = ref<VersionMap>({})
const cachedVersions = ref<VersionCache>({}); const cachedVersions = ref<VersionCache>({})
const versionStrings = ["forge", "fabric", "quilt", "neo"] as const; const versionStrings = ['forge', 'fabric', 'quilt', 'neo'] as const
const isSnapshotSelected = computed(() => { const isSnapshotSelected = computed(() => {
if (selectedMCVersion.value) { if (selectedMCVersion.value) {
const selected = tags.value.gameVersions.find((x) => x.version === selectedMCVersion.value); const selected = tags.value.gameVersions.find((x) => x.version === selectedMCVersion.value)
if (selected?.version_type !== "release") { if (selected?.version_type !== 'release') {
return true; return true
} }
} }
return false; return false
}); })
const getLoaderVersions = async (loader: string) => { const getLoaderVersions = async (loader: string) => {
return await $fetch( return await $fetch(
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/manifest.json`, `https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/manifest.json`,
); )
}; }
const fetchLoaderVersions = async () => { const fetchLoaderVersions = async () => {
const versions = await Promise.all( const versions = await Promise.all(
versionStrings.map(async (loader) => { versionStrings.map(async (loader) => {
const runFetch = async (iterations: number) => { const runFetch = async (iterations: number) => {
if (iterations > 5) { if (iterations > 5) {
throw new Error("Failed to fetch loader versions"); throw new Error('Failed to fetch loader versions')
} }
try { try {
const res = await getLoaderVersions(loader); const res = await getLoaderVersions(loader)
return { [loader]: (res as any).gameVersions }; return { [loader]: (res as any).gameVersions }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_) { } catch (_) {
return await runFetch(iterations + 1); return await runFetch(iterations + 1)
}
} }
};
try { try {
return await runFetch(0); return await runFetch(0)
} catch (e) { } catch (e) {
console.error(e); console.error(e)
return { [loader]: [] }; return { [loader]: [] }
} }
}), }),
); )
loaderVersions.value = versions.reduce((acc, val) => ({ ...acc, ...val }), {}); loaderVersions.value = versions.reduce((acc, val) => ({ ...acc, ...val }), {})
}; }
const fetchPaperVersions = async (mcVersion: string) => { const fetchPaperVersions = async (mcVersion: string) => {
try { try {
const res = await $fetch(`https://api.papermc.io/v2/projects/paper/versions/${mcVersion}`); 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); paperVersions.value[mcVersion] = (res as any).builds.sort((a: number, b: number) => b - a)
return res; return res
} catch (e) { } catch (e) {
console.error(e); console.error(e)
return null; return null
}
} }
};
const fetchPurpurVersions = async (mcVersion: string) => { const fetchPurpurVersions = async (mcVersion: string) => {
try { 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( purpurVersions.value[mcVersion] = (res as any).builds.all.sort(
(a: string, b: string) => parseInt(b) - parseInt(a), (a: string, b: string) => parseInt(b) - parseInt(a),
); )
return res; return res
} catch (e) { } catch (e) {
console.error(e); console.error(e)
return null; return null
}
} }
};
const selectedLoaderVersions = computed<string[]>(() => { const selectedLoaderVersions = computed<string[]>(() => {
const loader = selectedLoader.value.toLowerCase(); const loader = selectedLoader.value.toLowerCase()
if (loader === "paper") { if (loader === 'paper') {
return paperVersions.value[selectedMCVersion.value]?.map((x) => `${x}`) || []; return paperVersions.value[selectedMCVersion.value]?.map((x) => `${x}`) || []
} }
if (loader === "purpur") { if (loader === 'purpur') {
return purpurVersions.value[selectedMCVersion.value] || []; return purpurVersions.value[selectedMCVersion.value] || []
} }
if (loader === "vanilla") { if (loader === 'vanilla') {
return []; return []
} }
let apiLoader = loader; let apiLoader = loader
if (loader === "neoforge") { if (loader === 'neoforge') {
apiLoader = "neo"; apiLoader = 'neo'
} }
const backwardsCompatibleVersion = loaderVersions.value[apiLoader]?.find( const backwardsCompatibleVersion = loaderVersions.value[apiLoader]?.find(
(x) => x.id === '${modrinth.gameVersion}',
(x) => x.id === "${modrinth.gameVersion}", )
);
if (backwardsCompatibleVersion) { if (backwardsCompatibleVersion) {
return backwardsCompatibleVersion.loaders.map((x) => x.id); return backwardsCompatibleVersion.loaders.map((x) => x.id)
} }
return ( return (
loaderVersions.value[apiLoader] loaderVersions.value[apiLoader]
?.find((x) => x.id === selectedMCVersion.value) ?.find((x) => x.id === selectedMCVersion.value)
?.loaders.map((x) => x.id) || [] ?.loaders.map((x) => x.id) || []
); )
}); })
watch(selectedLoader, async () => { watch(selectedLoader, async () => {
if (selectedMCVersion.value) { if (selectedMCVersion.value) {
selectedLoaderVersion.value = ""; selectedLoaderVersion.value = ''
serverCheckError.value = ""; serverCheckError.value = ''
await checkVersionAvailability(selectedMCVersion.value); await checkVersionAvailability(selectedMCVersion.value)
} }
}); })
watch( watch(
selectedLoaderVersions, selectedLoaderVersions,
@ -369,161 +368,161 @@ watch(
newVersions.length > 0 && newVersions.length > 0 &&
(!selectedLoaderVersion.value || !newVersions.includes(selectedLoaderVersion.value)) (!selectedLoaderVersion.value || !newVersions.includes(selectedLoaderVersion.value))
) { ) {
selectedLoaderVersion.value = String(newVersions[0]); selectedLoaderVersion.value = String(newVersions[0])
} }
}, },
{ immediate: true }, { immediate: true },
); )
const getLoaderVersion = async (loader: string, version: string) => { const getLoaderVersion = async (loader: string, version: string) => {
return await $fetch( return await $fetch(
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/versions/${version}.json`, `https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/versions/${version}.json`,
); )
}; }
const checkVersionAvailability = async (version: string) => { const checkVersionAvailability = async (version: string) => {
if (!version || version.trim().length < 3) return; if (!version || version.trim().length < 3) return
isLoading.value = true; isLoading.value = true
loadingServerCheck.value = true; loadingServerCheck.value = true
try { 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) { if (!mcRes.downloads?.server) {
serverCheckError.value = "We couldn't find a server.jar for this version."; serverCheckError.value = "We couldn't find a server.jar for this version."
return; return
} }
const loader = selectedLoader.value.toLowerCase(); const loader = selectedLoader.value.toLowerCase()
if (loader === "paper" || loader === "purpur") { if (loader === 'paper' || loader === 'purpur') {
const fetchFn = loader === "paper" ? fetchPaperVersions : fetchPurpurVersions; const fetchFn = loader === 'paper' ? fetchPaperVersions : fetchPurpurVersions
const result = await fetchFn(version); const result = await fetchFn(version)
if (!result) { if (!result) {
serverCheckError.value = `This Minecraft version is not supported by ${loader}.`; serverCheckError.value = `This Minecraft version is not supported by ${loader}.`
return; return
} }
} }
serverCheckError.value = ""; serverCheckError.value = ''
} catch (error) { } catch (error) {
console.error(error); console.error(error)
serverCheckError.value = "Failed to fetch versions."; serverCheckError.value = 'Failed to fetch versions.'
} finally { } finally {
loadingServerCheck.value = false; loadingServerCheck.value = false
isLoading.value = false; isLoading.value = false
}
} }
};
watch(selectedMCVersion, checkVersionAvailability); watch(selectedMCVersion, checkVersionAvailability)
onMounted(() => { onMounted(() => {
fetchLoaderVersions(); fetchLoaderVersions()
}); })
const tags = useTags(); const tags = useTags()
const mcVersions = computed(() => const mcVersions = computed(() =>
tags.value.gameVersions tags.value.gameVersions
.filter((x) => .filter((x) =>
showSnapshots.value showSnapshots.value
? x.version_type === "snapshot" || x.version_type === "release" ? x.version_type === 'snapshot' || x.version_type === 'release'
: x.version_type === "release", : x.version_type === 'release',
) )
.map((x) => x.version), .map((x) => x.version),
); )
const isDangerous = computed(() => hardReset.value); const isDangerous = computed(() => hardReset.value)
const canInstall = computed(() => { const canInstall = computed(() => {
const conds = const conds =
!selectedMCVersion.value || !selectedMCVersion.value ||
isLoading.value || isLoading.value ||
loadingServerCheck.value || loadingServerCheck.value ||
serverCheckError.value.trim().length > 0; serverCheckError.value.trim().length > 0
if (selectedLoader.value.toLowerCase() === "vanilla") { if (selectedLoader.value.toLowerCase() === 'vanilla') {
return conds; return conds
} }
return conds || !selectedLoaderVersion.value; return conds || !selectedLoaderVersion.value
}); })
const handleReinstall = async () => { const handleReinstall = async () => {
if (hardReset.value && !isSecondPhase.value) { if (hardReset.value && !isSecondPhase.value) {
isSecondPhase.value = true; isSecondPhase.value = true
return; return
} }
isLoading.value = true; isLoading.value = true
try { try {
await props.server.general?.reinstall( await props.server.general?.reinstall(
true, true,
selectedLoader.value, selectedLoader.value,
selectedMCVersion.value, selectedMCVersion.value,
selectedLoader.value === "Vanilla" ? "" : selectedLoaderVersion.value, selectedLoader.value === 'Vanilla' ? '' : selectedLoaderVersion.value,
props.initialSetup ? true : hardReset.value, props.initialSetup ? true : hardReset.value,
); )
emit("reinstall", { emit('reinstall', {
loader: selectedLoader.value, loader: selectedLoader.value,
lVersion: selectedLoaderVersion.value, lVersion: selectedLoaderVersion.value,
mVersion: selectedMCVersion.value, mVersion: selectedMCVersion.value,
}); })
hide(); hide()
} catch (error) { } catch (error) {
if (error instanceof ModrinthServersFetchError && (error as any)?.statusCode === 429) { if (error instanceof ModrinthServersFetchError && (error as any)?.statusCode === 429) {
addNotification({ addNotification({
group: "server", group: 'server',
title: "Cannot reinstall server", title: 'Cannot reinstall server',
text: "You are being rate limited. Please try again later.", text: 'You are being rate limited. Please try again later.',
type: "error", type: 'error',
}); })
} else { } else {
addNotification({ addNotification({
group: "server", group: 'server',
title: "Reinstall Failed", title: 'Reinstall Failed',
text: "An unexpected error occurred while reinstalling. Please try again later.", text: 'An unexpected error occurred while reinstalling. Please try again later.',
type: "error", type: 'error',
}); })
} }
} finally { } finally {
isLoading.value = false; isLoading.value = false
}
} }
};
const onShow = () => { const onShow = () => {
selectedMCVersion.value = props.server.general?.mc_version || ""; selectedMCVersion.value = props.server.general?.mc_version || ''
if (isSnapshotSelected.value) { if (isSnapshotSelected.value) {
showSnapshots.value = true; showSnapshots.value = true
}
} }
};
const onHide = () => { const onHide = () => {
hardReset.value = false; hardReset.value = false
isSecondPhase.value = false; isSecondPhase.value = false
serverCheckError.value = ""; serverCheckError.value = ''
loadingServerCheck.value = false; loadingServerCheck.value = false
isLoading.value = false; isLoading.value = false
selectedMCVersion.value = ""; selectedMCVersion.value = ''
serverCheckError.value = ""; serverCheckError.value = ''
paperVersions.value = {}; paperVersions.value = {}
purpurVersions.value = {}; purpurVersions.value = {}
}; }
const show = (loader: Loaders) => { const show = (loader: Loaders) => {
if (selectedLoader.value !== loader) { if (selectedLoader.value !== loader) {
selectedLoaderVersion.value = ""; selectedLoaderVersion.value = ''
} }
selectedLoader.value = loader; selectedLoader.value = loader
selectedMCVersion.value = props.server.general?.mc_version || ""; selectedMCVersion.value = props.server.general?.mc_version || ''
versionSelectModal.value?.show(); versionSelectModal.value?.show()
}; }
const hide = () => versionSelectModal.value?.hide(); const hide = () => versionSelectModal.value?.hide()
defineExpose({ show, hide }); defineExpose({ show, hide })
</script> </script>
<style scoped> <style scoped>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,9 +24,9 @@
</template> </template>
<script setup> <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({ const props = defineProps({
thread: { thread: {
@ -50,36 +50,36 @@ const props = defineProps({
type: Array, type: Array,
required: false, required: false,
default() { default() {
return []; return []
}, },
}, },
auth: { auth: {
type: Object, type: Object,
required: true, required: true,
}, },
}); })
const app = useNuxtApp(); const app = useNuxtApp()
const members = computed(() => { const members = computed(() => {
const members = {}; const members = {}
for (const member of props.thread.members) { for (const member of props.thread.members) {
members[member.id] = member; members[member.id] = member
} }
members[props.auth.user.id] = props.auth.user; members[props.auth.user.id] = props.auth.user
return members; return members
}); })
const displayMessages = computed(() => { const displayMessages = computed(() => {
const sortedMessages = props.thread.messages const sortedMessages = props.thread.messages
.slice() .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) { if (props.messages.length > 0) {
return sortedMessages.filter((msg) => props.messages.includes(msg.id)); return sortedMessages.filter((msg) => props.messages.includes(msg.id))
} else { } else {
return sortedMessages.length > 0 ? [sortedMessages[sortedMessages.length - 1]] : []; return sortedMessages.length > 0 ? [sortedMessages[sortedMessages.length - 1]] : []
} }
}); })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

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

View File

@ -1,576 +1,576 @@
export const scopeMessages = defineMessages({ export const scopeMessages = defineMessages({
userReadEmailLabel: { userReadEmailLabel: {
id: "scopes.userReadEmail.label", id: 'scopes.userReadEmail.label',
defaultMessage: "Read user email", defaultMessage: 'Read user email',
}, },
userReadEmailDescription: { userReadEmailDescription: {
id: "scopes.userReadEmail.description", id: 'scopes.userReadEmail.description',
defaultMessage: "Read your email", defaultMessage: 'Read your email',
}, },
userReadLabel: { userReadLabel: {
id: "scopes.userRead.label", id: 'scopes.userRead.label',
defaultMessage: "Read user data", defaultMessage: 'Read user data',
}, },
userReadDescription: { userReadDescription: {
id: "scopes.userRead.description", id: 'scopes.userRead.description',
defaultMessage: "Access your public profile information", defaultMessage: 'Access your public profile information',
}, },
userWriteLabel: { userWriteLabel: {
id: "scopes.userWrite.label", id: 'scopes.userWrite.label',
defaultMessage: "Write user data", defaultMessage: 'Write user data',
}, },
userWriteDescription: { userWriteDescription: {
id: "scopes.userWrite.description", id: 'scopes.userWrite.description',
defaultMessage: "Write to your profile", defaultMessage: 'Write to your profile',
}, },
userDeleteLabel: { userDeleteLabel: {
id: "scopes.userDelete.label", id: 'scopes.userDelete.label',
defaultMessage: "Delete your account", defaultMessage: 'Delete your account',
}, },
userDeleteDescription: { userDeleteDescription: {
id: "scopes.userDelete.description", id: 'scopes.userDelete.description',
defaultMessage: "Delete your account", defaultMessage: 'Delete your account',
}, },
userAuthWriteLabel: { userAuthWriteLabel: {
id: "scopes.userAuthWrite.label", id: 'scopes.userAuthWrite.label',
defaultMessage: "Write auth data", defaultMessage: 'Write auth data',
}, },
userAuthWriteDescription: { userAuthWriteDescription: {
id: "scopes.userAuthWrite.description", id: 'scopes.userAuthWrite.description',
defaultMessage: "Modify your authentication data", defaultMessage: 'Modify your authentication data',
}, },
notificationReadLabel: { notificationReadLabel: {
id: "scopes.notificationRead.label", id: 'scopes.notificationRead.label',
defaultMessage: "Read notifications", defaultMessage: 'Read notifications',
}, },
notificationReadDescription: { notificationReadDescription: {
id: "scopes.notificationRead.description", id: 'scopes.notificationRead.description',
defaultMessage: "Read your notifications", defaultMessage: 'Read your notifications',
}, },
notificationWriteLabel: { notificationWriteLabel: {
id: "scopes.notificationWrite.label", id: 'scopes.notificationWrite.label',
defaultMessage: "Write notifications", defaultMessage: 'Write notifications',
}, },
notificationWriteDescription: { notificationWriteDescription: {
id: "scopes.notificationWrite.description", id: 'scopes.notificationWrite.description',
defaultMessage: "Delete/View your notifications", defaultMessage: 'Delete/View your notifications',
}, },
payoutsReadLabel: { payoutsReadLabel: {
id: "scopes.payoutsRead.label", id: 'scopes.payoutsRead.label',
defaultMessage: "Read payouts", defaultMessage: 'Read payouts',
}, },
payoutsReadDescription: { payoutsReadDescription: {
id: "scopes.payoutsRead.description", id: 'scopes.payoutsRead.description',
defaultMessage: "Read your payouts data", defaultMessage: 'Read your payouts data',
}, },
payoutsWriteLabel: { payoutsWriteLabel: {
id: "scopes.payoutsWrite.label", id: 'scopes.payoutsWrite.label',
defaultMessage: "Write payouts", defaultMessage: 'Write payouts',
}, },
payoutsWriteDescription: { payoutsWriteDescription: {
id: "scopes.payoutsWrite.description", id: 'scopes.payoutsWrite.description',
defaultMessage: "Withdraw money", defaultMessage: 'Withdraw money',
}, },
analyticsLabel: { analyticsLabel: {
id: "scopes.analytics.label", id: 'scopes.analytics.label',
defaultMessage: "Read analytics", defaultMessage: 'Read analytics',
}, },
analyticsDescription: { analyticsDescription: {
id: "scopes.analytics.description", id: 'scopes.analytics.description',
defaultMessage: "Access your analytics data", defaultMessage: 'Access your analytics data',
}, },
projectCreateLabel: { projectCreateLabel: {
id: "scopes.projectCreate.label", id: 'scopes.projectCreate.label',
defaultMessage: "Create projects", defaultMessage: 'Create projects',
}, },
projectCreateDescription: { projectCreateDescription: {
id: "scopes.projectCreate.description", id: 'scopes.projectCreate.description',
defaultMessage: "Create new projects", defaultMessage: 'Create new projects',
}, },
projectReadLabel: { projectReadLabel: {
id: "scopes.projectRead.label", id: 'scopes.projectRead.label',
defaultMessage: "Read projects", defaultMessage: 'Read projects',
}, },
projectReadDescription: { projectReadDescription: {
id: "scopes.projectRead.description", id: 'scopes.projectRead.description',
defaultMessage: "Read all your projects", defaultMessage: 'Read all your projects',
}, },
projectWriteLabel: { projectWriteLabel: {
id: "scopes.projectWrite.label", id: 'scopes.projectWrite.label',
defaultMessage: "Write projects", defaultMessage: 'Write projects',
}, },
projectWriteDescription: { projectWriteDescription: {
id: "scopes.projectWrite.description", id: 'scopes.projectWrite.description',
defaultMessage: "Write to project data", defaultMessage: 'Write to project data',
}, },
projectDeleteLabel: { projectDeleteLabel: {
id: "scopes.projectDelete.label", id: 'scopes.projectDelete.label',
defaultMessage: "Delete projects", defaultMessage: 'Delete projects',
}, },
projectDeleteDescription: { projectDeleteDescription: {
id: "scopes.projectDelete.description", id: 'scopes.projectDelete.description',
defaultMessage: "Delete your projects", defaultMessage: 'Delete your projects',
}, },
versionCreateLabel: { versionCreateLabel: {
id: "scopes.versionCreate.label", id: 'scopes.versionCreate.label',
defaultMessage: "Create versions", defaultMessage: 'Create versions',
}, },
versionCreateDescription: { versionCreateDescription: {
id: "scopes.versionCreate.description", id: 'scopes.versionCreate.description',
defaultMessage: "Create new versions", defaultMessage: 'Create new versions',
}, },
versionReadLabel: { versionReadLabel: {
id: "scopes.versionRead.label", id: 'scopes.versionRead.label',
defaultMessage: "Read versions", defaultMessage: 'Read versions',
}, },
versionReadDescription: { versionReadDescription: {
id: "scopes.versionRead.description", id: 'scopes.versionRead.description',
defaultMessage: "Read all versions", defaultMessage: 'Read all versions',
}, },
versionWriteLabel: { versionWriteLabel: {
id: "scopes.versionWrite.label", id: 'scopes.versionWrite.label',
defaultMessage: "Write versions", defaultMessage: 'Write versions',
}, },
versionWriteDescription: { versionWriteDescription: {
id: "scopes.versionWrite.description", id: 'scopes.versionWrite.description',
defaultMessage: "Write to version data", defaultMessage: 'Write to version data',
}, },
versionDeleteLabel: { versionDeleteLabel: {
id: "scopes.versionDelete.label", id: 'scopes.versionDelete.label',
defaultMessage: "Delete versions", defaultMessage: 'Delete versions',
}, },
versionDeleteDescription: { versionDeleteDescription: {
id: "scopes.versionDelete.description", id: 'scopes.versionDelete.description',
defaultMessage: "Delete a version", defaultMessage: 'Delete a version',
}, },
reportCreateLabel: { reportCreateLabel: {
id: "scopes.reportCreate.label", id: 'scopes.reportCreate.label',
defaultMessage: "Create reports", defaultMessage: 'Create reports',
}, },
reportCreateDescription: { reportCreateDescription: {
id: "scopes.reportCreate.description", id: 'scopes.reportCreate.description',
defaultMessage: "Create reports", defaultMessage: 'Create reports',
}, },
reportReadLabel: { reportReadLabel: {
id: "scopes.reportRead.label", id: 'scopes.reportRead.label',
defaultMessage: "Read reports", defaultMessage: 'Read reports',
}, },
reportReadDescription: { reportReadDescription: {
id: "scopes.reportRead.description", id: 'scopes.reportRead.description',
defaultMessage: "Read reports", defaultMessage: 'Read reports',
}, },
reportWriteLabel: { reportWriteLabel: {
id: "scopes.reportWrite.label", id: 'scopes.reportWrite.label',
defaultMessage: "Write reports", defaultMessage: 'Write reports',
}, },
reportWriteDescription: { reportWriteDescription: {
id: "scopes.reportWrite.description", id: 'scopes.reportWrite.description',
defaultMessage: "Edit reports", defaultMessage: 'Edit reports',
}, },
reportDeleteLabel: { reportDeleteLabel: {
id: "scopes.reportDelete.label", id: 'scopes.reportDelete.label',
defaultMessage: "Delete reports", defaultMessage: 'Delete reports',
}, },
reportDeleteDescription: { reportDeleteDescription: {
id: "scopes.reportDelete.description", id: 'scopes.reportDelete.description',
defaultMessage: "Delete reports", defaultMessage: 'Delete reports',
}, },
threadReadLabel: { threadReadLabel: {
id: "scopes.threadRead.label", id: 'scopes.threadRead.label',
defaultMessage: "Read threads", defaultMessage: 'Read threads',
}, },
threadReadDescription: { threadReadDescription: {
id: "scopes.threadRead.description", id: 'scopes.threadRead.description',
defaultMessage: "Read threads", defaultMessage: 'Read threads',
}, },
threadWriteLabel: { threadWriteLabel: {
id: "scopes.threadWrite.label", id: 'scopes.threadWrite.label',
defaultMessage: "Write threads", defaultMessage: 'Write threads',
}, },
threadWriteDescription: { threadWriteDescription: {
id: "scopes.threadWrite.description", id: 'scopes.threadWrite.description',
defaultMessage: "Write to threads", defaultMessage: 'Write to threads',
}, },
patCreateLabel: { patCreateLabel: {
id: "scopes.patCreate.label", id: 'scopes.patCreate.label',
defaultMessage: "Create PATs", defaultMessage: 'Create PATs',
}, },
patCreateDescription: { patCreateDescription: {
id: "scopes.patCreate.description", id: 'scopes.patCreate.description',
defaultMessage: "Create personal API tokens", defaultMessage: 'Create personal API tokens',
}, },
patReadLabel: { patReadLabel: {
id: "scopes.patRead.label", id: 'scopes.patRead.label',
defaultMessage: "Read PATs", defaultMessage: 'Read PATs',
}, },
patReadDescription: { patReadDescription: {
id: "scopes.patRead.description", id: 'scopes.patRead.description',
defaultMessage: "View created API tokens", defaultMessage: 'View created API tokens',
}, },
patWriteLabel: { patWriteLabel: {
id: "scopes.patWrite.label", id: 'scopes.patWrite.label',
defaultMessage: "Write PATs", defaultMessage: 'Write PATs',
}, },
patWriteDescription: { patWriteDescription: {
id: "scopes.patWrite.description", id: 'scopes.patWrite.description',
defaultMessage: "Edit personal API tokens", defaultMessage: 'Edit personal API tokens',
}, },
patDeleteLabel: { patDeleteLabel: {
id: "scopes.patDelete.label", id: 'scopes.patDelete.label',
defaultMessage: "Delete PATs", defaultMessage: 'Delete PATs',
}, },
patDeleteDescription: { patDeleteDescription: {
id: "scopes.patDelete.description", id: 'scopes.patDelete.description',
defaultMessage: "Delete your personal API tokens", defaultMessage: 'Delete your personal API tokens',
}, },
sessionReadLabel: { sessionReadLabel: {
id: "scopes.sessionRead.label", id: 'scopes.sessionRead.label',
defaultMessage: "Read sessions", defaultMessage: 'Read sessions',
}, },
sessionReadDescription: { sessionReadDescription: {
id: "scopes.sessionRead.description", id: 'scopes.sessionRead.description',
defaultMessage: "Read active sessions", defaultMessage: 'Read active sessions',
}, },
sessionDeleteLabel: { sessionDeleteLabel: {
id: "scopes.sessionDelete.label", id: 'scopes.sessionDelete.label',
defaultMessage: "Delete sessions", defaultMessage: 'Delete sessions',
}, },
sessionDeleteDescription: { sessionDeleteDescription: {
id: "scopes.sessionDelete.description", id: 'scopes.sessionDelete.description',
defaultMessage: "Delete sessions", defaultMessage: 'Delete sessions',
}, },
performAnalyticsLabel: { performAnalyticsLabel: {
id: "scopes.performAnalytics.label", id: 'scopes.performAnalytics.label',
defaultMessage: "Perform analytics", defaultMessage: 'Perform analytics',
}, },
performAnalyticsDescription: { performAnalyticsDescription: {
id: "scopes.performAnalytics.description", id: 'scopes.performAnalytics.description',
defaultMessage: "Perform analytics actions", defaultMessage: 'Perform analytics actions',
}, },
collectionCreateLabel: { collectionCreateLabel: {
id: "scopes.collectionCreate.label", id: 'scopes.collectionCreate.label',
defaultMessage: "Create collections", defaultMessage: 'Create collections',
}, },
collectionCreateDescription: { collectionCreateDescription: {
id: "scopes.collectionCreate.description", id: 'scopes.collectionCreate.description',
defaultMessage: "Create collections", defaultMessage: 'Create collections',
}, },
collectionReadLabel: { collectionReadLabel: {
id: "scopes.collectionRead.label", id: 'scopes.collectionRead.label',
defaultMessage: "Read collections", defaultMessage: 'Read collections',
}, },
collectionReadDescription: { collectionReadDescription: {
id: "scopes.collectionRead.description", id: 'scopes.collectionRead.description',
defaultMessage: "Read collections", defaultMessage: 'Read collections',
}, },
collectionWriteLabel: { collectionWriteLabel: {
id: "scopes.collectionWrite.label", id: 'scopes.collectionWrite.label',
defaultMessage: "Write collections", defaultMessage: 'Write collections',
}, },
collectionWriteDescription: { collectionWriteDescription: {
id: "scopes.collectionWrite.description", id: 'scopes.collectionWrite.description',
defaultMessage: "Write to collections", defaultMessage: 'Write to collections',
}, },
collectionDeleteLabel: { collectionDeleteLabel: {
id: "scopes.collectionDelete.label", id: 'scopes.collectionDelete.label',
defaultMessage: "Delete collections", defaultMessage: 'Delete collections',
}, },
collectionDeleteDescription: { collectionDeleteDescription: {
id: "scopes.collectionDelete.description", id: 'scopes.collectionDelete.description',
defaultMessage: "Delete collections", defaultMessage: 'Delete collections',
}, },
organizationCreateLabel: { organizationCreateLabel: {
id: "scopes.organizationCreate.label", id: 'scopes.organizationCreate.label',
defaultMessage: "Create organizations", defaultMessage: 'Create organizations',
}, },
organizationCreateDescription: { organizationCreateDescription: {
id: "scopes.organizationCreate.description", id: 'scopes.organizationCreate.description',
defaultMessage: "Create organizations", defaultMessage: 'Create organizations',
}, },
organizationReadLabel: { organizationReadLabel: {
id: "scopes.organizationRead.label", id: 'scopes.organizationRead.label',
defaultMessage: "Read organizations", defaultMessage: 'Read organizations',
}, },
organizationReadDescription: { organizationReadDescription: {
id: "scopes.organizationRead.description", id: 'scopes.organizationRead.description',
defaultMessage: "Read organizations", defaultMessage: 'Read organizations',
}, },
organizationWriteLabel: { organizationWriteLabel: {
id: "scopes.organizationWrite.label", id: 'scopes.organizationWrite.label',
defaultMessage: "Write organizations", defaultMessage: 'Write organizations',
}, },
organizationWriteDescription: { organizationWriteDescription: {
id: "scopes.organizationWrite.description", id: 'scopes.organizationWrite.description',
defaultMessage: "Write to organizations", defaultMessage: 'Write to organizations',
}, },
organizationDeleteLabel: { organizationDeleteLabel: {
id: "scopes.organizationDelete.label", id: 'scopes.organizationDelete.label',
defaultMessage: "Delete organizations", defaultMessage: 'Delete organizations',
}, },
organizationDeleteDescription: { organizationDeleteDescription: {
id: "scopes.organizationDelete.description", id: 'scopes.organizationDelete.description',
defaultMessage: "Delete organizations", defaultMessage: 'Delete organizations',
}, },
sessionAccessLabel: { sessionAccessLabel: {
id: "scopes.sessionAccess.label", id: 'scopes.sessionAccess.label',
defaultMessage: "Access sessions", defaultMessage: 'Access sessions',
}, },
sessionAccessDescription: { sessionAccessDescription: {
id: "scopes.sessionAccess.description", id: 'scopes.sessionAccess.description',
defaultMessage: "Access modrinth-issued sessions", defaultMessage: 'Access modrinth-issued sessions',
}, },
}); })
const scopeDefinitions = [ const scopeDefinitions = [
{ {
id: "USER_READ_EMAIL", id: 'USER_READ_EMAIL',
value: BigInt(1) << BigInt(0), value: BigInt(1) << BigInt(0),
label: scopeMessages.userReadEmailLabel, label: scopeMessages.userReadEmailLabel,
desc: scopeMessages.userReadEmailDescription, desc: scopeMessages.userReadEmailDescription,
}, },
{ {
id: "USER_READ", id: 'USER_READ',
value: BigInt(1) << BigInt(1), value: BigInt(1) << BigInt(1),
label: scopeMessages.userReadLabel, label: scopeMessages.userReadLabel,
desc: scopeMessages.userReadDescription, desc: scopeMessages.userReadDescription,
}, },
{ {
id: "USER_WRITE", id: 'USER_WRITE',
value: BigInt(1) << BigInt(2), value: BigInt(1) << BigInt(2),
label: scopeMessages.userWriteLabel, label: scopeMessages.userWriteLabel,
desc: scopeMessages.userWriteDescription, desc: scopeMessages.userWriteDescription,
}, },
{ {
id: "USER_DELETE", id: 'USER_DELETE',
value: BigInt(1) << BigInt(3), value: BigInt(1) << BigInt(3),
label: scopeMessages.userDeleteLabel, label: scopeMessages.userDeleteLabel,
desc: scopeMessages.userDeleteDescription, desc: scopeMessages.userDeleteDescription,
}, },
{ {
id: "USER_AUTH_WRITE", id: 'USER_AUTH_WRITE',
value: BigInt(1) << BigInt(4), value: BigInt(1) << BigInt(4),
label: scopeMessages.userAuthWriteLabel, label: scopeMessages.userAuthWriteLabel,
desc: scopeMessages.userAuthWriteDescription, desc: scopeMessages.userAuthWriteDescription,
}, },
{ {
id: "NOTIFICATION_READ", id: 'NOTIFICATION_READ',
value: BigInt(1) << BigInt(5), value: BigInt(1) << BigInt(5),
label: scopeMessages.notificationReadLabel, label: scopeMessages.notificationReadLabel,
desc: scopeMessages.notificationReadDescription, desc: scopeMessages.notificationReadDescription,
}, },
{ {
id: "NOTIFICATION_WRITE", id: 'NOTIFICATION_WRITE',
value: BigInt(1) << BigInt(6), value: BigInt(1) << BigInt(6),
label: scopeMessages.notificationWriteLabel, label: scopeMessages.notificationWriteLabel,
desc: scopeMessages.notificationWriteDescription, desc: scopeMessages.notificationWriteDescription,
}, },
{ {
id: "PAYOUTS_READ", id: 'PAYOUTS_READ',
value: BigInt(1) << BigInt(7), value: BigInt(1) << BigInt(7),
label: scopeMessages.payoutsReadLabel, label: scopeMessages.payoutsReadLabel,
desc: scopeMessages.payoutsReadDescription, desc: scopeMessages.payoutsReadDescription,
}, },
{ {
id: "PAYOUTS_WRITE", id: 'PAYOUTS_WRITE',
value: BigInt(1) << BigInt(8), value: BigInt(1) << BigInt(8),
label: scopeMessages.payoutsWriteLabel, label: scopeMessages.payoutsWriteLabel,
desc: scopeMessages.payoutsWriteDescription, desc: scopeMessages.payoutsWriteDescription,
}, },
{ {
id: "ANALYTICS", id: 'ANALYTICS',
value: BigInt(1) << BigInt(9), value: BigInt(1) << BigInt(9),
label: scopeMessages.analyticsLabel, label: scopeMessages.analyticsLabel,
desc: scopeMessages.analyticsDescription, desc: scopeMessages.analyticsDescription,
}, },
{ {
id: "PROJECT_CREATE", id: 'PROJECT_CREATE',
value: BigInt(1) << BigInt(10), value: BigInt(1) << BigInt(10),
label: scopeMessages.projectCreateLabel, label: scopeMessages.projectCreateLabel,
desc: scopeMessages.projectCreateDescription, desc: scopeMessages.projectCreateDescription,
}, },
{ {
id: "PROJECT_READ", id: 'PROJECT_READ',
value: BigInt(1) << BigInt(11), value: BigInt(1) << BigInt(11),
label: scopeMessages.projectReadLabel, label: scopeMessages.projectReadLabel,
desc: scopeMessages.projectReadDescription, desc: scopeMessages.projectReadDescription,
}, },
{ {
id: "PROJECT_WRITE", id: 'PROJECT_WRITE',
value: BigInt(1) << BigInt(12), value: BigInt(1) << BigInt(12),
label: scopeMessages.projectWriteLabel, label: scopeMessages.projectWriteLabel,
desc: scopeMessages.projectWriteDescription, desc: scopeMessages.projectWriteDescription,
}, },
{ {
id: "PROJECT_DELETE", id: 'PROJECT_DELETE',
value: BigInt(1) << BigInt(13), value: BigInt(1) << BigInt(13),
label: scopeMessages.projectDeleteLabel, label: scopeMessages.projectDeleteLabel,
desc: scopeMessages.projectDeleteDescription, desc: scopeMessages.projectDeleteDescription,
}, },
{ {
id: "VERSION_CREATE", id: 'VERSION_CREATE',
value: BigInt(1) << BigInt(14), value: BigInt(1) << BigInt(14),
label: scopeMessages.versionCreateLabel, label: scopeMessages.versionCreateLabel,
desc: scopeMessages.versionCreateDescription, desc: scopeMessages.versionCreateDescription,
}, },
{ {
id: "VERSION_READ", id: 'VERSION_READ',
value: BigInt(1) << BigInt(15), value: BigInt(1) << BigInt(15),
label: scopeMessages.versionReadLabel, label: scopeMessages.versionReadLabel,
desc: scopeMessages.versionReadDescription, desc: scopeMessages.versionReadDescription,
}, },
{ {
id: "VERSION_WRITE", id: 'VERSION_WRITE',
value: BigInt(1) << BigInt(16), value: BigInt(1) << BigInt(16),
label: scopeMessages.versionWriteLabel, label: scopeMessages.versionWriteLabel,
desc: scopeMessages.versionWriteDescription, desc: scopeMessages.versionWriteDescription,
}, },
{ {
id: "VERSION_DELETE", id: 'VERSION_DELETE',
value: BigInt(1) << BigInt(17), value: BigInt(1) << BigInt(17),
label: scopeMessages.versionDeleteLabel, label: scopeMessages.versionDeleteLabel,
desc: scopeMessages.versionDeleteDescription, desc: scopeMessages.versionDeleteDescription,
}, },
{ {
id: "REPORT_CREATE", id: 'REPORT_CREATE',
value: BigInt(1) << BigInt(18), value: BigInt(1) << BigInt(18),
label: scopeMessages.reportCreateLabel, label: scopeMessages.reportCreateLabel,
desc: scopeMessages.reportCreateDescription, desc: scopeMessages.reportCreateDescription,
}, },
{ {
id: "REPORT_READ", id: 'REPORT_READ',
value: BigInt(1) << BigInt(19), value: BigInt(1) << BigInt(19),
label: scopeMessages.reportReadLabel, label: scopeMessages.reportReadLabel,
desc: scopeMessages.reportReadDescription, desc: scopeMessages.reportReadDescription,
}, },
{ {
id: "REPORT_WRITE", id: 'REPORT_WRITE',
value: BigInt(1) << BigInt(20), value: BigInt(1) << BigInt(20),
label: scopeMessages.reportWriteLabel, label: scopeMessages.reportWriteLabel,
desc: scopeMessages.reportWriteDescription, desc: scopeMessages.reportWriteDescription,
}, },
{ {
id: "REPORT_DELETE", id: 'REPORT_DELETE',
value: BigInt(1) << BigInt(21), value: BigInt(1) << BigInt(21),
label: scopeMessages.reportDeleteLabel, label: scopeMessages.reportDeleteLabel,
desc: scopeMessages.reportDeleteDescription, desc: scopeMessages.reportDeleteDescription,
}, },
{ {
id: "THREAD_READ", id: 'THREAD_READ',
value: BigInt(1) << BigInt(22), value: BigInt(1) << BigInt(22),
label: scopeMessages.threadReadLabel, label: scopeMessages.threadReadLabel,
desc: scopeMessages.threadReadDescription, desc: scopeMessages.threadReadDescription,
}, },
{ {
id: "THREAD_WRITE", id: 'THREAD_WRITE',
value: BigInt(1) << BigInt(23), value: BigInt(1) << BigInt(23),
label: scopeMessages.threadWriteLabel, label: scopeMessages.threadWriteLabel,
desc: scopeMessages.threadWriteDescription, desc: scopeMessages.threadWriteDescription,
}, },
{ {
id: "PAT_CREATE", id: 'PAT_CREATE',
value: BigInt(1) << BigInt(24), value: BigInt(1) << BigInt(24),
label: scopeMessages.patCreateLabel, label: scopeMessages.patCreateLabel,
desc: scopeMessages.patCreateDescription, desc: scopeMessages.patCreateDescription,
}, },
{ {
id: "PAT_READ", id: 'PAT_READ',
value: BigInt(1) << BigInt(25), value: BigInt(1) << BigInt(25),
label: scopeMessages.patReadLabel, label: scopeMessages.patReadLabel,
desc: scopeMessages.patReadDescription, desc: scopeMessages.patReadDescription,
}, },
{ {
id: "PAT_WRITE", id: 'PAT_WRITE',
value: BigInt(1) << BigInt(26), value: BigInt(1) << BigInt(26),
label: scopeMessages.patWriteLabel, label: scopeMessages.patWriteLabel,
desc: scopeMessages.patWriteDescription, desc: scopeMessages.patWriteDescription,
}, },
{ {
id: "PAT_DELETE", id: 'PAT_DELETE',
value: BigInt(1) << BigInt(27), value: BigInt(1) << BigInt(27),
label: scopeMessages.patDeleteLabel, label: scopeMessages.patDeleteLabel,
desc: scopeMessages.patDeleteDescription, desc: scopeMessages.patDeleteDescription,
}, },
{ {
id: "SESSION_READ", id: 'SESSION_READ',
value: BigInt(1) << BigInt(28), value: BigInt(1) << BigInt(28),
label: scopeMessages.sessionReadLabel, label: scopeMessages.sessionReadLabel,
desc: scopeMessages.sessionReadDescription, desc: scopeMessages.sessionReadDescription,
}, },
{ {
id: "SESSION_DELETE", id: 'SESSION_DELETE',
value: BigInt(1) << BigInt(29), value: BigInt(1) << BigInt(29),
label: scopeMessages.sessionDeleteLabel, label: scopeMessages.sessionDeleteLabel,
desc: scopeMessages.sessionDeleteDescription, desc: scopeMessages.sessionDeleteDescription,
}, },
{ {
id: "PERFORM_ANALYTICS", id: 'PERFORM_ANALYTICS',
value: BigInt(1) << BigInt(30), value: BigInt(1) << BigInt(30),
label: scopeMessages.performAnalyticsLabel, label: scopeMessages.performAnalyticsLabel,
desc: scopeMessages.performAnalyticsDescription, desc: scopeMessages.performAnalyticsDescription,
}, },
{ {
id: "COLLECTION_CREATE", id: 'COLLECTION_CREATE',
value: BigInt(1) << BigInt(31), value: BigInt(1) << BigInt(31),
label: scopeMessages.collectionCreateLabel, label: scopeMessages.collectionCreateLabel,
desc: scopeMessages.collectionCreateDescription, desc: scopeMessages.collectionCreateDescription,
}, },
{ {
id: "COLLECTION_READ", id: 'COLLECTION_READ',
value: BigInt(1) << BigInt(32), value: BigInt(1) << BigInt(32),
label: scopeMessages.collectionReadLabel, label: scopeMessages.collectionReadLabel,
desc: scopeMessages.collectionReadDescription, desc: scopeMessages.collectionReadDescription,
}, },
{ {
id: "COLLECTION_WRITE", id: 'COLLECTION_WRITE',
value: BigInt(1) << BigInt(33), value: BigInt(1) << BigInt(33),
label: scopeMessages.collectionWriteLabel, label: scopeMessages.collectionWriteLabel,
desc: scopeMessages.collectionWriteDescription, desc: scopeMessages.collectionWriteDescription,
}, },
{ {
id: "COLLECTION_DELETE", id: 'COLLECTION_DELETE',
value: BigInt(1) << BigInt(34), value: BigInt(1) << BigInt(34),
label: scopeMessages.collectionDeleteLabel, label: scopeMessages.collectionDeleteLabel,
desc: scopeMessages.collectionDeleteDescription, desc: scopeMessages.collectionDeleteDescription,
}, },
{ {
id: "ORGANIZATION_CREATE", id: 'ORGANIZATION_CREATE',
value: BigInt(1) << BigInt(35), value: BigInt(1) << BigInt(35),
label: scopeMessages.organizationCreateLabel, label: scopeMessages.organizationCreateLabel,
desc: scopeMessages.organizationCreateDescription, desc: scopeMessages.organizationCreateDescription,
}, },
{ {
id: "ORGANIZATION_READ", id: 'ORGANIZATION_READ',
value: BigInt(1) << BigInt(36), value: BigInt(1) << BigInt(36),
label: scopeMessages.organizationReadLabel, label: scopeMessages.organizationReadLabel,
desc: scopeMessages.organizationReadDescription, desc: scopeMessages.organizationReadDescription,
}, },
{ {
id: "ORGANIZATION_WRITE", id: 'ORGANIZATION_WRITE',
value: BigInt(1) << BigInt(37), value: BigInt(1) << BigInt(37),
label: scopeMessages.organizationWriteLabel, label: scopeMessages.organizationWriteLabel,
desc: scopeMessages.organizationWriteDescription, desc: scopeMessages.organizationWriteDescription,
}, },
{ {
id: "ORGANIZATION_DELETE", id: 'ORGANIZATION_DELETE',
value: BigInt(1) << BigInt(38), value: BigInt(1) << BigInt(38),
label: scopeMessages.organizationDeleteLabel, label: scopeMessages.organizationDeleteLabel,
desc: scopeMessages.organizationDeleteDescription, desc: scopeMessages.organizationDeleteDescription,
}, },
{ {
id: "SESSION_ACCESS", id: 'SESSION_ACCESS',
value: BigInt(1) << BigInt(39), value: BigInt(1) << BigInt(39),
label: scopeMessages.sessionAccessLabel, label: scopeMessages.sessionAccessLabel,
desc: scopeMessages.sessionAccessDescription, desc: scopeMessages.sessionAccessDescription,
}, },
]; ]
const Scopes = scopeDefinitions.reduce( const Scopes = scopeDefinitions.reduce(
(acc, scope) => { (acc, scope) => {
acc[scope.id] = scope.value; acc[scope.id] = scope.value
return acc; return acc
}, },
{} as Record<string, bigint>, {} as Record<string, bigint>,
); )
export const restrictedScopes = [ export const restrictedScopes = [
Scopes.PAT_READ, Scopes.PAT_READ,
@ -583,18 +583,18 @@ export const restrictedScopes = [
Scopes.USER_AUTH_WRITE, Scopes.USER_AUTH_WRITE,
Scopes.USER_DELETE, Scopes.USER_DELETE,
Scopes.PERFORM_ANALYTICS, Scopes.PERFORM_ANALYTICS,
]; ]
export const scopeList = Object.entries(Scopes) export const scopeList = Object.entries(Scopes)
.filter(([_, value]) => !restrictedScopes.includes(value)) .filter(([_, value]) => !restrictedScopes.includes(value))
.map(([key, _]) => key); .map(([key, _]) => key)
export const getScopeValue = (scope: string) => { export const getScopeValue = (scope: string) => {
return Scopes[scope]; return Scopes[scope]
}; }
export const encodeScopes = (scopes: string[]) => { export const encodeScopes = (scopes: string[]) => {
let scopeFlag = BigInt(0); let scopeFlag = BigInt(0)
// We iterate over the provided scopes // We iterate over the provided scopes
for (const scope of scopes) { for (const scope of scopes) {
@ -602,77 +602,77 @@ export const encodeScopes = (scopes: string[]) => {
for (const [scopeName, scopeFlagValue] of Object.entries(Scopes)) { 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 the scope name is the same as the provided scope, add the scope flag to the scopeFlag variable
if (scopeName === scope) { if (scopeName === scope) {
scopeFlag = scopeFlag | scopeFlagValue; scopeFlag = scopeFlag | scopeFlagValue
} }
} }
} }
return scopeFlag; return scopeFlag
}; }
export const decodeScopes = (scopes: bigint | number) => { export const decodeScopes = (scopes: bigint | number) => {
if (typeof scopes === "number") { if (typeof scopes === 'number') {
scopes = BigInt(scopes); scopes = BigInt(scopes)
} }
const authorizedScopes = []; const authorizedScopes = []
// We iterate over the entries of the Scopes object // We iterate over the entries of the Scopes object
for (const [scopeName, scopeFlag] of Object.entries(Scopes)) { 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 the scope flag is present in the provided number, add the scope name to the list
if ((scopes & scopeFlag) === scopeFlag) { if ((scopes & scopeFlag) === scopeFlag) {
authorizedScopes.push(scopeName); authorizedScopes.push(scopeName)
} }
} }
return authorizedScopes; return authorizedScopes
}; }
export const hasScope = (scopes: bigint, scope: string) => { export const hasScope = (scopes: bigint, scope: string) => {
const authorizedScopes = decodeScopes(scopes); const authorizedScopes = decodeScopes(scopes)
return authorizedScopes.includes(scope); return authorizedScopes.includes(scope)
}; }
export const toggleScope = (scopes: bigint, scope: string) => { export const toggleScope = (scopes: bigint, scope: string) => {
const authorizedScopes = decodeScopes(scopes); const authorizedScopes = decodeScopes(scopes)
if (authorizedScopes.includes(scope)) { if (authorizedScopes.includes(scope)) {
return encodeScopes(authorizedScopes.filter((authorizedScope) => authorizedScope !== scope)); return encodeScopes(authorizedScopes.filter((authorizedScope) => authorizedScope !== scope))
} else { } else {
return encodeScopes([...authorizedScopes, scope]); return encodeScopes([...authorizedScopes, scope])
}
} }
};
export const useScopes = () => { export const useScopes = () => {
const { formatMessage } = useVIntl(); const { formatMessage } = useVIntl()
const scopesToDefinitions = (scopes: bigint) => { const scopesToDefinitions = (scopes: bigint) => {
const authorizedScopes = decodeScopes(scopes); const authorizedScopes = decodeScopes(scopes)
return authorizedScopes.map((scope) => { return authorizedScopes.map((scope) => {
const scopeDefinition = scopeDefinitions.find( const scopeDefinition = scopeDefinitions.find(
(scopeDefinition) => scopeDefinition.id === scope, (scopeDefinition) => scopeDefinition.id === scope,
); )
if (!scopeDefinition) { 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 scopesToLabels = (scopes: bigint) => {
const authorizedScopes = decodeScopes(scopes); const authorizedScopes = decodeScopes(scopes)
return authorizedScopes.map((scope) => { return authorizedScopes.map((scope) => {
const scopeDefinition = scopeDefinitions.find( const scopeDefinition = scopeDefinitions.find(
(scopeDefinition) => scopeDefinition.id === scope, (scopeDefinition) => scopeDefinition.id === scope,
); )
if (!scopeDefinition) { 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 { return {
scopesToDefinitions, scopesToDefinitions,
scopesToLabels, 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) { 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) { if (!formatter) {
formatter = new Intl.NumberFormat(locale, { formatter = new Intl.NumberFormat(locale, {
notation: "compact", notation: 'compact',
maximumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits,
}); })
formatters.set(context, formatter); formatters.set(context, formatter)
} }
function format(value: number): string { function format(value: number): string {
let formattedValue = value; let formattedValue = value
if (truncate) { if (truncate) {
const scale = Math.pow(10, fractionDigits); const scale = Math.pow(10, fractionDigits)
formattedValue = Math.floor(value * scale) / scale; 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 = () => { export const useUserCountry = () => {
const country = useState<string>("userCountry", () => "US"); const country = useState<string>('userCountry', () => 'US')
const fromServer = useState<boolean>("userCountryFromServer", () => false); const fromServer = useState<boolean>('userCountryFromServer', () => false)
if (import.meta.server) { if (import.meta.server) {
const headers = useRequestHeaders(["cf-ipcountry", "accept-language"]); const headers = useRequestHeaders(['cf-ipcountry', 'accept-language'])
const cf = headers["cf-ipcountry"]; const cf = headers['cf-ipcountry']
if (cf) { if (cf) {
country.value = cf.toUpperCase(); country.value = cf.toUpperCase()
fromServer.value = true; fromServer.value = true
} else { } else {
const al = headers["accept-language"] || ""; const al = headers['accept-language'] || ''
const tag = al.split(",")[0]; const tag = al.split(',')[0]
const val = tag.split("-")[1]?.toLowerCase(); const val = tag.split('-')[1]?.toLowerCase()
if (val) { if (val) {
country.value = val; country.value = val
fromServer.value = true; fromServer.value = true
} }
} }
} }
if (import.meta.client) { if (import.meta.client) {
onMounted(() => { onMounted(() => {
if (fromServer.value) return; if (fromServer.value) return
// @ts-expect-error - ignore TS not knowing about navigator.userLanguage // @ts-expect-error - ignore TS not knowing about navigator.userLanguage
const lang = navigator.language || navigator.userLanguage || ""; const lang = navigator.language || navigator.userLanguage || ''
const region = lang.split("-")[1]; const region = lang.split('-')[1]
if (region) { 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) { function safeTagFor(locale: string) {
let safeTag = safeTags.get(locale); let safeTag = safeTags.get(locale)
if (safeTag == null) { if (safeTag == null) {
safeTag = new Intl.Locale(locale).baseName; safeTag = new Intl.Locale(locale).baseName
safeTags.set(locale, safeTag); safeTags.set(locale, safeTag)
} }
return safeTag; return safeTag
} }
type DisplayNamesWrapper = Intl.DisplayNames & { 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) { function getWrapperKey(locale: string, options: Intl.DisplayNamesOptions) {
return JSON.stringify({ ...options, locale }); return JSON.stringify({ ...options, locale })
} }
export function createDisplayNames( export function createDisplayNames(
locale: string, locale: string,
options: Intl.DisplayNamesOptions = { type: "language" }, options: Intl.DisplayNamesOptions = { type: 'language' },
) { ) {
const wrapperKey = getWrapperKey(locale, options); const wrapperKey = getWrapperKey(locale, options)
let wrapper = displayNamesDicts.get(wrapperKey); let wrapper = displayNamesDicts.get(wrapperKey)
if (wrapper == null) { if (wrapper == null) {
const dict = new Intl.DisplayNames(locale, options); const dict = new Intl.DisplayNames(locale, options)
const badTags: string[] = []; const badTags: string[] = []
wrapper = { wrapper = {
resolvedOptions() { resolvedOptions() {
return dict.resolvedOptions(); return dict.resolvedOptions()
}, },
of(tag: string) { of(tag: string) {
let attempt = 0; let attempt = 0
lookupLoop: do { lookupLoop: do {
let lookup: string; let lookup: string
switch (attempt) { switch (attempt) {
case 0: case 0:
lookup = tag; lookup = tag
break; break
case 1: case 1:
lookup = safeTagFor(tag); lookup = safeTagFor(tag)
break; break
default: default:
break lookupLoop
break lookupLoop;
} }
if (badTags.includes(lookup)) continue; if (badTags.includes(lookup)) continue
try { try {
return dict.of(lookup); return dict.of(lookup)
} catch { } catch {
console.warn( console.warn(
`Failed to get display name for ${lookup} using dictionary for ${ `Failed to get display name for ${lookup} using dictionary for ${
this.resolvedOptions().locale this.resolvedOptions().locale
}`, }`,
); )
badTags.push(lookup); badTags.push(lookup)
continue; continue
} }
} while (++attempt < 5); } while (++attempt < 5)
return undefined; return undefined
}, },
};
displayNamesDicts.set(wrapperKey, wrapper);
} }
return wrapper; displayNamesDicts.set(wrapperKey, wrapper)
}
return wrapper
} }
export function useDisplayNames( export function useDisplayNames(
@ -85,8 +83,8 @@ export function useDisplayNames(
| (() => Intl.DisplayNamesOptions | undefined) | (() => Intl.DisplayNamesOptions | undefined)
| Ref<Intl.DisplayNamesOptions | undefined>, | Ref<Intl.DisplayNamesOptions | undefined>,
) { ) {
const $locale = toRef(locale); const $locale = toRef(locale)
const $options = toRef(options); 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