You've already forked AstralRinth
forked from didirus/AstralRinth
Merge commit '037cc86c1f520d8e89e721a631c9163d01c61070' into feature-clean
This commit is contained in:
@@ -22,10 +22,10 @@ import { ChevronRightIcon } from "@modrinth/assets";
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
// Clean.io
|
||||
src: "https://cadmus.script.ac/d14pdm1b7fi5kh/script.js",
|
||||
},
|
||||
// {
|
||||
// // Clean.io
|
||||
// src: "https://cadmus.script.ac/d14pdm1b7fi5kh/script.js",
|
||||
// },
|
||||
{
|
||||
// Aditude
|
||||
src: "https://dn0qt3r0xannq.cloudfront.net/modrinth-7JfmkEIXEp/modrinth-longform/prebid-load.js",
|
||||
|
||||
@@ -411,18 +411,21 @@ Per section 5.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#m
|
||||
name: "Insufficient",
|
||||
resultingMessage: `## Insufficient Summary
|
||||
Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) Your project summary should provide a brief overview of your project that informs and entices users.
|
||||
|
||||
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
|
||||
},
|
||||
{
|
||||
name: "Repeat of title",
|
||||
resultingMessage: `## Insufficient Summary
|
||||
Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) your Summary can not be the same as your project's Title. Your project summary should provide a brief overview of your project that informs and entices users.
|
||||
|
||||
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
|
||||
},
|
||||
{
|
||||
name: "Formatting",
|
||||
resultingMessage: `## Insufficient Summary
|
||||
Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) your Summary can not include any extra formatting such as lists, or links. Your project summary should provide a brief overview of your project that informs and entices users.
|
||||
|
||||
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
|
||||
},
|
||||
],
|
||||
@@ -559,7 +562,9 @@ Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#m
|
||||
name: "Inaccurate (modpack)",
|
||||
resultingMessage: `## Incorrect Environment Information
|
||||
Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous), it is important that the metadata of your projects is accurate, including whether the project runs on the client or server side.
|
||||
|
||||
For a brief rundown of how this works:
|
||||
|
||||
Some modpacks can be client-side, usually aimed at providing utility and optimization while allowing the player to join an unmodded server, for instance, [Fabulously Optimized](https://modrinth.com/modpack/fabulously-optimized).
|
||||
Most other modpacks that change how the game is played are going to be required on both the client and server, like the modpack [Dying Light](https://modrinth.com/modpack/dying-light).
|
||||
When in doubt, test for yourself or check the requirements of the mods in your pack.`,
|
||||
@@ -568,10 +573,11 @@ When in doubt, test for yourself or check the requirements of the mods in your p
|
||||
name: "Inaccurate (mod)",
|
||||
resultingMessage: `## Environment Information
|
||||
Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous), it is important that the metadata of your projects is accurate, including whether the project runs on the client or server side.
|
||||
|
||||
For a brief rundown of how this works:
|
||||
**Client side** refers to a mod that is only required by the client, like [Sodium](https://modrinth.com/mod/sodium).
|
||||
**Server side** mods change the behavior of the server without the client needing the mod, like Datapacks, recipes, or server-side behaviors, like [Falling Tree](https://modrinth.com/mod/fallingtree).
|
||||
A mod that adds features, entities, or new blocks and items, generally will be required on **both** the server and the client, for example [Cobblemon](https://modrinth.com/mod/cobblemon).`,
|
||||
- **Client side** refers to a mod that is only required by the client, like [Sodium](https://modrinth.com/mod/sodium).
|
||||
- **Server side** mods change the behavior of the server without the client needing the mod, like Datapacks, recipes, or server-side behaviors, like [Falling Tree](https://modrinth.com/mod/fallingtree).
|
||||
- A mod that adds features, entities, or new blocks and items, generally will be required on **both** the server and the client, for example [Cobblemon](https://modrinth.com/mod/cobblemon).`,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -602,6 +608,7 @@ Per section 5.5 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#m
|
||||
name: "Incorrect additional files",
|
||||
resultingMessage: `## Incorrect Use of Additional Files
|
||||
It looks like you've uploaded multiple \`mod.jar\` files to one Version as Additional Files. Per section 5.7 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) each Version of your project must include only one \`mod.jar\` that corresponds to its respective Minecraft and loader versions. This allows users to easily find and download the file they need for the version they're on with ease. The Additional Files feature can be used for things like a \`Sources.jar\`.
|
||||
|
||||
Please upload each version of your mod separately, thank you.`,
|
||||
},
|
||||
{
|
||||
@@ -629,7 +636,9 @@ It looks like you've selected loaders for your Resource Pack that are causing it
|
||||
name: "Re-upload",
|
||||
resultingMessage: `## Reuploads are forbidden
|
||||
This project appears to contain content from %ORIGINAL_PROJECT% by %ORIGINAL_AUTHOR%.
|
||||
|
||||
Per section 4 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) this is strictly forbidden.
|
||||
|
||||
If you believe this is an error, or you can verify you are the creator and rightful owner of this content please let us know. Otherwise, we ask that you **do not resubmit this project**.`,
|
||||
fillers: [
|
||||
{
|
||||
@@ -847,6 +856,7 @@ async function generateMessage() {
|
||||
for (const mod of mods) {
|
||||
message.value += `- ${mod}\n`;
|
||||
}
|
||||
message.value += "\n";
|
||||
}
|
||||
|
||||
if (modPackData.value && modPackData.value.length > 0) {
|
||||
@@ -913,7 +923,7 @@ async function generateMessage() {
|
||||
permanentNoMods.length > 0 ||
|
||||
unidentifiedMods.length > 0
|
||||
) {
|
||||
message.value += "## Copyrighted Content \n";
|
||||
message.value += "## Copyrighted content \n";
|
||||
|
||||
printMods(
|
||||
attributeMods,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<CompactChart
|
||||
v-if="analytics.formattedData.value.downloads"
|
||||
ref="tinyDownloadChart"
|
||||
:title="`Downloads since ${dayjs(startDate).format('MMM D, YYYY')}`"
|
||||
:title="`Downloads`"
|
||||
color="var(--color-brand)"
|
||||
:value="formatNumber(analytics.formattedData.value.downloads.sum, false)"
|
||||
:data="analytics.formattedData.value.downloads.chart.sumData"
|
||||
@@ -33,7 +33,7 @@
|
||||
<CompactChart
|
||||
v-if="analytics.formattedData.value.views"
|
||||
ref="tinyViewChart"
|
||||
:title="`Page views since ${dayjs(startDate).format('MMM D, YYYY')}`"
|
||||
:title="`Views`"
|
||||
color="var(--color-blue)"
|
||||
:value="formatNumber(analytics.formattedData.value.views.sum, false)"
|
||||
:data="analytics.formattedData.value.views.chart.sumData"
|
||||
@@ -50,7 +50,7 @@
|
||||
<CompactChart
|
||||
v-if="analytics.formattedData.value.revenue"
|
||||
ref="tinyRevenueChart"
|
||||
:title="`Revenue since ${dayjs(startDate).format('MMM D, YYYY')}`"
|
||||
:title="`Revenue`"
|
||||
color="var(--color-purple)"
|
||||
:value="formatMoney(analytics.formattedData.value.revenue.sum, false)"
|
||||
:data="analytics.formattedData.value.revenue.chart.sumData"
|
||||
@@ -71,6 +71,9 @@
|
||||
<span class="label__title">
|
||||
{{ formatCategoryHeader(selectedChart) }}
|
||||
</span>
|
||||
<span class="label__subtitle">
|
||||
{{ formattedCategorySubtitle }}
|
||||
</span>
|
||||
</h2>
|
||||
<div class="chart-controls__buttons">
|
||||
<Button v-tooltip="'Toggle project colors'" icon-only @click="onToggleColors">
|
||||
@@ -83,11 +86,12 @@
|
||||
<UpdatedIcon />
|
||||
</Button>
|
||||
<DropdownSelect
|
||||
class="range-dropdown"
|
||||
v-model="selectedRange"
|
||||
:options="selectableRanges"
|
||||
:options="ranges"
|
||||
name="Time range"
|
||||
:display-name="
|
||||
(o: (typeof selectableRanges)[number] | undefined) => o?.label || 'Custom'
|
||||
(o: RangeObject) => o?.getLabel([startDate, endDate]) ?? 'Loading...'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@@ -322,7 +326,7 @@ const props = withDefaults(
|
||||
* @deprecated Use `ranges` instead
|
||||
*/
|
||||
resoloutions?: Record<string, number>;
|
||||
ranges?: Record<number, [string, number] | string>;
|
||||
ranges?: RangeObject[];
|
||||
personal?: boolean;
|
||||
}>(),
|
||||
{
|
||||
@@ -335,12 +339,6 @@ const props = withDefaults(
|
||||
|
||||
const projects = ref(props.projects || []);
|
||||
|
||||
const selectableRanges = Object.entries(props.ranges).map(([duration, extra]) => ({
|
||||
label: typeof extra === "string" ? extra : extra[0],
|
||||
value: Number(duration),
|
||||
res: typeof extra === "string" ? Number(duration) : extra[1],
|
||||
}));
|
||||
|
||||
// const selectedChart = ref('downloads')
|
||||
const selectedChart = computed({
|
||||
get: () => {
|
||||
@@ -413,33 +411,78 @@ const isUsingProjectColors = computed({
|
||||
},
|
||||
});
|
||||
|
||||
const startDate = ref(dayjs().startOf("day"));
|
||||
const endDate = ref(dayjs().endOf("day"));
|
||||
const timeResolution = ref(30);
|
||||
|
||||
onBeforeMount(() => {
|
||||
// Load cached data and range from localStorage - cache.
|
||||
if (import.meta.client) {
|
||||
const rangeLabel = localStorage.getItem("analyticsSelectedRange");
|
||||
if (rangeLabel) {
|
||||
const range = props.ranges.find((r) => r.getLabel([dayjs(), dayjs()]) === rangeLabel)!;
|
||||
|
||||
if (range !== undefined) {
|
||||
internalRange.value = range;
|
||||
const ranges = range.getDates(dayjs());
|
||||
timeResolution.value = range.timeResolution;
|
||||
startDate.value = ranges.startDate;
|
||||
endDate.value = ranges.endDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (internalRange.value === null) {
|
||||
internalRange.value = props.ranges.find(
|
||||
(r) => r.getLabel([dayjs(), dayjs()]) === "Previous 30 days",
|
||||
)!;
|
||||
}
|
||||
|
||||
const ranges = selectedRange.value.getDates(dayjs());
|
||||
startDate.value = ranges.startDate;
|
||||
endDate.value = ranges.endDate;
|
||||
timeResolution.value = selectedRange.value.timeResolution;
|
||||
});
|
||||
|
||||
const internalRange: Ref<RangeObject> = ref(null as unknown as RangeObject);
|
||||
|
||||
const selectedRange = computed({
|
||||
get: () => {
|
||||
return internalRange.value;
|
||||
},
|
||||
set: (newRange) => {
|
||||
const ranges = newRange.getDates(dayjs());
|
||||
startDate.value = ranges.startDate;
|
||||
endDate.value = ranges.endDate;
|
||||
timeResolution.value = newRange.timeResolution;
|
||||
|
||||
internalRange.value = newRange;
|
||||
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem(
|
||||
"analyticsSelectedRange",
|
||||
internalRange.value?.getLabel([dayjs(), dayjs()]) ?? "Previous 30 days",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const analytics = useFetchAllAnalytics(
|
||||
resetCharts,
|
||||
projects,
|
||||
selectedDisplayProjects,
|
||||
props.personal,
|
||||
startDate,
|
||||
endDate,
|
||||
timeResolution,
|
||||
);
|
||||
|
||||
const { startDate, endDate, timeRange, timeResolution } = analytics;
|
||||
|
||||
const selectedRange = computed({
|
||||
get: () => {
|
||||
return (
|
||||
selectableRanges.find((option) => option.value === timeRange.value) || {
|
||||
label: "Custom",
|
||||
value: timeRange.value,
|
||||
}
|
||||
);
|
||||
},
|
||||
set: (newRange: { label: string; value: number; res?: number }) => {
|
||||
timeRange.value = newRange.value;
|
||||
startDate.value = Date.now() - timeRange.value * 60 * 1000;
|
||||
endDate.value = Date.now();
|
||||
|
||||
if (newRange?.res) {
|
||||
timeResolution.value = newRange.res;
|
||||
}
|
||||
},
|
||||
const formattedCategorySubtitle = computed(() => {
|
||||
return (
|
||||
selectedRange.value?.getLabel([dayjs(startDate.value), dayjs(endDate.value)]) ?? "Loading..."
|
||||
);
|
||||
});
|
||||
|
||||
const selectedDataSet = computed(() => {
|
||||
@@ -484,6 +527,9 @@ const onToggleColors = () => {
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
/**
|
||||
* @deprecated Use `ranges` instead
|
||||
*/
|
||||
const defaultResoloutions: Record<string, number> = {
|
||||
"5 minutes": 5,
|
||||
"30 minutes": 30,
|
||||
@@ -493,17 +539,169 @@ const defaultResoloutions: Record<string, number> = {
|
||||
"A week": 10080,
|
||||
};
|
||||
|
||||
const defaultRanges: Record<number, [string, number] | string> = {
|
||||
30: ["Last 30 minutes", 1],
|
||||
60: ["Last hour", 5],
|
||||
720: ["Last 12 hours", 15],
|
||||
1440: ["Last day", 60],
|
||||
10080: ["Last week", 720],
|
||||
43200: ["Last month", 1440],
|
||||
129600: ["Last quarter", 10080],
|
||||
525600: ["Last year", 20160],
|
||||
1051200: ["Last two years", 40320],
|
||||
type DateRange = { startDate: dayjs.Dayjs; endDate: dayjs.Dayjs };
|
||||
|
||||
type RangeObject = {
|
||||
getLabel: (dateRange: [dayjs.Dayjs, dayjs.Dayjs]) => string;
|
||||
getDates: (currentDate: dayjs.Dayjs) => DateRange;
|
||||
// A time resolution in minutes.
|
||||
timeResolution: number;
|
||||
};
|
||||
|
||||
const defaultRanges: RangeObject[] = [
|
||||
{
|
||||
getLabel: () => "Previous 30 minutes",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(30, "minute"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 1,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Previous hour",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, "hour"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 5,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Previous 12 hours",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(12, "hour"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 12,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Previous 24 hours",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, "day"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 30,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Today",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf("day"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 30,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Yesterday",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, "day").startOf("day"),
|
||||
endDate: dayjs(currentDate).startOf("day").subtract(1, "second"),
|
||||
}),
|
||||
timeResolution: 30,
|
||||
},
|
||||
{
|
||||
getLabel: () => "This week",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf("week").add(1, "hour"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 360,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Last week",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, "week").startOf("week").add(1, "hour"),
|
||||
endDate: dayjs(currentDate).startOf("week").subtract(1, "second"),
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Previous 7 days",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf("day").subtract(7, "day").add(1, "hour"),
|
||||
endDate: currentDate.startOf("day"),
|
||||
}),
|
||||
timeResolution: 720,
|
||||
},
|
||||
{
|
||||
getLabel: () => "This month",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf("month").add(1, "hour"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Last month",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, "month").startOf("month").add(1, "hour"),
|
||||
endDate: dayjs(currentDate).startOf("month").subtract(1, "second"),
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Previous 30 days",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf("day").subtract(30, "day").add(1, "hour"),
|
||||
endDate: currentDate.startOf("day"),
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => "This quarter",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf("quarter").add(1, "hour"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Last quarter",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, "quarter").startOf("quarter").add(1, "hour"),
|
||||
endDate: dayjs(currentDate).startOf("quarter").subtract(1, "second"),
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => "This year",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf("year"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 20160,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Last year",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, "year").startOf("year"),
|
||||
endDate: dayjs(currentDate).startOf("year").subtract(1, "second"),
|
||||
}),
|
||||
timeResolution: 20160,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Previous year",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, "year"),
|
||||
endDate: dayjs(currentDate),
|
||||
}),
|
||||
timeResolution: 40320,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Previous two years",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(2, "year"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 40320,
|
||||
},
|
||||
{
|
||||
getLabel: () => "All Time",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(0),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 40320,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -524,6 +722,20 @@ const defaultRanges: Record<number, [string, number] | string> = {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.label__subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.range-dropdown {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
@@ -688,6 +900,7 @@ const defaultRanges: Record<number, [string, number] | string> = {
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.percentage-bar {
|
||||
grid-area: bar;
|
||||
width: 100%;
|
||||
@@ -696,6 +909,7 @@ const defaultRanges: Record<number, [string, number] | string> = {
|
||||
border: 1px solid var(--color-button-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
|
||||
@@ -35,7 +35,7 @@ defineProps({
|
||||
const viewMode = ref("open");
|
||||
const reports = ref([]);
|
||||
|
||||
let { data: rawReports } = await useAsyncData("report", () => useBaseFetch("report"));
|
||||
let { data: rawReports } = await useAsyncData("report", () => useBaseFetch("report?count=1000"));
|
||||
|
||||
rawReports = rawReports.value.map((report) => {
|
||||
report.item_id = report.item_id.replace(/"/g, "");
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Auto backup</div>
|
||||
<p class="m-0">
|
||||
Automatically create a backup of your server every
|
||||
<strong>{{ autoBackupInterval == 1 ? "hour" : `${autoBackupInterval} hours` }}</strong>
|
||||
Automatically create a backup of your server
|
||||
<strong>{{ backupIntervalsLabel.toLowerCase() }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -22,54 +22,19 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Interval</div>
|
||||
<p class="m-0">
|
||||
The amount of hours between each backup. This will only backup your server if it has
|
||||
been modified since the last backup.
|
||||
The amount of time between each backup. This will only backup your server if it has been
|
||||
modified since the last backup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-contrast">
|
||||
<div
|
||||
class="flex w-fit items-center rounded-xl border border-solid border-button-border bg-table-alternateRow"
|
||||
>
|
||||
<button
|
||||
class="rounded-l-xl p-3 text-secondary enabled:hover:text-contrast [&&]:bg-transparent enabled:[&&]:hover:bg-button-bg"
|
||||
:disabled="!autoBackupEnabled || isSaving"
|
||||
@click="autoBackupInterval = Math.max(autoBackupInterval - 1, 1)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="2" viewBox="-2 0 18 2">
|
||||
<path
|
||||
d="M18,12H6"
|
||||
transform="translate(-5 -11)"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
id="auto-backup-interval"
|
||||
v-model="autoBackupInterval"
|
||||
class="w-16 !appearance-none text-center [&&]:bg-transparent [&&]:focus:shadow-none"
|
||||
type="number"
|
||||
style="-moz-appearance: textfield; appearance: none"
|
||||
min="1"
|
||||
max="24"
|
||||
step="1"
|
||||
:disabled="!autoBackupEnabled || isSaving"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="rounded-r-xl p-3 text-secondary enabled:hover:text-contrast [&&]:bg-transparent enabled:[&&]:hover:bg-button-bg"
|
||||
:disabled="!autoBackupEnabled || isSaving"
|
||||
@click="autoBackupInterval = Math.min(autoBackupInterval + 1, 24)"
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</div>
|
||||
{{ autoBackupInterval == 1 ? "hour" : "hours" }}
|
||||
</div>
|
||||
<UiServersTeleportDropdownMenu
|
||||
:id="'interval-field'"
|
||||
v-model="backupIntervalsLabel"
|
||||
:disabled="!autoBackupEnabled || isSaving"
|
||||
name="interval"
|
||||
:options="Object.keys(backupIntervals)"
|
||||
placeholder="Backup interval"
|
||||
/>
|
||||
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
@@ -92,7 +57,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { PlusIcon, XIcon, SaveIcon } from "@modrinth/assets";
|
||||
import { XIcon, SaveIcon } from "@modrinth/assets";
|
||||
import { ref, computed } from "vue";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
@@ -104,19 +69,25 @@ const modal = ref<InstanceType<typeof NewModal>>();
|
||||
|
||||
const initialSettings = ref<{ interval: number; enabled: boolean } | null>(null);
|
||||
const autoBackupEnabled = ref(false);
|
||||
const autoBackupInterval = ref(6);
|
||||
const isLoadingSettings = ref(true);
|
||||
const isSaving = ref(false);
|
||||
|
||||
const validatedBackupInterval = computed(() => {
|
||||
const roundedValue = Math.round(autoBackupInterval.value);
|
||||
const backupIntervals = {
|
||||
"Every 3 hours": 3,
|
||||
"Every 6 hours": 6,
|
||||
"Every 12 hours": 12,
|
||||
Daily: 24,
|
||||
};
|
||||
|
||||
if (roundedValue < 1) {
|
||||
return 1;
|
||||
} else if (roundedValue > 24) {
|
||||
return 24;
|
||||
}
|
||||
return roundedValue;
|
||||
const backupIntervalsLabel = ref<keyof typeof backupIntervals>("Every 6 hours");
|
||||
|
||||
const autoBackupInterval = computed({
|
||||
get: () => backupIntervals[backupIntervalsLabel.value],
|
||||
set: (value) => {
|
||||
const [label] =
|
||||
Object.entries(backupIntervals).find(([_, interval]) => interval === value) || [];
|
||||
if (label) backupIntervalsLabel.value = label as keyof typeof backupIntervals;
|
||||
},
|
||||
});
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
@@ -124,7 +95,7 @@ const hasChanges = computed(() => {
|
||||
|
||||
return (
|
||||
autoBackupEnabled.value !== initialSettings.value.enabled ||
|
||||
autoBackupInterval.value !== initialSettings.value.interval
|
||||
(initialSettings.value.enabled && autoBackupInterval.value !== initialSettings.value.interval)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -182,10 +153,6 @@ const saveSettings = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
watch(autoBackupInterval, () => {
|
||||
autoBackupInterval.value = validatedBackupInterval.value;
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
show: async () => {
|
||||
await fetchSettings();
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
<template>
|
||||
<NewModal ref="modModal" :header="`Editing ${type.toLocaleLowerCase()} version`">
|
||||
<template #title>
|
||||
<div class="flex min-w-full items-center gap-2 md:w-[calc(420px-5.5rem)]">
|
||||
<UiAvatar :src="modDetails?.icon_url" size="48px" :alt="`${modDetails?.name} Icon`" />
|
||||
<span class="truncate text-xl font-extrabold text-contrast">{{ modDetails?.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-2 md:w-[420px]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<template v-if="versionsLoading">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-fit animate-pulse select-none rounded-md bg-button-bg font-semibold">
|
||||
<span class="opacity-0" aria-hidden="true">{{ type }} version</span>
|
||||
</div>
|
||||
<div class="min-h-[22px] min-w-[140px] animate-pulse rounded-full bg-button-bg" />
|
||||
</div>
|
||||
<div class="min-h-9 w-full animate-pulse rounded-xl bg-button-bg" />
|
||||
<div class="w-fit animate-pulse select-none rounded-md bg-button-bg">
|
||||
<span class="ml-6 opacity-0" aria-hidden="true">
|
||||
Show any beta and alpha releases
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-semibold text-contrast">{{ type }} version</div>
|
||||
<NuxtLink
|
||||
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
|
||||
@click="
|
||||
versionFilter &&
|
||||
(unlockFilterAccordion.isOpen
|
||||
? unlockFilterAccordion.close()
|
||||
: unlockFilterAccordion.open())
|
||||
"
|
||||
>
|
||||
<TagItem
|
||||
v-if="formattedVersions.game_versions.length > 0"
|
||||
v-tooltip="formattedVersions.game_versions.join(', ')"
|
||||
:style="`--_color: var(--color-green)`"
|
||||
>
|
||||
{{ formattedVersions.game_versions[0] }}
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-if="formattedVersions.loaders.length > 0"
|
||||
v-tooltip="formattedVersions.loaders.join(', ')"
|
||||
:style="`--_color: var(--color-platform-${formattedVersions.loaders[0].toLowerCase()})`"
|
||||
>
|
||||
{{ formattedVersions.loaders[0] }}
|
||||
</TagItem>
|
||||
<DropdownIcon
|
||||
:class="[
|
||||
'transition-all duration-200 ease-in-out',
|
||||
{ 'rotate-180': unlockFilterAccordion.isOpen },
|
||||
{ 'opacity-0': !versionFilter },
|
||||
]"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<UiServersTeleportDropdownMenu
|
||||
v-model="selectedVersion"
|
||||
name="Project"
|
||||
:options="filteredVersions"
|
||||
placeholder="No valid versions found"
|
||||
class="!min-w-full"
|
||||
:disabled="filteredVersions.length === 0"
|
||||
:display-name="
|
||||
(version) => (typeof version === 'object' ? version?.version_number : version)
|
||||
"
|
||||
/>
|
||||
<Checkbox v-model="showBetaAlphaReleases"> Show any beta and alpha releases </Checkbox>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<Accordion
|
||||
ref="unlockFilterAccordion"
|
||||
:open-by-default="!versionFilter"
|
||||
:class="[
|
||||
versionFilter ? '' : '!border-solid border-orange bg-bg-orange !text-contrast',
|
||||
'flex flex-col gap-2 rounded-2xl border-2 border-dashed border-divider p-3 transition-all',
|
||||
]"
|
||||
>
|
||||
<p class="m-0 items-center font-bold">
|
||||
<span>
|
||||
{{
|
||||
noCompatibleVersions
|
||||
? `No compatible versions of this ${type.toLowerCase()} were found`
|
||||
: versionFilter
|
||||
? "Game version and platform is provided by the server"
|
||||
: "Incompatible game version and platform versions are unlocked"
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
<p class="m-0 text-sm">
|
||||
{{
|
||||
noCompatibleVersions
|
||||
? `No versions compatible with your server were found. You can still select any available version.`
|
||||
: versionFilter
|
||||
? `Unlocking this filter may allow you to change this ${type.toLowerCase()}
|
||||
to an incompatible version.`
|
||||
: "You might see versions listed that aren't compatible with your server configuration."
|
||||
}}
|
||||
</p>
|
||||
<ContentVersionFilter
|
||||
v-if="currentVersions"
|
||||
ref="filtersRef"
|
||||
:versions="currentVersions"
|
||||
:game-versions="tags.gameVersions"
|
||||
:select-classes="'w-full'"
|
||||
:type="type"
|
||||
:disabled="versionFilter"
|
||||
:platform-tags="tags.loaders"
|
||||
:listed-game-versions="gameVersions"
|
||||
:listed-platforms="platforms"
|
||||
@update:query="updateFiltersFromUi($event)"
|
||||
@vue:mounted="updateFiltersToUi"
|
||||
>
|
||||
<template #platform>
|
||||
<LoaderIcon
|
||||
v-if="filtersRef?.selectedPlatforms.length === 0"
|
||||
:loader="'Vanilla'"
|
||||
class="size-5 flex-none"
|
||||
/>
|
||||
<svg
|
||||
v-else
|
||||
class="size-5 flex-none"
|
||||
v-html="tags.loaders.find((x) => x.name === filtersRef?.selectedPlatforms[0])?.icon"
|
||||
></svg>
|
||||
|
||||
<div class="w-full truncate text-left">
|
||||
{{
|
||||
filtersRef?.selectedPlatforms.length === 0
|
||||
? "All platforms"
|
||||
: filtersRef?.selectedPlatforms.map((x) => formatCategory(x)).join(", ")
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
<template #game-versions>
|
||||
<GameIcon class="size-5 flex-none" />
|
||||
<div class="w-full truncate text-left">
|
||||
{{
|
||||
filtersRef?.selectedGameVersions.length === 0
|
||||
? "All game versions"
|
||||
: filtersRef?.selectedGameVersions.join(", ")
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</ContentVersionFilter>
|
||||
|
||||
<ButtonStyled v-if="!noCompatibleVersions" color-fill="text">
|
||||
<button
|
||||
class="w-full"
|
||||
:disabled="gameVersions.length < 2 && platforms.length < 2"
|
||||
@click="
|
||||
versionFilter = !versionFilter;
|
||||
setInitialFilters();
|
||||
updateFiltersToUi();
|
||||
"
|
||||
>
|
||||
<LockOpenIcon />
|
||||
{{
|
||||
gameVersions.length < 2 && platforms.length < 2
|
||||
? "No other platforms or versions available"
|
||||
: versionFilter
|
||||
? "Unlock"
|
||||
: "Return to compatibility"
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</Accordion>
|
||||
|
||||
<Admonition
|
||||
v-if="versionsError"
|
||||
type="critical"
|
||||
header="Failed to load versions"
|
||||
class="mb-2"
|
||||
>
|
||||
<div>
|
||||
<span>
|
||||
Something went wrong trying to load versions for this {{ type.toLocaleLowerCase() }}.
|
||||
Please try again later or contact support if the issue persists.
|
||||
</span>
|
||||
<LazyUiCopyCode class="!mt-2 !break-all" :text="versionsError" />
|
||||
</div>
|
||||
</Admonition>
|
||||
|
||||
<Admonition
|
||||
v-else-if="props.modPack"
|
||||
type="warning"
|
||||
header="Changing version may cause issues"
|
||||
class="mb-2"
|
||||
>
|
||||
Your server was created using a modpack. It's recommended to use the modpack's version of
|
||||
the mod.
|
||||
<NuxtLink
|
||||
class="mt-2 flex items-center gap-1"
|
||||
:to="`/servers/manage/${props.serverId}/options/loader`"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalIcon class="size-5 flex-none"></ExternalIcon> Modify modpack version
|
||||
</NuxtLink>
|
||||
</Admonition>
|
||||
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:disabled="versionsLoading || selectedVersion.id === modDetails?.version_id"
|
||||
@click="emitChangeModVersion"
|
||||
>
|
||||
<CheckIcon />
|
||||
Install
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="modModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownIcon,
|
||||
XIcon,
|
||||
CheckIcon,
|
||||
LockOpenIcon,
|
||||
GameIcon,
|
||||
ExternalIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { Admonition, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import TagItem from "@modrinth/ui/src/components/base/TagItem.vue";
|
||||
import { ref, computed } from "vue";
|
||||
import { formatCategory, formatVersionsForDisplay, type Version } from "@modrinth/utils";
|
||||
import Accordion from "~/components/ui/Accordion.vue";
|
||||
import Checkbox from "~/components/ui/Checkbox.vue";
|
||||
import ContentVersionFilter, {
|
||||
type ListedGameVersion,
|
||||
type ListedPlatform,
|
||||
} from "~/components/ui/servers/ContentVersionFilter.vue";
|
||||
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
type: "Mod" | "Plugin";
|
||||
loader: string;
|
||||
gameVersion: string;
|
||||
modPack: boolean;
|
||||
serverId: string;
|
||||
}>();
|
||||
|
||||
interface ContentItem extends Mod {
|
||||
changing?: boolean;
|
||||
}
|
||||
|
||||
interface EditVersion extends Version {
|
||||
installed: boolean;
|
||||
upgrade?: boolean;
|
||||
}
|
||||
|
||||
const modModal = ref();
|
||||
const modDetails = ref<ContentItem>();
|
||||
const currentVersions = ref<EditVersion[] | null>(null);
|
||||
const versionsLoading = ref(false);
|
||||
const versionsError = ref("");
|
||||
const showBetaAlphaReleases = ref(false);
|
||||
const unlockFilterAccordion = ref();
|
||||
const versionFilter = ref(true);
|
||||
const tags = useTags();
|
||||
const noCompatibleVersions = ref(false);
|
||||
|
||||
const { pluginLoaders, modLoaders } = tags.value.loaders.reduce(
|
||||
(acc, tag) => {
|
||||
if (tag.supported_project_types.includes("plugin")) {
|
||||
acc.pluginLoaders.push(tag.name);
|
||||
}
|
||||
if (tag.supported_project_types.includes("mod")) {
|
||||
acc.modLoaders.push(tag.name);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ pluginLoaders: [] as string[], modLoaders: [] as string[] },
|
||||
);
|
||||
|
||||
const selectedVersion = ref();
|
||||
const filtersRef: Ref<InstanceType<typeof ContentVersionFilter> | null> = ref(null);
|
||||
interface SelectedContentFilters {
|
||||
selectedGameVersions: string[];
|
||||
selectedPlatforms: string[];
|
||||
}
|
||||
const selectedFilters = ref<SelectedContentFilters>({
|
||||
selectedGameVersions: [],
|
||||
selectedPlatforms: [],
|
||||
});
|
||||
|
||||
const backwardCompatPlatformMap = {
|
||||
purpur: ["purpur", "paper", "spigot", "bukkit"],
|
||||
paper: ["paper", "spigot", "bukkit"],
|
||||
spigot: ["spigot", "bukkit"],
|
||||
};
|
||||
|
||||
const platforms = ref<ListedPlatform[]>([]);
|
||||
const gameVersions = ref<ListedGameVersion[]>([]);
|
||||
const initPlatform = ref<string>("");
|
||||
|
||||
const setInitialFilters = () => {
|
||||
selectedFilters.value = {
|
||||
selectedGameVersions: [
|
||||
gameVersions.value.find((version) => version.name === props.gameVersion)?.name ??
|
||||
gameVersions.value.find((version) => version.release)?.name ??
|
||||
gameVersions.value[0]?.name,
|
||||
],
|
||||
selectedPlatforms: [initPlatform.value],
|
||||
};
|
||||
};
|
||||
|
||||
const updateFiltersToUi = () => {
|
||||
if (!filtersRef.value) return;
|
||||
filtersRef.value.selectedGameVersions = selectedFilters.value.selectedGameVersions;
|
||||
filtersRef.value.selectedPlatforms = selectedFilters.value.selectedPlatforms;
|
||||
|
||||
selectedVersion.value = filteredVersions.value[0];
|
||||
};
|
||||
|
||||
const updateFiltersFromUi = (event: { g: string[]; l: string[] }) => {
|
||||
selectedFilters.value = {
|
||||
selectedGameVersions: event.g,
|
||||
selectedPlatforms: event.l,
|
||||
};
|
||||
};
|
||||
|
||||
const filteredVersions = computed(() => {
|
||||
if (!currentVersions.value) return [];
|
||||
|
||||
const versionsWithoutReleaseFilter = currentVersions.value.filter((version: EditVersion) => {
|
||||
if (version.installed) return true;
|
||||
return (
|
||||
filtersRef.value?.selectedPlatforms.every((platform) =>
|
||||
(
|
||||
backwardCompatPlatformMap[platform as keyof typeof backwardCompatPlatformMap] || [
|
||||
platform,
|
||||
]
|
||||
).some((loader) => version.loaders.includes(loader)),
|
||||
) &&
|
||||
filtersRef.value?.selectedGameVersions.every((gameVersion) =>
|
||||
version.game_versions.includes(gameVersion),
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const versionTypes = new Set(
|
||||
versionsWithoutReleaseFilter.map((v: EditVersion) => v.version_type),
|
||||
);
|
||||
const releaseVersions = versionTypes.has("release");
|
||||
const betaVersions = versionTypes.has("beta");
|
||||
const alphaVersions = versionTypes.has("alpha");
|
||||
|
||||
const versions = versionsWithoutReleaseFilter.filter((version: EditVersion) => {
|
||||
if (showBetaAlphaReleases.value || version.installed) return true;
|
||||
return releaseVersions
|
||||
? version.version_type === "release"
|
||||
: betaVersions
|
||||
? version.version_type === "beta"
|
||||
: alphaVersions
|
||||
? version.version_type === "alpha"
|
||||
: false;
|
||||
});
|
||||
|
||||
return versions.map((version: EditVersion) => {
|
||||
let suffix = "";
|
||||
|
||||
if (version.version_type === "alpha" && releaseVersions && betaVersions) {
|
||||
suffix += " (alpha)";
|
||||
} else if (version.version_type === "beta" && releaseVersions) {
|
||||
suffix += " (beta)";
|
||||
}
|
||||
|
||||
return {
|
||||
...version,
|
||||
version_number: version.version_number + suffix,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const formattedVersions = computed(() => {
|
||||
return {
|
||||
game_versions: formatVersionsForDisplay(
|
||||
selectedVersion.value?.game_versions || [],
|
||||
tags.value.gameVersions,
|
||||
),
|
||||
loaders: (selectedVersion.value?.loaders || [])
|
||||
.sort((firstLoader: string, secondLoader: string) => {
|
||||
const loaderList = backwardCompatPlatformMap[
|
||||
props.loader as keyof typeof backwardCompatPlatformMap
|
||||
] || [props.loader];
|
||||
|
||||
const firstLoaderPosition = loaderList.indexOf(firstLoader.toLowerCase());
|
||||
const secondLoaderPosition = loaderList.indexOf(secondLoader.toLowerCase());
|
||||
|
||||
if (firstLoaderPosition === -1 && secondLoaderPosition === -1) return 0;
|
||||
if (firstLoaderPosition === -1) return 1;
|
||||
if (secondLoaderPosition === -1) return -1;
|
||||
return firstLoaderPosition - secondLoaderPosition;
|
||||
})
|
||||
.map((loader: string) => formatCategory(loader)),
|
||||
};
|
||||
});
|
||||
|
||||
async function show(mod: ContentItem) {
|
||||
versionFilter.value = true;
|
||||
modModal.value.show();
|
||||
versionsLoading.value = true;
|
||||
modDetails.value = mod;
|
||||
versionsError.value = "";
|
||||
currentVersions.value = null;
|
||||
|
||||
try {
|
||||
const result = await useBaseFetch(`project/${mod.project_id}/version`, {}, false);
|
||||
if (
|
||||
Array.isArray(result) &&
|
||||
result.every(
|
||||
(item) =>
|
||||
"id" in item &&
|
||||
"version_number" in item &&
|
||||
"version_type" in item &&
|
||||
"loaders" in item &&
|
||||
"game_versions" in item,
|
||||
)
|
||||
) {
|
||||
currentVersions.value = result as EditVersion[];
|
||||
} else {
|
||||
throw new Error("Invalid version data received.");
|
||||
}
|
||||
|
||||
// find the installed version and move it to the top of the list
|
||||
const currentModIndex = currentVersions.value.findIndex(
|
||||
(item: { id: string }) => item.id === mod.version_id,
|
||||
);
|
||||
if (currentModIndex === -1) {
|
||||
currentVersions.value[currentModIndex] = {
|
||||
...currentVersions.value[currentModIndex],
|
||||
installed: true,
|
||||
version_number: `${mod.version_number} (current) (external)`,
|
||||
};
|
||||
} else {
|
||||
currentVersions.value[currentModIndex].version_number = `${mod.version_number} (current)`;
|
||||
currentVersions.value[currentModIndex].installed = true;
|
||||
}
|
||||
|
||||
// initially filter the platform and game versions for the server config
|
||||
const platformSet = new Set<string>();
|
||||
const gameVersionSet = new Set<string>();
|
||||
for (const version of currentVersions.value) {
|
||||
for (const loader of version.loaders) {
|
||||
platformSet.add(loader);
|
||||
}
|
||||
for (const gameVersion of version.game_versions) {
|
||||
gameVersionSet.add(gameVersion);
|
||||
}
|
||||
}
|
||||
if (gameVersionSet.size > 0) {
|
||||
const filteredGameVersions = tags.value.gameVersions.filter((x) =>
|
||||
gameVersionSet.has(x.version),
|
||||
);
|
||||
|
||||
gameVersions.value = filteredGameVersions.map((x) => ({
|
||||
name: x.version,
|
||||
release: x.version_type === "release",
|
||||
}));
|
||||
}
|
||||
if (platformSet.size > 0) {
|
||||
const tempPlatforms = Array.from(platformSet).map((platform) => ({
|
||||
name: platform,
|
||||
isType:
|
||||
props.type === "Plugin"
|
||||
? pluginLoaders.includes(platform)
|
||||
: props.type === "Mod"
|
||||
? modLoaders.includes(platform)
|
||||
: false,
|
||||
}));
|
||||
platforms.value = tempPlatforms;
|
||||
}
|
||||
|
||||
// set default platform
|
||||
const defaultPlatform = Array.from(platformSet)[0];
|
||||
initPlatform.value = platformSet.has(props.loader)
|
||||
? props.loader
|
||||
: props.loader in backwardCompatPlatformMap
|
||||
? backwardCompatPlatformMap[props.loader as keyof typeof backwardCompatPlatformMap].find(
|
||||
(p) => platformSet.has(p),
|
||||
) || defaultPlatform
|
||||
: defaultPlatform;
|
||||
|
||||
// check if there's nothing compatible with the server config
|
||||
noCompatibleVersions.value =
|
||||
!platforms.value.some((p) => p.isType) ||
|
||||
!gameVersions.value.some((v) => v.name === props.gameVersion);
|
||||
|
||||
if (noCompatibleVersions.value) {
|
||||
unlockFilterAccordion.value.open();
|
||||
versionFilter.value = false;
|
||||
}
|
||||
|
||||
setInitialFilters();
|
||||
versionsLoading.value = false;
|
||||
} catch (error) {
|
||||
console.error("Error loading versions:", error);
|
||||
versionsError.value = error instanceof Error ? error.message : "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
changeVersion: [string];
|
||||
}>();
|
||||
|
||||
function emitChangeModVersion() {
|
||||
if (!selectedVersion.value) return;
|
||||
emit("changeVersion", selectedVersion.value.id.toString());
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide: () => modModal.value.hide(),
|
||||
});
|
||||
</script>
|
||||
172
apps/frontend/src/components/ui/servers/ContentVersionFilter.vue
Normal file
172
apps/frontend/src/components/ui/servers/ContentVersionFilter.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="experimental-styles-within flex w-full flex-col items-center gap-2">
|
||||
<ManySelect
|
||||
v-model="selectedPlatforms"
|
||||
:tooltip="
|
||||
filterOptions.platform.length < 2 && !disabled ? 'No other platforms available' : undefined
|
||||
"
|
||||
:options="filterOptions.platform"
|
||||
:dropdown-id="`${baseId}-platform`"
|
||||
search
|
||||
show-always
|
||||
class="w-full"
|
||||
:disabled="disabled || filterOptions.platform.length < 2"
|
||||
:dropdown-class="'w-full'"
|
||||
@change="updateFilters"
|
||||
>
|
||||
<slot name="platform">
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
Platform
|
||||
</slot>
|
||||
<template #option="{ option }">
|
||||
{{ formatCategory(option) }}
|
||||
</template>
|
||||
<template v-if="hasAnyUnsupportedPlatforms" #footer>
|
||||
<Checkbox
|
||||
v-model="showSupportedPlatformsOnly"
|
||||
class="mx-1"
|
||||
:label="`Show ${type?.toLowerCase()} platforms only`"
|
||||
/>
|
||||
</template>
|
||||
</ManySelect>
|
||||
<ManySelect
|
||||
v-model="selectedGameVersions"
|
||||
:tooltip="
|
||||
filterOptions.gameVersion.length < 2 && !disabled
|
||||
? 'No other game versions available'
|
||||
: undefined
|
||||
"
|
||||
:options="filterOptions.gameVersion"
|
||||
:dropdown-id="`${baseId}-game-version`"
|
||||
search
|
||||
show-always
|
||||
class="w-full"
|
||||
:disabled="disabled || filterOptions.gameVersion.length < 2"
|
||||
:dropdown-class="'w-full'"
|
||||
@change="updateFilters"
|
||||
>
|
||||
<slot name="game-versions">
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
Game versions
|
||||
</slot>
|
||||
<template v-if="hasAnySnapshots" #footer>
|
||||
<Checkbox v-model="showSnapshots" class="mx-1" :label="`Show all versions`" />
|
||||
</template>
|
||||
</ManySelect>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FilterIcon } from "@modrinth/assets";
|
||||
import { type Version, formatCategory, type GameVersionTag } from "@modrinth/utils";
|
||||
import { ref, computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import ManySelect from "@modrinth/ui/src/components/base/ManySelect.vue";
|
||||
import Checkbox from "@modrinth/ui/src/components/base/Checkbox.vue";
|
||||
|
||||
export type ListedGameVersion = {
|
||||
name: string;
|
||||
release: boolean;
|
||||
};
|
||||
|
||||
export type ListedPlatform = {
|
||||
name: string;
|
||||
isType: boolean;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
versions: Version[];
|
||||
gameVersions: GameVersionTag[];
|
||||
listedGameVersions: ListedGameVersion[];
|
||||
listedPlatforms: ListedPlatform[];
|
||||
baseId?: string;
|
||||
type: "Mod" | "Plugin";
|
||||
platformTags: {
|
||||
name: string;
|
||||
supported_project_types: string[];
|
||||
}[];
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["update:query"]);
|
||||
const route = useRoute();
|
||||
|
||||
const showSnapshots = ref(false);
|
||||
const hasAnySnapshots = computed(() => {
|
||||
return props.versions.some((x) =>
|
||||
props.gameVersions.some(
|
||||
(y) => y.version_type !== "release" && x.game_versions.includes(y.version),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const hasOnlySnapshots = computed(() => {
|
||||
return props.versions.every((version) => {
|
||||
return version.game_versions.every((gv) => {
|
||||
const matched = props.gameVersions.find((tag) => tag.version === gv);
|
||||
return matched && matched.version_type !== "release";
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const hasAnyUnsupportedPlatforms = computed(() => {
|
||||
return props.listedPlatforms.some((x) => !x.isType);
|
||||
});
|
||||
|
||||
const hasOnlyUnsupportedPlatforms = computed(() => {
|
||||
return props.listedPlatforms.every((x) => !x.isType);
|
||||
});
|
||||
|
||||
const showSupportedPlatformsOnly = ref(true);
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const filters: Record<"gameVersion" | "platform", string[]> = {
|
||||
gameVersion: [],
|
||||
platform: [],
|
||||
};
|
||||
|
||||
filters.gameVersion = props.listedGameVersions
|
||||
.filter((x) => {
|
||||
return showSnapshots.value || hasOnlySnapshots.value ? true : x.release;
|
||||
})
|
||||
.map((x) => x.name);
|
||||
|
||||
filters.platform = props.listedPlatforms
|
||||
.filter((x) => {
|
||||
return !showSupportedPlatformsOnly.value || hasOnlyUnsupportedPlatforms.value
|
||||
? true
|
||||
: x.isType;
|
||||
})
|
||||
.map((x) => x.name);
|
||||
|
||||
return filters;
|
||||
});
|
||||
|
||||
const selectedGameVersions = ref<string[]>([]);
|
||||
const selectedPlatforms = ref<string[]>([]);
|
||||
|
||||
selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : [];
|
||||
selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : [];
|
||||
|
||||
function updateFilters() {
|
||||
emit("update:query", {
|
||||
g: selectedGameVersions.value,
|
||||
l: selectedPlatforms.value,
|
||||
});
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
selectedGameVersions,
|
||||
selectedPlatforms,
|
||||
});
|
||||
|
||||
function getArrayOrString(x: string | (string | null)[]): string[] {
|
||||
if (typeof x === "string") {
|
||||
return [x];
|
||||
} else {
|
||||
return x.filter((item): item is string => item !== null);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
v-if="isDragging"
|
||||
:class="[
|
||||
'absolute inset-0 flex items-center justify-center rounded-2xl bg-black bg-opacity-50 text-white',
|
||||
overlayClass,
|
||||
]"
|
||||
>
|
||||
<div class="text-center">
|
||||
<UploadIcon class="mx-auto h-16 w-16" />
|
||||
<p class="mt-2 text-xl">
|
||||
Drop {{ type ? type.toLocaleLowerCase() : "file" }}s here to upload
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UploadIcon } from "@modrinth/assets";
|
||||
import { ref } from "vue";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "filesDropped", files: File[]): void;
|
||||
}>();
|
||||
|
||||
defineProps<{
|
||||
overlayClass?: string;
|
||||
type?: string;
|
||||
}>();
|
||||
|
||||
const isDragging = ref(false);
|
||||
const dragCounter = ref(0);
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
if (!event.dataTransfer?.types.includes("application/pyro-file-move")) {
|
||||
dragCounter.value++;
|
||||
isDragging.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
dragCounter.value--;
|
||||
if (dragCounter.value === 0) {
|
||||
isDragging.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDragging.value = false;
|
||||
dragCounter.value = 0;
|
||||
|
||||
const isInternalMove = event.dataTransfer?.types.includes("application/pyro-file-move");
|
||||
if (isInternalMove) return;
|
||||
|
||||
const files = event.dataTransfer?.files;
|
||||
if (files) {
|
||||
emit("filesDropped", Array.from(files));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
306
apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
Normal file
306
apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
|
||||
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
|
||||
<div
|
||||
ref="statusContentRef"
|
||||
:class="['flex flex-col p-4 text-sm text-contrast', $attrs.class]"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 font-bold">
|
||||
<FolderOpenIcon class="size-4" />
|
||||
<span>
|
||||
<span class="capitalize">
|
||||
{{ props.fileType ? props.fileType : "File" }} Uploads
|
||||
</span>
|
||||
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-2">
|
||||
<div
|
||||
v-for="item in uploadQueue"
|
||||
:key="item.file.name"
|
||||
class="flex h-6 items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-2 truncate">
|
||||
<transition-group name="status-icon" mode="out-in">
|
||||
<UiServersPanelSpinner
|
||||
v-show="item.status === 'uploading'"
|
||||
key="spinner"
|
||||
class="absolute !size-4"
|
||||
/>
|
||||
<CheckCircleIcon
|
||||
v-show="item.status === 'completed'"
|
||||
key="check"
|
||||
class="absolute size-4 text-green"
|
||||
/>
|
||||
<XCircleIcon
|
||||
v-show="
|
||||
item.status === 'error' ||
|
||||
item.status === 'cancelled' ||
|
||||
item.status === 'incorrect-type'
|
||||
"
|
||||
key="error"
|
||||
class="absolute size-4 text-red"
|
||||
/>
|
||||
</transition-group>
|
||||
<span class="ml-6 truncate">{{ item.file.name }}</span>
|
||||
<span class="text-secondary">{{ item.size }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[80px] items-center justify-end gap-2">
|
||||
<template v-if="item.status === 'completed'">
|
||||
<span>Done</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error'">
|
||||
<span class="text-red">Failed - File already exists</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'incorrect-type'">
|
||||
<span class="text-red">Failed - Incorrect file type</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="item.status === 'uploading'">
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
|
||||
<button>Cancel</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'cancelled'">
|
||||
<span class="text-red">Cancelled</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
|
||||
interface UploadItem {
|
||||
file: File;
|
||||
progress: number;
|
||||
status: "pending" | "uploading" | "completed" | "error" | "cancelled" | "incorrect-type";
|
||||
size: string;
|
||||
uploader?: any;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
currentPath: string;
|
||||
fileType?: string;
|
||||
marginBottom?: number;
|
||||
acceptedTypes?: Array<string>;
|
||||
fs: FSModule;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "uploadComplete"): void;
|
||||
}>();
|
||||
|
||||
const uploadStatusRef = ref<HTMLElement | null>(null);
|
||||
const statusContentRef = ref<HTMLElement | null>(null);
|
||||
const uploadQueue = ref<UploadItem[]>([]);
|
||||
|
||||
const isUploading = computed(() => uploadQueue.value.length > 0);
|
||||
const activeUploads = computed(() =>
|
||||
uploadQueue.value.filter((item) => item.status === "pending" || item.status === "uploading"),
|
||||
);
|
||||
|
||||
const onUploadStatusEnter = (el: Element) => {
|
||||
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0);
|
||||
(el as HTMLElement).style.height = "0";
|
||||
// eslint-disable-next-line no-void
|
||||
void (el as HTMLElement).offsetHeight;
|
||||
(el as HTMLElement).style.height = `${height}px`;
|
||||
};
|
||||
|
||||
const onUploadStatusLeave = (el: Element) => {
|
||||
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0);
|
||||
(el as HTMLElement).style.height = `${height}px`;
|
||||
// eslint-disable-next-line no-void
|
||||
void (el as HTMLElement).offsetHeight;
|
||||
(el as HTMLElement).style.height = "0";
|
||||
};
|
||||
|
||||
watch(
|
||||
uploadQueue,
|
||||
() => {
|
||||
if (!uploadStatusRef.value) return;
|
||||
const el = uploadStatusRef.value;
|
||||
const itemsHeight = uploadQueue.value.length * 32;
|
||||
const headerHeight = 12;
|
||||
const gap = 8;
|
||||
const padding = 32;
|
||||
const totalHeight = padding + headerHeight + gap + itemsHeight + (props.marginBottom || 0);
|
||||
el.style.height = `${totalHeight}px`;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + " KB";
|
||||
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + " MB";
|
||||
return (bytes / 1024 ** 3).toFixed(1) + " GB";
|
||||
};
|
||||
|
||||
const cancelUpload = (item: UploadItem) => {
|
||||
if (item.uploader && item.status === "uploading") {
|
||||
item.uploader.cancel();
|
||||
item.status = "cancelled";
|
||||
|
||||
setTimeout(async () => {
|
||||
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name);
|
||||
if (index !== -1) {
|
||||
uploadQueue.value.splice(index, 1);
|
||||
await nextTick();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const badFileTypeMsg = "Upload had incorrect file type";
|
||||
const uploadFile = async (file: File) => {
|
||||
const uploadItem: UploadItem = {
|
||||
file,
|
||||
progress: 0,
|
||||
status: "pending",
|
||||
size: formatFileSize(file.size),
|
||||
};
|
||||
|
||||
uploadQueue.value.push(uploadItem);
|
||||
|
||||
try {
|
||||
if (
|
||||
props.acceptedTypes &&
|
||||
!props.acceptedTypes.includes(file.type) &&
|
||||
!props.acceptedTypes.some((type) => file.name.endsWith(type))
|
||||
) {
|
||||
throw new Error(badFileTypeMsg);
|
||||
}
|
||||
|
||||
uploadItem.status = "uploading";
|
||||
const filePath = `${props.currentPath}/${file.name}`.replace("//", "/");
|
||||
const uploader = await props.fs.uploadFile(filePath, file);
|
||||
uploadItem.uploader = uploader;
|
||||
|
||||
if (uploader?.onProgress) {
|
||||
uploader.onProgress(({ progress }: { progress: number }) => {
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1) {
|
||||
uploadQueue.value[index].progress = Math.round(progress);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await uploader?.promise;
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
||||
uploadQueue.value[index].status = "completed";
|
||||
uploadQueue.value[index].progress = 100;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
setTimeout(async () => {
|
||||
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (removeIndex !== -1) {
|
||||
uploadQueue.value.splice(removeIndex, 1);
|
||||
await nextTick();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
emit("uploadComplete");
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
||||
uploadQueue.value[index].status =
|
||||
error instanceof Error && error.message === badFileTypeMsg ? "incorrect-type" : "error";
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (removeIndex !== -1) {
|
||||
uploadQueue.value.splice(removeIndex, 1);
|
||||
await nextTick();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
if (error instanceof Error && error.message !== "Upload cancelled") {
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Upload failed",
|
||||
text: `Failed to upload ${file.name}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
uploadFile,
|
||||
cancelUpload,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-status {
|
||||
overflow: hidden;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-status-enter-active,
|
||||
.upload-status-leave-active {
|
||||
transition: height 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-status-enter-from,
|
||||
.upload-status-leave-to {
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.status-icon-enter-active,
|
||||
.status-icon-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.status-icon-enter-from,
|
||||
.status-icon-leave-to {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.status-icon-enter-to,
|
||||
.status-icon-leave-from {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -61,7 +61,15 @@
|
||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended'"
|
||||
v-if="status === 'suspended' && suspension_reason === 'support'"
|
||||
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-blue p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<HammerIcon />
|
||||
You recently requested support for your server and we are actively working on it. It will be
|
||||
back online shortly.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'"
|
||||
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<UiServersIconsPanelErrorIcon class="!size-5" />
|
||||
@@ -72,7 +80,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon, LockIcon } from "@modrinth/assets";
|
||||
import { ChevronRightIcon, HammerIcon, LockIcon } from "@modrinth/assets";
|
||||
import type { Project, Server } from "~/types/servers";
|
||||
|
||||
const props = defineProps<Partial<Server>>();
|
||||
|
||||
@@ -72,6 +72,8 @@
|
||||
:class="{
|
||||
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
|
||||
'bg-bg-raised': focusedOptionIndex === item.index,
|
||||
'rounded-b-xl': item.index === props.options.length - 1 && !isRenderingUp,
|
||||
'rounded-t-xl': item.index === 0 && isRenderingUp,
|
||||
}"
|
||||
:aria-selected="selectedValue === item.option"
|
||||
@click="selectOption(item.option, item.index)"
|
||||
@@ -225,7 +227,7 @@ const radioValue = computed<OptionValue>({
|
||||
});
|
||||
|
||||
const triggerClasses = computed(() => ({
|
||||
"cursor-not-allowed opacity-50 grayscale": props.disabled,
|
||||
"!cursor-not-allowed opacity-50 grayscale": props.disabled,
|
||||
"rounded-b-none": dropdownVisible.value && !isRenderingUp.value && !props.disabled,
|
||||
"rounded-t-none": dropdownVisible.value && isRenderingUp.value && !props.disabled,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user