Merge commit '90def724c28a2eacf173f4987e195dc14908f25d' into feature-clean

This commit is contained in:
2025-02-25 22:45:08 +03:00
83 changed files with 3417 additions and 1901 deletions

View File

@@ -19,10 +19,7 @@
</div>
<div class="flex flex-col gap-2">
<label for="additional-information" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Summary
<span class="text-brand-red">*</span>
</span>
<span class="text-lg font-semibold text-contrast"> Summary </span>
<span>A sentence or two that describes your collection.</span>
</label>
<div class="textarea-wrapper">
@@ -52,8 +49,8 @@
</NewModal>
</template>
<script setup>
import { XIcon, PlusIcon } from "@modrinth/assets";
import { NewModal, ButtonStyled } from "@modrinth/ui";
import { PlusIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
const router = useNativeRouter();
@@ -78,7 +75,7 @@ async function create() {
method: "POST",
body: {
name: name.value.trim(),
description: description.value.trim(),
description: description.value.trim() || undefined,
projects: props.projectIds,
},
apiVersion: 3,

View File

@@ -1,329 +1,366 @@
<template>
<div class="card moderation-checklist">
<h1>Moderation checklist</h1>
<div v-if="done">
<p>You are done moderating this project! There are {{ futureProjects.length }} left.</p>
<div
class="moderation-checklist flex w-[600px] max-w-full flex-col rounded-2xl border-[1px] border-solid border-orange bg-bg-raised p-4 transition-all delay-200 duration-200 ease-in-out"
:class="collapsed ? `sm:max-w-[300px]` : 'sm:max-w-[600px]'"
>
<div class="flex grow-0 items-center gap-2">
<h1 class="m-0 mr-auto flex items-center gap-2 text-2xl font-extrabold text-contrast">
<ScaleIcon class="text-orange" /> Moderation
</h1>
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
<button v-tooltip="`Exit moderation`" @click="exitModeration">
<CrossIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
<button v-tooltip="collapsed ? `Expand` : `Collapse`" @click="emit('toggleCollapsed')">
<DropdownIcon class="transition-transform" :class="{ 'rotate-180': collapsed }" />
</button>
</ButtonStyled>
</div>
<div v-else-if="generatedMessage">
<p>
Enter your moderation message here. Remember to check the Moderation tab to answer any
questions an author might have!
</p>
<div class="markdown-editor-spacing">
<MarkdownEditor v-model="message" :placeholder="'Enter moderation message'" />
<Collapsible base-class="grow" class="flex grow flex-col" :collapsed="collapsed">
<div class="my-4 h-[1px] w-full bg-divider" />
<div v-if="done">
<p>You are done moderating this project! There are {{ futureProjects.length }} left.</p>
</div>
</div>
<div v-else-if="steps[currentStepIndex].id === 'modpack-permissions'">
<h2 v-if="modPackData">
Modpack permissions
<template v-if="modPackIndex + 1 <= modPackData.length">
({{ modPackIndex + 1 }} / {{ modPackData.length }})
</template>
</h2>
<div v-if="!modPackData">Loading data...</div>
<div v-else-if="modPackData.length === 0">
<p>All permissions obtained. You may skip this step!</p>
<div v-else-if="generatedMessage">
<p>
Enter your moderation message here. Remember to check the Moderation tab to answer any
questions an author might have!
</p>
<div class="markdown-editor-spacing">
<MarkdownEditor v-model="message" :placeholder="'Enter moderation message'" />
</div>
</div>
<div v-else-if="!modPackData[modPackIndex]">
<p>All permission checks complete!</p>
<div class="input-group modpack-buttons">
<button class="btn" @click="modPackIndex -= 1">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
<div v-else-if="steps[currentStepIndex].id === 'modpack-permissions'">
<h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
Modpack permissions
<template v-if="modPackIndex + 1 <= modPackData.length">
({{ modPackIndex + 1 }} / {{ modPackData.length }})
</template>
</h2>
<div v-if="!modPackData">Loading data...</div>
<div v-else-if="modPackData.length === 0">
<p>All permissions obtained. You may skip this step!</p>
</div>
<div v-else-if="!modPackData[modPackIndex]">
<p>All permission checks complete!</p>
<div class="input-group modpack-buttons">
<ButtonStyled>
<button @click="modPackIndex -= 1">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
</ButtonStyled>
</div>
</div>
<div v-else>
<div v-if="modPackData[modPackIndex].type === 'unknown'">
<p>What is the approval type of {{ modPackData[modPackIndex].file_name }}?</p>
<div class="input-group">
<button
v-for="(option, index) in fileApprovalTypes"
:key="index"
class="btn"
:class="{
'option-selected': modPackData[modPackIndex].status === option.id,
}"
@click="modPackData[modPackIndex].status = option.id"
>
{{ option.name }}
</button>
</div>
<div
v-if="modPackData[modPackIndex].status !== 'unidentified'"
class="flex flex-col gap-1"
>
<label for="proof">
<span class="label__title">Proof</span>
</label>
<input
id="proof"
v-model="modPackData[modPackIndex].proof"
type="text"
autocomplete="off"
placeholder="Enter proof of status..."
/>
<label for="link">
<span class="label__title">Link</span>
</label>
<input
id="link"
v-model="modPackData[modPackIndex].url"
type="text"
autocomplete="off"
placeholder="Enter link of project..."
/>
<label for="title">
<span class="label__title">Title</span>
</label>
<input
id="title"
v-model="modPackData[modPackIndex].title"
type="text"
autocomplete="off"
placeholder="Enter title of project..."
/>
</div>
</div>
<div v-else-if="modPackData[modPackIndex].type === 'flame'">
<p>
What is the approval type of {{ modPackData[modPackIndex].title }} (<a
:href="modPackData[modPackIndex].url"
target="_blank"
class="text-link"
>{{ modPackData[modPackIndex].url }}</a
>?
</p>
<div class="input-group">
<button
v-for="(option, index) in fileApprovalTypes"
:key="index"
class="btn"
:class="{
'option-selected': modPackData[modPackIndex].status === option.id,
}"
@click="modPackData[modPackIndex].status = option.id"
>
{{ option.name }}
</button>
</div>
</div>
<div
v-if="
['unidentified', 'no', 'with-attribution'].includes(modPackData[modPackIndex].status)
"
>
<p v-if="modPackData[modPackIndex].status === 'unidentified'">
Does this project provide identification and permission for
<strong>{{ modPackData[modPackIndex].file_name }}</strong
>?
</p>
<p v-else-if="modPackData[modPackIndex].status === 'with-attribution'">
Does this project provide attribution for
<strong>{{ modPackData[modPackIndex].file_name }}</strong
>?
</p>
<p v-else>
Does this project provide proof of permission for
<strong>{{ modPackData[modPackIndex].file_name }}</strong
>?
</p>
<div class="input-group">
<button
v-for="(option, index) in filePermissionTypes"
:key="index"
class="btn"
:class="{
'option-selected': modPackData[modPackIndex].approved === option.id,
}"
@click="modPackData[modPackIndex].approved = option.id"
>
{{ option.name }}
</button>
</div>
</div>
<div class="mt-4 flex gap-2">
<ButtonStyled>
<button :disabled="modPackIndex <= 0" @click="modPackIndex -= 1">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
</ButtonStyled>
<ButtonStyled color="blue">
<button :disabled="!modPackData[modPackIndex].status" @click="modPackIndex += 1">
<RightArrowIcon aria-hidden="true" />
Next project
</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else>
<div v-if="modPackData[modPackIndex].type === 'unknown'">
<p>What is the approval type of {{ modPackData[modPackIndex].file_name }}?</p>
<div class="input-group">
<button
v-for="(option, index) in fileApprovalTypes"
:key="index"
class="btn"
:class="{
'option-selected': modPackData[modPackIndex].status === option.id,
}"
@click="modPackData[modPackIndex].status = option.id"
>
{{ option.name }}
</button>
</div>
<template v-if="modPackData[modPackIndex].status !== 'unidentified'">
<div class="universal-labels"></div>
<label for="proof">
<span class="label__title">Proof</span>
</label>
<input
id="proof"
v-model="modPackData[modPackIndex].proof"
type="text"
autocomplete="off"
placeholder="Enter proof of status..."
/>
<label for="link">
<span class="label__title">Link</span>
</label>
<input
id="link"
v-model="modPackData[modPackIndex].url"
type="text"
autocomplete="off"
placeholder="Enter link of project..."
/>
<label for="title">
<span class="label__title">Title</span>
</label>
<input
id="title"
v-model="modPackData[modPackIndex].title"
type="text"
autocomplete="off"
placeholder="Enter title of project..."
/>
<h2 class="m-0 mb-2 text-lg font-extrabold">{{ steps[currentStepIndex].question }}</h2>
<template v-if="steps[currentStepIndex].rules && steps[currentStepIndex].rules.length > 0">
<strong>Guidance:</strong>
<ul class="mb-3 mt-2 leading-tight">
<li v-for="(rule, index) in steps[currentStepIndex].rules" :key="index">
{{ rule }}
</li>
</ul>
</template>
<template
v-if="steps[currentStepIndex].examples && steps[currentStepIndex].examples.length > 0"
>
<strong>Reject things like:</strong>
<ul class="mb-3 mt-2 leading-tight">
<li v-for="(example, index) in steps[currentStepIndex].examples" :key="index">
{{ example }}
</li>
</ul>
</template>
<template
v-if="steps[currentStepIndex].exceptions && steps[currentStepIndex].exceptions.length > 0"
>
<strong>Exceptions:</strong>
<ul class="mb-3 mt-2 leading-tight">
<li v-for="(exception, index) in steps[currentStepIndex].exceptions" :key="index">
{{ exception }}
</li>
</ul>
</template>
<p v-if="steps[currentStepIndex].id === 'title'">
<strong>Title:</strong> {{ project.title }}
</p>
<p v-if="steps[currentStepIndex].id === 'slug'">
<strong>Slug:</strong> {{ project.slug }}
</p>
<p v-if="steps[currentStepIndex].id === 'summary'">
<strong>Summary:</strong> {{ project.description }}
</p>
<p v-if="steps[currentStepIndex].id === 'links'">
<template v-if="project.issues_url">
<strong>Issues: </strong>
<a class="text-link" :href="project.issues_url">{{ project.issues_url }}</a> <br />
</template>
</div>
<div v-else-if="modPackData[modPackIndex].type === 'flame'">
<p>
What is the approval type of {{ modPackData[modPackIndex].title }} (<a
:href="modPackData[modPackIndex].url"
target="_blank"
class="text-link"
>{{ modPackData[modPackIndex].url }}</a
>?
</p>
<div class="input-group">
<button
v-for="(option, index) in fileApprovalTypes"
:key="index"
class="btn"
:class="{
'option-selected': modPackData[modPackIndex].status === option.id,
}"
@click="modPackData[modPackIndex].status = option.id"
>
{{ option.name }}
</button>
</div>
<template v-if="project.source_url">
<strong>Source: </strong>
<a class="text-link" :href="project.source_url">{{ project.source_url }}</a> <br />
</template>
<template v-if="project.wiki_url">
<strong>Wiki: </strong>
<a class="text-link" :href="project.wiki_url">{{ project.wiki_url }}</a> <br />
</template>
<template v-if="project.discord_url">
<strong>Discord: </strong>
<a class="text-link" :href="project.discord_url">{{ project.discord_url }}</a>
<br />
</template>
<template v-for="(donation, index) in project.donation_urls" :key="index">
<strong>{{ donation.platform }}: </strong>
<a class="text-link" :href="donation.url">{{ donation.url }}</a>
<br />
</template>
</p>
<p v-if="steps[currentStepIndex].id === 'categories'">
<strong>Categories:</strong>
<Categories
:categories="project.categories.concat(project.additional_categories)"
:type="project.actualProjectType"
class="categories"
/>
</p>
<p v-if="steps[currentStepIndex].id === 'side-types'">
<strong>Client side:</strong> {{ project.client_side }} <br />
<strong>Server side:</strong> {{ project.server_side }}
</p>
<div class="options input-group">
<button
v-for="(option, index) in steps[currentStepIndex].options"
:key="index"
class="btn"
:class="{
'option-selected':
selectedOptions[steps[currentStepIndex].id] &&
selectedOptions[steps[currentStepIndex].id].find((x) => x.name === option.name),
}"
@click="toggleOption(steps[currentStepIndex].id, option)"
>
{{ option.name }}
</button>
</div>
<div
v-if="
['unidentified', 'no', 'with-attribution'].includes(modPackData[modPackIndex].status)
selectedOptions[steps[currentStepIndex].id] &&
selectedOptions[steps[currentStepIndex].id].length > 0
"
class="inputs universal-labels"
>
<p v-if="modPackData[modPackIndex].status === 'unidentified'">
Does this project provide identification and permission for
<strong>{{ modPackData[modPackIndex].file_name }}</strong
>?
</p>
<p v-else-if="modPackData[modPackIndex].status === 'with-attribution'">
Does this project provide attribution for
<strong>{{ modPackData[modPackIndex].file_name }}</strong
>?
</p>
<p v-else>
Does this project provide proof of permission for
<strong>{{ modPackData[modPackIndex].file_name }}</strong
>?
</p>
<div class="input-group">
<button
v-for="(option, index) in filePermissionTypes"
:key="index"
class="btn"
:class="{
'option-selected': modPackData[modPackIndex].approved === option.id,
}"
@click="modPackData[modPackIndex].approved = option.id"
>
{{ option.name }}
<div
v-for="(option, index) in selectedOptions[steps[currentStepIndex].id].filter(
(x) => x.fillers && x.fillers.length > 0,
)"
:key="index"
>
<div v-for="(filler, idx) in option.fillers" :key="idx">
<label :for="filler.id">
<span class="label__title">
{{ filler.question }}
<span v-if="filler.required" class="required">*</span>
</span>
</label>
<div v-if="filler.large" class="markdown-editor-spacing">
<MarkdownEditor v-model="filler.value" :placeholder="'Enter moderation message'" />
</div>
<input v-else :id="filler.id" v-model="filler.value" type="text" autocomplete="off" />
</div>
</div>
</div>
</div>
<div class="mt-auto">
<div
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
>
<div class="flex items-center gap-2">
<ButtonStyled v-if="!done">
<button aria-label="Skip" @click="goToNextProject">
<ExitIcon aria-hidden="true" />
<template v-if="futureProjects.length > 0">Skip</template>
<template v-else>Exit</template>
</button>
</ButtonStyled>
<ButtonStyled v-if="currentStepIndex > 0">
<button @click="previousPage() && !done">
<LeftArrowIcon aria-hidden="true" /> Previous
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-2">
<ButtonStyled v-if="currentStepIndex < steps.length - 1 && !done" color="brand">
<button @click="nextPage()"><RightArrowIcon aria-hidden="true" /> Next</button>
</ButtonStyled>
<ButtonStyled v-else-if="!generatedMessage" color="brand">
<button :disabled="loadingMessage" @click="generateMessage">
<UpdatedIcon aria-hidden="true" /> Generate message
</button>
</ButtonStyled>
<template v-if="generatedMessage && !done">
<ButtonStyled color="green">
<button @click="sendMessage(project.requested_status ?? 'approved')">
<CheckIcon aria-hidden="true" /> Approve
</button>
</ButtonStyled>
<div class="joined-buttons">
<ButtonStyled color="red">
<button @click="sendMessage('rejected')">
<CrossIcon aria-hidden="true" /> Reject
</button>
</ButtonStyled>
<ButtonStyled color="red">
<OverflowMenu
class="btn-dropdown-animation"
:options="[
{
id: 'withhold',
color: 'danger',
action: () => sendMessage('withheld'),
hoverFilled: true,
},
]"
>
<DropdownIcon style="rotate: 180deg" />
<template #withhold> <EyeOffIcon aria-hidden="true" /> Withhold </template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>
<button v-if="done" class="btn btn-primary next-project" @click="goToNextProject">
Next project
</button>
</div>
</div>
<div class="input-group modpack-buttons">
<button class="btn" :disabled="modPackIndex <= 0" @click="modPackIndex -= 1">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
<button
class="btn btn-blue"
:disabled="!modPackData[modPackIndex].status"
@click="modPackIndex += 1"
>
<RightArrowIcon aria-hidden="true" />
Next project
</button>
</div>
</div>
</div>
<div v-else>
<h2>{{ steps[currentStepIndex].question }}</h2>
<template v-if="steps[currentStepIndex].rules && steps[currentStepIndex].rules.length > 0">
<strong>Rules guidance:</strong>
<ul>
<li v-for="(rule, index) in steps[currentStepIndex].rules" :key="index">
{{ rule }}
</li>
</ul>
</template>
<template
v-if="steps[currentStepIndex].examples && steps[currentStepIndex].examples.length > 0"
>
<strong>Examples of what to reject:</strong>
<ul>
<li v-for="(example, index) in steps[currentStepIndex].examples" :key="index">
{{ example }}
</li>
</ul>
</template>
<template
v-if="steps[currentStepIndex].exceptions && steps[currentStepIndex].exceptions.length > 0"
>
<strong>Exceptions:</strong>
<ul>
<li v-for="(exception, index) in steps[currentStepIndex].exceptions" :key="index">
{{ exception }}
</li>
</ul>
</template>
<p v-if="steps[currentStepIndex].id === 'title'">
<strong>Title:</strong> {{ project.title }}
</p>
<p v-if="steps[currentStepIndex].id === 'slug'"><strong>Slug:</strong> {{ project.slug }}</p>
<p v-if="steps[currentStepIndex].id === 'summary'">
<strong>Summary:</strong> {{ project.description }}
</p>
<p v-if="steps[currentStepIndex].id === 'links'">
<template v-if="project.issues_url">
<strong>Issues: </strong>
<a class="text-link" :href="project.issues_url">{{ project.issues_url }}</a> <br />
</template>
<template v-if="project.source_url">
<strong>Source: </strong>
<a class="text-link" :href="project.source_url">{{ project.source_url }}</a> <br />
</template>
<template v-if="project.wiki_url">
<strong>Wiki: </strong>
<a class="text-link" :href="project.wiki_url">{{ project.wiki_url }}</a> <br />
</template>
<template v-if="project.discord_url">
<strong>Discord: </strong>
<a class="text-link" :href="project.discord_url">{{ project.discord_url }}</a>
<br />
</template>
<template v-for="(donation, index) in project.donation_urls" :key="index">
<strong>{{ donation.platform }}: </strong>
<a class="text-link" :href="donation.url">{{ donation.url }}</a>
<br />
</template>
</p>
<p v-if="steps[currentStepIndex].id === 'categories'">
<strong>Categories:</strong>
<Categories
:categories="project.categories.concat(project.additional_categories)"
:type="project.actualProjectType"
class="categories"
/>
</p>
<p v-if="steps[currentStepIndex].id === 'side-types'">
<strong>Client side:</strong> {{ project.client_side }} <br />
<strong>Server side:</strong> {{ project.server_side }}
</p>
<div class="options input-group">
<button
v-for="(option, index) in steps[currentStepIndex].options"
:key="index"
class="btn"
:class="{
'option-selected':
selectedOptions[steps[currentStepIndex].id] &&
selectedOptions[steps[currentStepIndex].id].find((x) => x.name === option.name),
}"
@click="toggleOption(steps[currentStepIndex].id, option)"
>
{{ option.name }}
</button>
</div>
<div
v-if="
selectedOptions[steps[currentStepIndex].id] &&
selectedOptions[steps[currentStepIndex].id].length > 0
"
class="inputs universal-labels"
>
<div
v-for="(option, index) in selectedOptions[steps[currentStepIndex].id].filter(
(x) => x.fillers && x.fillers.length > 0,
)"
:key="index"
>
<div v-for="(filler, idx) in option.fillers" :key="idx">
<label :for="filler.id">
<span class="label__title">
{{ filler.question }}
<span v-if="filler.required" class="required">*</span>
</span>
</label>
<div v-if="filler.large" class="markdown-editor-spacing">
<MarkdownEditor v-model="filler.value" :placeholder="'Enter moderation message'" />
</div>
<input v-else :id="filler.id" v-model="filler.value" type="text" autocomplete="off" />
</div>
</div>
</div>
</div>
<div class="input-group modpack-buttons">
<button v-if="!done" class="btn skip-btn" aria-label="Skip" @click="goToNextProject">
<ExitIcon aria-hidden="true" />
<template v-if="futureProjects.length > 0">Skip</template>
<template v-else>Exit</template>
</button>
<button v-if="currentStepIndex > 0" class="btn" @click="previousPage() && !done">
<LeftArrowIcon aria-hidden="true" /> Previous
</button>
<button
v-if="currentStepIndex < steps.length - 1 && !done"
class="btn btn-primary"
@click="nextPage()"
>
<RightArrowIcon aria-hidden="true" /> Next
</button>
<button
v-else-if="!generatedMessage"
class="btn btn-primary"
:disabled="loadingMessage"
@click="generateMessage"
>
<UpdatedIcon aria-hidden="true" /> Generate message
</button>
<template v-if="generatedMessage && !done">
<button class="btn btn-green" @click="sendMessage(project.requested_status ?? 'approved')">
<CheckIcon aria-hidden="true" /> Approve
</button>
<div class="joined-buttons">
<button class="btn btn-danger" @click="sendMessage('rejected')">
<CrossIcon aria-hidden="true" /> Reject
</button>
<OverflowMenu
class="btn btn-danger btn-dropdown-animation icon-only"
:options="[
{
id: 'withhold',
color: 'danger',
action: () => sendMessage('withheld'),
hoverFilled: true,
},
]"
>
<DropdownIcon style="rotate: 180deg" />
<template #withhold> <EyeOffIcon aria-hidden="true" /> Withhold </template>
</OverflowMenu>
</div>
</template>
<button v-if="done" class="btn btn-primary next-project" @click="goToNextProject">
Next project
</button>
</div>
</Collapsible>
</div>
</template>
@@ -337,8 +374,9 @@ import {
XIcon as CrossIcon,
EyeOffIcon,
ExitIcon,
ScaleIcon,
} from "@modrinth/assets";
import { MarkdownEditor, OverflowMenu } from "@modrinth/ui";
import { ButtonStyled, MarkdownEditor, OverflowMenu, Collapsible } from "@modrinth/ui";
import Categories from "~/components/ui/search/Categories.vue";
const props = defineProps({
@@ -355,8 +393,14 @@ const props = defineProps({
required: true,
default: () => {},
},
collapsed: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["exit", "toggleCollapsed"]);
const steps = computed(() =>
[
{
@@ -1008,6 +1052,20 @@ async function sendMessage(status) {
const router = useNativeRouter();
async function exitModeration() {
await router.push({
name: "type-id",
params: {
type: "project",
id: props.project.id,
},
state: {
showChecklist: false,
},
});
emit("exit");
}
async function goToNextProject() {
const project = props.futureProjects[0];
@@ -1031,23 +1089,8 @@ async function goToNextProject() {
<style scoped lang="scss">
.moderation-checklist {
position: sticky;
bottom: 0;
left: 100vw;
z-index: 100;
border: 1px solid var(--color-bg-inverted);
width: 600px;
.skip-btn {
margin-right: auto;
}
.next-project {
margin-left: auto;
}
.modpack-buttons {
margin-top: 1rem;
@media (prefers-reduced-motion) {
transition: none !important;
}
.option-selected {

View File

@@ -1,5 +1,4 @@
<template>
<Chips v-if="false" v-model="viewMode" :items="['open', 'archived']" />
<ReportInfo
v-for="report in reports.filter(
(x) =>
@@ -17,7 +16,6 @@
<p v-if="reports.length === 0">You don't have any active reports.</p>
</template>
<script setup>
import Chips from "~/components/ui/Chips.vue";
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
import { addReportMessage } from "~/helpers/threads.js";

View File

@@ -106,6 +106,7 @@ const fetchSettings = async () => {
initialSettings.value = settings as { interval: number; enabled: boolean };
autoBackupEnabled.value = settings?.enabled ?? false;
autoBackupInterval.value = settings?.interval || 6;
return true;
} catch (error) {
console.error("Error fetching backup settings:", error);
addNotification({
@@ -114,6 +115,7 @@ const fetchSettings = async () => {
text: "Failed to load backup settings",
type: "error",
});
return false;
} finally {
isLoadingSettings.value = false;
}
@@ -155,8 +157,10 @@ const saveSettings = async () => {
defineExpose({
show: async () => {
await fetchSettings();
modal.value?.show();
const success = await fetchSettings();
if (success) {
modal.value?.show();
}
},
});
</script>

View File

@@ -54,7 +54,8 @@ const locations = ref([
{ name: "New York", lat: 40.7128, lng: -74.006, active: true, clicked: false },
{ name: "Los Angeles", lat: 34.0522, lng: -118.2437, active: true, clicked: false },
{ name: "Miami", lat: 25.7617, lng: -80.1918, active: true, clicked: false },
{ name: "Seattle", lat: 47.608013, lng: -122.3321, active: true, clicked: false },
{ name: "Spokane", lat: 47.667309, lng: -117.411922, active: true, clicked: false },
{ name: "Dallas", lat: 32.78372, lng: -96.7947, active: true, clicked: false },
// Future Locations
// { name: "London", lat: 51.5074, lng: -0.1278, active: false, clicked: false },
// { name: "Frankfurt", lat: 50.1109, lng: 8.6821, active: false, clicked: false },

View File

@@ -0,0 +1,80 @@
<template>
<div
aria-hidden="true"
style="font-variant-numeric: tabular-nums"
class="pointer-events-none h-full w-full select-none"
>
<div class="flex flex-col gap-6">
<div class="flex flex-row items-center gap-6">
<div
class="relative max-h-[156px] min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0.00%</h2>
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 100%</h3>
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">CPU usage</h3>
</div>
<CPUIcon class="absolute right-10 top-10" />
</div>
<div
class="relative max-h-[156px] min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0.00%</h2>
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 100%</h3>
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">Memory usage</h3>
</div>
<DBIcon class="absolute right-10 top-10" />
</div>
<div
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8 transition-transform duration-100 hover:scale-105 active:scale-100"
>
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">0 B</h2>
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
</div>
</div>
<div
class="relative flex h-full w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="experimental-styles-within flex flex-row items-center">
<div class="flex flex-row items-center gap-4">
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
</div>
</div>
<div class="relative w-full">
<input type="text" placeholder="Search logs" class="h-12 !w-full !pl-10 !pr-48" />
<SearchIcon class="absolute left-4 top-1/2 -translate-y-1/2" />
</div>
<div
class="console relative h-full min-h-[516px] w-full overflow-hidden rounded-xl bg-bg text-sm"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CPUIcon, DBIcon, FolderOpenIcon, SearchIcon } from "@modrinth/assets";
</script>
<style scoped>
html.light-mode .console {
background: var(--color-bg);
}
html.dark-mode .console {
background: black;
}
html.oled-mode .console {
background: black;
}
</style>

View File

@@ -1,23 +1,25 @@
<template>
<div class="contents">
<NewModal ref="confirmActionModal" header="Confirming power action" @close="closePowerModal">
<NewModal ref="confirmActionModal" header="Confirming power action" @close="resetPowerAction">
<div class="flex flex-col gap-4 md:w-[400px]">
<p class="m-0">Are you sure you want to {{ currentPendingAction }} the server?</p>
<p class="m-0">
Are you sure you want to <span class="lowercase">{{ confirmActionText }}</span> the
server?
</p>
<UiCheckbox
v-model="powerDontAskAgainCheckbox"
v-model="dontAskAgain"
label="Don't ask me again"
class="text-sm"
:disabled="!currentPendingAction"
:disabled="!powerAction"
/>
<div class="flex flex-row gap-4">
<ButtonStyled type="standard" color="brand" @click="confirmAction">
<ButtonStyled type="standard" color="brand" @click="executePowerAction">
<button>
<CheckIcon class="h-5 w-5" />
{{ currentPendingActionFriendly }} server
{{ confirmActionText }} server
</button>
</ButtonStyled>
<ButtonStyled @click="closePowerModal">
<ButtonStyled @click="resetPowerAction">
<button>
<XIcon class="h-5 w-5" />
Cancel
@@ -29,7 +31,7 @@
<NewModal
ref="detailsModal"
:header="`All of ${props.serverName ? props.serverName : 'Server'} info`"
:header="`All of ${serverName || 'Server'} info`"
@close="closeDetailsModal"
>
<UiServersServerInfoLabels
@@ -51,75 +53,74 @@
<UiServersPanelSpinner class="size-5" /> Installing...
</button>
</ButtonStyled>
<div v-else class="contents">
<template v-else>
<ButtonStyled v-if="showStopButton" type="transparent">
<button :disabled="!canTakeAction || disabled || isStopping" @click="stopServer">
<button :disabled="!canTakeAction" @click="initiateAction('stop')">
<div class="flex gap-1">
<StopCircleIcon class="h-5 w-5" />
<span>{{ stopButtonText }}</span>
<span>{{ isStoppingState ? "Stopping..." : "Stop" }}</span>
</div>
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button :disabled="!canTakeAction || disabled || isStopping" @click="handleAction">
<div v-if="isStartingOrRestarting" class="grid place-content-center">
<button :disabled="!canTakeAction" @click="handlePrimaryAction">
<div v-if="isTransitionState" class="grid place-content-center">
<UiServersIconsLoadingIcon />
</div>
<div v-else class="contents">
<component :is="showRestartIcon ? UpdatedIcon : PlayIcon" />
</div>
<span>
{{ actionButtonText }}
</span>
<component :is="isRunning ? UpdatedIcon : PlayIcon" v-else />
<span>{{ primaryActionText }}</span>
</button>
</ButtonStyled>
</div>
<!-- Dropdown options -->
<ButtonStyled circular type="transparent">
<UiServersTeleportOverflowMenu
:options="[
...(props.isInstalling ? [] : [{ id: 'kill', action: () => killServer() }]),
{ id: 'allServers', action: () => router.push('/servers/manage') },
{ id: 'details', action: () => showDetailsModal() },
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #kill>
<SlashIcon class="h-5 w-5" />
<span>Kill server</span>
</template>
<template #allServers>
<ServerIcon class="h-5 w-5" />
<span>All servers</span>
</template>
<template #details>
<InfoIcon class="h-5 w-5" />
<span>Details</span>
</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<UiServersTeleportOverflowMenu :options="[...menuOptions]">
<MoreVerticalIcon aria-hidden="true" />
<template #kill>
<SlashIcon class="h-5 w-5" />
<span>Kill server</span>
</template>
<template #allServers>
<ServerIcon class="h-5 w-5" />
<span>All servers</span>
</template>
<template #details>
<InfoIcon class="h-5 w-5" />
<span>Details</span>
</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { ref, computed } from "vue";
import {
PlayIcon,
UpdatedIcon,
StopCircleIcon,
SlashIcon,
MoreVerticalIcon,
XIcon,
CheckIcon,
ServerIcon,
InfoIcon,
MoreVerticalIcon,
} from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { useRouter } from "vue-router";
import { useStorage } from "@vueuse/core";
type ServerAction = "start" | "stop" | "restart" | "kill";
type ServerState = "stopped" | "starting" | "running" | "stopping" | "restarting";
interface PowerAction {
action: ServerAction;
nextState: ServerState;
}
const props = defineProps<{
isOnline: boolean;
isActioning: boolean;
@@ -130,183 +131,142 @@ const props = defineProps<{
uptimeSeconds: number;
}>();
const emit = defineEmits<{
(e: "action", action: ServerAction): void;
}>();
const router = useRouter();
const serverId = router.currentRoute.value.params.id;
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
powerDontAskAgain: false,
});
const emit = defineEmits<{
(e: "action", action: "start" | "restart" | "stop" | "kill"): void;
}>();
const serverState = ref<ServerState>(props.isOnline ? "running" : "stopped");
const powerAction = ref<PowerAction | null>(null);
const dontAskAgain = ref(false);
const startingDelay = ref(false);
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
const ServerState = {
Stopped: "Stopped",
Starting: "Starting",
Running: "Running",
Stopping: "Stopping",
Restarting: "Restarting",
} as const;
type ServerStateType = (typeof ServerState)[keyof typeof ServerState];
const currentPendingAction = ref<string | null>(null);
const currentPendingState = ref<ServerStateType | null>(null);
const powerDontAskAgainCheckbox = ref(false);
const currentState = ref<ServerStateType>(
props.isOnline ? ServerState.Running : ServerState.Stopped,
);
const isStartingDelay = ref(false);
const showStopButton = computed(
() => currentState.value === ServerState.Running || currentState.value === ServerState.Stopping,
);
const showRestartIcon = computed(() => currentState.value === ServerState.Running);
const canTakeAction = computed(
() =>
!props.isActioning &&
!isStartingDelay.value &&
currentState.value !== ServerState.Starting &&
currentState.value !== ServerState.Stopping,
() => !props.isActioning && !startingDelay.value && !isTransitionState.value,
);
const isStartingOrRestarting = computed(
() =>
currentState.value === ServerState.Starting || currentState.value === ServerState.Restarting,
const isRunning = computed(() => serverState.value === "running");
const isTransitionState = computed(() =>
["starting", "stopping", "restarting"].includes(serverState.value),
);
const isStoppingState = computed(() => serverState.value === "stopping");
const showStopButton = computed(() => isRunning.value || isStoppingState.value);
const isStopping = computed(() => currentState.value === ServerState.Stopping);
const actionButtonText = computed(() => {
switch (currentState.value) {
case ServerState.Starting:
return "Starting...";
case ServerState.Restarting:
return "Restarting...";
case ServerState.Running:
return "Restart";
case ServerState.Stopping:
return "Stopping...";
default:
return "Start";
}
const primaryActionText = computed(() => {
const states: Record<ServerState, string> = {
starting: "Starting...",
restarting: "Restarting...",
running: "Restart",
stopping: "Stopping...",
stopped: "Start",
};
return states[serverState.value];
});
const currentPendingActionFriendly = computed(() => {
switch (currentPendingAction.value) {
case "start":
return "Start";
case "restart":
return "Restart";
case "stop":
return "Stop";
case "kill":
return "Kill";
default:
return null;
}
const confirmActionText = computed(() => {
if (!powerAction.value) return "";
return powerAction.value.action.charAt(0).toUpperCase() + powerAction.value.action.slice(1);
});
const stopButtonText = computed(() =>
currentState.value === ServerState.Stopping ? "Stopping..." : "Stop",
);
const menuOptions = computed(() => [
...(props.isInstalling
? []
: [
{
id: "kill",
label: "Kill server",
icon: SlashIcon,
action: () => initiateAction("kill"),
},
]),
{
id: "allServers",
label: "All servers",
icon: ServerIcon,
action: () => router.push("/servers/manage"),
},
{
id: "details",
label: "Details",
icon: InfoIcon,
action: () => detailsModal.value?.show(),
},
]);
const createPendingAction = () => {
function initiateAction(action: ServerAction) {
if (!canTakeAction.value) return;
if (currentState.value === ServerState.Running) {
currentPendingAction.value = "restart";
currentPendingState.value = ServerState.Restarting;
showPowerModal();
} else {
runAction("start", ServerState.Starting);
const stateMap: Record<ServerAction, ServerState> = {
start: "starting",
stop: "stopping",
restart: "restarting",
kill: "stopping",
};
if (action === "start") {
emit("action", action);
serverState.value = stateMap[action];
startingDelay.value = true;
setTimeout(() => (startingDelay.value = false), 5000);
return;
}
};
const handleAction = () => {
createPendingAction();
};
powerAction.value = { action, nextState: stateMap[action] };
const showPowerModal = () => {
if (userPreferences.value.powerDontAskAgain) {
runAction(
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
currentPendingState.value!,
);
executePowerAction();
} else {
confirmActionModal.value?.show();
}
};
}
const confirmAction = () => {
if (powerDontAskAgainCheckbox.value) {
function handlePrimaryAction() {
initiateAction(isRunning.value ? "restart" : "start");
}
function executePowerAction() {
if (!powerAction.value) return;
const { action, nextState } = powerAction.value;
emit("action", action);
serverState.value = nextState;
if (dontAskAgain.value) {
userPreferences.value.powerDontAskAgain = true;
}
runAction(
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
currentPendingState.value!,
);
closePowerModal();
};
const runAction = (action: "start" | "restart" | "stop" | "kill", serverState: ServerStateType) => {
emit("action", action);
currentState.value = serverState;
if (action === "start") {
isStartingDelay.value = true;
setTimeout(() => {
isStartingDelay.value = false;
}, 5000);
startingDelay.value = true;
setTimeout(() => (startingDelay.value = false), 5000);
}
};
const stopServer = () => {
if (!canTakeAction.value) return;
currentPendingAction.value = "stop";
currentPendingState.value = ServerState.Stopping;
showPowerModal();
};
resetPowerAction();
}
const killServer = () => {
currentPendingAction.value = "kill";
currentPendingState.value = ServerState.Stopping;
showPowerModal();
};
const closePowerModal = () => {
function resetPowerAction() {
confirmActionModal.value?.hide();
currentPendingAction.value = null;
powerDontAskAgainCheckbox.value = false;
};
powerAction.value = null;
dontAskAgain.value = false;
}
const closeDetailsModal = () => {
function closeDetailsModal() {
detailsModal.value?.hide();
};
const showDetailsModal = () => {
detailsModal.value?.show();
};
}
watch(
() => props.isOnline,
(newValue) => {
if (newValue) {
currentState.value = ServerState.Running;
} else {
currentState.value = ServerState.Stopped;
}
},
(online) => (serverState.value = online ? "running" : "stopped"),
);
watch(
() => router.currentRoute.value.fullPath,
() => {
closeDetailsModal();
},
() => closeDetailsModal(),
);
</script>

View File

@@ -1,66 +1,72 @@
<template>
<div
:aria-label="`Server is ${getStatusText}`"
:aria-label="`Server is ${getStatusText(state)}`"
class="relative inline-flex select-none items-center"
@mouseenter="isExpanded = true"
@mouseleave="isExpanded = false"
>
<div
:class="`h-4 w-4 rounded-full transition-all duration-300 ease-in-out ${getStatusClass.main}`"
:class="[
'h-4 w-4 rounded-full transition-all duration-300 ease-in-out',
getStatusClass(state).main,
]"
>
<div
:class="`absolute inline-flex h-4 w-4 animate-ping rounded-full ${getStatusClass.bg}`"
:class="[
'absolute inline-flex h-4 w-4 animate-ping rounded-full',
getStatusClass(state).bg,
]"
></div>
</div>
<div
:class="`absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out ${getStatusClass.bg} ${
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0'
}`"
:class="[
'absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out',
getStatusClass(state).bg,
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0',
]"
>
<div class="h-3 w-3 rounded-full"></div>
<span
class="origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out"
:class="`${isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75'}`"
:class="[
'origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out',
isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75',
]"
>
{{ getStatusText }}
{{ getStatusText(state) }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { ref } from "vue";
import type { ServerState } from "~/types/servers";
const props = defineProps<{
const STATUS_CLASSES = {
running: { main: "bg-brand", bg: "bg-bg-green" },
stopped: { main: "", bg: "" },
crashed: { main: "bg-brand-red", bg: "bg-bg-red" },
unknown: { main: "", bg: "" },
} as const;
const STATUS_TEXTS = {
running: "Running",
stopped: "",
crashed: "Crashed",
unknown: "Unknown",
} as const;
defineProps<{
state: ServerState;
}>();
const isExpanded = ref(false);
const getStatusClass = computed(() => {
switch (props.state) {
case "running":
return { main: "bg-brand", bg: "bg-bg-green" };
case "stopped":
return { main: "", bg: "" };
case "crashed":
return { main: "bg-brand-red", bg: "bg-bg-red" };
default:
return { main: "", bg: "" };
}
});
function getStatusClass(state: ServerState) {
return STATUS_CLASSES[state] ?? STATUS_CLASSES.unknown;
}
const getStatusText = computed(() => {
switch (props.state) {
case "running":
return "Running";
case "stopped":
return "";
case "crashed":
return "Crashed";
default:
return "Unknown";
}
});
function getStatusText(state: ServerState) {
return STATUS_TEXTS[state] ?? STATUS_TEXTS.unknown;
}
</script>

View File

@@ -260,7 +260,25 @@
</div>
<NewModal ref="viewLogModal" class="z-[9999]" header="Viewing selected logs">
<div class="text-contrast">
<pre class="select-text overflow-x-auto whitespace-pre font-mono">{{ selectedLog }}</pre>
<pre
class="select-text overflow-x-auto whitespace-pre rounded-lg bg-bg font-mono"
v-html="processedLogWithLinks"
></pre>
<div v-if="detectedLinks.length" class="border-contrast/20 mt-4 border-t pt-4">
<h2>Detected Links</h2>
<ul class="flex flex-col gap-2">
<li v-for="(link, index) in detectedLinks" :key="index">
<a
:href="link"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-blue hover:underline"
>
{{ link }}
</a>
</li>
</ul>
</div>
</div>
</NewModal>
</div>
@@ -272,6 +290,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import { useDebounceFn } from "@vueuse/core";
import { NewModal } from "@modrinth/ui";
import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue";
import DOMPurify from "dompurify";
import { usePyroConsole } from "~/store/console.ts";
const { $cosmetics } = useNuxtApp();
@@ -984,6 +1003,38 @@ const jumpToLine = (line: string, event?: MouseEvent) => {
});
};
const sanitizeUrl = (url: string): string => {
try {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) {
return "#";
}
return parsed.toString();
} catch {
return "#";
}
};
const detectedLinks = computed(() => {
const urlRegex = /(https?:\/\/[^\s,<]+(?=[,\s<]|$))/g;
const matches = [...selectedLog.value.matchAll(urlRegex)].map((match) => match[0]);
return matches.filter((url) => sanitizeUrl(url) !== "#");
});
const processedLogWithLinks = computed(() => {
const urlRegex = /(https?:\/\/[^\s,<]+(?=[,\s<]|$))/g;
const sanitizedLog = DOMPurify.sanitize(selectedLog.value, {
ALLOWED_TAGS: [],
ALLOWED_ATTR: [],
});
return sanitizedLog.replace(urlRegex, (url) => {
const safeUrl = sanitizeUrl(url);
if (safeUrl === "#") return url;
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer nofollow" class="text-blue hover:underline">${url}</a>`;
});
});
watch(
() => pyroConsole.filteredOutput.value,
() => {

View File

@@ -337,7 +337,7 @@ watch(
selectedLoaderVersions,
(newVersions) => {
if (newVersions.length > 0 && !selectedLoaderVersion.value) {
selectedLoaderVersion.value = String(newVersions[0]); // Ensure string type
selectedLoaderVersion.value = String(newVersions[0]);
}
},
{ immediate: true },
@@ -516,8 +516,6 @@ const handleReinstall = async () => {
const onShow = () => {
selectedMCVersion.value = props.server.general?.mc_version || "";
selectedLoaderVersion.value = "";
hardReset.value = false;
};
const onHide = () => {
@@ -528,13 +526,15 @@ const onHide = () => {
loadingServerCheck.value = false;
isLoading.value = false;
selectedMCVersion.value = "";
selectedLoaderVersion.value = "";
serverCheckError.value = "";
paperVersions.value = {};
purpurVersions.value = {};
};
const show = (loader: Loaders) => {
if (selectedLoader.value !== loader) {
selectedLoaderVersion.value = "";
}
selectedLoader.value = loader;
selectedMCVersion.value = props.server.general?.mc_version || "";
versionSelectModal.value?.show();

View File

@@ -69,11 +69,13 @@
</div>
<div
v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'"
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
>
<UiServersIconsPanelErrorIcon class="!size-5" />
Your server has been suspended due to a billing issue. Please visit your billing settings or
contact Modrinth Support for more information.
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
update your billing information or contact Modrinth Support for more information.
</div>
<UiCopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
</NuxtLink>
</template>

View File

@@ -2,7 +2,7 @@
<div
v-if="uptimeSeconds || uptimeSeconds !== 0"
v-tooltip="`Online for ${verboseUptime}`"
class="flex min-w-0 flex-row items-center gap-4"
class="server-action-buttons-anim flex min-w-0 flex-row items-center gap-4"
data-pyro-uptime
>
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>