refactor: migrate to common eslint+prettier configs (#4168)

* refactor: migrate to common eslint+prettier configs

* fix: prettier frontend

* feat: config changes

* fix: lint issues

* fix: lint

* fix: type imports

* fix: cyclical import issue

* fix: lockfile

* fix: missing dep

* fix: switch to tabs

* fix: continue switch to tabs

* fix: rustfmt parity

* fix: moderation lint issue

* fix: lint issues

* fix: ui intl

* fix: lint issues

* Revert "fix: rustfmt parity"

This reverts commit cb99d2376c321d813d4b7fc7e2a213bb30a54711.

* feat: revert last rs
This commit is contained in:
Cal H.
2025-08-14 21:48:38 +01:00
committed by GitHub
parent 82697278dc
commit 2aabcf36ee
702 changed files with 101360 additions and 102020 deletions

View File

@@ -1,14 +1,16 @@
<template>
<NuxtLayout>
<ModrinthLoadingIndicator />
<NotificationPanel />
<NuxtPage />
</NuxtLayout>
<NuxtLayout>
<ModrinthLoadingIndicator />
<NotificationPanel />
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
import { NotificationPanel, provideNotificationManager } from "@modrinth/ui";
import { FrontendNotificationManager } from "./providers/frontend-notifications.ts";
import ModrinthLoadingIndicator from "~/components/ui/modrinth-loading-indicator.ts";
import { NotificationPanel, provideNotificationManager } from '@modrinth/ui'
provideNotificationManager(new FrontendNotificationManager());
import ModrinthLoadingIndicator from '~/components/ui/modrinth-loading-indicator.ts'
import { FrontendNotificationManager } from './providers/frontend-notifications.ts'
provideNotificationManager(new FrontendNotificationManager())
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,558 +1,558 @@
html {
@extend .dark-mode;
--dark-color-text: #b0bac5;
--dark-color-text-dark: #ecf9fb;
--color-text-secondary: var(--color-icon);
@extend .dark-mode;
--dark-color-text: #b0bac5;
--dark-color-text-dark: #ecf9fb;
--color-text-secondary: var(--color-icon);
}
// TO BE MOVED TO OMORPHIA
:root {
--gap-2: 0.125rem;
--gap-4: calc(2 * var(--gap-2));
--gap-6: calc(3 * var(--gap-2));
--gap-8: calc(2 * var(--gap-4));
--gap-12: calc(3 * var(--gap-4));
--gap-16: calc(2 * var(--gap-8));
--gap-24: calc(3 * var(--gap-8));
--gap-2: 0.125rem;
--gap-4: calc(2 * var(--gap-2));
--gap-6: calc(3 * var(--gap-2));
--gap-8: calc(2 * var(--gap-4));
--gap-12: calc(3 * var(--gap-4));
--gap-16: calc(2 * var(--gap-8));
--gap-24: calc(3 * var(--gap-8));
--radius-card: 1rem;
--radius-max: 999999999px;
--radius-card: 1rem;
--radius-max: 999999999px;
--radius-btn: 0.75rem;
--radius-btn-large: 1rem;
--radius-btn-circle: var(--radius-max);
--radius-btn: 0.75rem;
--radius-btn-large: 1rem;
--radius-btn-circle: var(--radius-max);
--text-14: 0.875rem;
--text-16: 1rem;
--text-18: 1.125rem;
--text-24: 1.5rem;
--text-32: 2rem;
--text-14: 0.875rem;
--text-16: 1rem;
--text-18: 1.125rem;
--text-24: 1.5rem;
--text-32: 2rem;
--weight-normal: 500; // used for general body text
--weight-bold: 600; // used for text needing extra emphasis
--weight-extrabold: 800; // used for main titles and headings
--weight-normal: 500; // used for general body text
--weight-bold: 600; // used for text needing extra emphasis
--weight-extrabold: 800; // used for main titles and headings
--icon-14: 0.875rem; // used for icons inside a small container alongside a label
--icon-16: 1rem; // used for normal-sized icons alongside a label
--icon-20: 1.25rem; // used for icons in normal sized buttons
--icon-24: 1.5rem; // used for icons that are used as a primary label or in large buttons
--icon-32: 2rem;
--icon-14: 0.875rem; // used for icons inside a small container alongside a label
--icon-16: 1rem; // used for normal-sized icons alongside a label
--icon-20: 1.25rem; // used for icons in normal sized buttons
--icon-24: 1.5rem; // used for icons that are used as a primary label or in large buttons
--icon-32: 2rem;
interpolate-size: allow-keywords;
interpolate-size: allow-keywords;
}
.light-mode {
--color-secondary: #6b7280;
--color-icon: var(--color-secondary);
--color-text: hsl(221, 39%, 11%);
--color-text-inactive: hsl(215, 14%, 34%);
--color-text-dark: #1a202c;
--color-heading: #2c313d;
--color-bg: #e5e7eb;
--color-raised-bg: #ffffff;
--color-divider: hsl(220, 13%, 91%);
--color-divider-dark: #c8cdd3;
--color-secondary: #6b7280;
--color-icon: var(--color-secondary);
--color-text: hsl(221, 39%, 11%);
--color-text-inactive: hsl(215, 14%, 34%);
--color-text-dark: #1a202c;
--color-heading: #2c313d;
--color-bg: #e5e7eb;
--color-raised-bg: #ffffff;
--color-divider: hsl(220, 13%, 91%);
--color-divider-dark: #c8cdd3;
--color-text-inverted: var(--color-bg);
--color-bg-inverted: var(--color-text);
--color-text-inverted: var(--color-bg);
--color-bg-inverted: var(--color-text);
--color-brand: var(--color-green);
--color-brand-highlight: rgba(0, 175, 92, 0.25);
--color-brand-shadow: rgba(0, 175, 92, 0.7);
--color-brand-inverted: #ffffff;
--color-brand: var(--color-green);
--color-brand-highlight: rgba(0, 175, 92, 0.25);
--color-brand-shadow: rgba(0, 175, 92, 0.7);
--color-brand-inverted: #ffffff;
--tab-underline-hovered: #e2e8f0;
--tab-underline-hovered: #e2e8f0;
--color-button-bg: hsl(220, 13%, 91%);
--color-button-text: var(--color-text-dark);
--color-button-bg-hover: #d9dce0;
--color-button-text-hover: #1b1e24;
--color-button-bg-active: #c3c6cb;
--color-button-text-active: var(--color-button-text-hover);
--color-button-bg: hsl(220, 13%, 91%);
--color-button-text: var(--color-text-dark);
--color-button-bg-hover: #d9dce0;
--color-button-text-hover: #1b1e24;
--color-button-bg-active: #c3c6cb;
--color-button-text-active: var(--color-button-text-hover);
--color-toggle-handle: var(--color-icon);
--color-toggle-handle: var(--color-icon);
--color-dropdown-bg: var(--color-button-bg);
--color-dropdown-text: var(--color-button-text);
--color-dropdown-bg: var(--color-button-bg);
--color-dropdown-text: var(--color-button-text);
--color-code-bg: var(--color-bg);
--color-code-text: var(--color-text-dark);
--color-code-bg: var(--color-bg);
--color-code-text: var(--color-text-dark);
--color-kbd-shadow: rgba(0, 0, 0, 0.25);
--color-kbd-shadow: rgba(0, 0, 0, 0.25);
--color-ad: #d6e6f9;
--color-ad-raised: #b1c8e4;
--color-ad-contrast: var(--color-text);
--color-ad-highlight: #088cdb;
--color-ad: #d6e6f9;
--color-ad-raised: #b1c8e4;
--color-ad-contrast: var(--color-text);
--color-ad-highlight: #088cdb;
--color-grey-link: var(--color-text);
--color-grey-link-hover: var(--color-heading);
--color-grey-link-active: var(--color-text-dark);
--color-link: #0d60bb;
--color-link-hover: #1a76e7;
--color-link-active: #146fd7;
--color-grey-link: var(--color-text);
--color-grey-link-hover: var(--color-heading);
--color-grey-link-active: var(--color-text-dark);
--color-link: #0d60bb;
--color-link-hover: #1a76e7;
--color-link-active: #146fd7;
--color-warning-bg: hsl(355, 70%, 88%);
--color-warning-text: hsl(342, 70%, 35%);
--color-warning-bg: hsl(355, 70%, 88%);
--color-warning-text: hsl(342, 70%, 35%);
--color-warning-banner-text: hsl(0, 11%, 16%);
--color-warning-banner-bg: hsl(0, 100%, 95%);
--color-warning-banner-side: hsl(357, 78%, 40%);
--color-warning-banner-text: hsl(0, 11%, 16%);
--color-warning-banner-bg: hsl(0, 100%, 95%);
--color-warning-banner-side: hsl(357, 78%, 40%);
--color-info-banner-text: var(--color-text);
--color-info-banner-bg: var(--color-ad);
--color-info-banner-side: var(--color-blue);
--color-info-banner-text: var(--color-text);
--color-info-banner-bg: var(--color-ad);
--color-info-banner-side: var(--color-blue);
--color-block-quote: var(--color-tooltip-bg);
--color-header-underline: var(--color-divider-dark);
--color-hr: var(--color-text);
--color-block-quote: var(--color-tooltip-bg);
--color-header-underline: var(--color-divider-dark);
--color-hr: var(--color-text);
--color-table-border: #dfe2e5;
--color-table-alternate-row: #f2f4f7;
--color-table-border: #dfe2e5;
--color-table-alternate-row: #f2f4f7;
--shadow-inset-lg: inset 0px -2px 2px hsla(221, 39%, 11%, 0.1);
--shadow-inset: inset 0px -2px 2px hsla(221, 39%, 11%, 0.05);
--shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15);
--shadow-inset-lg: inset 0px -2px 2px hsla(221, 39%, 11%, 0.1);
--shadow-inset: inset 0px -2px 2px hsla(221, 39%, 11%, 0.05);
--shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15);
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised:
0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12),
4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09);
--shadow-floating:
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised:
0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12),
4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09);
--shadow-floating:
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
--landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing-light.webp");
--landing-maze-gradient-bg: url("https://cdn.modrinth.com/landing-new/landing-lower-light.webp");
--landing-maze-outer-bg: linear-gradient(180deg, #f0f0f0 0%, #ffffff 100%);
--landing-maze-bg: url('https://cdn.modrinth.com/landing-new/landing-light.webp');
--landing-maze-gradient-bg: url('https://cdn.modrinth.com/landing-new/landing-lower-light.webp');
--landing-maze-outer-bg: linear-gradient(180deg, #f0f0f0 0%, #ffffff 100%);
--landing-color-heading: #000;
--landing-color-subheading: #3a3f45;
--landing-color-heading: #000;
--landing-color-subheading: #3a3f45;
--landing-transition-gradient-start: rgba(255, 255, 255, 0);
--landing-transition-gradient-end: #ffffff;
--landing-hover-card-gradient: radial-gradient(
50% 50% at 50% 50%,
#fff 0%,
rgba(204, 204, 204, 0.77) 100%
);
--landing-border-gradient: linear-gradient(
to bottom right,
rgba(129, 137, 175, 0.75) 0%,
rgba(66, 71, 97, 0.34) 100%
);
--landing-border-color: rgba(129, 137, 175, 0.55);
--landing-creator-gradient: linear-gradient(180deg, #f8f8f8 0%, #f8f8f8 63.19%);
--landing-transition-gradient-start: rgba(255, 255, 255, 0);
--landing-transition-gradient-end: #ffffff;
--landing-hover-card-gradient: radial-gradient(
50% 50% at 50% 50%,
#fff 0%,
rgba(204, 204, 204, 0.77) 100%
);
--landing-border-gradient: linear-gradient(
to bottom right,
rgba(129, 137, 175, 0.75) 0%,
rgba(66, 71, 97, 0.34) 100%
);
--landing-border-color: rgba(129, 137, 175, 0.55);
--landing-creator-gradient: linear-gradient(180deg, #f8f8f8 0%, #f8f8f8 63.19%);
--landing-blob-gradient: radial-gradient(
50% 50% at 50% 50%,
rgba(255, 255, 255, 0.35) 0%,
rgba(255, 255, 255, 0.2695) 100%
);
--landing-blob-shadow:
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(255, 255, 255, 0.45);
--landing-blob-gradient: radial-gradient(
50% 50% at 50% 50%,
rgba(255, 255, 255, 0.35) 0%,
rgba(255, 255, 255, 0.2695) 100%
);
--landing-blob-shadow:
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(255, 255, 255, 0.45);
--landing-card-bg: rgba(255, 255, 255, 0.8);
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
--landing-card-bg: rgba(255, 255, 255, 0.8);
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
--landing-blue-label: #0098ba;
--landing-blue-label-bg: rgba(0, 177, 216, 0.15);
--landing-green-label: #00a936;
--landing-green-label-bg: rgba(0, 216, 69, 0.15);
--landing-blue-label: #0098ba;
--landing-blue-label-bg: rgba(0, 177, 216, 0.15);
--landing-green-label: #00a936;
--landing-green-label-bg: rgba(0, 216, 69, 0.15);
--landing-raw-bg: #fff;
--landing-raw-bg: #fff;
--banner-error-bg: #fee2e2;
--banner-error-text: #991b1b;
--banner-error-border: #ef4444;
--banner-error-bg: #fee2e2;
--banner-error-text: #991b1b;
--banner-error-border: #ef4444;
--banner-warning-bg: #ffedd5;
--banner-warning-text: #713f12;
--banner-warning-border: #f97316;
--banner-warning-bg: #ffedd5;
--banner-warning-text: #713f12;
--banner-warning-border: #f97316;
--banner-info-bg: #dbeafe;
--banner-info-text: #1e3a8a;
--banner-info-border: #3b82f6;
--banner-info-bg: #dbeafe;
--banner-info-text: #1e3a8a;
--banner-info-border: #3b82f6;
}
.dark,
.dark-mode,
.oled-mode,
.retro-mode {
--color-secondary: #96a2b0;
--color-icon: var(--color-secondary);
--color-text: var(--dark-color-text);
--color-text-inactive: #929aa3;
--color-text-dark: var(--dark-color-text-dark);
--color-heading: #c4cfdd;
--color-bg: #16181c;
--color-raised-bg: #26292f;
--color-divider: #474b54;
--color-divider-dark: #646c75;
--color-secondary: #96a2b0;
--color-icon: var(--color-secondary);
--color-text: var(--dark-color-text);
--color-text-inactive: #929aa3;
--color-text-dark: var(--dark-color-text-dark);
--color-heading: #c4cfdd;
--color-bg: #16181c;
--color-raised-bg: #26292f;
--color-divider: #474b54;
--color-divider-dark: #646c75;
--color-text-inverted: var(--color-bg);
--color-bg-inverted: var(--color-text);
--color-text-inverted: var(--color-bg);
--color-bg-inverted: var(--color-text);
--color-brand: var(--color-green);
--color-brand-highlight: rgba(27, 217, 106, 0.25);
--color-brand-shadow: rgba(27, 217, 106, 0.7);
--color-brand-inverted: #000;
--color-brand: var(--color-green);
--color-brand-highlight: rgba(27, 217, 106, 0.25);
--color-brand-shadow: rgba(27, 217, 106, 0.7);
--color-brand-inverted: #000;
--tab-underline-hovered: #414146;
--tab-underline-hovered: #414146;
--color-button-bg: hsl(222, 13%, 30%);
--color-button-text: var(--color-text);
--color-button-bg-hover: #494f58;
--color-button-text-hover: #ffffff;
--color-button-bg-active: #616570;
--color-button-text-active: var(--color-button-text-hover);
--color-button-bg: hsl(222, 13%, 30%);
--color-button-text: var(--color-text);
--color-button-bg-hover: #494f58;
--color-button-text-hover: #ffffff;
--color-button-bg-active: #616570;
--color-button-text-active: var(--color-button-text-hover);
--color-toggle-handle: var(--color-button-text);
--color-toggle-handle: var(--color-button-text);
--color-dropdown-bg: var(--color-button-bg);
--color-dropdown-text: var(--color-button-text);
--color-dropdown-bg: var(--color-button-bg);
--color-dropdown-text: var(--color-button-text);
--color-code-bg: var(--color-button-bg);
--color-code-text: var(--color-text-dark);
--color-code-bg: var(--color-button-bg);
--color-code-text: var(--color-text-dark);
--color-kbd-shadow: rgba(0, 0, 0, 0.35);
--color-kbd-shadow: rgba(0, 0, 0, 0.35);
--color-ad: #1f324a;
--color-ad-raised: #2e4057;
--color-ad-contrast: var(--color-text);
--color-ad-highlight: #088cdb;
--color-ad: #1f324a;
--color-ad-raised: #2e4057;
--color-ad-contrast: var(--color-text);
--color-ad-highlight: #088cdb;
--color-link: #74b6f3;
--color-link-hover: #92c0f5;
--color-link-active: #b5d5fd;
--color-link: #74b6f3;
--color-link-hover: #92c0f5;
--color-link-active: #b5d5fd;
--color-warning-bg: hsl(355, 70%, 20%);
--color-warning-text: hsl(342, 70%, 75%);
--color-warning-bg: hsl(355, 70%, 20%);
--color-warning-text: hsl(342, 70%, 75%);
--color-warning-banner-text: hsl(0, 100%, 96%);
--color-warning-banner-bg: hsl(356, 18%, 18%);
--color-warning-banner-side: hsl(357, 78%, 40%);
--color-warning-banner-text: hsl(0, 100%, 96%);
--color-warning-banner-bg: hsl(356, 18%, 18%);
--color-warning-banner-side: hsl(357, 78%, 40%);
--color-info-banner-text: var(--color-text);
--color-info-banner-bg: var(--color-ad);
--color-info-banner-side: var(--color-blue);
--color-info-banner-text: var(--color-text);
--color-info-banner-bg: var(--color-ad);
--color-info-banner-side: var(--color-blue);
--color-block-quote: var(--color-code-bg);
--color-header-underline: var(--color-divider-dark);
--color-hr: var(--color-text);
--color-block-quote: var(--color-code-bg);
--color-header-underline: var(--color-divider-dark);
--color-hr: var(--color-text);
--color-table-border: #4f5864;
--color-table-alternate-row: #202228;
--color-table-border: #4f5864;
--color-table-alternate-row: #202228;
--shadow-inset-lg: inset 0px -2px 2px hsla(221, 39%, 11%, 0.1);
--shadow-inset: inset 0px -2px 2px hsla(221, 39%, 11%, 0.05);
--shadow-inset-sm: inset 0px -1px 1px hsla(221, 39%, 11%, 0.25);
--shadow-inset-lg: inset 0px -2px 2px hsla(221, 39%, 11%, 0.1);
--shadow-inset: inset 0px -2px 2px hsla(221, 39%, 11%, 0.05);
--shadow-inset-sm: inset 0px -1px 1px hsla(221, 39%, 11%, 0.25);
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
--shadow-floating:
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
--shadow-floating:
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
--landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp");
--landing-maze-gradient-bg:
linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
url("https://cdn.modrinth.com/landing-new/landing-lower.webp");
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
--landing-maze-bg: url('https://cdn.modrinth.com/landing-new/landing.webp');
--landing-maze-gradient-bg:
linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
url('https://cdn.modrinth.com/landing-new/landing-lower.webp');
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
--landing-color-heading: #fff;
--landing-color-subheading: #afb6be;
--landing-color-heading: #fff;
--landing-color-subheading: #afb6be;
--landing-transition-gradient-start: rgba(14, 16, 32, 0);
--landing-transition-gradient-end: #060a1c;
--landing-hover-card-gradient: radial-gradient(
50% 50% at 50% 50%,
#2c304f 0%,
rgba(32, 35, 50, 0.77) 100%
);
--landing-border-gradient: linear-gradient(
to bottom right,
rgba(168, 177, 221, 0.75) 0%,
rgba(168, 177, 221, 0.18) 100%
);
--landing-border-color: rgba(168, 177, 221, 0.55);
--landing-creator-gradient: linear-gradient(180deg, #000000 0%, #0e101d 100%);
--landing-transition-gradient-start: rgba(14, 16, 32, 0);
--landing-transition-gradient-end: #060a1c;
--landing-hover-card-gradient: radial-gradient(
50% 50% at 50% 50%,
#2c304f 0%,
rgba(32, 35, 50, 0.77) 100%
);
--landing-border-gradient: linear-gradient(
to bottom right,
rgba(168, 177, 221, 0.75) 0%,
rgba(168, 177, 221, 0.18) 100%
);
--landing-border-color: rgba(168, 177, 221, 0.55);
--landing-creator-gradient: linear-gradient(180deg, #000000 0%, #0e101d 100%);
--landing-blob-gradient: radial-gradient(
50% 50% at 50% 50%,
rgba(44, 48, 79, 0.35) 0%,
rgba(32, 35, 50, 0.2695) 100%
);
--landing-blob-shadow:
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
--landing-blob-gradient: radial-gradient(
50% 50% at 50% 50%,
rgba(44, 48, 79, 0.35) 0%,
rgba(32, 35, 50, 0.2695) 100%
);
--landing-blob-shadow:
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
--landing-card-bg: rgba(59, 63, 85, 0.15);
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
--landing-card-bg: rgba(59, 63, 85, 0.15);
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
--landing-blue-label: #10c0e7;
--landing-blue-label-bg: rgba(0, 177, 216, 0.15);
--landing-green-label: #00d845;
--landing-green-label-bg: rgba(0, 216, 69, 0.15);
--landing-blue-label: #10c0e7;
--landing-blue-label-bg: rgba(0, 177, 216, 0.15);
--landing-green-label: #00d845;
--landing-green-label-bg: rgba(0, 216, 69, 0.15);
--landing-raw-bg: #000;
--landing-raw-bg: #000;
--hover-filter: brightness(120%);
--active-filter: brightness(140%);
--hover-filter: brightness(120%);
--active-filter: brightness(140%);
--banner-error-bg: #4c1515;
--banner-error-text: #fee2e2;
--banner-error-border: #7f1d1d;
--banner-error-bg: #4c1515;
--banner-error-text: #fee2e2;
--banner-error-border: #7f1d1d;
--banner-warning-bg: #4a2a0a;
--banner-warning-text: #ffe6c0;
--banner-warning-border: #b54708;
--banner-warning-bg: #4a2a0a;
--banner-warning-text: #ffe6c0;
--banner-warning-border: #b54708;
--banner-info-bg: #1e2a44;
--banner-info-text: #dbeafe;
--banner-info-border: #2563eb;
--banner-info-bg: #1e2a44;
--banner-info-text: #dbeafe;
--banner-info-border: #2563eb;
}
.oled-mode {
--color-bg: #000000;
--color-raised-bg: #101013;
--color-bg: #000000;
--color-raised-bg: #101013;
--color-button-bg: #222329;
--color-button-bg-hover: #2d2d32;
--color-button-bg-active: #3c3c40;
--color-table-alternate-row: #19191c;
--color-divider-dark: #2c3134;
--color-button-bg: #222329;
--color-button-bg-hover: #2d2d32;
--color-button-bg-active: #3c3c40;
--color-table-alternate-row: #19191c;
--color-divider-dark: #2c3134;
--color-warning-banner-bg: hsl(0, 45%, 11%);
--color-ad: #0d1828;
--color-warning-banner-bg: hsl(0, 45%, 11%);
--color-ad: #0d1828;
}
.retro-mode {
--color-bg: #191917;
--color-raised-bg: #1d1e1b;
--color-button-bg: #3a3b38;
--color-base: #c3c4b3;
--color-secondary: #777a74;
--color-contrast: #e6e2d1;
--color-bg: #191917;
--color-raised-bg: #1d1e1b;
--color-button-bg: #3a3b38;
--color-base: #c3c4b3;
--color-secondary: #777a74;
--color-contrast: #e6e2d1;
--color-brand: #4d9227;
--color-brand-highlight: #25421e;
--color-ad: var(--color-brand-highlight);
--color-ad-raised: var(--color-brand);
--color-ad-contrast: black;
--color-ad-highlight: var(--color-brand);
--color-brand: #4d9227;
--color-brand-highlight: #25421e;
--color-ad: var(--color-brand-highlight);
--color-ad-raised: var(--color-brand);
--color-ad-contrast: black;
--color-ad-highlight: var(--color-brand);
--color-red: rgb(232, 32, 13);
--color-orange: rgb(232, 141, 13);
--color-green: rgb(60, 219, 54);
--color-blue: rgb(9, 159, 239);
--color-purple: rgb(139, 129, 230);
--color-gray: #718096;
--color-red: rgb(232, 32, 13);
--color-orange: rgb(232, 141, 13);
--color-green: rgb(60, 219, 54);
--color-blue: rgb(9, 159, 239);
--color-purple: rgb(139, 129, 230);
--color-gray: #718096;
--color-red-highlight: rgba(232, 32, 13, 0.25);
--color-orange-highlight: rgba(232, 141, 13, 0.25);
--color-green-highlight: rgba(60, 219, 54, 0.25);
--color-blue-highlight: rgba(9, 159, 239, 0.25);
--color-purple-highlight: rgba(139, 129, 230, 0.25);
--color-gray-highlight: rgba(113, 128, 150, 0.25);
--color-red-highlight: rgba(232, 32, 13, 0.25);
--color-orange-highlight: rgba(232, 141, 13, 0.25);
--color-green-highlight: rgba(60, 219, 54, 0.25);
--color-blue-highlight: rgba(9, 159, 239, 0.25);
--color-purple-highlight: rgba(139, 129, 230, 0.25);
--color-gray-highlight: rgba(113, 128, 150, 0.25);
}
body {
// Defaults
background-color: var(--color-bg);
color: var(--color-text);
--font-standard:
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
font-family: var(--font-standard);
font-size: 16px;
font-weight: var(--font-weight-regular);
margin: 0;
padding: 0;
// Defaults
background-color: var(--color-bg);
color: var(--color-text);
--font-standard:
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
font-family: var(--font-standard);
font-size: 16px;
font-weight: var(--font-weight-regular);
margin: 0;
padding: 0;
// Rounding sizes
--size-rounded-xs: 0.5rem;
--size-rounded-sm: 0.75rem;
--size-rounded-md: 1rem;
--size-rounded-lg: 1.25rem;
// Rounding sizes
--size-rounded-xs: 0.5rem;
--size-rounded-sm: 0.75rem;
--size-rounded-md: 1rem;
--size-rounded-lg: 1.25rem;
--size-rounded-max: 999999999px;
--size-rounded-card: 1rem;
--size-rounded-icon: 1rem;
--size-rounded-control: 0.25rem;
--size-rounded-tooltip: 0.25rem;
--size-rounded-max: 999999999px;
--size-rounded-card: 1rem;
--size-rounded-icon: 1rem;
--size-rounded-control: 0.25rem;
--size-rounded-tooltip: 0.25rem;
--size-navbar-height: 3.5rem;
--size-mobile-navbar-height: 3.5rem;
--size-mobile-navbar-height-expanded: 13.75rem;
--size-navbar-height: 3.5rem;
--size-mobile-navbar-height: 3.5rem;
--size-mobile-navbar-height-expanded: 13.75rem;
--spacing-card-lg: 1.5rem;
--spacing-card-bg: 1rem;
--spacing-card-md: 0.75rem;
--spacing-card-sm: 0.5rem;
--spacing-card-xs: 0.25rem;
--spacing-card-lg: 1.5rem;
--spacing-card-bg: 1rem;
--spacing-card-md: 0.75rem;
--spacing-card-sm: 0.5rem;
--spacing-card-xs: 0.25rem;
// Font Sizes
--font-size-xxs: 0.625rem; //10px
--font-size-xs: 0.75rem; //12px
--font-size-sm: 0.875rem; //14px
--font-size-nm: 1rem; //16px
--font-size-md: 1.125rem; //18px
--font-size-lg: 1.25rem; //20px
--font-size-xl: 1.5rem; //24px
--font-size-2xl: 2rem; //32px
--font-size-3xl: 3rem; //48px
// Font Sizes
--font-size-xxs: 0.625rem; //10px
--font-size-xs: 0.75rem; //12px
--font-size-sm: 0.875rem; //14px
--font-size-nm: 1rem; //16px
--font-size-md: 1.125rem; //18px
--font-size-lg: 1.25rem; //20px
--font-size-xl: 1.5rem; //24px
--font-size-2xl: 2rem; //32px
--font-size-3xl: 3rem; //48px
// Font Weights
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-bold: 700;
--font-weight-extrabold: 800;
// Font Weights
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-bold: 700;
--font-weight-extrabold: 800;
--font-weight-text: var(--font-weight-medium);
--font-weight-heading: var(--font-weight-extrabold);
--font-weight-title: var(--font-weight-extrabold);
--font-weight-text: var(--font-weight-medium);
--font-weight-heading: var(--font-weight-extrabold);
--font-weight-title: var(--font-weight-extrabold);
@media screen and (min-width: 320px) {
--size-mobile-navbar-height-expanded: 11.5rem;
}
@media screen and (min-width: 320px) {
--size-mobile-navbar-height-expanded: 11.5rem;
}
@media screen and (min-width: 432px) {
--size-mobile-navbar-height-expanded: 9.25rem;
}
@media screen and (min-width: 432px) {
--size-mobile-navbar-height-expanded: 9.25rem;
}
@media screen and (min-width: 765px) {
--size-mobile-navbar-height-expanded: 7rem;
}
@media screen and (min-width: 765px) {
--size-mobile-navbar-height-expanded: 7rem;
}
}
svg {
height: 1em;
width: 1em;
height: 1em;
width: 1em;
}
a {
color: inherit;
text-decoration: none;
color: inherit;
text-decoration: none;
}
h1 {
color: var(--color-text-dark);
color: var(--color-text-dark);
}
h2 {
margin-top: 0;
margin-bottom: 1rem;
color: var(--color-text-dark);
margin-top: 0;
margin-bottom: 1rem;
color: var(--color-text-dark);
}
h3 {
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
color: var(--color-text-dark);
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
color: var(--color-text-dark);
}
input {
border-radius: var(--size-rounded-sm);
box-sizing: border-box;
border: 2px solid transparent;
// safari iOS rounds inputs by default
// set the appearance to none to prevent this
appearance: none !important;
border-radius: var(--size-rounded-sm);
box-sizing: border-box;
border: 2px solid transparent;
// safari iOS rounds inputs by default
// set the appearance to none to prevent this
appearance: none !important;
}
pre {
font-weight: var(--font-weight-regular);
font-weight: var(--font-weight-regular);
}
input,
textarea {
background: var(--color-button-bg);
color: var(--color-text);
padding: 0.5rem 1rem;
font-weight: var(--font-weight-medium);
border: none;
outline: 2px solid transparent;
box-shadow:
var(--shadow-inset-sm),
0 0 0 0 transparent;
transition: box-shadow 0.1s ease-in-out;
min-height: 36px;
background: var(--color-button-bg);
color: var(--color-text);
padding: 0.5rem 1rem;
font-weight: var(--font-weight-medium);
border: none;
outline: 2px solid transparent;
box-shadow:
var(--shadow-inset-sm),
0 0 0 0 transparent;
transition: box-shadow 0.1s ease-in-out;
min-height: 36px;
&:focus,
&:focus-visible {
box-shadow:
inset 0 0 0 transparent,
0 0 0 0.25rem var(--color-brand-shadow);
color: var(--color-button-text-active);
}
&:focus,
&:focus-visible {
box-shadow:
inset 0 0 0 transparent,
0 0 0 0.25rem var(--color-brand-shadow);
color: var(--color-button-text-active);
}
&:disabled,
&[disabled="true"] {
opacity: 0.6;
pointer-events: none;
cursor: not-allowed;
}
&:disabled,
&[disabled='true'] {
opacity: 0.6;
pointer-events: none;
cursor: not-allowed;
}
&:focus::placeholder {
opacity: 0.8;
}
&:focus::placeholder {
opacity: 0.8;
}
&::placeholder {
color: var(--color-button-text);
opacity: 0.6;
}
&::placeholder {
color: var(--color-button-text);
opacity: 0.6;
}
}
button,
input[type="button"] {
cursor: pointer;
border: none;
outline: 2px solid transparent;
input[type='button'] {
cursor: pointer;
border: none;
outline: 2px solid transparent;
}
kbd {
background-color: var(--color-code-bg);
color: var(--color-code-text);
box-shadow: 0 2px 1px var(--color-kbd-shadow);
padding: 0.2em 0.5em 0.1em;
border-radius: 3px;
line-height: 1;
font-size: 0.85em !important;
background-color: var(--color-code-bg);
color: var(--color-code-text);
box-shadow: 0 2px 1px var(--color-kbd-shadow);
padding: 0.2em 0.5em 0.1em;
border-radius: 3px;
line-height: 1;
font-size: 0.85em !important;
}
@import "~/assets/styles/layout.scss";
@import "~/assets/styles/utils.scss";
@import "~/assets/styles/components.scss";
@import '~/assets/styles/layout.scss';
@import '~/assets/styles/utils.scss';
@import '~/assets/styles/components.scss';
button:focus-visible,
a:focus-visible,
[tabindex="0"]:focus-visible {
outline: 0.25rem solid #ea80ff;
border-radius: 0.25rem;
[tabindex='0']:focus-visible {
outline: 0.25rem solid #ea80ff;
border-radius: 0.25rem;
}
// OMORPHIA FIXES
.card {
outline-offset: -2px;
outline-offset: -2px;
}
input {
outline: 2px solid transparent !important;
outline: 2px solid transparent !important;
}
.button-animation {
transition:
opacity 0.5s ease-in-out,
filter 0.2s ease-in-out,
transform 0.05s ease-in-out,
outline-width 0.2s ease-in-out;
transition:
opacity 0.5s ease-in-out,
filter 0.2s ease-in-out,
transform 0.05s ease-in-out,
outline-width 0.2s ease-in-out;
}
.button-transparent {
box-shadow: none;
box-shadow: none;
}

View File

@@ -1,227 +1,227 @@
.columns {
display: flex;
display: flex;
@for $i from 1 through 5 {
.column-grow-#{$i} {
flex-grow: $i;
}
}
@for $i from 1 through 5 {
.column-grow-#{$i} {
flex-grow: $i;
}
}
}
.rows {
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
@for $i from 1 through 4 {
.row-grow-#{$i} {
flex-grow: $i;
}
}
@for $i from 1 through 4 {
.row-grow-#{$i} {
flex-grow: $i;
}
}
}
.page-container {
margin: var(--spacing-card-md);
margin-top: 0;
margin: var(--spacing-card-md);
margin-top: 0;
.page-contents {
display: flex;
flex-direction: column;
.content {
width: 100%;
}
@media screen and (min-width: 1024px) {
flex-direction: row;
//max-width: 1280px;
margin-left: auto;
margin-right: auto;
}
}
.page-contents {
display: flex;
flex-direction: column;
.content {
width: 100%;
}
@media screen and (min-width: 1024px) {
flex-direction: row;
//max-width: 1280px;
margin-left: auto;
margin-right: auto;
}
}
}
.normal-page {
display: grid;
padding: 0 1.5rem;
display: grid;
padding: 0 1.5rem;
grid-template:
"sidebar"
"content"
"info"
/ 100%;
grid-template:
'sidebar'
'content'
'info'
/ 100%;
@media screen and (max-width: 1024px) {
margin-top: var(--spacing-card-md);
}
@media screen and (max-width: 1024px) {
margin-top: var(--spacing-card-md);
}
.normal-page__sidebar {
grid-area: sidebar;
}
.normal-page__sidebar {
grid-area: sidebar;
}
.normal-page__info {
grid-area: info;
}
.normal-page__info {
grid-area: info;
}
.normal-page__content {
grid-area: content;
}
.normal-page__content {
grid-area: content;
}
.normal-page__header {
grid-area: header;
}
.normal-page__header {
grid-area: header;
}
}
@media (min-width: 1024px) {
.full-page {
margin: 0 auto;
max-width: min(1280px, 100vw);
width: 80rem;
}
.full-page {
margin: 0 auto;
max-width: min(1280px, 100vw);
width: 80rem;
}
.normal-page {
margin: 0 auto;
max-width: 80rem;
column-gap: 0.75rem;
.normal-page {
margin: 0 auto;
max-width: 80rem;
column-gap: 0.75rem;
grid-template:
"sidebar content" auto
"info content" auto
"dummy content" 1fr
/ 18.75rem 1fr;
grid-template:
'sidebar content' auto
'info content' auto
'dummy content' 1fr
/ 18.75rem 1fr;
&.alt-layout {
grid-template:
"content sidebar" auto
"content info" auto
"content dummy" 1fr
/ 1fr 18.75rem;
}
&.alt-layout {
grid-template:
'content sidebar' auto
'content info' auto
'content dummy' 1fr
/ 1fr 18.75rem;
}
&.no-sidebar {
grid-template:
"header header" auto
"content content" auto
"info info" auto
"dummy dummy" 1fr
/ 1fr 1fr;
&.no-sidebar {
grid-template:
'header header' auto
'content content' auto
'info info' auto
'dummy dummy' 1fr
/ 1fr 1fr;
.normal-page__content {
grid-area: content;
max-width: 100%;
}
}
}
.normal-page__content {
grid-area: content;
max-width: 100%;
}
}
}
.normal-page__sidebar {
min-width: 18.75rem;
width: 18.75rem;
}
.normal-page__sidebar {
min-width: 18.75rem;
width: 18.75rem;
}
.normal-page__content {
max-width: calc(80rem - 18.75rem - 1.5rem);
//overflow-x: hidden;
}
.normal-page__content {
max-width: calc(80rem - 18.75rem - 1.5rem);
//overflow-x: hidden;
}
}
.new-page {
display: grid;
margin: 0 auto;
max-width: 80rem;
column-gap: 0.75rem;
padding: 0 1.5rem;
padding-bottom: 1.5rem;
display: grid;
margin: 0 auto;
max-width: 80rem;
column-gap: 0.75rem;
padding: 0 1.5rem;
padding-bottom: 1.5rem;
grid-template:
"header"
"content"
"sidebar"
/ 100%;
grid-template:
'header'
'content'
'sidebar'
/ 100%;
.normal-page__ultimate-sidebar {
grid-area: ultimate-sidebar;
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 100;
max-width: calc(100% - 2rem);
max-height: calc(100vh - 2rem);
overflow-y: auto;
.normal-page__ultimate-sidebar {
grid-area: ultimate-sidebar;
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 100;
max-width: calc(100% - 2rem);
max-height: calc(100vh - 2rem);
overflow-y: auto;
> div {
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
}
}
> div {
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
}
}
@media screen and (min-width: 1024px) {
&.sidebar {
grid-template:
"header header" auto
"content sidebar" auto
"content dummy" 1fr
/ 1fr 18.75rem;
@media screen and (min-width: 1024px) {
&.sidebar {
grid-template:
'header header' auto
'content sidebar' auto
'content dummy' 1fr
/ 1fr 18.75rem;
&.alt-layout {
grid-template:
"header header" auto
"sidebar content" auto
"dummy content" 1fr
/ 18.75rem 1fr;
}
}
&.alt-layout {
grid-template:
'header header' auto
'sidebar content' auto
'dummy content' 1fr
/ 18.75rem 1fr;
}
}
.normal-page__sidebar {
min-width: 18.75rem;
width: 18.75rem;
}
}
.normal-page__sidebar {
min-width: 18.75rem;
width: 18.75rem;
}
}
@media screen and (min-width: 1400px) {
&.ultimate-sidebar {
max-width: calc(80rem + 0.75rem + 600px);
@media screen and (min-width: 1400px) {
&.ultimate-sidebar {
max-width: calc(80rem + 0.75rem + 600px);
grid-template:
"header header ultimate-sidebar" auto
"content sidebar ultimate-sidebar" auto
"content dummy ultimate-sidebar" 1fr
/ 1fr 18.75rem auto;
grid-template:
'header header ultimate-sidebar' auto
'content sidebar ultimate-sidebar' auto
'content dummy ultimate-sidebar' 1fr
/ 1fr 18.75rem auto;
.normal-page__header {
max-width: 80rem;
}
.normal-page__header {
max-width: 80rem;
}
.normal-page__ultimate-sidebar {
position: sticky;
top: 4.5rem;
bottom: unset;
right: unset;
z-index: unset;
align-self: start;
display: flex;
height: calc(100vh - 4.5rem * 2);
.normal-page__ultimate-sidebar {
position: sticky;
top: 4.5rem;
bottom: unset;
right: unset;
z-index: unset;
align-self: start;
display: flex;
height: calc(100vh - 4.5rem * 2);
> div {
box-shadow: none;
}
}
> div {
box-shadow: none;
}
}
&.alt-layout {
grid-template:
"ultimate-sidebar header header" auto
"ultimate-sidebar sidebar content" auto
"ultimate-sidebar dummy content" 1fr
/ auto 18.75rem 1fr;
}
}
}
&.alt-layout {
grid-template:
'ultimate-sidebar header header' auto
'ultimate-sidebar sidebar content' auto
'ultimate-sidebar dummy content' 1fr
/ auto 18.75rem 1fr;
}
}
}
.normal-page__sidebar {
grid-area: sidebar;
}
.normal-page__sidebar {
grid-area: sidebar;
}
.normal-page__content {
grid-area: content;
max-width: calc(80rem - 18.75rem - 1.5rem);
//overflow-x: hidden;
}
.normal-page__content {
grid-area: content;
max-width: calc(80rem - 18.75rem - 1.5rem);
//overflow-x: hidden;
}
.normal-page__header {
grid-area: header;
}
.normal-page__header {
grid-area: header;
}
}

View File

@@ -5,5 +5,5 @@
h1,
h2,
h3 {
@apply font-bold;
@apply font-bold;
}

View File

@@ -1,10 +1,10 @@
body {
overflow-y: scroll;
overflow-x: hidden;
overflow-y: scroll;
overflow-x: hidden;
}
.text-container p {
line-height: 1.3;
line-height: 1.3;
}
// From the Bootstrap project
@@ -12,20 +12,20 @@ body {
// Copyright (c) 2011-2023 The Bootstrap Authors
// https://github.com/twbs/bootstrap/blob/2f617215755b066904248525a8c56ea425dde871/scss/mixins/_visually-hidden.scss#L8
.visually-hidden {
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
&:not(caption) {
position: absolute !important;
}
&:not(caption) {
position: absolute !important;
}
}
.preserve-lines {
white-space: pre-line;
white-space: pre-line;
}

View File

@@ -1,101 +1,101 @@
<template>
<div>
<svg
class="rotate outer"
width="100%"
height="100%"
viewBox="0 0 590 591"
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
>
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
<g transform="matrix(0.24,0,0,0.24,0,0)">
<path
d="M134.44,316.535C145.027,441.531 249.98,539.829 377.711,539.829C474.219,539.829 557.724,483.712 597.342,402.371L645.949,419.197C599.165,520.543 496.595,590.954 377.711,590.954C221.751,590.954 93.869,469.779 83.161,316.535L134.44,316.535ZM83.946,265.645C99.012,116.762 224.88,0.401 377.711,0.401C540.678,0.401 672.987,132.71 672.987,295.677C672.987,321.817 669.583,347.168 663.194,371.313L614.709,354.529C619.381,335.689 621.862,315.971 621.862,295.677C621.862,160.926 512.461,51.526 377.711,51.526C253.133,51.526 150.223,145.03 135.392,265.645L83.946,265.645Z"
style="fill: var(--color-brand)"
/>
</g>
</g>
</g>
</svg>
<svg
class="rotate inner"
width="100%"
height="100%"
viewBox="0 0 590 591"
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
>
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
<g transform="matrix(0.24,0,0,0.24,0,0)">
<path
d="M376.933,153.568C298.44,153.644 234.735,217.396 234.735,295.909C234.735,374.47 298.516,438.251 377.077,438.251C381.06,438.251 385.005,438.087 388.914,437.764L403.128,487.517C394.611,488.667 385.912,489.261 377.077,489.261C270.363,489.261 183.725,402.623 183.725,295.909C183.725,189.195 270.363,102.557 377.077,102.557C379.723,102.557 382.357,102.611 384.983,102.717L376.933,153.568ZM435.127,111.438C513.515,136.114 570.428,209.418 570.428,295.909C570.428,375.976 521.655,444.742 452.22,474.093L438.063,424.541C486.142,401.687 519.418,352.653 519.418,295.909C519.418,234.923 480.981,182.843 427.029,162.593L435.127,111.438Z"
style="fill: var(--color-brand)"
/>
</g>
</g>
</g>
</svg>
<svg
width="100%"
height="100%"
viewBox="0 0 590 591"
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
>
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
<g transform="matrix(0.24,0,0,0.24,0,0)">
<path
d="M300.366,311.86L283.216,266.381L336.966,211.169L404.9,196.531L424.57,220.74L393.254,252.46L365.941,261.052L346.425,281.11L355.987,307.719L375.387,328.306L402.745,321.031L422.216,299.648L464.729,286.185L477.395,314.677L433.529,368.46L360.02,391.735L327.058,355.031L138.217,468.344C129.245,456.811 118.829,440.485 112.15,424.792L300.366,311.86Z"
style="fill: var(--color-brand)"
/>
</g>
</g>
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
<g transform="matrix(0.24,0,0,0.24,0,0)">
<path
d="M655.189,194.555L505.695,234.873C513.927,256.795 516.638,269.674 518.915,283.863L668.152,243.609C665.764,227.675 661.5,211.444 655.189,194.555Z"
style="fill: var(--color-brand)"
/>
</g>
</g>
</g>
</svg>
</div>
<div>
<svg
class="rotate outer"
width="100%"
height="100%"
viewBox="0 0 590 591"
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
>
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
<g transform="matrix(0.24,0,0,0.24,0,0)">
<path
d="M134.44,316.535C145.027,441.531 249.98,539.829 377.711,539.829C474.219,539.829 557.724,483.712 597.342,402.371L645.949,419.197C599.165,520.543 496.595,590.954 377.711,590.954C221.751,590.954 93.869,469.779 83.161,316.535L134.44,316.535ZM83.946,265.645C99.012,116.762 224.88,0.401 377.711,0.401C540.678,0.401 672.987,132.71 672.987,295.677C672.987,321.817 669.583,347.168 663.194,371.313L614.709,354.529C619.381,335.689 621.862,315.971 621.862,295.677C621.862,160.926 512.461,51.526 377.711,51.526C253.133,51.526 150.223,145.03 135.392,265.645L83.946,265.645Z"
style="fill: var(--color-brand)"
/>
</g>
</g>
</g>
</svg>
<svg
class="rotate inner"
width="100%"
height="100%"
viewBox="0 0 590 591"
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
>
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
<g transform="matrix(0.24,0,0,0.24,0,0)">
<path
d="M376.933,153.568C298.44,153.644 234.735,217.396 234.735,295.909C234.735,374.47 298.516,438.251 377.077,438.251C381.06,438.251 385.005,438.087 388.914,437.764L403.128,487.517C394.611,488.667 385.912,489.261 377.077,489.261C270.363,489.261 183.725,402.623 183.725,295.909C183.725,189.195 270.363,102.557 377.077,102.557C379.723,102.557 382.357,102.611 384.983,102.717L376.933,153.568ZM435.127,111.438C513.515,136.114 570.428,209.418 570.428,295.909C570.428,375.976 521.655,444.742 452.22,474.093L438.063,424.541C486.142,401.687 519.418,352.653 519.418,295.909C519.418,234.923 480.981,182.843 427.029,162.593L435.127,111.438Z"
style="fill: var(--color-brand)"
/>
</g>
</g>
</g>
</svg>
<svg
width="100%"
height="100%"
viewBox="0 0 590 591"
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
>
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
<g transform="matrix(0.24,0,0,0.24,0,0)">
<path
d="M300.366,311.86L283.216,266.381L336.966,211.169L404.9,196.531L424.57,220.74L393.254,252.46L365.941,261.052L346.425,281.11L355.987,307.719L375.387,328.306L402.745,321.031L422.216,299.648L464.729,286.185L477.395,314.677L433.529,368.46L360.02,391.735L327.058,355.031L138.217,468.344C129.245,456.811 118.829,440.485 112.15,424.792L300.366,311.86Z"
style="fill: var(--color-brand)"
/>
</g>
</g>
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
<g transform="matrix(0.24,0,0,0.24,0,0)">
<path
d="M655.189,194.555L505.695,234.873C513.927,256.795 516.638,269.674 518.915,283.863L668.152,243.609C665.764,227.675 661.5,211.444 655.189,194.555Z"
style="fill: var(--color-brand)"
/>
</g>
</g>
</g>
</svg>
</div>
</template>
<style lang="scss" scoped>
div {
display: flex;
justify-content: center;
align-items: center;
height: 5rem;
margin-top: 1rem;
display: flex;
justify-content: center;
align-items: center;
height: 5rem;
margin-top: 1rem;
svg {
width: 5rem;
height: 5rem;
position: absolute;
&.rotate {
animation: rotate 4s infinite linear;
&.inner {
animation: rotate 6s infinite linear reverse;
}
}
@keyframes rotate {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
}
svg {
width: 5rem;
height: 5rem;
position: absolute;
&.rotate {
animation: rotate 4s infinite linear;
&.inner {
animation: rotate 6s infinite linear reverse;
}
}
@keyframes rotate {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
}
}
</style>

View File

@@ -1,97 +1,97 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
fill-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
clip-rule="evenodd"
viewBox="0 0 3307 593"
:class="{ animate: loading }"
>
<!-- modrinth -->
<path
v-if="api === 'prod'"
fill="currentColor"
fill-rule="nonzero"
d="M1053.02 205.51c35.59 0 64.27 10.1 84.98 30.81 20.72 21.25 31.34 52.05 31.34 93.48v162.53h-66.4V338.3c0-24.96-5.3-43.55-16.46-56.3-11.15-12.22-26.55-18.6-47.27-18.6-22.3 0-40.37 7.45-53.65 21.79-13.27 14.87-20.18 36.11-20.18 63.2v143.94h-66.4V338.3c0-24.96-5.3-43.55-16.46-56.3-11.15-12.22-26.56-18.6-47.27-18.6-22.84 0-40.37 7.45-53.65 21.79-13.27 14.34-20.18 35.58-20.18 63.2v143.94h-66.4V208.7h63.21v36.12c10.63-12.75 23.9-22.3 39.84-29.21 15.93-6.9 33.46-10.1 53.11-10.1 21.25 0 40.37 3.72 56.84 11.69 16.46 8.5 29.21 20.18 38.77 35.59 11.69-14.88 26.56-26.56 45.15-35.06 18.59-7.97 38.77-12.22 61.08-12.22Zm329.84 290.54c-28.68 0-54.7-6.37-77.54-18.59a133.19 133.19 0 0 1-53.65-52.05c-13.28-21.78-19.65-46.74-19.65-74.9 0-28.14 6.37-53.1 19.65-74.88a135.4 135.4 0 0 1 53.65-51.53c22.84-12.21 48.86-18.59 77.54-18.59 29.22 0 55.24 6.38 78.08 18.6 22.84 12.21 40.9 29.74 54.18 51.52 12.75 21.77 19.12 46.74 19.12 74.89s-6.37 53.11-19.12 74.89c-13.28 22.3-31.34 39.83-54.18 52.05-22.84 12.22-48.86 18.6-78.08 18.6Zm0-56.83c24.44 0 44.62-7.97 60.55-24.43 15.94-16.47 23.9-37.72 23.9-64.27 0-26.56-7.96-47.8-23.9-64.27-15.93-16.47-36.11-24.43-60.55-24.43-24.43 0-44.61 7.96-60.02 24.43-15.93 16.46-23.9 37.71-23.9 64.27 0 26.55 7.97 47.8 23.9 64.27 15.4 16.46 35.6 24.43 60.02 24.43Zm491.32-341v394.11h-63.74v-36.65a108.02 108.02 0 0 1-40.37 30.28c-16.46 6.9-34 10.1-53.65 10.1-27.08 0-51.52-5.85-73.3-18.07-21.77-12.21-39.3-29.21-51.52-51.52-12.21-21.78-18.59-47.27-18.59-75.95s6.38-54.18 18.6-75.96c12.21-21.77 29.74-38.77 51.52-50.99 21.77-12.21 46.2-18.06 73.3-18.06 18.59 0 36.11 3.2 51.52 9.56a106.35 106.35 0 0 1 39.83 28.69V98.22h66.4Zm-149.79 341c15.94 0 30.28-3.72 43.03-11.16 12.74-6.9 22.83-17.52 30.27-30.8 7.44-13.28 11.15-29.21 11.15-46.74s-3.71-33.46-11.15-46.74c-7.44-13.28-17.53-23.9-30.27-31.34-12.75-6.9-27.1-10.62-43.03-10.62s-30.27 3.71-43.02 10.62c-12.75 7.43-22.84 18.06-30.28 31.34-7.43 13.28-11.15 29.2-11.15 46.74 0 17.53 3.72 33.46 11.15 46.74 7.44 13.28 17.53 23.9 30.28 30.8 12.75 7.44 27.09 11.16 43.02 11.16Zm298.51-189.09c19.12-29.74 52.58-44.62 100.92-44.62v63.21a84.29 84.29 0 0 0-15.4-1.6c-26.03 0-46.22 7.44-60.56 22.32-14.34 15.4-21.78 37.18-21.78 65.33v137.56h-66.39V208.7h63.2v41.43Zm155.63-41.43h66.39v283.63h-66.4V208.7Zm33.46-46.74c-12.22 0-22.31-3.72-30.28-11.68a37.36 37.36 0 0 1-12.21-28.16c0-11.15 4.25-20.71 12.21-28.68 7.97-7.43 18.06-11.15 30.28-11.15 12.21 0 22.3 3.72 30.27 10.62 7.97 7.44 12.22 16.47 12.22 27.62 0 11.69-3.72 21.25-11.69 29.21-7.96 7.97-18.59 12.22-30.8 12.22Zm279.38 43.55c35.59 0 64.27 10.63 86.05 31.34 21.78 20.72 32.4 52.05 32.4 92.95v162.53h-66.4V338.3c0-24.96-5.84-43.55-17.52-56.3-11.69-12.22-28.15-18.6-49.93-18.6-24.43 0-43.55 7.45-57.9 21.79-14.34 14.87-21.24 36.11-21.24 63.73v143.41h-66.4V208.7h63.21v36.65c11.16-13.28 24.97-22.84 41.43-29.74 16.47-6.9 35.59-10.1 56.3-10.1Zm371.81 271.42a78.34 78.34 0 0 1-28.15 14.34 130.83 130.83 0 0 1-35.6 4.78c-31.33 0-55.23-7.97-72.23-24.43-17-16.47-25.5-39.84-25.5-71.17V263.94h-46.73v-53.11h46.74v-64.8h66.4v64.8h75.95v53.11h-75.96v134.91c0 13.81 3.19 24.43 10.1 31.34 6.9 7.44 16.46 11.15 29.2 11.15 14.88 0 27.1-3.71 37.19-11.68l18.59 47.27Zm214.05-271.42c35.59 0 64.27 10.63 86.05 31.34 21.77 20.72 32.4 52.05 32.4 92.95v162.53h-66.4V338.3c0-24.96-5.84-43.55-17.53-56.3-11.68-12.22-28.15-18.6-49.92-18.6-24.44 0-43.56 7.45-57.9 21.79-14.34 14.87-21.24 36.11-21.24 63.73v143.41h-66.4V98.23h66.4v143.4c11.15-11.68 24.43-20.71 40.9-27.09 15.93-5.84 33.99-9.03 53.64-9.03Z"
/>
<!-- staging -->
<path
v-else-if="api === 'staging'"
fill="currentColor"
fill-rule="nonzero"
d="M782.3,488.9c-23.6,0-46.3-3.1-68-9.4s-38.9-13.8-51.6-22.8l25.4-50.9c12.7,8.2,27.9,15,45.5,20.4 c17.6,5.4,35.3,8,52.9,8c20.8,0,35.9-2.9,45.2-8.6c9.3-5.7,14-13.4,14-23c0-7.8-3.2-13.8-9.5-17.9c-6.3-4.1-14.6-7.2-24.9-9.4 c-10.2-2.1-21.6-4.1-34.1-5.9c-12.5-1.8-25-4.2-37.6-7.2c-12.5-3-23.9-7.5-34.1-13.4c-10.2-5.9-18.5-13.8-24.9-23.8 c-6.3-10-9.5-23.2-9.5-39.6c0-18.2,5.1-34,15.3-47.4c10.2-13.4,24.6-23.7,43.1-31.1c18.5-7.3,40.5-11,65.9-11 c19,0,38.3,2.1,57.7,6.4c19.4,4.3,35.4,10.4,48.1,18.2l-25.4,50.9c-13.4-8.2-26.9-13.8-40.5-16.9c-13.6-3-27.1-4.6-40.5-4.6 c-20.1,0-35,3-44.7,9.1c-9.7,6.1-14.5,13.8-14.5,23c0,8.6,3.2,15,9.5,19.3c6.3,4.3,14.6,7.7,24.9,10.2c10.2,2.5,21.6,4.6,34.1,6.2 c12.5,1.6,24.9,4,37.3,7.2c12.3,3.2,23.7,7.6,34.1,13.1c10.4,5.5,18.8,13.3,25.1,23.3c6.3,10,9.5,23,9.5,39.1 c0,17.9-5.2,33.4-15.6,46.6c-10.4,13.2-25.1,23.5-44.2,30.8C831.5,485.2,808.7,488.9,782.3,488.9z M929.9,254.8v-53.6h188.3v53.6 H929.9z M1073.8,488.9c-31,0-55-8.1-71.9-24.4c-16.9-16.2-25.4-40.3-25.4-72V135.9h66.1v254.9c0,13.6,3.4,24.1,10.3,31.6 c6.9,7.5,16.5,11.2,28.8,11.2c14.8,0,27.2-3.9,37-11.8l18.5,47.7c-7.8,6.4-17.3,11.2-28.6,14.5 C1097.4,487.3,1085.8,488.9,1073.8,488.9z M1276.9,488.9c-21.2,0-39.7-3.7-55.5-11c-15.9-7.3-28.1-17.5-36.8-30.5 c-8.6-13-13-27.8-13-44.2c0-16.1,3.8-30.5,11.4-43.4c7.6-12.9,20-23,37.3-30.5c17.3-7.5,40.2-11.2,68.8-11.2h82v44.5h-77.2 c-22.6,0-37.7,3.7-45.5,11c-7.8,7.3-11.6,16.3-11.6,27c0,12.1,4.8,21.8,14.3,28.9c9.5,7.1,22.7,10.7,39.7,10.7 c16.2,0,30.8-3.7,43.6-11.2c12.9-7.5,22.1-18.6,27.8-33.2l11.1,40.2c-6.3,16.8-17.7,29.8-34.1,39.1 C1322.7,484.2,1302,488.9,1276.9,488.9z M1365.8,485.1v-57.8l-3.7-12.3V313.7c0-19.6-5.8-34.9-17.5-45.8 c-11.6-10.9-29.3-16.3-52.9-16.3c-15.9,0-31.5,2.5-46.8,7.5c-15.3,5-28.3,12-38.9,20.9l-25.9-48.7c15.2-11.8,33.2-20.6,54.2-26.5 c21-5.9,42.8-8.8,65.3-8.8c40.9,0,72.6,9.9,95,29.7c22.4,19.8,33.6,50.4,33.6,91.9v167.6H1365.8z M1633,472.3 c-26.8,0-51.1-5.8-72.7-17.4c-21.7-11.6-38.8-27.8-51.3-48.5c-12.5-20.7-18.8-45-18.8-72.8c0-27.5,6.3-51.6,18.8-72.3 c12.5-20.7,29.6-36.8,51.3-48.2c21.7-11.4,45.9-17.1,72.7-17.1c24,0,45.5,4.8,64.5,14.5s34.3,24.6,45.8,45 c11.5,20.4,17.2,46.4,17.2,78.2c0,31.8-5.7,57.9-17.2,78.5c-11.5,20.5-26.7,35.7-45.8,45.5C1678.4,467.4,1656.9,472.3,1633,472.3z M1641.4,592.8c-26.1,0-51.6-3.5-76.4-10.4c-24.9-7-45.2-17.1-61.1-30.3l29.6-50.3c12.3,10.4,27.9,18.7,46.8,24.9 c18.9,6.2,38,9.4,57.4,9.4c31,0,53.8-7.2,68.2-21.7c14.5-14.5,21.7-36.2,21.7-65.1v-50.9l5.3-64.8l-2.1-64.8v-69.6h63v242.6 c0,51.8-13.1,89.9-39.1,114.3C1728.5,580.5,1690.8,592.8,1641.4,592.8z M1643,415.5c16.6,0,31.4-3.5,44.4-10.4 c13-7,23.2-16.6,30.4-28.9c7.2-12.3,10.8-26.5,10.8-42.6c0-16.1-3.6-30.3-10.8-42.6c-7.2-12.3-17.4-21.8-30.4-28.4 c-13.1-6.6-27.9-9.9-44.4-9.9c-16.6,0-31.5,3.3-44.7,9.9c-13.2,6.6-23.5,16.1-30.7,28.4c-7.2,12.3-10.8,26.5-10.8,42.6 c0,16.1,3.6,30.3,10.8,42.6c7.2,12.3,17.5,22,30.7,28.9S1626.4,415.5,1643,415.5z M1913.9,152c-12.3,0-22.5-3.9-30.4-11.8 c-7.9-7.8-11.9-17.3-11.9-28.4c0-11.4,4-21,11.9-28.7c7.9-7.7,18.1-11.5,30.4-11.5c12.3,0,22.5,3.7,30.4,11 c7.9,7.3,11.9,16.5,11.9,27.6c0,11.8-3.9,21.7-11.6,29.7C1936.8,148,1926.6,152,1913.9,152z M1880.5,485.1v-286h66.1v286H1880.5z M2193.7,195.9c22.6,0,42.8,4.5,60.6,13.4c17.8,8.9,31.8,22.6,42.1,41c10.2,18.4,15.3,42,15.3,71v163.9h-66.1V329.8 c0-25.3-5.9-44.3-17.7-56.8c-11.8-12.5-28.3-18.7-49.5-18.7c-15.5,0-29.3,3.2-41.3,9.6c-12,6.4-21.3,16-27.8,28.7 c-6.5,12.7-9.8,28.7-9.8,47.9v144.6h-66.1v-286h63v77.1l-11.1-23.6c9.9-18.2,24.2-32.2,43.1-42 C2147.2,200.8,2169,195.9,2193.7,195.9z M2515.9,472.3c-26.8,0-51-5.8-72.7-17.4c-21.7-11.6-38.8-27.8-51.3-48.5 c-12.5-20.7-18.8-45-18.8-72.8c0-27.5,6.3-51.6,18.8-72.3c12.5-20.7,29.6-36.8,51.3-48.2c21.7-11.4,45.9-17.1,72.7-17.1 c24,0,45.5,4.8,64.5,14.5s34.3,24.6,45.8,45c11.5,20.4,17.2,46.4,17.2,78.2c0,31.8-5.7,57.9-17.2,78.5 c-11.5,20.5-26.7,35.7-45.8,45.5C2561.4,467.4,2539.9,472.3,2515.9,472.3z M2524.3,592.8c-26.1,0-51.6-3.5-76.4-10.4 c-24.9-7-45.2-17.1-61.1-30.3l29.6-50.3c12.3,10.4,27.9,18.7,46.8,24.9c18.9,6.2,38,9.4,57.4,9.4c31,0,53.8-7.2,68.2-21.7 c14.5-14.5,21.7-36.2,21.7-65.1v-50.9l5.3-64.8l-2.1-64.8v-69.6h63v242.6c0,51.8-13.1,89.9-39.1,114.3 C2611.4,580.5,2573.7,592.8,2524.3,592.8z M2525.9,415.5c16.6,0,31.4-3.5,44.4-10.4c13-7,23.2-16.6,30.4-28.9 c7.2-12.3,10.8-26.5,10.8-42.6c0-16.1-3.6-30.3-10.8-42.6c-7.2-12.3-17.4-21.8-30.4-28.4c-13.1-6.6-27.9-9.9-44.4-9.9 c-16.6,0-31.5,3.3-44.7,9.9c-13.2,6.6-23.5,16.1-30.7,28.4c-7.2,12.3-10.8,26.5-10.8,42.6c0,16.1,3.6,30.3,10.8,42.6 c7.2,12.3,17.5,22,30.7,28.9S2509.3,415.5,2525.9,415.5z"
/>
<!-- localhost -->
<path
v-else-if="api === 'localhost'"
fill="currentColor"
fill-rule="nonzero"
d="M695,492.3V94.9h66.1v397.4H695z M974.9,496.1c-28.9,0-54.7-6.3-77.2-19c-22.6-12.7-40.4-30.1-53.4-52.2 c-13.1-22.1-19.6-47.3-19.6-75.5c0-28.6,6.5-53.8,19.6-75.8c13-22,30.9-39.2,53.4-51.7c22.6-12.5,48.3-18.7,77.2-18.7 c29.3,0,55.3,6.3,78,18.7c22.7,12.5,40.6,29.6,53.4,51.4c12.9,21.8,19.3,47.1,19.3,76.1c0,28.2-6.4,53.4-19.3,75.5 c-12.9,22.1-30.7,39.6-53.4,52.2C1030.1,489.7,1004.1,496.1,974.9,496.1z M974.9,438.8c16.2,0,30.7-3.6,43.4-10.7 c12.7-7.1,22.7-17.5,29.9-31.1c7.2-13.6,10.8-29.5,10.8-47.7c0-18.6-3.6-34.5-10.8-47.9c-7.2-13.4-17.2-23.6-29.9-30.8 c-12.7-7.1-27-10.7-42.9-10.7c-16.2,0-30.6,3.6-43.1,10.7c-12.5,7.1-22.5,17.4-29.9,30.8s-11.1,29.4-11.1,47.9 c0,18.2,3.7,34.1,11.1,47.7c7.4,13.6,17.4,23.9,29.9,31.1C944.8,435.2,959,438.8,974.9,438.8z M1318.7,496.1c-29.6,0-56-6.3-79.1-19 c-23.1-12.7-41.2-30.1-54.2-52.2c-13.1-22.1-19.6-47.3-19.6-75.5c0-28.6,6.5-53.8,19.6-75.8c13-22,31.1-39.2,54.2-51.7 c23.1-12.5,49.5-18.7,79.1-18.7c27.5,0,51.8,5.6,72.7,16.9c21,11.2,36.9,27.8,47.9,49.5l-50.8,30c-8.5-13.6-18.8-23.6-30.9-30 s-25.3-9.6-39.4-9.6c-16.2,0-30.9,3.6-43.9,10.7c-13.1,7.1-23.3,17.4-30.7,30.8s-11.1,29.4-11.1,47.9c0,18.6,3.7,34.5,11.1,47.9 c7.4,13.4,17.6,23.7,30.7,30.8c13,7.1,27.7,10.7,43.9,10.7c14.1,0,27.2-3.2,39.4-9.6s22.5-16.4,30.9-30l50.8,30 c-10.9,21.4-26.9,37.9-47.9,49.5C1370.5,490.3,1346.2,496.1,1318.7,496.1z M1581.6,496.1c-21.2,0-39.7-3.7-55.5-11 c-15.9-7.3-28.1-17.5-36.8-30.5c-8.6-13-13-27.8-13-44.2c0-16.1,3.8-30.5,11.4-43.4c7.6-12.9,20-23,37.3-30.5 c17.3-7.5,40.2-11.2,68.8-11.2h82v44.5h-77.2c-22.6,0-37.7,3.7-45.5,11c-7.8,7.3-11.6,16.3-11.6,27c0,12.1,4.8,21.8,14.3,28.9 c9.5,7.1,22.7,10.7,39.7,10.7c16.2,0,30.8-3.7,43.6-11.2c12.9-7.5,22.1-18.6,27.8-33.2l11.1,40.2c-6.3,16.8-17.7,29.8-34.1,39.1 C1627.4,491.4,1606.7,496.1,1581.6,496.1z M1670.5,492.3v-57.8l-3.7-12.3V320.9c0-19.6-5.8-34.9-17.5-45.8 c-11.6-10.9-29.3-16.3-52.9-16.3c-15.9,0-31.5,2.5-46.8,7.5c-15.3,5-28.3,12-38.9,20.9l-25.9-48.7c15.2-11.8,33.2-20.6,54.2-26.5 c21-5.9,42.8-8.8,65.3-8.8c40.9,0,72.6,9.9,95,29.7c22.4,19.8,33.6,50.4,33.6,91.9v167.6H1670.5z M1817.6,492.3V94.9h66.1v397.4 H1817.6z M2130.7,203.1c22.6,0,42.8,4.5,60.6,13.4c17.8,8.9,31.8,22.6,42.1,41c10.2,18.4,15.3,42,15.3,71v163.9h-66.1V337 c0-25.3-5.9-44.3-17.7-56.8c-11.8-12.5-28.3-18.7-49.5-18.7c-15.5,0-29.3,3.2-41.3,9.6c-12,6.4-21.3,16-27.8,28.7 c-6.5,12.7-9.8,28.7-9.8,47.9v144.6h-66.1V94.9h66.1v188.5l-14.3-23.6c9.9-18.2,24.2-32.2,43.1-42 C2084.3,208,2106.1,203.1,2130.7,203.1z M2460.3,496.1c-28.9,0-54.7-6.3-77.2-19c-22.6-12.7-40.4-30.1-53.4-52.2 c-13.1-22.1-19.6-47.3-19.6-75.5c0-28.6,6.5-53.8,19.6-75.8c13-22,30.9-39.2,53.4-51.7c22.6-12.5,48.3-18.7,77.2-18.7 c29.3,0,55.3,6.3,78,18.7c22.7,12.5,40.6,29.6,53.4,51.4c12.9,21.8,19.3,47.1,19.3,76.1c0,28.2-6.4,53.4-19.3,75.5 c-12.9,22.1-30.7,39.6-53.4,52.2C2515.6,489.7,2489.6,496.1,2460.3,496.1z M2460.3,438.8c16.2,0,30.7-3.6,43.4-10.7 c12.7-7.1,22.7-17.5,29.9-31.1c7.2-13.6,10.8-29.5,10.8-47.7c0-18.6-3.6-34.5-10.8-47.9c-7.2-13.4-17.2-23.6-29.9-30.8 c-12.7-7.1-27-10.7-42.9-10.7c-16.2,0-30.6,3.6-43.1,10.7c-12.5,7.1-22.5,17.4-29.9,30.8s-11.1,29.4-11.1,47.9 c0,18.2,3.7,34.1,11.1,47.7c7.4,13.6,17.4,23.9,29.9,31.1C2430.3,435.2,2444.5,438.8,2460.3,438.8z M2761.9,496.1 c-23.6,0-46.3-3.1-68-9.4c-21.7-6.2-38.9-13.8-51.6-22.8l25.4-50.9c12.7,8.2,27.9,15,45.5,20.4c17.6,5.4,35.3,8,52.9,8 c20.8,0,35.9-2.9,45.2-8.6c9.3-5.7,14-13.4,14-23c0-7.8-3.2-13.8-9.5-17.9c-6.3-4.1-14.6-7.2-24.9-9.4c-10.2-2.1-21.6-4.1-34.1-5.9 c-12.5-1.8-25-4.2-37.6-7.2c-12.5-3-23.9-7.5-34.1-13.4c-10.2-5.9-18.5-13.8-24.9-23.8c-6.3-10-9.5-23.2-9.5-39.6 c0-18.2,5.1-34,15.3-47.4c10.2-13.4,24.6-23.7,43.1-31.1c18.5-7.3,40.5-11,65.9-11c19,0,38.3,2.1,57.7,6.4 c19.4,4.3,35.4,10.4,48.1,18.2l-25.4,50.9c-13.4-8.2-26.9-13.8-40.5-16.9c-13.6-3-27.1-4.6-40.5-4.6c-20.1,0-35,3-44.7,9.1 c-9.7,6.1-14.5,13.8-14.5,23c0,8.6,3.2,15,9.5,19.3c6.3,4.3,14.6,7.7,24.9,10.2c10.2,2.5,21.6,4.6,34.1,6.2 c12.5,1.6,24.9,4,37.3,7.2c12.3,3.2,23.7,7.6,34.1,13.1c10.4,5.5,18.8,13.3,25.1,23.3c6.3,10,9.5,23,9.5,39.1 c0,17.9-5.2,33.4-15.6,46.6c-10.4,13.2-25.1,23.5-44.2,30.8S2788.3,496.1,2761.9,496.1z M2909.5,262v-53.6h188.3V262H2909.5z M3053.4,496.1c-31,0-55-8.1-71.9-24.4c-16.9-16.2-25.4-40.3-25.4-72V143.1h66.1V398c0,13.6,3.4,24.1,10.3,31.6 c6.9,7.5,16.5,11.2,28.8,11.2c14.8,0,27.2-3.9,37-11.8l18.5,47.7c-7.8,6.4-17.3,11.2-28.6,14.5 C3077,494.5,3065.3,496.1,3053.4,496.1z"
/>
<!-- foreign -->
<path
v-else
fill="currentColor"
fill-rule="nonzero"
d="M657.4,254.8v-53.6h188.3v53.6H657.4z M704,485.1V183c0-30,8.7-54,26.2-72c17.5-18,42.4-27,74.9-27 c11.6,0,22.7,1.3,33.1,3.7c10.4,2.5,19.1,6.4,26.2,11.8l-18,50.3c-4.9-3.9-10.6-6.9-16.9-8.8c-6.3-2-12.9-2.9-19.6-2.9 c-13.8,0-24.2,3.8-31.2,11.5c-7.1,7.7-10.6,19.2-10.6,34.5v32.1l2.1,30v238.9H704z M1012.9,488.9c-28.9,0-54.7-6.3-77.2-19 c-22.6-12.7-40.4-30.1-53.4-52.2c-13.1-22.1-19.6-47.3-19.6-75.5c0-28.6,6.5-53.8,19.6-75.8c13-22,30.9-39.2,53.4-51.7 c22.6-12.5,48.3-18.7,77.2-18.7c29.3,0,55.3,6.3,78,18.7c22.7,12.5,40.6,29.6,53.4,51.4c12.9,21.8,19.3,47.1,19.3,76.1 c0,28.2-6.4,53.4-19.3,75.5c-12.9,22.1-30.7,39.6-53.4,52.2C1068.2,482.5,1042.2,488.9,1012.9,488.9z M1012.9,431.6 c16.2,0,30.7-3.6,43.4-10.7c12.7-7.1,22.7-17.5,29.9-31.1c7.2-13.6,10.8-29.5,10.8-47.7c0-18.6-3.6-34.5-10.8-47.9 c-7.2-13.4-17.2-23.6-29.9-30.8c-12.7-7.1-27-10.7-42.9-10.7c-16.2,0-30.6,3.6-43.1,10.7c-12.5,7.1-22.5,17.4-29.9,30.8 s-11.1,29.4-11.1,47.9c0,18.2,3.7,34.1,11.1,47.7c7.4,13.6,17.4,23.9,29.9,31.1C982.9,428,997.1,431.6,1012.9,431.6z M1227.2,485.1 v-286h63v78.7l-7.4-23c8.5-19.3,21.8-33.9,39.9-43.9c18.2-10,40.8-15,68-15v63.7c-2.8-0.7-5.5-1.2-7.9-1.3c-2.5-0.2-4.9-0.3-7.4-0.3 c-25,0-45,7.4-59.8,22.2c-14.8,14.8-22.2,36.9-22.2,66.1v138.7H1227.2z M1576.9,488.9c-31.4,0-58.8-6.3-82.3-19 c-23.5-12.7-41.6-30.1-54.5-52.2c-12.9-22.1-19.3-47.3-19.3-75.5c0-28.6,6.3-53.8,18.8-75.8c12.5-22,29.8-39.2,51.8-51.7 c22-12.5,47.2-18.7,75.4-18.7c27.5,0,52,6.1,73.5,18.2c21.5,12.1,38.4,29.3,50.8,51.4c12.3,22.1,18.5,48.2,18.5,78.2 c0,2.9-0.1,6.1-0.3,9.6c-0.2,3.6-0.4,7-0.8,10.2h-235.4v-44.5h200.5l-25.9,13.9c0.3-16.4-3-30.9-10.1-43.4 c-7.1-12.5-16.7-22.3-28.8-29.5c-12.2-7.1-26.2-10.7-42.1-10.7c-16.2,0-30.4,3.6-42.6,10.7c-12.2,7.1-21.7,17.1-28.6,29.7 c-6.9,12.7-10.3,27.6-10.3,44.7v10.7c0,17.1,3.9,32.3,11.6,45.5c7.8,13.2,18.7,23.4,32.8,30.5c14.1,7.1,30.3,10.7,48.7,10.7 c15.9,0,30.2-2.5,42.9-7.5c12.7-5,24-12.9,33.9-23.6l35.4,41.2c-12.7,15-28.7,26.5-47.9,34.5 C1623.5,484.9,1601.6,488.9,1576.9,488.9z M1806.5,152c-12.3,0-22.5-3.9-30.4-11.8c-7.9-7.8-11.9-17.3-11.9-28.4 c0-11.4,4-21,11.9-28.7c7.9-7.7,18.1-11.5,30.4-11.5c12.3,0,22.5,3.7,30.4,11c7.9,7.3,11.9,16.5,11.9,27.6 c0,11.8-3.9,21.7-11.6,29.7C1829.4,148,1819.2,152,1806.5,152z M1773.1,485.1v-286h66.1v286H1773.1z M2045.6,472.3 c-26.8,0-51-5.8-72.7-17.4c-21.7-11.6-38.8-27.8-51.3-48.5c-12.5-20.7-18.8-45-18.8-72.8c0-27.5,6.3-51.6,18.8-72.3 c12.5-20.7,29.6-36.8,51.3-48.2c21.7-11.4,45.9-17.1,72.7-17.1c24,0,45.5,4.8,64.5,14.5s34.3,24.6,45.8,45 c11.5,20.4,17.2,46.4,17.2,78.2c0,31.8-5.7,57.9-17.2,78.5c-11.5,20.5-26.7,35.7-45.8,45.5C2091.1,467.4,2069.6,472.3,2045.6,472.3z M2054,592.8c-26.1,0-51.6-3.5-76.4-10.4c-24.9-7-45.2-17.1-61.1-30.3l29.6-50.3c12.3,10.4,27.9,18.7,46.8,24.9 c18.9,6.2,38,9.4,57.4,9.4c31,0,53.8-7.2,68.2-21.7c14.5-14.5,21.7-36.2,21.7-65.1v-50.9l5.3-64.8l-2.1-64.8v-69.6h63v242.6 c0,51.8-13.1,89.9-39.1,114.3C2141.2,580.5,2103.4,592.8,2054,592.8z M2055.6,415.5c16.6,0,31.4-3.5,44.4-10.4 c13-7,23.2-16.6,30.4-28.9c7.2-12.3,10.8-26.5,10.8-42.6c0-16.1-3.6-30.3-10.8-42.6c-7.2-12.3-17.4-21.8-30.4-28.4 c-13.1-6.6-27.9-9.9-44.4-9.9c-16.6,0-31.5,3.3-44.7,9.9c-13.2,6.6-23.5,16.1-30.7,28.4c-7.2,12.3-10.8,26.5-10.8,42.6 c0,16.1,3.6,30.3,10.8,42.6c7.2,12.3,17.5,22,30.7,28.9S2039.1,415.5,2055.6,415.5z M2453.5,195.9c22.6,0,42.8,4.5,60.6,13.4 c17.8,8.9,31.8,22.6,42.1,41c10.2,18.4,15.3,42,15.3,71v163.9h-66.1V329.8c0-25.3-5.9-44.3-17.7-56.8 c-11.8-12.5-28.3-18.7-49.5-18.7c-15.5,0-29.3,3.2-41.3,9.6c-12,6.4-21.3,16-27.8,28.7c-6.5,12.7-9.8,28.7-9.8,47.9v144.6h-66.1 v-286h63v77.1l-11.1-23.6c9.9-18.2,24.2-32.2,43.1-42C2407,200.8,2428.8,195.9,2453.5,195.9z"
/>
<g fill="var(--color-brand)">
<path
d="m29 424.4 188.2-112.95-17.15-45.48 53.75-55.21 67.93-14.64 19.67 24.21-31.32 31.72-27.3 8.6-19.52 20.05 9.56 26.6 19.4 20.6 27.36-7.28 19.47-21.38 42.51-13.47 12.67 28.5-43.87 53.78-73.5 23.27-32.97-36.7L55.06 467.94C46.1 456.41 35.67 440.08 29 424.4Zm543.03-230.25-149.5 40.32c8.24 21.92 10.95 34.8 13.23 49l149.23-40.26c-2.38-15.94-6.65-32.17-12.96-49.06Z"
/>
<path
d="M51.28 316.13c10.59 125 115.54 223.3 243.27 223.3 96.51 0 180.02-56.12 219.63-137.46l48.61 16.83c-46.78 101.34-149.35 171.75-268.24 171.75C138.6 590.55 10.71 469.38 0 316.13h51.28ZM.78 265.24C15.86 116.36 141.73 0 294.56 0c162.97 0 295.28 132.31 295.28 295.28 0 26.14-3.4 51.49-9.8 75.63l-48.48-16.78a244.28 244.28 0 0 0 7.15-58.85c0-134.75-109.4-244.15-244.15-244.15-124.58 0-227.49 93.5-242.32 214.11H.8Z"
class="ring--large ring"
/>
<path
d="M293.77 153.17c-78.49.07-142.2 63.83-142.2 142.34 0 78.56 63.79 142.34 142.35 142.34 3.98 0 7.93-.16 11.83-.49l14.22 49.76a194.65 194.65 0 0 1-26.05 1.74c-106.72 0-193.36-86.64-193.36-193.35 0-106.72 86.64-193.35 193.36-193.35 2.64 0 5.28.05 7.9.16l-8.05 50.85Zm58.2-42.13c78.39 24.67 135.3 97.98 135.3 184.47 0 80.07-48.77 148.83-118.2 178.18l-14.17-49.55c48.08-22.85 81.36-71.89 81.36-128.63 0-60.99-38.44-113.07-92.39-133.32l8.1-51.15Z"
class="ring--small ring"
/>
</g>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
fill-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
clip-rule="evenodd"
viewBox="0 0 3307 593"
:class="{ animate: loading }"
>
<!-- modrinth -->
<path
v-if="api === 'prod'"
fill="currentColor"
fill-rule="nonzero"
d="M1053.02 205.51c35.59 0 64.27 10.1 84.98 30.81 20.72 21.25 31.34 52.05 31.34 93.48v162.53h-66.4V338.3c0-24.96-5.3-43.55-16.46-56.3-11.15-12.22-26.55-18.6-47.27-18.6-22.3 0-40.37 7.45-53.65 21.79-13.27 14.87-20.18 36.11-20.18 63.2v143.94h-66.4V338.3c0-24.96-5.3-43.55-16.46-56.3-11.15-12.22-26.56-18.6-47.27-18.6-22.84 0-40.37 7.45-53.65 21.79-13.27 14.34-20.18 35.58-20.18 63.2v143.94h-66.4V208.7h63.21v36.12c10.63-12.75 23.9-22.3 39.84-29.21 15.93-6.9 33.46-10.1 53.11-10.1 21.25 0 40.37 3.72 56.84 11.69 16.46 8.5 29.21 20.18 38.77 35.59 11.69-14.88 26.56-26.56 45.15-35.06 18.59-7.97 38.77-12.22 61.08-12.22Zm329.84 290.54c-28.68 0-54.7-6.37-77.54-18.59a133.19 133.19 0 0 1-53.65-52.05c-13.28-21.78-19.65-46.74-19.65-74.9 0-28.14 6.37-53.1 19.65-74.88a135.4 135.4 0 0 1 53.65-51.53c22.84-12.21 48.86-18.59 77.54-18.59 29.22 0 55.24 6.38 78.08 18.6 22.84 12.21 40.9 29.74 54.18 51.52 12.75 21.77 19.12 46.74 19.12 74.89s-6.37 53.11-19.12 74.89c-13.28 22.3-31.34 39.83-54.18 52.05-22.84 12.22-48.86 18.6-78.08 18.6Zm0-56.83c24.44 0 44.62-7.97 60.55-24.43 15.94-16.47 23.9-37.72 23.9-64.27 0-26.56-7.96-47.8-23.9-64.27-15.93-16.47-36.11-24.43-60.55-24.43-24.43 0-44.61 7.96-60.02 24.43-15.93 16.46-23.9 37.71-23.9 64.27 0 26.55 7.97 47.8 23.9 64.27 15.4 16.46 35.6 24.43 60.02 24.43Zm491.32-341v394.11h-63.74v-36.65a108.02 108.02 0 0 1-40.37 30.28c-16.46 6.9-34 10.1-53.65 10.1-27.08 0-51.52-5.85-73.3-18.07-21.77-12.21-39.3-29.21-51.52-51.52-12.21-21.78-18.59-47.27-18.59-75.95s6.38-54.18 18.6-75.96c12.21-21.77 29.74-38.77 51.52-50.99 21.77-12.21 46.2-18.06 73.3-18.06 18.59 0 36.11 3.2 51.52 9.56a106.35 106.35 0 0 1 39.83 28.69V98.22h66.4Zm-149.79 341c15.94 0 30.28-3.72 43.03-11.16 12.74-6.9 22.83-17.52 30.27-30.8 7.44-13.28 11.15-29.21 11.15-46.74s-3.71-33.46-11.15-46.74c-7.44-13.28-17.53-23.9-30.27-31.34-12.75-6.9-27.1-10.62-43.03-10.62s-30.27 3.71-43.02 10.62c-12.75 7.43-22.84 18.06-30.28 31.34-7.43 13.28-11.15 29.2-11.15 46.74 0 17.53 3.72 33.46 11.15 46.74 7.44 13.28 17.53 23.9 30.28 30.8 12.75 7.44 27.09 11.16 43.02 11.16Zm298.51-189.09c19.12-29.74 52.58-44.62 100.92-44.62v63.21a84.29 84.29 0 0 0-15.4-1.6c-26.03 0-46.22 7.44-60.56 22.32-14.34 15.4-21.78 37.18-21.78 65.33v137.56h-66.39V208.7h63.2v41.43Zm155.63-41.43h66.39v283.63h-66.4V208.7Zm33.46-46.74c-12.22 0-22.31-3.72-30.28-11.68a37.36 37.36 0 0 1-12.21-28.16c0-11.15 4.25-20.71 12.21-28.68 7.97-7.43 18.06-11.15 30.28-11.15 12.21 0 22.3 3.72 30.27 10.62 7.97 7.44 12.22 16.47 12.22 27.62 0 11.69-3.72 21.25-11.69 29.21-7.96 7.97-18.59 12.22-30.8 12.22Zm279.38 43.55c35.59 0 64.27 10.63 86.05 31.34 21.78 20.72 32.4 52.05 32.4 92.95v162.53h-66.4V338.3c0-24.96-5.84-43.55-17.52-56.3-11.69-12.22-28.15-18.6-49.93-18.6-24.43 0-43.55 7.45-57.9 21.79-14.34 14.87-21.24 36.11-21.24 63.73v143.41h-66.4V208.7h63.21v36.65c11.16-13.28 24.97-22.84 41.43-29.74 16.47-6.9 35.59-10.1 56.3-10.1Zm371.81 271.42a78.34 78.34 0 0 1-28.15 14.34 130.83 130.83 0 0 1-35.6 4.78c-31.33 0-55.23-7.97-72.23-24.43-17-16.47-25.5-39.84-25.5-71.17V263.94h-46.73v-53.11h46.74v-64.8h66.4v64.8h75.95v53.11h-75.96v134.91c0 13.81 3.19 24.43 10.1 31.34 6.9 7.44 16.46 11.15 29.2 11.15 14.88 0 27.1-3.71 37.19-11.68l18.59 47.27Zm214.05-271.42c35.59 0 64.27 10.63 86.05 31.34 21.77 20.72 32.4 52.05 32.4 92.95v162.53h-66.4V338.3c0-24.96-5.84-43.55-17.53-56.3-11.68-12.22-28.15-18.6-49.92-18.6-24.44 0-43.56 7.45-57.9 21.79-14.34 14.87-21.24 36.11-21.24 63.73v143.41h-66.4V98.23h66.4v143.4c11.15-11.68 24.43-20.71 40.9-27.09 15.93-5.84 33.99-9.03 53.64-9.03Z"
/>
<!-- staging -->
<path
v-else-if="api === 'staging'"
fill="currentColor"
fill-rule="nonzero"
d="M782.3,488.9c-23.6,0-46.3-3.1-68-9.4s-38.9-13.8-51.6-22.8l25.4-50.9c12.7,8.2,27.9,15,45.5,20.4 c17.6,5.4,35.3,8,52.9,8c20.8,0,35.9-2.9,45.2-8.6c9.3-5.7,14-13.4,14-23c0-7.8-3.2-13.8-9.5-17.9c-6.3-4.1-14.6-7.2-24.9-9.4 c-10.2-2.1-21.6-4.1-34.1-5.9c-12.5-1.8-25-4.2-37.6-7.2c-12.5-3-23.9-7.5-34.1-13.4c-10.2-5.9-18.5-13.8-24.9-23.8 c-6.3-10-9.5-23.2-9.5-39.6c0-18.2,5.1-34,15.3-47.4c10.2-13.4,24.6-23.7,43.1-31.1c18.5-7.3,40.5-11,65.9-11 c19,0,38.3,2.1,57.7,6.4c19.4,4.3,35.4,10.4,48.1,18.2l-25.4,50.9c-13.4-8.2-26.9-13.8-40.5-16.9c-13.6-3-27.1-4.6-40.5-4.6 c-20.1,0-35,3-44.7,9.1c-9.7,6.1-14.5,13.8-14.5,23c0,8.6,3.2,15,9.5,19.3c6.3,4.3,14.6,7.7,24.9,10.2c10.2,2.5,21.6,4.6,34.1,6.2 c12.5,1.6,24.9,4,37.3,7.2c12.3,3.2,23.7,7.6,34.1,13.1c10.4,5.5,18.8,13.3,25.1,23.3c6.3,10,9.5,23,9.5,39.1 c0,17.9-5.2,33.4-15.6,46.6c-10.4,13.2-25.1,23.5-44.2,30.8C831.5,485.2,808.7,488.9,782.3,488.9z M929.9,254.8v-53.6h188.3v53.6 H929.9z M1073.8,488.9c-31,0-55-8.1-71.9-24.4c-16.9-16.2-25.4-40.3-25.4-72V135.9h66.1v254.9c0,13.6,3.4,24.1,10.3,31.6 c6.9,7.5,16.5,11.2,28.8,11.2c14.8,0,27.2-3.9,37-11.8l18.5,47.7c-7.8,6.4-17.3,11.2-28.6,14.5 C1097.4,487.3,1085.8,488.9,1073.8,488.9z M1276.9,488.9c-21.2,0-39.7-3.7-55.5-11c-15.9-7.3-28.1-17.5-36.8-30.5 c-8.6-13-13-27.8-13-44.2c0-16.1,3.8-30.5,11.4-43.4c7.6-12.9,20-23,37.3-30.5c17.3-7.5,40.2-11.2,68.8-11.2h82v44.5h-77.2 c-22.6,0-37.7,3.7-45.5,11c-7.8,7.3-11.6,16.3-11.6,27c0,12.1,4.8,21.8,14.3,28.9c9.5,7.1,22.7,10.7,39.7,10.7 c16.2,0,30.8-3.7,43.6-11.2c12.9-7.5,22.1-18.6,27.8-33.2l11.1,40.2c-6.3,16.8-17.7,29.8-34.1,39.1 C1322.7,484.2,1302,488.9,1276.9,488.9z M1365.8,485.1v-57.8l-3.7-12.3V313.7c0-19.6-5.8-34.9-17.5-45.8 c-11.6-10.9-29.3-16.3-52.9-16.3c-15.9,0-31.5,2.5-46.8,7.5c-15.3,5-28.3,12-38.9,20.9l-25.9-48.7c15.2-11.8,33.2-20.6,54.2-26.5 c21-5.9,42.8-8.8,65.3-8.8c40.9,0,72.6,9.9,95,29.7c22.4,19.8,33.6,50.4,33.6,91.9v167.6H1365.8z M1633,472.3 c-26.8,0-51.1-5.8-72.7-17.4c-21.7-11.6-38.8-27.8-51.3-48.5c-12.5-20.7-18.8-45-18.8-72.8c0-27.5,6.3-51.6,18.8-72.3 c12.5-20.7,29.6-36.8,51.3-48.2c21.7-11.4,45.9-17.1,72.7-17.1c24,0,45.5,4.8,64.5,14.5s34.3,24.6,45.8,45 c11.5,20.4,17.2,46.4,17.2,78.2c0,31.8-5.7,57.9-17.2,78.5c-11.5,20.5-26.7,35.7-45.8,45.5C1678.4,467.4,1656.9,472.3,1633,472.3z M1641.4,592.8c-26.1,0-51.6-3.5-76.4-10.4c-24.9-7-45.2-17.1-61.1-30.3l29.6-50.3c12.3,10.4,27.9,18.7,46.8,24.9 c18.9,6.2,38,9.4,57.4,9.4c31,0,53.8-7.2,68.2-21.7c14.5-14.5,21.7-36.2,21.7-65.1v-50.9l5.3-64.8l-2.1-64.8v-69.6h63v242.6 c0,51.8-13.1,89.9-39.1,114.3C1728.5,580.5,1690.8,592.8,1641.4,592.8z M1643,415.5c16.6,0,31.4-3.5,44.4-10.4 c13-7,23.2-16.6,30.4-28.9c7.2-12.3,10.8-26.5,10.8-42.6c0-16.1-3.6-30.3-10.8-42.6c-7.2-12.3-17.4-21.8-30.4-28.4 c-13.1-6.6-27.9-9.9-44.4-9.9c-16.6,0-31.5,3.3-44.7,9.9c-13.2,6.6-23.5,16.1-30.7,28.4c-7.2,12.3-10.8,26.5-10.8,42.6 c0,16.1,3.6,30.3,10.8,42.6c7.2,12.3,17.5,22,30.7,28.9S1626.4,415.5,1643,415.5z M1913.9,152c-12.3,0-22.5-3.9-30.4-11.8 c-7.9-7.8-11.9-17.3-11.9-28.4c0-11.4,4-21,11.9-28.7c7.9-7.7,18.1-11.5,30.4-11.5c12.3,0,22.5,3.7,30.4,11 c7.9,7.3,11.9,16.5,11.9,27.6c0,11.8-3.9,21.7-11.6,29.7C1936.8,148,1926.6,152,1913.9,152z M1880.5,485.1v-286h66.1v286H1880.5z M2193.7,195.9c22.6,0,42.8,4.5,60.6,13.4c17.8,8.9,31.8,22.6,42.1,41c10.2,18.4,15.3,42,15.3,71v163.9h-66.1V329.8 c0-25.3-5.9-44.3-17.7-56.8c-11.8-12.5-28.3-18.7-49.5-18.7c-15.5,0-29.3,3.2-41.3,9.6c-12,6.4-21.3,16-27.8,28.7 c-6.5,12.7-9.8,28.7-9.8,47.9v144.6h-66.1v-286h63v77.1l-11.1-23.6c9.9-18.2,24.2-32.2,43.1-42 C2147.2,200.8,2169,195.9,2193.7,195.9z M2515.9,472.3c-26.8,0-51-5.8-72.7-17.4c-21.7-11.6-38.8-27.8-51.3-48.5 c-12.5-20.7-18.8-45-18.8-72.8c0-27.5,6.3-51.6,18.8-72.3c12.5-20.7,29.6-36.8,51.3-48.2c21.7-11.4,45.9-17.1,72.7-17.1 c24,0,45.5,4.8,64.5,14.5s34.3,24.6,45.8,45c11.5,20.4,17.2,46.4,17.2,78.2c0,31.8-5.7,57.9-17.2,78.5 c-11.5,20.5-26.7,35.7-45.8,45.5C2561.4,467.4,2539.9,472.3,2515.9,472.3z M2524.3,592.8c-26.1,0-51.6-3.5-76.4-10.4 c-24.9-7-45.2-17.1-61.1-30.3l29.6-50.3c12.3,10.4,27.9,18.7,46.8,24.9c18.9,6.2,38,9.4,57.4,9.4c31,0,53.8-7.2,68.2-21.7 c14.5-14.5,21.7-36.2,21.7-65.1v-50.9l5.3-64.8l-2.1-64.8v-69.6h63v242.6c0,51.8-13.1,89.9-39.1,114.3 C2611.4,580.5,2573.7,592.8,2524.3,592.8z M2525.9,415.5c16.6,0,31.4-3.5,44.4-10.4c13-7,23.2-16.6,30.4-28.9 c7.2-12.3,10.8-26.5,10.8-42.6c0-16.1-3.6-30.3-10.8-42.6c-7.2-12.3-17.4-21.8-30.4-28.4c-13.1-6.6-27.9-9.9-44.4-9.9 c-16.6,0-31.5,3.3-44.7,9.9c-13.2,6.6-23.5,16.1-30.7,28.4c-7.2,12.3-10.8,26.5-10.8,42.6c0,16.1,3.6,30.3,10.8,42.6 c7.2,12.3,17.5,22,30.7,28.9S2509.3,415.5,2525.9,415.5z"
/>
<!-- localhost -->
<path
v-else-if="api === 'localhost'"
fill="currentColor"
fill-rule="nonzero"
d="M695,492.3V94.9h66.1v397.4H695z M974.9,496.1c-28.9,0-54.7-6.3-77.2-19c-22.6-12.7-40.4-30.1-53.4-52.2 c-13.1-22.1-19.6-47.3-19.6-75.5c0-28.6,6.5-53.8,19.6-75.8c13-22,30.9-39.2,53.4-51.7c22.6-12.5,48.3-18.7,77.2-18.7 c29.3,0,55.3,6.3,78,18.7c22.7,12.5,40.6,29.6,53.4,51.4c12.9,21.8,19.3,47.1,19.3,76.1c0,28.2-6.4,53.4-19.3,75.5 c-12.9,22.1-30.7,39.6-53.4,52.2C1030.1,489.7,1004.1,496.1,974.9,496.1z M974.9,438.8c16.2,0,30.7-3.6,43.4-10.7 c12.7-7.1,22.7-17.5,29.9-31.1c7.2-13.6,10.8-29.5,10.8-47.7c0-18.6-3.6-34.5-10.8-47.9c-7.2-13.4-17.2-23.6-29.9-30.8 c-12.7-7.1-27-10.7-42.9-10.7c-16.2,0-30.6,3.6-43.1,10.7c-12.5,7.1-22.5,17.4-29.9,30.8s-11.1,29.4-11.1,47.9 c0,18.2,3.7,34.1,11.1,47.7c7.4,13.6,17.4,23.9,29.9,31.1C944.8,435.2,959,438.8,974.9,438.8z M1318.7,496.1c-29.6,0-56-6.3-79.1-19 c-23.1-12.7-41.2-30.1-54.2-52.2c-13.1-22.1-19.6-47.3-19.6-75.5c0-28.6,6.5-53.8,19.6-75.8c13-22,31.1-39.2,54.2-51.7 c23.1-12.5,49.5-18.7,79.1-18.7c27.5,0,51.8,5.6,72.7,16.9c21,11.2,36.9,27.8,47.9,49.5l-50.8,30c-8.5-13.6-18.8-23.6-30.9-30 s-25.3-9.6-39.4-9.6c-16.2,0-30.9,3.6-43.9,10.7c-13.1,7.1-23.3,17.4-30.7,30.8s-11.1,29.4-11.1,47.9c0,18.6,3.7,34.5,11.1,47.9 c7.4,13.4,17.6,23.7,30.7,30.8c13,7.1,27.7,10.7,43.9,10.7c14.1,0,27.2-3.2,39.4-9.6s22.5-16.4,30.9-30l50.8,30 c-10.9,21.4-26.9,37.9-47.9,49.5C1370.5,490.3,1346.2,496.1,1318.7,496.1z M1581.6,496.1c-21.2,0-39.7-3.7-55.5-11 c-15.9-7.3-28.1-17.5-36.8-30.5c-8.6-13-13-27.8-13-44.2c0-16.1,3.8-30.5,11.4-43.4c7.6-12.9,20-23,37.3-30.5 c17.3-7.5,40.2-11.2,68.8-11.2h82v44.5h-77.2c-22.6,0-37.7,3.7-45.5,11c-7.8,7.3-11.6,16.3-11.6,27c0,12.1,4.8,21.8,14.3,28.9 c9.5,7.1,22.7,10.7,39.7,10.7c16.2,0,30.8-3.7,43.6-11.2c12.9-7.5,22.1-18.6,27.8-33.2l11.1,40.2c-6.3,16.8-17.7,29.8-34.1,39.1 C1627.4,491.4,1606.7,496.1,1581.6,496.1z M1670.5,492.3v-57.8l-3.7-12.3V320.9c0-19.6-5.8-34.9-17.5-45.8 c-11.6-10.9-29.3-16.3-52.9-16.3c-15.9,0-31.5,2.5-46.8,7.5c-15.3,5-28.3,12-38.9,20.9l-25.9-48.7c15.2-11.8,33.2-20.6,54.2-26.5 c21-5.9,42.8-8.8,65.3-8.8c40.9,0,72.6,9.9,95,29.7c22.4,19.8,33.6,50.4,33.6,91.9v167.6H1670.5z M1817.6,492.3V94.9h66.1v397.4 H1817.6z M2130.7,203.1c22.6,0,42.8,4.5,60.6,13.4c17.8,8.9,31.8,22.6,42.1,41c10.2,18.4,15.3,42,15.3,71v163.9h-66.1V337 c0-25.3-5.9-44.3-17.7-56.8c-11.8-12.5-28.3-18.7-49.5-18.7c-15.5,0-29.3,3.2-41.3,9.6c-12,6.4-21.3,16-27.8,28.7 c-6.5,12.7-9.8,28.7-9.8,47.9v144.6h-66.1V94.9h66.1v188.5l-14.3-23.6c9.9-18.2,24.2-32.2,43.1-42 C2084.3,208,2106.1,203.1,2130.7,203.1z M2460.3,496.1c-28.9,0-54.7-6.3-77.2-19c-22.6-12.7-40.4-30.1-53.4-52.2 c-13.1-22.1-19.6-47.3-19.6-75.5c0-28.6,6.5-53.8,19.6-75.8c13-22,30.9-39.2,53.4-51.7c22.6-12.5,48.3-18.7,77.2-18.7 c29.3,0,55.3,6.3,78,18.7c22.7,12.5,40.6,29.6,53.4,51.4c12.9,21.8,19.3,47.1,19.3,76.1c0,28.2-6.4,53.4-19.3,75.5 c-12.9,22.1-30.7,39.6-53.4,52.2C2515.6,489.7,2489.6,496.1,2460.3,496.1z M2460.3,438.8c16.2,0,30.7-3.6,43.4-10.7 c12.7-7.1,22.7-17.5,29.9-31.1c7.2-13.6,10.8-29.5,10.8-47.7c0-18.6-3.6-34.5-10.8-47.9c-7.2-13.4-17.2-23.6-29.9-30.8 c-12.7-7.1-27-10.7-42.9-10.7c-16.2,0-30.6,3.6-43.1,10.7c-12.5,7.1-22.5,17.4-29.9,30.8s-11.1,29.4-11.1,47.9 c0,18.2,3.7,34.1,11.1,47.7c7.4,13.6,17.4,23.9,29.9,31.1C2430.3,435.2,2444.5,438.8,2460.3,438.8z M2761.9,496.1 c-23.6,0-46.3-3.1-68-9.4c-21.7-6.2-38.9-13.8-51.6-22.8l25.4-50.9c12.7,8.2,27.9,15,45.5,20.4c17.6,5.4,35.3,8,52.9,8 c20.8,0,35.9-2.9,45.2-8.6c9.3-5.7,14-13.4,14-23c0-7.8-3.2-13.8-9.5-17.9c-6.3-4.1-14.6-7.2-24.9-9.4c-10.2-2.1-21.6-4.1-34.1-5.9 c-12.5-1.8-25-4.2-37.6-7.2c-12.5-3-23.9-7.5-34.1-13.4c-10.2-5.9-18.5-13.8-24.9-23.8c-6.3-10-9.5-23.2-9.5-39.6 c0-18.2,5.1-34,15.3-47.4c10.2-13.4,24.6-23.7,43.1-31.1c18.5-7.3,40.5-11,65.9-11c19,0,38.3,2.1,57.7,6.4 c19.4,4.3,35.4,10.4,48.1,18.2l-25.4,50.9c-13.4-8.2-26.9-13.8-40.5-16.9c-13.6-3-27.1-4.6-40.5-4.6c-20.1,0-35,3-44.7,9.1 c-9.7,6.1-14.5,13.8-14.5,23c0,8.6,3.2,15,9.5,19.3c6.3,4.3,14.6,7.7,24.9,10.2c10.2,2.5,21.6,4.6,34.1,6.2 c12.5,1.6,24.9,4,37.3,7.2c12.3,3.2,23.7,7.6,34.1,13.1c10.4,5.5,18.8,13.3,25.1,23.3c6.3,10,9.5,23,9.5,39.1 c0,17.9-5.2,33.4-15.6,46.6c-10.4,13.2-25.1,23.5-44.2,30.8S2788.3,496.1,2761.9,496.1z M2909.5,262v-53.6h188.3V262H2909.5z M3053.4,496.1c-31,0-55-8.1-71.9-24.4c-16.9-16.2-25.4-40.3-25.4-72V143.1h66.1V398c0,13.6,3.4,24.1,10.3,31.6 c6.9,7.5,16.5,11.2,28.8,11.2c14.8,0,27.2-3.9,37-11.8l18.5,47.7c-7.8,6.4-17.3,11.2-28.6,14.5 C3077,494.5,3065.3,496.1,3053.4,496.1z"
/>
<!-- foreign -->
<path
v-else
fill="currentColor"
fill-rule="nonzero"
d="M657.4,254.8v-53.6h188.3v53.6H657.4z M704,485.1V183c0-30,8.7-54,26.2-72c17.5-18,42.4-27,74.9-27 c11.6,0,22.7,1.3,33.1,3.7c10.4,2.5,19.1,6.4,26.2,11.8l-18,50.3c-4.9-3.9-10.6-6.9-16.9-8.8c-6.3-2-12.9-2.9-19.6-2.9 c-13.8,0-24.2,3.8-31.2,11.5c-7.1,7.7-10.6,19.2-10.6,34.5v32.1l2.1,30v238.9H704z M1012.9,488.9c-28.9,0-54.7-6.3-77.2-19 c-22.6-12.7-40.4-30.1-53.4-52.2c-13.1-22.1-19.6-47.3-19.6-75.5c0-28.6,6.5-53.8,19.6-75.8c13-22,30.9-39.2,53.4-51.7 c22.6-12.5,48.3-18.7,77.2-18.7c29.3,0,55.3,6.3,78,18.7c22.7,12.5,40.6,29.6,53.4,51.4c12.9,21.8,19.3,47.1,19.3,76.1 c0,28.2-6.4,53.4-19.3,75.5c-12.9,22.1-30.7,39.6-53.4,52.2C1068.2,482.5,1042.2,488.9,1012.9,488.9z M1012.9,431.6 c16.2,0,30.7-3.6,43.4-10.7c12.7-7.1,22.7-17.5,29.9-31.1c7.2-13.6,10.8-29.5,10.8-47.7c0-18.6-3.6-34.5-10.8-47.9 c-7.2-13.4-17.2-23.6-29.9-30.8c-12.7-7.1-27-10.7-42.9-10.7c-16.2,0-30.6,3.6-43.1,10.7c-12.5,7.1-22.5,17.4-29.9,30.8 s-11.1,29.4-11.1,47.9c0,18.2,3.7,34.1,11.1,47.7c7.4,13.6,17.4,23.9,29.9,31.1C982.9,428,997.1,431.6,1012.9,431.6z M1227.2,485.1 v-286h63v78.7l-7.4-23c8.5-19.3,21.8-33.9,39.9-43.9c18.2-10,40.8-15,68-15v63.7c-2.8-0.7-5.5-1.2-7.9-1.3c-2.5-0.2-4.9-0.3-7.4-0.3 c-25,0-45,7.4-59.8,22.2c-14.8,14.8-22.2,36.9-22.2,66.1v138.7H1227.2z M1576.9,488.9c-31.4,0-58.8-6.3-82.3-19 c-23.5-12.7-41.6-30.1-54.5-52.2c-12.9-22.1-19.3-47.3-19.3-75.5c0-28.6,6.3-53.8,18.8-75.8c12.5-22,29.8-39.2,51.8-51.7 c22-12.5,47.2-18.7,75.4-18.7c27.5,0,52,6.1,73.5,18.2c21.5,12.1,38.4,29.3,50.8,51.4c12.3,22.1,18.5,48.2,18.5,78.2 c0,2.9-0.1,6.1-0.3,9.6c-0.2,3.6-0.4,7-0.8,10.2h-235.4v-44.5h200.5l-25.9,13.9c0.3-16.4-3-30.9-10.1-43.4 c-7.1-12.5-16.7-22.3-28.8-29.5c-12.2-7.1-26.2-10.7-42.1-10.7c-16.2,0-30.4,3.6-42.6,10.7c-12.2,7.1-21.7,17.1-28.6,29.7 c-6.9,12.7-10.3,27.6-10.3,44.7v10.7c0,17.1,3.9,32.3,11.6,45.5c7.8,13.2,18.7,23.4,32.8,30.5c14.1,7.1,30.3,10.7,48.7,10.7 c15.9,0,30.2-2.5,42.9-7.5c12.7-5,24-12.9,33.9-23.6l35.4,41.2c-12.7,15-28.7,26.5-47.9,34.5 C1623.5,484.9,1601.6,488.9,1576.9,488.9z M1806.5,152c-12.3,0-22.5-3.9-30.4-11.8c-7.9-7.8-11.9-17.3-11.9-28.4 c0-11.4,4-21,11.9-28.7c7.9-7.7,18.1-11.5,30.4-11.5c12.3,0,22.5,3.7,30.4,11c7.9,7.3,11.9,16.5,11.9,27.6 c0,11.8-3.9,21.7-11.6,29.7C1829.4,148,1819.2,152,1806.5,152z M1773.1,485.1v-286h66.1v286H1773.1z M2045.6,472.3 c-26.8,0-51-5.8-72.7-17.4c-21.7-11.6-38.8-27.8-51.3-48.5c-12.5-20.7-18.8-45-18.8-72.8c0-27.5,6.3-51.6,18.8-72.3 c12.5-20.7,29.6-36.8,51.3-48.2c21.7-11.4,45.9-17.1,72.7-17.1c24,0,45.5,4.8,64.5,14.5s34.3,24.6,45.8,45 c11.5,20.4,17.2,46.4,17.2,78.2c0,31.8-5.7,57.9-17.2,78.5c-11.5,20.5-26.7,35.7-45.8,45.5C2091.1,467.4,2069.6,472.3,2045.6,472.3z M2054,592.8c-26.1,0-51.6-3.5-76.4-10.4c-24.9-7-45.2-17.1-61.1-30.3l29.6-50.3c12.3,10.4,27.9,18.7,46.8,24.9 c18.9,6.2,38,9.4,57.4,9.4c31,0,53.8-7.2,68.2-21.7c14.5-14.5,21.7-36.2,21.7-65.1v-50.9l5.3-64.8l-2.1-64.8v-69.6h63v242.6 c0,51.8-13.1,89.9-39.1,114.3C2141.2,580.5,2103.4,592.8,2054,592.8z M2055.6,415.5c16.6,0,31.4-3.5,44.4-10.4 c13-7,23.2-16.6,30.4-28.9c7.2-12.3,10.8-26.5,10.8-42.6c0-16.1-3.6-30.3-10.8-42.6c-7.2-12.3-17.4-21.8-30.4-28.4 c-13.1-6.6-27.9-9.9-44.4-9.9c-16.6,0-31.5,3.3-44.7,9.9c-13.2,6.6-23.5,16.1-30.7,28.4c-7.2,12.3-10.8,26.5-10.8,42.6 c0,16.1,3.6,30.3,10.8,42.6c7.2,12.3,17.5,22,30.7,28.9S2039.1,415.5,2055.6,415.5z M2453.5,195.9c22.6,0,42.8,4.5,60.6,13.4 c17.8,8.9,31.8,22.6,42.1,41c10.2,18.4,15.3,42,15.3,71v163.9h-66.1V329.8c0-25.3-5.9-44.3-17.7-56.8 c-11.8-12.5-28.3-18.7-49.5-18.7c-15.5,0-29.3,3.2-41.3,9.6c-12,6.4-21.3,16-27.8,28.7c-6.5,12.7-9.8,28.7-9.8,47.9v144.6h-66.1 v-286h63v77.1l-11.1-23.6c9.9-18.2,24.2-32.2,43.1-42C2407,200.8,2428.8,195.9,2453.5,195.9z"
/>
<g fill="var(--color-brand)">
<path
d="m29 424.4 188.2-112.95-17.15-45.48 53.75-55.21 67.93-14.64 19.67 24.21-31.32 31.72-27.3 8.6-19.52 20.05 9.56 26.6 19.4 20.6 27.36-7.28 19.47-21.38 42.51-13.47 12.67 28.5-43.87 53.78-73.5 23.27-32.97-36.7L55.06 467.94C46.1 456.41 35.67 440.08 29 424.4Zm543.03-230.25-149.5 40.32c8.24 21.92 10.95 34.8 13.23 49l149.23-40.26c-2.38-15.94-6.65-32.17-12.96-49.06Z"
/>
<path
d="M51.28 316.13c10.59 125 115.54 223.3 243.27 223.3 96.51 0 180.02-56.12 219.63-137.46l48.61 16.83c-46.78 101.34-149.35 171.75-268.24 171.75C138.6 590.55 10.71 469.38 0 316.13h51.28ZM.78 265.24C15.86 116.36 141.73 0 294.56 0c162.97 0 295.28 132.31 295.28 295.28 0 26.14-3.4 51.49-9.8 75.63l-48.48-16.78a244.28 244.28 0 0 0 7.15-58.85c0-134.75-109.4-244.15-244.15-244.15-124.58 0-227.49 93.5-242.32 214.11H.8Z"
class="ring--large ring"
/>
<path
d="M293.77 153.17c-78.49.07-142.2 63.83-142.2 142.34 0 78.56 63.79 142.34 142.35 142.34 3.98 0 7.93-.16 11.83-.49l14.22 49.76a194.65 194.65 0 0 1-26.05 1.74c-106.72 0-193.36-86.64-193.36-193.35 0-106.72 86.64-193.35 193.36-193.35 2.64 0 5.28.05 7.9.16l-8.05 50.85Zm58.2-42.13c78.39 24.67 135.3 97.98 135.3 184.47 0 80.07-48.77 148.83-118.2 178.18l-14.17-49.55c48.08-22.85 81.36-71.89 81.36-128.63 0-60.99-38.44-113.07-92.39-133.32l8.1-51.15Z"
class="ring--small ring"
/>
</g>
</svg>
</template>
<script setup>
const loading = useLoading();
const loading = useLoading()
const config = useRuntimeConfig();
const config = useRuntimeConfig()
const api = computed(() => {
const apiUrl = config.public.apiBaseUrl;
if (apiUrl.startsWith("https://api.modrinth.com")) {
return "prod";
} else if (apiUrl.startsWith("https://staging-api.modrinth.com")) {
return "staging";
} else if (apiUrl.startsWith("localhost") || apiUrl.startsWith("127.0.0.1")) {
return "localhost";
}
return "foreign";
});
const apiUrl = config.public.apiBaseUrl
if (apiUrl.startsWith('https://api.modrinth.com')) {
return 'prod'
} else if (apiUrl.startsWith('https://staging-api.modrinth.com')) {
return 'staging'
} else if (apiUrl.startsWith('localhost') || apiUrl.startsWith('127.0.0.1')) {
return 'localhost'
}
return 'foreign'
})
</script>
<style lang="scss" scoped>
.animate {
.ring {
transform-origin: center;
transform-box: fill-box;
animation-fill-mode: forwards;
transition: transform 2s ease-in-out;
&--large {
animation: spin 1s ease-in-out infinite forwards;
}
&--small {
animation: spin 2s ease-in-out infinite reverse;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.ring {
transform-origin: center;
transform-box: fill-box;
animation-fill-mode: forwards;
transition: transform 2s ease-in-out;
&--large {
animation: spin 1s ease-in-out infinite forwards;
}
&--small {
animation: spin 2s ease-in-out infinite reverse;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
}
</style>

View File

@@ -1,92 +1,92 @@
<template>
<div>
<ButtonStyled v-if="!!slots.title" :type="type">
<button class="!w-full" @click="() => (isOpen ? close() : open())">
<slot name="title" /><DropdownIcon
class="ml-auto size-5 text-contrast transition-transform duration-300"
:class="{ 'rotate-180': isOpen }"
/>
</button>
</ButtonStyled>
<div class="accordion-content" :class="{ open: isOpen }">
<div>
<div :class="{ 'mt-2': !!slots.title }" v-bind="$attrs" :inert="!isOpen">
<slot />
</div>
</div>
</div>
</div>
<div>
<ButtonStyled v-if="!!slots.title" :type="type">
<button class="!w-full" @click="() => (isOpen ? close() : open())">
<slot name="title" /><DropdownIcon
class="ml-auto size-5 text-contrast transition-transform duration-300"
:class="{ 'rotate-180': isOpen }"
/>
</button>
</ButtonStyled>
<div class="accordion-content" :class="{ open: isOpen }">
<div>
<div :class="{ 'mt-2': !!slots.title }" v-bind="$attrs" :inert="!isOpen">
<slot />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { DropdownIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { DropdownIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
const props = withDefaults(
defineProps<{
openByDefault?: boolean;
type?: "standard" | "outlined" | "transparent";
}>(),
{
type: "standard",
openByDefault: false,
},
);
defineProps<{
openByDefault?: boolean
type?: 'standard' | 'outlined' | 'transparent'
}>(),
{
type: 'standard',
openByDefault: false,
},
)
const isOpen = ref(props.openByDefault);
const emit = defineEmits(["onOpen", "onClose"]);
const isOpen = ref(props.openByDefault)
const emit = defineEmits(['onOpen', 'onClose'])
const slots = useSlots();
const slots = useSlots()
function open() {
isOpen.value = true;
emit("onOpen");
isOpen.value = true
emit('onOpen')
}
function close() {
isOpen.value = false;
emit("onClose");
isOpen.value = false
emit('onClose')
}
defineExpose({
open,
close,
isOpen,
});
open,
close,
isOpen,
})
defineOptions({
inheritAttrs: false,
});
inheritAttrs: false,
})
</script>
<style scoped>
.accordion-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-in-out;
animation: height-animate 500ms ease-in-out both;
content-visibility: auto;
animation-composition: replace;
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-in-out;
animation: height-animate 500ms ease-in-out both;
content-visibility: auto;
animation-composition: replace;
}
@media (prefers-reduced-motion) {
.accordion-content {
transition: none !important;
}
.accordion-content {
transition: none !important;
}
}
.accordion-content.open {
grid-template-rows: 1fr;
grid-template-rows: 1fr;
}
.accordion-content > div {
overflow: hidden;
overflow: hidden;
}
@keyframes height-animate {
from {
block-size: initial;
}
to {
block-size: auto;
}
from {
block-size: initial;
}
to {
block-size: auto;
}
}
</style>

View File

@@ -1,122 +1,122 @@
<template>
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
<nuxt-link
to="/servers"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
>
<img
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp"
alt="Host your next server with Modrinth Servers"
class="light-image hidden rounded-[inherit]"
/>
<img
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp"
alt="Host your next server with Modrinth Servers"
class="dark-image rounded-[inherit]"
/>
</nuxt-link>
<div
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
>
<div id="modrinth-rail-1" />
</div>
</div>
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
<nuxt-link
to="/servers"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
>
<img
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp"
alt="Host your next server with Modrinth Servers"
class="light-image hidden rounded-[inherit]"
/>
<img
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp"
alt="Host your next server with Modrinth Servers"
class="dark-image rounded-[inherit]"
/>
</nuxt-link>
<div
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
>
<div id="modrinth-rail-1" />
</div>
</div>
</template>
<script setup>
useHead({
script: [
// {
// // Clean.io
// src: "https://cadmus.script.ac/d14pdm1b7fi5kh/script.js",
// },
{
// Aditude
src: "https://dn0qt3r0xannq.cloudfront.net/modrinth-7JfmkEIXEp/modrinth-longform/prebid-load.js",
async: true,
},
{
// Optima
src: "https://bservr.com/o.js?uid=8118d1fdb2e0d6f32180bd27",
async: true,
},
{
src: "/inmobi.js",
async: true,
},
],
link: [
{
rel: "preload",
as: "script",
href: "https://www.googletagservices.com/tag/js/gpt.js",
},
],
});
script: [
// {
// // Clean.io
// src: "https://cadmus.script.ac/d14pdm1b7fi5kh/script.js",
// },
{
// Aditude
src: 'https://dn0qt3r0xannq.cloudfront.net/modrinth-7JfmkEIXEp/modrinth-longform/prebid-load.js',
async: true,
},
{
// Optima
src: 'https://bservr.com/o.js?uid=8118d1fdb2e0d6f32180bd27',
async: true,
},
{
src: '/inmobi.js',
async: true,
},
],
link: [
{
rel: 'preload',
as: 'script',
href: 'https://www.googletagservices.com/tag/js/gpt.js',
},
],
})
onMounted(() => {
window.tude = window.tude || { cmd: [] };
window.Raven = window.Raven || { cmd: [] };
window.tude = window.tude || { cmd: [] }
window.Raven = window.Raven || { cmd: [] }
window.Raven.cmd.push(({ config }) => {
config.setCustom({
param1: "web",
});
});
window.Raven.cmd.push(({ config }) => {
config.setCustom({
param1: 'web',
})
})
tude.cmd.push(function () {
tude.refreshAdsViaDivMappings([
{
divId: "modrinth-rail-1",
baseDivId: "pb-slot-square-2",
targeting: {
location: "web",
},
},
]);
});
});
tude.cmd.push(function () {
tude.refreshAdsViaDivMappings([
{
divId: 'modrinth-rail-1',
baseDivId: 'pb-slot-square-2',
targeting: {
location: 'web',
},
},
])
})
})
</script>
<style>
iframe[id^="google_ads_iframe"] {
color-scheme: normal;
background: transparent;
iframe[id^='google_ads_iframe'] {
color-scheme: normal;
background: transparent;
}
#qc-cmp2-ui {
background: var(--color-raised-bg);
border-radius: var(--radius-lg);
color: var(--color-base);
background: var(--color-raised-bg);
border-radius: var(--radius-lg);
color: var(--color-base);
}
#qc-cmp2-ui::before {
background: var(--color-raised-bg);
background: var(--color-raised-bg);
}
#qc-cmp2-ui::after {
background: var(--color-raised-bg);
background: var(--color-raised-bg);
}
#qc-cmp2-ui button[mode="primary"] {
background: var(--color-brand);
color: var(--color-accent-contrast);
border-radius: var(--radius-lg);
border: none;
#qc-cmp2-ui button[mode='primary'] {
background: var(--color-brand);
color: var(--color-accent-contrast);
border-radius: var(--radius-lg);
border: none;
}
#qc-cmp2-ui button[mode="secondary"] {
background: var(--color-button-bg);
color: var(--color-base);
border-radius: var(--radius-lg);
border: none;
#qc-cmp2-ui button[mode='secondary'] {
background: var(--color-button-bg);
color: var(--color-base);
border-radius: var(--radius-lg);
border: none;
}
#qc-cmp2-ui button[mode="link"] {
color: var(--color-link);
#qc-cmp2-ui button[mode='link'] {
color: var(--color-link);
}
#qc-cmp2-ui h2 {
color: var(--color-contrast);
font-size: 1.5rem;
color: var(--color-contrast);
font-size: 1.5rem;
}
#qc-cmp2-ui div,
@@ -125,31 +125,31 @@ iframe[id^="google_ads_iframe"] {
#qc-cmp2-ui p,
#qc-cmp2-ui .qc-cmp2-list-item-title,
#qc-cmp2-ui .qc-cmp2-expandable-info {
color: var(--color-base);
font-family: var(--font-standard);
color: var(--color-base);
font-family: var(--font-standard);
}
#qc-cmp2-ui .qc-cmp2-toggle[aria-checked="true"] {
background-color: var(--color-brand);
border: 1px solid var(--color-brand);
#qc-cmp2-ui .qc-cmp2-toggle[aria-checked='true'] {
background-color: var(--color-brand);
border: 1px solid var(--color-brand);
}
@media (max-width: 1024px) {
.ad-parent {
display: none;
}
.ad-parent {
display: none;
}
}
</style>
<style lang="scss" scoped>
.light,
.light-mode {
.dark-image {
display: none;
}
.dark-image {
display: none;
}
.light-image {
display: block;
}
.light-image {
display: block;
}
}
</style>

View File

@@ -1,70 +1,70 @@
<template>
<div class="accordion-wrapper" :class="{ 'has-content': hasContent }">
<div class="accordion-content">
<div>
<div v-bind="$attrs" ref="slotContainer" class="content-container">
<slot />
</div>
</div>
</div>
</div>
<div class="accordion-wrapper" :class="{ 'has-content': hasContent }">
<div class="accordion-content">
<div>
<div v-bind="$attrs" ref="slotContainer" class="content-container">
<slot />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({
inheritAttrs: false,
});
inheritAttrs: false,
})
const slotContainer = ref();
const slotContainer = ref()
const hasContent = ref(false);
const hasContent = ref(false)
const mutationObserver = ref<MutationObserver | null>(null);
const mutationObserver = ref<MutationObserver | null>(null)
function updateContent() {
if (!slotContainer.value) return false;
if (!slotContainer.value) return false
hasContent.value = slotContainer.value ? slotContainer.value.children.length > 0 : false;
hasContent.value = slotContainer.value ? slotContainer.value.children.length > 0 : false
}
onMounted(() => {
mutationObserver.value = new MutationObserver(updateContent);
mutationObserver.value = new MutationObserver(updateContent)
mutationObserver.value.observe(slotContainer.value, {
childList: true,
});
mutationObserver.value.observe(slotContainer.value, {
childList: true,
})
updateContent();
});
updateContent()
})
onUnmounted(() => {
if (mutationObserver.value) {
mutationObserver.value.disconnect();
}
});
if (mutationObserver.value) {
mutationObserver.value.disconnect()
}
})
</script>
<style scoped>
.accordion-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-in-out;
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-in-out;
}
@media (prefers-reduced-motion) {
.accordion-content {
transition: none !important;
}
.accordion-content {
transition: none !important;
}
}
.has-content .accordion-content {
grid-template-rows: 1fr;
grid-template-rows: 1fr;
}
.accordion-content > div {
overflow: hidden;
overflow: hidden;
}
.accordion-wrapper.has-content {
display: contents;
display: contents;
}
</style>

View File

@@ -1,54 +1,54 @@
<template>
<nav class="breadcrumbs">
<template v-for="(link, index) in linkStack" :key="index">
<NuxtLink
:to="link.href"
class="breadcrumb goto-link"
:class="{ trim: link.allowTrimming ? link.allowTrimming : false }"
>
{{ link.label }}
</NuxtLink>
<ChevronRightIcon />
</template>
<span class="breadcrumb">{{ currentTitle }}</span>
</nav>
<nav class="breadcrumbs">
<template v-for="(link, index) in linkStack" :key="index">
<NuxtLink
:to="link.href"
class="breadcrumb goto-link"
:class="{ trim: link.allowTrimming ? link.allowTrimming : false }"
>
{{ link.label }}
</NuxtLink>
<ChevronRightIcon />
</template>
<span class="breadcrumb">{{ currentTitle }}</span>
</nav>
</template>
<script setup>
import { ChevronRightIcon } from "@modrinth/assets";
import { ChevronRightIcon } from '@modrinth/assets'
defineProps({
linkStack: {
type: Array,
default: () => [],
},
currentTitle: {
type: String,
required: true,
},
});
linkStack: {
type: Array,
default: () => [],
},
currentTitle: {
type: String,
required: true,
},
})
</script>
<style lang="scss" scoped>
.breadcrumbs {
//padding: var(--spacing-card-md) var(--spacing-card-lg);
display: flex;
margin-bottom: var(--spacing-card-bg);
align-items: center;
flex-wrap: wrap;
//padding: var(--spacing-card-md) var(--spacing-card-lg);
display: flex;
margin-bottom: var(--spacing-card-bg);
align-items: center;
flex-wrap: wrap;
svg {
width: 1.25rem;
height: 1.25rem;
}
svg {
width: 1.25rem;
height: 1.25rem;
}
a.breadcrumb {
padding-block: var(--spacing-card-xs);
a.breadcrumb {
padding-block: var(--spacing-card-xs);
&.trim {
text-overflow: ellipsis;
white-space: nowrap;
}
}
&.trim {
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>

View File

@@ -1,151 +1,151 @@
<template>
<div
class="checkbox-outer button-within"
:class="{ disabled, checked: modelValue }"
role="presentation"
@click="toggle"
>
<button
class="checkbox"
role="checkbox"
:disabled="disabled"
:class="{ checked: modelValue, collapsing: collapsingToggleStyle }"
:aria-label="description ?? label"
:aria-checked="modelValue"
>
<CheckIcon v-if="modelValue && !collapsingToggleStyle" aria-hidden="true" />
<DropdownIcon v-else-if="collapsingToggleStyle" aria-hidden="true" />
</button>
<!-- aria-hidden is set so screenreaders only use the <button>'s aria-label -->
<p v-if="label" aria-hidden="true">
{{ label }}
</p>
<slot v-else />
</div>
<div
class="checkbox-outer button-within"
:class="{ disabled, checked: modelValue }"
role="presentation"
@click="toggle"
>
<button
class="checkbox"
role="checkbox"
:disabled="disabled"
:class="{ checked: modelValue, collapsing: collapsingToggleStyle }"
:aria-label="description ?? label"
:aria-checked="modelValue"
>
<CheckIcon v-if="modelValue && !collapsingToggleStyle" aria-hidden="true" />
<DropdownIcon v-else-if="collapsingToggleStyle" aria-hidden="true" />
</button>
<!-- aria-hidden is set so screenreaders only use the <button>'s aria-label -->
<p v-if="label" aria-hidden="true">
{{ label }}
</p>
<slot v-else />
</div>
</template>
<script>
import { CheckIcon, DropdownIcon } from "@modrinth/assets";
import { CheckIcon, DropdownIcon } from '@modrinth/assets'
export default {
components: {
CheckIcon,
DropdownIcon,
},
props: {
label: {
type: String,
required: false,
default: "",
},
disabled: {
type: Boolean,
required: false,
default: false,
},
description: {
type: String,
required: false,
default: null,
},
modelValue: Boolean,
clickEvent: {
type: Function,
required: false,
default: () => {},
},
collapsingToggleStyle: {
type: Boolean,
required: false,
default: false,
},
},
emits: ["update:modelValue"],
methods: {
toggle() {
if (!this.disabled) {
this.$emit("update:modelValue", !this.modelValue);
}
},
},
};
components: {
CheckIcon,
DropdownIcon,
},
props: {
label: {
type: String,
required: false,
default: '',
},
disabled: {
type: Boolean,
required: false,
default: false,
},
description: {
type: String,
required: false,
default: null,
},
modelValue: Boolean,
clickEvent: {
type: Function,
required: false,
default: () => {},
},
collapsingToggleStyle: {
type: Boolean,
required: false,
default: false,
},
},
emits: ['update:modelValue'],
methods: {
toggle() {
if (!this.disabled) {
this.$emit('update:modelValue', !this.modelValue)
}
},
},
}
</script>
<style lang="scss" scoped>
.checkbox-outer {
display: flex;
align-items: center;
cursor: pointer;
display: flex;
align-items: center;
cursor: pointer;
p {
user-select: none;
padding: 0.2rem 0;
margin: 0;
}
p {
user-select: none;
padding: 0.2rem 0;
margin: 0;
}
&.disabled {
cursor: not-allowed;
}
&.disabled {
cursor: not-allowed;
}
&.checked {
outline: 2px solid transparent;
outline-offset: 4px;
border-radius: 0.25rem;
}
&.checked {
outline: 2px solid transparent;
outline-offset: 4px;
border-radius: 0.25rem;
}
}
.checkbox {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
min-width: 1rem;
min-height: 1rem;
min-width: 1rem;
min-height: 1rem;
padding: 0;
margin: 0 0.5rem 0 0;
padding: 0;
margin: 0 0.5rem 0 0;
color: var(--color-button-text);
background-color: var(--color-button-bg);
border-radius: var(--size-rounded-control);
box-shadow:
var(--shadow-inset-sm),
0 0 0 0 transparent;
color: var(--color-button-text);
background-color: var(--color-button-bg);
border-radius: var(--size-rounded-control);
box-shadow:
var(--shadow-inset-sm),
0 0 0 0 transparent;
&.checked {
background-color: var(--color-brand);
}
&.checked {
background-color: var(--color-brand);
}
svg {
color: var(--color-accent-contrast, var(--color-brand-inverted));
stroke-width: 0.2rem;
height: 0.8rem;
width: 0.8rem;
flex-shrink: 0;
}
svg {
color: var(--color-accent-contrast, var(--color-brand-inverted));
stroke-width: 0.2rem;
height: 0.8rem;
width: 0.8rem;
flex-shrink: 0;
}
&.collapsing {
background-color: transparent !important;
box-shadow: none;
&.collapsing {
background-color: transparent !important;
box-shadow: none;
svg {
color: inherit;
height: 1rem;
width: 1rem;
transition: transform 0.25s ease-in-out;
}
svg {
color: inherit;
height: 1rem;
width: 1rem;
transition: transform 0.25s ease-in-out;
}
&.checked {
svg {
transform: rotate(180deg);
}
}
}
&.checked {
svg {
transform: rotate(180deg);
}
}
}
&:disabled {
box-shadow: none;
cursor: not-allowed;
}
&:disabled {
box-shadow: none;
cursor: not-allowed;
}
}
</style>

View File

@@ -1,103 +1,103 @@
<template>
<div class="chips">
<button
v-for="item in items"
:key="item"
class="iconified-button"
:class="{ selected: selected === item, capitalize: capitalize }"
@click="toggleItem(item)"
>
<CheckIcon v-if="selected === item" />
<span>{{ formatLabel(item) }}</span>
</button>
</div>
<div class="chips">
<button
v-for="item in items"
:key="item"
class="iconified-button"
:class="{ selected: selected === item, capitalize: capitalize }"
@click="toggleItem(item)"
>
<CheckIcon v-if="selected === item" />
<span>{{ formatLabel(item) }}</span>
</button>
</div>
</template>
<script>
import { CheckIcon } from "@modrinth/assets";
import { CheckIcon } from '@modrinth/assets'
export default {
components: {
CheckIcon,
},
props: {
modelValue: {
required: true,
type: String,
},
items: {
required: true,
type: Array,
},
neverEmpty: {
default: true,
type: Boolean,
},
formatLabel: {
default: (x) => x,
type: Function,
},
capitalize: {
type: Boolean,
default: true,
},
},
emits: ["update:modelValue"],
computed: {
selected: {
get() {
return this.modelValue;
},
set(value) {
this.$emit("update:modelValue", value);
},
},
},
created() {
if (this.items.length > 0 && this.neverEmpty) {
this.selected = this.items[0];
}
},
methods: {
toggleItem(item) {
if (this.selected === item && !this.neverEmpty) {
this.selected = null;
} else {
this.selected = item;
}
},
},
};
components: {
CheckIcon,
},
props: {
modelValue: {
required: true,
type: String,
},
items: {
required: true,
type: Array,
},
neverEmpty: {
default: true,
type: Boolean,
},
formatLabel: {
default: (x) => x,
type: Function,
},
capitalize: {
type: Boolean,
default: true,
},
},
emits: ['update:modelValue'],
computed: {
selected: {
get() {
return this.modelValue
},
set(value) {
this.$emit('update:modelValue', value)
},
},
},
created() {
if (this.items.length > 0 && this.neverEmpty) {
this.selected = this.items[0]
}
},
methods: {
toggleItem(item) {
if (this.selected === item && !this.neverEmpty) {
this.selected = null
} else {
this.selected = item
}
},
},
}
</script>
<style lang="scss" scoped>
.chips {
display: flex;
grid-gap: 0.5rem;
flex-wrap: wrap;
display: flex;
grid-gap: 0.5rem;
flex-wrap: wrap;
.iconified-button {
&.capitalize {
text-transform: capitalize;
}
.iconified-button {
&.capitalize {
text-transform: capitalize;
}
svg {
width: 1em;
height: 1em;
}
svg {
width: 1em;
height: 1em;
}
&:focus-visible {
outline: 0.25rem solid #ea80ff;
border-radius: 0.25rem;
}
}
&:focus-visible {
outline: 0.25rem solid #ea80ff;
border-radius: 0.25rem;
}
}
.selected {
color: var(--color-button-text-active);
background-color: var(--color-brand-highlight);
box-shadow:
inset 0 0 0 transparent,
0 0 0 2px var(--color-brand);
}
.selected {
color: var(--color-button-text-active);
background-color: var(--color-brand-highlight);
box-shadow:
inset 0 0 0 transparent,
0 0 0 2px var(--color-brand);
}
}
</style>

View File

@@ -1,129 +1,128 @@
<template>
<NewModal ref="modal" header="Creating a collection">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
Name
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
:placeholder="`Enter collection name...`"
autocomplete="off"
/>
</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>
<span>A sentence or two that describes your collection.</span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
</div>
</div>
<p class="m-0 max-w-[30rem]">
Your new collection will be created as a public collection with
{{ projectIds.length > 0 ? projectIds.length : "no" }}
{{ projectIds.length !== 1 ? "projects" : "project" }}.
</p>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="create">
<PlusIcon aria-hidden="true" />
Create collection
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal.hide()">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal ref="modal" header="Creating a collection">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
Name
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
:placeholder="`Enter collection name...`"
autocomplete="off"
/>
</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>
<span>A sentence or two that describes your collection.</span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
</div>
</div>
<p class="m-0 max-w-[30rem]">
Your new collection will be created as a public collection with
{{ projectIds.length > 0 ? projectIds.length : 'no' }}
{{ projectIds.length !== 1 ? 'projects' : 'project' }}.
</p>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="create">
<PlusIcon aria-hidden="true" />
Create collection
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal.hide()">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup>
import { PlusIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { injectNotificationManager } from "@modrinth/ui";
import { PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
const { addNotification } = injectNotificationManager();
const router = useNativeRouter();
const { addNotification } = injectNotificationManager()
const router = useNativeRouter()
const name = ref("");
const description = ref("");
const name = ref('')
const description = ref('')
const modal = ref();
const modal = ref()
const props = defineProps({
projectIds: {
type: Array,
default() {
return [];
},
},
});
projectIds: {
type: Array,
default() {
return []
},
},
})
async function create() {
startLoading();
try {
const result = await useBaseFetch("collection", {
method: "POST",
body: {
name: name.value.trim(),
description: description.value.trim() || undefined,
projects: props.projectIds,
},
apiVersion: 3,
});
startLoading()
try {
const result = await useBaseFetch('collection', {
method: 'POST',
body: {
name: name.value.trim(),
description: description.value.trim() || undefined,
projects: props.projectIds,
},
apiVersion: 3,
})
await initUserCollections();
await initUserCollections()
modal.value.hide();
await router.push(`/collection/${result.id}`);
} catch (err) {
addNotification({
title: "An error occurred",
text: err?.data?.description || err?.message || err,
type: "error",
});
}
stopLoading();
modal.value.hide()
await router.push(`/collection/${result.id}`)
} catch (err) {
addNotification({
title: 'An error occurred',
text: err?.data?.description || err?.message || err,
type: 'error',
})
}
stopLoading()
}
function show(event) {
name.value = "";
description.value = "";
modal.value.show(event);
name.value = ''
description.value = ''
modal.value.show(event)
}
defineExpose({
show,
});
show,
})
</script>
<style scoped lang="scss">
.modal-creation {
input {
width: 20rem;
max-width: 100%;
}
input {
width: 20rem;
max-width: 100%;
}
.text-input-wrapper {
width: 100%;
}
.text-input-wrapper {
width: 100%;
}
textarea {
min-height: 5rem;
}
textarea {
min-height: 5rem;
}
.input-group {
margin-top: var(--gap-md);
}
.input-group {
margin-top: var(--gap-md);
}
}
</style>

View File

@@ -1,21 +1,21 @@
<template>
<nuxt-link v-if="isLink" :to="to">
<slot />
</nuxt-link>
<span v-else>
<slot />
</span>
<nuxt-link v-if="isLink" :to="to">
<slot />
</nuxt-link>
<span v-else>
<slot />
</span>
</template>
<script setup>
defineProps({
to: {
type: String,
required: true,
},
isLink: {
type: Boolean,
required: true,
},
});
to: {
type: String,
required: true,
},
isLink: {
type: Boolean,
required: true,
},
})
</script>

View File

@@ -1,33 +1,33 @@
<template>
<div class="double-icon">
<slot name="primary" />
<div class="secondary">
<slot name="secondary" />
</div>
</div>
<div class="double-icon">
<slot name="primary" />
<div class="secondary">
<slot name="secondary" />
</div>
</div>
</template>
<style lang="scss" scoped>
.double-icon {
position: relative;
height: fit-content;
line-height: 0;
position: relative;
height: fit-content;
line-height: 0;
.secondary {
position: absolute;
bottom: -4px;
right: -4px;
background-color: var(--color-bg);
padding: var(--spacing-card-xs);
border-radius: 50%;
aspect-ratio: 1 / 1;
width: fit-content;
height: fit-content;
line-height: 0;
.secondary {
position: absolute;
bottom: -4px;
right: -4px;
background-color: var(--color-bg);
padding: var(--spacing-card-xs);
border-radius: 50%;
aspect-ratio: 1 / 1;
width: fit-content;
height: fit-content;
line-height: 0;
svg {
width: 1rem;
height: 1rem;
}
}
svg {
width: 1rem;
height: 1rem;
}
}
}
</style>

View File

@@ -1,94 +1,94 @@
<template>
<div
ref="drop_area"
class="drop-area"
@drop.stop.prevent="
(event) => {
$refs.drop_area.style.visibility = 'hidden';
<div
ref="drop_area"
class="drop-area"
@drop.stop.prevent="
(event) => {
$refs.drop_area.style.visibility = 'hidden'
if (event.dataTransfer && event.dataTransfer.files && fileAllowed) {
$emit('change', event.dataTransfer.files);
}
}
"
@dragenter.prevent="allowDrag"
@dragover.prevent="allowDrag"
@dragleave.prevent="$refs.drop_area.style.visibility = 'hidden'"
/>
if (event.dataTransfer && event.dataTransfer.files && fileAllowed) {
$emit('change', event.dataTransfer.files)
}
}
"
@dragenter.prevent="allowDrag"
@dragover.prevent="allowDrag"
@dragleave.prevent="$refs.drop_area.style.visibility = 'hidden'"
/>
</template>
<script>
export default {
props: {
accept: {
type: String,
default: "",
},
},
emits: ["change"],
data() {
return {
fileAllowed: false,
};
},
mounted() {
document.addEventListener("dragenter", this.allowDrag);
},
methods: {
allowDrag(event) {
const file = event.dataTransfer?.items[0];
props: {
accept: {
type: String,
default: '',
},
},
emits: ['change'],
data() {
return {
fileAllowed: false,
}
},
mounted() {
document.addEventListener('dragenter', this.allowDrag)
},
methods: {
allowDrag(event) {
const file = event.dataTransfer?.items[0]
if (
file &&
this.accept
.split(",")
.reduce((acc, t) => acc || file.type.startsWith(t) || file.type === t || t === "*", false)
) {
this.fileAllowed = true;
event.dataTransfer.dropEffect = "copy";
event.preventDefault();
if (
file &&
this.accept
.split(',')
.reduce((acc, t) => acc || file.type.startsWith(t) || file.type === t || t === '*', false)
) {
this.fileAllowed = true
event.dataTransfer.dropEffect = 'copy'
event.preventDefault()
if (this.$refs.drop_area) {
this.$refs.drop_area.style.visibility = "visible";
}
} else {
this.fileAllowed = false;
if (this.$refs.drop_area) {
this.$refs.drop_area.style.visibility = 'visible'
}
} else {
this.fileAllowed = false
if (this.$refs.drop_area) {
this.$refs.drop_area.style.visibility = "hidden";
}
}
},
},
};
if (this.$refs.drop_area) {
this.$refs.drop_area.style.visibility = 'hidden'
}
}
},
},
}
</script>
<style lang="scss" scoped>
.drop-area {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
visibility: hidden;
background-color: hsla(0, 0%, 0%, 0.5);
transition:
visibility 0.2s ease-in-out,
background-color 0.1s ease-in-out;
display: flex;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
visibility: hidden;
background-color: hsla(0, 0%, 0%, 0.5);
transition:
visibility 0.2s ease-in-out,
background-color 0.1s ease-in-out;
display: flex;
&::before {
--indent: 4rem;
&::before {
--indent: 4rem;
content: " ";
position: relative;
top: var(--indent);
left: var(--indent);
width: calc(100% - (2 * var(--indent)));
height: calc(100% - (2 * var(--indent)));
border-radius: 1rem;
border: 0.25rem dashed var(--color-button-bg);
}
content: ' ';
position: relative;
top: var(--indent);
left: var(--indent);
width: calc(100% - (2 * var(--indent)));
height: calc(100% - (2 * var(--indent)));
border-radius: 1rem;
border: 0.25rem dashed var(--color-button-bg);
}
}
</style>

View File

@@ -1,105 +1,105 @@
<template>
<span v-if="typeOnly" class="environment">
<InfoIcon aria-hidden="true" />
A {{ type }}
</span>
<span
v-else-if="
!['resourcepack', 'shader'].includes(type) &&
!(type === 'plugin' && search) &&
!categories.some((x) => tags.loaderData.dataPackLoaders.includes(x))
"
class="environment"
>
<template v-if="clientSide === 'optional' && serverSide === 'optional'">
<GlobeIcon aria-hidden="true" />
Client or server
</template>
<template v-else-if="clientSide === 'required' && serverSide === 'required'">
<GlobeIcon aria-hidden="true" />
Client and server
</template>
<template
v-else-if="
(clientSide === 'optional' || clientSide === 'required') &&
(serverSide === 'optional' || serverSide === 'unsupported')
"
>
<ClientIcon aria-hidden="true" />
Client
</template>
<template
v-else-if="
(serverSide === 'optional' || serverSide === 'required') &&
(clientSide === 'optional' || clientSide === 'unsupported')
"
>
<ServerIcon aria-hidden="true" />
Server
</template>
<template v-else-if="serverSide === 'unsupported' && clientSide === 'unsupported'">
<GlobeIcon aria-hidden="true" />
Unsupported
</template>
<template v-else-if="alwaysShow">
<InfoIcon aria-hidden="true" />
A {{ type }}
</template>
</span>
<span v-if="typeOnly" class="environment">
<InfoIcon aria-hidden="true" />
A {{ type }}
</span>
<span
v-else-if="
!['resourcepack', 'shader'].includes(type) &&
!(type === 'plugin' && search) &&
!categories.some((x) => tags.loaderData.dataPackLoaders.includes(x))
"
class="environment"
>
<template v-if="clientSide === 'optional' && serverSide === 'optional'">
<GlobeIcon aria-hidden="true" />
Client or server
</template>
<template v-else-if="clientSide === 'required' && serverSide === 'required'">
<GlobeIcon aria-hidden="true" />
Client and server
</template>
<template
v-else-if="
(clientSide === 'optional' || clientSide === 'required') &&
(serverSide === 'optional' || serverSide === 'unsupported')
"
>
<ClientIcon aria-hidden="true" />
Client
</template>
<template
v-else-if="
(serverSide === 'optional' || serverSide === 'required') &&
(clientSide === 'optional' || clientSide === 'unsupported')
"
>
<ServerIcon aria-hidden="true" />
Server
</template>
<template v-else-if="serverSide === 'unsupported' && clientSide === 'unsupported'">
<GlobeIcon aria-hidden="true" />
Unsupported
</template>
<template v-else-if="alwaysShow">
<InfoIcon aria-hidden="true" />
A {{ type }}
</template>
</span>
</template>
<script setup>
import { InfoIcon, ClientIcon, GlobeIcon, ServerIcon } from "@modrinth/assets";
import { ClientIcon, GlobeIcon, InfoIcon, ServerIcon } from '@modrinth/assets'
defineProps({
type: {
type: String,
default: "mod",
},
serverSide: {
type: String,
required: false,
default: "",
},
clientSide: {
type: String,
required: false,
default: "",
},
typeOnly: {
type: Boolean,
required: false,
default: false,
},
alwaysShow: {
type: Boolean,
required: false,
default: false,
},
search: {
type: Boolean,
required: false,
default: false,
},
categories: {
type: Array,
required: false,
default() {
return [];
},
},
});
type: {
type: String,
default: 'mod',
},
serverSide: {
type: String,
required: false,
default: '',
},
clientSide: {
type: String,
required: false,
default: '',
},
typeOnly: {
type: Boolean,
required: false,
default: false,
},
alwaysShow: {
type: Boolean,
required: false,
default: false,
},
search: {
type: Boolean,
required: false,
default: false,
},
categories: {
type: Array,
required: false,
default() {
return []
},
},
})
const tags = useTags();
const tags = useTags()
</script>
<style lang="scss" scoped>
.environment {
display: flex;
color: var(--color-text) !important;
font-weight: bold;
display: flex;
color: var(--color-text) !important;
font-weight: bold;
svg {
margin-right: 0.2rem;
}
svg {
margin-right: 0.2rem;
}
}
</style>

View File

@@ -1,117 +1,117 @@
<template>
<label
:class="{ 'long-style': longStyle }"
:disabled="disabled"
@drop.prevent="handleDrop"
@dragover.prevent
>
<slot />
{{ prompt }}
<input
type="file"
:multiple="multiple"
:accept="accept"
:disabled="disabled"
@change="handleChange"
/>
</label>
<label
:class="{ 'long-style': longStyle }"
:disabled="disabled"
@drop.prevent="handleDrop"
@dragover.prevent
>
<slot />
{{ prompt }}
<input
type="file"
:multiple="multiple"
:accept="accept"
:disabled="disabled"
@change="handleChange"
/>
</label>
</template>
<script>
import { fileIsValid } from "~/helpers/fileUtils.js";
import { fileIsValid } from '~/helpers/fileUtils.js'
export default {
components: {},
props: {
prompt: {
type: String,
default: "Select file",
},
multiple: {
type: Boolean,
default: false,
},
accept: {
type: String,
default: null,
},
/**
* The max file size in bytes
*/
maxSize: {
type: Number,
default: null,
},
showIcon: {
type: Boolean,
default: true,
},
shouldAlwaysReset: {
type: Boolean,
default: false,
},
longStyle: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ["change"],
data() {
return {
files: [],
};
},
methods: {
addFiles(files, shouldNotReset) {
if (!shouldNotReset || this.shouldAlwaysReset) {
this.files = files;
}
components: {},
props: {
prompt: {
type: String,
default: 'Select file',
},
multiple: {
type: Boolean,
default: false,
},
accept: {
type: String,
default: null,
},
/**
* The max file size in bytes
*/
maxSize: {
type: Number,
default: null,
},
showIcon: {
type: Boolean,
default: true,
},
shouldAlwaysReset: {
type: Boolean,
default: false,
},
longStyle: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ['change'],
data() {
return {
files: [],
}
},
methods: {
addFiles(files, shouldNotReset) {
if (!shouldNotReset || this.shouldAlwaysReset) {
this.files = files
}
const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true };
this.files = [...this.files].filter((file) => fileIsValid(file, validationOptions));
const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true }
this.files = [...this.files].filter((file) => fileIsValid(file, validationOptions))
if (this.files.length > 0) {
this.$emit("change", this.files);
}
},
handleDrop(e) {
this.addFiles(e.dataTransfer.files);
},
handleChange(e) {
this.addFiles(e.target.files);
},
},
};
if (this.files.length > 0) {
this.$emit('change', this.files)
}
},
handleDrop(e) {
this.addFiles(e.dataTransfer.files)
},
handleChange(e) {
this.addFiles(e.target.files)
},
},
}
</script>
<style lang="scss" scoped>
label {
flex-direction: unset;
max-height: unset;
flex-direction: unset;
max-height: unset;
svg {
height: 1rem;
}
svg {
height: 1rem;
}
input {
display: none;
}
input {
display: none;
}
&.long-style {
display: flex;
padding: 1.5rem 2rem;
justify-content: center;
align-items: center;
grid-gap: 0.5rem;
background-color: var(--color-button-bg);
border-radius: var(--size-rounded-sm);
border: dashed 0.3rem var(--color-text);
cursor: pointer;
color: var(--color-text-dark);
}
&.long-style {
display: flex;
padding: 1.5rem 2rem;
justify-content: center;
align-items: center;
grid-gap: 0.5rem;
background-color: var(--color-button-bg);
border-radius: var(--size-rounded-sm);
border: dashed 0.3rem var(--color-text);
cursor: pointer;
color: var(--color-text-dark);
}
}
</style>

View File

@@ -1,63 +1,63 @@
<script setup>
const token = defineModel();
const id = ref(null);
const token = defineModel()
const id = ref(null)
function hCaptchaUpdateToken(newToken) {
token.value = newToken;
token.value = newToken
}
function hCaptchaReady() {
window.hCaptchaUpdateToken = hCaptchaUpdateToken;
id.value = window.hcaptcha.render("h-captcha");
window.hCaptchaUpdateToken = hCaptchaUpdateToken
id.value = window.hcaptcha.render('h-captcha')
}
onMounted(() => {
if (window.hcaptcha) {
hCaptchaReady();
} else {
window.hCaptchaReady = hCaptchaReady;
if (window.hcaptcha) {
hCaptchaReady()
} else {
window.hCaptchaReady = hCaptchaReady
useHead({
script: [
{
src: "https://js.hcaptcha.com/1/api.js?render=explicit&onload=hCaptchaReady",
async: true,
defer: true,
},
],
});
}
});
useHead({
script: [
{
src: 'https://js.hcaptcha.com/1/api.js?render=explicit&onload=hCaptchaReady',
async: true,
defer: true,
},
],
})
}
})
defineExpose({
reset: () => {
token.value = null;
window.hcaptcha.reset(id.value);
},
});
reset: () => {
token.value = null
window.hcaptcha.reset(id.value)
},
})
</script>
<template>
<div
id="h-captcha"
class="h-captcha"
data-sitekey="4a7a2c80-68f2-4190-9d52-131c76e0c14e"
:data-theme="$theme.active === 'light' ? 'light' : 'dark'"
data-callback="hCaptchaUpdateToken"
></div>
<div
id="h-captcha"
class="h-captcha"
data-sitekey="4a7a2c80-68f2-4190-9d52-131c76e0c14e"
:data-theme="$theme.active === 'light' ? 'light' : 'dark'"
data-callback="hCaptchaUpdateToken"
></div>
</template>
<style lang="scss">
.h-captcha {
display: flex;
justify-content: center;
overflow: hidden;
border-radius: var(--radius-md);
border: 2px solid var(--color-button-bg);
height: 78px;
display: flex;
justify-content: center;
overflow: hidden;
border-radius: var(--radius-md);
border: 2px solid var(--color-button-bg);
height: 78px;
iframe {
margin: -1px;
}
iframe {
margin: -1px;
}
}
</style>

View File

@@ -1,58 +1,58 @@
<template>
<div class="message-banner">
<div class="message-banner__content" :class="cardClassByType" :aria-label="ariaLabelByType">
<slot></slot>
</div>
</div>
<div class="message-banner">
<div class="message-banner__content" :class="cardClassByType" :aria-label="ariaLabelByType">
<slot></slot>
</div>
</div>
</template>
<script lang="ts" setup>
type MessageType = "information" | "warning";
type MessageType = 'information' | 'warning'
const props = withDefaults(defineProps<{ messageType?: MessageType }>(), {
messageType: "information",
});
const cardClassByType = computed(() => `message-banner__content_${props.messageType}`);
const ariaLabelByType = computed(() => `Banner with ${props.messageType} message`);
messageType: 'information',
})
const cardClassByType = computed(() => `message-banner__content_${props.messageType}`)
const ariaLabelByType = computed(() => `Banner with ${props.messageType} message`)
</script>
<style lang="css" scoped>
.message-banner {
position: relative;
min-height: var(--font-size-2xl);
position: relative;
min-height: var(--font-size-2xl);
background: var(--color-raised-bg);
border-radius: var(--size-rounded-card);
overflow: hidden;
outline: 2px solid transparent;
outline-offset: -2px;
background: var(--color-raised-bg);
border-radius: var(--size-rounded-card);
overflow: hidden;
outline: 2px solid transparent;
outline-offset: -2px;
margin-bottom: var(--spacing-card-md);
margin-bottom: var(--spacing-card-md);
box-shadow: var(--shadow-card);
box-shadow: var(--shadow-card);
line-height: 1.5;
min-height: 0;
line-height: 1.5;
min-height: 0;
}
:slotted(a) {
/* Uses active color to increase contrast */
color: var(--color-link-active);
text-decoration: underline;
/* Uses active color to increase contrast */
color: var(--color-link-active);
text-decoration: underline;
}
.message-banner__content {
padding: var(--spacing-card-md) var(--spacing-card-lg);
padding: var(--spacing-card-md) var(--spacing-card-lg);
}
.message-banner__content_warning {
border-left: 0.5rem solid var(--color-warning-banner-side);
background-color: var(--color-warning-banner-bg);
color: var(--color-warning-banner-text);
border-left: 0.5rem solid var(--color-warning-banner-side);
background-color: var(--color-warning-banner-bg);
color: var(--color-warning-banner-text);
}
.message-banner__content_information {
border-left: 0.5rem solid var(--color-info-banner-side);
background-color: var(--color-info-banner-bg);
color: var(--color-info-banner-text);
border-left: 0.5rem solid var(--color-info-banner-side);
background-color: var(--color-info-banner-bg);
color: var(--color-info-banner-text);
}
</style>

View File

@@ -1,152 +1,152 @@
<template>
<div v-if="shown">
<div
:class="{
shown: actuallyShown,
noblur: !$orElse(cosmetics.advancedRendering, true),
}"
class="modal-overlay"
@click="hide"
/>
<div class="modal-container" :class="{ shown: actuallyShown }">
<div class="modal-body">
<div v-if="header" class="header">
<strong>{{ header }}</strong>
<button class="iconified-button icon-only transparent" @click="hide">
<XIcon />
</button>
</div>
<div class="content">
<slot />
</div>
</div>
</div>
</div>
<div v-else />
<div v-if="shown">
<div
:class="{
shown: actuallyShown,
noblur: !$orElse(cosmetics.advancedRendering, true),
}"
class="modal-overlay"
@click="hide"
/>
<div class="modal-container" :class="{ shown: actuallyShown }">
<div class="modal-body">
<div v-if="header" class="header">
<strong>{{ header }}</strong>
<button class="iconified-button icon-only transparent" @click="hide">
<XIcon />
</button>
</div>
<div class="content">
<slot />
</div>
</div>
</div>
</div>
<div v-else />
</template>
<script>
import { XIcon } from "@modrinth/assets";
import { XIcon } from '@modrinth/assets'
export default {
components: {
XIcon,
},
props: {
header: {
type: String,
default: null,
},
},
setup() {
const cosmetics = useCosmetics();
components: {
XIcon,
},
props: {
header: {
type: String,
default: null,
},
},
setup() {
const cosmetics = useCosmetics()
return { cosmetics };
},
data() {
return {
shown: false,
actuallyShown: false,
};
},
methods: {
show() {
this.shown = true;
setTimeout(() => {
this.actuallyShown = true;
}, 50);
},
hide() {
this.actuallyShown = false;
setTimeout(() => {
this.shown = false;
}, 300);
},
},
};
return { cosmetics }
},
data() {
return {
shown: false,
actuallyShown: false,
}
},
methods: {
show() {
this.shown = true
setTimeout(() => {
this.actuallyShown = true
}, 50)
},
hide() {
this.actuallyShown = false
setTimeout(() => {
this.shown = false
}, 300)
},
},
}
</script>
<style lang="scss" scoped>
.modal-overlay {
visibility: hidden;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 20;
transition: all 0.3s ease-in-out;
visibility: hidden;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 20;
transition: all 0.3s ease-in-out;
&.shown {
opacity: 1;
visibility: visible;
background: hsla(0, 0%, 0%, 0.5);
backdrop-filter: blur(3px);
}
&.shown {
opacity: 1;
visibility: visible;
background: hsla(0, 0%, 0%, 0.5);
backdrop-filter: blur(3px);
}
&.noblur {
backdrop-filter: none;
}
&.noblur {
backdrop-filter: none;
}
}
.modal-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 21;
visibility: hidden;
pointer-events: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 21;
visibility: hidden;
pointer-events: none;
&.shown {
visibility: visible;
&.shown {
visibility: visible;
.modal-body {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
}
.modal-body {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
}
.modal-body {
position: fixed;
box-shadow: var(--shadow-raised), var(--shadow-inset);
border-radius: var(--size-rounded-lg);
max-height: calc(100% - 2 * var(--spacing-card-bg));
overflow-y: auto;
width: 600px;
pointer-events: auto;
outline: 3px solid transparent;
.modal-body {
position: fixed;
box-shadow: var(--shadow-raised), var(--shadow-inset);
border-radius: var(--size-rounded-lg);
max-height: calc(100% - 2 * var(--spacing-card-bg));
overflow-y: auto;
width: 600px;
pointer-events: auto;
outline: 3px solid transparent;
.header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--color-bg);
padding: var(--spacing-card-md) var(--spacing-card-lg);
.header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--color-bg);
padding: var(--spacing-card-md) var(--spacing-card-lg);
strong {
font-size: 1.25rem;
margin: 0.67em 0;
}
}
strong {
font-size: 1.25rem;
margin: 0.67em 0;
}
}
.content {
background-color: var(--color-raised-bg);
}
.content {
background-color: var(--color-raised-bg);
}
transform: translateY(50vh);
visibility: hidden;
opacity: 0;
transition: all 0.25s ease-in-out;
transform: translateY(50vh);
visibility: hidden;
opacity: 0;
transition: all 0.25s ease-in-out;
@media screen and (max-width: 650px) {
width: calc(100% - 2 * var(--spacing-card-bg));
}
}
@media screen and (max-width: 650px) {
width: calc(100% - 2 * var(--spacing-card-bg));
}
}
}
</style>

View File

@@ -1,217 +1,215 @@
<template>
<NewModal ref="modal" header="Creating a project">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
Name
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
placeholder="Enter project name..."
autocomplete="off"
@input="updatedName()"
/>
</div>
<div class="flex flex-col gap-2">
<label for="slug">
<span class="text-lg font-semibold text-contrast">
URL
<span class="text-brand-red">*</span>
</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/project/</div>
<input
id="slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
@input="manualSlug = true"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<label for="visibility" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Visibility
<span class="text-brand-red">*</span>
</span>
<span> The visibility of your project after it has been approved. </span>
</label>
<DropdownSelect
id="visibility"
v-model="visibility"
:options="visibilities"
:display-name="(x) => x.display"
name="Visibility"
/>
</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> A sentence or two that describes your project. </span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
</div>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="createProject">
<PlusIcon aria-hidden="true" />
Create project
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="cancel">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal ref="modal" header="Creating a project">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
Name
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
placeholder="Enter project name..."
autocomplete="off"
@input="updatedName()"
/>
</div>
<div class="flex flex-col gap-2">
<label for="slug">
<span class="text-lg font-semibold text-contrast">
URL
<span class="text-brand-red">*</span>
</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/project/</div>
<input
id="slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
@input="manualSlug = true"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<label for="visibility" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Visibility
<span class="text-brand-red">*</span>
</span>
<span> The visibility of your project after it has been approved. </span>
</label>
<DropdownSelect
id="visibility"
v-model="visibility"
:options="visibilities"
:display-name="(x) => x.display"
name="Visibility"
/>
</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> A sentence or two that describes your project. </span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
</div>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="createProject">
<PlusIcon aria-hidden="true" />
Create project
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="cancel">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup>
import { PlusIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, DropdownSelect, NewModal } from "@modrinth/ui";
import { injectNotificationManager } from "@modrinth/ui";
import { PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, DropdownSelect, injectNotificationManager, NewModal } from '@modrinth/ui'
const { addNotification } = injectNotificationManager();
const { addNotification } = injectNotificationManager()
const router = useRouter();
const app = useNuxtApp();
const router = useRouter()
const props = defineProps({
organizationId: {
type: String,
required: false,
default: null,
},
});
organizationId: {
type: String,
required: false,
default: null,
},
})
const modal = ref();
const modal = ref()
const name = ref("");
const slug = ref("");
const description = ref("");
const manualSlug = ref(false);
const name = ref('')
const slug = ref('')
const description = ref('')
const manualSlug = ref(false)
const visibilities = ref([
{
actual: "approved",
display: "Public",
},
{
actual: "unlisted",
display: "Unlisted",
},
{
actual: "private",
display: "Private",
},
]);
{
actual: 'approved',
display: 'Public',
},
{
actual: 'unlisted',
display: 'Unlisted',
},
{
actual: 'private',
display: 'Private',
},
])
const visibility = ref({
actual: "approved",
display: "Public",
});
actual: 'approved',
display: 'Public',
})
const cancel = () => {
modal.value.hide();
};
modal.value.hide()
}
async function createProject() {
startLoading();
startLoading()
const formData = new FormData();
const formData = new FormData()
const auth = await useAuth();
const auth = await useAuth()
const projectData = {
title: name.value.trim(),
project_type: "mod",
slug: slug.value,
description: description.value.trim(),
body: "",
requested_status: visibility.value.actual,
initial_versions: [],
team_members: [
{
user_id: auth.value.user.id,
name: auth.value.user.username,
role: "Owner",
},
],
categories: [],
client_side: "required",
server_side: "required",
license_id: "LicenseRef-Unknown",
is_draft: true,
};
const projectData = {
title: name.value.trim(),
project_type: 'mod',
slug: slug.value,
description: description.value.trim(),
body: '',
requested_status: visibility.value.actual,
initial_versions: [],
team_members: [
{
user_id: auth.value.user.id,
name: auth.value.user.username,
role: 'Owner',
},
],
categories: [],
client_side: 'required',
server_side: 'required',
license_id: 'LicenseRef-Unknown',
is_draft: true,
}
if (props.organizationId) {
projectData.organization_id = props.organizationId;
}
if (props.organizationId) {
projectData.organization_id = props.organizationId
}
formData.append("data", JSON.stringify(projectData));
formData.append('data', JSON.stringify(projectData))
try {
await useBaseFetch("project", {
method: "POST",
body: formData,
headers: {
"Content-Disposition": formData,
},
});
try {
await useBaseFetch('project', {
method: 'POST',
body: formData,
headers: {
'Content-Disposition': formData,
},
})
modal.value.hide();
await router.push({
name: "type-id",
params: {
type: "project",
id: slug.value,
},
});
} catch (err) {
addNotification({
title: "An error occurred",
text: err.data ? err.data.description : err,
type: "error",
});
}
stopLoading();
modal.value.hide()
await router.push({
name: 'type-id',
params: {
type: 'project',
id: slug.value,
},
})
} catch (err) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
function show(event) {
name.value = "";
slug.value = "";
description.value = "";
manualSlug.value = false;
modal.value.show(event);
name.value = ''
slug.value = ''
description.value = ''
manualSlug.value = false
modal.value.show(event)
}
defineExpose({
show,
});
show,
})
function updatedName() {
if (!manualSlug.value) {
slug.value = name.value
.trim()
.toLowerCase()
.replaceAll(" ", "-")
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, "")
.replaceAll(/--+/gm, "-");
}
if (!manualSlug.value) {
slug.value = name.value
.trim()
.toLowerCase()
.replaceAll(' ', '-')
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, '')
.replaceAll(/--+/gm, '-')
}
}
</script>

View File

@@ -1,160 +1,160 @@
<template>
<nav class="navigation">
<NuxtLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="rowLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="nav-link button-animation"
>
<span>{{ link.label }}</span>
</NuxtLink>
<div
class="nav-indicator"
:style="{
left: positionToMoveX,
top: positionToMoveY,
width: sliderWidth,
opacity: activeIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
></div>
</nav>
<nav class="navigation">
<NuxtLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="rowLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="nav-link button-animation"
>
<span>{{ link.label }}</span>
</NuxtLink>
<div
class="nav-indicator"
:style="{
left: positionToMoveX,
top: positionToMoveY,
width: sliderWidth,
opacity: activeIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
></div>
</nav>
</template>
<script setup>
const route = useNativeRoute();
const route = useNativeRoute()
const props = defineProps({
links: {
default: () => [],
type: Array,
},
query: {
default: null,
type: String,
},
});
links: {
default: () => [],
type: Array,
},
query: {
default: null,
type: String,
},
})
const sliderPositionX = ref(0);
const sliderPositionY = ref(18);
const selectedElementWidth = ref(0);
const activeIndex = ref(-1);
const oldIndex = ref(-1);
const sliderPositionX = ref(0)
const sliderPositionY = ref(18)
const selectedElementWidth = ref(0)
const activeIndex = ref(-1)
const oldIndex = ref(-1)
const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
);
const positionToMoveX = computed(() => `${sliderPositionX.value}px`);
const positionToMoveY = computed(() => `${sliderPositionY.value}px`);
const sliderWidth = computed(() => `${selectedElementWidth.value}px`);
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
)
const positionToMoveX = computed(() => `${sliderPositionX.value}px`)
const positionToMoveY = computed(() => `${sliderPositionY.value}px`)
const sliderWidth = computed(() => `${selectedElementWidth.value}px`)
function pickLink() {
activeIndex.value = props.query
? filteredLinks.value.findIndex(
(x) => (x.href === "" ? undefined : x.href) === route.path[props.query],
)
: filteredLinks.value.findIndex((x) => x.href === decodeURIComponent(route.path));
activeIndex.value = props.query
? filteredLinks.value.findIndex(
(x) => (x.href === '' ? undefined : x.href) === route.path[props.query],
)
: filteredLinks.value.findIndex((x) => x.href === decodeURIComponent(route.path))
if (activeIndex.value !== -1) {
startAnimation();
} else {
oldIndex.value = -1;
sliderPositionX.value = 0;
selectedElementWidth.value = 0;
}
if (activeIndex.value !== -1) {
startAnimation()
} else {
oldIndex.value = -1
sliderPositionX.value = 0
selectedElementWidth.value = 0
}
}
const rowLinkElements = ref();
const rowLinkElements = ref()
function startAnimation() {
const el = rowLinkElements.value[activeIndex.value].$el;
const el = rowLinkElements.value[activeIndex.value].$el
if (!el || !el.offsetParent) return;
if (!el || !el.offsetParent) return
sliderPositionX.value = el.offsetLeft;
sliderPositionY.value = el.offsetTop + el.offsetHeight;
selectedElementWidth.value = el.offsetWidth;
sliderPositionX.value = el.offsetLeft
sliderPositionY.value = el.offsetTop + el.offsetHeight
selectedElementWidth.value = el.offsetWidth
}
onMounted(() => {
window.addEventListener("resize", pickLink);
pickLink();
});
window.addEventListener('resize', pickLink)
pickLink()
})
onUnmounted(() => {
window.removeEventListener("resize", pickLink);
});
window.removeEventListener('resize', pickLink)
})
watch(route, () => pickLink());
watch(route, () => pickLink())
</script>
<style lang="scss" scoped>
.navigation {
display: flex;
flex-direction: row;
align-items: center;
grid-gap: 1rem;
flex-wrap: wrap;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
grid-gap: 1rem;
flex-wrap: wrap;
position: relative;
.nav-link {
text-transform: capitalize;
font-weight: var(--font-weight-bold);
color: var(--color-text);
position: relative;
.nav-link {
text-transform: capitalize;
font-weight: var(--font-weight-bold);
color: var(--color-text);
position: relative;
&:hover {
color: var(--color-text);
&:hover {
color: var(--color-text);
&::after {
opacity: 0.4;
}
}
&::after {
opacity: 0.4;
}
}
&:active::after {
opacity: 0.2;
}
&:active::after {
opacity: 0.2;
}
&.router-link-exact-active {
color: var(--color-text);
&.router-link-exact-active {
color: var(--color-text);
&:not(:focus-visible) {
outline: 2px solid transparent;
outline-offset: 6px;
border-radius: 0.25rem;
}
&:not(:focus-visible) {
outline: 2px solid transparent;
outline-offset: 6px;
border-radius: 0.25rem;
}
&::after {
opacity: 1;
}
}
}
&::after {
opacity: 1;
}
}
}
&.use-animation {
.nav-link {
&.is-active::after {
opacity: 0;
}
}
}
&.use-animation {
.nav-link {
&.is-active::after {
opacity: 0;
}
}
}
.nav-indicator {
position: absolute;
height: 0.25rem;
bottom: -5px;
left: 0;
width: 3rem;
transition: all ease-in-out 0.2s;
border-radius: var(--size-rounded-max);
background-color: var(--color-brand);
outline: 2px solid transparent;
outline-offset: -2px;
.nav-indicator {
position: absolute;
height: 0.25rem;
bottom: -5px;
left: 0;
width: 3rem;
transition: all ease-in-out 0.2s;
border-radius: var(--size-rounded-max);
background-color: var(--color-brand);
outline: 2px solid transparent;
outline-offset: -2px;
@media (prefers-reduced-motion) {
transition: none !important;
}
}
@media (prefers-reduced-motion) {
transition: none !important;
}
}
}
</style>

View File

@@ -1,32 +1,32 @@
<template>
<nav>
<ul>
<slot />
</ul>
</nav>
<nav>
<ul>
<slot />
</ul>
</nav>
</template>
<script>
export default {};
export default {}
</script>
<style lang="scss" scoped>
ul {
display: flex;
flex-direction: column;
grid-gap: var(--spacing-card-xs);
flex-wrap: wrap;
list-style-type: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
grid-gap: var(--spacing-card-xs);
flex-wrap: wrap;
list-style-type: none;
margin: 0;
padding: 0;
> :first-child {
margin-top: 0;
}
> :first-child {
margin-top: 0;
}
}
li {
display: unset;
text-align: unset;
display: unset;
text-align: unset;
}
</style>

View File

@@ -1,116 +1,116 @@
<template>
<NuxtLink v-if="link !== null" class="nav-link button-base" :to="link">
<div class="nav-content">
<slot />
<span>{{ label }}</span>
<span v-if="beta" class="beta-badge">BETA</span>
<span v-if="chevron" class="chevron"><ChevronRightIcon /></span>
</div>
</NuxtLink>
<button
v-else-if="action"
class="nav-link button-base"
:class="{ 'danger-button': danger }"
@click="action"
>
<span class="nav-content">
<slot />
<span>{{ label }}</span>
<span v-if="beta" class="beta-badge">BETA</span>
</span>
</button>
<span v-else>i forgor 💀</span>
<NuxtLink v-if="link !== null" class="nav-link button-base" :to="link">
<div class="nav-content">
<slot />
<span>{{ label }}</span>
<span v-if="beta" class="beta-badge">BETA</span>
<span v-if="chevron" class="chevron"><ChevronRightIcon /></span>
</div>
</NuxtLink>
<button
v-else-if="action"
class="nav-link button-base"
:class="{ 'danger-button': danger }"
@click="action"
>
<span class="nav-content">
<slot />
<span>{{ label }}</span>
<span v-if="beta" class="beta-badge">BETA</span>
</span>
</button>
<span v-else>i forgor 💀</span>
</template>
<script>
import { ChevronRightIcon } from "@modrinth/assets";
import { ChevronRightIcon } from '@modrinth/assets'
export default {
components: {
ChevronRightIcon,
},
props: {
link: {
default: null,
type: String,
},
action: {
default: null,
type: Function,
},
label: {
required: true,
type: String,
},
beta: {
default: false,
type: Boolean,
},
chevron: {
default: false,
type: Boolean,
},
danger: {
default: false,
type: Boolean,
},
},
};
components: {
ChevronRightIcon,
},
props: {
link: {
default: null,
type: String,
},
action: {
default: null,
type: Function,
},
label: {
required: true,
type: String,
},
beta: {
default: false,
type: Boolean,
},
chevron: {
default: false,
type: Boolean,
},
danger: {
default: false,
type: Boolean,
},
},
}
</script>
<style lang="scss" scoped>
.nav-link {
font-weight: var(--font-weight-bold);
background-color: transparent;
color: var(--text-color);
position: relative;
display: flex;
flex-direction: row;
gap: 0.25rem;
box-shadow: none;
padding: 0;
width: 100%;
outline: none;
font-weight: var(--font-weight-bold);
background-color: transparent;
color: var(--text-color);
position: relative;
display: flex;
flex-direction: row;
gap: 0.25rem;
box-shadow: none;
padding: 0;
width: 100%;
outline: none;
:where(.nav-link) {
--text-color: var(--color-text);
--background-color: var(--color-raised-bg);
}
:where(.nav-link) {
--text-color: var(--color-text);
--background-color: var(--color-raised-bg);
}
.nav-content {
box-sizing: border-box;
padding: 0.5rem 0.75rem;
border-radius: var(--size-rounded-sm);
display: flex;
align-items: center;
gap: 0.4rem;
flex-grow: 1;
background-color: var(--background-color);
}
.nav-content {
box-sizing: border-box;
padding: 0.5rem 0.75rem;
border-radius: var(--size-rounded-sm);
display: flex;
align-items: center;
gap: 0.4rem;
flex-grow: 1;
background-color: var(--background-color);
}
&:focus-visible {
.nav-content {
border-radius: 0.25rem;
}
}
&:focus-visible {
.nav-content {
border-radius: 0.25rem;
}
}
&.router-link-exact-active {
outline: 2px solid transparent;
border-radius: 0.25rem;
&.router-link-exact-active {
outline: 2px solid transparent;
border-radius: 0.25rem;
.nav-content {
color: var(--color-button-text-active);
background-color: var(--color-button-bg);
box-shadow: none;
}
}
.nav-content {
color: var(--color-button-text-active);
background-color: var(--color-button-bg);
box-shadow: none;
}
}
.beta-badge {
margin: 0;
}
.beta-badge {
margin: 0;
}
.chevron {
margin-left: auto;
}
.chevron {
margin-left: auto;
}
}
</style>

View File

@@ -1,173 +1,173 @@
<template>
<nav
ref="scrollContainer"
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<NuxtLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="tabLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="{
'text-button-textSelected': activeIndex === index && !subpageSelected,
'text-contrast': activeIndex === index && subpageSelected,
}"
>
<component :is="link.icon" v-if="link.icon" class="size-5" />
<span class="text-nowrap">{{ link.label }}</span>
</NuxtLink>
<div
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'
}`"
:style="{
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
></div>
</nav>
<nav
ref="scrollContainer"
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<NuxtLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="tabLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="{
'text-button-textSelected': activeIndex === index && !subpageSelected,
'text-contrast': activeIndex === index && subpageSelected,
}"
>
<component :is="link.icon" v-if="link.icon" class="size-5" />
<span class="text-nowrap">{{ link.label }}</span>
</NuxtLink>
<div
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'
}`"
:style="{
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
></div>
</nav>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import { computed, onMounted, ref, watch } from 'vue'
const route = useNativeRoute();
const route = useNativeRoute()
interface Tab {
label: string;
href: string;
shown?: boolean;
icon?: string;
subpages?: string[];
label: string
href: string
shown?: boolean
icon?: string
subpages?: string[]
}
const props = defineProps<{
links: Tab[];
query?: string;
}>();
links: Tab[]
query?: string
}>()
const scrollContainer = ref<HTMLElement | null>(null);
const scrollContainer = ref<HTMLElement | null>(null)
const sliderLeft = ref(4);
const sliderTop = ref(4);
const sliderRight = ref(4);
const sliderBottom = ref(4);
const activeIndex = ref(-1);
const subpageSelected = ref(false);
const sliderLeft = ref(4)
const sliderTop = ref(4)
const sliderRight = ref(4)
const sliderBottom = ref(4)
const activeIndex = ref(-1)
const subpageSelected = ref(false)
const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
);
const sliderLeftPx = computed(() => `${sliderLeft.value}px`);
const sliderTopPx = computed(() => `${sliderTop.value}px`);
const sliderRightPx = computed(() => `${sliderRight.value}px`);
const sliderBottomPx = computed(() => `${sliderBottom.value}px`);
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
)
const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
const sliderTopPx = computed(() => `${sliderTop.value}px`)
const sliderRightPx = computed(() => `${sliderRight.value}px`)
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
const tabLinkElements = ref();
const tabLinkElements = ref()
function pickLink() {
let index = -1;
subpageSelected.value = false;
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i];
if (props.query) {
if (route.query[props.query] === link.href || (!route.query[props.query] && !link.href)) {
index = i;
break;
}
} else if (decodeURIComponent(route.path) === link.href) {
index = i;
break;
} else if (
decodeURIComponent(route.path).includes(link.href) ||
(link.subpages &&
link.subpages.some((subpage) => decodeURIComponent(route.path).includes(subpage)))
) {
index = i;
subpageSelected.value = true;
break;
}
}
activeIndex.value = index;
let index = -1
subpageSelected.value = false
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]
if (props.query) {
if (route.query[props.query] === link.href || (!route.query[props.query] && !link.href)) {
index = i
break
}
} else if (decodeURIComponent(route.path) === link.href) {
index = i
break
} else if (
decodeURIComponent(route.path).includes(link.href) ||
(link.subpages &&
link.subpages.some((subpage) => decodeURIComponent(route.path).includes(subpage)))
) {
index = i
subpageSelected.value = true
break
}
}
activeIndex.value = index
if (activeIndex.value !== -1) {
startAnimation();
} else {
sliderLeft.value = 0;
sliderRight.value = 0;
}
if (activeIndex.value !== -1) {
startAnimation()
} else {
sliderLeft.value = 0
sliderRight.value = 0
}
}
function startAnimation() {
const el = tabLinkElements.value[activeIndex.value]?.$el;
const el = tabLinkElements.value[activeIndex.value]?.$el
if (!el || !el.offsetParent) return;
if (!el || !el.offsetParent) return
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
};
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
}
if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left;
sliderRight.value = newValues.right;
sliderTop.value = newValues.top;
sliderBottom.value = newValues.bottom;
} else {
const delay = 200;
if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
} else {
const delay = 200
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left;
setTimeout(() => {
sliderRight.value = newValues.right;
}, delay);
} else {
sliderRight.value = newValues.right;
setTimeout(() => {
sliderLeft.value = newValues.left;
}, delay);
}
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left
setTimeout(() => {
sliderRight.value = newValues.right
}, delay)
} else {
sliderRight.value = newValues.right
setTimeout(() => {
sliderLeft.value = newValues.left
}, delay)
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top;
setTimeout(() => {
sliderBottom.value = newValues.bottom;
}, delay);
} else {
sliderBottom.value = newValues.bottom;
setTimeout(() => {
sliderTop.value = newValues.top;
}, delay);
}
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top
setTimeout(() => {
sliderBottom.value = newValues.bottom
}, delay)
} else {
sliderBottom.value = newValues.bottom
setTimeout(() => {
sliderTop.value = newValues.top
}, delay)
}
}
}
onMounted(() => {
pickLink();
});
pickLink()
})
watch(
() => [route.path, route.query],
() => pickLink(),
);
() => [route.path, route.query],
() => pickLink(),
)
</script>
<style scoped>
.navtabs-transition {
transition:
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
transition:
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
.card-shadow {
box-shadow: var(--shadow-card);
box-shadow: var(--shadow-card);
}
</style>

View File

@@ -1,55 +1,57 @@
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { MailIcon, CheckIcon } from "@modrinth/assets";
import { ref } from "vue";
import { useBaseFetch } from "~/composables/fetch.js";
import { CheckIcon, MailIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { ref } from 'vue'
const auth = await useAuth();
const showSubscriptionConfirmation = ref(false);
import { useBaseFetch } from '~/composables/fetch.js'
const auth = await useAuth()
const showSubscriptionConfirmation = ref(false)
const showSubscribeButton = useAsyncData(
async () => {
if (auth.value?.user) {
try {
const { subscribed } = await useBaseFetch("auth/email/subscribe", {
method: "GET",
});
return !subscribed;
} catch {
return true;
}
} else {
return false;
}
},
{ watch: [auth], server: false },
);
async () => {
if (auth.value?.user) {
try {
const { subscribed } = await useBaseFetch('auth/email/subscribe', {
method: 'GET',
})
return !subscribed
} catch {
return true
}
} else {
return false
}
},
{ watch: [auth], server: false },
)
async function subscribe() {
try {
await useBaseFetch("auth/email/subscribe", {
method: "POST",
});
showSubscriptionConfirmation.value = true;
} catch {
} finally {
setTimeout(() => {
showSubscriptionConfirmation.value = false;
showSubscribeButton.status.value = "success";
showSubscribeButton.data.value = false;
}, 2500);
}
try {
await useBaseFetch('auth/email/subscribe', {
method: 'POST',
})
showSubscriptionConfirmation.value = true
} catch {
// Ignored
} finally {
setTimeout(() => {
showSubscriptionConfirmation.value = false
showSubscribeButton.status.value = 'success'
showSubscribeButton.data.value = false
}, 2500)
}
}
</script>
<template>
<ButtonStyled
v-if="showSubscribeButton.status.value === 'success' && showSubscribeButton.data.value"
color="brand"
type="outlined"
>
<button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe">
<template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template>
<template v-else> <CheckIcon /> Subscribed! </template>
</button>
</ButtonStyled>
<ButtonStyled
v-if="showSubscribeButton.status.value === 'success' && showSubscribeButton.data.value"
color="brand"
type="outlined"
>
<button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe">
<template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template>
<template v-else> <CheckIcon /> Subscribed! </template>
</button>
</ButtonStyled>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -1,128 +1,128 @@
<template>
<nav
ref="scrollContainer"
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<button
v-for="(option, index) in options"
:key="`option-group-${index}`"
ref="optionButtons"
class="button-animation z-[1] flex flex-row items-center gap-2 rounded-full bg-transparent px-4 py-2 font-semibold"
:class="{
'text-button-textSelected': modelValue === option,
'text-primary': modelValue !== option,
}"
@click="setOption(option)"
>
<slot :option="option" :selected="modelValue === option" />
</button>
<div
class="navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full bg-button-bgSelected p-1"
:style="{
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: initialized ? 1 : 0,
}"
aria-hidden="true"
></div>
</nav>
<nav
ref="scrollContainer"
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<button
v-for="(option, index) in options"
:key="`option-group-${index}`"
ref="optionButtons"
class="button-animation z-[1] flex flex-row items-center gap-2 rounded-full bg-transparent px-4 py-2 font-semibold"
:class="{
'text-button-textSelected': modelValue === option,
'text-primary': modelValue !== option,
}"
@click="setOption(option)"
>
<slot :option="option" :selected="modelValue === option" />
</button>
<div
class="navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full bg-button-bgSelected p-1"
:style="{
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: initialized ? 1 : 0,
}"
aria-hidden="true"
></div>
</nav>
</template>
<script setup lang="ts" generic="T">
import { ref, computed, onMounted } from "vue";
import { computed, onMounted, ref } from 'vue'
const modelValue = defineModel<T>({ required: true });
const modelValue = defineModel<T>({ required: true })
const props = defineProps<{
options: T[];
}>();
options: T[]
}>()
const scrollContainer = ref<HTMLElement | null>(null);
const scrollContainer = ref<HTMLElement | null>(null)
const sliderLeft = ref(4);
const sliderTop = ref(4);
const sliderRight = ref(4);
const sliderBottom = ref(4);
const sliderLeft = ref(4)
const sliderTop = ref(4)
const sliderRight = ref(4)
const sliderBottom = ref(4)
const sliderLeftPx = computed(() => `${sliderLeft.value}px`);
const sliderTopPx = computed(() => `${sliderTop.value}px`);
const sliderRightPx = computed(() => `${sliderRight.value}px`);
const sliderBottomPx = computed(() => `${sliderBottom.value}px`);
const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
const sliderTopPx = computed(() => `${sliderTop.value}px`)
const sliderRightPx = computed(() => `${sliderRight.value}px`)
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
const optionButtons = ref();
const optionButtons = ref()
const initialized = ref(false);
const initialized = ref(false)
function setOption(option: T) {
modelValue.value = option;
modelValue.value = option
}
watch(modelValue, () => {
startAnimation(props.options.indexOf(modelValue.value));
});
startAnimation(props.options.indexOf(modelValue.value))
})
function startAnimation(index: number) {
const el = optionButtons.value[index];
const el = optionButtons.value[index]
if (!el || !el.offsetParent) return;
if (!el || !el.offsetParent) return
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
};
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
}
if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left;
sliderRight.value = newValues.right;
sliderTop.value = newValues.top;
sliderBottom.value = newValues.bottom;
} else {
const delay = 200;
if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
} else {
const delay = 200
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left;
setTimeout(() => {
sliderRight.value = newValues.right;
}, delay);
} else {
sliderRight.value = newValues.right;
setTimeout(() => {
sliderLeft.value = newValues.left;
}, delay);
}
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left
setTimeout(() => {
sliderRight.value = newValues.right
}, delay)
} else {
sliderRight.value = newValues.right
setTimeout(() => {
sliderLeft.value = newValues.left
}, delay)
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top;
setTimeout(() => {
sliderBottom.value = newValues.bottom;
}, delay);
} else {
sliderBottom.value = newValues.bottom;
setTimeout(() => {
sliderTop.value = newValues.top;
}, delay);
}
}
initialized.value = true;
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top
setTimeout(() => {
sliderBottom.value = newValues.bottom
}, delay)
} else {
sliderBottom.value = newValues.bottom
setTimeout(() => {
sliderTop.value = newValues.top
}, delay)
}
}
initialized.value = true
}
onMounted(() => {
startAnimation(props.options.indexOf(modelValue.value));
});
startAnimation(props.options.indexOf(modelValue.value))
})
</script>
<style scoped>
.navtabs-transition {
transition:
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
transition:
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
.card-shadow {
box-shadow: var(--shadow-card);
box-shadow: var(--shadow-card);
}
</style>

View File

@@ -1,166 +1,166 @@
<template>
<NewModal ref="modal" header="Creating an organization">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
Name
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
:placeholder="`Enter organization name...`"
autocomplete="off"
@input="updateSlug"
/>
</div>
<div class="flex flex-col gap-2">
<label for="slug">
<span class="text-lg font-semibold text-contrast">
URL
<span class="text-brand-red">*</span>
</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/organization/</div>
<input
id="slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
@input="setManualSlug"
/>
</div>
</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>A sentence or two that describes your organization.</span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
</div>
</div>
<p class="m-0 max-w-[30rem]">
You will be the owner of this organization, but you can invite other members and transfer
ownership at any time.
</p>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="createOrganization">
<PlusIcon aria-hidden="true" />
Create organization
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal ref="modal" header="Creating an organization">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
Name
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
:placeholder="`Enter organization name...`"
autocomplete="off"
@input="updateSlug"
/>
</div>
<div class="flex flex-col gap-2">
<label for="slug">
<span class="text-lg font-semibold text-contrast">
URL
<span class="text-brand-red">*</span>
</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/organization/</div>
<input
id="slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
@input="setManualSlug"
/>
</div>
</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>A sentence or two that describes your organization.</span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
</div>
</div>
<p class="m-0 max-w-[30rem]">
You will be the owner of this organization, but you can invite other members and transfer
ownership at any time.
</p>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="createOrganization">
<PlusIcon aria-hidden="true" />
Create organization
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { PlusIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal, injectNotificationManager } from "@modrinth/ui";
import { ref } from "vue";
import { PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
import { ref } from 'vue'
const router = useNativeRouter();
const { addNotification } = injectNotificationManager();
const router = useNativeRouter()
const { addNotification } = injectNotificationManager()
const name = ref<string>("");
const slug = ref<string>("");
const description = ref<string>("");
const manualSlug = ref<boolean>(false);
const modal = ref<InstanceType<typeof NewModal>>();
const name = ref<string>('')
const slug = ref<string>('')
const description = ref<string>('')
const manualSlug = ref<boolean>(false)
const modal = ref<InstanceType<typeof NewModal>>()
async function createOrganization(): Promise<void> {
startLoading();
try {
const value = {
name: name.value.trim(),
description: description.value.trim(),
slug: slug.value.trim().replace(/ +/g, ""),
};
startLoading()
try {
const value = {
name: name.value.trim(),
description: description.value.trim(),
slug: slug.value.trim().replace(/ +/g, ''),
}
const result: any = await useBaseFetch("organization", {
method: "POST",
body: JSON.stringify(value),
apiVersion: 3,
});
const result: any = await useBaseFetch('organization', {
method: 'POST',
body: JSON.stringify(value),
apiVersion: 3,
})
modal.value?.hide();
modal.value?.hide()
await router.push(`/organization/${result.slug}`);
} catch (err: any) {
console.error(err);
addNotification({
title: "An error occurred",
text: err.data ? err.data.description : err,
type: "error",
});
}
stopLoading();
await router.push(`/organization/${result.slug}`)
} catch (err: any) {
console.error(err)
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
function show(event?: MouseEvent): void {
name.value = "";
description.value = "";
modal.value?.show(event);
name.value = ''
description.value = ''
modal.value?.show(event)
}
function hide(): void {
modal.value?.hide();
modal.value?.hide()
}
function updateSlug(): void {
if (!manualSlug.value) {
slug.value = name.value
.trim()
.toLowerCase()
.replaceAll(" ", "-")
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, "")
.replaceAll(/--+/gm, "-");
}
if (!manualSlug.value) {
slug.value = name.value
.trim()
.toLowerCase()
.replaceAll(' ', '-')
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, '')
.replaceAll(/--+/gm, '-')
}
}
function setManualSlug(): void {
manualSlug.value = true;
manualSlug.value = true
}
defineExpose({
show,
});
show,
})
</script>
<style scoped lang="scss">
.modal-creation {
input {
width: 20rem;
max-width: 100%;
}
input {
width: 20rem;
max-width: 100%;
}
.text-input-wrapper {
width: 100%;
}
.text-input-wrapper {
width: 100%;
}
textarea {
min-height: 5rem;
}
textarea {
min-height: 5rem;
}
.input-group {
margin-top: var(--gap-md);
}
.input-group {
margin-top: var(--gap-md);
}
}
</style>

View File

@@ -1,247 +1,247 @@
<template>
<div>
<Modal ref="modalOpen" header="Transfer Projects">
<div class="universal-modal items">
<div class="table">
<div class="table-head table-row">
<div class="check-cell table-cell">
<Checkbox
:model-value="selectedProjects.length === props.projects.length"
@update:model-value="toggleSelectedProjects()"
/>
</div>
<div class="table-cell">Icon</div>
<div class="table-cell">Name</div>
<div class="table-cell">ID</div>
<div class="table-cell">Type</div>
<div class="table-cell" />
</div>
<div v-for="project in props.projects" :key="`project-${project.id}`" class="table-row">
<div class="check-cell table-cell">
<Checkbox
:disabled="(project.permissions & EDIT_DETAILS) === EDIT_DETAILS"
:model-value="selectedProjects.includes(project)"
@update:model-value="
selectedProjects.includes(project)
? (selectedProjects = selectedProjects.filter((it) => it !== project))
: selectedProjects.push(project)
"
/>
</div>
<div class="table-cell">
<nuxt-link tabindex="-1" :to="`/project/${project.slug ? project.slug : project.id}`">
<Avatar
:src="project.icon_url"
aria-hidden="true"
:alt="'Icon for ' + project.name"
no-shadow
/>
</nuxt-link>
</div>
<div>
<Modal ref="modalOpen" header="Transfer Projects">
<div class="universal-modal items">
<div class="table">
<div class="table-head table-row">
<div class="check-cell table-cell">
<Checkbox
:model-value="selectedProjects.length === props.projects.length"
@update:model-value="toggleSelectedProjects()"
/>
</div>
<div class="table-cell">Icon</div>
<div class="table-cell">Name</div>
<div class="table-cell">ID</div>
<div class="table-cell">Type</div>
<div class="table-cell" />
</div>
<div v-for="project in props.projects" :key="`project-${project.id}`" class="table-row">
<div class="check-cell table-cell">
<Checkbox
:disabled="(project.permissions & EDIT_DETAILS) === EDIT_DETAILS"
:model-value="selectedProjects.includes(project)"
@update:model-value="
selectedProjects.includes(project)
? (selectedProjects = selectedProjects.filter((it) => it !== project))
: selectedProjects.push(project)
"
/>
</div>
<div class="table-cell">
<nuxt-link tabindex="-1" :to="`/project/${project.slug ? project.slug : project.id}`">
<Avatar
:src="project.icon_url"
aria-hidden="true"
:alt="'Icon for ' + project.name"
no-shadow
/>
</nuxt-link>
</div>
<div class="table-cell">
<span class="project-title">
<nuxt-link
class="hover-link wrap-as-needed"
:to="`/project/${project.slug ? project.slug : project.id}`"
>
{{ project.title }}
</nuxt-link>
</span>
</div>
<div class="table-cell">
<span class="project-title">
<nuxt-link
class="hover-link wrap-as-needed"
:to="`/project/${project.slug ? project.slug : project.id}`"
>
{{ project.title }}
</nuxt-link>
</span>
</div>
<div class="table-cell">
<CopyCode :text="project.id" />
</div>
<div class="table-cell">
<CopyCode :text="project.id" />
</div>
<div class="table-cell">
<BoxIcon />
<span>{{
formatProjectType(
$getProjectTypeForDisplay(
project.project_types?.[0] ?? "project",
project.loaders,
),
)
}}</span>
</div>
<div class="table-cell">
<BoxIcon />
<span>{{
formatProjectType(
$getProjectTypeForDisplay(
project.project_types?.[0] ?? 'project',
project.loaders,
),
)
}}</span>
</div>
<div class="table-cell">
<nuxt-link
class="btn icon-only"
:to="`/project/${project.slug ? project.slug : project.id}/settings`"
>
<SettingsIcon />
</nuxt-link>
</div>
</div>
</div>
<div class="push-right input-group">
<Button @click="$refs.modalOpen?.hide()">
<XIcon />
Cancel
</Button>
<Button :disabled="!selectedProjects?.length" color="primary" @click="onSubmitHandler()">
<TransferIcon />
<span>
Transfer
<span>
{{
selectedProjects.length === props.projects.length
? "All"
: selectedProjects.length
}}
</span>
<span>
{{ " " }}
{{ selectedProjects.length === 1 ? "project" : "projects" }}
</span>
</span>
</Button>
</div>
</div>
</Modal>
<Button @click="$refs.modalOpen?.show()">
<TransferIcon />
<span>Transfer projects</span>
</Button>
</div>
<div class="table-cell">
<nuxt-link
class="btn icon-only"
:to="`/project/${project.slug ? project.slug : project.id}/settings`"
>
<SettingsIcon />
</nuxt-link>
</div>
</div>
</div>
<div class="push-right input-group">
<Button @click="$refs.modalOpen?.hide()">
<XIcon />
Cancel
</Button>
<Button :disabled="!selectedProjects?.length" color="primary" @click="onSubmitHandler()">
<TransferIcon />
<span>
Transfer
<span>
{{
selectedProjects.length === props.projects.length
? 'All'
: selectedProjects.length
}}
</span>
<span>
{{ ' ' }}
{{ selectedProjects.length === 1 ? 'project' : 'projects' }}
</span>
</span>
</Button>
</div>
</div>
</Modal>
<Button @click="$refs.modalOpen?.show()">
<TransferIcon />
<span>Transfer projects</span>
</Button>
</div>
</template>
<script setup>
import { BoxIcon, SettingsIcon, TransferIcon, XIcon } from "@modrinth/assets";
import { Button, Modal, Checkbox, CopyCode, Avatar } from "@modrinth/ui";
import { formatProjectType } from "@modrinth/utils";
import { BoxIcon, SettingsIcon, TransferIcon, XIcon } from '@modrinth/assets'
import { Avatar, Button, Checkbox, CopyCode, Modal } from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
const modalOpen = ref(null);
const modalOpen = ref(null)
const props = defineProps({
projects: {
type: Array,
required: true,
},
});
projects: {
type: Array,
required: true,
},
})
// define emit for submission
const emit = defineEmits(["submit"]);
const emit = defineEmits(['submit'])
const selectedProjects = ref([]);
const selectedProjects = ref([])
const toggleSelectedProjects = () => {
if (selectedProjects.value.length === props.projects.length) {
selectedProjects.value = [];
} else {
selectedProjects.value = props.projects;
}
};
if (selectedProjects.value.length === props.projects.length) {
selectedProjects.value = []
} else {
selectedProjects.value = props.projects
}
}
const onSubmitHandler = () => {
if (selectedProjects.value.length === 0) {
return;
}
emit("submit", selectedProjects.value);
selectedProjects.value = [];
modalOpen.value?.hide();
};
if (selectedProjects.value.length === 0) {
return
}
emit('submit', selectedProjects.value)
selectedProjects.value = []
modalOpen.value?.hide()
}
</script>
<style lang="scss" scoped>
.table {
display: grid;
border-radius: var(--radius-md);
overflow: hidden;
margin-top: var(--gap-md);
border: 1px solid var(--color-button-bg);
background-color: var(--color-raised-bg);
display: grid;
border-radius: var(--radius-md);
overflow: hidden;
margin-top: var(--gap-md);
border: 1px solid var(--color-button-bg);
background-color: var(--color-raised-bg);
.table-row {
grid-template-columns: 2.75rem 3.75rem 2fr 1fr 1fr 3.5rem;
}
.table-row {
grid-template-columns: 2.75rem 3.75rem 2fr 1fr 1fr 3.5rem;
}
.table-cell {
display: flex;
align-items: center;
gap: var(--gap-xs);
padding: var(--gap-md);
padding-left: 0;
}
.table-cell {
display: flex;
align-items: center;
gap: var(--gap-xs);
padding: var(--gap-md);
padding-left: 0;
}
.check-cell {
padding-left: var(--gap-md);
}
.check-cell {
padding-left: var(--gap-md);
}
@media screen and (max-width: 750px) {
display: flex;
flex-direction: column;
@media screen and (max-width: 750px) {
display: flex;
flex-direction: column;
.table-row {
display: grid;
grid-template: "checkbox icon name type settings" "checkbox icon id type settings";
grid-template-columns:
min-content min-content minmax(min-content, 2fr)
minmax(min-content, 1fr) min-content;
.table-row {
display: grid;
grid-template: 'checkbox icon name type settings' 'checkbox icon id type settings';
grid-template-columns:
min-content min-content minmax(min-content, 2fr)
minmax(min-content, 1fr) min-content;
:nth-child(1) {
grid-area: checkbox;
}
:nth-child(1) {
grid-area: checkbox;
}
:nth-child(2) {
grid-area: icon;
}
:nth-child(2) {
grid-area: icon;
}
:nth-child(3) {
grid-area: name;
}
:nth-child(3) {
grid-area: name;
}
:nth-child(4) {
grid-area: id;
padding-top: 0;
}
:nth-child(4) {
grid-area: id;
padding-top: 0;
}
:nth-child(5) {
grid-area: type;
}
:nth-child(5) {
grid-area: type;
}
:nth-child(6) {
grid-area: settings;
}
}
:nth-child(6) {
grid-area: settings;
}
}
.table-head {
grid-template: "checkbox settings";
grid-template-columns: min-content minmax(min-content, 1fr);
.table-head {
grid-template: 'checkbox settings';
grid-template-columns: min-content minmax(min-content, 1fr);
:nth-child(2),
:nth-child(3),
:nth-child(4),
:nth-child(5) {
display: none;
}
}
}
:nth-child(2),
:nth-child(3),
:nth-child(4),
:nth-child(5) {
display: none;
}
}
}
@media screen and (max-width: 560px) {
.table-row {
display: grid;
grid-template: "checkbox icon name settings" "checkbox icon id settings" "checkbox icon type settings" "checkbox icon status settings";
grid-template-columns: min-content min-content minmax(min-content, 1fr) min-content;
@media screen and (max-width: 560px) {
.table-row {
display: grid;
grid-template: 'checkbox icon name settings' 'checkbox icon id settings' 'checkbox icon type settings' 'checkbox icon status settings';
grid-template-columns: min-content min-content minmax(min-content, 1fr) min-content;
:nth-child(5) {
padding-top: 0;
}
}
:nth-child(5) {
padding-top: 0;
}
}
.table-head {
grid-template: "checkbox settings";
grid-template-columns: min-content minmax(min-content, 1fr);
}
}
.table-head {
grid-template: 'checkbox settings';
grid-template-columns: min-content minmax(min-content, 1fr);
}
}
}
.items {
display: flex;
flex-direction: column;
gap: var(--gap-md);
padding: var(--gap-md);
display: flex;
flex-direction: column;
gap: var(--gap-md);
padding: var(--gap-md);
}
</style>

View File

@@ -1,525 +1,526 @@
<template>
<article class="project-card base-card padding-bg" :aria-label="name" role="listitem">
<nuxt-link
:title="name"
class="icon"
tabindex="-1"
:to="`/${$getProjectTypeForUrl(type, categories)}/${id}`"
>
<Avatar :src="iconUrl" :alt="name" size="md" no-shadow loading="lazy" />
</nuxt-link>
<nuxt-link
class="gallery"
:class="{ 'no-image': !featuredImage }"
tabindex="-1"
:to="`/${$getProjectTypeForUrl(type, categories)}/${id}`"
:style="color ? `background-color: ${toColor};` : ''"
>
<img v-if="featuredImage" :src="featuredImage" alt="gallery image" loading="lazy" />
</nuxt-link>
<div class="title">
<nuxt-link :to="`/${$getProjectTypeForUrl(type, categories)}/${id}`">
<h2 class="name !text-2xl">
{{ name }}
</h2>
</nuxt-link>
<p v-if="author" class="author">
by
<nuxt-link class="title-link" :to="'/user/' + author">
{{ author }}
</nuxt-link>
</p>
<ProjectStatusBadge v-if="status && status !== 'approved'" :status="status" class="status" />
</div>
<p class="description">
{{ description }}
</p>
<Categories
:categories="
categories.filter((x) => !hideLoaders || !tags.loaders.find((y) => y.name === x))
"
:type="type"
class="tags"
>
<EnvironmentIndicator
v-if="clientSide && serverSide"
:type-only="moderation"
:client-side="clientSide"
:server-side="serverSide"
:type="projectTypeDisplay"
:search="search"
:categories="categories"
/>
</Categories>
<div class="stats">
<div v-if="downloads" class="stat">
<DownloadIcon aria-hidden="true" />
<p>
<strong>{{ $formatNumber(downloads) }}</strong
><span class="stat-label"> download<span v-if="downloads !== '1'">s</span></span>
</p>
</div>
<div v-if="follows" class="stat">
<HeartIcon aria-hidden="true" />
<p>
<strong>{{ $formatNumber(follows) }}</strong
><span class="stat-label"> follower<span v-if="follows !== '1'">s</span></span>
</p>
</div>
<div class="buttons">
<slot />
</div>
<div
v-if="showUpdatedDate"
v-tooltip="$dayjs(updatedAt).format('MMMM D, YYYY [at] h:mm A')"
class="stat date"
>
<UpdatedIcon aria-hidden="true" />
<span class="date-label">Updated </span>{{ formatRelativeTime(updatedAt) }}
</div>
<div
v-else-if="showCreatedDate"
v-tooltip="$dayjs(createdAt).format('MMMM D, YYYY [at] h:mm A')"
class="stat date"
>
<CalendarIcon aria-hidden="true" />
<span class="date-label">Published </span>{{ formatRelativeTime(createdAt) }}
</div>
</div>
</article>
<article class="project-card base-card padding-bg" :aria-label="name" role="listitem">
<nuxt-link
:title="name"
class="icon"
tabindex="-1"
:to="`/${$getProjectTypeForUrl(type, categories)}/${id}`"
>
<Avatar :src="iconUrl" :alt="name" size="md" no-shadow loading="lazy" />
</nuxt-link>
<nuxt-link
class="gallery"
:class="{ 'no-image': !featuredImage }"
tabindex="-1"
:to="`/${$getProjectTypeForUrl(type, categories)}/${id}`"
:style="color ? `background-color: ${toColor};` : ''"
>
<img v-if="featuredImage" :src="featuredImage" alt="gallery image" loading="lazy" />
</nuxt-link>
<div class="title">
<nuxt-link :to="`/${$getProjectTypeForUrl(type, categories)}/${id}`">
<h2 class="name !text-2xl">
{{ name }}
</h2>
</nuxt-link>
<p v-if="author" class="author">
by
<nuxt-link class="title-link" :to="'/user/' + author">
{{ author }}
</nuxt-link>
</p>
<ProjectStatusBadge v-if="status && status !== 'approved'" :status="status" class="status" />
</div>
<p class="description">
{{ description }}
</p>
<Categories
:categories="
categories.filter((x) => !hideLoaders || !tags.loaders.find((y) => y.name === x))
"
:type="type"
class="tags"
>
<EnvironmentIndicator
v-if="clientSide && serverSide"
:type-only="moderation"
:client-side="clientSide"
:server-side="serverSide"
:type="projectTypeDisplay"
:search="search"
:categories="categories"
/>
</Categories>
<div class="stats">
<div v-if="downloads" class="stat">
<DownloadIcon aria-hidden="true" />
<p>
<strong>{{ $formatNumber(downloads) }}</strong
><span class="stat-label"> download<span v-if="downloads !== '1'">s</span></span>
</p>
</div>
<div v-if="follows" class="stat">
<HeartIcon aria-hidden="true" />
<p>
<strong>{{ $formatNumber(follows) }}</strong
><span class="stat-label"> follower<span v-if="follows !== '1'">s</span></span>
</p>
</div>
<div class="buttons">
<slot />
</div>
<div
v-if="showUpdatedDate"
v-tooltip="$dayjs(updatedAt).format('MMMM D, YYYY [at] h:mm A')"
class="stat date"
>
<UpdatedIcon aria-hidden="true" />
<span class="date-label">Updated </span>{{ formatRelativeTime(updatedAt) }}
</div>
<div
v-else-if="showCreatedDate"
v-tooltip="$dayjs(createdAt).format('MMMM D, YYYY [at] h:mm A')"
class="stat date"
>
<CalendarIcon aria-hidden="true" />
<span class="date-label">Published </span>{{ formatRelativeTime(createdAt) }}
</div>
</div>
</article>
</template>
<script>
import { CalendarIcon, UpdatedIcon, DownloadIcon, HeartIcon } from "@modrinth/assets";
import { Avatar, ProjectStatusBadge, useRelativeTime } from "@modrinth/ui";
import Categories from "~/components/ui/search/Categories.vue";
import EnvironmentIndicator from "~/components/ui/EnvironmentIndicator.vue";
import { CalendarIcon, DownloadIcon, HeartIcon, UpdatedIcon } from '@modrinth/assets'
import { Avatar, ProjectStatusBadge, useRelativeTime } from '@modrinth/ui'
import EnvironmentIndicator from '~/components/ui/EnvironmentIndicator.vue'
import Categories from '~/components/ui/search/Categories.vue'
export default {
components: {
ProjectStatusBadge,
EnvironmentIndicator,
Avatar,
Categories,
CalendarIcon,
UpdatedIcon,
DownloadIcon,
HeartIcon,
},
props: {
id: {
type: String,
default: "modrinth-0",
},
type: {
type: String,
default: "mod",
},
name: {
type: String,
default: "Project Name",
},
author: {
type: String,
default: null,
},
description: {
type: String,
default: "A _type description",
},
iconUrl: {
type: String,
default: "#",
required: false,
},
downloads: {
type: String,
default: null,
required: false,
},
follows: {
type: String,
default: null,
required: false,
},
createdAt: {
type: String,
default: "0000-00-00",
},
updatedAt: {
type: String,
default: null,
},
categories: {
type: Array,
default() {
return [];
},
},
status: {
type: String,
default: null,
},
hasModMessage: {
type: Boolean,
default: false,
},
serverSide: {
type: String,
required: false,
default: "",
},
clientSide: {
type: String,
required: false,
default: "",
},
moderation: {
type: Boolean,
required: false,
default: false,
},
search: {
type: Boolean,
required: false,
default: false,
},
featuredImage: {
type: String,
required: false,
default: null,
},
showUpdatedDate: {
type: Boolean,
required: false,
default: true,
},
showCreatedDate: {
type: Boolean,
required: false,
default: true,
},
hideLoaders: {
type: Boolean,
required: false,
default: false,
},
color: {
type: Number,
required: false,
default: null,
},
},
setup() {
const tags = useTags();
const formatRelativeTime = useRelativeTime();
components: {
ProjectStatusBadge,
EnvironmentIndicator,
Avatar,
Categories,
CalendarIcon,
UpdatedIcon,
DownloadIcon,
HeartIcon,
},
props: {
id: {
type: String,
default: 'modrinth-0',
},
type: {
type: String,
default: 'mod',
},
name: {
type: String,
default: 'Project Name',
},
author: {
type: String,
default: null,
},
description: {
type: String,
default: 'A _type description',
},
iconUrl: {
type: String,
default: '#',
required: false,
},
downloads: {
type: String,
default: null,
required: false,
},
follows: {
type: String,
default: null,
required: false,
},
createdAt: {
type: String,
default: '0000-00-00',
},
updatedAt: {
type: String,
default: null,
},
categories: {
type: Array,
default() {
return []
},
},
status: {
type: String,
default: null,
},
hasModMessage: {
type: Boolean,
default: false,
},
serverSide: {
type: String,
required: false,
default: '',
},
clientSide: {
type: String,
required: false,
default: '',
},
moderation: {
type: Boolean,
required: false,
default: false,
},
search: {
type: Boolean,
required: false,
default: false,
},
featuredImage: {
type: String,
required: false,
default: null,
},
showUpdatedDate: {
type: Boolean,
required: false,
default: true,
},
showCreatedDate: {
type: Boolean,
required: false,
default: true,
},
hideLoaders: {
type: Boolean,
required: false,
default: false,
},
color: {
type: Number,
required: false,
default: null,
},
},
setup() {
const tags = useTags()
const formatRelativeTime = useRelativeTime()
return { tags, formatRelativeTime };
},
computed: {
projectTypeDisplay() {
return this.$getProjectTypeForDisplay(this.type, this.categories);
},
toColor() {
let color = this.color;
return { tags, formatRelativeTime }
},
computed: {
projectTypeDisplay() {
return this.$getProjectTypeForDisplay(this.type, this.categories)
},
toColor() {
let color = this.color
color >>>= 0;
const b = color & 0xff;
const g = (color & 0xff00) >>> 8;
const r = (color & 0xff0000) >>> 16;
return "rgba(" + [r, g, b, 1].join(",") + ")";
},
},
};
color >>>= 0
const b = color & 0xff
const g = (color & 0xff00) >>> 8
const r = (color & 0xff0000) >>> 16
return 'rgba(' + [r, g, b, 1].join(',') + ')'
},
},
}
</script>
<style lang="scss" scoped>
.project-card {
display: inline-grid;
box-sizing: border-box;
overflow: hidden;
margin: 0;
display: inline-grid;
box-sizing: border-box;
overflow: hidden;
margin: 0;
}
.display-mode--list .project-card {
grid-template:
"icon title stats"
"icon description stats"
"icon tags stats";
grid-template-columns: min-content 1fr auto;
grid-template-rows: min-content 1fr min-content;
column-gap: var(--spacing-card-md);
row-gap: var(--spacing-card-sm);
width: 100%;
grid-template:
'icon title stats'
'icon description stats'
'icon tags stats';
grid-template-columns: min-content 1fr auto;
grid-template-rows: min-content 1fr min-content;
column-gap: var(--spacing-card-md);
row-gap: var(--spacing-card-sm);
width: 100%;
@media screen and (max-width: 750px) {
grid-template:
"icon title"
"icon description"
"icon tags"
"stats stats";
grid-template-columns: min-content auto;
grid-template-rows: min-content 1fr min-content min-content;
}
@media screen and (max-width: 750px) {
grid-template:
'icon title'
'icon description'
'icon tags'
'stats stats';
grid-template-columns: min-content auto;
grid-template-rows: min-content 1fr min-content min-content;
}
@media screen and (max-width: 550px) {
grid-template:
"icon title"
"icon description"
"tags tags"
"stats stats";
grid-template-columns: min-content auto;
grid-template-rows: min-content 1fr min-content min-content;
}
@media screen and (max-width: 550px) {
grid-template:
'icon title'
'icon description'
'tags tags'
'stats stats';
grid-template-columns: min-content auto;
grid-template-rows: min-content 1fr min-content min-content;
}
}
.display-mode--gallery .project-card,
.display-mode--grid .project-card {
padding: 0 0 var(--spacing-card-bg) 0;
grid-template: "gallery gallery" "icon title" "description description" "tags tags" "stats stats";
grid-template-columns: min-content 1fr;
grid-template-rows: min-content min-content 1fr min-content min-content;
row-gap: var(--spacing-card-sm);
padding: 0 0 var(--spacing-card-bg) 0;
grid-template: 'gallery gallery' 'icon title' 'description description' 'tags tags' 'stats stats';
grid-template-columns: min-content 1fr;
grid-template-rows: min-content min-content 1fr min-content min-content;
row-gap: var(--spacing-card-sm);
.gallery {
display: inline-block;
width: 100%;
height: 10rem;
background-color: var(--color-button-bg-active);
.gallery {
display: inline-block;
width: 100%;
height: 10rem;
background-color: var(--color-button-bg-active);
&.no-image {
filter: brightness(0.7);
}
&.no-image {
filter: brightness(0.7);
}
img {
box-shadow: none;
width: 100%;
height: 10rem;
object-fit: cover;
}
}
img {
box-shadow: none;
width: 100%;
height: 10rem;
object-fit: cover;
}
}
.icon {
margin-left: var(--spacing-card-bg);
margin-top: -3rem;
z-index: 1;
.icon {
margin-left: var(--spacing-card-bg);
margin-top: -3rem;
z-index: 1;
img,
svg {
border-radius: var(--size-rounded-lg);
box-shadow:
-2px -2px 0 2px var(--color-raised-bg),
2px -2px 0 2px var(--color-raised-bg);
}
}
img,
svg {
border-radius: var(--size-rounded-lg);
box-shadow:
-2px -2px 0 2px var(--color-raised-bg),
2px -2px 0 2px var(--color-raised-bg);
}
}
.title {
margin-left: var(--spacing-card-md);
margin-right: var(--spacing-card-bg);
flex-direction: column;
.title {
margin-left: var(--spacing-card-md);
margin-right: var(--spacing-card-bg);
flex-direction: column;
.name {
font-size: 1.25rem;
}
.name {
font-size: 1.25rem;
}
.status {
margin-top: var(--spacing-card-xs);
}
}
.status {
margin-top: var(--spacing-card-xs);
}
}
.description {
margin-inline: var(--spacing-card-bg);
}
.description {
margin-inline: var(--spacing-card-bg);
}
.tags {
margin-inline: var(--spacing-card-bg);
}
.tags {
margin-inline: var(--spacing-card-bg);
}
.stats {
margin-inline: var(--spacing-card-bg);
flex-direction: row;
align-items: center;
.stats {
margin-inline: var(--spacing-card-bg);
flex-direction: row;
align-items: center;
.stat-label {
display: none;
}
.stat-label {
display: none;
}
.buttons {
flex-direction: row;
gap: var(--spacing-card-sm);
align-items: center;
.buttons {
flex-direction: row;
gap: var(--spacing-card-sm);
align-items: center;
> :first-child {
margin-left: auto;
}
> :first-child {
margin-left: auto;
}
&:first-child > :last-child {
margin-right: auto;
}
}
&:first-child > :last-child {
margin-right: auto;
}
}
.buttons:not(:empty) + .date {
flex-basis: 100%;
}
}
.buttons:not(:empty) + .date {
flex-basis: 100%;
}
}
}
.display-mode--grid .project-card {
.gallery {
display: none;
}
.gallery {
display: none;
}
.icon {
margin-top: calc(var(--spacing-card-bg) - var(--spacing-card-sm));
.icon {
margin-top: calc(var(--spacing-card-bg) - var(--spacing-card-sm));
img,
svg {
border: none;
}
}
img,
svg {
border: none;
}
}
.title {
margin-top: calc(var(--spacing-card-bg) - var(--spacing-card-sm));
}
.title {
margin-top: calc(var(--spacing-card-bg) - var(--spacing-card-sm));
}
}
.icon {
grid-area: icon;
display: flex;
align-items: center;
grid-area: icon;
display: flex;
align-items: center;
}
.gallery {
display: none;
height: 10rem;
grid-area: gallery;
display: none;
height: 10rem;
grid-area: gallery;
}
.title {
grid-area: title;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
column-gap: var(--spacing-card-sm);
row-gap: 0;
word-wrap: anywhere;
grid-area: title;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
column-gap: var(--spacing-card-sm);
row-gap: 0;
word-wrap: anywhere;
h2,
p {
margin: 0;
}
h2,
p {
margin: 0;
}
svg {
width: auto;
color: var(--color-orange);
height: 1.5rem;
margin-bottom: -0.25rem;
}
svg {
width: auto;
color: var(--color-orange);
height: 1.5rem;
margin-bottom: -0.25rem;
}
}
.stats {
grid-area: stats;
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-card-md);
grid-area: stats;
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-card-md);
.stat {
display: flex;
flex-direction: row;
align-items: center;
width: fit-content;
gap: var(--spacing-card-xs);
--stat-strong-size: 1.25rem;
.stat {
display: flex;
flex-direction: row;
align-items: center;
width: fit-content;
gap: var(--spacing-card-xs);
--stat-strong-size: 1.25rem;
strong {
font-size: var(--stat-strong-size);
}
strong {
font-size: var(--stat-strong-size);
}
p {
margin: 0;
}
p {
margin: 0;
}
svg {
height: var(--stat-strong-size);
width: var(--stat-strong-size);
}
}
svg {
height: var(--stat-strong-size);
width: var(--stat-strong-size);
}
}
.date {
margin-top: auto;
}
.date {
margin-top: auto;
}
@media screen and (max-width: 750px) {
flex-direction: row;
column-gap: var(--spacing-card-md);
margin-top: var(--spacing-card-xs);
}
@media screen and (max-width: 750px) {
flex-direction: row;
column-gap: var(--spacing-card-md);
margin-top: var(--spacing-card-xs);
}
@media screen and (max-width: 600px) {
margin-top: 0;
@media screen and (max-width: 600px) {
margin-top: 0;
.stat-label {
display: none;
}
}
.stat-label {
display: none;
}
}
}
.environment {
color: var(--color-text) !important;
font-weight: bold;
color: var(--color-text) !important;
font-weight: bold;
}
.description {
grid-area: description;
margin-block: 0;
display: flex;
justify-content: flex-start;
grid-area: description;
margin-block: 0;
display: flex;
justify-content: flex-start;
}
.tags {
grid-area: tags;
display: flex;
flex-direction: row;
grid-area: tags;
display: flex;
flex-direction: row;
@media screen and (max-width: 550px) {
margin-top: var(--spacing-card-xs);
}
@media screen and (max-width: 550px) {
margin-top: var(--spacing-card-xs);
}
}
.buttons {
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
align-items: flex-end;
flex-grow: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
align-items: flex-end;
flex-grow: 1;
}
.small-mode {
@media screen and (min-width: 750px) {
grid-template:
"icon title"
"icon description"
"icon tags"
"stats stats" !important;
grid-template-columns: min-content auto !important;
grid-template-rows: min-content 1fr min-content min-content !important;
@media screen and (min-width: 750px) {
grid-template:
'icon title'
'icon description'
'icon tags'
'stats stats' !important;
grid-template-columns: min-content auto !important;
grid-template-rows: min-content 1fr min-content min-content !important;
.tags {
margin-top: var(--spacing-card-xs) !important;
}
.tags {
margin-top: var(--spacing-card-xs) !important;
}
.stats {
flex-direction: row;
column-gap: var(--spacing-card-md) !important;
margin-top: var(--spacing-card-xs) !important;
.stats {
flex-direction: row;
column-gap: var(--spacing-card-md) !important;
margin-top: var(--spacing-card-xs) !important;
.stat-label {
display: none !important;
}
}
}
.stat-label {
display: none !important;
}
}
}
}
</style>

View File

@@ -1,478 +1,480 @@
<template>
<div v-if="showInvitation" class="universal-card information invited my-4">
<h2>{{ getFormattedMessage(messages.invitationTitle) }}</h2>
<p v-if="currentMember?.project_role">
{{ formatMessage(messages.invitationWithRole, { role: currentMember.project_role }) }}
</p>
<p v-else>{{ getFormattedMessage(messages.invitationNoRole) }}</p>
<div class="input-group">
<ButtonStyled color="brand">
<button class="brand-button" @click="acceptInvite()">
<CheckIcon />
{{ getFormattedMessage(messages.accept) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="declineInvite">
<XIcon />
{{ getFormattedMessage(messages.decline) }}
</button>
</ButtonStyled>
</div>
</div>
<div
v-if="
currentMember &&
visibleNags.length > 0 &&
(project.status === 'draft' || tags.rejectedStatuses.includes(project.status))
"
class="universal-card my-4"
>
<div class="flex max-w-full flex-wrap items-center gap-x-6 gap-y-4">
<div class="flex flex-auto flex-wrap items-center gap-x-6 gap-y-4">
<h2 class="my-0 mr-auto">{{ getFormattedMessage(messages.publishingChecklist) }}</h2>
<div class="flex flex-row gap-2">
<div class="flex items-center gap-1">
<AsteriskIcon class="size-4 text-red" />
<span class="text-secondary">{{ getFormattedMessage(messages.required) }}</span>
</div>
|
<div class="flex items-center gap-1">
<TriangleAlertIcon class="size-4 text-orange" />
<span class="text-secondary">{{ getFormattedMessage(messages.warning) }}</span>
</div>
|
<div class="flex items-center gap-1">
<LightBulbIcon class="size-4 text-purple" />
<span class="text-secondary">{{ getFormattedMessage(messages.suggestion) }}</span>
</div>
</div>
</div>
<div class="input-group">
<ButtonStyled circular>
<button :class="!collapsed && '[&>svg]:rotate-180'" @click="toggleCollapsed()">
<DropdownIcon class="duration-250 transition-transform ease-in-out" />
</button>
</ButtonStyled>
</div>
</div>
<div v-if="!collapsed" class="grid-display width-16 mt-4">
<div v-for="nag in visibleNags" :key="nag.id" class="grid-display__item">
<span class="flex items-center gap-2 font-semibold">
<component
:is="nag.icon || getDefaultIcon(nag.status)"
v-tooltip="getStatusTooltip(nag.status)"
:class="[
'size-4',
nag.status === 'required' && 'text-red',
nag.status === 'warning' && 'text-orange',
nag.status === 'suggestion' && 'text-purple',
]"
:aria-label="getStatusTooltip(nag.status)"
/>
{{ getFormattedMessage(nag.title) }}
</span>
{{ getNagDescription(nag) }}
<NuxtLink
v-if="nag.link && shouldShowLink(nag)"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/${
nag.link.path
}`"
class="goto-link"
>
{{ getFormattedMessage(nag.link.title) }}
<ChevronRightIcon aria-hidden="true" class="featured-header-chevron" />
</NuxtLink>
<ButtonStyled
v-if="nag.status === 'special-submit-action' && nag.id === 'submit-for-review'"
color="orange"
@click="submitForReview"
>
<button
:disabled="!canSubmitForReview"
v-tooltip="
!canSubmitForReview ? getFormattedMessage(messages.submitChecklistTooltip) : undefined
"
>
<SendIcon />
{{ getFormattedMessage(messages.submitForReview) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-if="showInvitation" class="universal-card information invited my-4">
<h2>{{ getFormattedMessage(messages.invitationTitle) }}</h2>
<p v-if="currentMember?.project_role">
{{ formatMessage(messages.invitationWithRole, { role: currentMember.project_role }) }}
</p>
<p v-else>{{ getFormattedMessage(messages.invitationNoRole) }}</p>
<div class="input-group">
<ButtonStyled color="brand">
<button class="brand-button" @click="acceptInvite()">
<CheckIcon />
{{ getFormattedMessage(messages.accept) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="declineInvite">
<XIcon />
{{ getFormattedMessage(messages.decline) }}
</button>
</ButtonStyled>
</div>
</div>
<div
v-if="
currentMember &&
visibleNags.length > 0 &&
(project.status === 'draft' || tags.rejectedStatuses.includes(project.status))
"
class="universal-card my-4"
>
<div class="flex max-w-full flex-wrap items-center gap-x-6 gap-y-4">
<div class="flex flex-auto flex-wrap items-center gap-x-6 gap-y-4">
<h2 class="my-0 mr-auto">
{{ getFormattedMessage(messages.publishingChecklist) }}
</h2>
<div class="flex flex-row gap-2">
<div class="flex items-center gap-1">
<AsteriskIcon class="size-4 text-red" />
<span class="text-secondary">{{ getFormattedMessage(messages.required) }}</span>
</div>
|
<div class="flex items-center gap-1">
<TriangleAlertIcon class="size-4 text-orange" />
<span class="text-secondary">{{ getFormattedMessage(messages.warning) }}</span>
</div>
|
<div class="flex items-center gap-1">
<LightBulbIcon class="size-4 text-purple" />
<span class="text-secondary">{{ getFormattedMessage(messages.suggestion) }}</span>
</div>
</div>
</div>
<div class="input-group">
<ButtonStyled circular>
<button :class="!collapsed && '[&>svg]:rotate-180'" @click="handleToggleCollapsed()">
<DropdownIcon class="duration-250 transition-transform ease-in-out" />
</button>
</ButtonStyled>
</div>
</div>
<div v-if="!collapsed" class="grid-display width-16 mt-4">
<div v-for="nag in visibleNags" :key="nag.id" class="grid-display__item">
<span class="flex items-center gap-2 font-semibold">
<component
:is="nag.icon || getDefaultIcon(nag.status)"
v-tooltip="getStatusTooltip(nag.status)"
:class="[
'size-4',
nag.status === 'required' && 'text-red',
nag.status === 'warning' && 'text-orange',
nag.status === 'suggestion' && 'text-purple',
]"
:aria-label="getStatusTooltip(nag.status)"
/>
{{ getFormattedMessage(nag.title) }}
</span>
{{ getNagDescription(nag) }}
<NuxtLink
v-if="nag.link && shouldShowLink(nag)"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/${
nag.link.path
}`"
class="goto-link"
>
{{ getFormattedMessage(nag.link.title) }}
<ChevronRightIcon aria-hidden="true" class="featured-header-chevron" />
</NuxtLink>
<ButtonStyled
v-if="nag.status === 'special-submit-action' && nag.id === 'submit-for-review'"
color="orange"
@click="submitForReview"
>
<button
v-tooltip="
!canSubmitForReview ? getFormattedMessage(messages.submitChecklistTooltip) : undefined
"
:disabled="!canSubmitForReview"
>
<SendIcon />
{{ getFormattedMessage(messages.submitForReview) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
AsteriskIcon,
CheckIcon,
ChevronRightIcon,
DropdownIcon,
LightBulbIcon,
ScaleIcon,
SendIcon,
TriangleAlertIcon,
XIcon,
} from "@modrinth/assets";
import type { Nag, NagContext, NagStatus } from "@modrinth/moderation";
import { nags } from "@modrinth/moderation";
import { ButtonStyled, injectNotificationManager } from "@modrinth/ui";
import type { Project, User, Version } from "@modrinth/utils";
import { defineMessages, useVIntl, type MessageDescriptor } from "@vintl/vintl";
import type { Component } from "vue";
import { acceptTeamInvite, removeTeamMember } from "~/helpers/teams.js";
AsteriskIcon,
CheckIcon,
ChevronRightIcon,
DropdownIcon,
LightBulbIcon,
ScaleIcon,
SendIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import type { Nag, NagContext, NagStatus } from '@modrinth/moderation'
import { nags } from '@modrinth/moderation'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import type { Project, User, Version } from '@modrinth/utils'
import { defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import type { Component } from 'vue'
import { computed } from 'vue'
const { addNotification } = injectNotificationManager();
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
const { addNotification } = injectNotificationManager()
interface Tags {
rejectedStatuses: string[];
rejectedStatuses: string[]
}
interface Auth {
user: {
id: string;
};
user: {
id: string
}
}
interface Member {
accepted?: boolean;
project_role?: string;
user?: Partial<User>;
accepted?: boolean
project_role?: string
user?: Partial<User>
}
interface Props {
project: Project;
versions?: Version[];
currentMember?: Member | null;
allMembers?: Member[] | null;
isSettings?: boolean;
collapsed?: boolean;
routeName?: string;
auth: Auth;
tags: Tags;
setProcessing?: (processing: boolean) => void;
toggleCollapsed?: () => void;
updateMembers?: () => void | Promise<void>;
project: Project
versions?: Version[]
currentMember?: Member | null
allMembers?: Member[] | null
isSettings?: boolean
collapsed?: boolean
routeName?: string
auth: Auth
tags: Tags
setProcessing?: (processing: boolean) => void
toggleCollapsed?: () => void
updateMembers?: () => void | Promise<void>
}
const messages = defineMessages({
invitationTitle: {
id: "project-member-header.invitation-title",
defaultMessage: "Invitation to join project",
},
invitationWithRole: {
id: "project-member-header.invitation-with-role",
defaultMessage: "You've been invited be a member of this project with the role of '{role}'.",
},
invitationNoRole: {
id: "project-member-header.invitation-no-role",
defaultMessage:
"You've been invited to join this project. Please accept or decline the invitation.",
},
accept: {
id: "project-member-header.accept",
defaultMessage: "Accept",
},
decline: {
id: "project-member-header.decline",
defaultMessage: "Decline",
},
publishingChecklist: {
id: "project-member-header.publishing-checklist",
defaultMessage: "Publishing checklist",
},
submitForReview: {
id: "project-member-header.submit-for-review",
defaultMessage: "Submit for review",
},
submitForReviewDesc: {
id: "project-member-header.submit-for-review-desc",
defaultMessage:
"Your project is only viewable by members of the project. It must be reviewed by moderators in order to be published.",
},
resubmitForReview: {
id: "project-member-header.resubmit-for-review",
defaultMessage: "Resubmit for review",
},
resubmitForReviewDesc: {
id: "project-member-header.resubmit-for-review-desc",
defaultMessage:
"Your project has been {status} by Modrinth's staff. In most cases, you can resubmit for review after addressing the staff's message.",
},
showKey: {
id: "project-member-header.show-key",
defaultMessage: "Toggle key",
},
keyTitle: {
id: "project-member-header.key-title",
defaultMessage: "Status Key",
},
action: {
id: "project-member-header.action",
defaultMessage: "Action",
},
visitModerationPage: {
id: "project-member-header.visit-moderation-page",
defaultMessage: "Visit moderation page",
},
submitChecklistTooltip: {
id: "project-member-header.submit-checklist-tooltip",
defaultMessage: "You must complete the required steps in the publishing checklist!",
},
successJoin: {
id: "project-member-header.success-join",
defaultMessage: "You have joined the project team",
},
errorJoin: {
id: "project-member-header.error-join",
defaultMessage: "Failed to accept team invitation",
},
successDecline: {
id: "project-member-header.success-decline",
defaultMessage: "You have declined the team invitation",
},
errorDecline: {
id: "project-member-header.error-decline",
defaultMessage: "Failed to decline team invitation",
},
success: {
id: "project-member-header.success",
defaultMessage: "Success",
},
error: {
id: "project-member-header.error",
defaultMessage: "Error",
},
required: {
id: "project-member-header.required",
defaultMessage: "Required",
},
warning: {
id: "project-member-header.warning",
defaultMessage: "Warning",
},
suggestion: {
id: "project-member-header.suggestion",
defaultMessage: "Suggestion",
},
});
invitationTitle: {
id: 'project-member-header.invitation-title',
defaultMessage: 'Invitation to join project',
},
invitationWithRole: {
id: 'project-member-header.invitation-with-role',
defaultMessage: "You've been invited be a member of this project with the role of '{role}'.",
},
invitationNoRole: {
id: 'project-member-header.invitation-no-role',
defaultMessage:
"You've been invited to join this project. Please accept or decline the invitation.",
},
accept: {
id: 'project-member-header.accept',
defaultMessage: 'Accept',
},
decline: {
id: 'project-member-header.decline',
defaultMessage: 'Decline',
},
publishingChecklist: {
id: 'project-member-header.publishing-checklist',
defaultMessage: 'Publishing checklist',
},
submitForReview: {
id: 'project-member-header.submit-for-review',
defaultMessage: 'Submit for review',
},
submitForReviewDesc: {
id: 'project-member-header.submit-for-review-desc',
defaultMessage:
'Your project is only viewable by members of the project. It must be reviewed by moderators in order to be published.',
},
resubmitForReview: {
id: 'project-member-header.resubmit-for-review',
defaultMessage: 'Resubmit for review',
},
resubmitForReviewDesc: {
id: 'project-member-header.resubmit-for-review-desc',
defaultMessage:
"Your project has been {status} by Modrinth's staff. In most cases, you can resubmit for review after addressing the staff's message.",
},
showKey: {
id: 'project-member-header.show-key',
defaultMessage: 'Toggle key',
},
keyTitle: {
id: 'project-member-header.key-title',
defaultMessage: 'Status Key',
},
action: {
id: 'project-member-header.action',
defaultMessage: 'Action',
},
visitModerationPage: {
id: 'project-member-header.visit-moderation-page',
defaultMessage: 'Visit moderation page',
},
submitChecklistTooltip: {
id: 'project-member-header.submit-checklist-tooltip',
defaultMessage: 'You must complete the required steps in the publishing checklist!',
},
successJoin: {
id: 'project-member-header.success-join',
defaultMessage: 'You have joined the project team',
},
errorJoin: {
id: 'project-member-header.error-join',
defaultMessage: 'Failed to accept team invitation',
},
successDecline: {
id: 'project-member-header.success-decline',
defaultMessage: 'You have declined the team invitation',
},
errorDecline: {
id: 'project-member-header.error-decline',
defaultMessage: 'Failed to decline team invitation',
},
success: {
id: 'project-member-header.success',
defaultMessage: 'Success',
},
error: {
id: 'project-member-header.error',
defaultMessage: 'Error',
},
required: {
id: 'project-member-header.required',
defaultMessage: 'Required',
},
warning: {
id: 'project-member-header.warning',
defaultMessage: 'Warning',
},
suggestion: {
id: 'project-member-header.suggestion',
defaultMessage: 'Suggestion',
},
})
const { formatMessage } = useVIntl();
const { formatMessage } = useVIntl()
function getNagDescription(nag: Nag): string {
if (typeof nag.description === "function") {
return nag.description(nagContext.value);
}
return formatMessage(nag.description);
if (typeof nag.description === 'function') {
return nag.description(nagContext.value)
}
return formatMessage(nag.description)
}
function getFormattedMessage(message: string | MessageDescriptor): string {
if (typeof message === "string") {
return message;
}
return formatMessage(message);
if (typeof message === 'string') {
return message
}
return formatMessage(message)
}
const props = withDefaults(defineProps<Props>(), {
versions: () => [],
currentMember: null,
allMembers: null,
isSettings: false,
collapsed: false,
routeName: "",
});
versions: () => [],
currentMember: null,
allMembers: null,
isSettings: false,
collapsed: false,
routeName: '',
setProcessing: () => {},
toggleCollapsed: () => {},
updateMembers: async () => {},
})
const emit = defineEmits<{
toggleCollapsed: [];
updateMembers: [];
setProcessing: [processing: boolean];
}>();
toggleCollapsed: []
updateMembers: []
setProcessing: [processing: boolean]
}>()
const nagContext = computed<NagContext>(() => ({
project: props.project,
versions: props.versions,
currentMember: props.currentMember as User,
currentRoute: props.routeName,
tags: props.tags,
submitProject: submitForReview,
}));
const showKey = ref(false);
function toggleKey(): void {
showKey.value = !showKey.value;
}
project: props.project,
versions: props.versions,
currentMember: props.currentMember as User,
currentRoute: props.routeName,
tags: props.tags,
submitProject: submitForReview,
}))
const canSubmitForReview = computed(() => {
return (
applicableNags.value.filter((nag) => nag.status === "required" && !isNagComplete(nag))
.length === 0
);
});
return (
applicableNags.value.filter((nag) => nag.status === 'required' && !isNagComplete(nag))
.length === 0
)
})
async function submitForReview() {
if (canSubmitForReview) {
await setProcessing(true);
}
if (canSubmitForReview.value) {
await handleSetProcessing(true)
}
}
const applicableNags = computed<Nag[]>(() => {
return nags.filter((nag) => {
return nag.shouldShow(nagContext.value);
});
});
return nags.filter((nag) => {
return nag.shouldShow(nagContext.value)
})
})
function isNagComplete(nag: Nag): boolean {
const context = nagContext.value;
return !nag.shouldShow(context);
const context = nagContext.value
return !nag.shouldShow(context)
}
const visibleNags = computed<Nag[]>(() => {
const finalNags = applicableNags.value.filter((nag) => !isNagComplete(nag));
const finalNags = applicableNags.value.filter((nag) => !isNagComplete(nag))
if (props.project.status === "draft") {
finalNags.push({
id: "submit-for-review",
title: messages.submitForReview,
description: () => formatMessage(messages.submitForReviewDesc),
status: "special-submit-action",
shouldShow: (ctx) => ctx.project.status === "draft",
});
}
if (props.project.status === 'draft') {
finalNags.push({
id: 'submit-for-review',
title: messages.submitForReview,
description: () => formatMessage(messages.submitForReviewDesc),
status: 'special-submit-action',
shouldShow: (ctx) => ctx.project.status === 'draft',
})
}
if (props.tags.rejectedStatuses.includes(props.project.status)) {
finalNags.push({
id: "resubmit-for-review",
title: messages.resubmitForReview,
description: (ctx) =>
formatMessage(messages.resubmitForReviewDesc, { status: ctx.project.status }),
status: "special-submit-action",
shouldShow: (ctx) => ctx.tags.rejectedStatuses.includes(ctx.project.status),
link: {
path: "moderation",
title: messages.visitModerationPage,
shouldShow: () => props.routeName !== "type-id-moderation",
},
});
}
if (props.tags.rejectedStatuses.includes(props.project.status)) {
finalNags.push({
id: 'resubmit-for-review',
title: messages.resubmitForReview,
description: (ctx) =>
formatMessage(messages.resubmitForReviewDesc, { status: ctx.project.status }),
status: 'special-submit-action',
shouldShow: (ctx) => ctx.tags.rejectedStatuses.includes(ctx.project.status),
link: {
path: 'moderation',
title: messages.visitModerationPage,
shouldShow: () => props.routeName !== 'type-id-moderation',
},
})
}
finalNags.sort((a, b) => {
const statusOrder = { required: 0, warning: 1, suggestion: 2, "special-submit-action": 3 };
return statusOrder[a.status] - statusOrder[b.status];
});
finalNags.sort((a, b) => {
const statusOrder = { required: 0, warning: 1, suggestion: 2, 'special-submit-action': 3 }
return statusOrder[a.status] - statusOrder[b.status]
})
return finalNags;
});
return finalNags
})
function shouldShowLink(nag: Nag): boolean {
return nag.link?.shouldShow ? nag.link.shouldShow(nagContext.value) : false;
return nag.link?.shouldShow ? nag.link.shouldShow(nagContext.value) : false
}
function getDefaultIcon(status: NagStatus): Component {
switch (status) {
case "required":
return AsteriskIcon;
case "warning":
return TriangleAlertIcon;
case "suggestion":
return LightBulbIcon;
case "special-submit-action":
return ScaleIcon;
default:
return AsteriskIcon;
}
switch (status) {
case 'required':
return AsteriskIcon
case 'warning':
return TriangleAlertIcon
case 'suggestion':
return LightBulbIcon
case 'special-submit-action':
return ScaleIcon
default:
return AsteriskIcon
}
}
function getStatusTooltip(status: NagStatus): string {
switch (status) {
case "required":
return formatMessage(messages.required);
case "warning":
return formatMessage(messages.warning);
case "suggestion":
return formatMessage(messages.suggestion);
default:
return formatMessage(messages.required);
}
switch (status) {
case 'required':
return formatMessage(messages.required)
case 'warning':
return formatMessage(messages.warning)
case 'suggestion':
return formatMessage(messages.suggestion)
default:
return formatMessage(messages.required)
}
}
const showInvitation = computed<boolean>(() => {
if (props.allMembers && props.auth) {
const member = props.allMembers.find((x) => x?.user?.id === props.auth.user.id);
return !!member && !member.accepted;
}
return false;
});
if (props.allMembers && props.auth) {
const member = props.allMembers.find((x) => x?.user?.id === props.auth.user.id)
return !!member && !member.accepted
}
return false
})
function toggleCollapsed(): void {
if (props.toggleCollapsed) {
props.toggleCollapsed();
} else {
emit("toggleCollapsed");
}
function handleToggleCollapsed(): void {
if (props.toggleCollapsed) {
props.toggleCollapsed()
} else {
emit('toggleCollapsed')
}
}
async function updateMembers(): Promise<void> {
if (props.updateMembers) {
await props.updateMembers();
} else {
emit("updateMembers");
}
async function handleUpdateMembers(): Promise<void> {
if (props.updateMembers) {
await props.updateMembers()
} else {
emit('updateMembers')
}
}
function setProcessing(processing: boolean): void {
if (props.setProcessing) {
props.setProcessing(processing);
} else {
emit("setProcessing", processing);
}
function handleSetProcessing(processing: boolean): void {
if (props.setProcessing) {
props.setProcessing(processing)
} else {
emit('setProcessing', processing)
}
}
async function acceptInvite(): Promise<void> {
try {
setProcessing(true);
await acceptTeamInvite(props.project.team);
await updateMembers();
addNotification({
title: formatMessage(messages.success),
text: formatMessage(messages.successJoin),
type: "success",
});
} catch (error) {
addNotification({
title: formatMessage(messages.error),
text: formatMessage(messages.errorJoin),
type: "error",
});
} finally {
setProcessing(false);
}
try {
handleSetProcessing(true)
await acceptTeamInvite(props.project.team)
await handleUpdateMembers()
addNotification({
title: formatMessage(messages.success),
text: formatMessage(messages.successJoin),
type: 'success',
})
} catch {
addNotification({
title: formatMessage(messages.error),
text: formatMessage(messages.errorJoin),
type: 'error',
})
} finally {
handleSetProcessing(false)
}
}
async function declineInvite(): Promise<void> {
try {
setProcessing(true);
await removeTeamMember(props.project.team, props.auth.user.id);
await updateMembers();
addNotification({
title: formatMessage(messages.success),
text: formatMessage(messages.successDecline),
type: "success",
});
} catch (error) {
addNotification({
title: formatMessage(messages.error),
text: formatMessage(messages.errorDecline),
type: "error",
});
} finally {
setProcessing(false);
}
try {
handleSetProcessing(true)
await removeTeamMember(props.project.team, props.auth.user.id)
await handleUpdateMembers()
addNotification({
title: formatMessage(messages.success),
text: formatMessage(messages.successDecline),
type: 'success',
})
} catch {
addNotification({
title: formatMessage(messages.error),
text: formatMessage(messages.errorDecline),
type: 'error',
})
} finally {
handleSetProcessing(false)
}
}
</script>
<style lang="scss" scoped>
.duration-250 {
transition-duration: 250ms;
transition-duration: 250ms;
}
</style>

View File

@@ -1,86 +1,86 @@
<template>
<div class="flex gap-2">
<ButtonStyled circular>
<a
v-tooltip="`Share on Bluesky`"
:href="`https://bsky.app/intent/compose?text=${encodedUrl}`"
target="_blank"
>
<BlueskyIcon />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="`Share on Mastodon`"
:href="`https://tootpick.org/#text=${encodedUrl}`"
target="_blank"
>
<MastodonIcon />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="`Share on X`"
:href="`https://www.x.com/intent/post?url=${encodedUrl}`"
target="_blank"
>
<TwitterIcon />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="`Share via email`"
:href="`mailto:${encodedTitle ? `?subject=${encodedTitle}&` : `?`}body=${encodedUrl}`"
target="_blank"
>
<MailIcon />
</a>
</ButtonStyled>
<ButtonStyled circular>
<button
v-tooltip="copied ? `Copied to clipboard` : `Copy link`"
:disabled="copied"
class="relative grid place-items-center overflow-hidden"
@click="copyToClipboard(url)"
>
<CheckIcon
class="absolute transition-all ease-in-out"
:class="copied ? 'translate-y-0' : 'translate-y-7'"
/>
<LinkIcon
class="absolute transition-all ease-in-out"
:class="copied ? '-translate-y-7' : 'translate-y-0'"
/>
</button>
</ButtonStyled>
</div>
<div class="flex gap-2">
<ButtonStyled circular>
<a
v-tooltip="`Share on Bluesky`"
:href="`https://bsky.app/intent/compose?text=${encodedUrl}`"
target="_blank"
>
<BlueskyIcon />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="`Share on Mastodon`"
:href="`https://tootpick.org/#text=${encodedUrl}`"
target="_blank"
>
<MastodonIcon />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="`Share on X`"
:href="`https://www.x.com/intent/post?url=${encodedUrl}`"
target="_blank"
>
<TwitterIcon />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="`Share via email`"
:href="`mailto:${encodedTitle ? `?subject=${encodedTitle}&` : `?`}body=${encodedUrl}`"
target="_blank"
>
<MailIcon />
</a>
</ButtonStyled>
<ButtonStyled circular>
<button
v-tooltip="copied ? `Copied to clipboard` : `Copy link`"
:disabled="copied"
class="relative grid place-items-center overflow-hidden"
@click="copyToClipboard(url)"
>
<CheckIcon
class="absolute transition-all ease-in-out"
:class="copied ? 'translate-y-0' : 'translate-y-7'"
/>
<LinkIcon
class="absolute transition-all ease-in-out"
:class="copied ? '-translate-y-7' : 'translate-y-0'"
/>
</button>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import {
BlueskyIcon,
CheckIcon,
LinkIcon,
MailIcon,
MastodonIcon,
TwitterIcon,
} from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
BlueskyIcon,
CheckIcon,
LinkIcon,
MailIcon,
MastodonIcon,
TwitterIcon,
} from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
const props = defineProps<{
title?: string;
url: string;
}>();
title?: string
url: string
}>()
const copied = ref(false);
const encodedUrl = computed(() => encodeURIComponent(props.url));
const encodedTitle = computed(() => (props.title ? encodeURIComponent(props.title) : undefined));
const copied = ref(false)
const encodedUrl = computed(() => encodeURIComponent(props.url))
const encodedTitle = computed(() => (props.title ? encodeURIComponent(props.title) : undefined))
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 3000);
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 3000)
}
</script>

View File

@@ -1,91 +1,91 @@
<script setup>
import dayjs from "dayjs";
import { formatNumber, formatMoney } from "@modrinth/utils";
import VueApexCharts from "vue3-apexcharts";
import { formatMoney, formatNumber } from '@modrinth/utils'
import dayjs from 'dayjs'
import VueApexCharts from 'vue3-apexcharts'
const props = defineProps({
name: {
type: String,
required: true,
},
labels: {
type: Array,
required: true,
},
data: {
type: Array,
required: true,
},
formatLabels: {
type: Function,
default: (label) => dayjs(label).format("MMM D"),
},
colors: {
type: Array,
default: () => [],
},
prefix: {
type: String,
default: "",
},
suffix: {
type: String,
default: "",
},
hideToolbar: {
type: Boolean,
default: false,
},
hideLegend: {
type: Boolean,
default: false,
},
stacked: {
type: Boolean,
default: false,
},
type: {
type: String,
default: "bar",
},
hideTotal: {
type: Boolean,
default: false,
},
isMoney: {
type: Boolean,
default: false,
},
legendPosition: {
type: String,
default: "right",
},
xAxisType: {
type: String,
default: "datetime",
},
percentStacked: {
type: Boolean,
default: false,
},
horizontalBar: {
type: Boolean,
default: false,
},
disableAnimations: {
type: Boolean,
default: false,
},
});
name: {
type: String,
required: true,
},
labels: {
type: Array,
required: true,
},
data: {
type: Array,
required: true,
},
formatLabels: {
type: Function,
default: (label) => dayjs(label).format('MMM D'),
},
colors: {
type: Array,
default: () => [],
},
prefix: {
type: String,
default: '',
},
suffix: {
type: String,
default: '',
},
hideToolbar: {
type: Boolean,
default: false,
},
hideLegend: {
type: Boolean,
default: false,
},
stacked: {
type: Boolean,
default: false,
},
type: {
type: String,
default: 'bar',
},
hideTotal: {
type: Boolean,
default: false,
},
isMoney: {
type: Boolean,
default: false,
},
legendPosition: {
type: String,
default: 'right',
},
xAxisType: {
type: String,
default: 'datetime',
},
percentStacked: {
type: Boolean,
default: false,
},
horizontalBar: {
type: Boolean,
default: false,
},
disableAnimations: {
type: Boolean,
default: false,
},
})
function formatTooltipValue(value, props) {
return props.isMoney ? formatMoney(value, false) : formatNumber(value, false);
return props.isMoney ? formatMoney(value, false) : formatNumber(value, false)
}
function generateListEntry(value, index, _, w, props) {
const color = w.globals.colors?.[index];
const color = w.globals.colors?.[index]
return `<div class="list-entry">
return `<div class="list-entry">
<span class="circle" style="background-color: ${color}"></span>
<div class="label">
${w.globals.seriesNames[index]}
@@ -93,398 +93,398 @@ function generateListEntry(value, index, _, w, props) {
<div class="value">
${props.prefix}${formatTooltipValue(value, props)}${props.suffix}
</div>
</div>`;
</div>`
}
function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
const label = w.globals.lastXAxis.categories?.[dataPointIndex];
const label = w.globals.lastXAxis.categories?.[dataPointIndex]
const formattedLabel = props.formatLabels(label);
const formattedLabel = props.formatLabels(label)
let tooltip = `<div class="bar-tooltip">
let tooltip = `<div class="bar-tooltip">
<div class="seperated-entry title">
<div class="label">${formattedLabel}</div>`;
<div class="label">${formattedLabel}</div>`
// Logic for total and percent stacked
if (!props.hideTotal) {
if (props.percentStacked) {
const total = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0);
const percentValue = (100 * series[seriesIndex][dataPointIndex]) / total;
tooltip += `<div class="value">${props.prefix}${formatNumber(percentValue)}%${
props.suffix
}</div>`;
} else {
const totalValue = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0);
tooltip += `<div class="value">${props.prefix}${formatTooltipValue(totalValue, props)}${
props.suffix
}</div>`;
}
}
// Logic for total and percent stacked
if (!props.hideTotal) {
if (props.percentStacked) {
const total = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
const percentValue = (100 * series[seriesIndex][dataPointIndex]) / total
tooltip += `<div class="value">${props.prefix}${formatNumber(percentValue)}%${
props.suffix
}</div>`
} else {
const totalValue = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
tooltip += `<div class="value">${props.prefix}${formatTooltipValue(totalValue, props)}${
props.suffix
}</div>`
}
}
tooltip += '</div><hr class="card-divider" />';
tooltip += '</div><hr class="card-divider" />'
// Logic for generating list entries
if (props.percentStacked) {
tooltip += generateListEntry(
series[seriesIndex][dataPointIndex],
seriesIndex,
seriesIndex,
w,
props,
);
} else {
const returnTopN = 15;
// Logic for generating list entries
if (props.percentStacked) {
tooltip += generateListEntry(
series[seriesIndex][dataPointIndex],
seriesIndex,
seriesIndex,
w,
props,
)
} else {
const returnTopN = 15
const listEntries = series
.map((value, index) => [
value[dataPointIndex],
generateListEntry(value[dataPointIndex], index, seriesIndex, w, props),
])
.filter((value) => value[0] > 0)
.sort((a, b) => b[0] - a[0])
.slice(0, returnTopN) // Return only the top X entries
.map((value) => value[1])
.join("");
const listEntries = series
.map((value, index) => [
value[dataPointIndex],
generateListEntry(value[dataPointIndex], index, seriesIndex, w, props),
])
.filter((value) => value[0] > 0)
.sort((a, b) => b[0] - a[0])
.slice(0, returnTopN) // Return only the top X entries
.map((value) => value[1])
.join('')
tooltip += listEntries;
}
tooltip += listEntries
}
tooltip += "</div>";
return tooltip;
tooltip += '</div>'
return tooltip
}
const chartOptions = computed(() => {
return {
chart: {
id: props.name,
fontFamily:
"Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif",
foreColor: "var(--color-base)",
selection: {
enabled: true,
fill: {
color: "var(--color-brand)",
},
},
toolbar: {
show: false,
},
stacked: props.stacked,
stackType: props.percentStacked ? "100%" : "normal",
zoom: {
autoScaleYaxis: true,
},
animations: {
enabled: props.disableAnimations,
},
},
xaxis: {
type: props.xAxisType,
categories: props.labels,
labels: {
style: {
borderRadius: "var(--radius-sm)",
},
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
yaxis: {
tooltip: {
enabled: false,
},
},
colors: props.colors,
dataLabels: {
enabled: false,
background: {
enabled: true,
borderRadius: 20,
},
},
grid: {
borderColor: "var(--color-button-bg)",
tickColor: "var(--color-button-bg)",
},
legend: {
show: !props.hideLegend,
position: props.legendPosition,
showForZeroSeries: false,
showForSingleSeries: false,
showForNullSeries: false,
fontSize: "var(--font-size-nm)",
fontFamily:
"Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif",
onItemClick: {
toggleDataSeries: true,
},
},
markers: {
size: 0,
strokeColor: "var(--color-contrast)",
strokeWidth: 3,
strokeOpacity: 1,
fillOpacity: 1,
hover: {
size: 6,
},
},
plotOptions: {
bar: {
horizontal: props.horizontalBar,
columnWidth: "80%",
endingShape: "rounded",
borderRadius: 5,
borderRadiusApplication: "end",
borderRadiusWhenStacked: "last",
},
},
stroke: {
curve: "smooth",
width: 2,
},
tooltip: {
custom: (d) => generateTooltip(d, props),
},
fill:
props.type === "area"
? {
colors: props.colors,
type: "gradient",
opacity: 1,
gradient: {
shade: "light",
type: "vertical",
shadeIntensity: 0,
gradientToColors: props.colors,
inverseColors: true,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 100],
colorStops: [],
},
}
: {},
};
});
return {
chart: {
id: props.name,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
foreColor: 'var(--color-base)',
selection: {
enabled: true,
fill: {
color: 'var(--color-brand)',
},
},
toolbar: {
show: false,
},
stacked: props.stacked,
stackType: props.percentStacked ? '100%' : 'normal',
zoom: {
autoScaleYaxis: true,
},
animations: {
enabled: props.disableAnimations,
},
},
xaxis: {
type: props.xAxisType,
categories: props.labels,
labels: {
style: {
borderRadius: 'var(--radius-sm)',
},
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
yaxis: {
tooltip: {
enabled: false,
},
},
colors: props.colors,
dataLabels: {
enabled: false,
background: {
enabled: true,
borderRadius: 20,
},
},
grid: {
borderColor: 'var(--color-button-bg)',
tickColor: 'var(--color-button-bg)',
},
legend: {
show: !props.hideLegend,
position: props.legendPosition,
showForZeroSeries: false,
showForSingleSeries: false,
showForNullSeries: false,
fontSize: 'var(--font-size-nm)',
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
onItemClick: {
toggleDataSeries: true,
},
},
markers: {
size: 0,
strokeColor: 'var(--color-contrast)',
strokeWidth: 3,
strokeOpacity: 1,
fillOpacity: 1,
hover: {
size: 6,
},
},
plotOptions: {
bar: {
horizontal: props.horizontalBar,
columnWidth: '80%',
endingShape: 'rounded',
borderRadius: 5,
borderRadiusApplication: 'end',
borderRadiusWhenStacked: 'last',
},
},
stroke: {
curve: 'smooth',
width: 2,
},
tooltip: {
custom: (d) => generateTooltip(d, props),
},
fill:
props.type === 'area'
? {
colors: props.colors,
type: 'gradient',
opacity: 1,
gradient: {
shade: 'light',
type: 'vertical',
shadeIntensity: 0,
gradientToColors: props.colors,
inverseColors: true,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 100],
colorStops: [],
},
}
: {},
}
})
const chart = ref(null);
const chart = ref(null)
const legendValues = ref(
[...props.data].map((project, index) => {
return { name: project.name, visible: true, color: props.colors[index] };
}),
);
[...props.data].map((project, index) => {
return { name: project.name, visible: true, color: props.colors[index] }
}),
)
const flipLegend = (legend, newVal) => {
legend.visible = newVal;
chart.value.toggleSeries(legend.name);
};
legend.visible = newVal
chart.value.toggleSeries(legend.name)
}
const resetChart = () => {
if (!chart.value) return;
chart.value.updateSeries([...props.data]);
chart.value.updateOptions({
xaxis: {
categories: props.labels,
},
});
chart.value.resetSeries();
legendValues.value.forEach((legend) => {
legend.visible = true;
});
};
if (!chart.value) return
chart.value.updateSeries([...props.data])
chart.value.updateOptions({
xaxis: {
categories: props.labels,
},
})
chart.value.resetSeries()
legendValues.value.forEach((legend) => {
legend.visible = true
})
}
defineExpose({
resetChart,
flipLegend,
});
resetChart,
flipLegend,
})
</script>
<template>
<VueApexCharts ref="chart" :type="type" :options="chartOptions" :series="data" class="chart" />
<VueApexCharts ref="chart" :type="type" :options="chartOptions" :series="data" class="chart" />
</template>
<style scoped lang="scss">
.chart {
width: 100%;
height: 100%;
width: 100%;
height: 100%;
}
svg {
width: 100%;
height: 100%;
width: 100%;
height: 100%;
}
.btn {
svg {
width: 1.25rem;
height: 1.25rem;
}
svg {
width: 1.25rem;
height: 1.25rem;
}
}
.bar-chart {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
overflow: hidden;
}
.title-bar {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
}
.toolbar {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
z-index: 1;
margin-left: auto;
display: flex;
flex-direction: row;
gap: var(--gap-xs);
z-index: 1;
margin-left: auto;
}
:deep(.apexcharts-menu),
:deep(.apexcharts-tooltip),
:deep(.apexcharts-yaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-button-bg) !important;
box-shadow: var(--shadow-floating) !important;
font-size: var(--font-size-nm) !important;
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-button-bg) !important;
box-shadow: var(--shadow-floating) !important;
font-size: var(--font-size-nm) !important;
}
:deep(.apexcharts-grid-borders) {
line {
stroke: var(--color-button-bg) !important;
}
line {
stroke: var(--color-button-bg) !important;
}
}
:deep(.apexcharts-yaxistooltip),
:deep(.apexcharts-xaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-button-bg) !important;
font-size: var(--font-size-nm) !important;
color: var(--color-base) !important;
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-button-bg) !important;
font-size: var(--font-size-nm) !important;
color: var(--color-base) !important;
.apexcharts-xaxistooltip-text {
font-size: var(--font-size-nm) !important;
color: var(--color-base) !important;
}
.apexcharts-xaxistooltip-text {
font-size: var(--font-size-nm) !important;
color: var(--color-base) !important;
}
}
:deep(.apexcharts-yaxistooltip-left:after) {
border-left-color: var(--color-raised-bg) !important;
border-left-color: var(--color-raised-bg) !important;
}
:deep(.apexcharts-yaxistooltip-left:before) {
border-left-color: var(--color-button-bg) !important;
border-left-color: var(--color-button-bg) !important;
}
:deep(.apexcharts-xaxistooltip-bottom:after) {
border-bottom-color: var(--color-raised-bg) !important;
border-bottom-color: var(--color-raised-bg) !important;
}
:deep(.apexcharts-xaxistooltip-bottom:before) {
border-bottom-color: var(--color-button-bg) !important;
border-bottom-color: var(--color-button-bg) !important;
}
:deep(.apexcharts-menu-item) {
border-radius: var(--radius-sm) !important;
padding: var(--gap-xs) var(--gap-sm) !important;
border-radius: var(--radius-sm) !important;
padding: var(--gap-xs) var(--gap-sm) !important;
&:hover {
transition: all 0.3s !important;
color: var(--color-accent-contrast) !important;
background: var(--color-brand) !important;
}
&:hover {
transition: all 0.3s !important;
color: var(--color-accent-contrast) !important;
background: var(--color-brand) !important;
}
}
:deep(.apexcharts-tooltip) {
.bar-tooltip {
min-width: 10rem;
display: flex;
flex-direction: column;
gap: var(--gap-xs);
padding: var(--gap-sm);
.bar-tooltip {
min-width: 10rem;
display: flex;
flex-direction: column;
gap: var(--gap-xs);
padding: var(--gap-sm);
.card-divider {
margin: var(--gap-xs) 0;
}
.card-divider {
margin: var(--gap-xs) 0;
}
.seperated-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.seperated-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.title {
font-weight: bolder;
}
.title {
font-weight: bolder;
}
.label {
margin-right: var(--gap-xl);
color: var(--color-contrast);
}
.label {
margin-right: var(--gap-xl);
color: var(--color-contrast);
}
.value {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
color: var(--color-base);
}
.value {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
color: var(--color-base);
}
.list-entry {
display: flex;
flex-direction: row;
align-items: center;
font-size: var(--font-size-sm);
.list-entry {
display: flex;
flex-direction: row;
align-items: center;
font-size: var(--font-size-sm);
.value {
margin-left: auto;
}
}
.value {
margin-left: auto;
}
}
.circle {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
display: inline-block;
margin-right: var(--gap-sm);
border: 2px solid var(--color-base);
}
.circle {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
display: inline-block;
margin-right: var(--gap-sm);
border: 2px solid var(--color-base);
}
svg {
height: 1em;
width: 1em;
}
}
svg {
height: 1em;
width: 1em;
}
}
}
.legend {
display: flex;
flex-wrap: wrap;
flex-direction: row;
align-items: center;
gap: var(--gap-lg);
justify-content: center;
display: flex;
flex-wrap: wrap;
flex-direction: row;
align-items: center;
gap: var(--gap-lg);
justify-content: center;
}
:deep(.checkbox) {
white-space: nowrap;
white-space: nowrap;
}
.legend-checkbox :deep(.checkbox.checked) {
background-color: var(--color);
background-color: var(--color);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
<script setup>
import { Card } from "@modrinth/ui";
import VueApexCharts from "vue3-apexcharts";
import { Card } from '@modrinth/ui'
import VueApexCharts from 'vue3-apexcharts'
// let VueApexCharts
// if (import.meta.client) {
@@ -8,273 +8,273 @@ import VueApexCharts from "vue3-apexcharts";
// }
const props = defineProps({
value: {
type: String,
default: "",
},
title: {
type: String,
default: "",
},
data: {
type: Array,
default: () => [],
},
labels: {
type: Array,
default: () => [],
},
prefix: {
type: String,
default: "",
},
suffix: {
type: String,
default: "",
},
isMoney: {
type: Boolean,
default: false,
},
color: {
type: String,
default: "var(--color-brand)",
},
});
value: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
data: {
type: Array,
default: () => [],
},
labels: {
type: Array,
default: () => [],
},
prefix: {
type: String,
default: '',
},
suffix: {
type: String,
default: '',
},
isMoney: {
type: Boolean,
default: false,
},
color: {
type: String,
default: 'var(--color-brand)',
},
})
// no grid lines, no toolbar, no legend, no data labels
const chartOptions = {
chart: {
id: props.title,
fontFamily:
"Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif",
foreColor: "var(--color-base)",
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
sparkline: {
enabled: true,
},
parentHeightOffset: 0,
},
stroke: {
curve: "smooth",
width: 2,
},
fill: {
colors: [props.color],
type: "gradient",
opacity: 1,
gradient: {
shade: "light",
type: "vertical",
shadeIntensity: 0,
gradientToColors: [props.color],
inverseColors: true,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 100],
colorStops: [],
},
},
grid: {
show: false,
},
legend: {
show: false,
},
colors: [props.color],
dataLabels: {
enabled: false,
},
xaxis: {
type: "datetime",
categories: props.labels,
labels: {
show: false,
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
yaxis: {
labels: {
show: false,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
tooltip: {
enabled: false,
},
};
chart: {
id: props.title,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
foreColor: 'var(--color-base)',
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
sparkline: {
enabled: true,
},
parentHeightOffset: 0,
},
stroke: {
curve: 'smooth',
width: 2,
},
fill: {
colors: [props.color],
type: 'gradient',
opacity: 1,
gradient: {
shade: 'light',
type: 'vertical',
shadeIntensity: 0,
gradientToColors: [props.color],
inverseColors: true,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 100],
colorStops: [],
},
},
grid: {
show: false,
},
legend: {
show: false,
},
colors: [props.color],
dataLabels: {
enabled: false,
},
xaxis: {
type: 'datetime',
categories: props.labels,
labels: {
show: false,
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
yaxis: {
labels: {
show: false,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
tooltip: {
enabled: false,
},
}
const chart = ref(null);
const chart = ref(null)
const resetChart = () => {
chart.value?.updateSeries([...props.data]);
chart.value?.updateOptions({
xaxis: {
categories: props.labels,
},
});
chart.value?.resetSeries();
};
chart.value?.updateSeries([...props.data])
chart.value?.updateOptions({
xaxis: {
categories: props.labels,
},
})
chart.value?.resetSeries()
}
defineExpose({
resetChart,
});
resetChart,
})
</script>
<template>
<Card class="compact-chart">
<h1 class="value">
{{ value }}
</h1>
<div class="subtitle">
{{ title }}
</div>
<div class="chart">
<VueApexCharts ref="chart" type="area" :options="chartOptions" :series="data" height="70" />
</div>
</Card>
<Card class="compact-chart">
<h1 class="value">
{{ value }}
</h1>
<div class="subtitle">
{{ title }}
</div>
<div class="chart">
<VueApexCharts ref="chart" type="area" :options="chartOptions" :series="data" height="70" />
</div>
</Card>
</template>
<style scoped lang="scss">
.compact-chart {
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
gap: var(--gap-xs);
border: 1px solid var(--color-button-bg);
border-radius: var(--radius-md);
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-floating);
gap: var(--gap-xs);
border: 1px solid var(--color-button-bg);
border-radius: var(--radius-md);
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-floating);
color: var(--color-base);
font-size: var(--font-size-nm);
color: var(--color-base);
font-size: var(--font-size-nm);
width: 100%;
width: 100%;
padding-top: var(--gap-xl);
padding-bottom: 0;
padding-top: var(--gap-xl);
padding-bottom: 0;
.value {
margin: 0;
}
.value {
margin: 0;
}
}
.chart {
// width: calc(100% + 3rem);
margin: 0 -1.5rem 0.25rem -1.5rem;
// width: calc(100% + 3rem);
margin: 0 -1.5rem 0.25rem -1.5rem;
}
svg {
width: 100%;
height: 100%;
width: 100%;
height: 100%;
}
:deep(.apexcharts-menu),
:deep(.apexcharts-tooltip),
:deep(.apexcharts-yaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-button-bg) !important;
box-shadow: var(--shadow-floating) !important;
font-size: var(--font-size-nm) !important;
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-button-bg) !important;
box-shadow: var(--shadow-floating) !important;
font-size: var(--font-size-nm) !important;
}
:deep(.apexcharts-graphical) {
width: 100%;
width: 100%;
}
:deep(.apexcharts-tooltip) {
.bar-tooltip {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
padding: var(--gap-sm);
.bar-tooltip {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
padding: var(--gap-sm);
.card-divider {
margin: var(--gap-xs) 0;
}
.card-divider {
margin: var(--gap-xs) 0;
}
.label {
display: flex;
flex-direction: row;
align-items: center;
}
.label {
display: flex;
flex-direction: row;
align-items: center;
}
.value {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
color: var(--color-base);
}
.value {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
color: var(--color-base);
}
.list-entry {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--gap-md);
}
.list-entry {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--gap-md);
}
.circle {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
display: inline-block;
margin-right: var(--gap-sm);
}
.circle {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
display: inline-block;
margin-right: var(--gap-sm);
}
svg {
height: 1em;
width: 1em;
}
svg {
height: 1em;
width: 1em;
}
.divider {
font-size: var(--font-size-lg);
font-weight: 400;
}
}
.divider {
font-size: var(--font-size-lg);
font-weight: 400;
}
}
}
.legend {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-md);
justify-content: center;
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-md);
justify-content: center;
}
:deep(.apexcharts-grid-borders) {
line {
stroke: var(--color-button-bg) !important;
}
line {
stroke: var(--color-button-bg) !important;
}
}
:deep(.apexcharts-xaxis) {
line {
stroke: none;
}
line {
stroke: none;
}
}
.legend-checkbox :deep(.checkbox.checked) {
background-color: var(--color);
background-color: var(--color);
}
</style>

View File

@@ -1,182 +1,182 @@
<template>
<div class="universal-card">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex min-w-0 flex-1 items-center gap-3">
<Avatar :src="report.project.icon_url" size="3rem" class="flex-shrink-0" />
<div class="min-w-0 flex-1">
<h3 class="truncate text-lg font-semibold">{{ report.project.title }}</h3>
<div class="universal-card">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex min-w-0 flex-1 items-center gap-3">
<Avatar :src="report.project.icon_url" size="3rem" class="flex-shrink-0" />
<div class="min-w-0 flex-1">
<h3 class="truncate text-lg font-semibold">{{ report.project.title }}</h3>
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
<nuxt-link
v-if="report.target"
:to="`/${report.target.type}/${report.target.slug}`"
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.target.avatar_url"
:circle="report.target.type === 'user'"
size="1rem"
class="flex-shrink-0"
/>
<span class="truncate">
<OrganizationIcon
v-if="report.target.type === 'organization'"
class="align-middle"
/>
{{ report.target.name }}
</span>
</nuxt-link>
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
<nuxt-link
v-if="report.target"
:to="`/${report.target.type}/${report.target.slug}`"
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.target.avatar_url"
:circle="report.target.type === 'user'"
size="1rem"
class="flex-shrink-0"
/>
<span class="truncate">
<OrganizationIcon
v-if="report.target.type === 'organization'"
class="align-middle"
/>
{{ report.target.name }}
</span>
</nuxt-link>
<div class="flex flex-wrap items-center gap-2">
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
>
Score: {{ report.priority_score }}
</span>
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold"
:class="{
'text-brand': report.status === 'approved',
'text-red': report.status === 'rejected',
'text-secondary': report.status === 'pending',
}"
>
{{ report.status.charAt(0).toUpperCase() + report.status.slice(1) }}
</span>
<span class="max-w-[200px] truncate font-mono text-xs sm:max-w-none">
{{
report.version.files.find((file) => file.primary)?.filename ||
"Unknown primary file"
}}
</span>
</div>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
>
Score: {{ report.priority_score }}
</span>
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold"
:class="{
'text-brand': report.status === 'approved',
'text-red': report.status === 'rejected',
'text-secondary': report.status === 'pending',
}"
>
{{ report.status.charAt(0).toUpperCase() + report.status.slice(1) }}
</span>
<span class="max-w-[200px] truncate font-mono text-xs sm:max-w-none">
{{
report.version.files.find((file) => file.primary)?.filename ||
'Unknown primary file'
}}
</span>
</div>
</div>
</div>
</div>
<div
class="mt-2 flex flex-col items-stretch gap-2 sm:mt-0 sm:flex-row sm:items-center sm:gap-2"
>
<span class="hidden whitespace-nowrap text-sm text-secondary sm:block">
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
</span>
<div
class="mt-2 flex flex-col items-stretch gap-2 sm:mt-0 sm:flex-row sm:items-center sm:gap-2"
>
<span class="hidden whitespace-nowrap text-sm text-secondary sm:block">
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
</span>
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex gap-2">
<ButtonStyled class="flex-1 sm:flex-none">
<button
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
:disabled="!isPending"
class="w-full sm:w-auto"
>
Accept
</button>
</ButtonStyled>
<ButtonStyled class="flex-1 sm:flex-none">
<button
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
:disabled="!isPending"
class="w-full sm:w-auto"
>
Reject
</button>
</ButtonStyled>
</div>
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex gap-2">
<ButtonStyled class="flex-1 sm:flex-none">
<button
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
:disabled="!isPending"
class="w-full sm:w-auto"
>
Accept
</button>
</ButtonStyled>
<ButtonStyled class="flex-1 sm:flex-none">
<button
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
:disabled="!isPending"
class="w-full sm:w-auto"
>
Reject
</button>
</ButtonStyled>
</div>
<div class="flex justify-center gap-2 sm:justify-start">
<ButtonStyled circular>
<nuxt-link :to="versionUrl">
<EyeIcon />
</nuxt-link>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div class="flex justify-center gap-2 sm:justify-start">
<ButtonStyled circular>
<nuxt-link :to="versionUrl">
<EyeIcon />
</nuxt-link>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div class="text-sm text-secondary sm:hidden">
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
</div>
</div>
</div>
<div class="text-sm text-secondary sm:hidden">
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ClipboardCopyIcon,
EllipsisVerticalIcon,
EyeIcon,
LinkIcon,
OrganizationIcon,
} from "@modrinth/assets";
import type { ExtendedDelphiReport } from "@modrinth/moderation";
ClipboardCopyIcon,
EllipsisVerticalIcon,
EyeIcon,
LinkIcon,
OrganizationIcon,
} from '@modrinth/assets'
import type { ExtendedDelphiReport } from '@modrinth/moderation'
import {
Avatar,
ButtonStyled,
injectNotificationManager,
OverflowMenu,
useRelativeTime,
type OverflowMenuOption,
} from "@modrinth/ui";
import dayjs from "dayjs";
Avatar,
ButtonStyled,
injectNotificationManager,
OverflowMenu,
type OverflowMenuOption,
useRelativeTime,
} from '@modrinth/ui'
import dayjs from 'dayjs'
const { addNotification } = injectNotificationManager();
const { addNotification } = injectNotificationManager()
const props = defineProps<{
report: ExtendedDelphiReport;
}>();
report: ExtendedDelphiReport
}>()
const formatRelativeTime = useRelativeTime();
const isPending = computed(() => props.report.status === "pending");
const formatRelativeTime = useRelativeTime()
const isPending = computed(() => props.report.status === 'pending')
const quickActions: OverflowMenuOption[] = [
{
id: "copy-link",
action: () => {
const base = window.location.origin;
const reviewUrl = `${base}/moderation/tech-reviews?q=${props.report.version.id}`;
navigator.clipboard.writeText(reviewUrl).then(() => {
addNotification({
type: "success",
title: "Tech review link copied",
text: "The link to this tech review has been copied to your clipboard.",
});
});
},
},
{
id: "copy-id",
action: () => {
navigator.clipboard.writeText(props.report.version.id).then(() => {
addNotification({
type: "success",
title: "Version ID copied",
text: "The ID of this version has been copied to your clipboard.",
});
});
},
},
];
{
id: 'copy-link',
action: () => {
const base = window.location.origin
const reviewUrl = `${base}/moderation/tech-reviews?q=${props.report.version.id}`
navigator.clipboard.writeText(reviewUrl).then(() => {
addNotification({
type: 'success',
title: 'Tech review link copied',
text: 'The link to this tech review has been copied to your clipboard.',
})
})
},
},
{
id: 'copy-id',
action: () => {
navigator.clipboard.writeText(props.report.version.id).then(() => {
addNotification({
type: 'success',
title: 'Version ID copied',
text: 'The ID of this version has been copied to your clipboard.',
})
})
},
},
]
const versionUrl = computed(() => {
return `/${props.report.project.project_type}/${props.report.project.slug}/versions/${props.report.version.id}`;
});
return `/${props.report.project.project_type}/${props.report.project.slug}/versions/${props.report.version.id}`
})
</script>
<style lang="scss" scoped></style>

View File

@@ -1,204 +1,200 @@
<template>
<div
class="universal-card flex min-h-[6rem] flex-col justify-between gap-3 rounded-lg p-4 sm:h-24 sm:flex-row sm:items-center sm:gap-0"
>
<div class="flex min-w-0 flex-1 items-center gap-3">
<div class="flex-shrink-0 rounded-lg">
<Avatar size="48px" :src="queueEntry.project.icon_url" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<h3 class="truncate text-lg font-semibold">
{{ queueEntry.project.name }}
</h3>
<nuxt-link
v-if="queueEntry.owner"
target="_blank"
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
:to="`/user/${queueEntry.owner.user.username}`"
>
<Avatar
:src="queueEntry.owner.user.avatar_url"
circle
size="16px"
class="inline-block flex-shrink-0"
/>
<span class="truncate">{{ queueEntry.owner.user.username }}</span>
</nuxt-link>
<nuxt-link
v-else-if="queueEntry.org"
target="_blank"
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
:to="`/organization/${queueEntry.org.slug}`"
>
<Avatar
:src="queueEntry.org.icon_url"
circle
size="16px"
class="inline-block flex-shrink-0"
/>
<span class="truncate">{{ queueEntry.org.name }}</span>
</nuxt-link>
</div>
</div>
<div
class="universal-card flex min-h-[6rem] flex-col justify-between gap-3 rounded-lg p-4 sm:h-24 sm:flex-row sm:items-center sm:gap-0"
>
<div class="flex min-w-0 flex-1 items-center gap-3">
<div class="flex-shrink-0 rounded-lg">
<Avatar size="48px" :src="queueEntry.project.icon_url" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<h3 class="truncate text-lg font-semibold">
{{ queueEntry.project.name }}
</h3>
<nuxt-link
v-if="queueEntry.owner"
target="_blank"
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
:to="`/user/${queueEntry.owner.user.username}`"
>
<Avatar
:src="queueEntry.owner.user.avatar_url"
circle
size="16px"
class="inline-block flex-shrink-0"
/>
<span class="truncate">{{ queueEntry.owner.user.username }}</span>
</nuxt-link>
<nuxt-link
v-else-if="queueEntry.org"
target="_blank"
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
:to="`/organization/${queueEntry.org.slug}`"
>
<Avatar
:src="queueEntry.org.icon_url"
circle
size="16px"
class="inline-block flex-shrink-0"
/>
<span class="truncate">{{ queueEntry.org.name }}</span>
</nuxt-link>
</div>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-1">
<span class="flex items-center gap-1 whitespace-nowrap text-sm">
<BoxIcon
v-if="queueEntry.project.project_type === 'mod'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PaintbrushIcon
v-else-if="queueEntry.project.project_type === 'resourcepack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<BracesIcon
v-else-if="queueEntry.project.project_type === 'datapack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PackageOpenIcon
v-else-if="queueEntry.project.project_type === 'modpack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<GlassesIcon
v-else-if="queueEntry.project.project_type === 'shader'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PlugIcon
v-else-if="queueEntry.project.project_type === 'plugin'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<span class="hidden sm:inline">{{
props.queueEntry.project.project_types.map(formatProjectType).join(", ")
}}</span>
<span class="sm:hidden">{{
formatProjectType(props.queueEntry.project.project_type ?? "project").substring(0, 3)
}}</span>
</span>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-1">
<span class="flex items-center gap-1 whitespace-nowrap text-sm">
<BoxIcon
v-if="queueEntry.project.project_type === 'mod'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PaintbrushIcon
v-else-if="queueEntry.project.project_type === 'resourcepack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<BracesIcon
v-else-if="queueEntry.project.project_type === 'datapack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PackageOpenIcon
v-else-if="queueEntry.project.project_type === 'modpack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<GlassesIcon
v-else-if="queueEntry.project.project_type === 'shader'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PlugIcon
v-else-if="queueEntry.project.project_type === 'plugin'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<span class="hidden sm:inline">{{
props.queueEntry.project.project_types.map(formatProjectType).join(', ')
}}</span>
<span class="sm:hidden">{{
formatProjectType(props.queueEntry.project.project_type ?? 'project').substring(0, 3)
}}</span>
</span>
<span class="hidden text-sm sm:inline">&#x2022;</span>
<span class="hidden text-sm sm:inline">&#x2022;</span>
<div class="flex flex-row gap-2 text-sm">
Requesting
<Badge
v-if="props.queueEntry.project.requested_status"
:type="props.queueEntry.project.requested_status"
class="status"
/>
</div>
<div class="flex flex-row gap-2 text-sm">
Requesting
<Badge
v-if="props.queueEntry.project.requested_status"
:type="props.queueEntry.project.requested_status"
class="status"
/>
</div>
<span class="hidden text-sm sm:inline">&#x2022;</span>
<span class="hidden text-sm sm:inline">&#x2022;</span>
<span
v-tooltip="`Since ${queuedDate.toLocaleString()}`"
class="truncate text-sm"
:class="{
'text-red': daysInQueue > 4,
'text-orange': daysInQueue > 2,
}"
>
<span class="hidden sm:inline">{{ getSubmittedTime(queueEntry) }}</span>
<span class="sm:hidden">{{
getSubmittedTime(queueEntry).replace("Submitted ", "")
}}</span>
</span>
</div>
<span
v-tooltip="`Since ${queuedDate.toLocaleString()}`"
class="truncate text-sm"
:class="{
'text-red': daysInQueue > 4,
'text-orange': daysInQueue > 2,
}"
>
<span class="hidden sm:inline">{{ getSubmittedTime(queueEntry) }}</span>
<span class="sm:hidden">{{
getSubmittedTime(queueEntry).replace('Submitted ', '')
}}</span>
</span>
</div>
<div class="flex items-center justify-end gap-2 sm:justify-start">
<ButtonStyled circular>
<NuxtLink target="_blank" :to="`/project/${queueEntry.project.slug}`">
<EyeIcon class="size-4" />
</NuxtLink>
</ButtonStyled>
<ButtonStyled circular color="orange" @click="openProjectForReview">
<button>
<ScaleIcon class="size-4" />
</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="flex items-center justify-end gap-2 sm:justify-start">
<ButtonStyled circular>
<NuxtLink target="_blank" :to="`/project/${queueEntry.project.slug}`">
<EyeIcon class="size-4" />
</NuxtLink>
</ButtonStyled>
<ButtonStyled circular color="orange" @click="openProjectForReview">
<button>
<ScaleIcon class="size-4" />
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from "dayjs";
import {
EyeIcon,
PaintbrushIcon,
ScaleIcon,
BoxIcon,
GlassesIcon,
PlugIcon,
PackageOpenIcon,
BracesIcon,
} from "@modrinth/assets";
import { useRelativeTime, Avatar, ButtonStyled, Badge } from "@modrinth/ui";
import {
formatProjectType,
type Organization,
type Project,
type TeamMember,
} from "@modrinth/utils";
import { computed } from "vue";
import { useModerationStore } from "~/store/moderation.ts";
import type { ModerationProject } from "~/helpers/moderation";
BoxIcon,
BracesIcon,
EyeIcon,
GlassesIcon,
PackageOpenIcon,
PaintbrushIcon,
PlugIcon,
ScaleIcon,
} from '@modrinth/assets'
import { Avatar, Badge, ButtonStyled, useRelativeTime } from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
const formatRelativeTime = useRelativeTime();
const moderationStore = useModerationStore();
import type { ModerationProject } from '~/helpers/moderation'
import { useModerationStore } from '~/store/moderation.ts'
const formatRelativeTime = useRelativeTime()
const moderationStore = useModerationStore()
const props = defineProps<{
queueEntry: ModerationProject;
}>();
queueEntry: ModerationProject
}>()
function getDaysQueued(date: Date): number {
const now = new Date();
const diff = now.getTime() - date.getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24));
const now = new Date()
const diff = now.getTime() - date.getTime()
return Math.floor(diff / (1000 * 60 * 60 * 24))
}
const queuedDate = computed(() => {
return dayjs(
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.updated,
);
});
return dayjs(
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.updated,
)
})
const daysInQueue = computed(() => {
return getDaysQueued(queuedDate.value.toDate());
});
return getDaysQueued(queuedDate.value.toDate())
})
function openProjectForReview() {
moderationStore.setSingleProject(props.queueEntry.project.id);
navigateTo({
name: "type-id",
params: {
type: "project",
id: props.queueEntry.project.id,
},
state: {
showChecklist: true,
},
});
moderationStore.setSingleProject(props.queueEntry.project.id)
navigateTo({
name: 'type-id',
params: {
type: 'project',
id: props.queueEntry.project.id,
},
state: {
showChecklist: true,
},
})
}
function getSubmittedTime(project: any): string {
const date =
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.updated;
if (!date) return "Unknown";
function getSubmittedTime(): string {
const date =
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.updated
if (!date) return 'Unknown'
try {
return `Submitted ${formatRelativeTime(dayjs(date).toISOString())}`;
} catch {
return "Unknown";
}
try {
return `Submitted ${formatRelativeTime(dayjs(date).toISOString())}`
} catch {
return 'Unknown'
}
}
</script>

View File

@@ -1,278 +1,282 @@
<template>
<div class="universal-card">
<div
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
>
<span class="text-md flex flex-col gap-2 sm:flex-row sm:items-center">
<span class="flex items-center gap-2">
Reported for
<span class="whitespace-nowrap rounded-full align-middle font-semibold text-contrast">
{{ formattedReportType }}
</span>
</span>
<span class="flex items-center gap-2">
<span class="hidden sm:inline">By</span>
<span class="sm:hidden">Reporter:</span>
<nuxt-link
:to="`/user/${report.reporter_user.username}`"
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.reporter_user.avatar_url"
circle
size="1.75rem"
class="flex-shrink-0"
/>
<span class="truncate">{{ report.reporter_user.username }}</span>
</nuxt-link>
</span>
</span>
<div class="universal-card">
<div
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
>
<span class="text-md flex flex-col gap-2 sm:flex-row sm:items-center">
<span class="flex items-center gap-2">
Reported for
<span class="whitespace-nowrap rounded-full align-middle font-semibold text-contrast">
{{ formattedReportType }}
</span>
</span>
<span class="flex items-center gap-2">
<span class="hidden sm:inline">By</span>
<span class="sm:hidden">Reporter:</span>
<nuxt-link
:to="`/user/${report.reporter_user.username}`"
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.reporter_user.avatar_url"
circle
size="1.75rem"
class="flex-shrink-0"
/>
<span class="truncate">{{ report.reporter_user.username }}</span>
</nuxt-link>
</span>
</span>
<div class="flex flex-row items-center gap-2 self-end sm:self-auto">
<span class="text-md whitespace-nowrap text-secondary">{{
formatRelativeTime(report.created)
}}</span>
<ButtonStyled v-if="visibleQuickReplies.length > 0" circular>
<OverflowMenu :options="visibleQuickReplies">
<span class="hidden sm:inline">Quick Reply</span>
<span class="sr-only sm:hidden">Quick Reply</span>
<ChevronDownIcon />
</OverflowMenu>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
<div class="flex flex-row items-center gap-2 self-end sm:self-auto">
<span class="text-md whitespace-nowrap text-secondary">{{
formatRelativeTime(report.created)
}}</span>
<ButtonStyled v-if="visibleQuickReplies.length > 0" circular>
<OverflowMenu :options="visibleQuickReplies">
<span class="hidden sm:inline">Quick Reply</span>
<span class="sr-only sm:hidden">Quick Reply</span>
<ChevronDownIcon />
</OverflowMenu>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
<hr class="my-4 rounded-xl border-solid text-divider" />
<hr class="my-4 rounded-xl border-solid text-divider" />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex min-w-0 flex-1 items-center gap-3">
<Avatar
:src="reportItemAvatarUrl"
:circle="report.item_type === 'user'"
size="3rem"
class="flex-shrink-0"
/>
<div class="min-w-0 flex-1">
<span class="block truncate text-lg font-semibold">{{ reportItemTitle }}</span>
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
<nuxt-link
v-if="report.target && report.item_type != 'user'"
:to="`/${report.target.type}/${report.target.slug}`"
class="inline-flex flex-row items-center gap-1 truncate transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.target?.avatar_url"
:circle="report.target.type === 'user'"
size="1rem"
class="flex-shrink-0"
/>
<span class="truncate">
<OrganizationIcon
v-if="report.target.type === 'organization'"
class="align-middle"
/>
{{ report.target.name || "Unknown User" }}
</span>
</nuxt-link>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex min-w-0 flex-1 items-center gap-3">
<Avatar
:src="reportItemAvatarUrl"
:circle="report.item_type === 'user'"
size="3rem"
class="flex-shrink-0"
/>
<div class="min-w-0 flex-1">
<span class="block truncate text-lg font-semibold">{{ reportItemTitle }}</span>
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
<nuxt-link
v-if="report.target && report.item_type != 'user'"
:to="`/${report.target.type}/${report.target.slug}`"
class="inline-flex flex-row items-center gap-1 truncate transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.target?.avatar_url"
:circle="report.target.type === 'user'"
size="1rem"
class="flex-shrink-0"
/>
<span class="truncate">
<OrganizationIcon
v-if="report.target.type === 'organization'"
class="align-middle"
/>
{{ report.target.name || 'Unknown User' }}
</span>
</nuxt-link>
<div class="flex flex-wrap items-center gap-2">
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
>
{{ formattedItemType }}
</span>
<span
v-if="report.item_type === 'version' && report.version"
class="max-w-[200px] truncate font-mono text-xs sm:max-w-none"
>
{{
report.version.files.find((file) => file.primary)?.filename || "Unknown Version"
}}
</span>
</div>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
>
{{ formattedItemType }}
</span>
<span
v-if="report.item_type === 'version' && report.version"
class="max-w-[200px] truncate font-mono text-xs sm:max-w-none"
>
{{
report.version.files.find((file) => file.primary)?.filename || 'Unknown Version'
}}
</span>
</div>
</div>
</div>
</div>
<div class="flex justify-end sm:justify-start">
<ButtonStyled circular>
<nuxt-link :to="reportItemUrl">
<EyeIcon />
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
<div class="flex justify-end sm:justify-start">
<ButtonStyled circular>
<nuxt-link :to="reportItemUrl">
<EyeIcon />
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
<CollapsibleRegion class="my-4" ref="collapsibleRegion">
<ReportThread
v-if="report.thread"
ref="reportThread"
class="mb-16 sm:mb-0"
:thread="report.thread"
:report="report"
:reporter="report.reporter_user"
@update-thread="updateThread"
/>
</CollapsibleRegion>
</div>
<CollapsibleRegion ref="collapsibleRegion" class="my-4">
<ReportThread
v-if="report.thread"
ref="reportThread"
class="mb-16 sm:mb-0"
:thread="report.thread"
:report="report"
:reporter="report.reporter_user"
@update-thread="updateThread"
/>
</CollapsibleRegion>
</div>
</template>
<script setup lang="ts">
import {
ClipboardCopyIcon,
EllipsisVerticalIcon,
EyeIcon,
LinkIcon,
OrganizationIcon,
} from "@modrinth/assets";
ClipboardCopyIcon,
EllipsisVerticalIcon,
EyeIcon,
LinkIcon,
OrganizationIcon,
} from '@modrinth/assets'
import {
type ExtendedReport,
reportQuickReplies,
type ReportQuickReply,
} from "@modrinth/moderation";
type ExtendedReport,
reportQuickReplies,
type ReportQuickReply,
} from '@modrinth/moderation'
import {
Avatar,
ButtonStyled,
CollapsibleRegion,
injectNotificationManager,
OverflowMenu,
type OverflowMenuOption,
useRelativeTime,
} from "@modrinth/ui";
import ChevronDownIcon from "../servers/icons/ChevronDownIcon.vue";
import ReportThread from "../thread/ReportThread.vue";
Avatar,
ButtonStyled,
CollapsibleRegion,
injectNotificationManager,
OverflowMenu,
type OverflowMenuOption,
useRelativeTime,
} from '@modrinth/ui'
import { computed } from 'vue'
const { addNotification } = injectNotificationManager();
import ChevronDownIcon from '../servers/icons/ChevronDownIcon.vue'
import ReportThread from '../thread/ReportThread.vue'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
report: ExtendedReport;
}>();
report: ExtendedReport
}>()
const reportThread = ref<InstanceType<typeof ReportThread> | null>(null);
const collapsibleRegion = ref<InstanceType<typeof CollapsibleRegion> | null>(null);
const reportThread = ref<InstanceType<typeof ReportThread> | null>(null)
const collapsibleRegion = ref<InstanceType<typeof CollapsibleRegion> | null>(null)
const formatRelativeTime = useRelativeTime();
const formatRelativeTime = useRelativeTime()
function updateThread(newThread: any) {
if (props.report.thread) {
Object.assign(props.report.thread, newThread);
}
if (props.report.thread) {
Object.assign(props.report.thread, newThread)
}
}
const quickActions: OverflowMenuOption[] = [
{
id: "copy-link",
action: () => {
const base = window.location.origin;
const reportUrl = `${base}/moderation/reports/${props.report.id}`;
navigator.clipboard.writeText(reportUrl).then(() => {
addNotification({
type: "success",
title: "Report link copied",
text: "The link to this report has been copied to your clipboard.",
});
});
},
},
{
id: "copy-id",
action: () => {
navigator.clipboard.writeText(props.report.id).then(() => {
addNotification({
type: "success",
title: "Report ID copied",
text: "The ID of this report has been copied to your clipboard.",
});
});
},
},
];
{
id: 'copy-link',
action: () => {
const base = window.location.origin
const reportUrl = `${base}/moderation/reports/${props.report.id}`
navigator.clipboard.writeText(reportUrl).then(() => {
addNotification({
type: 'success',
title: 'Report link copied',
text: 'The link to this report has been copied to your clipboard.',
})
})
},
},
{
id: 'copy-id',
action: () => {
navigator.clipboard.writeText(props.report.id).then(() => {
addNotification({
type: 'success',
title: 'Report ID copied',
text: 'The ID of this report has been copied to your clipboard.',
})
})
},
},
]
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
return reportQuickReplies
.filter((reply) => {
if (reply.shouldShow === undefined) return true;
if (typeof reply.shouldShow === "function") {
return reply.shouldShow(props.report);
}
return reportQuickReplies
.filter((reply) => {
if (reply.shouldShow === undefined) return true
if (typeof reply.shouldShow === 'function') {
return reply.shouldShow(props.report)
}
return reply.shouldShow;
})
.map(
(reply) =>
({
id: reply.label,
action: () => handleQuickReply(reply),
}) as OverflowMenuOption,
);
});
return reply.shouldShow
})
.map(
(reply) =>
({
id: reply.label,
action: () => handleQuickReply(reply),
}) as OverflowMenuOption,
)
})
async function handleQuickReply(reply: ReportQuickReply) {
const message =
typeof reply.message === "function" ? await reply.message(props.report) : reply.message;
const message =
typeof reply.message === 'function' ? await reply.message(props.report) : reply.message
collapsibleRegion.value?.setCollapsed(false);
await nextTick();
reportThread.value?.setReplyContent(message);
collapsibleRegion.value?.setCollapsed(false)
await nextTick()
reportThread.value?.setReplyContent(message)
}
const reportItemAvatarUrl = computed(() => {
switch (props.report.item_type) {
case "project":
case "version":
return props.report.project?.icon_url || "";
case "user":
return props.report.user?.avatar_url || "";
default:
return undefined;
}
});
switch (props.report.item_type) {
case 'project':
case 'version':
return props.report.project?.icon_url || ''
case 'user':
return props.report.user?.avatar_url || ''
default:
return undefined
}
})
const reportItemTitle = computed(() => {
if (props.report.item_type === "user") return props.report.user?.username || "Unknown User";
if (props.report.item_type === 'user') return props.report.user?.username || 'Unknown User'
return props.report.project?.title || "Unknown Project";
});
return props.report.project?.title || 'Unknown Project'
})
const reportItemUrl = computed(() => {
switch (props.report.item_type) {
case "user":
return `/user/${props.report.user?.username}`;
case "project":
return `/${props.report.project?.project_type}/${props.report.project?.slug}`;
case "version":
return `/${props.report.project?.project_type}/${props.report.project?.slug}/versions/${props.report.version?.id}`;
}
});
switch (props.report.item_type) {
case 'user':
return `/user/${props.report.user?.username}`
case 'project':
return `/${props.report.project?.project_type}/${props.report.project?.slug}`
case 'version':
return `/${props.report.project?.project_type}/${props.report.project?.slug}/versions/${props.report.version?.id}`
default:
return `/${props.report.item_type}/${props.report.id}`
}
})
const formattedItemType = computed(() => {
const itemType = props.report.item_type;
return itemType.charAt(0).toUpperCase() + itemType.slice(1);
});
const itemType = props.report.item_type
return itemType.charAt(0).toUpperCase() + itemType.slice(1)
})
const formattedReportType = computed(() => {
const reportType = props.report.report_type;
const reportType = props.report.report_type
// some are split by -, some are split by " "
const words = reportType.includes("-") ? reportType.split("-") : reportType.split(" ");
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
});
// some are split by -, some are split by " "
const words = reportType.includes('-') ? reportType.split('-') : reportType.split(' ')
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
})
</script>
<style lang="scss" scoped></style>

View File

@@ -1,116 +1,116 @@
<template>
<NewModal ref="modal" header="Moderation shortcuts" :closable="true">
<div>
<div class="keybinds-sections">
<div class="grid grid-cols-2 gap-x-12 gap-y-3">
<div
v-for="keybind in keybinds"
:key="keybind.id"
class="keybind-item flex items-center justify-between gap-4"
:class="{
'col-span-2': keybinds.length % 2 === 1 && keybinds[keybinds.length - 1] === keybind,
}"
>
<span class="text-sm text-secondary">{{ keybind.description }}</span>
<div class="flex items-center gap-1">
<kbd
v-for="(key, index) in parseKeybindDisplay(keybind.keybind)"
:key="`${keybind.id}-key-${index}`"
class="keybind-key"
>
{{ key }}
</kbd>
</div>
</div>
</div>
</div>
</div>
</NewModal>
<NewModal ref="modal" header="Moderation shortcuts" :closable="true">
<div>
<div class="keybinds-sections">
<div class="grid grid-cols-2 gap-x-12 gap-y-3">
<div
v-for="keybind in keybinds"
:key="keybind.id"
class="keybind-item flex items-center justify-between gap-4"
:class="{
'col-span-2': keybinds.length % 2 === 1 && keybinds[keybinds.length - 1] === keybind,
}"
>
<span class="text-sm text-secondary">{{ keybind.description }}</span>
<div class="flex items-center gap-1">
<kbd
v-for="(key, index) in parseKeybindDisplay(keybind.keybind)"
:key="`${keybind.id}-key-${index}`"
class="keybind-key"
>
{{ key }}
</kbd>
</div>
</div>
</div>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ref } from "vue";
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
import { type KeybindListener, keybinds, normalizeKeybind } from '@modrinth/moderation'
import NewModal from '@modrinth/ui/src/components/modal/NewModal.vue'
import { ref } from 'vue'
const modal = ref<InstanceType<typeof NewModal>>();
const modal = ref<InstanceType<typeof NewModal>>()
function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
const keybinds = Array.isArray(keybind) ? keybind : [keybind];
const normalized = keybinds[0];
const def = normalizeKeybind(normalized);
function parseKeybindDisplay(keybind: KeybindListener['keybind']): string[] {
const keybinds = Array.isArray(keybind) ? keybind : [keybind]
const normalized = keybinds[0]
const def = normalizeKeybind(normalized)
const keys = [];
const keys = []
if (def.ctrl || def.meta) {
keys.push(isMac() ? "CMD" : "CTRL");
}
if (def.shift) keys.push("SHIFT");
if (def.alt) keys.push("ALT");
if (def.ctrl || def.meta) {
keys.push(isMac() ? 'CMD' : 'CTRL')
}
if (def.shift) keys.push('SHIFT')
if (def.alt) keys.push('ALT')
const mainKey = def.key
.replace("ArrowLeft", "←")
.replace("ArrowRight", "→")
.replace("ArrowUp", "↑")
.replace("ArrowDown", "↓")
.replace("Enter", "↵")
.replace("Space", "SPACE")
.replace("Escape", "ESC")
.toUpperCase();
const mainKey = def.key
.replace('ArrowLeft', '←')
.replace('ArrowRight', '→')
.replace('ArrowUp', '↑')
.replace('ArrowDown', '↓')
.replace('Enter', '↵')
.replace('Space', 'SPACE')
.replace('Escape', 'ESC')
.toUpperCase()
keys.push(mainKey);
keys.push(mainKey)
return keys;
return keys
}
function isMac() {
return navigator.platform.toUpperCase().includes("MAC");
return navigator.platform.toUpperCase().includes('MAC')
}
function show(event?: MouseEvent) {
modal.value?.show(event);
modal.value?.show(event)
}
function hide() {
modal.value?.hide();
modal.value?.hide()
}
defineExpose({
show,
hide,
});
show,
hide,
})
</script>
<style scoped lang="scss">
.keybind-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
padding: 0.25rem 0.5rem;
background-color: var(--color-bg);
border: 1px solid var(--color-divider);
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-contrast);
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
padding: 0.25rem 0.5rem;
background-color: var(--color-bg);
border: 1px solid var(--color-divider);
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-contrast);
+ .keybind-key {
margin-left: 0.25rem;
}
+ .keybind-key {
margin-left: 0.25rem;
}
}
.keybind-item {
min-height: 2rem;
min-height: 2rem;
}
@media (max-width: 768px) {
.keybinds-sections {
.grid {
grid-template-columns: 1fr;
gap: 0.75rem;
}
}
.keybinds-sections {
.grid {
grid-template-columns: 1fr;
gap: 0.75rem;
}
}
}
</style>

View File

@@ -1,513 +1,513 @@
<template>
<div>
<h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
Modpack permissions ({{ Math.min(modPackData.length, currentIndex + 1) }} /
{{ modPackData.length }})
</h2>
<div>
<h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
Modpack permissions ({{ Math.min(modPackData.length, currentIndex + 1) }} /
{{ modPackData.length }})
</h2>
<div v-if="!modPackData">Loading data...</div>
<div v-if="!modPackData">Loading data...</div>
<div v-else-if="modPackData.length === 0">
<p>All permissions already obtained.</p>
</div>
<div v-else-if="modPackData.length === 0">
<p>All permissions already obtained.</p>
</div>
<div v-else-if="!modPackData[currentIndex]">
<p>All permission checks complete!</p>
</div>
<div v-else-if="!modPackData[currentIndex]">
<p>All permission checks complete!</p>
</div>
<div v-else>
<div v-if="modPackData[currentIndex].type === 'unknown'">
<p>What is the approval type of {{ modPackData[currentIndex].file_name }}?</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in fileApprovalTypes"
:key="index"
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
@click="setStatus(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
<div v-if="modPackData[currentIndex].status !== 'unidentified'" class="flex flex-col gap-1">
<label for="proof">
<span class="label__title">Proof</span>
</label>
<input
id="proof"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).proof"
type="text"
autocomplete="off"
placeholder="Enter proof of status..."
@input="persistAll()"
/>
<label for="link">
<span class="label__title">Link</span>
</label>
<input
id="link"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).url"
type="text"
autocomplete="off"
placeholder="Enter link of project..."
@input="persistAll()"
/>
<label for="title">
<span class="label__title">Title</span>
</label>
<input
id="title"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).title"
type="text"
autocomplete="off"
placeholder="Enter title of project..."
@input="persistAll()"
/>
</div>
</div>
<div v-else>
<div v-if="modPackData[currentIndex].type === 'unknown'">
<p>What is the approval type of {{ modPackData[currentIndex].file_name }}?</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in fileApprovalTypes"
:key="index"
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
@click="setStatus(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
<div v-if="modPackData[currentIndex].status !== 'unidentified'" class="flex flex-col gap-1">
<label for="proof">
<span class="label__title">Proof</span>
</label>
<input
id="proof"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).proof"
type="text"
autocomplete="off"
placeholder="Enter proof of status..."
@input="persistAll()"
/>
<label for="link">
<span class="label__title">Link</span>
</label>
<input
id="link"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).url"
type="text"
autocomplete="off"
placeholder="Enter link of project..."
@input="persistAll()"
/>
<label for="title">
<span class="label__title">Title</span>
</label>
<input
id="title"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).title"
type="text"
autocomplete="off"
placeholder="Enter title of project..."
@input="persistAll()"
/>
</div>
</div>
<div v-else-if="modPackData[currentIndex].type === 'flame'">
<p>
What is the approval type of {{ modPackData[currentIndex].title }} (<a
:href="modPackData[currentIndex].url"
target="_blank"
class="text-link"
>{{ modPackData[currentIndex].url }}</a
>)?
</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in fileApprovalTypes"
:key="index"
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
@click="setStatus(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
</div>
<div v-else-if="modPackData[currentIndex].type === 'flame'">
<p>
What is the approval type of {{ modPackData[currentIndex].title }} (<a
:href="modPackData[currentIndex].url"
target="_blank"
class="text-link"
>{{ modPackData[currentIndex].url }}</a
>)?
</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in fileApprovalTypes"
:key="index"
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
@click="setStatus(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
</div>
<div
v-if="
['unidentified', 'no', 'with-attribution'].includes(
modPackData[currentIndex].status || '',
)
"
>
<p v-if="modPackData[currentIndex].status === 'unidentified'">
Does this project provide identification and permission for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<p v-else-if="modPackData[currentIndex].status === 'with-attribution'">
Does this project provide attribution for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<p v-else>
Does this project provide proof of permission for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in filePermissionTypes"
:key="index"
:color="modPackData[currentIndex].approved === option.id ? 'brand' : 'standard'"
@click="setApproval(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<div
v-if="
['unidentified', 'no', 'with-attribution'].includes(
modPackData[currentIndex].status || '',
)
"
>
<p v-if="modPackData[currentIndex].status === 'unidentified'">
Does this project provide identification and permission for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<p v-else-if="modPackData[currentIndex].status === 'with-attribution'">
Does this project provide attribution for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<p v-else>
Does this project provide proof of permission for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in filePermissionTypes"
:key="index"
:color="modPackData[currentIndex].approved === option.id ? 'brand' : 'standard'"
@click="setApproval(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="mt-4 flex gap-2">
<ButtonStyled>
<button :disabled="currentIndex <= 0" @click="goToPrevious">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
</ButtonStyled>
<ButtonStyled v-if="modPackData && currentIndex < modPackData.length" color="blue">
<button :disabled="!canGoNext" @click="goToNext">
<RightArrowIcon aria-hidden="true" />
{{ currentIndex + 1 >= modPackData.length ? "Complete" : "Next" }}
</button>
</ButtonStyled>
</div>
</div>
<div class="mt-4 flex gap-2">
<ButtonStyled>
<button :disabled="currentIndex <= 0" @click="goToPrevious">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
</ButtonStyled>
<ButtonStyled v-if="modPackData && currentIndex < modPackData.length" color="blue">
<button :disabled="!canGoNext" @click="goToNext">
<RightArrowIcon aria-hidden="true" />
{{ currentIndex + 1 >= modPackData.length ? 'Complete' : 'Next' }}
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { LeftArrowIcon, RightArrowIcon } from "@modrinth/assets";
import { LeftArrowIcon, RightArrowIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import type {
ModerationJudgements,
ModerationModpackItem,
ModerationModpackResponse,
ModerationUnknownModpackItem,
ModerationFlameModpackItem,
ModerationModpackPermissionApprovalType,
ModerationPermissionType,
} from "@modrinth/utils";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, onMounted } from "vue";
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
ModerationFlameModpackItem,
ModerationJudgements,
ModerationModpackItem,
ModerationModpackPermissionApprovalType,
ModerationModpackResponse,
ModerationPermissionType,
ModerationUnknownModpackItem,
} from '@modrinth/utils'
import { useLocalStorage, useSessionStorage } from '@vueuse/core'
import { computed, onMounted, ref, watch } from 'vue'
const props = defineProps<{
projectId: string;
modelValue?: ModerationJudgements;
}>();
projectId: string
modelValue?: ModerationJudgements
}>()
const emit = defineEmits<{
complete: [];
"update:modelValue": [judgements: ModerationJudgements];
}>();
complete: []
'update:modelValue': [judgements: ModerationJudgements]
}>()
const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
`modpack-permissions-${props.projectId}`,
null,
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : null),
write: (v: any) => JSON.stringify(v),
},
},
);
`modpack-permissions-${props.projectId}`,
null,
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : null),
write: (v: any) => JSON.stringify(v),
},
},
)
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0)
const modPackData = useSessionStorage<ModerationModpackItem[] | null>(
`modpack-permissions-data-${props.projectId}`,
null,
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : null),
write: (v: any) => JSON.stringify(v),
},
},
);
`modpack-permissions-data-${props.projectId}`,
null,
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : null),
write: (v: any) => JSON.stringify(v),
},
},
)
const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>(
`modpack-permissions-permanent-no-${props.projectId}`,
[],
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : []),
write: (v: any) => JSON.stringify(v),
},
},
);
const currentIndex = ref(0);
`modpack-permissions-permanent-no-${props.projectId}`,
[],
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : []),
write: (v: any) => JSON.stringify(v),
},
},
)
const currentIndex = ref(0)
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
{
id: "yes",
name: "Yes",
},
{
id: "with-attribution-and-source",
name: "With attribution and source",
},
{
id: "with-attribution",
name: "With attribution",
},
{
id: "no",
name: "No",
},
{
id: "permanent-no",
name: "Permanent no",
},
{
id: "unidentified",
name: "Unidentified",
},
];
{
id: 'yes',
name: 'Yes',
},
{
id: 'with-attribution-and-source',
name: 'With attribution and source',
},
{
id: 'with-attribution',
name: 'With attribution',
},
{
id: 'no',
name: 'No',
},
{
id: 'permanent-no',
name: 'Permanent no',
},
{
id: 'unidentified',
name: 'Unidentified',
},
]
const filePermissionTypes: ModerationPermissionType[] = [
{ id: "yes", name: "Yes" },
{ id: "no", name: "No" },
];
{ id: 'yes', name: 'Yes' },
{ id: 'no', name: 'No' },
]
function persistAll() {
persistedModPackData.value = modPackData.value;
persistedIndex.value = currentIndex.value;
persistedModPackData.value = modPackData.value
persistedIndex.value = currentIndex.value
}
watch(
modPackData,
(newValue) => {
persistedModPackData.value = newValue;
},
{ deep: true },
);
modPackData,
(newValue) => {
persistedModPackData.value = newValue
},
{ deep: true },
)
watch(currentIndex, (newValue) => {
persistedIndex.value = newValue;
});
persistedIndex.value = newValue
})
function loadPersistedData(): void {
if (persistedModPackData.value) {
modPackData.value = persistedModPackData.value;
}
currentIndex.value = persistedIndex.value;
if (persistedModPackData.value) {
modPackData.value = persistedModPackData.value
}
currentIndex.value = persistedIndex.value
}
function clearPersistedData(): void {
persistedModPackData.value = null;
persistedIndex.value = 0;
persistedModPackData.value = null
persistedIndex.value = 0
}
async function fetchModPackData(): Promise<void> {
try {
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
internal: true,
})) as ModerationModpackResponse;
try {
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
internal: true,
})) as ModerationModpackResponse
const permanentNoItems: ModerationModpackItem[] = Object.entries(data.identified || {})
.filter(([_, file]) => file.status === "permanent-no")
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: "identified",
status: file.status,
approved: null,
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name));
const permanentNoItems: ModerationModpackItem[] = Object.entries(data.identified || {})
.filter(([_, file]) => file.status === 'permanent-no')
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: 'identified',
status: file.status,
approved: null,
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name))
permanentNoFiles.value = permanentNoItems;
permanentNoFiles.value = permanentNoItems
const sortedData: ModerationModpackItem[] = [
...Object.entries(data.identified || {})
.filter(
([_, file]) =>
file.status !== "yes" &&
file.status !== "with-attribution-and-source" &&
file.status !== "permanent-no",
)
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: "identified",
status: file.status,
approved: null,
...(file.status === "unidentified" && {
proof: "",
url: "",
title: "",
}),
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
...Object.entries(data.unknown_files || {})
.map(
([sha1, fileName]): ModerationUnknownModpackItem => ({
sha1,
file_name: fileName,
type: "unknown",
status: null,
approved: null,
proof: "",
url: "",
title: "",
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
...Object.entries(data.flame_files || {})
.map(
([sha1, info]): ModerationFlameModpackItem => ({
sha1,
file_name: info.file_name,
type: "flame",
status: null,
approved: null,
id: info.id,
title: info.title || info.file_name,
url: info.url || `https://www.curseforge.com/minecraft/mc-mods/${info.id}`,
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
];
const sortedData: ModerationModpackItem[] = [
...Object.entries(data.identified || {})
.filter(
([_, file]) =>
file.status !== 'yes' &&
file.status !== 'with-attribution-and-source' &&
file.status !== 'permanent-no',
)
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: 'identified',
status: file.status,
approved: null,
...(file.status === 'unidentified' && {
proof: '',
url: '',
title: '',
}),
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
...Object.entries(data.unknown_files || {})
.map(
([sha1, fileName]): ModerationUnknownModpackItem => ({
sha1,
file_name: fileName,
type: 'unknown',
status: null,
approved: null,
proof: '',
url: '',
title: '',
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
...Object.entries(data.flame_files || {})
.map(
([sha1, info]): ModerationFlameModpackItem => ({
sha1,
file_name: info.file_name,
type: 'flame',
status: null,
approved: null,
id: info.id,
title: info.title || info.file_name,
url: info.url || `https://www.curseforge.com/minecraft/mc-mods/${info.id}`,
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
]
if (modPackData.value) {
const existingMap = new Map(modPackData.value.map((item) => [item.sha1, item]));
if (modPackData.value) {
const existingMap = new Map(modPackData.value.map((item) => [item.sha1, item]))
sortedData.forEach((item) => {
const existing = existingMap.get(item.sha1);
if (existing) {
Object.assign(item, {
status: existing.status,
approved: existing.approved,
...(item.type === "unknown" && {
proof: (existing as ModerationUnknownModpackItem).proof || "",
url: (existing as ModerationUnknownModpackItem).url || "",
title: (existing as ModerationUnknownModpackItem).title || "",
}),
...(item.type === "flame" && {
url: (existing as ModerationFlameModpackItem).url || item.url,
title: (existing as ModerationFlameModpackItem).title || item.title,
}),
});
}
});
}
sortedData.forEach((item) => {
const existing = existingMap.get(item.sha1)
if (existing) {
Object.assign(item, {
status: existing.status,
approved: existing.approved,
...(item.type === 'unknown' && {
proof: (existing as ModerationUnknownModpackItem).proof || '',
url: (existing as ModerationUnknownModpackItem).url || '',
title: (existing as ModerationUnknownModpackItem).title || '',
}),
...(item.type === 'flame' && {
url: (existing as ModerationFlameModpackItem).url || item.url,
title: (existing as ModerationFlameModpackItem).title || item.title,
}),
})
}
})
}
modPackData.value = sortedData;
persistAll();
} catch (error) {
console.error("Failed to fetch modpack data:", error);
modPackData.value = [];
permanentNoFiles.value = [];
persistAll();
}
modPackData.value = sortedData
persistAll()
} catch (error) {
console.error('Failed to fetch modpack data:', error)
modPackData.value = []
permanentNoFiles.value = []
persistAll()
}
}
function goToPrevious(): void {
if (currentIndex.value > 0) {
currentIndex.value--;
persistAll();
}
if (currentIndex.value > 0) {
currentIndex.value--
persistAll()
}
}
watch(
modPackData,
(newValue) => {
persistedModPackData.value = newValue;
},
{ deep: true },
);
modPackData,
(newValue) => {
persistedModPackData.value = newValue
},
{ deep: true },
)
function goToNext(): void {
if (modPackData.value && currentIndex.value < modPackData.value.length) {
currentIndex.value++;
if (modPackData.value && currentIndex.value < modPackData.value.length) {
currentIndex.value++
if (currentIndex.value >= modPackData.value.length) {
const judgements = getJudgements();
emit("update:modelValue", judgements);
emit("complete");
clearPersistedData();
} else {
persistAll();
}
}
if (currentIndex.value >= modPackData.value.length) {
const judgements = getJudgements()
emit('update:modelValue', judgements)
emit('complete')
clearPersistedData()
} else {
persistAll()
}
}
}
function setStatus(index: number, status: ModerationModpackPermissionApprovalType["id"]): void {
if (modPackData.value && modPackData.value[index]) {
modPackData.value[index].status = status;
modPackData.value[index].approved = null;
persistAll();
emit("update:modelValue", getJudgements());
}
function setStatus(index: number, status: ModerationModpackPermissionApprovalType['id']): void {
if (modPackData.value && modPackData.value[index]) {
modPackData.value[index].status = status
modPackData.value[index].approved = null
persistAll()
emit('update:modelValue', getJudgements())
}
}
function setApproval(index: number, approved: ModerationPermissionType["id"]): void {
if (modPackData.value && modPackData.value[index]) {
modPackData.value[index].approved = approved;
persistAll();
emit("update:modelValue", getJudgements());
}
function setApproval(index: number, approved: ModerationPermissionType['id']): void {
if (modPackData.value && modPackData.value[index]) {
modPackData.value[index].approved = approved
persistAll()
emit('update:modelValue', getJudgements())
}
}
const canGoNext = computed(() => {
if (!modPackData.value || !modPackData.value[currentIndex.value]) return false;
const current = modPackData.value[currentIndex.value];
return current.status !== null;
});
if (!modPackData.value || !modPackData.value[currentIndex.value]) return false
const current = modPackData.value[currentIndex.value]
return current.status !== null
})
function getJudgements(): ModerationJudgements {
if (!modPackData.value) return {};
if (!modPackData.value) return {}
const judgements: ModerationJudgements = {};
const judgements: ModerationJudgements = {}
modPackData.value.forEach((item) => {
if (item.type === "flame") {
judgements[item.sha1] = {
type: "flame",
id: item.id,
status: item.status,
link: item.url,
title: item.title,
file_name: item.file_name,
};
} else if (item.type === "unknown") {
judgements[item.sha1] = {
type: "unknown",
status: item.status,
proof: item.proof,
link: item.url,
title: item.title,
file_name: item.file_name,
};
}
});
modPackData.value.forEach((item) => {
if (item.type === 'flame') {
judgements[item.sha1] = {
type: 'flame',
id: item.id,
status: item.status,
link: item.url,
title: item.title,
file_name: item.file_name,
}
} else if (item.type === 'unknown') {
judgements[item.sha1] = {
type: 'unknown',
status: item.status,
proof: item.proof,
link: item.url,
title: item.title,
file_name: item.file_name,
}
}
})
return judgements;
return judgements
}
onMounted(() => {
loadPersistedData();
if (!modPackData.value) {
fetchModPackData();
}
});
loadPersistedData()
if (!modPackData.value) {
fetchModPackData()
}
})
watch(
modPackData,
(newValue) => {
if (newValue && newValue.length === 0) {
emit("complete");
clearPersistedData();
}
},
{ immediate: true },
);
modPackData,
(newValue) => {
if (newValue && newValue.length === 0) {
emit('complete')
clearPersistedData()
}
},
{ immediate: true },
)
watch(
() => props.projectId,
() => {
clearPersistedData();
loadPersistedData();
if (!modPackData.value) {
fetchModPackData();
}
},
);
() => props.projectId,
() => {
clearPersistedData()
loadPersistedData()
if (!modPackData.value) {
fetchModPackData()
}
},
)
function getModpackFiles(): {
interactive: ModerationModpackItem[];
permanentNo: ModerationModpackItem[];
interactive: ModerationModpackItem[]
permanentNo: ModerationModpackItem[]
} {
return {
interactive: modPackData.value || [],
permanentNo: permanentNoFiles.value,
};
return {
interactive: modPackData.value || [],
permanentNo: permanentNoFiles.value,
}
}
defineExpose({
getModpackFiles,
});
getModpackFiles,
})
</script>
<style scoped>
.input-group {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.modpack-buttons {
margin-top: 1rem;
margin-top: 1rem;
}
</style>

View File

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

View File

@@ -1,49 +1,49 @@
<template>
<div class="mx-2 p-4 !py-8 sm:mx-8 sm:p-32">
<div class="my-8 flex items-center justify-between">
<h2 class="m-0 mx-auto text-3xl font-extrabold sm:text-4xl">Latest news from Modrinth</h2>
</div>
<div class="mx-2 p-4 !py-8 sm:mx-8 sm:p-32">
<div class="my-8 flex items-center justify-between">
<h2 class="m-0 mx-auto text-3xl font-extrabold sm:text-4xl">Latest news from Modrinth</h2>
</div>
<div v-if="latestArticles" class="grid grid-cols-[repeat(auto-fit,minmax(250px,1fr))] gap-4">
<div
v-for="(article, index) in latestArticles"
:key="article.slug"
:class="{ 'max-xl:hidden': index === 2 }"
>
<NewsArticleCard :article="article" />
</div>
</div>
<div class="mx-2 my-8 flex w-full items-center justify-center">
<ButtonStyled color="brand" size="large">
<nuxt-link to="/news">
<NewspaperIcon />
View all news
</nuxt-link>
</ButtonStyled>
</div>
</div>
<div v-if="latestArticles" class="grid grid-cols-[repeat(auto-fit,minmax(250px,1fr))] gap-4">
<div
v-for="(article, index) in latestArticles"
:key="article.slug"
:class="{ 'max-xl:hidden': index === 2 }"
>
<NewsArticleCard :article="article" />
</div>
</div>
<div class="mx-2 my-8 flex w-full items-center justify-center">
<ButtonStyled color="brand" size="large">
<nuxt-link to="/news">
<NewspaperIcon />
View all news
</nuxt-link>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { NewspaperIcon } from "@modrinth/assets";
import { articles as rawArticles } from "@modrinth/blog";
import { ButtonStyled, NewsArticleCard } from "@modrinth/ui";
import { ref, computed } from "vue";
import { NewspaperIcon } from '@modrinth/assets'
import { articles as rawArticles } from '@modrinth/blog'
import { ButtonStyled, NewsArticleCard } from '@modrinth/ui'
import { computed, ref } from 'vue'
const articles = ref(
rawArticles
.map((article) => ({
...article,
path: `/news/article/${article.slug}/`,
thumbnail: article.thumbnail
? `/news/article/${article.slug}/thumbnail.webp`
: `/news/default.webp`,
title: article.title,
summary: article.summary,
date: article.date,
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
);
rawArticles
.map((article) => ({
...article,
path: `/news/article/${article.slug}/`,
thumbnail: article.thumbnail
? `/news/article/${article.slug}/thumbnail.webp`
: `/news/default.webp`,
title: article.title,
summary: article.summary,
date: article.date,
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
)
const latestArticles = computed(() => articles.value.slice(0, 3));
const latestArticles = computed(() => articles.value.slice(0, 3))
</script>

View File

@@ -1,211 +1,212 @@
<template>
<div class="report">
<div v-if="report.item_type === 'project'" class="item-info">
<nuxt-link
:to="`/${$getProjectTypeForUrl(report.project.project_type, report.project.loaders)}/${
report.project.slug
}`"
class="iconified-stacked-link"
>
<Avatar :src="report.project.icon_url" size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.project.title }}</span>
<span>{{
formatProjectType(
getProjectTypeForUrl(report.project.project_type, report.project.loaders),
)
}}</span>
</div>
</nuxt-link>
</div>
<div v-else-if="report.item_type === 'user'" class="item-info">
<nuxt-link
v-if="report.user"
:to="`/user/${report.user.username}`"
class="iconified-stacked-link"
>
<Avatar :src="report.user.avatar_url" circle size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.user.username }}</span>
<span>User</span>
</div>
</nuxt-link>
<div v-else class="item-info">
<div class="backed-svg" :class="{ raised: raised }">
<UnknownIcon />
</div>
<span>Reported user not found: <CopyCode :text="report.item_id" /> </span>
</div>
</div>
<div v-else-if="report.item_type === 'version'" class="item-info">
<nuxt-link
:to="`/project/${report.project.slug}/version/${report.version.id}`"
class="iconified-link"
>
<div class="backed-svg" :class="{ raised: raised }">
<VersionIcon />
</div>
<span class="title">{{ report.version.name }}</span>
</nuxt-link>
of
<nuxt-link :to="`/project/${report.project.slug}`" class="iconified-stacked-link">
<Avatar :src="report.project.icon_url" size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.project.title }}</span>
<span>{{
formatProjectType(
getProjectTypeForUrl(report.project.project_type, report.project.loaders),
)
}}</span>
</div>
</nuxt-link>
</div>
<div v-else class="item-info">
<div class="backed-svg" :class="{ raised: raised }">
<UnknownIcon />
</div>
<span>Unknown report type: {{ report.item_type }}</span>
</div>
<div class="report-type">
<Badge v-if="report.closed" type="closed" />
<Badge :type="`Reported for ${report.report_type}`" color="orange" />
</div>
<div v-if="showMessage" class="markdown-body" v-html="renderHighlightedString(report.body)" />
<ThreadSummary
v-if="thread"
:thread="thread"
class="thread-summary"
:raised="raised"
:link="`/${moderation ? 'moderation' : 'dashboard'}/report/${report.id}`"
:auth="auth"
/>
<div class="reporter-info">
<ReportIcon class="inline-svg" />
Reported by
<span v-if="auth.user.id === report.reporterUser.id">you</span>
<nuxt-link v-else :to="`/user/${report.reporterUser.username}`" class="iconified-link">
<Avatar
:src="report.reporterUser.avatar_url"
circle
size="xxs"
no-shadow
:raised="raised"
/>
<span>{{ report.reporterUser.username }}</span>
</nuxt-link>
<span>&nbsp;</span>
<span v-tooltip="$dayjs(report.created).format('MMMM D, YYYY [at] h:mm A')">{{
formatRelativeTime(report.created)
}}</span>
<CopyCode v-if="flags.developerMode" :text="report.id" class="report-id" />
</div>
</div>
<div class="report">
<div v-if="report.item_type === 'project'" class="item-info">
<nuxt-link
:to="`/${$getProjectTypeForUrl(report.project.project_type, report.project.loaders)}/${
report.project.slug
}`"
class="iconified-stacked-link"
>
<Avatar :src="report.project.icon_url" size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.project.title }}</span>
<span>{{
formatProjectType(
getProjectTypeForUrl(report.project.project_type, report.project.loaders),
)
}}</span>
</div>
</nuxt-link>
</div>
<div v-else-if="report.item_type === 'user'" class="item-info">
<nuxt-link
v-if="report.user"
:to="`/user/${report.user.username}`"
class="iconified-stacked-link"
>
<Avatar :src="report.user.avatar_url" circle size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.user.username }}</span>
<span>User</span>
</div>
</nuxt-link>
<div v-else class="item-info">
<div class="backed-svg" :class="{ raised: raised }">
<UnknownIcon />
</div>
<span>Reported user not found: <CopyCode :text="report.item_id" /> </span>
</div>
</div>
<div v-else-if="report.item_type === 'version'" class="item-info">
<nuxt-link
:to="`/project/${report.project.slug}/version/${report.version.id}`"
class="iconified-link"
>
<div class="backed-svg" :class="{ raised: raised }">
<VersionIcon />
</div>
<span class="title">{{ report.version.name }}</span>
</nuxt-link>
of
<nuxt-link :to="`/project/${report.project.slug}`" class="iconified-stacked-link">
<Avatar :src="report.project.icon_url" size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.project.title }}</span>
<span>{{
formatProjectType(
getProjectTypeForUrl(report.project.project_type, report.project.loaders),
)
}}</span>
</div>
</nuxt-link>
</div>
<div v-else class="item-info">
<div class="backed-svg" :class="{ raised: raised }">
<UnknownIcon />
</div>
<span>Unknown report type: {{ report.item_type }}</span>
</div>
<div class="report-type">
<Badge v-if="report.closed" type="closed" />
<Badge :type="`Reported for ${report.report_type}`" color="orange" />
</div>
<div v-if="showMessage" class="markdown-body" v-html="renderHighlightedString(report.body)" />
<ThreadSummary
v-if="thread"
:thread="thread"
class="thread-summary"
:raised="raised"
:link="`/${moderation ? 'moderation' : 'dashboard'}/report/${report.id}`"
:auth="auth"
/>
<div class="reporter-info">
<ReportIcon class="inline-svg" />
Reported by
<span v-if="auth.user.id === report.reporterUser.id">you</span>
<nuxt-link v-else :to="`/user/${report.reporterUser.username}`" class="iconified-link">
<Avatar
:src="report.reporterUser.avatar_url"
circle
size="xxs"
no-shadow
:raised="raised"
/>
<span>{{ report.reporterUser.username }}</span>
</nuxt-link>
<span>&nbsp;</span>
<span v-tooltip="$dayjs(report.created).format('MMMM D, YYYY [at] h:mm A')">{{
formatRelativeTime(report.created)
}}</span>
<CopyCode v-if="flags.developerMode" :text="report.id" class="report-id" />
</div>
</div>
</template>
<script setup>
import { ReportIcon, UnknownIcon, VersionIcon } from "@modrinth/assets";
import { Avatar, Badge, CopyCode, useRelativeTime } from "@modrinth/ui";
import { formatProjectType } from "@modrinth/utils";
import { renderHighlightedString } from "~/helpers/highlight.js";
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
import { getProjectTypeForUrl } from "~/helpers/projects.js";
import { ReportIcon, UnknownIcon, VersionIcon } from '@modrinth/assets'
import { Avatar, Badge, CopyCode, useRelativeTime } from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
const formatRelativeTime = useRelativeTime();
import ThreadSummary from '~/components/ui/thread/ThreadSummary.vue'
import { renderHighlightedString } from '~/helpers/highlight.js'
import { getProjectTypeForUrl } from '~/helpers/projects.js'
const formatRelativeTime = useRelativeTime()
defineProps({
report: {
type: Object,
required: true,
},
raised: {
type: Boolean,
default: false,
},
thread: {
type: Object,
default: null,
},
showMessage: {
type: Boolean,
default: true,
},
moderation: {
type: Boolean,
default: false,
},
auth: {
type: Object,
required: true,
},
});
report: {
type: Object,
required: true,
},
raised: {
type: Boolean,
default: false,
},
thread: {
type: Object,
default: null,
},
showMessage: {
type: Boolean,
default: true,
},
moderation: {
type: Boolean,
default: false,
},
auth: {
type: Object,
required: true,
},
})
const flags = useFeatureFlags();
const flags = useFeatureFlags()
</script>
<style lang="scss" scoped>
.report {
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
flex-wrap: wrap;
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
flex-wrap: wrap;
.report-type {
grid-area: type;
display: flex;
flex-direction: row;
gap: var(--spacing-card-sm);
margin-top: var(--spacing-card-xs);
}
.report-type {
grid-area: type;
display: flex;
flex-direction: row;
gap: var(--spacing-card-sm);
margin-top: var(--spacing-card-xs);
}
.item-info {
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
color: var(--color-heading);
grid-area: title;
.item-info {
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
color: var(--color-heading);
grid-area: title;
img,
.backed-svg {
margin-right: var(--spacing-card-xs);
}
}
img,
.backed-svg {
margin-right: var(--spacing-card-xs);
}
}
.markdown-body {
grid-area: body;
max-width: 100%;
}
.markdown-body {
grid-area: body;
max-width: 100%;
}
.reporter-info {
grid-area: reporter;
gap: var(--spacing-card-xs);
color: var(--color-text-secondary);
.reporter-info {
grid-area: reporter;
gap: var(--spacing-card-xs);
color: var(--color-text-secondary);
img {
vertical-align: middle;
position: relative;
top: -1px;
margin-right: var(--spacing-card-xs);
}
img {
vertical-align: middle;
position: relative;
top: -1px;
margin-right: var(--spacing-card-xs);
}
a {
gap: var(--spacing-card-xs);
}
}
a {
gap: var(--spacing-card-xs);
}
}
.action {
grid-area: action;
}
.action {
grid-area: action;
}
.thread-summary {
grid-area: summary;
}
.thread-summary {
grid-area: summary;
}
&:not(:last-child) {
margin-bottom: var(--spacing-card-md);
}
&:not(:last-child) {
margin-bottom: var(--spacing-card-md);
}
.report-id {
margin-left: var(--spacing-card-sm);
}
.report-id {
margin-left: var(--spacing-card-sm);
}
}
</style>

View File

@@ -1,119 +1,119 @@
<template>
<div>
<section class="universal-card">
<Breadcrumbs
v-if="breadcrumbsStack"
:current-title="`Report ${reportId}`"
:link-stack="breadcrumbsStack"
/>
<h2>Report details</h2>
<ReportInfo :report="report" :show-thread="false" :show-message="false" :auth="auth" />
</section>
<section class="universal-card">
<h2>Messages</h2>
<ConversationThread
:thread="thread"
:report="report"
:auth="auth"
@update-thread="updateThread"
/>
</section>
</div>
<div>
<section class="universal-card">
<Breadcrumbs
v-if="breadcrumbsStack"
:current-title="`Report ${reportId}`"
:link-stack="breadcrumbsStack"
/>
<h2>Report details</h2>
<ReportInfo :report="report" :show-thread="false" :show-message="false" :auth="auth" />
</section>
<section class="universal-card">
<h2>Messages</h2>
<ConversationThread
:thread="thread"
:report="report"
:auth="auth"
@update-thread="updateThread"
/>
</section>
</div>
</template>
<script setup>
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import ConversationThread from "~/components/ui/thread/ConversationThread.vue";
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
import { addReportMessage } from "~/helpers/threads.js";
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
import ReportInfo from '~/components/ui/report/ReportInfo.vue'
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
import { addReportMessage } from '~/helpers/threads.js'
const props = defineProps({
reportId: {
type: String,
required: true,
},
breadcrumbsStack: {
type: Array,
default: null,
},
auth: {
type: Object,
required: true,
},
});
reportId: {
type: String,
required: true,
},
breadcrumbsStack: {
type: Array,
default: null,
},
auth: {
type: Object,
required: true,
},
})
const report = ref(null);
const report = ref(null)
await fetchReport().then((result) => {
report.value = result;
});
report.value = result
})
const { data: rawThread } = await useAsyncData(`thread/${report.value.thread_id}`, () =>
useBaseFetch(`thread/${report.value.thread_id}`),
);
const thread = computed(() => addReportMessage(rawThread.value, report.value));
useBaseFetch(`thread/${report.value.thread_id}`),
)
const thread = computed(() => addReportMessage(rawThread.value, report.value))
async function updateThread(newThread) {
rawThread.value = newThread;
report.value = await fetchReport();
rawThread.value = newThread
report.value = await fetchReport()
}
async function fetchReport() {
const { data: rawReport } = await useAsyncData(`report/${props.reportId}`, () =>
useBaseFetch(`report/${props.reportId}`),
);
rawReport.value.item_id = rawReport.value.item_id.replace(/"/g, "");
const { data: rawReport } = await useAsyncData(`report/${props.reportId}`, () =>
useBaseFetch(`report/${props.reportId}`),
)
rawReport.value.item_id = rawReport.value.item_id.replace(/"/g, '')
const userIds = [];
userIds.push(rawReport.value.reporter);
if (rawReport.value.item_type === "user") {
userIds.push(rawReport.value.item_id);
}
const userIds = []
userIds.push(rawReport.value.reporter)
if (rawReport.value.item_type === 'user') {
userIds.push(rawReport.value.item_id)
}
const versionId = rawReport.value.item_type === "version" ? rawReport.value.item_id : null;
const versionId = rawReport.value.item_type === 'version' ? rawReport.value.item_id : null
let users = [];
if (userIds.length > 0) {
const { data: usersVal } = await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
useBaseFetch(`users?ids=${encodeURIComponent(JSON.stringify(userIds))}`),
);
users = usersVal.value;
}
let users = []
if (userIds.length > 0) {
const { data: usersVal } = await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
useBaseFetch(`users?ids=${encodeURIComponent(JSON.stringify(userIds))}`),
)
users = usersVal.value
}
let version = null;
if (versionId) {
const { data: versionVal } = await useAsyncData(`version/${versionId}`, () =>
useBaseFetch(`version/${versionId}`),
);
version = versionVal.value;
}
let version = null
if (versionId) {
const { data: versionVal } = await useAsyncData(`version/${versionId}`, () =>
useBaseFetch(`version/${versionId}`),
)
version = versionVal.value
}
const projectId = version
? version.project_id
: rawReport.value.item_type === "project"
? rawReport.value.item_id
: null;
const projectId = version
? version.project_id
: rawReport.value.item_type === 'project'
? rawReport.value.item_id
: null
let project = null;
if (projectId) {
const { data: projectVal } = await useAsyncData(`project/${projectId}`, () =>
useBaseFetch(`project/${projectId}`),
);
project = projectVal.value;
}
let project = null
if (projectId) {
const { data: projectVal } = await useAsyncData(`project/${projectId}`, () =>
useBaseFetch(`project/${projectId}`),
)
project = projectVal.value
}
const reportData = rawReport.value;
reportData.project = project;
reportData.version = version;
reportData.reporterUser = users.find((user) => user.id === rawReport.value.reporter);
if (rawReport.value.item_type === "user") {
reportData.user = users.find((user) => user.id === rawReport.value.item_id);
}
return reportData;
const reportData = rawReport.value
reportData.project = project
reportData.version = version
reportData.reporterUser = users.find((user) => user.id === rawReport.value.reporter)
if (rawReport.value.item_type === 'user') {
reportData.user = users.find((user) => user.id === rawReport.value.item_id)
}
return reportData
}
</script>
<style lang="scss" scoped>
.stacked {
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -1,120 +1,121 @@
<template>
<template v-if="moderation">
<Chips v-model="reasonFilter" :items="reasons" />
<p v-if="reports.length === MAX_REPORTS" class="text-red">
There are at least {{ MAX_REPORTS }} open reports. This page is at its max reports and will
not show any more recent ones.
</p>
<p v-else-if="reasonFilter === 'All'">There are {{ filteredReports.length }} open reports.</p>
<p v-else>
There are {{ filteredReports.length }}/{{ reports.length }} open '{{ reasonFilter }}' reports.
</p>
</template>
<ReportInfo
v-for="report in filteredReports"
:key="report.id"
:report="report"
:thread="report.thread"
:show-message="false"
:moderation="moderation"
raised
:auth="auth"
class="universal-card recessed"
/>
<p v-if="reports.length === 0">You don't have any active reports.</p>
<template v-if="moderation">
<Chips v-model="reasonFilter" :items="reasons" />
<p v-if="reports.length === MAX_REPORTS" class="text-red">
There are at least {{ MAX_REPORTS }} open reports. This page is at its max reports and will
not show any more recent ones.
</p>
<p v-else-if="reasonFilter === 'All'">There are {{ filteredReports.length }} open reports.</p>
<p v-else>
There are {{ filteredReports.length }}/{{ reports.length }} open '{{ reasonFilter }}' reports.
</p>
</template>
<ReportInfo
v-for="report in filteredReports"
:key="report.id"
:report="report"
:thread="report.thread"
:show-message="false"
:moderation="moderation"
raised
:auth="auth"
class="universal-card recessed"
/>
<p v-if="reports.length === 0">You don't have any active reports.</p>
</template>
<script setup>
import { Chips } from "@modrinth/ui";
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
import { addReportMessage } from "~/helpers/threads.js";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
import { Chips } from '@modrinth/ui'
import ReportInfo from '~/components/ui/report/ReportInfo.vue'
import { addReportMessage } from '~/helpers/threads.js'
import { asEncodedJsonArray, fetchSegmented } from '~/utils/fetch-helpers.ts'
const props = defineProps({
moderation: {
type: Boolean,
default: false,
},
auth: {
type: Object,
required: true,
},
});
moderation: {
type: Boolean,
default: false,
},
auth: {
type: Object,
required: true,
},
})
const viewMode = ref("open");
const reasonFilter = ref("All");
const reports = ref([]);
const viewMode = ref('open')
const reasonFilter = ref('All')
const reports = ref([])
const MAX_REPORTS = 1500;
const MAX_REPORTS = 1500
let { data: rawReports } = await useAsyncData("report", () =>
useBaseFetch(`report?count=${MAX_REPORTS}`),
);
let { data: rawReports } = await useAsyncData('report', () =>
useBaseFetch(`report?count=${MAX_REPORTS}`),
)
rawReports = rawReports.value.map((report) => {
report.item_id = report.item_id.replace(/"/g, "");
return report;
});
report.item_id = report.item_id.replace(/"/g, '')
return report
})
const reporterUsers = rawReports.map((report) => report.reporter);
const reporterUsers = rawReports.map((report) => report.reporter)
const reportedUsers = rawReports
.filter((report) => report.item_type === "user")
.map((report) => report.item_id);
const versionReports = rawReports.filter((report) => report.item_type === "version");
const versionIds = [...new Set(versionReports.map((report) => report.item_id))];
const userIds = [...new Set(reporterUsers.concat(reportedUsers))];
.filter((report) => report.item_type === 'user')
.map((report) => report.item_id)
const versionReports = rawReports.filter((report) => report.item_type === 'version')
const versionIds = [...new Set(versionReports.map((report) => report.item_id))]
const userIds = [...new Set(reporterUsers.concat(reportedUsers))]
const threadIds = [
...new Set(rawReports.filter((report) => report.thread_id).map((report) => report.thread_id)),
];
const reasons = ["All", ...new Set(rawReports.map((report) => report.report_type))];
...new Set(rawReports.filter((report) => report.thread_id).map((report) => report.thread_id)),
]
const reasons = ['All', ...new Set(rawReports.map((report) => report.report_type))]
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
fetchSegmented(userIds, (ids) => `users?ids=${asEncodedJsonArray(ids)}`),
),
await useAsyncData(`versions?ids=${JSON.stringify(versionIds)}`, () =>
fetchSegmented(versionIds, (ids) => `versions?ids=${asEncodedJsonArray(ids)}`),
),
await useAsyncData(`threads?ids=${JSON.stringify(threadIds)}`, () =>
fetchSegmented(threadIds, (ids) => `threads?ids=${asEncodedJsonArray(ids)}`),
),
]);
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
fetchSegmented(userIds, (ids) => `users?ids=${asEncodedJsonArray(ids)}`),
),
await useAsyncData(`versions?ids=${JSON.stringify(versionIds)}`, () =>
fetchSegmented(versionIds, (ids) => `versions?ids=${asEncodedJsonArray(ids)}`),
),
await useAsyncData(`threads?ids=${JSON.stringify(threadIds)}`, () =>
fetchSegmented(threadIds, (ids) => `threads?ids=${asEncodedJsonArray(ids)}`),
),
])
const reportedProjects = rawReports
.filter((report) => report.item_type === "project")
.map((report) => report.item_id);
const versionProjects = versions.value.map((version) => version.project_id);
const projectIds = [...new Set(reportedProjects.concat(versionProjects))];
.filter((report) => report.item_type === 'project')
.map((report) => report.item_id)
const versionProjects = versions.value.map((version) => version.project_id)
const projectIds = [...new Set(reportedProjects.concat(versionProjects))]
const { data: projects } = await useAsyncData(`projects?ids=${JSON.stringify(projectIds)}`, () =>
fetchSegmented(projectIds, (ids) => `projects?ids=${asEncodedJsonArray(ids)}`),
);
fetchSegmented(projectIds, (ids) => `projects?ids=${asEncodedJsonArray(ids)}`),
)
reports.value = rawReports.map((report) => {
report.reporterUser = users.value.find((user) => user.id === report.reporter);
if (report.item_type === "user") {
report.user = users.value.find((user) => user.id === report.item_id);
} else if (report.item_type === "project") {
report.project = projects.value.find((project) => project.id === report.item_id);
} else if (report.item_type === "version") {
report.version = versions.value.find((version) => version.id === report.item_id);
report.project = projects.value.find((project) => project.id === report.version.project_id);
}
if (report.thread_id) {
report.thread = addReportMessage(
threads.value.find((thread) => report.thread_id === thread.id),
report,
);
}
report.open = true;
return report;
});
report.reporterUser = users.value.find((user) => user.id === report.reporter)
if (report.item_type === 'user') {
report.user = users.value.find((user) => user.id === report.item_id)
} else if (report.item_type === 'project') {
report.project = projects.value.find((project) => project.id === report.item_id)
} else if (report.item_type === 'version') {
report.version = versions.value.find((version) => version.id === report.item_id)
report.project = projects.value.find((project) => project.id === report.version.project_id)
}
if (report.thread_id) {
report.thread = addReportMessage(
threads.value.find((thread) => report.thread_id === thread.id),
report,
)
}
report.open = true
return report
})
const filteredReports = computed(() =>
reports.value?.filter(
(x) =>
(props.moderation || x.reporterUser.id === props.auth.user.id) &&
(viewMode.value === "open" ? x.open : !x.open) &&
(reasonFilter.value === "All" || reasonFilter.value === x.report_type),
),
);
reports.value?.filter(
(x) =>
(props.moderation || x.reporterUser.id === props.auth.user.id) &&
(viewMode.value === 'open' ? x.open : !x.open) &&
(reasonFilter.value === 'All' || reasonFilter.value === x.report_type),
),
)
</script>

View File

@@ -1,72 +1,72 @@
<template>
<div class="categories">
<slot />
<span
v-for="category in categoriesFiltered"
:key="category.name"
v-html="category.icon + formatCategory(category.name)"
/>
</div>
<div class="categories">
<slot />
<span
v-for="category in categoriesFiltered"
:key="category.name"
v-html="category.icon + formatCategory(category.name)"
/>
</div>
</template>
<script>
import { formatCategory } from "@modrinth/utils";
import { formatCategory } from '@modrinth/utils'
export default {
props: {
categories: {
type: Array,
default() {
return [];
},
},
type: {
type: String,
required: true,
},
},
setup() {
const tags = useTags();
props: {
categories: {
type: Array,
default() {
return []
},
},
type: {
type: String,
required: true,
},
},
setup() {
const tags = useTags()
return { tags };
},
computed: {
categoriesFiltered() {
return this.tags.categories
.concat(this.tags.loaders)
.filter(
(x) =>
this.categories.includes(x.name) && (!x.project_type || x.project_type === this.type),
);
},
},
methods: { formatCategory },
};
return { tags }
},
computed: {
categoriesFiltered() {
return this.tags.categories
.concat(this.tags.loaders)
.filter(
(x) =>
this.categories.includes(x.name) && (!x.project_type || x.project_type === this.type),
)
},
},
methods: { formatCategory },
}
</script>
<style lang="scss" scoped>
.categories {
display: flex;
flex-direction: row;
flex-wrap: wrap;
display: flex;
flex-direction: row;
flex-wrap: wrap;
:deep(span) {
display: flex;
align-items: center;
flex-direction: row;
:deep(span) {
display: flex;
align-items: center;
flex-direction: row;
&:not(:last-child) {
margin-right: var(--spacing-card-md);
}
&:not(:last-child) {
margin-right: var(--spacing-card-md);
}
&:not(.badge) {
color: var(--color-icon);
}
&:not(.badge) {
color: var(--color-icon);
}
svg {
width: 1rem;
margin-right: 0.2rem;
}
}
svg {
width: 1rem;
margin-right: 0.2rem;
}
}
}
</style>

View File

@@ -1,77 +1,77 @@
<template>
<Checkbox
class="filter"
:model-value="activeFilters.includes(facetName)"
:description="displayName"
@update:model-value="toggle()"
>
<div class="filter-text">
<div v-if="icon" aria-hidden="true" class="icon" v-html="icon" />
<div v-else class="icon">
<slot />
</div>
<span aria-hidden="true"> {{ displayName }}</span>
</div>
</Checkbox>
<Checkbox
class="filter"
:model-value="activeFilters.includes(facetName)"
:description="displayName"
@update:model-value="toggle()"
>
<div class="filter-text">
<div v-if="icon" aria-hidden="true" class="icon" v-html="icon" />
<div v-else class="icon">
<slot />
</div>
<span aria-hidden="true"> {{ displayName }}</span>
</div>
</Checkbox>
</template>
<script>
import Checkbox from "~/components/ui/Checkbox.vue";
import Checkbox from '~/components/ui/Checkbox.vue'
export default {
components: {
Checkbox,
},
props: {
facetName: {
type: String,
default: "",
},
displayName: {
type: String,
default: "",
},
icon: {
type: String,
default: "",
},
activeFilters: {
type: Array,
default() {
return [];
},
},
},
emits: ["toggle"],
methods: {
toggle() {
this.$emit("toggle", this.facetName);
},
},
};
components: {
Checkbox,
},
props: {
facetName: {
type: String,
default: '',
},
displayName: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
activeFilters: {
type: Array,
default() {
return []
},
},
},
emits: ['toggle'],
methods: {
toggle() {
this.$emit('toggle', this.facetName)
},
},
}
</script>
<style lang="scss" scoped>
.filter {
margin-bottom: 0.5rem;
margin-bottom: 0.5rem;
:deep(.filter-text) {
display: flex;
align-items: center;
:deep(.filter-text) {
display: flex;
align-items: center;
.icon {
height: 1rem;
.icon {
height: 1rem;
svg {
margin-right: 0.25rem;
width: 1rem;
height: 1rem;
}
}
}
svg {
margin-right: 0.25rem;
width: 1rem;
height: 1rem;
}
}
}
span {
user-select: none;
}
span {
user-select: none;
}
}
</style>

View File

@@ -1,125 +1,126 @@
<template>
<NewModal ref="modal" header="Creating backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px]">
<label for="backup-name-input">
<span class="text-lg font-semibold text-contrast"> Name </span>
</label>
<input
id="backup-name-input"
ref="input"
v-model="backupName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
:placeholder="`Backup #${newBackupAmount}`"
maxlength="48"
/>
<div v-if="nameExists && !isCreating" class="flex items-center gap-1">
<IssuesIcon class="hidden text-orange sm:block" />
<span class="text-sm text-orange">
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
>'
</span>
</div>
<div v-if="isRateLimited" class="mt-2 text-sm text-red">
You're creating backups too fast. Please wait a moment before trying again.
</div>
</div>
<div class="mt-2 flex justify-start gap-2">
<ButtonStyled color="brand">
<button :disabled="isCreating || nameExists" @click="createBackup">
<PlusIcon />
Create backup
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hideModal">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</NewModal>
<NewModal ref="modal" header="Creating backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px]">
<label for="backup-name-input">
<span class="text-lg font-semibold text-contrast"> Name </span>
</label>
<input
id="backup-name-input"
ref="input"
v-model="backupName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
:placeholder="`Backup #${newBackupAmount}`"
maxlength="48"
/>
<div v-if="nameExists && !isCreating" class="flex items-center gap-1">
<IssuesIcon class="hidden text-orange sm:block" />
<span class="text-sm text-orange">
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
>'
</span>
</div>
<div v-if="isRateLimited" class="mt-2 text-sm text-red">
You're creating backups too fast. Please wait a moment before trying again.
</div>
</div>
<div class="mt-2 flex justify-start gap-2">
<ButtonStyled color="brand">
<button :disabled="isCreating || nameExists" @click="createBackup">
<PlusIcon />
Create backup
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hideModal">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { IssuesIcon, PlusIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, injectNotificationManager, NewModal } from "@modrinth/ui";
import { ModrinthServersFetchError, type ServerBackup } from "@modrinth/utils";
import { computed, nextTick, ref } from "vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { IssuesIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
import { ModrinthServersFetchError, type ServerBackup } from '@modrinth/utils'
import { computed, nextTick, ref } from 'vue'
const { addNotification } = injectNotificationManager();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const modal = ref<InstanceType<typeof NewModal>>();
const input = ref<HTMLInputElement>();
const isCreating = ref(false);
const isRateLimited = ref(false);
const backupName = ref("");
const modal = ref<InstanceType<typeof NewModal>>()
const input = ref<HTMLInputElement>()
const isCreating = ref(false)
const isRateLimited = ref(false)
const backupName = ref('')
const newBackupAmount = computed(() =>
props.server.backups?.data?.length === undefined ? 1 : props.server.backups?.data?.length + 1,
);
props.server.backups?.data?.length === undefined ? 1 : props.server.backups?.data?.length + 1,
)
const trimmedName = computed(() => backupName.value.trim());
const trimmedName = computed(() => backupName.value.trim())
const nameExists = computed(() => {
if (!props.server.backups?.data) return false;
return props.server.backups.data.some(
(backup: ServerBackup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
);
});
if (!props.server.backups?.data) return false
return props.server.backups.data.some(
(backup: ServerBackup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
)
})
const focusInput = () => {
nextTick(() => {
setTimeout(() => {
input.value?.focus();
}, 100);
});
};
nextTick(() => {
setTimeout(() => {
input.value?.focus()
}, 100)
})
}
function show() {
backupName.value = "";
isCreating.value = false;
modal.value?.show();
backupName.value = ''
isCreating.value = false
modal.value?.show()
}
const hideModal = () => {
modal.value?.hide();
};
modal.value?.hide()
}
const createBackup = async () => {
if (backupName.value.trim().length === 0) {
backupName.value = `Backup #${newBackupAmount.value}`;
}
if (backupName.value.trim().length === 0) {
backupName.value = `Backup #${newBackupAmount.value}`
}
isCreating.value = true;
isRateLimited.value = false;
try {
await props.server.backups?.create(trimmedName.value);
hideModal();
await props.server.refresh();
} catch (error) {
if (error instanceof ModrinthServersFetchError && error?.statusCode === 429) {
isRateLimited.value = true;
addNotification({
type: "error",
title: "Error creating backup",
text: "You're creating backups too fast.",
});
} else {
const message = error instanceof Error ? error.message : String(error);
addNotification({ type: "error", title: "Error creating backup", text: message });
}
} finally {
isCreating.value = false;
}
};
isCreating.value = true
isRateLimited.value = false
try {
await props.server.backups?.create(trimmedName.value)
hideModal()
await props.server.refresh()
} catch (error) {
if (error instanceof ModrinthServersFetchError && error?.statusCode === 429) {
isRateLimited.value = true
addNotification({
type: 'error',
title: 'Error creating backup',
text: "You're creating backups too fast.",
})
} else {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Error creating backup', text: message })
}
} finally {
isCreating.value = false
}
}
defineExpose({
show,
hide: hideModal,
});
show,
hide: hideModal,
})
</script>

View File

@@ -1,41 +1,42 @@
<template>
<ConfirmModal
ref="modal"
danger
title="Are you sure you want to delete this backup?"
proceed-label="Delete backup"
:confirmation-text="currentBackup?.name ?? 'null'"
has-to-type
@proceed="emit('delete', currentBackup)"
>
<BackupItem
v-if="currentBackup"
:backup="currentBackup"
preview
class="border-px border-solid border-button-border"
/>
</ConfirmModal>
<ConfirmModal
ref="modal"
danger
title="Are you sure you want to delete this backup?"
proceed-label="Delete backup"
:confirmation-text="currentBackup?.name ?? 'null'"
has-to-type
@proceed="emit('delete', currentBackup)"
>
<BackupItem
v-if="currentBackup"
:backup="currentBackup"
preview
class="border-px border-solid border-button-border"
/>
</ConfirmModal>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ConfirmModal } from "@modrinth/ui";
import type { Backup } from "@modrinth/utils";
import BackupItem from "~/components/ui/servers/BackupItem.vue";
import { ConfirmModal } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { ref } from 'vue'
import BackupItem from '~/components/ui/servers/BackupItem.vue'
const emit = defineEmits<{
(e: "delete", backup: Backup | undefined): void;
}>();
(e: 'delete', backup: Backup | undefined): void
}>()
const modal = ref<InstanceType<typeof ConfirmModal>>();
const currentBackup = ref<Backup | undefined>(undefined);
const modal = ref<InstanceType<typeof ConfirmModal>>()
const currentBackup = ref<Backup | undefined>(undefined)
function show(backup: Backup) {
currentBackup.value = backup;
modal.value?.show();
currentBackup.value = backup
modal.value?.show()
}
defineExpose({
show,
});
show,
})
</script>

View File

@@ -1,345 +1,345 @@
<script setup lang="ts">
import dayjs from "dayjs";
import {
MoreVerticalIcon,
HistoryIcon,
DownloadIcon,
SpinnerIcon,
EditIcon,
LockIcon,
TrashIcon,
FolderArchiveIcon,
BotIcon,
XIcon,
LockOpenIcon,
RotateCounterClockwiseIcon,
} from "@modrinth/assets";
import { ButtonStyled, commonMessages, OverflowMenu, ProgressBar } from "@modrinth/ui";
import { defineMessages, useVIntl } from "@vintl/vintl";
import { ref, computed } from "vue";
import type { Backup } from "@modrinth/utils";
BotIcon,
DownloadIcon,
EditIcon,
FolderArchiveIcon,
HistoryIcon,
LockIcon,
LockOpenIcon,
MoreVerticalIcon,
RotateCounterClockwiseIcon,
SpinnerIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, commonMessages, OverflowMenu, ProgressBar } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
const flags = useFeatureFlags();
const { formatMessage } = useVIntl();
const flags = useFeatureFlags()
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: "prepare" | "download" | "rename" | "restore" | "lock" | "retry"): void;
(e: "delete", skipConfirmation?: boolean): void;
}>();
(e: 'prepare' | 'download' | 'rename' | 'restore' | 'lock' | 'retry'): void
(e: 'delete', skipConfirmation?: boolean): void
}>()
const props = withDefaults(
defineProps<{
backup: Backup;
preview?: boolean;
kyrosUrl?: string;
jwt?: string;
}>(),
{
preview: false,
kyrosUrl: undefined,
jwt: undefined,
},
);
defineProps<{
backup: Backup
preview?: boolean
kyrosUrl?: string
jwt?: string
}>(),
{
preview: false,
kyrosUrl: undefined,
jwt: undefined,
},
)
const backupQueued = computed(
() =>
props.backup.task?.create?.progress === 0 ||
(props.backup.ongoing && !props.backup.task?.create),
);
const automated = computed(() => props.backup.automated);
const failedToCreate = computed(() => props.backup.interrupted);
() =>
props.backup.task?.create?.progress === 0 ||
(props.backup.ongoing && !props.backup.task?.create),
)
const automated = computed(() => props.backup.automated)
const failedToCreate = computed(() => props.backup.interrupted)
const preparedDownloadStates = ["ready", "done"];
const inactiveStates = ["failed", "cancelled"];
const preparedDownloadStates = ['ready', 'done']
const inactiveStates = ['failed', 'cancelled']
const hasPreparedDownload = computed(() => {
const fileState = props.backup.task?.file?.state ?? "";
return preparedDownloadStates.includes(fileState);
});
const fileState = props.backup.task?.file?.state ?? ''
return preparedDownloadStates.includes(fileState)
})
const creating = computed(() => {
const task = props.backup.task?.create;
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task;
}
if (props.backup.ongoing) {
return {
progress: 0,
state: "ongoing",
};
}
return undefined;
});
const task = props.backup.task?.create
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task
}
if (props.backup.ongoing) {
return {
progress: 0,
state: 'ongoing',
}
}
return undefined
})
const restoring = computed(() => {
const task = props.backup.task?.restore;
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task;
}
return undefined;
});
const task = props.backup.task?.restore
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task
}
return undefined
})
const initiatedPrepare = ref(false);
const initiatedPrepare = ref(false)
const preparingFile = computed(() => {
if (hasPreparedDownload.value) {
return false;
}
if (hasPreparedDownload.value) {
return false
}
const task = props.backup.task?.file;
return (
(!task && initiatedPrepare.value) ||
(task && task.progress < 1 && !inactiveStates.includes(task.state))
);
});
const task = props.backup.task?.file
return (
(!task && initiatedPrepare.value) ||
(task && task.progress < 1 && !inactiveStates.includes(task.state))
)
})
const failedToRestore = computed(() => props.backup.task?.restore?.state === "failed");
const failedToPrepareFile = computed(() => props.backup.task?.file?.state === "failed");
const failedToRestore = computed(() => props.backup.task?.restore?.state === 'failed')
const failedToPrepareFile = computed(() => props.backup.task?.file?.state === 'failed')
const messages = defineMessages({
locked: {
id: "servers.backups.item.locked",
defaultMessage: "Locked",
},
lock: {
id: "servers.backups.item.lock",
defaultMessage: "Lock",
},
unlock: {
id: "servers.backups.item.unlock",
defaultMessage: "Unlock",
},
restore: {
id: "servers.backups.item.restore",
defaultMessage: "Restore",
},
rename: {
id: "servers.backups.item.rename",
defaultMessage: "Rename",
},
queuedForBackup: {
id: "servers.backups.item.queued-for-backup",
defaultMessage: "Queued for backup",
},
preparingDownload: {
id: "servers.backups.item.preparing-download",
defaultMessage: "Preparing download...",
},
prepareDownload: {
id: "servers.backups.item.prepare-download",
defaultMessage: "Prepare download",
},
prepareDownloadAgain: {
id: "servers.backups.item.prepare-download-again",
defaultMessage: "Try preparing again",
},
alreadyPreparing: {
id: "servers.backups.item.already-preparing",
defaultMessage: "Already preparing backup for download",
},
creatingBackup: {
id: "servers.backups.item.creating-backup",
defaultMessage: "Creating backup...",
},
restoringBackup: {
id: "servers.backups.item.restoring-backup",
defaultMessage: "Restoring from backup...",
},
failedToCreateBackup: {
id: "servers.backups.item.failed-to-create-backup",
defaultMessage: "Failed to create backup",
},
failedToRestoreBackup: {
id: "servers.backups.item.failed-to-restore-backup",
defaultMessage: "Failed to restore from backup",
},
failedToPrepareFile: {
id: "servers.backups.item.failed-to-prepare-backup",
defaultMessage: "Failed to prepare download",
},
automated: {
id: "servers.backups.item.automated",
defaultMessage: "Automated",
},
retry: {
id: "servers.backups.item.retry",
defaultMessage: "Retry",
},
});
locked: {
id: 'servers.backups.item.locked',
defaultMessage: 'Locked',
},
lock: {
id: 'servers.backups.item.lock',
defaultMessage: 'Lock',
},
unlock: {
id: 'servers.backups.item.unlock',
defaultMessage: 'Unlock',
},
restore: {
id: 'servers.backups.item.restore',
defaultMessage: 'Restore',
},
rename: {
id: 'servers.backups.item.rename',
defaultMessage: 'Rename',
},
queuedForBackup: {
id: 'servers.backups.item.queued-for-backup',
defaultMessage: 'Queued for backup',
},
preparingDownload: {
id: 'servers.backups.item.preparing-download',
defaultMessage: 'Preparing download...',
},
prepareDownload: {
id: 'servers.backups.item.prepare-download',
defaultMessage: 'Prepare download',
},
prepareDownloadAgain: {
id: 'servers.backups.item.prepare-download-again',
defaultMessage: 'Try preparing again',
},
alreadyPreparing: {
id: 'servers.backups.item.already-preparing',
defaultMessage: 'Already preparing backup for download',
},
creatingBackup: {
id: 'servers.backups.item.creating-backup',
defaultMessage: 'Creating backup...',
},
restoringBackup: {
id: 'servers.backups.item.restoring-backup',
defaultMessage: 'Restoring from backup...',
},
failedToCreateBackup: {
id: 'servers.backups.item.failed-to-create-backup',
defaultMessage: 'Failed to create backup',
},
failedToRestoreBackup: {
id: 'servers.backups.item.failed-to-restore-backup',
defaultMessage: 'Failed to restore from backup',
},
failedToPrepareFile: {
id: 'servers.backups.item.failed-to-prepare-backup',
defaultMessage: 'Failed to prepare download',
},
automated: {
id: 'servers.backups.item.automated',
defaultMessage: 'Automated',
},
retry: {
id: 'servers.backups.item.retry',
defaultMessage: 'Retry',
},
})
</script>
<template>
<div
:class="
preview
? 'grid-cols-[min-content_1fr_1fr] sm:grid-cols-[min-content_3fr_2fr_1fr] md:grid-cols-[auto_3fr_2fr_1fr]'
: 'grid-cols-[min-content_1fr_1fr] sm:grid-cols-[min-content_3fr_2fr_1fr] md:grid-cols-[auto_3fr_2fr_1fr_2fr]'
"
class="grid items-center gap-4 rounded-2xl bg-bg-raised px-4 py-3"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg"
>
<SpinnerIcon
v-if="creating"
class="h-6 w-6 animate-spin"
:class="{ 'text-orange': backupQueued, 'text-green': !backupQueued }"
/>
<FolderArchiveIcon v-else class="h-6 w-6" />
</div>
<div class="col-span-2 flex flex-col gap-1 sm:col-span-1">
<span class="font-bold text-contrast">
{{ backup.name }}
</span>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span v-if="backup.locked" class="flex items-center gap-1 text-sm text-secondary">
<LockIcon /> {{ formatMessage(messages.locked) }}
</span>
<span v-if="automated && backup.locked"></span>
<span v-if="automated" class="flex items-center gap-1 text-secondary">
<BotIcon /> {{ formatMessage(messages.automated) }}
</span>
<span v-if="(failedToCreate || failedToRestore) && (automated || backup.locked)"></span>
<span
v-if="failedToCreate || failedToRestore || failedToPrepareFile"
class="flex items-center gap-1 text-sm text-red"
>
<XIcon />
{{
formatMessage(
failedToCreate
? messages.failedToCreateBackup
: failedToRestore
? messages.failedToRestoreBackup
: messages.failedToPrepareFile,
)
}}
</span>
</div>
</div>
<div v-if="creating" class="col-span-2 flex flex-col gap-3">
<span v-if="backupQueued" class="text-orange">
{{ formatMessage(messages.queuedForBackup) }}
</span>
<span v-else class="text-green"> {{ formatMessage(messages.creatingBackup) }} </span>
<ProgressBar
:progress="creating.progress"
:color="backupQueued ? 'orange' : 'green'"
:waiting="creating.progress === 0"
class="max-w-full"
/>
</div>
<div v-else-if="restoring" class="col-span-2 flex flex-col gap-3 text-purple">
{{ formatMessage(messages.restoringBackup) }}
<ProgressBar
:progress="restoring.progress"
color="purple"
:waiting="restoring.progress === 0"
class="max-w-full"
/>
</div>
<template v-else>
<div class="col-span-2">
{{ dayjs(backup.created_at).format("MMMM D, YYYY [at] h:mm A") }}
</div>
<div v-if="false">{{ 245 }} MiB</div>
</template>
<div
v-if="!preview"
class="col-span-full flex justify-normal gap-2 md:col-span-1 md:justify-end"
>
<template v-if="failedToCreate">
<ButtonStyled>
<button @click="() => emit('retry')">
<RotateCounterClockwiseIcon />
{{ formatMessage(messages.retry) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="() => emit('delete', true)">
<TrashIcon />
Remove
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else-if="creating">
<button @click="() => emit('delete')">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<template v-else>
<ButtonStyled>
<a
v-if="hasPreparedDownload"
:class="{
disabled: !kyrosUrl || !jwt,
}"
:href="`https://${kyrosUrl}/modrinth/v0/backups/${backup.id}/download?auth=${jwt}`"
@click="() => emit('download')"
>
<DownloadIcon />
{{ formatMessage(commonMessages.downloadButton) }}
</a>
<button
v-else
:disabled="!!preparingFile"
@click="
() => {
initiatedPrepare = true;
emit('prepare');
}
"
>
<SpinnerIcon v-if="preparingFile" class="animate-spin" />
<DownloadIcon v-else />
{{
formatMessage(
preparingFile
? messages.preparingDownload
: failedToPrepareFile
? messages.prepareDownloadAgain
: messages.prepareDownload,
)
}}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{ id: 'rename', action: () => emit('rename') },
{
id: 'restore',
action: () => emit('restore'),
disabled: !!restoring || !!preparingFile,
},
{ id: 'lock', action: () => emit('lock') },
{ divider: true },
{
id: 'delete',
color: 'red',
action: () => emit('delete'),
disabled: !!restoring || !!preparingFile,
},
]"
>
<MoreVerticalIcon />
<template #rename> <EditIcon /> {{ formatMessage(messages.rename) }} </template>
<template #restore> <HistoryIcon /> {{ formatMessage(messages.restore) }} </template>
<template v-if="backup.locked" #lock>
<LockOpenIcon /> {{ formatMessage(messages.unlock) }}
</template>
<template v-else #lock> <LockIcon /> {{ formatMessage(messages.lock) }} </template>
<template #delete>
<TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</div>
<pre
v-if="!preview && flags.advancedDebugInfo"
class="col-span-full m-0 rounded-xl bg-button-bg text-xs"
>{{ backup }}</pre
>
</div>
<div
:class="
preview
? 'grid-cols-[min-content_1fr_1fr] sm:grid-cols-[min-content_3fr_2fr_1fr] md:grid-cols-[auto_3fr_2fr_1fr]'
: 'grid-cols-[min-content_1fr_1fr] sm:grid-cols-[min-content_3fr_2fr_1fr] md:grid-cols-[auto_3fr_2fr_1fr_2fr]'
"
class="grid items-center gap-4 rounded-2xl bg-bg-raised px-4 py-3"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg"
>
<SpinnerIcon
v-if="creating"
class="h-6 w-6 animate-spin"
:class="{ 'text-orange': backupQueued, 'text-green': !backupQueued }"
/>
<FolderArchiveIcon v-else class="h-6 w-6" />
</div>
<div class="col-span-2 flex flex-col gap-1 sm:col-span-1">
<span class="font-bold text-contrast">
{{ backup.name }}
</span>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span v-if="backup.locked" class="flex items-center gap-1 text-sm text-secondary">
<LockIcon /> {{ formatMessage(messages.locked) }}
</span>
<span v-if="automated && backup.locked"></span>
<span v-if="automated" class="flex items-center gap-1 text-secondary">
<BotIcon /> {{ formatMessage(messages.automated) }}
</span>
<span v-if="(failedToCreate || failedToRestore) && (automated || backup.locked)"></span>
<span
v-if="failedToCreate || failedToRestore || failedToPrepareFile"
class="flex items-center gap-1 text-sm text-red"
>
<XIcon />
{{
formatMessage(
failedToCreate
? messages.failedToCreateBackup
: failedToRestore
? messages.failedToRestoreBackup
: messages.failedToPrepareFile,
)
}}
</span>
</div>
</div>
<div v-if="creating" class="col-span-2 flex flex-col gap-3">
<span v-if="backupQueued" class="text-orange">
{{ formatMessage(messages.queuedForBackup) }}
</span>
<span v-else class="text-green"> {{ formatMessage(messages.creatingBackup) }} </span>
<ProgressBar
:progress="creating.progress"
:color="backupQueued ? 'orange' : 'green'"
:waiting="creating.progress === 0"
class="max-w-full"
/>
</div>
<div v-else-if="restoring" class="col-span-2 flex flex-col gap-3 text-purple">
{{ formatMessage(messages.restoringBackup) }}
<ProgressBar
:progress="restoring.progress"
color="purple"
:waiting="restoring.progress === 0"
class="max-w-full"
/>
</div>
<template v-else>
<div class="col-span-2">
{{ dayjs(backup.created_at).format('MMMM D, YYYY [at] h:mm A') }}
</div>
<div v-if="false">{{ 245 }} MiB</div>
</template>
<div
v-if="!preview"
class="col-span-full flex justify-normal gap-2 md:col-span-1 md:justify-end"
>
<template v-if="failedToCreate">
<ButtonStyled>
<button @click="() => emit('retry')">
<RotateCounterClockwiseIcon />
{{ formatMessage(messages.retry) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="() => emit('delete', true)">
<TrashIcon />
Remove
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else-if="creating">
<button @click="() => emit('delete')">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<template v-else>
<ButtonStyled>
<a
v-if="hasPreparedDownload"
:class="{
disabled: !kyrosUrl || !jwt,
}"
:href="`https://${kyrosUrl}/modrinth/v0/backups/${backup.id}/download?auth=${jwt}`"
@click="() => emit('download')"
>
<DownloadIcon />
{{ formatMessage(commonMessages.downloadButton) }}
</a>
<button
v-else
:disabled="!!preparingFile"
@click="
() => {
initiatedPrepare = true
emit('prepare')
}
"
>
<SpinnerIcon v-if="preparingFile" class="animate-spin" />
<DownloadIcon v-else />
{{
formatMessage(
preparingFile
? messages.preparingDownload
: failedToPrepareFile
? messages.prepareDownloadAgain
: messages.prepareDownload,
)
}}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{ id: 'rename', action: () => emit('rename') },
{
id: 'restore',
action: () => emit('restore'),
disabled: !!restoring || !!preparingFile,
},
{ id: 'lock', action: () => emit('lock') },
{ divider: true },
{
id: 'delete',
color: 'red',
action: () => emit('delete'),
disabled: !!restoring || !!preparingFile,
},
]"
>
<MoreVerticalIcon />
<template #rename> <EditIcon /> {{ formatMessage(messages.rename) }} </template>
<template #restore> <HistoryIcon /> {{ formatMessage(messages.restore) }} </template>
<template v-if="backup.locked" #lock>
<LockOpenIcon /> {{ formatMessage(messages.unlock) }}
</template>
<template v-else #lock> <LockIcon /> {{ formatMessage(messages.lock) }} </template>
<template #delete>
<TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</div>
<pre
v-if="!preview && flags.advancedDebugInfo"
class="col-span-full m-0 rounded-xl bg-button-bg text-xs"
>{{ backup }}</pre
>
</div>
</template>

View File

@@ -1,143 +1,144 @@
<template>
<NewModal ref="modal" header="Renaming backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px]">
<label for="backup-name-input">
<span class="text-lg font-semibold text-contrast"> Name </span>
</label>
<input
id="backup-name-input"
ref="input"
v-model="backupName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
:placeholder="`Backup #${backupNumber}`"
maxlength="48"
/>
<div v-if="nameExists" class="flex items-center gap-1">
<IssuesIcon class="hidden text-orange sm:block" />
<span class="text-sm text-orange">
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
>'
</span>
</div>
</div>
<div class="mt-2 flex justify-start gap-2">
<ButtonStyled color="brand">
<button :disabled="isRenaming || nameExists" @click="renameBackup">
<template v-if="isRenaming">
<SpinnerIcon class="animate-spin" />
Renaming...
</template>
<template v-else>
<SaveIcon />
Save changes
</template>
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</NewModal>
<NewModal ref="modal" header="Renaming backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px]">
<label for="backup-name-input">
<span class="text-lg font-semibold text-contrast"> Name </span>
</label>
<input
id="backup-name-input"
ref="input"
v-model="backupName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
:placeholder="`Backup #${backupNumber}`"
maxlength="48"
/>
<div v-if="nameExists" class="flex items-center gap-1">
<IssuesIcon class="hidden text-orange sm:block" />
<span class="text-sm text-orange">
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
>'
</span>
</div>
</div>
<div class="mt-2 flex justify-start gap-2">
<ButtonStyled color="brand">
<button :disabled="isRenaming || nameExists" @click="renameBackup">
<template v-if="isRenaming">
<SpinnerIcon class="animate-spin" />
Renaming...
</template>
<template v-else>
<SaveIcon />
Save changes
</template>
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { IssuesIcon, SaveIcon, SpinnerIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, injectNotificationManager, NewModal } from "@modrinth/ui";
import type { Backup } from "@modrinth/utils";
import { computed, nextTick, ref } from "vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { IssuesIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { computed, nextTick, ref } from 'vue'
const { addNotification } = injectNotificationManager();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const modal = ref<InstanceType<typeof NewModal>>();
const input = ref<HTMLInputElement>();
const backupName = ref("");
const originalName = ref("");
const isRenaming = ref(false);
const modal = ref<InstanceType<typeof NewModal>>()
const input = ref<HTMLInputElement>()
const backupName = ref('')
const originalName = ref('')
const isRenaming = ref(false)
const currentBackup = ref<Backup | null>(null);
const currentBackup = ref<Backup | null>(null)
const trimmedName = computed(() => backupName.value.trim());
const trimmedName = computed(() => backupName.value.trim())
const nameExists = computed(() => {
if (!props.server.backups?.data || trimmedName.value === originalName.value || isRenaming.value) {
return false;
}
if (!props.server.backups?.data || trimmedName.value === originalName.value || isRenaming.value) {
return false
}
return props.server.backups.data.some(
(backup: Backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
);
});
return props.server.backups.data.some(
(backup: Backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
)
})
const backupNumber = computed(
() => (props.server.backups?.data?.findIndex((b) => b.id === currentBackup.value?.id) ?? 0) + 1,
);
() => (props.server.backups?.data?.findIndex((b) => b.id === currentBackup.value?.id) ?? 0) + 1,
)
const focusInput = () => {
nextTick(() => {
setTimeout(() => {
input.value?.focus();
}, 100);
});
};
nextTick(() => {
setTimeout(() => {
input.value?.focus()
}, 100)
})
}
function show(backup: Backup) {
currentBackup.value = backup;
backupName.value = backup.name;
originalName.value = backup.name;
isRenaming.value = false;
modal.value?.show();
currentBackup.value = backup
backupName.value = backup.name
originalName.value = backup.name
isRenaming.value = false
modal.value?.show()
}
function hide() {
modal.value?.hide();
modal.value?.hide()
}
const renameBackup = async () => {
if (!currentBackup.value) {
addNotification({
type: "error",
title: "Error renaming backup",
text: "Current backup is null",
});
return;
}
if (!currentBackup.value) {
addNotification({
type: 'error',
title: 'Error renaming backup',
text: 'Current backup is null',
})
return
}
if (trimmedName.value === originalName.value) {
hide();
return;
}
if (trimmedName.value === originalName.value) {
hide()
return
}
isRenaming.value = true;
try {
let newName = trimmedName.value;
isRenaming.value = true
try {
let newName = trimmedName.value
if (newName.length === 0) {
newName = `Backup #${backupNumber.value}`;
}
if (newName.length === 0) {
newName = `Backup #${backupNumber.value}`
}
await props.server.backups?.rename(currentBackup.value.id, newName);
hide();
await props.server.refresh();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
addNotification({ type: "error", title: "Error renaming backup", text: message });
} finally {
hide();
isRenaming.value = false;
}
};
await props.server.backups?.rename(currentBackup.value.id, newName)
hide()
await props.server.refresh()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Error renaming backup', text: message })
} finally {
hide()
isRenaming.value = false
}
}
defineExpose({
show,
hide,
});
show,
hide,
})
</script>

View File

@@ -1,61 +1,63 @@
<template>
<ConfirmModal
ref="modal"
danger
title="Are you sure you want to restore from this backup?"
proceed-label="Restore from backup"
description="This will **overwrite all files on your server** and replace them with the files from the backup."
@proceed="restoreBackup"
>
<BackupItem
v-if="currentBackup"
:backup="currentBackup"
preview
class="border-px border-solid border-button-border"
/>
</ConfirmModal>
<ConfirmModal
ref="modal"
danger
title="Are you sure you want to restore from this backup?"
proceed-label="Restore from backup"
description="This will **overwrite all files on your server** and replace them with the files from the backup."
@proceed="restoreBackup"
>
<BackupItem
v-if="currentBackup"
:backup="currentBackup"
preview
class="border-px border-solid border-button-border"
/>
</ConfirmModal>
</template>
<script setup lang="ts">
import { ConfirmModal, injectNotificationManager, NewModal } from "@modrinth/ui";
import type { Backup } from "@modrinth/utils";
import { ref } from "vue";
import BackupItem from "~/components/ui/servers/BackupItem.vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { NewModal } from '@modrinth/ui'
import { ConfirmModal, injectNotificationManager } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { ref } from 'vue'
const { addNotification } = injectNotificationManager();
import BackupItem from '~/components/ui/servers/BackupItem.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const modal = ref<InstanceType<typeof NewModal>>();
const currentBackup = ref<Backup | null>(null);
const modal = ref<InstanceType<typeof NewModal>>()
const currentBackup = ref<Backup | null>(null)
function show(backup: Backup) {
currentBackup.value = backup;
modal.value?.show();
currentBackup.value = backup
modal.value?.show()
}
const restoreBackup = async () => {
if (!currentBackup.value) {
addNotification({
type: "error",
title: "Failed to restore backup",
text: "Current backup is null",
});
return;
}
if (!currentBackup.value) {
addNotification({
type: 'error',
title: 'Failed to restore backup',
text: 'Current backup is null',
})
return
}
try {
await props.server.backups?.restore(currentBackup.value.id);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
addNotification({ type: "error", title: "Failed to restore backup", text: message });
}
};
try {
await props.server.backups?.restore(currentBackup.value.id)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Failed to restore backup', text: message })
}
}
defineExpose({
show,
});
show,
})
</script>

View File

@@ -1,171 +1,172 @@
<template>
<NewModal ref="modal" header="Editing auto backup settings">
<div class="flex flex-col gap-4 md:w-[600px]">
<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
<strong>{{ backupIntervalsLabel.toLowerCase() }}</strong>
</p>
</div>
<NewModal ref="modal" header="Editing auto backup settings">
<div class="flex flex-col gap-4 md:w-[600px]">
<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
<strong>{{ backupIntervalsLabel.toLowerCase() }}</strong>
</p>
</div>
<div v-if="isLoadingSettings" class="py-2 text-sm text-secondary">Loading settings...</div>
<template v-else>
<input
id="auto-backup-toggle"
v-model="autoBackupEnabled"
class="switch stylized-toggle"
type="checkbox"
:disabled="isSaving"
/>
<div v-if="isLoadingSettings" class="py-2 text-sm text-secondary">Loading settings...</div>
<template v-else>
<input
id="auto-backup-toggle"
v-model="autoBackupEnabled"
class="switch stylized-toggle"
type="checkbox"
:disabled="isSaving"
/>
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Interval</div>
<p class="m-0">
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 flex-col gap-2">
<div class="font-semibold text-contrast">Interval</div>
<p class="m-0">
The amount of time between each backup. This will only backup your server if it has been
modified since the last backup.
</p>
</div>
<UiServersTeleportDropdownMenu
:id="'interval-field'"
v-model="backupIntervalsLabel"
:disabled="!autoBackupEnabled || isSaving"
name="interval"
:options="Object.keys(backupIntervals)"
placeholder="Backup interval"
/>
<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">
<button :disabled="!hasChanges || isSaving" @click="saveSettings">
<SaveIcon class="h-5 w-5" />
{{ isSaving ? "Saving..." : "Save changes" }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="isSaving" @click="modal?.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</template>
</div>
</NewModal>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!hasChanges || isSaving" @click="saveSettings">
<SaveIcon class="h-5 w-5" />
{{ isSaving ? 'Saving...' : 'Save changes' }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="isSaving" @click="modal?.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</template>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { SaveIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, injectNotificationManager, NewModal } from "@modrinth/ui";
import { computed, ref } from "vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
import { computed, ref } from 'vue'
const { addNotification } = injectNotificationManager();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const modal = ref<InstanceType<typeof NewModal>>();
const modal = ref<InstanceType<typeof NewModal>>()
const initialSettings = ref<{ interval: number; enabled: boolean } | null>(null);
const autoBackupEnabled = ref(false);
const isLoadingSettings = ref(true);
const isSaving = ref(false);
const initialSettings = ref<{ interval: number; enabled: boolean } | null>(null)
const autoBackupEnabled = ref(false)
const isLoadingSettings = ref(true)
const isSaving = ref(false)
const backupIntervals = {
"Every 3 hours": 3,
"Every 6 hours": 6,
"Every 12 hours": 12,
Daily: 24,
};
'Every 3 hours': 3,
'Every 6 hours': 6,
'Every 12 hours': 12,
Daily: 24,
}
const backupIntervalsLabel = ref<keyof typeof backupIntervals>("Every 6 hours");
const backupIntervalsLabel = ref<keyof typeof backupIntervals>('Every 6 hours')
const autoBackupInterval = computed({
get: () => backupIntervals[backupIntervalsLabel.value],
set: (value) => {
const [label] =
Object.entries(backupIntervals).find(([_, interval]) => interval === value) || [];
if (label) backupIntervalsLabel.value = label as keyof typeof backupIntervals;
},
});
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(() => {
if (!initialSettings.value) return false;
if (!initialSettings.value) return false
return (
autoBackupEnabled.value !== initialSettings.value.enabled ||
(initialSettings.value.enabled && autoBackupInterval.value !== initialSettings.value.interval)
);
});
return (
autoBackupEnabled.value !== initialSettings.value.enabled ||
(initialSettings.value.enabled && autoBackupInterval.value !== initialSettings.value.interval)
)
})
const fetchSettings = async () => {
isLoadingSettings.value = true;
try {
const settings = await props.server.backups?.getAutoBackup();
initialSettings.value = settings as { interval: number; enabled: boolean };
autoBackupEnabled.value = settings?.enabled ?? false;
autoBackupInterval.value = settings?.interval || 6;
return true;
} catch (error) {
console.error("Error fetching backup settings:", error);
addNotification({
title: "Error",
text: "Failed to load backup settings",
type: "error",
});
return false;
} finally {
isLoadingSettings.value = false;
}
};
isLoadingSettings.value = true
try {
const settings = await props.server.backups?.getAutoBackup()
initialSettings.value = settings as { interval: number; enabled: boolean }
autoBackupEnabled.value = settings?.enabled ?? false
autoBackupInterval.value = settings?.interval || 6
return true
} catch (error) {
console.error('Error fetching backup settings:', error)
addNotification({
title: 'Error',
text: 'Failed to load backup settings',
type: 'error',
})
return false
} finally {
isLoadingSettings.value = false
}
}
const saveSettings = async () => {
isSaving.value = true;
try {
await props.server.backups?.updateAutoBackup(
autoBackupEnabled.value ? "enable" : "disable",
autoBackupInterval.value,
);
isSaving.value = true
try {
await props.server.backups?.updateAutoBackup(
autoBackupEnabled.value ? 'enable' : 'disable',
autoBackupInterval.value,
)
initialSettings.value = {
enabled: autoBackupEnabled.value,
interval: autoBackupInterval.value,
};
initialSettings.value = {
enabled: autoBackupEnabled.value,
interval: autoBackupInterval.value,
}
addNotification({
title: "Success",
text: "Backup settings updated successfully",
type: "success",
});
addNotification({
title: 'Success',
text: 'Backup settings updated successfully',
type: 'success',
})
modal.value?.hide();
} catch (error) {
console.error("Error saving backup settings:", error);
addNotification({
title: "Error",
text: "Failed to save backup settings",
type: "error",
});
} finally {
isSaving.value = false;
}
};
modal.value?.hide()
} catch (error) {
console.error('Error saving backup settings:', error)
addNotification({
title: 'Error',
text: 'Failed to save backup settings',
type: 'error',
})
} finally {
isSaving.value = false
}
}
defineExpose({
show: async () => {
const success = await fetchSettings();
if (success) {
modal.value?.show();
}
},
});
show: async () => {
const success = await fetchSettings()
if (success) {
modal.value?.show()
}
},
})
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -1,532 +1,532 @@
<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)]">
<Avatar :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>
<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)]">
<Avatar :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>
<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()}
<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>
: "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>
<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>
<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>
<CopyCode class="!mt-2 !break-all" :text="versionsError" />
</div>
</Admonition>
<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>
<CopyCode 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>
<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>
<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, Avatar, ButtonStyled, CopyCode, NewModal } from "@modrinth/ui";
import TagItem from "@modrinth/ui/src/components/base/TagItem.vue";
import { ref, computed } from "vue";
import { formatCategory, formatVersionsForDisplay, type Mod, type Version } from "@modrinth/utils";
import Accordion from "~/components/ui/Accordion.vue";
import Checkbox from "~/components/ui/Checkbox.vue";
CheckIcon,
DropdownIcon,
ExternalIcon,
GameIcon,
LockOpenIcon,
XIcon,
} from '@modrinth/assets'
import { Admonition, Avatar, ButtonStyled, CopyCode, NewModal } from '@modrinth/ui'
import TagItem from '@modrinth/ui/src/components/base/TagItem.vue'
import { formatCategory, formatVersionsForDisplay, type Mod, type Version } from '@modrinth/utils'
import { computed, ref } from 'vue'
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";
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;
}>();
type: 'Mod' | 'Plugin'
loader: string
gameVersion: string
modPack: boolean
serverId: string
}>()
interface ContentItem extends Mod {
changing?: boolean;
changing?: boolean
}
interface EditVersion extends Version {
installed: boolean;
upgrade?: boolean;
installed: boolean
upgrade?: boolean
}
const modModal = ref();
const modDetails = ref<ContentItem>();
const currentVersions = ref<EditVersion[] | null>(null);
const versionsLoading = ref(false);
const versionsError = ref("");
const showBetaAlphaReleases = ref(false);
const unlockFilterAccordion = ref();
const versionFilter = ref(true);
const tags = useTags();
const noCompatibleVersions = ref(false);
const modModal = ref()
const modDetails = ref<ContentItem>()
const currentVersions = ref<EditVersion[] | null>(null)
const versionsLoading = ref(false)
const versionsError = ref('')
const showBetaAlphaReleases = ref(false)
const unlockFilterAccordion = ref()
const versionFilter = ref(true)
const tags = useTags()
const noCompatibleVersions = ref(false)
const { pluginLoaders, modLoaders } = tags.value.loaders.reduce(
(acc, tag) => {
if (tag.supported_project_types.includes("plugin")) {
acc.pluginLoaders.push(tag.name);
}
if (tag.supported_project_types.includes("mod")) {
acc.modLoaders.push(tag.name);
}
return acc;
},
{ pluginLoaders: [] as string[], modLoaders: [] as string[] },
);
(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);
const selectedVersion = ref()
const filtersRef: Ref<InstanceType<typeof ContentVersionFilter> | null> = ref(null)
interface SelectedContentFilters {
selectedGameVersions: string[];
selectedPlatforms: string[];
selectedGameVersions: string[]
selectedPlatforms: string[]
}
const selectedFilters = ref<SelectedContentFilters>({
selectedGameVersions: [],
selectedPlatforms: [],
});
selectedGameVersions: [],
selectedPlatforms: [],
})
const backwardCompatPlatformMap = {
purpur: ["purpur", "paper", "spigot", "bukkit"],
paper: ["paper", "spigot", "bukkit"],
spigot: ["spigot", "bukkit"],
};
purpur: ['purpur', 'paper', 'spigot', 'bukkit'],
paper: ['paper', 'spigot', 'bukkit'],
spigot: ['spigot', 'bukkit'],
}
const platforms = ref<ListedPlatform[]>([]);
const gameVersions = ref<ListedGameVersion[]>([]);
const initPlatform = ref<string>("");
const platforms = ref<ListedPlatform[]>([])
const gameVersions = ref<ListedGameVersion[]>([])
const initPlatform = ref<string>('')
const setInitialFilters = () => {
selectedFilters.value = {
selectedGameVersions: [
gameVersions.value.find((version) => version.name === props.gameVersion)?.name ??
gameVersions.value.find((version) => version.release)?.name ??
gameVersions.value[0]?.name,
],
selectedPlatforms: [initPlatform.value],
};
};
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;
if (!filtersRef.value) return
filtersRef.value.selectedGameVersions = selectedFilters.value.selectedGameVersions
filtersRef.value.selectedPlatforms = selectedFilters.value.selectedPlatforms
selectedVersion.value = filteredVersions.value[0];
};
selectedVersion.value = filteredVersions.value[0]
}
const updateFiltersFromUi = (event: { g: string[]; l: string[] }) => {
selectedFilters.value = {
selectedGameVersions: event.g,
selectedPlatforms: event.l,
};
};
selectedFilters.value = {
selectedGameVersions: event.g,
selectedPlatforms: event.l,
}
}
const filteredVersions = computed(() => {
if (!currentVersions.value) return [];
if (!currentVersions.value) return []
const versionsWithoutReleaseFilter = currentVersions.value.filter((version: EditVersion) => {
if (version.installed) return true;
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 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 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;
});
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 = "";
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)";
}
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,
};
});
});
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];
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());
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)),
};
});
if (firstLoaderPosition === -1 && secondLoaderPosition === -1) return 0
if (firstLoaderPosition === -1) return 1
if (secondLoaderPosition === -1) return -1
return firstLoaderPosition - secondLoaderPosition
})
.map((loader: string) => formatCategory(loader)),
}
})
async function show(mod: ContentItem) {
versionFilter.value = true;
modModal.value.show();
versionsLoading.value = true;
modDetails.value = mod;
versionsError.value = "";
currentVersions.value = null;
versionFilter.value = true
modModal.value.show()
versionsLoading.value = true
modDetails.value = mod
versionsError.value = ''
currentVersions.value = null
try {
const result = await useBaseFetch(`project/${mod.project_id}/version`, {}, false);
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.");
}
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;
}
// 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),
);
// 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;
}
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;
// 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);
// 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;
}
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";
}
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];
}>();
changeVersion: [string]
}>()
function emitChangeModVersion() {
if (!selectedVersion.value) return;
emit("changeVersion", selectedVersion.value.id.toString());
if (!selectedVersion.value) return
emit('changeVersion', selectedVersion.value.id.toString())
}
defineExpose({
show,
hide: () => modModal.value.hide(),
});
show,
hide: () => modModal.value.hide(),
})
</script>

View File

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

View File

@@ -1,412 +1,410 @@
<template>
<li
role="button"
data-pyro-file
:class="[
containerClasses,
isDragOver && type === 'directory' ? 'bg-brand-highlight' : '',
isDragging ? 'opacity-50' : '',
]"
tabindex="0"
draggable="true"
@click="selectItem"
@contextmenu="openContextMenu"
@keydown="(e) => e.key === 'Enter' && selectItem()"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<div
data-pyro-file-metadata
class="pointer-events-none flex w-full items-center gap-4 truncate"
>
<div
class="pointer-events-none flex size-8 items-center justify-center rounded-full bg-bg-raised p-[6px] group-hover:bg-brand-highlight group-hover:text-brand group-focus:bg-brand-highlight group-focus:text-brand"
:class="isEditableFile ? 'group-active:scale-[0.8]' : ''"
>
<component :is="iconComponent" class="size-6" />
</div>
<div class="pointer-events-none flex w-full flex-col truncate">
<span
class="pointer-events-none w-[98%] truncate font-bold group-hover:text-contrast group-focus:text-contrast"
>
{{ name }}
</span>
<span class="pointer-events-none text-xs text-secondary group-hover:text-primary">
{{ subText }}
</span>
</div>
</div>
<div
data-pyro-file-actions
class="pointer-events-auto flex w-fit flex-shrink-0 items-center gap-4 md:gap-12"
>
<span class="hidden w-[160px] text-nowrap font-mono text-sm text-secondary md:flex">
{{ formattedCreationDate }}
</span>
<span class="w-[160px] text-nowrap font-mono text-sm text-secondary">
{{ formattedModifiedDate }}
</span>
<ButtonStyled circular type="transparent">
<UiServersTeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
<template #extract><PackageOpenIcon /> Extract</template>
<template #rename><EditIcon /> Rename</template>
<template #move><RightArrowIcon /> Move</template>
<template #download><DownloadIcon /> Download</template>
<template #delete><TrashIcon /> Delete</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
</li>
<li
role="button"
data-pyro-file
:class="[
containerClasses,
isDragOver && type === 'directory' ? 'bg-brand-highlight' : '',
isDragging ? 'opacity-50' : '',
]"
tabindex="0"
draggable="true"
@click="selectItem"
@contextmenu="openContextMenu"
@keydown="(e) => e.key === 'Enter' && selectItem()"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<div
data-pyro-file-metadata
class="pointer-events-none flex w-full items-center gap-4 truncate"
>
<div
class="pointer-events-none flex size-8 items-center justify-center rounded-full bg-bg-raised p-[6px] group-hover:bg-brand-highlight group-hover:text-brand group-focus:bg-brand-highlight group-focus:text-brand"
:class="isEditableFile ? 'group-active:scale-[0.8]' : ''"
>
<component :is="iconComponent" class="size-6" />
</div>
<div class="pointer-events-none flex w-full flex-col truncate">
<span
class="pointer-events-none w-[98%] truncate font-bold group-hover:text-contrast group-focus:text-contrast"
>
{{ name }}
</span>
<span class="pointer-events-none text-xs text-secondary group-hover:text-primary">
{{ subText }}
</span>
</div>
</div>
<div
data-pyro-file-actions
class="pointer-events-auto flex w-fit flex-shrink-0 items-center gap-4 md:gap-12"
>
<span class="hidden w-[160px] text-nowrap font-mono text-sm text-secondary md:flex">
{{ formattedCreationDate }}
</span>
<span class="w-[160px] text-nowrap font-mono text-sm text-secondary">
{{ formattedModifiedDate }}
</span>
<ButtonStyled circular type="transparent">
<UiServersTeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
<template #extract><PackageOpenIcon /> Extract</template>
<template #rename><EditIcon /> Rename</template>
<template #move><RightArrowIcon /> Move</template>
<template #download><DownloadIcon /> Download</template>
<template #delete><TrashIcon /> Delete</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
</li>
</template>
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import {
MoreHorizontalIcon,
EditIcon,
DownloadIcon,
TrashIcon,
FolderOpenIcon,
FileIcon,
RightArrowIcon,
PackageOpenIcon,
FileArchiveIcon,
} from "@modrinth/assets";
import { computed, shallowRef, ref } from "vue";
import { renderToString } from "vue/server-renderer";
import { useRouter, useRoute } from "vue-router";
DownloadIcon,
EditIcon,
FileArchiveIcon,
FileIcon,
FolderOpenIcon,
MoreHorizontalIcon,
PackageOpenIcon,
RightArrowIcon,
TrashIcon,
} from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { computed, ref, shallowRef } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { useRoute, useRouter } from 'vue-router'
import {
UiServersIconsCogFolderIcon,
UiServersIconsEarthIcon,
UiServersIconsCodeFileIcon,
UiServersIconsTextFileIcon,
UiServersIconsImageFileIcon,
} from "#components";
import PaletteIcon from "~/assets/icons/palette.svg?component";
UiServersIconsCodeFileIcon,
UiServersIconsCogFolderIcon,
UiServersIconsEarthIcon,
UiServersIconsImageFileIcon,
UiServersIconsTextFileIcon,
} from '#components'
import PaletteIcon from '~/assets/icons/palette.svg?component'
interface FileItemProps {
name: string;
type: "directory" | "file";
size?: number;
count?: number;
modified: number;
created: number;
path: string;
name: string
type: 'directory' | 'file'
size?: number
count?: number
modified: number
created: number
path: string
}
const props = defineProps<FileItemProps>();
const props = defineProps<FileItemProps>()
const emit = defineEmits<{
(
e: "rename" | "move" | "download" | "delete" | "edit" | "extract",
item: { name: string; type: string; path: string },
): void;
(
e: "moveDirectTo",
item: { name: string; type: string; path: string; destination: string },
): void;
(e: "contextmenu", x: number, y: number): void;
}>();
(
e: 'rename' | 'move' | 'download' | 'delete' | 'edit' | 'extract',
item: { name: string; type: string; path: string },
): void
(e: 'moveDirectTo', item: { name: string; type: string; path: string; destination: string }): void
(e: 'contextmenu', x: number, y: number): void
}>()
const isDragOver = ref(false);
const isDragging = ref(false);
const isDragOver = ref(false)
const isDragging = ref(false)
const codeExtensions = Object.freeze([
"json",
"json5",
"jsonc",
"java",
"kt",
"kts",
"sh",
"bat",
"ps1",
"yml",
"yaml",
"toml",
"js",
"ts",
"py",
"rb",
"php",
"html",
"css",
"cpp",
"c",
"h",
"rs",
"go",
]);
'json',
'json5',
'jsonc',
'java',
'kt',
'kts',
'sh',
'bat',
'ps1',
'yml',
'yaml',
'toml',
'js',
'ts',
'py',
'rb',
'php',
'html',
'css',
'cpp',
'c',
'h',
'rs',
'go',
])
const textExtensions = Object.freeze(["txt", "md", "log", "cfg", "conf", "properties", "ini"]);
const imageExtensions = Object.freeze(["png", "jpg", "jpeg", "gif", "svg", "webp"]);
const supportedArchiveExtensions = Object.freeze(["zip"]);
const units = Object.freeze(["B", "KB", "MB", "GB", "TB", "PB", "EB"]);
const textExtensions = Object.freeze(['txt', 'md', 'log', 'cfg', 'conf', 'properties', 'ini'])
const imageExtensions = Object.freeze(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'])
const supportedArchiveExtensions = Object.freeze(['zip'])
const units = Object.freeze(['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'])
const route = shallowRef(useRoute());
const router = useRouter();
const route = shallowRef(useRoute())
const router = useRouter()
const containerClasses = computed(() => [
"group m-0 p-0 focus:!outline-none flex w-full select-none items-center justify-between overflow-hidden border-0 border-b border-solid border-bg-raised p-3 last:border-none hover:bg-bg-raised focus:bg-bg-raised",
isEditableFile.value ? "cursor-pointer" : props.type === "directory" ? "cursor-pointer" : "",
isDragOver.value ? "bg-brand-highlight" : "",
]);
'group m-0 p-0 focus:!outline-none flex w-full select-none items-center justify-between overflow-hidden border-0 border-b border-solid border-bg-raised p-3 last:border-none hover:bg-bg-raised focus:bg-bg-raised',
isEditableFile.value ? 'cursor-pointer' : props.type === 'directory' ? 'cursor-pointer' : '',
isDragOver.value ? 'bg-brand-highlight' : '',
])
const fileExtension = computed(() => props.name.split(".").pop()?.toLowerCase() || "");
const fileExtension = computed(() => props.name.split('.').pop()?.toLowerCase() || '')
const isZip = computed(() => fileExtension.value === "zip");
const isZip = computed(() => fileExtension.value === 'zip')
const menuOptions = computed(() => [
{
id: "extract",
shown: isZip.value,
action: () => emit("extract", { name: props.name, type: props.type, path: props.path }),
},
{
divider: true,
shown: isZip.value,
},
{
id: "rename",
action: () => emit("rename", { name: props.name, type: props.type, path: props.path }),
},
{
id: "move",
action: () => emit("move", { name: props.name, type: props.type, path: props.path }),
},
{
id: "download",
action: () => emit("download", { name: props.name, type: props.type, path: props.path }),
shown: props.type !== "directory",
},
{
id: "delete",
action: () => emit("delete", { name: props.name, type: props.type, path: props.path }),
color: "red" as const,
},
]);
{
id: 'extract',
shown: isZip.value,
action: () => emit('extract', { name: props.name, type: props.type, path: props.path }),
},
{
divider: true,
shown: isZip.value,
},
{
id: 'rename',
action: () => emit('rename', { name: props.name, type: props.type, path: props.path }),
},
{
id: 'move',
action: () => emit('move', { name: props.name, type: props.type, path: props.path }),
},
{
id: 'download',
action: () => emit('download', { name: props.name, type: props.type, path: props.path }),
shown: props.type !== 'directory',
},
{
id: 'delete',
action: () => emit('delete', { name: props.name, type: props.type, path: props.path }),
color: 'red' as const,
},
])
const iconComponent = computed(() => {
if (props.type === "directory") {
if (props.name === "config") return UiServersIconsCogFolderIcon;
if (props.name === "world") return UiServersIconsEarthIcon;
if (props.name === "resourcepacks") return PaletteIcon;
return FolderOpenIcon;
}
if (props.type === 'directory') {
if (props.name === 'config') return UiServersIconsCogFolderIcon
if (props.name === 'world') return UiServersIconsEarthIcon
if (props.name === 'resourcepacks') return PaletteIcon
return FolderOpenIcon
}
const ext = fileExtension.value;
if (codeExtensions.includes(ext)) return UiServersIconsCodeFileIcon;
if (textExtensions.includes(ext)) return UiServersIconsTextFileIcon;
if (imageExtensions.includes(ext)) return UiServersIconsImageFileIcon;
if (supportedArchiveExtensions.includes(ext)) return FileArchiveIcon;
return FileIcon;
});
const ext = fileExtension.value
if (codeExtensions.includes(ext)) return UiServersIconsCodeFileIcon
if (textExtensions.includes(ext)) return UiServersIconsTextFileIcon
if (imageExtensions.includes(ext)) return UiServersIconsImageFileIcon
if (supportedArchiveExtensions.includes(ext)) return FileArchiveIcon
return FileIcon
})
const subText = computed(() => {
if (props.type === "directory") {
return `${props.count} ${props.count === 1 ? "item" : "items"}`;
}
return formattedSize.value;
});
if (props.type === 'directory') {
return `${props.count} ${props.count === 1 ? 'item' : 'items'}`
}
return formattedSize.value
})
const formattedModifiedDate = computed(() => {
const date = new Date(props.modified * 1000);
return `${date.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "2-digit",
})}, ${date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "numeric",
hour12: true,
})}`;
});
const date = new Date(props.modified * 1000)
return `${date.toLocaleDateString('en-US', {
month: '2-digit',
day: '2-digit',
year: '2-digit',
})}, ${date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: 'numeric',
hour12: true,
})}`
})
const formattedCreationDate = computed(() => {
const date = new Date(props.created * 1000);
return `${date.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "2-digit",
})}, ${date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "numeric",
hour12: true,
})}`;
});
const date = new Date(props.created * 1000)
return `${date.toLocaleDateString('en-US', {
month: '2-digit',
day: '2-digit',
year: '2-digit',
})}, ${date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: 'numeric',
hour12: true,
})}`
})
const isEditableFile = computed(() => {
if (props.type === "file") {
const ext = fileExtension.value;
return (
!props.name.includes(".") ||
textExtensions.includes(ext) ||
codeExtensions.includes(ext) ||
imageExtensions.includes(ext)
);
}
return false;
});
if (props.type === 'file') {
const ext = fileExtension.value
return (
!props.name.includes('.') ||
textExtensions.includes(ext) ||
codeExtensions.includes(ext) ||
imageExtensions.includes(ext)
)
}
return false
})
const formattedSize = computed(() => {
if (props.size === undefined) return "";
const bytes = props.size;
if (bytes === 0) return "0 B";
if (props.size === undefined) return ''
const bytes = props.size
if (bytes === 0) return '0 B'
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const size = (bytes / Math.pow(1024, exponent)).toFixed(2);
return `${size} ${units[exponent]}`;
});
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const size = (bytes / Math.pow(1024, exponent)).toFixed(2)
return `${size} ${units[exponent]}`
})
const openContextMenu = (event: MouseEvent) => {
event.preventDefault();
emit("contextmenu", event.clientX, event.clientY);
};
event.preventDefault()
emit('contextmenu', event.clientX, event.clientY)
}
const navigateToFolder = () => {
const currentPath = route.value.query.path?.toString() || "";
const newPath = currentPath.endsWith("/")
? `${currentPath}${props.name}`
: `${currentPath}/${props.name}`;
router.push({ query: { path: newPath, page: 1 } });
};
const currentPath = route.value.query.path?.toString() || ''
const newPath = currentPath.endsWith('/')
? `${currentPath}${props.name}`
: `${currentPath}/${props.name}`
router.push({ query: { path: newPath, page: 1 } })
}
const isNavigating = ref(false);
const isNavigating = ref(false)
const selectItem = () => {
if (isNavigating.value) return;
isNavigating.value = true;
if (isNavigating.value) return
isNavigating.value = true
if (props.type === "directory") {
navigateToFolder();
} else if (props.type === "file" && isEditableFile.value) {
emit("edit", { name: props.name, type: props.type, path: props.path });
}
if (props.type === 'directory') {
navigateToFolder()
} else if (props.type === 'file' && isEditableFile.value) {
emit('edit', { name: props.name, type: props.type, path: props.path })
}
setTimeout(() => {
isNavigating.value = false;
}, 500);
};
setTimeout(() => {
isNavigating.value = false
}, 500)
}
const getDragIcon = async () => {
let iconToUse;
let iconToUse
if (props.type === "directory") {
if (props.name === "config") {
iconToUse = UiServersIconsCogFolderIcon;
} else if (props.name === "world") {
iconToUse = UiServersIconsEarthIcon;
} else if (props.name === "resourcepacks") {
iconToUse = PaletteIcon;
} else {
iconToUse = FolderOpenIcon;
}
} else {
const ext = fileExtension.value;
if (codeExtensions.includes(ext)) {
iconToUse = UiServersIconsCodeFileIcon;
} else if (textExtensions.includes(ext)) {
iconToUse = UiServersIconsTextFileIcon;
} else if (imageExtensions.includes(ext)) {
iconToUse = UiServersIconsImageFileIcon;
} else {
iconToUse = FileIcon;
}
}
if (props.type === 'directory') {
if (props.name === 'config') {
iconToUse = UiServersIconsCogFolderIcon
} else if (props.name === 'world') {
iconToUse = UiServersIconsEarthIcon
} else if (props.name === 'resourcepacks') {
iconToUse = PaletteIcon
} else {
iconToUse = FolderOpenIcon
}
} else {
const ext = fileExtension.value
if (codeExtensions.includes(ext)) {
iconToUse = UiServersIconsCodeFileIcon
} else if (textExtensions.includes(ext)) {
iconToUse = UiServersIconsTextFileIcon
} else if (imageExtensions.includes(ext)) {
iconToUse = UiServersIconsImageFileIcon
} else {
iconToUse = FileIcon
}
}
return await renderToString(h(iconToUse));
};
return await renderToString(h(iconToUse))
}
const handleDragStart = async (event: DragEvent) => {
if (!event.dataTransfer) return;
isDragging.value = true;
if (!event.dataTransfer) return
isDragging.value = true
const dragGhost = document.createElement("div");
dragGhost.className =
"fixed left-0 top-0 flex items-center max-w-[500px] flex-row gap-3 rounded-lg bg-bg-raised p-3 shadow-lg pointer-events-none";
const dragGhost = document.createElement('div')
dragGhost.className =
'fixed left-0 top-0 flex items-center max-w-[500px] flex-row gap-3 rounded-lg bg-bg-raised p-3 shadow-lg pointer-events-none'
const iconContainer = document.createElement("div");
iconContainer.className = "flex size-6 items-center justify-center";
const iconContainer = document.createElement('div')
iconContainer.className = 'flex size-6 items-center justify-center'
const icon = document.createElement("div");
icon.className = "size-4";
icon.innerHTML = await getDragIcon();
iconContainer.appendChild(icon);
const icon = document.createElement('div')
icon.className = 'size-4'
icon.innerHTML = await getDragIcon()
iconContainer.appendChild(icon)
const nameSpan = document.createElement("span");
nameSpan.className = "font-bold truncate text-contrast";
nameSpan.textContent = props.name;
const nameSpan = document.createElement('span')
nameSpan.className = 'font-bold truncate text-contrast'
nameSpan.textContent = props.name
dragGhost.appendChild(iconContainer);
dragGhost.appendChild(nameSpan);
document.body.appendChild(dragGhost);
dragGhost.appendChild(iconContainer)
dragGhost.appendChild(nameSpan)
document.body.appendChild(dragGhost)
event.dataTransfer.setDragImage(dragGhost, 0, 0);
event.dataTransfer.setDragImage(dragGhost, 0, 0)
requestAnimationFrame(() => {
document.body.removeChild(dragGhost);
});
requestAnimationFrame(() => {
document.body.removeChild(dragGhost)
})
event.dataTransfer.setData(
"application/pyro-file-move",
JSON.stringify({
name: props.name,
type: props.type,
path: props.path,
}),
);
event.dataTransfer.effectAllowed = "move";
};
event.dataTransfer.setData(
'application/pyro-file-move',
JSON.stringify({
name: props.name,
type: props.type,
path: props.path,
}),
)
event.dataTransfer.effectAllowed = 'move'
}
const isChildPath = (parentPath: string, childPath: string) => {
return childPath.startsWith(parentPath + "/");
};
return childPath.startsWith(parentPath + '/')
}
const handleDragEnd = () => {
isDragging.value = false;
};
isDragging.value = false
}
const handleDragEnter = () => {
if (props.type !== "directory") return;
isDragOver.value = true;
};
if (props.type !== 'directory') return
isDragOver.value = true
}
const handleDragOver = (event: DragEvent) => {
if (props.type !== "directory" || !event.dataTransfer) return;
event.dataTransfer.dropEffect = "move";
};
if (props.type !== 'directory' || !event.dataTransfer) return
event.dataTransfer.dropEffect = 'move'
}
const handleDragLeave = () => {
isDragOver.value = false;
};
isDragOver.value = false
}
const handleDrop = (event: DragEvent) => {
isDragOver.value = false;
if (props.type !== "directory" || !event.dataTransfer) return;
isDragOver.value = false
if (props.type !== 'directory' || !event.dataTransfer) return
try {
const dragData = JSON.parse(event.dataTransfer.getData("application/pyro-file-move"));
try {
const dragData = JSON.parse(event.dataTransfer.getData('application/pyro-file-move'))
if (dragData.path === props.path) return;
if (dragData.path === props.path) return
if (dragData.type === "directory" && isChildPath(dragData.path, props.path)) {
console.error("Cannot move a folder into its own subfolder");
return;
}
if (dragData.type === 'directory' && isChildPath(dragData.path, props.path)) {
console.error('Cannot move a folder into its own subfolder')
return
}
emit("moveDirectTo", {
name: dragData.name,
type: dragData.type,
path: dragData.path,
destination: props.path,
});
} catch (error) {
console.error("Error handling file drop:", error);
}
};
emit('moveDirectTo', {
name: dragData.name,
type: dragData.type,
path: dragData.path,
destination: props.path,
})
} catch (error) {
console.error('Error handling file drop:', error)
}
}
</script>

View File

@@ -1,40 +1,39 @@
<template>
<div class="flex h-full w-full items-center justify-center gap-6 p-20">
<FileIcon class="size-28" />
<div class="flex flex-col gap-2">
<h3 class="text-red-500 m-0 text-2xl font-bold">{{ title }}</h3>
<p class="m-0 text-sm text-secondary">
{{ message }}
</p>
<div class="flex gap-2">
<ButtonStyled>
<button size="sm" @click="$emit('refetch')">
<UiServersIconsLoadingIcon class="h-5 w-5" />
Try again
</button>
</ButtonStyled>
<ButtonStyled>
<button size="sm" @click="$emit('home')">
<HomeIcon class="h-5 w-5" />
Go to home folder
</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="flex h-full w-full items-center justify-center gap-6 p-20">
<FileIcon class="size-28" />
<div class="flex flex-col gap-2">
<h3 class="text-red-500 m-0 text-2xl font-bold">{{ title }}</h3>
<p class="m-0 text-sm text-secondary">
{{ message }}
</p>
<div class="flex gap-2">
<ButtonStyled>
<button size="sm" @click="$emit('refetch')">
<UiServersIconsLoadingIcon class="h-5 w-5" />
Try again
</button>
</ButtonStyled>
<ButtonStyled>
<button size="sm" @click="$emit('home')">
<HomeIcon class="h-5 w-5" />
Go to home folder
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { FileIcon, HomeIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { FileIcon, HomeIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
defineProps<{
title: string;
message: string;
}>();
title: string
message: string
}>()
defineEmits<{
(e: "refetch"): void;
(e: "home"): void;
}>();
(e: 'refetch' | 'home'): void
}>()
</script>

View File

@@ -1,121 +1,121 @@
<template>
<div ref="listContainer" data-pyro-files-virtual-list-root class="relative w-full">
<div
:style="{
position: 'relative',
minHeight: `${totalHeight}px`,
}"
data-pyro-files-virtual-height-watcher
>
<ul
class="list-none"
:style="{
position: 'absolute',
top: `${visibleTop}px`,
width: '100%',
margin: 0,
padding: 0,
}"
data-pyro-files-virtual-list
>
<UiServersFileItem
v-for="item in visibleItems"
:key="item.path"
:count="item.count"
:created="item.created"
:modified="item.modified"
:name="item.name"
:path="item.path"
:type="item.type"
:size="item.size"
@delete="$emit('delete', item)"
@rename="$emit('rename', item)"
@extract="$emit('extract', item)"
@download="$emit('download', item)"
@move="$emit('move', item)"
@move-direct-to="$emit('moveDirectTo', $event)"
@edit="$emit('edit', item)"
@contextmenu="(x, y) => $emit('contextmenu', item, x, y)"
/>
</ul>
</div>
</div>
<div ref="listContainer" data-pyro-files-virtual-list-root class="relative w-full">
<div
:style="{
position: 'relative',
minHeight: `${totalHeight}px`,
}"
data-pyro-files-virtual-height-watcher
>
<ul
class="list-none"
:style="{
position: 'absolute',
top: `${visibleTop}px`,
width: '100%',
margin: 0,
padding: 0,
}"
data-pyro-files-virtual-list
>
<UiServersFileItem
v-for="item in visibleItems"
:key="item.path"
:count="item.count"
:created="item.created"
:modified="item.modified"
:name="item.name"
:path="item.path"
:type="item.type"
:size="item.size"
@delete="$emit('delete', item)"
@rename="$emit('rename', item)"
@extract="$emit('extract', item)"
@download="$emit('download', item)"
@move="$emit('move', item)"
@move-direct-to="$emit('moveDirectTo', $event)"
@edit="$emit('edit', item)"
@contextmenu="(x, y) => $emit('contextmenu', item, x, y)"
/>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { computed, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps<{
items: any[];
}>();
items: any[]
}>()
const emit = defineEmits<{
(
e: "delete" | "rename" | "download" | "move" | "edit" | "moveDirectTo" | "extract",
item: any,
): void;
(e: "contextmenu", item: any, x: number, y: number): void;
(e: "loadMore"): void;
}>();
(
e: 'delete' | 'rename' | 'download' | 'move' | 'edit' | 'moveDirectTo' | 'extract',
item: any,
): void
(e: 'contextmenu', item: any, x: number, y: number): void
(e: 'loadMore'): void
}>()
const ITEM_HEIGHT = 61;
const BUFFER_SIZE = 5;
const ITEM_HEIGHT = 61
const BUFFER_SIZE = 5
const listContainer = ref<HTMLElement | null>(null);
const windowScrollY = ref(0);
const windowHeight = ref(0);
const listContainer = ref<HTMLElement | null>(null)
const windowScrollY = ref(0)
const windowHeight = ref(0)
const totalHeight = computed(() => props.items.length * ITEM_HEIGHT);
const totalHeight = computed(() => props.items.length * ITEM_HEIGHT)
const visibleRange = computed(() => {
if (!listContainer.value) return { start: 0, end: 0 };
if (!listContainer.value) return { start: 0, end: 0 }
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY;
const relativeScrollTop = Math.max(0, windowScrollY.value - containerTop);
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY
const relativeScrollTop = Math.max(0, windowScrollY.value - containerTop)
const start = Math.floor(relativeScrollTop / ITEM_HEIGHT);
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT);
const start = Math.floor(relativeScrollTop / ITEM_HEIGHT)
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT)
return {
start: Math.max(0, start - BUFFER_SIZE),
end: Math.min(props.items.length, start + visibleCount + BUFFER_SIZE * 2),
};
});
return {
start: Math.max(0, start - BUFFER_SIZE),
end: Math.min(props.items.length, start + visibleCount + BUFFER_SIZE * 2),
}
})
const visibleTop = computed(() => {
return visibleRange.value.start * ITEM_HEIGHT;
});
return visibleRange.value.start * ITEM_HEIGHT
})
const visibleItems = computed(() => {
return props.items.slice(visibleRange.value.start, visibleRange.value.end);
});
return props.items.slice(visibleRange.value.start, visibleRange.value.end)
})
const handleScroll = () => {
windowScrollY.value = window.scrollY;
windowScrollY.value = window.scrollY
if (!listContainer.value) return;
if (!listContainer.value) return
const containerBottom = listContainer.value.getBoundingClientRect().bottom;
const remainingScroll = containerBottom - window.innerHeight;
const containerBottom = listContainer.value.getBoundingClientRect().bottom
const remainingScroll = containerBottom - window.innerHeight
if (remainingScroll < windowHeight.value * 0.2) {
emit("loadMore");
}
};
if (remainingScroll < windowHeight.value * 0.2) {
emit('loadMore')
}
}
const handleResize = () => {
windowHeight.value = window.innerHeight;
};
windowHeight.value = window.innerHeight
}
onMounted(() => {
windowHeight.value = window.innerHeight;
window.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleResize, { passive: true });
handleScroll();
});
windowHeight.value = window.innerHeight
window.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', handleResize, { passive: true })
handleScroll()
})
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleResize);
});
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
})
</script>

View File

@@ -1,248 +1,247 @@
<template>
<div ref="pyroFilesSentinel" class="sentinel" data-pyro-files-sentinel />
<header
:class="[
'duration-20 top-0 flex select-none flex-col justify-between gap-2 bg-table-alternateRow p-3 transition-[border-radius] sm:h-12 sm:flex-row',
!isStuck ? 'rounded-t-2xl' : 'sticky top-0 z-20',
]"
data-pyro-files-state="browsing"
aria-label="File navigation"
>
<nav
aria-label="Breadcrumb navigation"
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
>
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
<li class="-ml-1 flex-shrink-0">
<ButtonStyled type="transparent">
<button
v-tooltip="'Back to home'"
type="button"
class="mr-2 grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
@click="$emit('navigate', -1)"
>
<span
class="grid size-8 place-content-center rounded-full bg-button-bg p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
>
<HomeIcon class="h-5 w-5" />
<span class="sr-only">Home</span>
</span>
</button>
</ButtonStyled>
</li>
<li class="m-0 -ml-2 min-w-0 flex-shrink p-0">
<ol class="m-0 flex min-w-0 flex-shrink items-center overflow-hidden p-0">
<TransitionGroup
name="breadcrumb"
tag="span"
class="relative flex min-w-0 flex-shrink items-center"
>
<li
v-for="(segment, index) in breadcrumbSegments"
:key="`${segment || index}-group`"
class="relative flex min-w-0 flex-shrink items-center text-sm"
>
<div class="flex min-w-0 flex-shrink items-center">
<ButtonStyled type="transparent">
<button
class="cursor-pointer truncate focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
:aria-current="
index === breadcrumbSegments.length - 1 ? 'location' : undefined
"
:class="{
'!text-contrast': index === breadcrumbSegments.length - 1,
}"
@click="$emit('navigate', index)"
>
{{ segment || "" }}
</button>
</ButtonStyled>
<ChevronRightIcon
v-if="index < breadcrumbSegments.length - 1"
class="size-4 flex-shrink-0 text-secondary"
aria-hidden="true"
/>
</div>
</li>
</TransitionGroup>
</ol>
</li>
</ol>
</nav>
<div ref="pyroFilesSentinel" class="sentinel" data-pyro-files-sentinel />
<header
:class="[
'duration-20 top-0 flex select-none flex-col justify-between gap-2 bg-table-alternateRow p-3 transition-[border-radius] sm:h-12 sm:flex-row',
!isStuck ? 'rounded-t-2xl' : 'sticky top-0 z-20',
]"
data-pyro-files-state="browsing"
aria-label="File navigation"
>
<nav
aria-label="Breadcrumb navigation"
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
>
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
<li class="-ml-1 flex-shrink-0">
<ButtonStyled type="transparent">
<button
v-tooltip="'Back to home'"
type="button"
class="mr-2 grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
@click="$emit('navigate', -1)"
>
<span
class="grid size-8 place-content-center rounded-full bg-button-bg p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
>
<HomeIcon class="h-5 w-5" />
<span class="sr-only">Home</span>
</span>
</button>
</ButtonStyled>
</li>
<li class="m-0 -ml-2 min-w-0 flex-shrink p-0">
<ol class="m-0 flex min-w-0 flex-shrink items-center overflow-hidden p-0">
<TransitionGroup
name="breadcrumb"
tag="span"
class="relative flex min-w-0 flex-shrink items-center"
>
<li
v-for="(segment, index) in breadcrumbSegments"
:key="`${segment || index}-group`"
class="relative flex min-w-0 flex-shrink items-center text-sm"
>
<div class="flex min-w-0 flex-shrink items-center">
<ButtonStyled type="transparent">
<button
class="cursor-pointer truncate focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
:aria-current="
index === breadcrumbSegments.length - 1 ? 'location' : undefined
"
:class="{
'!text-contrast': index === breadcrumbSegments.length - 1,
}"
@click="$emit('navigate', index)"
>
{{ segment || '' }}
</button>
</ButtonStyled>
<ChevronRightIcon
v-if="index < breadcrumbSegments.length - 1"
class="size-4 flex-shrink-0 text-secondary"
aria-hidden="true"
/>
</div>
</li>
</TransitionGroup>
</ol>
</li>
</ol>
</nav>
<div class="flex flex-shrink-0 items-center gap-1">
<div class="flex w-full flex-row-reverse sm:flex-row">
<ButtonStyled type="transparent">
<UiServersTeleportOverflowMenu
position="bottom"
direction="left"
aria-label="Filter view"
:options="[
{ id: 'all', action: () => $emit('filter', 'all') },
{ id: 'filesOnly', action: () => $emit('filter', 'filesOnly') },
{ id: 'foldersOnly', action: () => $emit('filter', 'foldersOnly') },
]"
>
<div class="flex items-center gap-1">
<FilterIcon aria-hidden="true" class="h-5 w-5" />
<span class="hidden text-sm font-medium sm:block">
{{ filterLabel }}
</span>
</div>
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #all>Show all</template>
<template #filesOnly>Files only</template>
<template #foldersOnly>Folders only</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
<div class="mx-1 w-full text-sm sm:w-48">
<label for="search-folder" class="sr-only">Search folder</label>
<div class="relative">
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search-folder"
:value="searchQuery"
type="search"
name="search"
autocomplete="off"
class="h-8 min-h-[unset] w-full border-[1px] border-solid border-divider bg-transparent py-2 pl-9"
placeholder="Search..."
@input="$emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
</div>
<div class="flex flex-shrink-0 items-center gap-1">
<div class="flex w-full flex-row-reverse sm:flex-row">
<ButtonStyled type="transparent">
<UiServersTeleportOverflowMenu
position="bottom"
direction="left"
aria-label="Filter view"
:options="[
{ id: 'all', action: () => $emit('filter', 'all') },
{ id: 'filesOnly', action: () => $emit('filter', 'filesOnly') },
{ id: 'foldersOnly', action: () => $emit('filter', 'foldersOnly') },
]"
>
<div class="flex items-center gap-1">
<FilterIcon aria-hidden="true" class="h-5 w-5" />
<span class="hidden text-sm font-medium sm:block">
{{ filterLabel }}
</span>
</div>
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #all>Show all</template>
<template #filesOnly>Files only</template>
<template #foldersOnly>Folders only</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
<div class="mx-1 w-full text-sm sm:w-48">
<label for="search-folder" class="sr-only">Search folder</label>
<div class="relative">
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search-folder"
:value="searchQuery"
type="search"
name="search"
autocomplete="off"
class="h-8 min-h-[unset] w-full border-[1px] border-solid border-divider bg-transparent py-2 pl-9"
placeholder="Search..."
@input="$emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
</div>
<ButtonStyled type="transparent">
<OverflowMenu
:dropdown-id="`create-new-${baseId}`"
position="bottom"
direction="left"
aria-label="Create new..."
:options="[
{ id: 'file', action: () => $emit('create', 'file') },
{ id: 'directory', action: () => $emit('create', 'directory') },
{ id: 'upload', action: () => $emit('upload') },
{ divider: true },
{ id: 'upload-zip', shown: false, action: () => $emit('upload-zip') },
{ id: 'install-from-url', action: () => $emit('unzip-from-url', false) },
{ id: 'install-cf-pack', action: () => $emit('unzip-from-url', true) },
]"
>
<PlusIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </template>
<template #upload-zip>
<FileArchiveIcon aria-hidden="true" /> Upload from .zip file
</template>
<template #install-from-url>
<LinkIcon aria-hidden="true" /> Upload from .zip URL
</template>
<template #install-cf-pack>
<CurseForgeIcon aria-hidden="true" /> Install CurseForge pack
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</header>
<ButtonStyled type="transparent">
<OverflowMenu
:dropdown-id="`create-new-${baseId}`"
position="bottom"
direction="left"
aria-label="Create new..."
:options="[
{ id: 'file', action: () => $emit('create', 'file') },
{ id: 'directory', action: () => $emit('create', 'directory') },
{ id: 'upload', action: () => $emit('upload') },
{ divider: true },
{ id: 'upload-zip', shown: false, action: () => $emit('upload-zip') },
{ id: 'install-from-url', action: () => $emit('unzip-from-url', false) },
{ id: 'install-cf-pack', action: () => $emit('unzip-from-url', true) },
]"
>
<PlusIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </template>
<template #upload-zip>
<FileArchiveIcon aria-hidden="true" /> Upload from .zip file
</template>
<template #install-from-url>
<LinkIcon aria-hidden="true" /> Upload from .zip URL
</template>
<template #install-cf-pack>
<CurseForgeIcon aria-hidden="true" /> Install CurseForge pack
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</header>
</template>
<script setup lang="ts">
import {
LinkIcon,
CurseForgeIcon,
FileArchiveIcon,
BoxIcon,
PlusIcon,
UploadIcon,
DropdownIcon,
FolderOpenIcon,
SearchIcon,
HomeIcon,
ChevronRightIcon,
FilterIcon,
} from "@modrinth/assets";
import { ButtonStyled, OverflowMenu } from "@modrinth/ui";
import { ref, computed } from "vue";
import { useIntersectionObserver } from "@vueuse/core";
BoxIcon,
ChevronRightIcon,
CurseForgeIcon,
DropdownIcon,
FileArchiveIcon,
FilterIcon,
FolderOpenIcon,
HomeIcon,
LinkIcon,
PlusIcon,
SearchIcon,
UploadIcon,
} from '@modrinth/assets'
import { ButtonStyled, OverflowMenu } from '@modrinth/ui'
import { useIntersectionObserver } from '@vueuse/core'
import { computed, ref } from 'vue'
const props = defineProps<{
breadcrumbSegments: string[];
searchQuery: string;
currentFilter: string;
baseId: string;
}>();
breadcrumbSegments: string[]
searchQuery: string
currentFilter: string
baseId: string
}>()
defineEmits<{
(e: "navigate", index: number): void;
(e: "create", type: "file" | "directory"): void;
(e: "upload" | "upload-zip"): void;
(e: "unzip-from-url", cf: boolean): void;
(e: "update:searchQuery", value: string): void;
(e: "filter", type: string): void;
}>();
(e: 'navigate', index: number): void
(e: 'create', type: 'file' | 'directory'): void
(e: 'upload' | 'upload-zip'): void
(e: 'unzip-from-url', cf: boolean): void
(e: 'update:searchQuery' | 'filter', value: string): void
}>()
const pyroFilesSentinel = ref<HTMLElement | null>(null);
const isStuck = ref(false);
const pyroFilesSentinel = ref<HTMLElement | null>(null)
const isStuck = ref(false)
useIntersectionObserver(
pyroFilesSentinel,
([{ isIntersecting }]) => {
isStuck.value = !isIntersecting;
},
{ threshold: [0, 1] },
);
pyroFilesSentinel,
([{ isIntersecting }]) => {
isStuck.value = !isIntersecting
},
{ threshold: [0, 1] },
)
const filterLabel = computed(() => {
switch (props.currentFilter) {
case "filesOnly":
return "Files only";
case "foldersOnly":
return "Folders only";
default:
return "Show all";
}
});
switch (props.currentFilter) {
case 'filesOnly':
return 'Files only'
case 'foldersOnly':
return 'Folders only'
default:
return 'Show all'
}
})
</script>
<style scoped>
.sentinel {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
visibility: hidden;
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
visibility: hidden;
}
.breadcrumb-move,
.breadcrumb-enter-active,
.breadcrumb-leave-active {
transition: all 0.2s ease;
transition: all 0.2s ease;
}
.breadcrumb-enter-from {
opacity: 0;
transform: translateX(-10px) scale(0.9);
opacity: 0;
transform: translateX(-10px) scale(0.9);
}
.breadcrumb-leave-to {
opacity: 0;
transform: translateX(-10px) scale(0.8);
filter: blur(4px);
opacity: 0;
transform: translateX(-10px) scale(0.8);
filter: blur(4px);
}
.breadcrumb-leave-active {
position: relative;
pointer-events: none;
position: relative;
pointer-events: none;
}
.breadcrumb-move {
z-index: 1;
z-index: 1;
}
</style>

View File

@@ -1,106 +1,104 @@
<template>
<div
class="fixed"
:style="{
transform: `translateY(${isAtBottom ? '-100%' : '0'})`,
top: `${y}px`,
left: `${x}px`,
}"
>
<Transition>
<div
v-if="item"
id="item-context-menu"
ref="ctxRef"
:style="{
border: '1px solid var(--color-divider)',
borderRadius: 'var(--radius-lg)',
backgroundColor: 'var(--color-raised-bg)',
padding: 'var(--gap-sm)',
boxShadow: 'var(--shadow-floating)',
gap: 'var(--gap-xs)',
width: 'max-content',
}"
class="flex h-fit w-fit select-none flex-col"
>
<button
class="btn btn-transparent flex !w-full items-center"
@click="$emit('rename', item)"
>
<EditIcon class="h-5 w-5" />
Rename
</button>
<button class="btn btn-transparent flex !w-full items-center" @click="$emit('move', item)">
<RightArrowIcon />
Move
</button>
<button
v-if="item.type !== 'directory'"
class="btn btn-transparent flex !w-full items-center"
@click="$emit('download', item)"
>
<DownloadIcon class="h-5 w-5" />
Download
</button>
<button
class="btn btn-transparent btn-red flex !w-full items-center"
@click="$emit('delete', item)"
>
<TrashIcon class="h-5 w-5" />
Delete
</button>
</div>
</Transition>
</div>
<div
class="fixed"
:style="{
transform: `translateY(${isAtBottom ? '-100%' : '0'})`,
top: `${y}px`,
left: `${x}px`,
}"
>
<Transition>
<div
v-if="item"
id="item-context-menu"
ref="ctxRef"
:style="{
border: '1px solid var(--color-divider)',
borderRadius: 'var(--radius-lg)',
backgroundColor: 'var(--color-raised-bg)',
padding: 'var(--gap-sm)',
boxShadow: 'var(--shadow-floating)',
gap: 'var(--gap-xs)',
width: 'max-content',
}"
class="flex h-fit w-fit select-none flex-col"
>
<button
class="btn btn-transparent flex !w-full items-center"
@click="$emit('rename', item)"
>
<EditIcon class="h-5 w-5" />
Rename
</button>
<button class="btn btn-transparent flex !w-full items-center" @click="$emit('move', item)">
<RightArrowIcon />
Move
</button>
<button
v-if="item.type !== 'directory'"
class="btn btn-transparent flex !w-full items-center"
@click="$emit('download', item)"
>
<DownloadIcon class="h-5 w-5" />
Download
</button>
<button
class="btn btn-transparent btn-red flex !w-full items-center"
@click="$emit('delete', item)"
>
<TrashIcon class="h-5 w-5" />
Delete
</button>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { EditIcon, DownloadIcon, TrashIcon, RightArrowIcon } from "@modrinth/assets";
import { DownloadIcon, EditIcon, RightArrowIcon, TrashIcon } from '@modrinth/assets'
import { ref } from 'vue'
interface FileItem {
type: string;
name: string;
[key: string]: any;
type: string
name: string
[key: string]: any
}
defineProps<{
item: FileItem | null;
x: number;
y: number;
isAtBottom: boolean;
}>();
item: FileItem | null
x: number
y: number
isAtBottom: boolean
}>()
const ctxRef = ref<HTMLElement | null>(null);
const ctxRef = ref<HTMLElement | null>(null)
defineEmits<{
(e: "rename", item: FileItem): void;
(e: "move", item: FileItem): void;
(e: "download", item: FileItem): void;
(e: "delete", item: FileItem): void;
}>();
(e: 'rename' | 'move' | 'download' | 'delete', item: FileItem): void
}>()
defineExpose({
ctxRef,
});
ctxRef,
})
</script>
<style scoped>
#item-context-menu {
transition:
transform 0.1s ease,
opacity 0.1s ease;
transform-origin: top left;
transition:
transform 0.1s ease,
opacity 0.1s ease;
transform-origin: top left;
}
#item-context-menu.v-enter-active,
#item-context-menu.v-leave-active {
transform: scale(1);
opacity: 1;
transform: scale(1);
opacity: 1;
}
#item-context-menu.v-enter-from,
#item-context-menu.v-leave-to {
transform: scale(0.5);
opacity: 0;
transform: scale(0.5);
opacity: 0;
}
</style>

View File

@@ -1,96 +1,96 @@
<template>
<NewModal ref="modal" :header="`Creating a ${displayType}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Name</div>
<input
ref="createInput"
v-model="itemName"
autofocus
type="text"
class="bg-bg-input w-full rounded-lg p-4"
:placeholder="`e.g. ${type === 'file' ? 'config.yml' : 'plugins'}`"
required
/>
<div v-if="submitted && error" class="text-red">{{ error }}</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!!error" type="submit">
<PlusIcon class="h-5 w-5" />
Create {{ displayType }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
<NewModal ref="modal" :header="`Creating a ${displayType}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Name</div>
<input
ref="createInput"
v-model="itemName"
autofocus
type="text"
class="bg-bg-input w-full rounded-lg p-4"
:placeholder="`e.g. ${type === 'file' ? 'config.yml' : 'plugins'}`"
required
/>
<div v-if="submitted && error" class="text-red">{{ error }}</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!!error" type="submit">
<PlusIcon class="h-5 w-5" />
Create {{ displayType }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { PlusIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { ref, computed, nextTick } from "vue";
import { PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
const props = defineProps<{
type: "file" | "directory";
}>();
type: 'file' | 'directory'
}>()
const emit = defineEmits<{
(e: "create", name: string): void;
}>();
(e: 'create', name: string): void
}>()
const modal = ref<typeof NewModal>();
const displayType = computed(() => (props.type === "directory" ? "folder" : props.type));
const createInput = ref<HTMLInputElement | null>(null);
const itemName = ref("");
const submitted = ref(false);
const modal = ref<typeof NewModal>()
const displayType = computed(() => (props.type === 'directory' ? 'folder' : props.type))
const createInput = ref<HTMLInputElement | null>(null)
const itemName = ref('')
const submitted = ref(false)
const error = computed(() => {
if (!itemName.value) {
return "Name is required.";
}
if (props.type === "file") {
const validPattern = /^[a-zA-Z0-9-_.\s]+$/;
if (!validPattern.test(itemName.value)) {
return "Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.";
}
} else {
const validPattern = /^[a-zA-Z0-9-_\s]+$/;
if (!validPattern.test(itemName.value)) {
return "Name must contain only alphanumeric characters, dashes, underscores, or spaces.";
}
}
return "";
});
if (!itemName.value) {
return 'Name is required.'
}
if (props.type === 'file') {
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
if (!validPattern.test(itemName.value)) {
return 'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.'
}
} else {
const validPattern = /^[a-zA-Z0-9-_\s]+$/
if (!validPattern.test(itemName.value)) {
return 'Name must contain only alphanumeric characters, dashes, underscores, or spaces.'
}
}
return ''
})
const handleSubmit = () => {
submitted.value = true;
if (!error.value) {
emit("create", itemName.value);
hide();
}
};
submitted.value = true
if (!error.value) {
emit('create', itemName.value)
hide()
}
}
const show = () => {
itemName.value = "";
submitted.value = false;
modal.value?.show();
nextTick(() => {
setTimeout(() => {
createInput.value?.focus();
}, 100);
});
};
itemName.value = ''
submitted.value = false
modal.value?.show()
nextTick(() => {
setTimeout(() => {
createInput.value?.focus()
}, 100)
})
}
const hide = () => {
modal.value?.hide();
};
modal.value?.hide()
}
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>

View File

@@ -1,77 +1,77 @@
<template>
<NewModal ref="modal" danger :header="`Deleting ${item?.type}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div
class="relative flex w-full items-center gap-2 rounded-2xl border border-solid border-[#cb224436] bg-[#f57b7b0e] p-6 shadow-md dark:border-0 dark:bg-[#0e0e0ea4]"
>
<div
class="flex h-9 w-9 items-center justify-center rounded-full bg-[#3f1818a4] p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
>
<FolderOpenIcon v-if="item?.type === 'directory'" class="h-5 w-5" />
<FileIcon v-else-if="item?.type === 'file'" class="h-5 w-5" />
</div>
<div class="flex flex-col">
<span class="font-bold group-hover:text-contrast">{{ item?.name }}</span>
<span
v-if="item?.type === 'directory'"
class="text-xs text-secondary group-hover:text-primary"
>
{{ item?.count }} items
</span>
<span v-else class="text-xs text-secondary group-hover:text-primary">
{{ ((item?.size ?? 0) / 1024 / 1024).toFixed(2) }} MB
</span>
</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="red">
<button type="submit">
<TrashIcon class="h-5 w-5" />
Delete {{ item?.type }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
<NewModal ref="modal" danger :header="`Deleting ${item?.type}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div
class="relative flex w-full items-center gap-2 rounded-2xl border border-solid border-[#cb224436] bg-[#f57b7b0e] p-6 shadow-md dark:border-0 dark:bg-[#0e0e0ea4]"
>
<div
class="flex h-9 w-9 items-center justify-center rounded-full bg-[#3f1818a4] p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
>
<FolderOpenIcon v-if="item?.type === 'directory'" class="h-5 w-5" />
<FileIcon v-else-if="item?.type === 'file'" class="h-5 w-5" />
</div>
<div class="flex flex-col">
<span class="font-bold group-hover:text-contrast">{{ item?.name }}</span>
<span
v-if="item?.type === 'directory'"
class="text-xs text-secondary group-hover:text-primary"
>
{{ item?.count }} items
</span>
<span v-else class="text-xs text-secondary group-hover:text-primary">
{{ ((item?.size ?? 0) / 1024 / 1024).toFixed(2) }} MB
</span>
</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="red">
<button type="submit">
<TrashIcon class="h-5 w-5" />
Delete {{ item?.type }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { FileIcon, FolderOpenIcon, TrashIcon, XIcon } from "@modrinth/assets";
import { FileIcon, FolderOpenIcon, TrashIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
defineProps<{
item: {
name: string;
type: string;
count?: number;
size?: number;
} | null;
}>();
item: {
name: string
type: string
count?: number
size?: number
} | null
}>()
const emit = defineEmits<{
(e: "delete"): void;
}>();
(e: 'delete'): void
}>()
const modal = ref<typeof NewModal>();
const modal = ref<typeof NewModal>()
const handleSubmit = () => {
emit("delete");
hide();
};
emit('delete')
hide()
}
const show = () => {
modal.value?.show();
};
modal.value?.show()
}
const hide = () => {
modal.value?.hide();
};
modal.value?.hide()
}
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>

View File

@@ -1,140 +1,138 @@
<template>
<header
data-pyro-files-state="editing"
class="flex h-12 select-none items-center justify-between rounded-t-2xl bg-table-alternateRow p-3"
aria-label="File editor navigation"
>
<nav
aria-label="Breadcrumb navigation"
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
>
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
<li class="-ml-1 flex-shrink-0">
<ButtonStyled type="transparent">
<button
v-tooltip="'Back to home'"
type="button"
class="mr-2 grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
@click="goHome"
>
<span
class="grid size-8 place-content-center rounded-full bg-button-bg p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
>
<HomeIcon class="h-5 w-5" />
<span class="sr-only">Home</span>
</span>
</button>
</ButtonStyled>
</li>
<li class="m-0 -ml-2 p-0">
<ol class="m-0 flex items-center p-0">
<li
v-for="(segment, index) in breadcrumbSegments"
:key="index"
class="flex items-center text-sm"
>
<ButtonStyled type="transparent">
<button
class="cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
:class="{ '!text-contrast': index === breadcrumbSegments.length - 1 }"
@click="$emit('navigate', index)"
>
{{ segment || "" }}
</button>
</ButtonStyled>
<ChevronRightIcon
v-if="index < breadcrumbSegments.length"
class="size-4 text-secondary"
aria-hidden="true"
/>
</li>
<li class="flex items-center px-3 text-sm">
<span class="font-semibold !text-contrast" aria-current="location">{{
fileName
}}</span>
</li>
</ol>
</li>
</ol>
</nav>
<div v-if="!isImage" class="flex gap-2">
<Button
v-if="isLogFile"
v-tooltip="'Share to mclo.gs'"
icon-only
transparent
aria-label="Share to mclo.gs"
@click="$emit('share')"
>
<ShareIcon />
</Button>
<ButtonStyled type="transparent">
<UiServersTeleportOverflowMenu
position="bottom"
direction="left"
aria-label="Save file"
:options="[
{ id: 'save', action: () => $emit('save') },
{ id: 'save-as', action: () => $emit('save-as') },
{ id: 'save&restart', action: () => $emit('save-restart') },
]"
>
<SaveIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #save> <SaveIcon aria-hidden="true" /> Save </template>
<template #save-as> <SaveIcon aria-hidden="true" /> Save as... </template>
<template #save&restart>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z"
clip-rule="evenodd"
/>
</svg>
Save & restart
</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
</header>
<header
data-pyro-files-state="editing"
class="flex h-12 select-none items-center justify-between rounded-t-2xl bg-table-alternateRow p-3"
aria-label="File editor navigation"
>
<nav
aria-label="Breadcrumb navigation"
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
>
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
<li class="-ml-1 flex-shrink-0">
<ButtonStyled type="transparent">
<button
v-tooltip="'Back to home'"
type="button"
class="mr-2 grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
@click="goHome"
>
<span
class="grid size-8 place-content-center rounded-full bg-button-bg p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
>
<HomeIcon class="h-5 w-5" />
<span class="sr-only">Home</span>
</span>
</button>
</ButtonStyled>
</li>
<li class="m-0 -ml-2 p-0">
<ol class="m-0 flex items-center p-0">
<li
v-for="(segment, index) in breadcrumbSegments"
:key="index"
class="flex items-center text-sm"
>
<ButtonStyled type="transparent">
<button
class="cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
:class="{
'!text-contrast': index === breadcrumbSegments.length - 1,
}"
@click="$emit('navigate', index)"
>
{{ segment || '' }}
</button>
</ButtonStyled>
<ChevronRightIcon
v-if="index < breadcrumbSegments.length"
class="size-4 text-secondary"
aria-hidden="true"
/>
</li>
<li class="flex items-center px-3 text-sm">
<span class="font-semibold !text-contrast" aria-current="location">{{
fileName
}}</span>
</li>
</ol>
</li>
</ol>
</nav>
<div v-if="!isImage" class="flex gap-2">
<Button
v-if="isLogFile"
v-tooltip="'Share to mclo.gs'"
icon-only
transparent
aria-label="Share to mclo.gs"
@click="$emit('share')"
>
<ShareIcon />
</Button>
<ButtonStyled type="transparent">
<UiServersTeleportOverflowMenu
position="bottom"
direction="left"
aria-label="Save file"
:options="[
{ id: 'save', action: () => $emit('save') },
{ id: 'save-as', action: () => $emit('save-as') },
{ id: 'save&restart', action: () => $emit('save-restart') },
]"
>
<SaveIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #save> <SaveIcon aria-hidden="true" /> Save </template>
<template #save-as> <SaveIcon aria-hidden="true" /> Save as... </template>
<template #save&restart>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z"
clip-rule="evenodd"
/>
</svg>
Save & restart
</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
</header>
</template>
<script setup lang="ts">
import { DropdownIcon, SaveIcon, ShareIcon, HomeIcon, ChevronRightIcon } from "@modrinth/assets";
import { Button, ButtonStyled } from "@modrinth/ui";
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ChevronRightIcon, DropdownIcon, HomeIcon, SaveIcon, ShareIcon } from '@modrinth/assets'
import { Button, ButtonStyled } from '@modrinth/ui'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const props = defineProps<{
breadcrumbSegments: string[];
fileName?: string;
isImage: boolean;
filePath?: string;
}>();
breadcrumbSegments: string[]
fileName?: string
isImage: boolean
filePath?: string
}>()
const isLogFile = computed(() => {
return props.filePath?.startsWith("logs") || props.filePath?.endsWith(".log");
});
return props.filePath?.startsWith('logs') || props.filePath?.endsWith('.log')
})
const route = useRoute();
const router = useRouter();
const route = useRoute()
const router = useRouter()
const emit = defineEmits<{
(e: "cancel"): void;
(e: "save"): void;
(e: "save-as"): void;
(e: "save-restart"): void;
(e: "share"): void;
(e: "navigate", index: number): void;
}>();
(e: 'cancel' | 'save' | 'save-as' | 'save-restart' | 'share'): void
(e: 'navigate', index: number): void
}>()
const goHome = () => {
emit("cancel");
router.push({ path: "/servers/manage/" + route.params.id + "/files" });
};
emit('cancel')
router.push({ path: '/servers/manage/' + route.params.id + '/files' })
}
</script>

View File

@@ -1,178 +1,178 @@
<template>
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center">
<div
ref="container"
class="relative w-full flex-grow cursor-grab overflow-hidden rounded-b-2xl bg-black active:cursor-grabbing"
@mousedown="startPan"
@mousemove="handlePan"
@mouseup="stopPan"
@mouseleave="stopPan"
@wheel.prevent="handleWheel"
>
<div v-if="state.isLoading" />
<div
v-if="state.hasError"
class="flex h-full w-full flex-col items-center justify-center gap-8"
>
<UiServersIconsPanelErrorIcon />
<p class="m-0">{{ state.errorMessage || "Invalid or empty image file." }}</p>
</div>
<img
v-show="isReady"
ref="imageRef"
:src="imageObjectUrl"
class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform"
:style="imageStyle"
alt="Viewed image"
@load="handleImageLoad"
@error="handleImageError"
/>
</div>
<div
v-if="!state.hasError"
class="absolute bottom-0 mb-2 flex w-fit justify-center gap-2 space-x-4 rounded-2xl bg-bg p-2"
>
<ButtonStyled type="transparent" @click="zoom(ZOOM_IN_FACTOR)">
<button v-tooltip="'Zoom in'">
<ZoomInIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" @click="zoom(ZOOM_OUT_FACTOR)">
<button v-tooltip="'Zoom out'">
<ZoomOutIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" @click="reset">
<button>
<span class="font-mono">{{ Math.round(state.scale * 100) }}%</span>
<span class="ml-4 text-sm text-blue">Reset</span>
</button>
</ButtonStyled>
</div>
</div>
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center">
<div
ref="container"
class="relative w-full flex-grow cursor-grab overflow-hidden rounded-b-2xl bg-black active:cursor-grabbing"
@mousedown="startPan"
@mousemove="handlePan"
@mouseup="stopPan"
@mouseleave="stopPan"
@wheel.prevent="handleWheel"
>
<div v-if="state.isLoading" />
<div
v-if="state.hasError"
class="flex h-full w-full flex-col items-center justify-center gap-8"
>
<UiServersIconsPanelErrorIcon />
<p class="m-0">{{ state.errorMessage || 'Invalid or empty image file.' }}</p>
</div>
<img
v-show="isReady"
ref="imageRef"
:src="imageObjectUrl"
class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform"
:style="imageStyle"
alt="Viewed image"
@load="handleImageLoad"
@error="handleImageError"
/>
</div>
<div
v-if="!state.hasError"
class="absolute bottom-0 mb-2 flex w-fit justify-center gap-2 space-x-4 rounded-2xl bg-bg p-2"
>
<ButtonStyled type="transparent" @click="zoom(ZOOM_IN_FACTOR)">
<button v-tooltip="'Zoom in'">
<ZoomInIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" @click="zoom(ZOOM_OUT_FACTOR)">
<button v-tooltip="'Zoom out'">
<ZoomOutIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" @click="reset">
<button>
<span class="font-mono">{{ Math.round(state.scale * 100) }}%</span>
<span class="ml-4 text-sm text-blue">Reset</span>
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { ZoomInIcon, ZoomOutIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { ZoomInIcon, ZoomOutIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 5;
const ZOOM_IN_FACTOR = 1.2;
const ZOOM_OUT_FACTOR = 0.8;
const INITIAL_SCALE = 0.5;
const MAX_IMAGE_DIMENSION = 4096;
const ZOOM_MIN = 0.1
const ZOOM_MAX = 5
const ZOOM_IN_FACTOR = 1.2
const ZOOM_OUT_FACTOR = 0.8
const INITIAL_SCALE = 0.5
const MAX_IMAGE_DIMENSION = 4096
const props = defineProps<{
imageBlob: Blob;
}>();
imageBlob: Blob
}>()
const state = ref({
scale: INITIAL_SCALE,
translateX: 0,
translateY: 0,
isPanning: false,
startX: 0,
startY: 0,
isLoading: false,
hasError: false,
errorMessage: "",
});
scale: INITIAL_SCALE,
translateX: 0,
translateY: 0,
isPanning: false,
startX: 0,
startY: 0,
isLoading: false,
hasError: false,
errorMessage: '',
})
const imageRef = ref<HTMLImageElement | null>(null);
const container = ref<HTMLElement | null>(null);
const imageObjectUrl = ref("");
const rafId = ref(0);
const imageRef = ref<HTMLImageElement | null>(null)
const container = ref<HTMLElement | null>(null)
const imageObjectUrl = ref('')
const rafId = ref(0)
const isReady = computed(() => !state.value.isLoading && !state.value.hasError);
const isReady = computed(() => !state.value.isLoading && !state.value.hasError)
const imageStyle = computed(() => ({
transform: `translate(-50%, -50%) scale(${state.value.scale}) translate(${state.value.translateX}px, ${state.value.translateY}px)`,
transition: state.value.isPanning ? "none" : "transform 0.3s ease-out",
}));
transform: `translate(-50%, -50%) scale(${state.value.scale}) translate(${state.value.translateX}px, ${state.value.translateY}px)`,
transition: state.value.isPanning ? 'none' : 'transform 0.3s ease-out',
}))
const validateImageDimensions = (img: HTMLImageElement): boolean => {
if (img.naturalWidth > MAX_IMAGE_DIMENSION || img.naturalHeight > MAX_IMAGE_DIMENSION) {
state.value.hasError = true;
state.value.errorMessage = `Image too large to view (max ${MAX_IMAGE_DIMENSION}x${MAX_IMAGE_DIMENSION} pixels)`;
return false;
}
return true;
};
if (img.naturalWidth > MAX_IMAGE_DIMENSION || img.naturalHeight > MAX_IMAGE_DIMENSION) {
state.value.hasError = true
state.value.errorMessage = `Image too large to view (max ${MAX_IMAGE_DIMENSION}x${MAX_IMAGE_DIMENSION} pixels)`
return false
}
return true
}
const updateImageUrl = (blob: Blob) => {
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value);
imageObjectUrl.value = URL.createObjectURL(blob);
};
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
imageObjectUrl.value = URL.createObjectURL(blob)
}
const handleImageLoad = () => {
if (!imageRef.value || !validateImageDimensions(imageRef.value)) {
state.value.isLoading = false;
return;
}
state.value.isLoading = false;
reset();
};
if (!imageRef.value || !validateImageDimensions(imageRef.value)) {
state.value.isLoading = false
return
}
state.value.isLoading = false
reset()
}
const handleImageError = () => {
state.value.isLoading = false;
state.value.hasError = true;
state.value.errorMessage = "Failed to load image";
};
state.value.isLoading = false
state.value.hasError = true
state.value.errorMessage = 'Failed to load image'
}
const zoom = (factor: number) => {
const newScale = state.value.scale * factor;
state.value.scale = Math.max(ZOOM_MIN, Math.min(newScale, ZOOM_MAX));
};
const newScale = state.value.scale * factor
state.value.scale = Math.max(ZOOM_MIN, Math.min(newScale, ZOOM_MAX))
}
const reset = () => {
state.value.scale = INITIAL_SCALE;
state.value.translateX = 0;
state.value.translateY = 0;
};
state.value.scale = INITIAL_SCALE
state.value.translateX = 0
state.value.translateY = 0
}
const startPan = (e: MouseEvent) => {
state.value.isPanning = true;
state.value.startX = e.clientX - state.value.translateX;
state.value.startY = e.clientY - state.value.translateY;
};
state.value.isPanning = true
state.value.startX = e.clientX - state.value.translateX
state.value.startY = e.clientY - state.value.translateY
}
const handlePan = (e: MouseEvent) => {
if (!state.value.isPanning) return;
cancelAnimationFrame(rafId.value);
rafId.value = requestAnimationFrame(() => {
state.value.translateX = e.clientX - state.value.startX;
state.value.translateY = e.clientY - state.value.startY;
});
};
if (!state.value.isPanning) return
cancelAnimationFrame(rafId.value)
rafId.value = requestAnimationFrame(() => {
state.value.translateX = e.clientX - state.value.startX
state.value.translateY = e.clientY - state.value.startY
})
}
const stopPan = () => {
state.value.isPanning = false;
};
state.value.isPanning = false
}
const handleWheel = (e: WheelEvent) => {
const delta = e.deltaY * -0.001;
const factor = 1 + delta;
zoom(factor);
};
const delta = e.deltaY * -0.001
const factor = 1 + delta
zoom(factor)
}
watch(
() => props.imageBlob,
(newBlob) => {
if (!newBlob) return;
state.value.isLoading = true;
state.value.hasError = false;
updateImageUrl(newBlob);
},
);
() => props.imageBlob,
(newBlob) => {
if (!newBlob) return
state.value.isLoading = true
state.value.hasError = false
updateImageUrl(newBlob)
},
)
onMounted(() => {
if (props.imageBlob) updateImageUrl(props.imageBlob);
});
if (props.imageBlob) updateImageUrl(props.imageBlob)
})
onUnmounted(() => {
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value);
cancelAnimationFrame(rafId.value);
});
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
cancelAnimationFrame(rafId.value)
})
</script>

View File

@@ -1,65 +1,65 @@
<template>
<div
aria-hidden="true"
class="sticky top-12 z-20 flex h-8 w-full select-none flex-row items-center border-0 border-b border-solid border-bg-raised bg-bg px-3 text-xs font-bold uppercase"
>
<div class="min-w-[48px]"></div>
<button
class="flex h-full w-full appearance-none items-center gap-1 bg-transparent text-left hover:text-brand"
@click="$emit('sort', 'name')"
>
<span>Name</span>
<ChevronUpIcon v-if="sortField === 'name' && !sortDesc" class="h-3 w-3" aria-hidden="true" />
<ChevronDownIcon v-if="sortField === 'name' && sortDesc" class="h-3 w-3" aria-hidden="true" />
</button>
<div class="flex shrink-0 gap-4 text-right md:gap-12">
<button
class="hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
@click="$emit('sort', 'created')"
>
<span>Created</span>
<ChevronUpIcon
v-if="sortField === 'created' && !sortDesc"
class="h-3 w-3"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'created' && sortDesc"
class="h-3 w-3"
aria-hidden="true"
/>
</button>
<button
class="mr-4 hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
@click="$emit('sort', 'modified')"
>
<span>Modified</span>
<ChevronUpIcon
v-if="sortField === 'modified' && !sortDesc"
class="h-3 w-3"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'modified' && sortDesc"
class="h-3 w-3"
aria-hidden="true"
/>
</button>
<div class="min-w-[24px]"></div>
</div>
</div>
<div
aria-hidden="true"
class="sticky top-12 z-20 flex h-8 w-full select-none flex-row items-center border-0 border-b border-solid border-bg-raised bg-bg px-3 text-xs font-bold uppercase"
>
<div class="min-w-[48px]"></div>
<button
class="flex h-full w-full appearance-none items-center gap-1 bg-transparent text-left hover:text-brand"
@click="$emit('sort', 'name')"
>
<span>Name</span>
<ChevronUpIcon v-if="sortField === 'name' && !sortDesc" class="h-3 w-3" aria-hidden="true" />
<ChevronDownIcon v-if="sortField === 'name' && sortDesc" class="h-3 w-3" aria-hidden="true" />
</button>
<div class="flex shrink-0 gap-4 text-right md:gap-12">
<button
class="hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
@click="$emit('sort', 'created')"
>
<span>Created</span>
<ChevronUpIcon
v-if="sortField === 'created' && !sortDesc"
class="h-3 w-3"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'created' && sortDesc"
class="h-3 w-3"
aria-hidden="true"
/>
</button>
<button
class="mr-4 hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
@click="$emit('sort', 'modified')"
>
<span>Modified</span>
<ChevronUpIcon
v-if="sortField === 'modified' && !sortDesc"
class="h-3 w-3"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'modified' && sortDesc"
class="h-3 w-3"
aria-hidden="true"
/>
</button>
<div class="min-w-[24px]"></div>
</div>
</div>
</template>
<script setup lang="ts">
import ChevronDownIcon from "./icons/ChevronDownIcon.vue";
import ChevronUpIcon from "./icons/ChevronUpIcon.vue";
import ChevronDownIcon from './icons/ChevronDownIcon.vue'
import ChevronUpIcon from './icons/ChevronUpIcon.vue'
defineProps<{
sortField: string;
sortDesc: boolean;
}>();
sortField: string
sortDesc: boolean
}>()
defineEmits<{
(e: "sort", field: string): void;
}>();
(e: 'sort', field: string): void
}>()
</script>

View File

@@ -1,82 +1,82 @@
<template>
<NewModal ref="modal" :header="`Moving ${item?.name}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<input
ref="destinationInput"
v-model="destination"
autofocus
type="text"
class="bg-bg-input w-full rounded-lg p-4"
placeholder="e.g. /mods/modname"
required
/>
</div>
<div class="flex items-center gap-2 text-nowrap">
New location:
<div class="w-full rounded-lg bg-table-alternateRow p-2 font-bold text-contrast">
<span class="text-secondary">/root</span>{{ newpath }}
</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="brand">
<button type="submit">
<ArrowBigUpDashIcon class="h-5 w-5" />
Move
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
<NewModal ref="modal" :header="`Moving ${item?.name}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<input
ref="destinationInput"
v-model="destination"
autofocus
type="text"
class="bg-bg-input w-full rounded-lg p-4"
placeholder="e.g. /mods/modname"
required
/>
</div>
<div class="flex items-center gap-2 text-nowrap">
New location:
<div class="w-full rounded-lg bg-table-alternateRow p-2 font-bold text-contrast">
<span class="text-secondary">/root</span>{{ newpath }}
</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="brand">
<button type="submit">
<ArrowBigUpDashIcon class="h-5 w-5" />
Move
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { ArrowBigUpDashIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { ref, nextTick, computed } from "vue";
import { ArrowBigUpDashIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
const destinationInput = ref<HTMLInputElement | null>(null);
const destinationInput = ref<HTMLInputElement | null>(null)
const props = defineProps<{
item: { name: string } | null;
currentPath: string;
}>();
item: { name: string } | null
currentPath: string
}>()
const emit = defineEmits<{
(e: "move", destination: string): void;
}>();
(e: 'move', destination: string): void
}>()
const modal = ref<typeof NewModal>();
const destination = ref("");
const modal = ref<typeof NewModal>()
const destination = ref('')
const newpath = computed(() => {
const path = destination.value.replace("//", "/");
return path.startsWith("/") ? path : `/${path}`;
});
const path = destination.value.replace('//', '/')
return path.startsWith('/') ? path : `/${path}`
})
const handleSubmit = () => {
emit("move", newpath.value);
hide();
};
emit('move', newpath.value)
hide()
}
const show = () => {
destination.value = props.currentPath;
modal.value?.show();
nextTick(() => {
setTimeout(() => {
destinationInput.value?.focus();
}, 100);
});
};
destination.value = props.currentPath
modal.value?.show()
nextTick(() => {
setTimeout(() => {
destinationInput.value?.focus()
}, 100)
})
}
const hide = () => {
modal.value?.hide();
};
modal.value?.hide()
}
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>

View File

@@ -1,94 +1,94 @@
<template>
<NewModal ref="modal" :header="`Renaming ${item?.type}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Name</div>
<input
ref="renameInput"
v-model="itemName"
autofocus
type="text"
class="bg-bg-input w-full rounded-lg p-4"
required
/>
<div v-if="submitted && error" class="text-red">{{ error }}</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!!error" type="submit">
<EditIcon class="h-5 w-5" />
Rename
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
<NewModal ref="modal" :header="`Renaming ${item?.type}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Name</div>
<input
ref="renameInput"
v-model="itemName"
autofocus
type="text"
class="bg-bg-input w-full rounded-lg p-4"
required
/>
<div v-if="submitted && error" class="text-red">{{ error }}</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!!error" type="submit">
<EditIcon class="h-5 w-5" />
Rename
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { EditIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { ref, computed, nextTick } from "vue";
import { EditIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
const props = defineProps<{
item: { name: string; type: string } | null;
}>();
item: { name: string; type: string } | null
}>()
const emit = defineEmits<{
(e: "rename", newName: string): void;
}>();
(e: 'rename', newName: string): void
}>()
const modal = ref<typeof NewModal>();
const renameInput = ref<HTMLInputElement | null>(null);
const itemName = ref("");
const submitted = ref(false);
const modal = ref<typeof NewModal>()
const renameInput = ref<HTMLInputElement | null>(null)
const itemName = ref('')
const submitted = ref(false)
const error = computed(() => {
if (!itemName.value) {
return "Name is required.";
}
if (props.item?.type === "file") {
const validPattern = /^[a-zA-Z0-9-_.\s]+$/;
if (!validPattern.test(itemName.value)) {
return "Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.";
}
} else {
const validPattern = /^[a-zA-Z0-9-_\s]+$/;
if (!validPattern.test(itemName.value)) {
return "Name must contain only alphanumeric characters, dashes, underscores, or spaces.";
}
}
return "";
});
if (!itemName.value) {
return 'Name is required.'
}
if (props.item?.type === 'file') {
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
if (!validPattern.test(itemName.value)) {
return 'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.'
}
} else {
const validPattern = /^[a-zA-Z0-9-_\s]+$/
if (!validPattern.test(itemName.value)) {
return 'Name must contain only alphanumeric characters, dashes, underscores, or spaces.'
}
}
return ''
})
const handleSubmit = () => {
submitted.value = true;
if (!error.value) {
emit("rename", itemName.value);
hide();
}
};
submitted.value = true
if (!error.value) {
emit('rename', itemName.value)
hide()
}
}
const show = (item: { name: string; type: string }) => {
itemName.value = item.name;
submitted.value = false;
modal.value?.show();
nextTick(() => {
setTimeout(() => {
renameInput.value?.focus();
}, 100);
});
};
itemName.value = item.name
submitted.value = false
modal.value?.show()
nextTick(() => {
setTimeout(() => {
renameInput.value?.focus()
}, 100)
})
}
const hide = () => {
modal.value?.hide();
};
modal.value?.hide()
}
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>

View File

@@ -1,56 +1,56 @@
<template>
<ConfirmModal
ref="modal"
title="Do you want to overwrite these conflicting files?"
:proceed-label="`Overwrite`"
:proceed-icon="CheckIcon"
@proceed="proceed"
>
<div class="flex max-w-[30rem] flex-col gap-4">
<p class="m-0 font-semibold leading-normal">
<template v-if="hasMany">
Over 100 files will be overwritten if you proceed with extraction; here is just some of
them:
</template>
<template v-else>
The following {{ files.length }} files already exist on your server, and will be
overwritten if you proceed with extraction:
</template>
</p>
<ul class="m-0 max-h-80 list-none overflow-auto rounded-2xl bg-bg px-4 py-3">
<li v-for="file in files" :key="file" class="flex items-center gap-1 py-1 font-medium">
<XIcon class="shrink-0 text-red" /> {{ file }}
</li>
</ul>
</div>
</ConfirmModal>
<ConfirmModal
ref="modal"
title="Do you want to overwrite these conflicting files?"
:proceed-label="`Overwrite`"
:proceed-icon="CheckIcon"
@proceed="proceed"
>
<div class="flex max-w-[30rem] flex-col gap-4">
<p class="m-0 font-semibold leading-normal">
<template v-if="hasMany">
Over 100 files will be overwritten if you proceed with extraction; here is just some of
them:
</template>
<template v-else>
The following {{ files.length }} files already exist on your server, and will be
overwritten if you proceed with extraction:
</template>
</p>
<ul class="m-0 max-h-80 list-none overflow-auto rounded-2xl bg-bg px-4 py-3">
<li v-for="file in files" :key="file" class="flex items-center gap-1 py-1 font-medium">
<XIcon class="shrink-0 text-red" /> {{ file }}
</li>
</ul>
</div>
</ConfirmModal>
</template>
<script setup lang="ts">
import { ConfirmModal } from "@modrinth/ui";
import { ref } from "vue";
import { XIcon, CheckIcon } from "@modrinth/assets";
import { CheckIcon, XIcon } from '@modrinth/assets'
import { ConfirmModal } from '@modrinth/ui'
import { ref } from 'vue'
const path = ref("");
const files = ref<string[]>([]);
const path = ref('')
const files = ref<string[]>([])
const emit = defineEmits<{
(e: "proceed", path: string): void;
}>();
(e: 'proceed', path: string): void
}>()
const modal = ref<typeof ConfirmModal>();
const modal = ref<typeof ConfirmModal>()
const hasMany = computed(() => files.value.length > 100);
const hasMany = computed(() => files.value.length > 100)
const show = (zipPath: string, conflictingFiles: string[]) => {
path.value = zipPath;
files.value = conflictingFiles;
modal.value?.show();
};
path.value = zipPath
files.value = conflictingFiles
modal.value?.show()
}
const proceed = () => {
emit("proceed", path.value);
};
emit('proceed', path.value)
}
defineExpose({ show });
defineExpose({ show })
</script>

View File

@@ -1,75 +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>
<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";
import { UploadIcon } from '@modrinth/assets'
import { ref } from 'vue'
const emit = defineEmits<{
(event: "filesDropped", files: File[]): void;
}>();
(event: 'filesDropped', files: File[]): void
}>()
defineProps<{
overlayClass?: string;
type?: string;
}>();
overlayClass?: string
type?: string
}>()
const isDragging = ref(false);
const dragCounter = ref(0);
const isDragging = ref(false)
const dragCounter = ref(0)
const handleDragEnter = (event: DragEvent) => {
event.preventDefault();
if (!event.dataTransfer?.types.includes("application/pyro-file-move")) {
dragCounter.value++;
isDragging.value = true;
}
};
event.preventDefault()
if (!event.dataTransfer?.types.includes('application/pyro-file-move')) {
dragCounter.value++
isDragging.value = true
}
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
};
event.preventDefault()
}
const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
dragCounter.value--;
if (dragCounter.value === 0) {
isDragging.value = false;
}
};
event.preventDefault()
dragCounter.value--
if (dragCounter.value === 0) {
isDragging.value = false
}
}
const handleDrop = (event: DragEvent) => {
event.preventDefault();
isDragging.value = false;
dragCounter.value = 0;
event.preventDefault()
isDragging.value = false
dragCounter.value = 0
const isInternalMove = event.dataTransfer?.types.includes("application/pyro-file-move");
if (isInternalMove) return;
const isInternalMove = event.dataTransfer?.types.includes('application/pyro-file-move')
if (isInternalMove) return
const files = event.dataTransfer?.files;
if (files) {
emit("filesDropped", Array.from(files));
}
};
const files = event.dataTransfer?.files
if (files) {
emit('filesDropped', Array.from(files))
}
}
</script>

View File

@@ -1,334 +1,335 @@
<template>
<div>
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
<div
ref="statusContentRef"
v-bind="$attrs"
:class="['flex flex-col p-4 text-sm text-contrast']"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 font-bold">
<FolderOpenIcon class="size-4" />
<span>
<span class="capitalize">
{{ props.fileType ? props.fileType : "File" }} uploads
</span>
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
</span>
</div>
</div>
<div>
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
<div
ref="statusContentRef"
v-bind="$attrs"
:class="['flex flex-col p-4 text-sm text-contrast']"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 font-bold">
<FolderOpenIcon class="size-4" />
<span>
<span class="capitalize">
{{ props.fileType ? props.fileType : 'File' }} uploads
</span>
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : '' }}</span>
</span>
</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.includes('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-file-exists'">
<span class="text-red">Failed - File already exists</span>
</template>
<template v-else-if="item.status === 'error-generic'">
<span class="text-red"
>Failed - {{ item.error?.message || "An unexpected error occured." }}</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>
</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.includes('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-file-exists'">
<span class="text-red">Failed - File already exists</span>
</template>
<template v-else-if="item.status === 'error-generic'">
<span class="text-red"
>Failed - {{ item.error?.message || 'An unexpected error occured.' }}</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>
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon, FolderOpenIcon, XCircleIcon } from "@modrinth/assets";
import { ButtonStyled, injectNotificationManager } from "@modrinth/ui";
import { computed, nextTick, ref, watch } from "vue";
import { FSModule } from "~/composables/servers/modules/fs.ts";
import { CheckCircleIcon, FolderOpenIcon, XCircleIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { computed, nextTick, ref, watch } from 'vue'
const { addNotification } = injectNotificationManager();
import type { FSModule } from '~/composables/servers/modules/fs.ts'
const { addNotification } = injectNotificationManager()
interface UploadItem {
file: File;
progress: number;
status:
| "pending"
| "uploading"
| "completed"
| "error-file-exists"
| "error-generic"
| "cancelled"
| "incorrect-type";
size: string;
uploader?: any;
error?: Error;
file: File
progress: number
status:
| 'pending'
| 'uploading'
| 'completed'
| 'error-file-exists'
| 'error-generic'
| 'cancelled'
| 'incorrect-type'
size: string
uploader?: any
error?: Error
}
interface Props {
currentPath: string;
fileType?: string;
marginBottom?: number;
acceptedTypes?: Array<string>;
fs: FSModule;
currentPath: string
fileType?: string
marginBottom?: number
acceptedTypes?: Array<string>
fs: FSModule
}
defineOptions({
inheritAttrs: false,
});
inheritAttrs: false,
})
const props = defineProps<Props>();
const props = defineProps<Props>()
const emit = defineEmits<{
(e: "uploadComplete"): void;
}>();
(e: 'uploadComplete'): void
}>()
const uploadStatusRef = ref<HTMLElement | null>(null);
const statusContentRef = ref<HTMLElement | null>(null);
const uploadQueue = ref<UploadItem[]>([]);
const uploadStatusRef = ref<HTMLElement | null>(null)
const statusContentRef = ref<HTMLElement | null>(null)
const uploadQueue = ref<UploadItem[]>([])
const isUploading = computed(() => uploadQueue.value.length > 0);
const isUploading = computed(() => uploadQueue.value.length > 0)
const activeUploads = computed(() =>
uploadQueue.value.filter((item) => item.status === "pending" || item.status === "uploading"),
);
uploadQueue.value.filter((item) => item.status === 'pending' || item.status === 'uploading'),
)
const onUploadStatusEnter = (el: Element) => {
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0);
(el as HTMLElement).style.height = "0";
// eslint-disable-next-line no-void
void (el as HTMLElement).offsetHeight;
(el as HTMLElement).style.height = `${height}px`;
};
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0)
;(el as HTMLElement).style.height = '0'
void (el as HTMLElement).offsetHeight
;(el as HTMLElement).style.height = `${height}px`
}
const onUploadStatusLeave = (el: Element) => {
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0);
(el as HTMLElement).style.height = `${height}px`;
// eslint-disable-next-line no-void
void (el as HTMLElement).offsetHeight;
(el as HTMLElement).style.height = "0";
};
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0)
;(el as HTMLElement).style.height = `${height}px`
void (el as HTMLElement).offsetHeight
;(el as HTMLElement).style.height = '0'
}
watch(
uploadQueue,
() => {
if (!uploadStatusRef.value) return;
const el = uploadStatusRef.value;
const itemsHeight = uploadQueue.value.length * 32;
const headerHeight = 12;
const gap = 8;
const padding = 32;
const totalHeight = padding + headerHeight + gap + itemsHeight + (props.marginBottom || 0);
el.style.height = `${totalHeight}px`;
},
{ deep: true },
);
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";
};
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + ' KB'
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + ' MB'
return (bytes / 1024 ** 3).toFixed(1) + ' GB'
}
const cancelUpload = (item: UploadItem) => {
if (item.uploader && item.status === "uploading") {
item.uploader.cancel();
item.status = "cancelled";
if (item.uploader && item.status === 'uploading') {
item.uploader.cancel()
item.status = 'cancelled'
setTimeout(async () => {
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name);
if (index !== -1) {
uploadQueue.value.splice(index, 1);
await nextTick();
}
}, 5000);
}
};
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 badFileTypeMsg = 'Upload had incorrect file type'
const uploadFile = async (file: File) => {
const uploadItem: UploadItem = {
file,
progress: 0,
status: "pending",
size: formatFileSize(file.size),
};
const uploadItem: UploadItem = {
file,
progress: 0,
status: 'pending',
size: formatFileSize(file.size),
}
uploadQueue.value.push(uploadItem);
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);
}
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;
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);
}
});
}
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 uploader?.promise
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
uploadQueue.value[index].status = 'completed'
uploadQueue.value[index].progress = 100
}
await nextTick();
await nextTick()
setTimeout(async () => {
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
if (removeIndex !== -1) {
uploadQueue.value.splice(removeIndex, 1);
await nextTick();
}
}, 5000);
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") {
const target = uploadQueue.value[index];
emit('uploadComplete')
} catch (error) {
console.error('Error uploading file:', error)
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
const target = uploadQueue.value[index]
if (error instanceof Error) {
if (error.message === badFileTypeMsg) {
target.status = "incorrect-type";
} else if (target.progress === 100 && error.message.includes("401")) {
target.status = "error-file-exists";
} else {
target.status = "error-generic";
target.error = error;
}
}
}
if (error instanceof Error) {
if (error.message === badFileTypeMsg) {
target.status = 'incorrect-type'
} else if (target.progress === 100 && error.message.includes('401')) {
target.status = 'error-file-exists'
} else {
target.status = 'error-generic'
target.error = 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);
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({
title: "Upload failed",
text: `Failed to upload ${file.name}`,
type: "error",
});
}
}
};
if (error instanceof Error && error.message !== 'Upload cancelled') {
addNotification({
title: 'Upload failed',
text: `Failed to upload ${file.name}`,
type: 'error',
})
}
}
}
defineExpose({
uploadFile,
cancelUpload,
});
uploadFile,
cancelUpload,
})
</script>
<style scoped>
.upload-status {
overflow: hidden;
transition: height 0.2s ease;
overflow: hidden;
transition: height 0.2s ease;
}
.upload-status-enter-active,
.upload-status-leave-active {
transition: height 0.2s ease;
overflow: hidden;
transition: height 0.2s ease;
overflow: hidden;
}
.upload-status-enter-from,
.upload-status-leave-to {
height: 0 !important;
height: 0 !important;
}
.status-icon-enter-active,
.status-icon-leave-active {
transition: all 0.25s ease;
transition: all 0.25s ease;
}
.status-icon-enter-from,
.status-icon-leave-to {
transform: scale(0);
opacity: 0;
transform: scale(0);
opacity: 0;
}
.status-icon-enter-to,
.status-icon-leave-from {
transform: scale(1);
opacity: 1;
transform: scale(1);
opacity: 1;
}
</style>

View File

@@ -1,154 +1,156 @@
<template>
<NewModal
ref="modal"
:header="cf ? `Installing a CurseForge pack` : `Uploading .zip contents from URL`"
>
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<div class="font-bold text-contrast">
{{ cf ? `How to get the modpack version's URL` : "URL of .zip file" }}
</div>
<ol v-if="cf" class="mb-1 mt-0 flex flex-col gap-1 pl-8 leading-normal text-secondary">
<li>
<a
href="https://www.curseforge.com/minecraft/search?page=1&pageSize=40&sortBy=relevancy&class=modpacks"
class="inline-flex font-semibold text-[#F16436] transition-all hover:underline active:brightness-[--hover-brightness]"
target="_blank"
rel="noopener noreferrer"
>
Find the CurseForge modpack
<ExternalIcon class="ml-1 inline size-4" stroke-width="3" />
</a>
you'd like to install on your server.
</li>
<li>
On the modpack's page, go to the
<span class="font-semibold text-primary">"Files"</span> tab, and
<span class="font-semibold text-primary">select the version</span> of the modpack you
want to install.
</li>
<li>
<span class="font-semibold text-primary">Copy the URL</span> of the version you want to
install, and paste it in the box below.
</li>
</ol>
<p v-else class="mb-1 mt-0">Copy and paste the direct download URL of a .zip file.</p>
<input
ref="urlInput"
v-model="url"
autofocus
:disabled="submitted"
type="text"
data-1p-ignore
data-lpignore="true"
data-protonpass-ignore="true"
required
:placeholder="
cf
? 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
: 'https://www.example.com/.../modpack-name-1.0.2.zip'
"
autocomplete="off"
/>
<div v-if="submitted && error" class="text-red">{{ error }}</div>
</div>
<BackupWarning :backup-link="`/servers/manage/${props.server.serverId}/backups`" />
<div class="flex justify-start gap-2">
<ButtonStyled color="brand">
<button v-tooltip="error" :disabled="submitted || !!error" type="submit">
<SpinnerIcon v-if="submitted" class="animate-spin" />
<DownloadIcon v-else class="h-5 w-5" />
{{ submitted ? "Installing..." : "Install" }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
{{ submitted ? "Close" : "Cancel" }}
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
<NewModal
ref="modal"
:header="cf ? `Installing a CurseForge pack` : `Uploading .zip contents from URL`"
>
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-2">
<div class="font-bold text-contrast">
{{ cf ? `How to get the modpack version's URL` : 'URL of .zip file' }}
</div>
<ol v-if="cf" class="mb-1 mt-0 flex flex-col gap-1 pl-8 leading-normal text-secondary">
<li>
<a
href="https://www.curseforge.com/minecraft/search?page=1&pageSize=40&sortBy=relevancy&class=modpacks"
class="inline-flex font-semibold text-[#F16436] transition-all hover:underline active:brightness-[--hover-brightness]"
target="_blank"
rel="noopener noreferrer"
>
Find the CurseForge modpack
<ExternalIcon class="ml-1 inline size-4" stroke-width="3" />
</a>
you'd like to install on your server.
</li>
<li>
On the modpack's page, go to the
<span class="font-semibold text-primary">"Files"</span> tab, and
<span class="font-semibold text-primary">select the version</span> of the modpack you
want to install.
</li>
<li>
<span class="font-semibold text-primary">Copy the URL</span> of the version you want to
install, and paste it in the box below.
</li>
</ol>
<p v-else class="mb-1 mt-0">Copy and paste the direct download URL of a .zip file.</p>
<input
ref="urlInput"
v-model="url"
autofocus
:disabled="submitted"
type="text"
data-1p-ignore
data-lpignore="true"
data-protonpass-ignore="true"
required
:placeholder="
cf
? 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
: 'https://www.example.com/.../modpack-name-1.0.2.zip'
"
autocomplete="off"
/>
<div v-if="submitted && error" class="text-red">{{ error }}</div>
</div>
<BackupWarning :backup-link="`/servers/manage/${props.server.serverId}/backups`" />
<div class="flex justify-start gap-2">
<ButtonStyled color="brand">
<button v-tooltip="error" :disabled="submitted || !!error" type="submit">
<SpinnerIcon v-if="submitted" class="animate-spin" />
<DownloadIcon v-else class="h-5 w-5" />
{{ submitted ? 'Installing...' : 'Install' }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
{{ submitted ? 'Close' : 'Cancel' }}
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { ExternalIcon, SpinnerIcon, DownloadIcon, XIcon } from "@modrinth/assets";
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
import { ModrinthServersFetchError } from "@modrinth/utils";
import { ref, computed, nextTick } from "vue";
import { handleError, ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { DownloadIcon, ExternalIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { BackupWarning, ButtonStyled, NewModal } from '@modrinth/ui'
import { ModrinthServersFetchError } from '@modrinth/utils'
import { computed, nextTick, ref } from 'vue'
const cf = ref(false);
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { handleError } from '~/composables/servers/modrinth-servers.ts'
const cf = ref(false)
const props = defineProps<{
server: ModrinthServer;
}>();
server: ModrinthServer
}>()
const modal = ref<typeof NewModal>();
const urlInput = ref<HTMLInputElement | null>(null);
const url = ref("");
const submitted = ref(false);
const modal = ref<typeof NewModal>()
const urlInput = ref<HTMLInputElement | null>(null)
const url = ref('')
const submitted = ref(false)
const trimmedUrl = computed(() => url.value.trim());
const trimmedUrl = computed(() => url.value.trim())
const regex = /https:\/\/(www\.)?curseforge\.com\/minecraft\/modpacks\/[^/]+\/files\/\d+/;
const regex = /https:\/\/(www\.)?curseforge\.com\/minecraft\/modpacks\/[^/]+\/files\/\d+/
const error = computed(() => {
if (trimmedUrl.value.length === 0) {
return "URL is required.";
}
if (cf.value && !regex.test(trimmedUrl.value)) {
return "URL must be a CurseForge modpack version URL.";
} else if (!cf.value && !trimmedUrl.value.includes("/")) {
return "URL must be valid.";
}
return "";
});
if (trimmedUrl.value.length === 0) {
return 'URL is required.'
}
if (cf.value && !regex.test(trimmedUrl.value)) {
return 'URL must be a CurseForge modpack version URL.'
} else if (!cf.value && !trimmedUrl.value.includes('/')) {
return 'URL must be valid.'
}
return ''
})
const handleSubmit = async () => {
submitted.value = true;
if (!error.value) {
// hide();
try {
const dry = await props.server.fs.extractFile(trimmedUrl.value, true, true);
submitted.value = true
if (!error.value) {
// hide();
try {
const dry = await props.server.fs.extractFile(trimmedUrl.value, true, true)
if (!cf.value || dry.modpack_name) {
await props.server.fs.extractFile(trimmedUrl.value, true, false, true);
hide();
} else {
submitted.value = false;
handleError(
new ModrinthServersFetchError(
"Could not find CurseForge modpack at that URL.",
404,
new Error(`No modpack found at ${url.value}`),
),
);
}
} catch (error) {
submitted.value = false;
console.error("Error installing:", error);
handleError(error);
}
}
};
if (!cf.value || dry.modpack_name) {
await props.server.fs.extractFile(trimmedUrl.value, true, false, true)
hide()
} else {
submitted.value = false
handleError(
new ModrinthServersFetchError(
'Could not find CurseForge modpack at that URL.',
404,
new Error(`No modpack found at ${url.value}`),
),
)
}
} catch (error) {
submitted.value = false
console.error('Error installing:', error)
handleError(error)
}
}
}
const show = (isCf: boolean) => {
cf.value = isCf;
url.value = "";
submitted.value = false;
modal.value?.show();
nextTick(() => {
setTimeout(() => {
urlInput.value?.focus();
}, 100);
});
};
cf.value = isCf
url.value = ''
submitted.value = false
modal.value?.show()
nextTick(() => {
setTimeout(() => {
urlInput.value?.focus()
}, 100)
})
}
const hide = () => {
modal.value?.hide();
};
modal.value?.hide()
}
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>

View File

@@ -1,134 +1,134 @@
<template>
<div ref="container" class="relative h-[400px] w-full cursor-move lg:h-[600px]">
<div
v-for="location in locations"
:key="location.name"
:class="{
'opacity-0': !showLabels,
hidden: !isLocationVisible(location),
'z-40': location.clicked,
}"
:style="{
position: 'absolute',
left: `${location.screenPosition?.x || 0}px`,
top: `${location.screenPosition?.y || 0}px`,
}"
class="location-button center-on-top-left flex transform cursor-pointer items-center rounded-full bg-bg px-3 outline-1 outline-red transition-opacity duration-200 hover:z-50"
@click="toggleLocationClicked(location)"
>
<div
:class="{
'animate-pulse': location.active,
'border-gray-400': !location.active,
'border-purple bg-purple': location.active,
'border-dashed': !location.active,
'opacity-40': !location.active,
}"
class="my-3 size-2.5 shrink-0 rounded-full border-2"
></div>
<div
class="expanding-item"
:class="{
expanded: location.clicked,
}"
>
<div class="whitespace-nowrap text-sm">
<span class="ml-2"> {{ location.name }} </span>
<span v-if="!location.active" class="ml-1 text-xs text-secondary">(Coming Soon)</span>
</div>
</div>
</div>
</div>
<div ref="container" class="relative h-[400px] w-full cursor-move lg:h-[600px]">
<div
v-for="location in locations"
:key="location.name"
:class="{
'opacity-0': !showLabels,
hidden: !isLocationVisible(location),
'z-40': location.clicked,
}"
:style="{
position: 'absolute',
left: `${location.screenPosition?.x || 0}px`,
top: `${location.screenPosition?.y || 0}px`,
}"
class="location-button center-on-top-left flex transform cursor-pointer items-center rounded-full bg-bg px-3 outline-1 outline-red transition-opacity duration-200 hover:z-50"
@click="toggleLocationClicked(location)"
>
<div
:class="{
'animate-pulse': location.active,
'border-gray-400': !location.active,
'border-purple bg-purple': location.active,
'border-dashed': !location.active,
'opacity-40': !location.active,
}"
class="my-3 size-2.5 shrink-0 rounded-full border-2"
></div>
<div
class="expanding-item"
:class="{
expanded: location.clicked,
}"
>
<div class="whitespace-nowrap text-sm">
<span class="ml-2"> {{ location.name }} </span>
<span v-if="!location.active" class="ml-1 text-xs text-secondary">(Coming Soon)</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { ref, onMounted, onUnmounted } from "vue";
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { onMounted, onUnmounted, ref } from 'vue'
const container = ref(null);
const showLabels = ref(false);
const container = ref(null)
const showLabels = ref(false)
const locations = ref([
// Active locations
{ name: "New York", lat: 40.7128, lng: -74.006, active: true, clicked: false },
{ name: "Los Angeles", lat: 34.0522, lng: -118.2437, active: true, clicked: false },
{ name: "Miami", lat: 25.7617, lng: -80.1918, active: true, clicked: false },
{ name: "Spokane", lat: 47.667309, lng: -117.411922, active: true, clicked: false },
{ name: "Dallas", lat: 32.78372, lng: -96.7947, active: true, clicked: false },
// 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 },
// { name: "Amsterdam", lat: 52.3676, lng: 4.9041, active: false, clicked: false },
// { name: "Paris", lat: 48.8566, lng: 2.3522, active: false, clicked: false },
// { name: "Singapore", lat: 1.3521, lng: 103.8198, active: false, clicked: false },
// { name: "Tokyo", lat: 35.6762, lng: 139.6503, active: false, clicked: false },
// { name: "Sydney", lat: -33.8688, lng: 151.2093, active: false, clicked: false },
// { name: "São Paulo", lat: -23.5505, lng: -46.6333, active: false, clicked: false },
// { name: "Toronto", lat: 43.6532, lng: -79.3832, active: false, clicked: false },
]);
// Active locations
{ name: 'New York', lat: 40.7128, lng: -74.006, active: true, clicked: false },
{ name: 'Los Angeles', lat: 34.0522, lng: -118.2437, active: true, clicked: false },
{ name: 'Miami', lat: 25.7617, lng: -80.1918, active: true, clicked: false },
{ name: 'Spokane', lat: 47.667309, lng: -117.411922, active: true, clicked: false },
{ name: 'Dallas', lat: 32.78372, lng: -96.7947, active: true, clicked: false },
// 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 },
// { name: "Amsterdam", lat: 52.3676, lng: 4.9041, active: false, clicked: false },
// { name: "Paris", lat: 48.8566, lng: 2.3522, active: false, clicked: false },
// { name: "Singapore", lat: 1.3521, lng: 103.8198, active: false, clicked: false },
// { name: "Tokyo", lat: 35.6762, lng: 139.6503, active: false, clicked: false },
// { name: "Sydney", lat: -33.8688, lng: 151.2093, active: false, clicked: false },
// { name: "São Paulo", lat: -23.5505, lng: -46.6333, active: false, clicked: false },
// { name: "Toronto", lat: 43.6532, lng: -79.3832, active: false, clicked: false },
])
const isLocationVisible = (location) => {
if (!location.screenPosition || !globe) return false;
if (!location.screenPosition || !globe) return false
const vector = latLngToVector3(location.lat, location.lng).clone();
vector.applyMatrix4(globe.matrixWorld);
const vector = latLngToVector3(location.lat, location.lng).clone()
vector.applyMatrix4(globe.matrixWorld)
const cameraVector = new THREE.Vector3();
camera.getWorldPosition(cameraVector);
const cameraVector = new THREE.Vector3()
camera.getWorldPosition(cameraVector)
const viewVector = vector.clone().sub(cameraVector).normalize();
const viewVector = vector.clone().sub(cameraVector).normalize()
const normal = vector.clone().normalize();
const normal = vector.clone().normalize()
const dotProduct = normal.dot(viewVector);
const dotProduct = normal.dot(viewVector)
return dotProduct < -0.15;
};
return dotProduct < -0.15
}
const toggleLocationClicked = (location) => {
console.log("clicked", location.name);
locations.value.find((loc) => loc.name === location.name).clicked = !location.clicked;
};
console.log('clicked', location.name)
locations.value.find((loc) => loc.name === location.name).clicked = !location.clicked
}
let scene, camera, renderer, globe, controls;
let animationFrame;
let scene, camera, renderer, globe, controls
let animationFrame
const init = () => {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(
45,
container.value.clientWidth / container.value.clientHeight,
0.1,
1000,
);
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: "low-power",
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(container.value.clientWidth, container.value.clientHeight);
container.value.appendChild(renderer.domElement);
scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera(
45,
container.value.clientWidth / container.value.clientHeight,
0.1,
1000,
)
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: 'low-power',
})
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setSize(container.value.clientWidth, container.value.clientHeight)
container.value.appendChild(renderer.domElement)
const geometry = new THREE.SphereGeometry(5, 64, 64);
const outlineTexture = new THREE.TextureLoader().load("/earth-outline.png");
outlineTexture.minFilter = THREE.LinearFilter;
outlineTexture.magFilter = THREE.LinearFilter;
const geometry = new THREE.SphereGeometry(5, 64, 64)
const outlineTexture = new THREE.TextureLoader().load('/earth-outline.png')
outlineTexture.minFilter = THREE.LinearFilter
outlineTexture.magFilter = THREE.LinearFilter
const material = new THREE.ShaderMaterial({
uniforms: {
outlineTexture: { value: outlineTexture },
globeColor: { value: new THREE.Color("#60fbb5") },
},
vertexShader: `
const material = new THREE.ShaderMaterial({
uniforms: {
outlineTexture: { value: outlineTexture },
globeColor: { value: new THREE.Color('#60fbb5') },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
fragmentShader: `
uniform sampler2D outlineTexture;
uniform vec3 globeColor;
varying vec2 vUv;
@@ -139,22 +139,22 @@ const init = () => {
gl_FragColor = vec4(globeColor, brightness * 0.8);
}
`,
transparent: true,
side: THREE.FrontSide,
});
transparent: true,
side: THREE.FrontSide,
})
globe = new THREE.Mesh(geometry, material);
scene.add(globe);
globe = new THREE.Mesh(geometry, material)
scene.add(globe)
const atmosphereGeometry = new THREE.SphereGeometry(5.2, 64, 64);
const atmosphereMaterial = new THREE.ShaderMaterial({
transparent: true,
side: THREE.BackSide,
uniforms: {
color: { value: new THREE.Color("#56f690") },
viewVector: { value: camera.position },
},
vertexShader: `
const atmosphereGeometry = new THREE.SphereGeometry(5.2, 64, 64)
const atmosphereMaterial = new THREE.ShaderMaterial({
transparent: true,
side: THREE.BackSide,
uniforms: {
color: { value: new THREE.Color('#56f690') },
viewVector: { value: camera.position },
},
vertexShader: `
uniform vec3 viewVector;
varying float intensity;
void main() {
@@ -164,146 +164,146 @@ const init = () => {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
fragmentShader: `
uniform vec3 color;
varying float intensity;
void main() {
gl_FragColor = vec4(color, intensity * 0.4);
}
`,
});
})
const atmosphere = new THREE.Mesh(atmosphereGeometry, atmosphereMaterial);
scene.add(atmosphere);
const atmosphere = new THREE.Mesh(atmosphereGeometry, atmosphereMaterial)
scene.add(atmosphere)
const ambientLight = new THREE.AmbientLight(0x404040, 0.5);
scene.add(ambientLight);
const ambientLight = new THREE.AmbientLight(0x404040, 0.5)
scene.add(ambientLight)
camera.position.z = 15;
camera.position.z = 15
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.rotateSpeed = 0.3;
controls.enableZoom = false;
controls.enablePan = false;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.05;
controls.minPolarAngle = Math.PI * 0.3;
controls.maxPolarAngle = Math.PI * 0.7;
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.rotateSpeed = 0.3
controls.enableZoom = false
controls.enablePan = false
controls.autoRotate = true
controls.autoRotateSpeed = 0.05
controls.minPolarAngle = Math.PI * 0.3
controls.maxPolarAngle = Math.PI * 0.7
globe.rotation.y = Math.PI * 1.9;
globe.rotation.x = Math.PI * 0.15;
};
globe.rotation.y = Math.PI * 1.9
globe.rotation.x = Math.PI * 0.15
}
const animate = () => {
animationFrame = requestAnimationFrame(animate);
controls.update();
animationFrame = requestAnimationFrame(animate)
controls.update()
locations.value.forEach((location) => {
const position = latLngToVector3(location.lat, location.lng);
const vector = position.clone();
vector.applyMatrix4(globe.matrixWorld);
locations.value.forEach((location) => {
const position = latLngToVector3(location.lat, location.lng)
const vector = position.clone()
vector.applyMatrix4(globe.matrixWorld)
const coords = vector.project(camera);
const screenPosition = {
x: (coords.x * 0.5 + 0.5) * container.value.clientWidth,
y: (-coords.y * 0.5 + 0.5) * container.value.clientHeight,
};
location.screenPosition = screenPosition;
});
const coords = vector.project(camera)
const screenPosition = {
x: (coords.x * 0.5 + 0.5) * container.value.clientWidth,
y: (-coords.y * 0.5 + 0.5) * container.value.clientHeight,
}
location.screenPosition = screenPosition
})
renderer.render(scene, camera);
};
renderer.render(scene, camera)
}
const latLngToVector3 = (lat, lng) => {
const phi = (90 - lat) * (Math.PI / 180);
const theta = (lng + 180) * (Math.PI / 180);
const radius = 5;
const phi = (90 - lat) * (Math.PI / 180)
const theta = (lng + 180) * (Math.PI / 180)
const radius = 5
return new THREE.Vector3(
-radius * Math.sin(phi) * Math.cos(theta),
radius * Math.cos(phi),
radius * Math.sin(phi) * Math.sin(theta),
);
};
return new THREE.Vector3(
-radius * Math.sin(phi) * Math.cos(theta),
radius * Math.cos(phi),
radius * Math.sin(phi) * Math.sin(theta),
)
}
const handleResize = () => {
if (!container.value) return;
camera.aspect = container.value.clientWidth / container.value.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.value.clientWidth, container.value.clientHeight);
};
if (!container.value) return
camera.aspect = container.value.clientWidth / container.value.clientHeight
camera.updateProjectionMatrix()
renderer.setSize(container.value.clientWidth, container.value.clientHeight)
}
onMounted(() => {
init();
animate();
window.addEventListener("resize", handleResize);
init()
animate()
window.addEventListener('resize', handleResize)
setTimeout(() => {
showLabels.value = true;
}, 1000);
});
setTimeout(() => {
showLabels.value = true
}, 1000)
})
onUnmounted(() => {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
window.removeEventListener("resize", handleResize);
if (renderer) {
renderer.dispose();
}
if (container.value) {
container.value.innerHTML = "";
}
});
if (animationFrame) {
cancelAnimationFrame(animationFrame)
}
window.removeEventListener('resize', handleResize)
if (renderer) {
renderer.dispose()
}
if (container.value) {
container.value.innerHTML = ''
}
})
</script>
<style scoped>
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(27, 217, 106, 0.3);
}
70% {
box-shadow: 0 0 0 4px rgba(27, 217, 106, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(27, 217, 106, 0);
}
0% {
box-shadow: 0 0 0 0 rgba(27, 217, 106, 0.3);
}
70% {
box-shadow: 0 0 0 4px rgba(27, 217, 106, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(27, 217, 106, 0);
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.center-on-top-left {
transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}
.expanding-item.expanded {
grid-template-columns: 1fr;
grid-template-columns: 1fr;
}
@media (hover: hover) {
.location-button:hover .expanding-item {
grid-template-columns: 1fr;
}
.location-button:hover .expanding-item {
grid-template-columns: 1fr;
}
}
.expanding-item {
display: grid;
grid-template-columns: 0fr;
transition: grid-template-columns 0.15s ease-in-out;
overflow: hidden;
display: grid;
grid-template-columns: 0fr;
transition: grid-template-columns 0.15s ease-in-out;
overflow: hidden;
> div {
overflow: hidden;
}
> div {
overflow: hidden;
}
}
@media (prefers-reduced-motion) {
.expanding-item {
transition: none !important;
}
.expanding-item {
transition: none !important;
}
}
</style>

View File

@@ -1,76 +1,76 @@
<template>
<div class="ticker-container">
<div class="ticker-content">
<div
v-for="(message, index) in msgs"
:key="message"
class="ticker-item text-xs"
:class="{ active: index === currentIndex % msgs.length }"
>
{{ message }}
</div>
</div>
</div>
<div class="ticker-container">
<div class="ticker-content">
<div
v-for="(message, index) in msgs"
:key="message"
class="ticker-item text-xs"
:class="{ active: index === currentIndex % msgs.length }"
>
{{ message }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { onMounted, onUnmounted, ref } from 'vue'
const msgs = [
"Organizing files...",
"Downloading mods...",
"Configuring server...",
"Setting up environment...",
"Adding Java...",
];
'Organizing files...',
'Downloading mods...',
'Configuring server...',
'Setting up environment...',
'Adding Java...',
]
const currentIndex = ref(0);
const currentIndex = ref(0)
let intervalId: NodeJS.Timeout | null = null;
let intervalId: NodeJS.Timeout | null = null
onMounted(() => {
intervalId = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % msgs.length;
}, 3000);
});
intervalId = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % msgs.length
}, 3000)
})
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId);
}
});
if (intervalId) {
clearInterval(intervalId)
}
})
</script>
<style scoped>
.ticker-container {
height: 20px;
width: 100%;
position: relative;
height: 20px;
width: 100%;
position: relative;
}
.ticker-content {
position: relative;
width: 100%;
position: relative;
width: 100%;
}
.ticker-item {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 20px;
display: flex;
align-items: center;
color: var(--color-secondary-text);
opacity: 0;
transform: scale(0.9);
filter: blur(4px);
transition: all 0.3s ease-in-out;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 20px;
display: flex;
align-items: center;
color: var(--color-secondary-text);
opacity: 0;
transform: scale(0.9);
filter: blur(4px);
transition: all 0.3s ease-in-out;
}
.ticker-item.active {
opacity: 1;
transform: scale(1);
filter: blur(0);
opacity: 1;
transform: scale(1);
filter: blur(0);
}
</style>

View File

@@ -1,95 +1,95 @@
<template>
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
<div
v-for="loader in vanillaLoaders"
:key="loader.name"
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
>
<UiServersLoaderSelectorCard
:loader="loader"
:is-current="isCurrentLoader(loader.name)"
:loader-version="data.loader_version"
:current-loader="data.loader"
:is-installing="isInstalling"
@select="selectLoader"
/>
</div>
</div>
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
<div
v-for="loader in vanillaLoaders"
:key="loader.name"
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
>
<UiServersLoaderSelectorCard
:loader="loader"
:is-current="isCurrentLoader(loader.name)"
:loader-version="data.loader_version"
:current-loader="data.loader"
:is-installing="isInstalling"
@select="selectLoader"
/>
</div>
</div>
<div class="mt-4">
<h2 class="mb-2 px-2 text-lg font-bold text-contrast">Mod loaders</h2>
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
<div
v-for="loader in modLoaders"
:key="loader.name"
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
>
<UiServersLoaderSelectorCard
:loader="loader"
:is-current="isCurrentLoader(loader.name)"
:loader-version="data.loader_version"
:current-loader="data.loader"
:is-installing="isInstalling"
@select="selectLoader"
/>
</div>
</div>
</div>
<div class="mt-4">
<h2 class="mb-2 px-2 text-lg font-bold text-contrast">Mod loaders</h2>
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
<div
v-for="loader in modLoaders"
:key="loader.name"
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
>
<UiServersLoaderSelectorCard
:loader="loader"
:is-current="isCurrentLoader(loader.name)"
:loader-version="data.loader_version"
:current-loader="data.loader"
:is-installing="isInstalling"
@select="selectLoader"
/>
</div>
</div>
</div>
<div class="mt-4">
<h2 class="mb-2 px-2 text-lg font-bold text-contrast">Plugin loaders</h2>
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
<div
v-for="loader in pluginLoaders"
:key="loader.name"
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
>
<UiServersLoaderSelectorCard
:loader="loader"
:is-current="isCurrentLoader(loader.name)"
:loader-version="data.loader_version"
:current-loader="data.loader"
:is-installing="isInstalling"
@select="selectLoader"
/>
</div>
</div>
</div>
<div class="mt-4">
<h2 class="mb-2 px-2 text-lg font-bold text-contrast">Plugin loaders</h2>
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
<div
v-for="loader in pluginLoaders"
:key="loader.name"
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
>
<UiServersLoaderSelectorCard
:loader="loader"
:is-current="isCurrentLoader(loader.name)"
:loader-version="data.loader_version"
:current-loader="data.loader"
:is-installing="isInstalling"
@select="selectLoader"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
data: {
loader: string | null;
loader_version: string | null;
};
ignoreCurrentInstallation?: boolean;
isInstalling?: boolean;
}>();
data: {
loader: string | null
loader_version: string | null
}
ignoreCurrentInstallation?: boolean
isInstalling?: boolean
}>()
const emit = defineEmits<{
(e: "selectLoader", loader: string): void;
}>();
(e: 'selectLoader', loader: string): void
}>()
const vanillaLoaders = [{ name: "Vanilla" as const, displayName: "Vanilla" }];
const vanillaLoaders = [{ name: 'Vanilla' as const, displayName: 'Vanilla' }]
const modLoaders = [
{ name: "Fabric" as const, displayName: "Fabric" },
{ name: "Quilt" as const, displayName: "Quilt" },
{ name: "Forge" as const, displayName: "Forge" },
{ name: "NeoForge" as const, displayName: "NeoForge" },
];
{ name: 'Fabric' as const, displayName: 'Fabric' },
{ name: 'Quilt' as const, displayName: 'Quilt' },
{ name: 'Forge' as const, displayName: 'Forge' },
{ name: 'NeoForge' as const, displayName: 'NeoForge' },
]
const pluginLoaders = [
{ name: "Paper" as const, displayName: "Paper" },
{ name: "Purpur" as const, displayName: "Purpur" },
];
{ name: 'Paper' as const, displayName: 'Paper' },
{ name: 'Purpur' as const, displayName: 'Purpur' },
]
const isCurrentLoader = (loaderName: string) => {
return props.data.loader?.toLowerCase() === loaderName.toLowerCase();
};
return props.data.loader?.toLowerCase() === loaderName.toLowerCase()
}
const selectLoader = (loader: string) => {
emit("selectLoader", loader);
};
emit('selectLoader', loader)
}
</script>

View File

@@ -1,71 +1,71 @@
<template>
<div class="flex w-full items-center justify-between">
<div class="flex items-center gap-4">
<div
class="grid size-10 place-content-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
:class="isCurrentLoader ? '[&&]:bg-bg-green' : ''"
>
<UiServersIconsLoaderIcon
:loader="loader.name"
class="[&&]:size-6"
:class="isCurrentLoader ? 'text-brand' : ''"
/>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex flex-row items-center gap-2">
<h1 class="m-0 text-xl font-bold leading-none text-contrast">
{{ loader.displayName }}
</h1>
<span
v-if="isCurrentLoader"
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
>
<CheckIcon class="h-4 w-4" />
Current
</span>
</div>
<p v-if="isCurrentLoader" class="m-0 text-xs text-secondary">
{{ loaderVersion }}
</p>
</div>
</div>
<div class="flex w-full items-center justify-between">
<div class="flex items-center gap-4">
<div
class="grid size-10 place-content-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
:class="isCurrentLoader ? '[&&]:bg-bg-green' : ''"
>
<UiServersIconsLoaderIcon
:loader="loader.name"
class="[&&]:size-6"
:class="isCurrentLoader ? 'text-brand' : ''"
/>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex flex-row items-center gap-2">
<h1 class="m-0 text-xl font-bold leading-none text-contrast">
{{ loader.displayName }}
</h1>
<span
v-if="isCurrentLoader"
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
>
<CheckIcon class="h-4 w-4" />
Current
</span>
</div>
<p v-if="isCurrentLoader" class="m-0 text-xs text-secondary">
{{ loaderVersion }}
</p>
</div>
</div>
<ButtonStyled>
<button :disabled="isInstalling" @click="onSelect">
<DownloadIcon class="h-5 w-5" />
{{ isCurrentLoader ? "Reinstall" : "Install" }}
</button>
</ButtonStyled>
</div>
<ButtonStyled>
<button :disabled="isInstalling" @click="onSelect">
<DownloadIcon class="h-5 w-5" />
{{ isCurrentLoader ? 'Reinstall' : 'Install' }}
</button>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { CheckIcon, DownloadIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { CheckIcon, DownloadIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
interface LoaderInfo {
name: "Vanilla" | "Fabric" | "Forge" | "Quilt" | "Paper" | "NeoForge" | "Purpur";
displayName: string;
name: 'Vanilla' | 'Fabric' | 'Forge' | 'Quilt' | 'Paper' | 'NeoForge' | 'Purpur'
displayName: string
}
interface Props {
loader: LoaderInfo;
currentLoader: string | null;
loaderVersion: string | null;
isInstalling?: boolean;
loader: LoaderInfo
currentLoader: string | null
loaderVersion: string | null
isInstalling?: boolean
}
const props = defineProps<Props>();
const props = defineProps<Props>()
const emit = defineEmits<{
(e: "select", loader: string): void;
}>();
(e: 'select', loader: string): void
}>()
const isCurrentLoader = computed(() => {
return props.currentLoader?.toLowerCase() === props.loader.name.toLowerCase();
});
return props.currentLoader?.toLowerCase() === props.loader.name.toLowerCase()
})
const onSelect = () => {
emit("select", props.loader.name);
};
emit('select', props.loader.name)
}
</script>

View File

@@ -1,91 +1,91 @@
<template>
<div
class="parsed-log relative flex h-8 w-full items-center overflow-hidden rounded-lg px-6"
@mouseenter="checkOverflow"
@touchstart="checkOverflow"
>
<div ref="logContent" class="log-content flex-1 truncate whitespace-pre">
<span v-html="sanitizedLog"></span>
</div>
<button
v-if="isOverflowing"
class="ml-2 flex h-6 items-center rounded-md bg-bg px-2 text-xs text-contrast opacity-50 transition-opacity hover:opacity-100"
type="button"
@click.stop="$emit('show-full-log', props.log)"
>
...
</button>
</div>
<div
class="parsed-log relative flex h-8 w-full items-center overflow-hidden rounded-lg px-6"
@mouseenter="checkOverflow"
@touchstart="checkOverflow"
>
<div ref="logContent" class="log-content flex-1 truncate whitespace-pre">
<span v-html="sanitizedLog"></span>
</div>
<button
v-if="isOverflowing"
class="ml-2 flex h-6 items-center rounded-md bg-bg px-2 text-xs text-contrast opacity-50 transition-opacity hover:opacity-100"
type="button"
@click.stop="$emit('show-full-log', props.log)"
>
...
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import Convert from "ansi-to-html";
import DOMPurify from "dompurify";
import Convert from 'ansi-to-html'
import DOMPurify from 'dompurify'
import { computed, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps<{
log: string;
}>();
log: string
}>()
defineEmits<{
"show-full-log": [log: string];
}>();
'show-full-log': [log: string]
}>()
const logContent = ref<HTMLElement | null>(null);
const isOverflowing = ref(false);
const logContent = ref<HTMLElement | null>(null)
const isOverflowing = ref(false)
const checkOverflow = () => {
if (logContent.value && !isOverflowing.value) {
isOverflowing.value = logContent.value.scrollWidth > logContent.value.clientWidth;
}
};
if (logContent.value && !isOverflowing.value) {
isOverflowing.value = logContent.value.scrollWidth > logContent.value.clientWidth
}
}
const convert = new Convert({
fg: "#FFF",
bg: "#000",
newline: false,
escapeXML: true,
stream: false,
});
fg: '#FFF',
bg: '#000',
newline: false,
escapeXML: true,
stream: false,
})
const sanitizedLog = computed(() =>
DOMPurify.sanitize(convert.toHtml(props.log), {
ALLOWED_TAGS: ["span"],
ALLOWED_ATTR: ["style"],
USE_PROFILES: { html: true },
}),
);
DOMPurify.sanitize(convert.toHtml(props.log), {
ALLOWED_TAGS: ['span'],
ALLOWED_ATTR: ['style'],
USE_PROFILES: { html: true },
}),
)
const preventSelection = (e: MouseEvent) => {
e.preventDefault();
};
e.preventDefault()
}
onMounted(() => {
logContent.value?.addEventListener("mousedown", preventSelection);
});
logContent.value?.addEventListener('mousedown', preventSelection)
})
onUnmounted(() => {
logContent.value?.removeEventListener("mousedown", preventSelection);
});
logContent.value?.removeEventListener('mousedown', preventSelection)
})
</script>
<style scoped>
.parsed-log {
background: transparent;
transition: background-color 0.1s;
background: transparent;
transition: background-color 0.1s;
}
.parsed-log:hover {
background: rgba(128, 128, 128, 0.25);
transition: 0s;
background: rgba(128, 128, 128, 0.25);
transition: 0s;
}
.log-content > span {
user-select: none;
white-space: pre;
user-select: none;
white-space: pre;
}
.log-content {
white-space: pre;
white-space: pre;
}
</style>

View File

@@ -1,53 +1,53 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:serif="http://www.serif.com/"
version="1.1"
viewBox="0 0 1793 199"
>
<g>
<g id="Layer_1">
<g id="green" fill="var(--color-brand)">
<path
d="M1184.1,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"
/>
<path
d="M1291.1,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"
/>
<path
d="M1357.2,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"
/>
<path d="M1460,165.3l-40.8-95.1h23.2l35.1,83.9h-11.4l36.3-83.9h21.4l-40.8,95.1h-23Z" />
<path
d="M1579.6,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"
/>
<path
d="M1645.7,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"
/>
<path
d="M1749.9,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"
/>
<g>
<path
d="M9.8,143l63.4-38.1-5.8-15.3,18.1-18.6,22.9-4.9,6.6,8.2-10.6,10.7-9.2,2.9-6.6,6.8,3.2,9,6.5,6.9,9.2-2.5,6.6-7.2,14.3-4.5,4.3,9.6-14.8,18.1-24.8,7.8-11.1-12.4-63.6,38.2c-3-3.9-6.5-9.4-8.8-14.7ZM192.8,65.4l-50.4,13.6c2.8,7.4,3.7,11.7,4.5,16.5l50.3-13.6c-.8-5.4-2.2-10.8-4.4-16.5Z"
fill-rule="evenodd"
/>
<path
d="M17.3,106.5c3.6,42.1,38.9,75.2,82,75.2s60.7-18.9,74-46.3l16.4,5.7c-15.8,34.1-50.3,57.9-90.4,57.9S3.6,158.2,0,106.5h17.3ZM.3,89.4C5.3,39.2,47.8,0,99.3,0s99.5,44.6,99.5,99.5-1.1,17.4-3.3,25.5l-16.3-5.7c1.6-6.5,2.4-13.1,2.4-19.8,0-45.4-36.9-82.3-82.3-82.3S22.6,48.7,17.6,89.4H.3Z"
fill-rule="evenodd"
/>
<path
d="M99,51.6c-26.4,0-47.9,21.5-47.9,48s21.5,48,48,48,2.7,0,4-.2l4.8,16.8c-2.9.4-5.8.6-8.8.6-36,0-65.2-29.2-65.2-65.2S63.1,34.4,99,34.4s1.8,0,2.7,0l-2.7,17.1ZM118.6,37.4c26.4,8.3,45.6,33,45.6,62.2s-16.4,50.2-39.8,60l-4.8-16.7c16.2-7.7,27.4-24.2,27.4-43.3s-13-38.1-31.1-44.9l2.7-17.2Z"
fill-rule="evenodd"
/>
</g>
</g>
<g id="black" fill="currentColor">
<path
d="M354.8,69.2c12,0,21.7,3.4,28.6,10.4,7,7.2,10.6,17.5,10.6,31.5v54.8h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,5-6.8,12.2-6.8,21.3v48.5h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,4.8-6.8,12-6.8,21.3v48.5h-22.4v-95.6h21.3v12.2c3.6-4.3,8.1-7.5,13.4-9.8,5.4-2.3,11.3-3.4,17.9-3.4s13.6,1.3,19.2,3.9c5.5,2.9,9.8,6.8,13.1,12,3.9-5,8.9-8.9,15.2-11.8,6.3-2.7,13.1-4.1,20.6-4.1ZM466,167.2c-9.7,0-18.4-2.1-26.1-6.3-7.6-4-13.8-10.1-18.1-17.5-4.5-7.3-6.6-15.7-6.6-25.2s2.1-17.9,6.6-25.2c4.3-7.4,10.6-13.4,18.1-17.4,7.7-4.1,16.5-6.3,26.1-6.3s18.6,2.1,26.3,6.3c7.7,4.1,13.8,10,18.3,17.4,4.3,7.3,6.4,15.7,6.4,25.2s-2.1,17.9-6.4,25.2c-4.5,7.5-10.6,13.4-18.3,17.5-7.7,4.1-16.5,6.3-26.3,6.3h0ZM466,148c8.2,0,15-2.7,20.4-8.2,5.4-5.5,8.1-12.7,8.1-21.7s-2.7-16.1-8.1-21.7c-5.4-5.5-12.2-8.2-20.4-8.2s-15,2.7-20.2,8.2c-5.4,5.5-8.1,12.7-8.1,21.7s2.7,16.1,8.1,21.7c5.2,5.5,12,8.2,20.2,8.2ZM631.5,33.1v132.8h-21.5v-12.3c-3.7,4.4-8.3,7.9-13.6,10.2-5.5,2.3-11.5,3.4-18.1,3.4s-17.4-2-24.7-6.1c-7.3-4.1-13.2-9.8-17.4-17.4-4.1-7.3-6.3-15.9-6.3-25.6s2.1-18.3,6.3-25.6c4.1-7.3,10-13.1,17.4-17.2,7.3-4.1,15.6-6.1,24.7-6.1s12.2,1.1,17.4,3.2c5.2,2.1,9.8,5.4,13.4,9.7v-49h22.4ZM581.1,148c5.4,0,10.2-1.3,14.5-3.8,4.3-2.3,7.7-5.9,10.2-10.4,2.5-4.5,3.8-9.8,3.8-15.7s-1.3-11.3-3.8-15.7c-2.5-4.5-5.9-8.1-10.2-10.6-4.3-2.3-9.1-3.6-14.5-3.6s-10.2,1.3-14.5,3.6c-4.3,2.5-7.7,6.1-10.2,10.6-2.5,4.5-3.8,9.8-3.8,15.7s1.3,11.3,3.8,15.7c2.5,4.5,5.9,8.1,10.2,10.4,4.3,2.5,9.1,3.8,14.5,3.8ZM681.6,84.3c6.4-10,17.7-15,34-15v21.3c-1.7-.3-3.4-.5-5.2-.5-8.8,0-15.6,2.5-20.4,7.5-4.8,5.2-7.3,12.5-7.3,22v46.4h-22.4v-95.6h21.3v14h0ZM734.1,70.3h22.4v95.6h-22.4v-95.6ZM745.4,54.6c-4.1,0-7.5-1.3-10.2-3.9-2.7-2.4-4.2-5.9-4.1-9.5,0-3.8,1.4-7,4.1-9.7,2.7-2.5,6.1-3.8,10.2-3.8s7.5,1.3,10.2,3.6c2.7,2.5,4.1,5.5,4.1,9.3s-1.3,7.2-3.9,9.8c-2.7,2.7-6.3,4.1-10.4,4.1ZM839.5,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4v-95.6h21.3v12.3c3.8-4.5,8.4-7.7,14-10,5.5-2.3,12-3.4,19-3.4ZM964.8,160.7c-2.8,2.2-6,3.9-9.5,4.8-3.9,1.1-7.9,1.6-12,1.6-10.6,0-18.6-2.7-24.3-8.2-5.7-5.5-8.6-13.4-8.6-24v-46h-15.7v-17.9h15.7v-21.8h22.4v21.8h25.6v17.9h-25.6v45.5c0,4.7,1.1,8.2,3.4,10.6,2.3,2.5,5.5,3.8,9.8,3.8s9.1-1.3,12.5-3.9l6.3,15.9ZM1036.9,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4V33.1h22.4v48.3c3.8-3.9,8.2-7,13.8-9.1,5.4-2,11.5-3,18.1-3Z"
/>
</g>
</g>
</g>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:serif="http://www.serif.com/"
version="1.1"
viewBox="0 0 1793 199"
>
<g>
<g id="Layer_1">
<g id="green" fill="var(--color-brand)">
<path
d="M1184.1,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"
/>
<path
d="M1291.1,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"
/>
<path
d="M1357.2,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"
/>
<path d="M1460,165.3l-40.8-95.1h23.2l35.1,83.9h-11.4l36.3-83.9h21.4l-40.8,95.1h-23Z" />
<path
d="M1579.6,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"
/>
<path
d="M1645.7,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"
/>
<path
d="M1749.9,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"
/>
<g>
<path
d="M9.8,143l63.4-38.1-5.8-15.3,18.1-18.6,22.9-4.9,6.6,8.2-10.6,10.7-9.2,2.9-6.6,6.8,3.2,9,6.5,6.9,9.2-2.5,6.6-7.2,14.3-4.5,4.3,9.6-14.8,18.1-24.8,7.8-11.1-12.4-63.6,38.2c-3-3.9-6.5-9.4-8.8-14.7ZM192.8,65.4l-50.4,13.6c2.8,7.4,3.7,11.7,4.5,16.5l50.3-13.6c-.8-5.4-2.2-10.8-4.4-16.5Z"
fill-rule="evenodd"
/>
<path
d="M17.3,106.5c3.6,42.1,38.9,75.2,82,75.2s60.7-18.9,74-46.3l16.4,5.7c-15.8,34.1-50.3,57.9-90.4,57.9S3.6,158.2,0,106.5h17.3ZM.3,89.4C5.3,39.2,47.8,0,99.3,0s99.5,44.6,99.5,99.5-1.1,17.4-3.3,25.5l-16.3-5.7c1.6-6.5,2.4-13.1,2.4-19.8,0-45.4-36.9-82.3-82.3-82.3S22.6,48.7,17.6,89.4H.3Z"
fill-rule="evenodd"
/>
<path
d="M99,51.6c-26.4,0-47.9,21.5-47.9,48s21.5,48,48,48,2.7,0,4-.2l4.8,16.8c-2.9.4-5.8.6-8.8.6-36,0-65.2-29.2-65.2-65.2S63.1,34.4,99,34.4s1.8,0,2.7,0l-2.7,17.1ZM118.6,37.4c26.4,8.3,45.6,33,45.6,62.2s-16.4,50.2-39.8,60l-4.8-16.7c16.2-7.7,27.4-24.2,27.4-43.3s-13-38.1-31.1-44.9l2.7-17.2Z"
fill-rule="evenodd"
/>
</g>
</g>
<g id="black" fill="currentColor">
<path
d="M354.8,69.2c12,0,21.7,3.4,28.6,10.4,7,7.2,10.6,17.5,10.6,31.5v54.8h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,5-6.8,12.2-6.8,21.3v48.5h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,4.8-6.8,12-6.8,21.3v48.5h-22.4v-95.6h21.3v12.2c3.6-4.3,8.1-7.5,13.4-9.8,5.4-2.3,11.3-3.4,17.9-3.4s13.6,1.3,19.2,3.9c5.5,2.9,9.8,6.8,13.1,12,3.9-5,8.9-8.9,15.2-11.8,6.3-2.7,13.1-4.1,20.6-4.1ZM466,167.2c-9.7,0-18.4-2.1-26.1-6.3-7.6-4-13.8-10.1-18.1-17.5-4.5-7.3-6.6-15.7-6.6-25.2s2.1-17.9,6.6-25.2c4.3-7.4,10.6-13.4,18.1-17.4,7.7-4.1,16.5-6.3,26.1-6.3s18.6,2.1,26.3,6.3c7.7,4.1,13.8,10,18.3,17.4,4.3,7.3,6.4,15.7,6.4,25.2s-2.1,17.9-6.4,25.2c-4.5,7.5-10.6,13.4-18.3,17.5-7.7,4.1-16.5,6.3-26.3,6.3h0ZM466,148c8.2,0,15-2.7,20.4-8.2,5.4-5.5,8.1-12.7,8.1-21.7s-2.7-16.1-8.1-21.7c-5.4-5.5-12.2-8.2-20.4-8.2s-15,2.7-20.2,8.2c-5.4,5.5-8.1,12.7-8.1,21.7s2.7,16.1,8.1,21.7c5.2,5.5,12,8.2,20.2,8.2ZM631.5,33.1v132.8h-21.5v-12.3c-3.7,4.4-8.3,7.9-13.6,10.2-5.5,2.3-11.5,3.4-18.1,3.4s-17.4-2-24.7-6.1c-7.3-4.1-13.2-9.8-17.4-17.4-4.1-7.3-6.3-15.9-6.3-25.6s2.1-18.3,6.3-25.6c4.1-7.3,10-13.1,17.4-17.2,7.3-4.1,15.6-6.1,24.7-6.1s12.2,1.1,17.4,3.2c5.2,2.1,9.8,5.4,13.4,9.7v-49h22.4ZM581.1,148c5.4,0,10.2-1.3,14.5-3.8,4.3-2.3,7.7-5.9,10.2-10.4,2.5-4.5,3.8-9.8,3.8-15.7s-1.3-11.3-3.8-15.7c-2.5-4.5-5.9-8.1-10.2-10.6-4.3-2.3-9.1-3.6-14.5-3.6s-10.2,1.3-14.5,3.6c-4.3,2.5-7.7,6.1-10.2,10.6-2.5,4.5-3.8,9.8-3.8,15.7s1.3,11.3,3.8,15.7c2.5,4.5,5.9,8.1,10.2,10.4,4.3,2.5,9.1,3.8,14.5,3.8ZM681.6,84.3c6.4-10,17.7-15,34-15v21.3c-1.7-.3-3.4-.5-5.2-.5-8.8,0-15.6,2.5-20.4,7.5-4.8,5.2-7.3,12.5-7.3,22v46.4h-22.4v-95.6h21.3v14h0ZM734.1,70.3h22.4v95.6h-22.4v-95.6ZM745.4,54.6c-4.1,0-7.5-1.3-10.2-3.9-2.7-2.4-4.2-5.9-4.1-9.5,0-3.8,1.4-7,4.1-9.7,2.7-2.5,6.1-3.8,10.2-3.8s7.5,1.3,10.2,3.6c2.7,2.5,4.1,5.5,4.1,9.3s-1.3,7.2-3.9,9.8c-2.7,2.7-6.3,4.1-10.4,4.1ZM839.5,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4v-95.6h21.3v12.3c3.8-4.5,8.4-7.7,14-10,5.5-2.3,12-3.4,19-3.4ZM964.8,160.7c-2.8,2.2-6,3.9-9.5,4.8-3.9,1.1-7.9,1.6-12,1.6-10.6,0-18.6-2.7-24.3-8.2-5.7-5.5-8.6-13.4-8.6-24v-46h-15.7v-17.9h15.7v-21.8h22.4v21.8h25.6v17.9h-25.6v45.5c0,4.7,1.1,8.2,3.4,10.6,2.3,2.5,5.5,3.8,9.8,3.8s9.1-1.3,12.5-3.9l6.3,15.9ZM1036.9,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4V33.1h22.4v48.3c3.8-3.9,8.2-7,13.8-9.1,5.4-2,11.5-3,18.1-3Z"
/>
</g>
</g>
</g>
</svg>
</template>

View File

@@ -1,291 +1,291 @@
<template>
<div class="contents">
<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 <span class="lowercase">{{ confirmActionText }}</span> the
server?
</p>
<UiCheckbox
v-model="dontAskAgain"
label="Don't ask me again"
class="text-sm"
:disabled="!powerAction"
/>
<div class="flex flex-row gap-4">
<ButtonStyled type="standard" color="brand" @click="executePowerAction">
<button>
<CheckIcon class="h-5 w-5" />
{{ confirmActionText }} server
</button>
</ButtonStyled>
<ButtonStyled @click="resetPowerAction">
<button>
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div class="contents">
<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
<span class="lowercase">{{ confirmActionText }}</span> the server?
</p>
<UiCheckbox
v-model="dontAskAgain"
label="Don't ask me again"
class="text-sm"
:disabled="!powerAction"
/>
<div class="flex flex-row gap-4">
<ButtonStyled type="standard" color="brand" @click="executePowerAction">
<button>
<CheckIcon class="h-5 w-5" />
{{ confirmActionText }} server
</button>
</ButtonStyled>
<ButtonStyled @click="resetPowerAction">
<button>
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal
ref="detailsModal"
:header="`All of ${serverName || 'Server'} info`"
@close="closeDetailsModal"
>
<UiServersServerInfoLabels
:server-data="serverData"
:show-game-label="true"
:show-loader-label="true"
:uptime-seconds="uptimeSeconds"
:column="true"
class="mb-6 flex flex-col gap-2"
/>
<div v-if="flags.advancedDebugInfo" class="markdown-body">
<pre>{{ serverData }}</pre>
</div>
<ButtonStyled type="standard" color="brand" @click="closeDetailsModal">
<button class="w-full">Close</button>
</ButtonStyled>
</NewModal>
<NewModal
ref="detailsModal"
:header="`All of ${serverName || 'Server'} info`"
@close="closeDetailsModal"
>
<UiServersServerInfoLabels
:server-data="serverData"
:show-game-label="true"
:show-loader-label="true"
:uptime-seconds="uptimeSeconds"
:column="true"
class="mb-6 flex flex-col gap-2"
/>
<div v-if="flags.advancedDebugInfo" class="markdown-body">
<pre>{{ serverData }}</pre>
</div>
<ButtonStyled type="standard" color="brand" @click="closeDetailsModal">
<button class="w-full">Close</button>
</ButtonStyled>
</NewModal>
<div class="flex flex-row items-center gap-2 rounded-lg">
<ButtonStyled v-if="isInstalling" type="standard" color="brand">
<button disabled class="flex-shrink-0">
<UiServersPanelSpinner class="size-5" /> Installing...
</button>
</ButtonStyled>
<div class="flex flex-row items-center gap-2 rounded-lg">
<ButtonStyled v-if="isInstalling" type="standard" color="brand">
<button disabled class="flex-shrink-0">
<UiServersPanelSpinner class="size-5" /> Installing...
</button>
</ButtonStyled>
<template v-else>
<ButtonStyled v-if="showStopButton" type="transparent">
<button :disabled="!canTakeAction" @click="initiateAction('Stop')">
<div class="flex gap-1">
<StopCircleIcon class="h-5 w-5" />
<span>{{ isStoppingState ? "Stopping..." : "Stop" }}</span>
</div>
</button>
</ButtonStyled>
<template v-else>
<ButtonStyled v-if="showStopButton" type="transparent">
<button :disabled="!canTakeAction" @click="initiateAction('Stop')">
<div class="flex gap-1">
<StopCircleIcon class="h-5 w-5" />
<span>{{ isStoppingState ? 'Stopping...' : 'Stop' }}</span>
</div>
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button :disabled="!canTakeAction" @click="handlePrimaryAction">
<div v-if="isTransitionState" class="grid place-content-center">
<UiServersIconsLoadingIcon />
</div>
<component :is="isRunning ? UpdatedIcon : PlayIcon" v-else />
<span>{{ primaryActionText }}</span>
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button :disabled="!canTakeAction" @click="handlePrimaryAction">
<div v-if="isTransitionState" class="grid place-content-center">
<UiServersIconsLoadingIcon />
</div>
<component :is="isRunning ? UpdatedIcon : PlayIcon" v-else />
<span>{{ primaryActionText }}</span>
</button>
</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>
<template #copy-id>
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
<span>Copy ID</span>
</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</template>
</div>
</div>
<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>
<template #copy-id>
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
<span>Copy ID</span>
</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import {
PlayIcon,
UpdatedIcon,
StopCircleIcon,
SlashIcon,
XIcon,
CheckIcon,
ServerIcon,
InfoIcon,
MoreVerticalIcon,
ClipboardCopyIcon,
} from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { useRouter } from "vue-router";
import { useStorage } from "@vueuse/core";
import type { PowerAction as ServerPowerAction, ServerState } from "@modrinth/utils";
CheckIcon,
ClipboardCopyIcon,
InfoIcon,
MoreVerticalIcon,
PlayIcon,
ServerIcon,
SlashIcon,
StopCircleIcon,
UpdatedIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
import type { PowerAction as ServerPowerAction, ServerState } from '@modrinth/utils'
import { useStorage } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
const flags = useFeatureFlags();
const flags = useFeatureFlags()
interface PowerAction {
action: ServerPowerAction;
nextState: ServerState;
action: ServerPowerAction
nextState: ServerState
}
const props = defineProps<{
isOnline: boolean;
isActioning: boolean;
isInstalling: boolean;
disabled: boolean;
serverName?: string;
serverData: object;
uptimeSeconds: number;
}>();
isOnline: boolean
isActioning: boolean
isInstalling: boolean
disabled: boolean
serverName?: string
serverData: object
uptimeSeconds: number
}>()
const emit = defineEmits<{
(e: "action", action: ServerPowerAction): void;
}>();
(e: 'action', action: ServerPowerAction): void
}>()
const router = useRouter();
const serverId = router.currentRoute.value.params.id;
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
const router = useRouter()
const serverId = router.currentRoute.value.params.id
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null)
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null)
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
powerDontAskAgain: false,
});
powerDontAskAgain: false,
})
const serverState = ref<ServerState>(props.isOnline ? "running" : "stopped");
const powerAction = ref<PowerAction | null>(null);
const dontAskAgain = ref(false);
const startingDelay = ref(false);
const serverState = ref<ServerState>(props.isOnline ? 'running' : 'stopped')
const powerAction = ref<PowerAction | null>(null)
const dontAskAgain = ref(false)
const startingDelay = ref(false)
const canTakeAction = computed(
() => !props.isActioning && !startingDelay.value && !isTransitionState.value,
);
const isRunning = computed(() => serverState.value === "running");
() => !props.isActioning && !startingDelay.value && !isTransitionState.value,
)
const isRunning = computed(() => serverState.value === 'running')
const isTransitionState = computed(() =>
["starting", "stopping", "restarting"].includes(serverState.value),
);
const isStoppingState = computed(() => serverState.value === "stopping");
const showStopButton = computed(() => isRunning.value || isStoppingState.value);
['starting', 'stopping', 'restarting'].includes(serverState.value),
)
const isStoppingState = computed(() => serverState.value === 'stopping')
const showStopButton = computed(() => isRunning.value || isStoppingState.value)
const primaryActionText = computed(() => {
const states: Partial<Record<ServerState, string>> = {
starting: "Starting...",
restarting: "Restarting...",
running: "Restart",
stopping: "Stopping...",
stopped: "Start",
};
return states[serverState.value];
});
const states: Partial<Record<ServerState, string>> = {
starting: 'Starting...',
restarting: 'Restarting...',
running: 'Restart',
stopping: 'Stopping...',
stopped: 'Start',
}
return states[serverState.value]
})
const confirmActionText = computed(() => {
if (!powerAction.value) return "";
return powerAction.value.action.charAt(0).toUpperCase() + powerAction.value.action.slice(1);
});
if (!powerAction.value) return ''
return powerAction.value.action.charAt(0).toUpperCase() + powerAction.value.action.slice(1)
})
const menuOptions = computed(() => [
...(props.isInstalling
? []
: [
{
id: "kill",
label: "Kill server",
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(),
},
{
id: "copy-id",
label: "Copy ID",
icon: ClipboardCopyIcon,
action: () => copyId(),
shown: flags.value.developerMode,
},
]);
...(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(),
},
{
id: 'copy-id',
label: 'Copy ID',
icon: ClipboardCopyIcon,
action: () => copyId(),
shown: flags.value.developerMode,
},
])
async function copyId() {
await navigator.clipboard.writeText(serverId as string);
await navigator.clipboard.writeText(serverId as string)
}
function initiateAction(action: ServerPowerAction) {
if (!canTakeAction.value) return;
if (!canTakeAction.value) return
const stateMap: Record<ServerPowerAction, ServerState> = {
Start: "starting",
Stop: "stopping",
Restart: "restarting",
Kill: "stopping",
};
const stateMap: Record<ServerPowerAction, ServerState> = {
Start: 'starting',
Stop: 'stopping',
Restart: 'restarting',
Kill: 'stopping',
}
if (action === "Start") {
emit("action", action);
serverState.value = stateMap[action];
startingDelay.value = true;
setTimeout(() => (startingDelay.value = false), 5000);
return;
}
if (action === 'Start') {
emit('action', action)
serverState.value = stateMap[action]
startingDelay.value = true
setTimeout(() => (startingDelay.value = false), 5000)
return
}
powerAction.value = { action, nextState: stateMap[action] };
powerAction.value = { action, nextState: stateMap[action] }
if (userPreferences.value.powerDontAskAgain) {
executePowerAction();
} else {
confirmActionModal.value?.show();
}
if (userPreferences.value.powerDontAskAgain) {
executePowerAction()
} else {
confirmActionModal.value?.show()
}
}
function handlePrimaryAction() {
initiateAction(isRunning.value ? "Restart" : "Start");
initiateAction(isRunning.value ? 'Restart' : 'Start')
}
function executePowerAction() {
if (!powerAction.value) return;
if (!powerAction.value) return
const { action, nextState } = powerAction.value;
emit("action", action);
serverState.value = nextState;
const { action, nextState } = powerAction.value
emit('action', action)
serverState.value = nextState
if (dontAskAgain.value) {
userPreferences.value.powerDontAskAgain = true;
}
if (dontAskAgain.value) {
userPreferences.value.powerDontAskAgain = true
}
if (action === "Start") {
startingDelay.value = true;
setTimeout(() => (startingDelay.value = false), 5000);
}
if (action === 'Start') {
startingDelay.value = true
setTimeout(() => (startingDelay.value = false), 5000)
}
resetPowerAction();
resetPowerAction()
}
function resetPowerAction() {
confirmActionModal.value?.hide();
powerAction.value = null;
dontAskAgain.value = false;
confirmActionModal.value?.hide()
powerAction.value = null
dontAskAgain.value = false
}
function closeDetailsModal() {
detailsModal.value?.hide();
detailsModal.value?.hide()
}
watch(
() => props.isOnline,
(online) => (serverState.value = online ? "running" : "stopped"),
);
() => props.isOnline,
(online) => (serverState.value = online ? 'running' : 'stopped'),
)
watch(
() => router.currentRoute.value.fullPath,
() => closeDetailsModal(),
);
() => router.currentRoute.value.fullPath,
() => closeDetailsModal(),
)
</script>

View File

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

View File

@@ -1,22 +1,22 @@
<template>
<svg
class="h-5 w-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
class="h-5 w-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -1,164 +1,165 @@
<template>
<NewModal
ref="modal"
:header="'Changing ' + props.project?.title + ' version'"
@hide="onHide"
@show="onShow"
>
<div class="flex flex-col gap-4 md:w-[600px]">
<div class="flex flex-col gap-2">
<p class="m-0">
Select the version of {{ props.project?.title || "the modpack" }} you want to install on
your server.
</p>
<p v-if="props.currentVersion" class="m-0 text-sm text-secondary">
Currently installed: {{ props.currentVersion.version_number }}
</p>
</div>
<NewModal
ref="modal"
:header="'Changing ' + props.project?.title + ' version'"
@hide="onHide"
@show="onShow"
>
<div class="flex flex-col gap-4 md:w-[600px]">
<div class="flex flex-col gap-2">
<p class="m-0">
Select the version of {{ props.project?.title || 'the modpack' }} you want to install on
your server.
</p>
<p v-if="props.currentVersion" class="m-0 text-sm text-secondary">
Currently installed: {{ props.currentVersion.version_number }}
</p>
</div>
<div class="flex w-full flex-col gap-4">
<UiServersTeleportDropdownMenu
v-if="props.versions?.length"
v-model="selectedVersion"
:options="versionOptions"
placeholder="Select version..."
name="version"
class="w-full max-w-full"
/>
<div class="flex w-full flex-col gap-4">
<UiServersTeleportDropdownMenu
v-if="props.versions?.length"
v-model="selectedVersion"
:options="versionOptions"
placeholder="Select version..."
name="version"
class="w-full max-w-full"
/>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="modpack-hard-reset">
Erase all data
</label>
<input
id="modpack-hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
If enabled, existing mods, worlds, and configurations, will be deleted before installing
the new modpack version.
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="modpack-hard-reset">
Erase all data
</label>
<input
id="modpack-hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
If enabled, existing mods, worlds, and configurations, will be deleted before installing
the new modpack version.
</div>
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="hardReset ? 'red' : 'brand'">
<button
:disabled="isLoading || !selectedVersion || props.serverStatus === 'installing'"
@click="handleReinstall"
>
<DownloadIcon class="size-4" />
{{ isLoading ? "Installing..." : hardReset ? "Erase and install" : "Install" }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="isLoading" @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</div>
</NewModal>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="hardReset ? 'red' : 'brand'">
<button
:disabled="isLoading || !selectedVersion || props.serverStatus === 'installing'"
@click="handleReinstall"
>
<DownloadIcon class="size-4" />
{{ isLoading ? 'Installing...' : hardReset ? 'Erase and install' : 'Install' }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="isLoading" @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { DownloadIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, injectNotificationManager, NewModal } from "@modrinth/ui";
import { ModrinthServersFetchError } from "@modrinth/utils";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
import { ModrinthServersFetchError } from '@modrinth/utils'
const { addNotification } = injectNotificationManager();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer;
project: any;
versions: any[];
currentVersion?: any;
currentVersionId?: string;
serverStatus?: string;
}>();
server: ModrinthServer
project: any
versions: any[]
currentVersion?: any
currentVersionId?: string
serverStatus?: string
}>()
const emit = defineEmits<{
reinstall: [any?];
}>();
reinstall: [any?]
}>()
const modal = ref();
const hardReset = ref(false);
const isLoading = ref(false);
const selectedVersion = ref("");
const modal = ref()
const hardReset = ref(false)
const isLoading = ref(false)
const selectedVersion = ref('')
const versionOptions = computed(() => props.versions?.map((v) => v.version_number) || []);
const versionOptions = computed(() => props.versions?.map((v) => v.version_number) || [])
const handleReinstall = async () => {
if (!selectedVersion.value || !props.project?.id) return;
if (!selectedVersion.value || !props.project?.id) return
isLoading.value = true;
try {
const versionId = props.versions.find((v) => v.version_number === selectedVersion.value)?.id;
isLoading.value = true
try {
const versionId = props.versions.find((v) => v.version_number === selectedVersion.value)?.id
await props.server.general.reinstall(
false,
props.project.id,
versionId,
undefined,
hardReset.value,
);
await props.server.general.reinstall(
false,
props.project.id,
versionId,
undefined,
hardReset.value,
)
emit("reinstall");
hide();
} catch (error) {
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
addNotification({
title: "Cannot reinstall server",
text: "You are being rate limited. Please try again later.",
type: "error",
});
} else {
addNotification({
title: "Reinstall Failed",
text: "An unexpected error occurred while reinstalling. Please try again later.",
type: "error",
});
}
} finally {
isLoading.value = false;
}
};
emit('reinstall')
hide()
} catch (error) {
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
addNotification({
title: 'Cannot reinstall server',
text: 'You are being rate limited. Please try again later.',
type: 'error',
})
} else {
addNotification({
title: 'Reinstall Failed',
text: 'An unexpected error occurred while reinstalling. Please try again later.',
type: 'error',
})
}
} finally {
isLoading.value = false
}
}
watch(
() => props.serverStatus,
(newStatus) => {
if (newStatus === "installing") {
hide();
}
},
);
() => props.serverStatus,
(newStatus) => {
if (newStatus === 'installing') {
hide()
}
},
)
const onShow = () => {
hardReset.value = false;
selectedVersion.value =
props.currentVersion?.version_number ?? props.versions?.[0]?.version_number ?? "";
};
hardReset.value = false
selectedVersion.value =
props.currentVersion?.version_number ?? props.versions?.[0]?.version_number ?? ''
}
const onHide = () => {
hardReset.value = false;
selectedVersion.value = "";
isLoading.value = false;
};
hardReset.value = false
selectedVersion.value = ''
isLoading.value = false
}
const show = () => modal.value?.show();
const hide = () => modal.value?.hide();
const show = () => modal.value?.show()
const hide = () => modal.value?.hide()
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -1,361 +1,362 @@
<template>
<NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow">
<div class="flex flex-col gap-4 md:w-[600px]">
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="isLoading" class="w-full">
<div class="mb-2 flex justify-between text-sm">
<Transition name="phrase-fade" mode="out-in">
<span :key="currentPhrase" class="text-lg font-medium text-contrast">{{
currentPhrase
}}</span>
</Transition>
<div class="flex flex-col items-end">
<span class="text-secondary">{{ Math.round(uploadProgress) }}%</span>
<span class="text-xs text-secondary"
>{{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }}</span
>
</div>
</div>
<div class="h-2 w-full rounded-full bg-divider">
<div
class="h-2 animate-pulse rounded-full bg-brand transition-all duration-300 ease-out"
:style="{ width: `${uploadProgress}%` }"
></div>
</div>
</div>
</Transition>
<NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow">
<div class="flex flex-col gap-4 md:w-[600px]">
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="isLoading" class="w-full">
<div class="mb-2 flex justify-between text-sm">
<Transition name="phrase-fade" mode="out-in">
<span :key="currentPhrase" class="text-lg font-medium text-contrast">{{
currentPhrase
}}</span>
</Transition>
<div class="flex flex-col items-end">
<span class="text-secondary">{{ Math.round(uploadProgress) }}%</span>
<span class="text-xs text-secondary"
>{{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }}</span
>
</div>
</div>
<div class="h-2 w-full rounded-full bg-divider">
<div
class="h-2 animate-pulse rounded-full bg-brand transition-all duration-300 ease-out"
:style="{ width: `${uploadProgress}%` }"
></div>
</div>
</div>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="!isLoading" class="flex flex-col gap-4">
<p
v-if="isMrpackModalSecondPhase"
:style="{
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
}"
>
This will reinstall your server and erase all data. You may want to back up your server
before proceeding. Are you sure you want to continue?
</p>
<div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4">
<div class="mx-auto flex flex-row items-center gap-4">
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
>
<UploadIcon class="size-10" />
</div>
<ArrowBigRightDashIcon class="size-10" />
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
>
<ServerIcon class="size-10" />
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
<input
type="file"
accept=".mrpack"
class=""
:disabled="isLoading"
@change="uploadMrpack"
/>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="!isLoading" class="flex flex-col gap-4">
<p
v-if="isMrpackModalSecondPhase"
:style="{
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
}"
>
This will reinstall your server and erase all data. You may want to back up your server
before proceeding. Are you sure you want to continue?
</p>
<div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4">
<div class="mx-auto flex flex-row items-center gap-4">
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
>
<UploadIcon class="size-10" />
</div>
<ArrowBigRightDashIcon class="size-10" />
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
>
<ServerIcon class="size-10" />
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
<input
type="file"
accept=".mrpack"
class=""
:disabled="isLoading"
@change="uploadMrpack"
/>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
</label>
<input
id="hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
Removes all data on your server, including your worlds, mods, and configuration
files, then reinstalls it with the selected version.
</div>
<div class="font-bold">
This does not affect your backups, which are stored off-site.
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
</label>
<input
id="hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
Removes all data on your server, including your worlds, mods, and configuration
files, then reinstalls it with the selected version.
</div>
<div class="font-bold">
This does not affect your backups, which are stored off-site.
</div>
</div>
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button
v-tooltip="backupInProgress ? backupInProgress.tooltip : undefined"
:disabled="canInstall || !!backupInProgress"
@click="handleReinstall"
>
<RightArrowIcon />
{{
isMrpackModalSecondPhase
? "Erase and install"
: loadingServerCheck
? "Loading..."
: isDangerous
? "Erase and install"
: "Install"
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
:disabled="isLoading"
@click="
() => {
if (isMrpackModalSecondPhase) {
isMrpackModalSecondPhase = false;
} else {
hide();
}
}
"
>
<XIcon />
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }}
</button>
</ButtonStyled>
</div>
</div>
</Transition>
</div>
</NewModal>
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button
v-tooltip="backupInProgress ? backupInProgress.tooltip : undefined"
:disabled="canInstall || !!backupInProgress"
@click="handleReinstall"
>
<RightArrowIcon />
{{
isMrpackModalSecondPhase
? 'Erase and install'
: loadingServerCheck
? 'Loading...'
: isDangerous
? 'Erase and install'
: 'Install'
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
:disabled="isLoading"
@click="
() => {
if (isMrpackModalSecondPhase) {
isMrpackModalSecondPhase = false
} else {
hide()
}
}
"
>
<XIcon />
{{ isMrpackModalSecondPhase ? 'Go back' : 'Cancel' }}
</button>
</ButtonStyled>
</div>
</div>
</Transition>
</div>
</NewModal>
</template>
<script setup lang="ts">
import {
ArrowBigRightDashIcon,
RightArrowIcon,
ServerIcon,
UploadIcon,
XIcon,
} from "@modrinth/assets";
import { BackupWarning, ButtonStyled, injectNotificationManager, NewModal } from "@modrinth/ui";
import { formatBytes, ModrinthServersFetchError } from "@modrinth/utils";
import { onMounted, onUnmounted } from "vue";
import type { ModrinthServer } from "~/composables/servers/modrinth-servers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
ArrowBigRightDashIcon,
RightArrowIcon,
ServerIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { BackupWarning, ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
import { formatBytes, ModrinthServersFetchError } from '@modrinth/utils'
import { onMounted, onUnmounted } from 'vue'
const { addNotification } = injectNotificationManager();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
const { addNotification } = injectNotificationManager()
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (isLoading.value) {
event.preventDefault();
return "Upload in progress. Are you sure you want to leave?";
}
};
if (isLoading.value) {
event.preventDefault()
return 'Upload in progress. Are you sure you want to leave?'
}
}
onMounted(() => {
window.addEventListener("beforeunload", handleBeforeUnload);
});
window.addEventListener('beforeunload', handleBeforeUnload)
})
onUnmounted(() => {
window.removeEventListener("beforeunload", handleBeforeUnload);
});
window.removeEventListener('beforeunload', handleBeforeUnload)
})
const props = defineProps<{
server: ModrinthServer;
backupInProgress?: BackupInProgressReason;
}>();
server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
const emit = defineEmits<{
reinstall: [any?];
}>();
reinstall: [any?]
}>()
const mrpackModal = ref();
const isMrpackModalSecondPhase = ref(false);
const hardReset = ref(false);
const isLoading = ref(false);
const loadingServerCheck = ref(false);
const mrpackFile = ref<File | null>(null);
const uploadProgress = ref(0);
const uploadedBytes = ref(0);
const totalBytes = ref(0);
const mrpackModal = ref()
const isMrpackModalSecondPhase = ref(false)
const hardReset = ref(false)
const isLoading = ref(false)
const loadingServerCheck = ref(false)
const mrpackFile = ref<File | null>(null)
const uploadProgress = ref(0)
const uploadedBytes = ref(0)
const totalBytes = ref(0)
const uploadPhrases = [
"Removing Herobrine...",
"Feeding parrots...",
"Teaching villagers new trades...",
"Convincing creepers to be friendly...",
"Polishing diamonds...",
"Training wolves to fetch...",
"Building pixel art...",
"Explaining redstone to beginners...",
"Collecting all the cats...",
"Negotiating with endermen...",
"Planting suspicious stew ingredients...",
"Calibrating TNT blast radius...",
"Teaching chickens to fly...",
"Sorting inventory alphabetically...",
"Convincing iron golems to smile...",
];
'Removing Herobrine...',
'Feeding parrots...',
'Teaching villagers new trades...',
'Convincing creepers to be friendly...',
'Polishing diamonds...',
'Training wolves to fetch...',
'Building pixel art...',
'Explaining redstone to beginners...',
'Collecting all the cats...',
'Negotiating with endermen...',
'Planting suspicious stew ingredients...',
'Calibrating TNT blast radius...',
'Teaching chickens to fly...',
'Sorting inventory alphabetically...',
'Convincing iron golems to smile...',
]
const currentPhrase = ref("Uploading...");
let phraseInterval: NodeJS.Timeout | null = null;
const usedPhrases = ref(new Set<number>());
const currentPhrase = ref('Uploading...')
let phraseInterval: NodeJS.Timeout | null = null
const usedPhrases = ref(new Set<number>())
const getNextPhrase = () => {
if (usedPhrases.value.size >= uploadPhrases.length) {
const currentPhraseIndex = uploadPhrases.indexOf(currentPhrase.value);
usedPhrases.value.clear();
if (currentPhraseIndex !== -1) {
usedPhrases.value.add(currentPhraseIndex);
}
}
const availableIndices = uploadPhrases
.map((_, index) => index)
.filter((index) => !usedPhrases.value.has(index));
if (usedPhrases.value.size >= uploadPhrases.length) {
const currentPhraseIndex = uploadPhrases.indexOf(currentPhrase.value)
usedPhrases.value.clear()
if (currentPhraseIndex !== -1) {
usedPhrases.value.add(currentPhraseIndex)
}
}
const availableIndices = uploadPhrases
.map((_, index) => index)
.filter((index) => !usedPhrases.value.has(index))
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)];
usedPhrases.value.add(randomIndex);
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)]
usedPhrases.value.add(randomIndex)
return uploadPhrases[randomIndex];
};
return uploadPhrases[randomIndex]
}
const isDangerous = computed(() => hardReset.value);
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
const isDangerous = computed(() => hardReset.value)
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value)
const uploadMrpack = (event: Event) => {
const target = event.target as HTMLInputElement;
if (!target.files || target.files.length === 0) {
return;
}
mrpackFile.value = target.files[0];
};
const target = event.target as HTMLInputElement
if (!target.files || target.files.length === 0) {
return
}
mrpackFile.value = target.files[0]
}
const handleReinstall = async () => {
if (hardReset.value && !isMrpackModalSecondPhase.value) {
isMrpackModalSecondPhase.value = true;
return;
}
if (hardReset.value && !isMrpackModalSecondPhase.value) {
isMrpackModalSecondPhase.value = true
return
}
if (!mrpackFile.value) {
addNotification({
title: "No file selected",
text: "Choose a .mrpack file before installing.",
type: "error",
});
return;
}
if (!mrpackFile.value) {
addNotification({
title: 'No file selected',
text: 'Choose a .mrpack file before installing.',
type: 'error',
})
return
}
isLoading.value = true;
uploadProgress.value = 0;
uploadProgress.value = 0;
uploadedBytes.value = 0;
totalBytes.value = mrpackFile.value.size;
isLoading.value = true
uploadProgress.value = 0
uploadProgress.value = 0
uploadedBytes.value = 0
totalBytes.value = mrpackFile.value.size
currentPhrase.value = getNextPhrase();
phraseInterval = setInterval(() => {
currentPhrase.value = getNextPhrase();
}, 4500);
currentPhrase.value = getNextPhrase()
phraseInterval = setInterval(() => {
currentPhrase.value = getNextPhrase()
}, 4500)
const { onProgress, promise } = props.server.general.reinstallFromMrpack(
mrpackFile.value,
hardReset.value,
);
const { onProgress, promise } = props.server.general.reinstallFromMrpack(
mrpackFile.value,
hardReset.value,
)
onProgress(({ loaded, total, progress }) => {
uploadProgress.value = progress;
uploadedBytes.value = loaded;
totalBytes.value = total;
onProgress(({ loaded, total, progress }) => {
uploadProgress.value = progress
uploadedBytes.value = loaded
totalBytes.value = total
if (phraseInterval && progress >= 100) {
clearInterval(phraseInterval);
phraseInterval = null;
currentPhrase.value = "Installing modpack...";
}
});
if (phraseInterval && progress >= 100) {
clearInterval(phraseInterval)
phraseInterval = null
currentPhrase.value = 'Installing modpack...'
}
})
try {
await promise;
try {
await promise
emit("reinstall", {
loader: "mrpack",
lVersion: "",
mVersion: "",
});
emit('reinstall', {
loader: 'mrpack',
lVersion: '',
mVersion: '',
})
await nextTick();
window.scrollTo(0, 0);
hide();
} catch (error) {
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
addNotification({
title: "Cannot upload and install modpack to server",
text: "You are being rate limited. Please try again later.",
type: "error",
});
} else {
addNotification({
title: "Modpack upload and install failed",
text: "An unexpected error occurred while uploading/installing. Please try again later.",
type: "error",
});
}
} finally {
isLoading.value = false;
if (phraseInterval) {
clearInterval(phraseInterval);
phraseInterval = null;
}
}
};
await nextTick()
window.scrollTo(0, 0)
hide()
} catch (error) {
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
addNotification({
title: 'Cannot upload and install modpack to server',
text: 'You are being rate limited. Please try again later.',
type: 'error',
})
} else {
addNotification({
title: 'Modpack upload and install failed',
text: 'An unexpected error occurred while uploading/installing. Please try again later.',
type: 'error',
})
}
} finally {
isLoading.value = false
if (phraseInterval) {
clearInterval(phraseInterval)
phraseInterval = null
}
}
}
const onShow = () => {
hardReset.value = false;
isMrpackModalSecondPhase.value = false;
loadingServerCheck.value = false;
isLoading.value = false;
mrpackFile.value = null;
uploadProgress.value = 0;
uploadedBytes.value = 0;
totalBytes.value = 0;
currentPhrase.value = "Uploading...";
usedPhrases.value.clear();
if (phraseInterval) {
clearInterval(phraseInterval);
phraseInterval = null;
}
};
hardReset.value = false
isMrpackModalSecondPhase.value = false
loadingServerCheck.value = false
isLoading.value = false
mrpackFile.value = null
uploadProgress.value = 0
uploadedBytes.value = 0
totalBytes.value = 0
currentPhrase.value = 'Uploading...'
usedPhrases.value.clear()
if (phraseInterval) {
clearInterval(phraseInterval)
phraseInterval = null
}
}
const show = () => mrpackModal.value?.show();
const hide = () => mrpackModal.value?.hide();
const show = () => mrpackModal.value?.show()
const hide = () => mrpackModal.value?.hide()
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
background: var(--color-accent-contrast) !important;
}
.phrase-fade-enter-active,
.phrase-fade-leave-active {
transition: opacity 0.3s ease;
transition: opacity 0.3s ease;
}
.phrase-fade-enter-from,
.phrase-fade-leave-to {
opacity: 0;
opacity: 0;
}
</style>

View File

@@ -1,537 +1,537 @@
<template>
<NewModal
ref="versionSelectModal"
:header="
isSecondPhase
? 'Confirming reinstallation'
: `${props.currentLoader === selectedLoader ? 'Reinstalling' : 'Installing'}
<NewModal
ref="versionSelectModal"
:header="
isSecondPhase
? 'Confirming reinstallation'
: `${props.currentLoader === selectedLoader ? 'Reinstalling' : 'Installing'}
${selectedLoader.toLowerCase() === 'vanilla' ? 'Vanilla Minecraft' : selectedLoader}`
"
@hide="onHide"
@show="onShow"
>
<div class="flex flex-col gap-4 md:w-[600px]">
<p
v-if="isSecondPhase"
:style="{
lineHeight: isSecondPhase ? '1.5' : undefined,
marginBottom: isSecondPhase ? '-12px' : '0',
marginTop: isSecondPhase ? '-4px' : '-2px',
}"
>
{{
"This will reinstall your server and erase all data. Are you sure you want to continue?"
}}
</p>
<div v-if="!isSecondPhase" class="flex flex-col gap-4">
<div class="mx-auto flex flex-row items-center gap-4">
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
>
<UiServersIconsLoaderIcon class="size-10" :loader="selectedLoader" />
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-10"
>
<path d="M5 9v6" />
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
</svg>
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
>
<ServerIcon class="size-10" />
</div>
</div>
"
@hide="onHide"
@show="onShow"
>
<div class="flex flex-col gap-4 md:w-[600px]">
<p
v-if="isSecondPhase"
:style="{
lineHeight: isSecondPhase ? '1.5' : undefined,
marginBottom: isSecondPhase ? '-12px' : '0',
marginTop: isSecondPhase ? '-4px' : '-2px',
}"
>
{{
'This will reinstall your server and erase all data. Are you sure you want to continue?'
}}
</p>
<div v-if="!isSecondPhase" class="flex flex-col gap-4">
<div class="mx-auto flex flex-row items-center gap-4">
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
>
<UiServersIconsLoaderIcon class="size-10" :loader="selectedLoader" />
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-10"
>
<path d="M5 9v6" />
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
</svg>
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
>
<ServerIcon class="size-10" />
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-lg font-bold text-contrast">Minecraft version</div>
<UiServersTeleportDropdownMenu
v-model="selectedMCVersion"
name="mcVersion"
:options="mcVersions"
class="w-full max-w-[100%]"
placeholder="Select Minecraft version..."
/>
<div class="mt-2 flex items-center justify-between gap-2">
<label for="toggle-snapshots" class="font-semibold"> Show snapshot versions </label>
<div
v-tooltip="
isSnapshotSelected ? 'A snapshot version is currently selected.' : undefined
"
>
<Toggle
id="toggle-snapshots"
v-model="showSnapshots"
:disabled="isSnapshotSelected"
/>
</div>
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-lg font-bold text-contrast">Minecraft version</div>
<UiServersTeleportDropdownMenu
v-model="selectedMCVersion"
name="mcVersion"
:options="mcVersions"
class="w-full max-w-[100%]"
placeholder="Select Minecraft version..."
/>
<div class="mt-2 flex items-center justify-between gap-2">
<label for="toggle-snapshots" class="font-semibold"> Show snapshot versions </label>
<div
v-tooltip="
isSnapshotSelected ? 'A snapshot version is currently selected.' : undefined
"
>
<Toggle
id="toggle-snapshots"
v-model="showSnapshots"
:disabled="isSnapshotSelected"
/>
</div>
</div>
</div>
<div
v-if="selectedLoader.toLowerCase() !== 'vanilla'"
class="flex w-full flex-col gap-2 rounded-2xl p-4"
:class="{
'bg-table-alternateRow':
!selectedMCVersion || isLoading || selectedLoaderVersions.length > 0,
'bg-highlight-red':
selectedMCVersion && !isLoading && selectedLoaderVersions.length === 0,
}"
>
<div class="flex flex-col gap-2">
<div class="text-lg font-bold text-contrast">{{ selectedLoader }} version</div>
<div
v-if="selectedLoader.toLowerCase() !== 'vanilla'"
class="flex w-full flex-col gap-2 rounded-2xl p-4"
:class="{
'bg-table-alternateRow':
!selectedMCVersion || isLoading || selectedLoaderVersions.length > 0,
'bg-highlight-red':
selectedMCVersion && !isLoading && selectedLoaderVersions.length === 0,
}"
>
<div class="flex flex-col gap-2">
<div class="text-lg font-bold text-contrast">{{ selectedLoader }} version</div>
<template v-if="!selectedMCVersion">
<div
class="relative flex h-9 w-full select-none items-center rounded-xl bg-button-bg px-4 opacity-50"
>
Select a Minecraft version to see available versions
<DropdownIcon class="absolute right-4" />
</div>
</template>
<template v-else-if="isLoading">
<div
class="relative flex h-9 w-full items-center rounded-xl bg-button-bg px-4 opacity-50"
>
<UiServersIconsLoadingIcon class="mr-2 animate-spin" />
Loading versions...
<DropdownIcon class="absolute right-4" />
</div>
</template>
<template v-else-if="selectedLoaderVersions.length > 0">
<UiServersTeleportDropdownMenu
v-model="selectedLoaderVersion"
name="loaderVersion"
:options="selectedLoaderVersions"
class="w-full max-w-[100%]"
:placeholder="
selectedLoader.toLowerCase() === 'paper' ||
selectedLoader.toLowerCase() === 'purpur'
? `Select build number...`
: `Select loader version...`
"
/>
</template>
<template v-else>
<div>No versions available for Minecraft {{ selectedMCVersion }}.</div>
</template>
</div>
</div>
<template v-if="!selectedMCVersion">
<div
class="relative flex h-9 w-full select-none items-center rounded-xl bg-button-bg px-4 opacity-50"
>
Select a Minecraft version to see available versions
<DropdownIcon class="absolute right-4" />
</div>
</template>
<template v-else-if="isLoading">
<div
class="relative flex h-9 w-full items-center rounded-xl bg-button-bg px-4 opacity-50"
>
<UiServersIconsLoadingIcon class="mr-2 animate-spin" />
Loading versions...
<DropdownIcon class="absolute right-4" />
</div>
</template>
<template v-else-if="selectedLoaderVersions.length > 0">
<UiServersTeleportDropdownMenu
v-model="selectedLoaderVersion"
name="loaderVersion"
:options="selectedLoaderVersions"
class="w-full max-w-[100%]"
:placeholder="
selectedLoader.toLowerCase() === 'paper' ||
selectedLoader.toLowerCase() === 'purpur'
? `Select build number...`
: `Select loader version...`
"
/>
</template>
<template v-else>
<div>No versions available for Minecraft {{ selectedMCVersion }}.</div>
</template>
</div>
</div>
<div
v-if="!initialSetup"
class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4"
>
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
</label>
<input
id="hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
Removes all data on your server, including your worlds, mods, and configuration files,
then reinstalls it with the selected version.
</div>
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
</div>
<div
v-if="!initialSetup"
class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4"
>
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
</label>
<input
id="hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
Removes all data on your server, including your worlds, mods, and configuration files,
then reinstalls it with the selected version.
</div>
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
</div>
<BackupWarning
v-if="!initialSetup"
:backup-link="`/servers/manage/${props.server?.serverId}/backups`"
/>
</div>
<BackupWarning
v-if="!initialSetup"
:backup-link="`/servers/manage/${props.server?.serverId}/backups`"
/>
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="canInstall || !!backupInProgress"
@click="handleReinstall"
>
<RightArrowIcon />
{{
isLoading
? "Installing..."
: isSecondPhase
? "Erase and install"
: hardReset
? "Continue"
: "Install"
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
:disabled="isLoading"
@click="
() => {
if (isSecondPhase) {
isSecondPhase = false;
} else {
hide();
}
}
"
>
<XIcon />
{{ isSecondPhase ? "Go back" : "Cancel" }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="canInstall || !!backupInProgress"
@click="handleReinstall"
>
<RightArrowIcon />
{{
isLoading
? 'Installing...'
: isSecondPhase
? 'Erase and install'
: hardReset
? 'Continue'
: 'Install'
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
:disabled="isLoading"
@click="
() => {
if (isSecondPhase) {
isSecondPhase = false
} else {
hide()
}
}
"
>
<XIcon />
{{ isSecondPhase ? 'Go back' : 'Cancel' }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from "@modrinth/assets";
import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from '@modrinth/assets'
import {
BackupWarning,
ButtonStyled,
injectNotificationManager,
NewModal,
Toggle,
} from "@modrinth/ui";
import { type Loaders, ModrinthServersFetchError } from "@modrinth/utils";
import { $fetch } from "ofetch";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
BackupWarning,
ButtonStyled,
injectNotificationManager,
NewModal,
Toggle,
} from '@modrinth/ui'
import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
import { $fetch } from 'ofetch'
const { addNotification } = injectNotificationManager();
const { formatMessage } = useVIntl();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
interface LoaderVersion {
id: string;
stable: boolean;
loaders: {
id: string;
url: string;
stable: boolean;
}[];
id: string
stable: boolean
loaders: {
id: string
url: string
stable: boolean
}[]
}
type VersionMap = Record<string, LoaderVersion[]>;
type VersionCache = Record<string, any>;
type VersionMap = Record<string, LoaderVersion[]>
type VersionCache = Record<string, any>
const props = defineProps<{
server: ModrinthServer;
currentLoader: Loaders | undefined;
backupInProgress?: BackupInProgressReason;
initialSetup?: boolean;
}>();
server: ModrinthServer
currentLoader: Loaders | undefined
backupInProgress?: BackupInProgressReason
initialSetup?: boolean
}>()
const emit = defineEmits<{
reinstall: [any?];
}>();
reinstall: [any?]
}>()
const versionSelectModal = ref();
const isSecondPhase = ref(false);
const hardReset = ref(false);
const isLoading = ref(false);
const loadingServerCheck = ref(false);
const serverCheckError = ref("");
const showSnapshots = ref(false);
const versionSelectModal = ref()
const isSecondPhase = ref(false)
const hardReset = ref(false)
const isLoading = ref(false)
const loadingServerCheck = ref(false)
const serverCheckError = ref('')
const showSnapshots = ref(false)
const selectedLoader = ref<Loaders>("Vanilla");
const selectedMCVersion = ref("");
const selectedLoaderVersion = ref("");
const selectedLoader = ref<Loaders>('Vanilla')
const selectedMCVersion = ref('')
const selectedLoaderVersion = ref('')
const paperVersions = ref<Record<string, number[]>>({});
const purpurVersions = ref<Record<string, string[]>>({});
const loaderVersions = ref<VersionMap>({});
const cachedVersions = ref<VersionCache>({});
const paperVersions = ref<Record<string, number[]>>({})
const purpurVersions = ref<Record<string, string[]>>({})
const loaderVersions = ref<VersionMap>({})
const cachedVersions = ref<VersionCache>({})
const versionStrings = ["forge", "fabric", "quilt", "neo"] as const;
const versionStrings = ['forge', 'fabric', 'quilt', 'neo'] as const
const isSnapshotSelected = computed(() => {
if (selectedMCVersion.value) {
const selected = tags.value.gameVersions.find((x) => x.version === selectedMCVersion.value);
if (selected?.version_type !== "release") {
return true;
}
}
return false;
});
if (selectedMCVersion.value) {
const selected = tags.value.gameVersions.find((x) => x.version === selectedMCVersion.value)
if (selected?.version_type !== 'release') {
return true
}
}
return false
})
const getLoaderVersions = async (loader: string) => {
return await $fetch(
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/manifest.json`,
);
};
return await $fetch(
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/manifest.json`,
)
}
const fetchLoaderVersions = async () => {
const versions = await Promise.all(
versionStrings.map(async (loader) => {
const runFetch = async (iterations: number) => {
if (iterations > 5) {
throw new Error("Failed to fetch loader versions");
}
try {
const res = await getLoaderVersions(loader);
return { [loader]: (res as any).gameVersions };
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_) {
return await runFetch(iterations + 1);
}
};
try {
return await runFetch(0);
} catch (e) {
console.error(e);
return { [loader]: [] };
}
}),
);
const versions = await Promise.all(
versionStrings.map(async (loader) => {
const runFetch = async (iterations: number) => {
if (iterations > 5) {
throw new Error('Failed to fetch loader versions')
}
try {
const res = await getLoaderVersions(loader)
return { [loader]: (res as any).gameVersions }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_) {
return await runFetch(iterations + 1)
}
}
try {
return await runFetch(0)
} catch (e) {
console.error(e)
return { [loader]: [] }
}
}),
)
loaderVersions.value = versions.reduce((acc, val) => ({ ...acc, ...val }), {});
};
loaderVersions.value = versions.reduce((acc, val) => ({ ...acc, ...val }), {})
}
const fetchPaperVersions = async (mcVersion: string) => {
try {
const res = await $fetch(`https://api.papermc.io/v2/projects/paper/versions/${mcVersion}`);
paperVersions.value[mcVersion] = (res as any).builds.sort((a: number, b: number) => b - a);
return res;
} catch (e) {
console.error(e);
return null;
}
};
try {
const res = await $fetch(`https://api.papermc.io/v2/projects/paper/versions/${mcVersion}`)
paperVersions.value[mcVersion] = (res as any).builds.sort((a: number, b: number) => b - a)
return res
} catch (e) {
console.error(e)
return null
}
}
const fetchPurpurVersions = async (mcVersion: string) => {
try {
const res = await $fetch(`https://api.purpurmc.org/v2/purpur/${mcVersion}`);
purpurVersions.value[mcVersion] = (res as any).builds.all.sort(
(a: string, b: string) => parseInt(b) - parseInt(a),
);
return res;
} catch (e) {
console.error(e);
return null;
}
};
try {
const res = await $fetch(`https://api.purpurmc.org/v2/purpur/${mcVersion}`)
purpurVersions.value[mcVersion] = (res as any).builds.all.sort(
(a: string, b: string) => parseInt(b) - parseInt(a),
)
return res
} catch (e) {
console.error(e)
return null
}
}
const selectedLoaderVersions = computed<string[]>(() => {
const loader = selectedLoader.value.toLowerCase();
const loader = selectedLoader.value.toLowerCase()
if (loader === "paper") {
return paperVersions.value[selectedMCVersion.value]?.map((x) => `${x}`) || [];
}
if (loader === 'paper') {
return paperVersions.value[selectedMCVersion.value]?.map((x) => `${x}`) || []
}
if (loader === "purpur") {
return purpurVersions.value[selectedMCVersion.value] || [];
}
if (loader === 'purpur') {
return purpurVersions.value[selectedMCVersion.value] || []
}
if (loader === "vanilla") {
return [];
}
if (loader === 'vanilla') {
return []
}
let apiLoader = loader;
if (loader === "neoforge") {
apiLoader = "neo";
}
let apiLoader = loader
if (loader === 'neoforge') {
apiLoader = 'neo'
}
const backwardsCompatibleVersion = loaderVersions.value[apiLoader]?.find(
// eslint-disable-next-line no-template-curly-in-string
(x) => x.id === "${modrinth.gameVersion}",
);
const backwardsCompatibleVersion = loaderVersions.value[apiLoader]?.find(
(x) => x.id === '${modrinth.gameVersion}',
)
if (backwardsCompatibleVersion) {
return backwardsCompatibleVersion.loaders.map((x) => x.id);
}
if (backwardsCompatibleVersion) {
return backwardsCompatibleVersion.loaders.map((x) => x.id)
}
return (
loaderVersions.value[apiLoader]
?.find((x) => x.id === selectedMCVersion.value)
?.loaders.map((x) => x.id) || []
);
});
return (
loaderVersions.value[apiLoader]
?.find((x) => x.id === selectedMCVersion.value)
?.loaders.map((x) => x.id) || []
)
})
watch(selectedLoader, async () => {
if (selectedMCVersion.value) {
selectedLoaderVersion.value = "";
serverCheckError.value = "";
if (selectedMCVersion.value) {
selectedLoaderVersion.value = ''
serverCheckError.value = ''
await checkVersionAvailability(selectedMCVersion.value);
}
});
await checkVersionAvailability(selectedMCVersion.value)
}
})
watch(
selectedLoaderVersions,
(newVersions) => {
if (
newVersions.length > 0 &&
(!selectedLoaderVersion.value || !newVersions.includes(selectedLoaderVersion.value))
) {
selectedLoaderVersion.value = String(newVersions[0]);
}
},
{ immediate: true },
);
selectedLoaderVersions,
(newVersions) => {
if (
newVersions.length > 0 &&
(!selectedLoaderVersion.value || !newVersions.includes(selectedLoaderVersion.value))
) {
selectedLoaderVersion.value = String(newVersions[0])
}
},
{ immediate: true },
)
const getLoaderVersion = async (loader: string, version: string) => {
return await $fetch(
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/versions/${version}.json`,
);
};
return await $fetch(
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/versions/${version}.json`,
)
}
const checkVersionAvailability = async (version: string) => {
if (!version || version.trim().length < 3) return;
if (!version || version.trim().length < 3) return
isLoading.value = true;
loadingServerCheck.value = true;
isLoading.value = true
loadingServerCheck.value = true
try {
const mcRes = cachedVersions.value[version] || (await getLoaderVersion("minecraft", version));
try {
const mcRes = cachedVersions.value[version] || (await getLoaderVersion('minecraft', version))
cachedVersions.value[version] = mcRes;
cachedVersions.value[version] = mcRes
if (!mcRes.downloads?.server) {
serverCheckError.value = "We couldn't find a server.jar for this version.";
return;
}
if (!mcRes.downloads?.server) {
serverCheckError.value = "We couldn't find a server.jar for this version."
return
}
const loader = selectedLoader.value.toLowerCase();
if (loader === "paper" || loader === "purpur") {
const fetchFn = loader === "paper" ? fetchPaperVersions : fetchPurpurVersions;
const result = await fetchFn(version);
if (!result) {
serverCheckError.value = `This Minecraft version is not supported by ${loader}.`;
return;
}
}
const loader = selectedLoader.value.toLowerCase()
if (loader === 'paper' || loader === 'purpur') {
const fetchFn = loader === 'paper' ? fetchPaperVersions : fetchPurpurVersions
const result = await fetchFn(version)
if (!result) {
serverCheckError.value = `This Minecraft version is not supported by ${loader}.`
return
}
}
serverCheckError.value = "";
} catch (error) {
console.error(error);
serverCheckError.value = "Failed to fetch versions.";
} finally {
loadingServerCheck.value = false;
isLoading.value = false;
}
};
serverCheckError.value = ''
} catch (error) {
console.error(error)
serverCheckError.value = 'Failed to fetch versions.'
} finally {
loadingServerCheck.value = false
isLoading.value = false
}
}
watch(selectedMCVersion, checkVersionAvailability);
watch(selectedMCVersion, checkVersionAvailability)
onMounted(() => {
fetchLoaderVersions();
});
fetchLoaderVersions()
})
const tags = useTags();
const tags = useTags()
const mcVersions = computed(() =>
tags.value.gameVersions
.filter((x) =>
showSnapshots.value
? x.version_type === "snapshot" || x.version_type === "release"
: x.version_type === "release",
)
.map((x) => x.version),
);
tags.value.gameVersions
.filter((x) =>
showSnapshots.value
? x.version_type === 'snapshot' || x.version_type === 'release'
: x.version_type === 'release',
)
.map((x) => x.version),
)
const isDangerous = computed(() => hardReset.value);
const isDangerous = computed(() => hardReset.value)
const canInstall = computed(() => {
const conds =
!selectedMCVersion.value ||
isLoading.value ||
loadingServerCheck.value ||
serverCheckError.value.trim().length > 0;
const conds =
!selectedMCVersion.value ||
isLoading.value ||
loadingServerCheck.value ||
serverCheckError.value.trim().length > 0
if (selectedLoader.value.toLowerCase() === "vanilla") {
return conds;
}
if (selectedLoader.value.toLowerCase() === 'vanilla') {
return conds
}
return conds || !selectedLoaderVersion.value;
});
return conds || !selectedLoaderVersion.value
})
const handleReinstall = async () => {
if (hardReset.value && !isSecondPhase.value) {
isSecondPhase.value = true;
return;
}
if (hardReset.value && !isSecondPhase.value) {
isSecondPhase.value = true
return
}
isLoading.value = true;
isLoading.value = true
try {
await props.server.general?.reinstall(
true,
selectedLoader.value,
selectedMCVersion.value,
selectedLoader.value === "Vanilla" ? "" : selectedLoaderVersion.value,
props.initialSetup ? true : hardReset.value,
);
try {
await props.server.general?.reinstall(
true,
selectedLoader.value,
selectedMCVersion.value,
selectedLoader.value === 'Vanilla' ? '' : selectedLoaderVersion.value,
props.initialSetup ? true : hardReset.value,
)
emit("reinstall", {
loader: selectedLoader.value,
lVersion: selectedLoaderVersion.value,
mVersion: selectedMCVersion.value,
});
emit('reinstall', {
loader: selectedLoader.value,
lVersion: selectedLoaderVersion.value,
mVersion: selectedMCVersion.value,
})
hide();
} catch (error) {
if (error instanceof ModrinthServersFetchError && (error as any)?.statusCode === 429) {
addNotification({
title: "Cannot reinstall server",
text: "You are being rate limited. Please try again later.",
type: "error",
});
} else {
addNotification({
title: "Reinstall Failed",
text: "An unexpected error occurred while reinstalling. Please try again later.",
type: "error",
});
}
} finally {
isLoading.value = false;
}
};
hide()
} catch (error) {
if (error instanceof ModrinthServersFetchError && (error as any)?.statusCode === 429) {
addNotification({
title: 'Cannot reinstall server',
text: 'You are being rate limited. Please try again later.',
type: 'error',
})
} else {
addNotification({
title: 'Reinstall Failed',
text: 'An unexpected error occurred while reinstalling. Please try again later.',
type: 'error',
})
}
} finally {
isLoading.value = false
}
}
const onShow = () => {
selectedMCVersion.value = props.server.general?.mc_version || "";
if (isSnapshotSelected.value) {
showSnapshots.value = true;
}
};
selectedMCVersion.value = props.server.general?.mc_version || ''
if (isSnapshotSelected.value) {
showSnapshots.value = true
}
}
const onHide = () => {
hardReset.value = false;
isSecondPhase.value = false;
serverCheckError.value = "";
loadingServerCheck.value = false;
isLoading.value = false;
selectedMCVersion.value = "";
serverCheckError.value = "";
paperVersions.value = {};
purpurVersions.value = {};
};
hardReset.value = false
isSecondPhase.value = false
serverCheckError.value = ''
loadingServerCheck.value = false
isLoading.value = false
selectedMCVersion.value = ''
serverCheckError.value = ''
paperVersions.value = {}
purpurVersions.value = {}
}
const show = (loader: Loaders) => {
if (selectedLoader.value !== loader) {
selectedLoaderVersion.value = "";
}
selectedLoader.value = loader;
selectedMCVersion.value = props.server.general?.mc_version || "";
versionSelectModal.value?.show();
};
const hide = () => versionSelectModal.value?.hide();
if (selectedLoader.value !== loader) {
selectedLoaderVersion.value = ''
}
selectedLoader.value = loader
selectedMCVersion.value = props.server.general?.mc_version || ''
versionSelectModal.value?.show()
}
const hide = () => versionSelectModal.value?.hide()
defineExpose({ show, hide });
defineExpose({ show, hide })
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -1,75 +1,76 @@
<template>
<Transition name="save-banner">
<div
v-if="props.isVisible"
data-pyro-save-banner
class="fixed bottom-16 left-0 right-0 z-[6] mx-auto h-fit w-full max-w-4xl transition-all duration-300 sm:bottom-8"
>
<div class="mx-2 rounded-2xl border-2 border-solid border-button-border bg-bg-raised p-4">
<div class="flex flex-col items-center justify-between gap-2 md:flex-row">
<span class="font-bold text-contrast">Careful, you have unsaved changes!</span>
<div class="flex gap-2">
<ButtonStyled type="transparent" color="standard">
<button :disabled="props.isUpdating" @click="props.reset">Reset</button>
</ButtonStyled>
<ButtonStyled type="standard" :color="props.restart ? 'standard' : 'brand'">
<button :disabled="props.isUpdating" @click="props.save">
{{ props.isUpdating ? "Saving..." : "Save" }}
</button>
</ButtonStyled>
<ButtonStyled v-if="props.restart" type="standard" color="brand">
<button :disabled="props.isUpdating" @click="saveAndRestart">
{{ props.isUpdating ? "Saving..." : "Save & restart" }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</Transition>
<Transition name="save-banner">
<div
v-if="props.isVisible"
data-pyro-save-banner
class="fixed bottom-16 left-0 right-0 z-[6] mx-auto h-fit w-full max-w-4xl transition-all duration-300 sm:bottom-8"
>
<div class="mx-2 rounded-2xl border-2 border-solid border-button-border bg-bg-raised p-4">
<div class="flex flex-col items-center justify-between gap-2 md:flex-row">
<span class="font-bold text-contrast">Careful, you have unsaved changes!</span>
<div class="flex gap-2">
<ButtonStyled type="transparent" color="standard">
<button :disabled="props.isUpdating" @click="props.reset">Reset</button>
</ButtonStyled>
<ButtonStyled type="standard" :color="props.restart ? 'standard' : 'brand'">
<button :disabled="props.isUpdating" @click="props.save">
{{ props.isUpdating ? 'Saving...' : 'Save' }}
</button>
</ButtonStyled>
<ButtonStyled v-if="props.restart" type="standard" color="brand">
<button :disabled="props.isUpdating" @click="saveAndRestart">
{{ props.isUpdating ? 'Saving...' : 'Save & restart' }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { ButtonStyled } from '@modrinth/ui'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const props = defineProps<{
isUpdating: boolean;
restart?: boolean;
save: () => void;
reset: () => void;
isVisible: boolean;
server: ModrinthServer;
}>();
isUpdating: boolean
restart?: boolean
save: () => void
reset: () => void
isVisible: boolean
server: ModrinthServer
}>()
const saveAndRestart = async () => {
props.save();
await props.server.general?.power("Restart");
};
props.save()
await props.server.general?.power('Restart')
}
</script>
<style scoped>
.save-banner-enter-active {
transition:
opacity 300ms,
transform 300ms;
transition:
opacity 300ms,
transform 300ms;
}
.save-banner-leave-active {
transition:
opacity 200ms,
transform 200ms;
transition:
opacity 200ms,
transform 200ms;
}
.save-banner-enter-from,
.save-banner-leave-to {
opacity: 0;
transform: translateY(100%) scale(0.98);
opacity: 0;
transform: translateY(100%) scale(0.98);
}
.save-banner-enter-to,
.save-banner-leave-from {
opacity: 1;
transform: none;
opacity: 1;
transform: none;
}
</style>

View File

@@ -1,39 +1,39 @@
<template>
<div
v-if="game"
v-tooltip="'Change server version'"
class="min-w-0 flex-none flex-row items-center gap-2 first:!flex"
>
<GameIcon aria-hidden="true" class="size-5 shrink-0" />
<NuxtLink
v-if="isLink"
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
class="flex min-w-0 items-center truncate text-sm font-semibold"
:class="serverId ? 'hover:underline' : ''"
>
<div class="flex flex-row items-center gap-1">
{{ game[0].toUpperCase() + game.slice(1) }}
<span v-if="mcVersion">{{ mcVersion }}</span>
<span v-else class="inline-block h-3 w-12 animate-pulse rounded bg-button-border"></span>
</div>
</NuxtLink>
<div v-else class="flex min-w-0 flex-row items-center gap-1 truncate text-sm font-semibold">
{{ game[0].toUpperCase() + game.slice(1) }}
<span v-if="mcVersion">{{ mcVersion }}</span>
<span v-else class="inline-block h-3 w-16 animate-pulse rounded bg-button-border"></span>
</div>
</div>
<div
v-if="game"
v-tooltip="'Change server version'"
class="min-w-0 flex-none flex-row items-center gap-2 first:!flex"
>
<GameIcon aria-hidden="true" class="size-5 shrink-0" />
<NuxtLink
v-if="isLink"
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
class="flex min-w-0 items-center truncate text-sm font-semibold"
:class="serverId ? 'hover:underline' : ''"
>
<div class="flex flex-row items-center gap-1">
{{ game[0].toUpperCase() + game.slice(1) }}
<span v-if="mcVersion">{{ mcVersion }}</span>
<span v-else class="inline-block h-3 w-12 animate-pulse rounded bg-button-border"></span>
</div>
</NuxtLink>
<div v-else class="flex min-w-0 flex-row items-center gap-1 truncate text-sm font-semibold">
{{ game[0].toUpperCase() + game.slice(1) }}
<span v-if="mcVersion">{{ mcVersion }}</span>
<span v-else class="inline-block h-3 w-16 animate-pulse rounded bg-button-border"></span>
</div>
</div>
</template>
<script setup lang="ts">
import { GameIcon } from "@modrinth/assets";
import { GameIcon } from '@modrinth/assets'
defineProps<{
game: string;
mcVersion: string;
isLink?: boolean;
}>();
game: string
mcVersion: string
isLink?: boolean
}>()
const route = useNativeRoute();
const serverId = route.params.id as string;
const route = useNativeRoute()
const serverId = route.params.id as string
</script>

View File

@@ -1,26 +1,26 @@
<template>
<div
class="experimental-styles-within flex size-24 shrink-0 overflow-hidden rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
>
<client-only>
<img
v-if="image"
class="h-full w-full select-none object-fill"
alt="Server Icon"
:src="image"
/>
<img
v-else
class="h-full w-full select-none object-fill"
alt="Server Icon"
src="~/assets/images/servers/minecraft_server_icon.png"
/>
</client-only>
</div>
<div
class="experimental-styles-within flex size-24 shrink-0 overflow-hidden rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
>
<client-only>
<img
v-if="image"
class="h-full w-full select-none object-fill"
alt="Server Icon"
:src="image"
/>
<img
v-else
class="h-full w-full select-none object-fill"
alt="Server Icon"
src="~/assets/images/servers/minecraft_server_icon.png"
/>
</client-only>
</div>
</template>
<script setup lang="ts">
defineProps<{
image: string | undefined;
}>();
image: string | undefined
}>()
</script>

View File

@@ -1,40 +1,40 @@
<template>
<div>
<UiServersServerGameLabel
v-if="showGameLabel"
:game="serverData.game"
:mc-version="serverData.mc_version ?? ''"
:is-link="linked"
/>
<UiServersServerLoaderLabel
:loader="serverData.loader"
:loader-version="serverData.loader_version ?? ''"
:no-separator="column"
:is-link="linked"
/>
<UiServersServerSubdomainLabel
v-if="serverData.net?.domain"
:subdomain="serverData.net.domain"
:no-separator="column"
:is-link="linked"
/>
<UiServersServerUptimeLabel
v-if="uptimeSeconds"
:uptime-seconds="uptimeSeconds"
:no-separator="column"
/>
</div>
<div>
<UiServersServerGameLabel
v-if="showGameLabel"
:game="serverData.game"
:mc-version="serverData.mc_version ?? ''"
:is-link="linked"
/>
<UiServersServerLoaderLabel
:loader="serverData.loader"
:loader-version="serverData.loader_version ?? ''"
:no-separator="column"
:is-link="linked"
/>
<UiServersServerSubdomainLabel
v-if="serverData.net?.domain"
:subdomain="serverData.net.domain"
:no-separator="column"
:is-link="linked"
/>
<UiServersServerUptimeLabel
v-if="uptimeSeconds"
:uptime-seconds="uptimeSeconds"
:no-separator="column"
/>
</div>
</template>
<script setup lang="ts">
interface ServerInfoLabelsProps {
serverData: Record<string, any>;
showGameLabel: boolean;
showLoaderLabel: boolean;
uptimeSeconds?: number;
column?: boolean;
linked?: boolean;
serverData: Record<string, any>
showGameLabel: boolean
showLoaderLabel: boolean
uptimeSeconds?: number
column?: boolean
linked?: boolean
}
defineProps<ServerInfoLabelsProps>();
defineProps<ServerInfoLabelsProps>()
</script>

View File

@@ -1,278 +1,279 @@
<template>
<LazyUiServersPlatformVersionSelectModal
ref="versionSelectModal"
:server="props.server"
:current-loader="ignoreCurrentInstallation ? undefined : (data?.loader as Loaders)"
:backup-in-progress="backupInProgress"
:initial-setup="ignoreCurrentInstallation"
@reinstall="emit('reinstall', $event)"
/>
<LazyUiServersPlatformVersionSelectModal
ref="versionSelectModal"
:server="props.server"
:current-loader="ignoreCurrentInstallation ? undefined : (data?.loader as Loaders)"
:backup-in-progress="backupInProgress"
:initial-setup="ignoreCurrentInstallation"
@reinstall="emit('reinstall', $event)"
/>
<LazyUiServersPlatformMrpackModal
ref="mrpackModal"
:server="props.server"
@reinstall="emit('reinstall', $event)"
/>
<LazyUiServersPlatformMrpackModal
ref="mrpackModal"
:server="props.server"
@reinstall="emit('reinstall', $event)"
/>
<LazyUiServersPlatformChangeModpackVersionModal
ref="modpackVersionModal"
:server="props.server"
:project="data?.project"
:versions="Array.isArray(versions) ? versions : []"
:current-version="currentVersion"
:current-version-id="data?.upstream?.version_id"
:server-status="data?.status"
@reinstall="emit('reinstall')"
/>
<LazyUiServersPlatformChangeModpackVersionModal
ref="modpackVersionModal"
:server="props.server"
:project="data?.project"
:versions="Array.isArray(versions) ? versions : []"
:current-version="currentVersion"
:current-version-id="data?.upstream?.version_id"
:server-status="data?.status"
@reinstall="emit('reinstall')"
/>
<div class="flex h-full w-full flex-col">
<div v-if="data && versions" class="flex w-full flex-col">
<div class="card flex flex-col gap-4">
<div class="flex select-none flex-col items-center justify-between gap-2 lg:flex-row">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Modpack</h2>
<div
v-if="updateAvailable"
class="rounded-full bg-bg-orange px-2 py-1 text-xs font-medium text-orange"
>
<span>Update available</span>
</div>
</div>
<div v-if="data.upstream" class="flex gap-4">
<ButtonStyled>
<button
class="!w-full sm:!w-auto"
:disabled="isInstalling"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Import .mrpack
</button>
</ButtonStyled>
<!-- dumb hack to make a button link not a link -->
<ButtonStyled>
<template v-if="isInstalling">
<button :disabled="isInstalling">
<TransferIcon class="size-4" />
Switch modpack
</button>
</template>
<nuxt-link v-else :to="`/modpacks?sid=${props.server.serverId}`">
<TransferIcon class="size-4" />
Switch modpack
</nuxt-link>
</ButtonStyled>
</div>
</div>
<div v-if="data.upstream" class="flex flex-col gap-2">
<div
v-if="versionsError || currentVersionError"
class="rounded-2xl border border-solid border-red p-4 text-contrast"
>
<p class="m-0 font-bold">Something went wrong while loading your modpack.</p>
<p class="m-0 mb-2 mt-1 text-sm">
{{ versionsError || currentVersionError }}
</p>
<ButtonStyled>
<button :disabled="isInstalling" @click="refreshData">Retry</button>
</ButtonStyled>
</div>
<div class="flex h-full w-full flex-col">
<div v-if="data && versions" class="flex w-full flex-col">
<div class="card flex flex-col gap-4">
<div class="flex select-none flex-col items-center justify-between gap-2 lg:flex-row">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Modpack</h2>
<div
v-if="updateAvailable"
class="rounded-full bg-bg-orange px-2 py-1 text-xs font-medium text-orange"
>
<span>Update available</span>
</div>
</div>
<div v-if="data.upstream" class="flex gap-4">
<ButtonStyled>
<button
class="!w-full sm:!w-auto"
:disabled="isInstalling"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Import .mrpack
</button>
</ButtonStyled>
<!-- dumb hack to make a button link not a link -->
<ButtonStyled>
<template v-if="isInstalling">
<button :disabled="isInstalling">
<TransferIcon class="size-4" />
Switch modpack
</button>
</template>
<nuxt-link v-else :to="`/modpacks?sid=${props.server.serverId}`">
<TransferIcon class="size-4" />
Switch modpack
</nuxt-link>
</ButtonStyled>
</div>
</div>
<div v-if="data.upstream" class="flex flex-col gap-2">
<div
v-if="versionsError || currentVersionError"
class="rounded-2xl border border-solid border-red p-4 text-contrast"
>
<p class="m-0 font-bold">Something went wrong while loading your modpack.</p>
<p class="m-0 mb-2 mt-1 text-sm">
{{ versionsError || currentVersionError }}
</p>
<ButtonStyled>
<button :disabled="isInstalling" @click="refreshData">Retry</button>
</ButtonStyled>
</div>
<NewProjectCard
v-if="!versionsError && !currentVersionError"
class="!cursor-default !bg-bg !filter-none"
:project="projectCardData"
:categories="data.project?.categories || []"
>
<template #actions>
<ButtonStyled color="brand">
<button :disabled="isInstalling" @click="modpackVersionModal.show()">
<SettingsIcon class="size-4" />
Change version
</button>
</ButtonStyled>
</template>
</NewProjectCard>
</div>
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
<ButtonStyled>
<nuxt-link
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:class="{ disabled: backupInProgress }"
class="!w-full sm:!w-auto"
:to="`/modpacks?sid=${props.server.serverId}`"
>
<CompassIcon class="size-4" /> Find a modpack
</nuxt-link>
</ButtonStyled>
<span class="hidden sm:block">or</span>
<ButtonStyled>
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="!!backupInProgress"
class="!w-full sm:!w-auto"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Upload .mrpack file
</button>
</ButtonStyled>
</div>
</div>
<NewProjectCard
v-if="!versionsError && !currentVersionError"
class="!cursor-default !bg-bg !filter-none"
:project="projectCardData"
:categories="data.project?.categories || []"
>
<template #actions>
<ButtonStyled color="brand">
<button :disabled="isInstalling" @click="modpackVersionModal.show()">
<SettingsIcon class="size-4" />
Change version
</button>
</ButtonStyled>
</template>
</NewProjectCard>
</div>
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
<ButtonStyled>
<nuxt-link
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:class="{ disabled: backupInProgress }"
class="!w-full sm:!w-auto"
:to="`/modpacks?sid=${props.server.serverId}`"
>
<CompassIcon class="size-4" /> Find a modpack
</nuxt-link>
</ButtonStyled>
<span class="hidden sm:block">or</span>
<ButtonStyled>
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="!!backupInProgress"
class="!w-full sm:!w-auto"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Upload .mrpack file
</button>
</ButtonStyled>
</div>
</div>
<div class="card flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Platform</h2>
<p class="m-0">Your server's platform is the software that runs mods and plugins.</p>
<div v-if="data.upstream" class="mt-2 flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
<span class="text-sm text-secondary">
The current platform was automatically selected based on your modpack.
</span>
</div>
</div>
<div
class="flex w-full flex-col gap-1 rounded-2xl"
:class="{
'pointer-events-none cursor-not-allowed select-none opacity-50':
props.server.general?.status === 'installing',
}"
:tabindex="props.server.general?.status === 'installing' ? -1 : 0"
>
<UiServersLoaderSelector
:data="
ignoreCurrentInstallation
? {
loader: null,
loader_version: null,
}
: data
"
:is-installing="isInstalling"
@select-loader="selectLoader"
/>
</div>
</div>
</div>
<div class="card flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Platform</h2>
<p class="m-0">Your server's platform is the software that runs mods and plugins.</p>
<div v-if="data.upstream" class="mt-2 flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
<span class="text-sm text-secondary">
The current platform was automatically selected based on your modpack.
</span>
</div>
</div>
<div
class="flex w-full flex-col gap-1 rounded-2xl"
:class="{
'pointer-events-none cursor-not-allowed select-none opacity-50':
props.server.general?.status === 'installing',
}"
:tabindex="props.server.general?.status === 'installing' ? -1 : 0"
>
<UiServersLoaderSelector
:data="
ignoreCurrentInstallation
? {
loader: null,
loader_version: null,
}
: data
"
:is-installing="isInstalling"
@select-loader="selectLoader"
/>
</div>
</div>
</div>
<div v-else />
</div>
<div v-else />
</div>
</template>
<script setup lang="ts">
import { ButtonStyled, NewProjectCard } from "@modrinth/ui";
import { TransferIcon, UploadIcon, InfoIcon, CompassIcon, SettingsIcon } from "@modrinth/assets";
import type { Loaders } from "@modrinth/utils";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { CompassIcon, InfoIcon, SettingsIcon, TransferIcon, UploadIcon } from '@modrinth/assets'
import { ButtonStyled, NewProjectCard } from '@modrinth/ui'
import type { Loaders } from '@modrinth/utils'
const { formatMessage } = useVIntl();
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
const { formatMessage } = useVIntl()
const props = defineProps<{
server: ModrinthServer;
ignoreCurrentInstallation?: boolean;
backupInProgress?: BackupInProgressReason;
}>();
server: ModrinthServer
ignoreCurrentInstallation?: boolean
backupInProgress?: BackupInProgressReason
}>()
const emit = defineEmits<{
reinstall: [any?];
}>();
reinstall: [any?]
}>()
const isInstalling = computed(() => props.server.general?.status === "installing");
const isInstalling = computed(() => props.server.general?.status === 'installing')
const versionSelectModal = ref();
const mrpackModal = ref();
const modpackVersionModal = ref();
const versionSelectModal = ref()
const mrpackModal = ref()
const modpackVersionModal = ref()
const data = computed(() => props.server.general);
const data = computed(() => props.server.general)
const {
data: versions,
error: versionsError,
refresh: refreshVersions,
data: versions,
error: versionsError,
refresh: refreshVersions,
} = await useAsyncData(
`content-loader-versions-${data.value?.upstream?.project_id}`,
async () => {
if (!data.value?.upstream?.project_id) return [];
try {
const result = await useBaseFetch(`project/${data.value.upstream.project_id}/version`);
return result || [];
} catch (e) {
console.error("couldnt fetch all versions:", e);
throw new Error("Failed to load modpack versions.");
}
},
{ default: () => [] },
);
`content-loader-versions-${data.value?.upstream?.project_id}`,
async () => {
if (!data.value?.upstream?.project_id) return []
try {
const result = await useBaseFetch(`project/${data.value.upstream.project_id}/version`)
return result || []
} catch (e) {
console.error('couldnt fetch all versions:', e)
throw new Error('Failed to load modpack versions.')
}
},
{ default: () => [] },
)
const {
data: currentVersion,
error: currentVersionError,
refresh: refreshCurrentVersion,
data: currentVersion,
error: currentVersionError,
refresh: refreshCurrentVersion,
} = await useAsyncData(
`content-loader-version-${data.value?.upstream?.version_id}`,
async () => {
if (!data.value?.upstream?.version_id) return null;
try {
const result = await useBaseFetch(`version/${data.value.upstream.version_id}`);
return result || null;
} catch (e) {
console.error("couldnt fetch version:", e);
throw new Error("Failed to load modpack version.");
}
},
{ default: () => null },
);
`content-loader-version-${data.value?.upstream?.version_id}`,
async () => {
if (!data.value?.upstream?.version_id) return null
try {
const result = await useBaseFetch(`version/${data.value.upstream.version_id}`)
return result || null
} catch (e) {
console.error('couldnt fetch version:', e)
throw new Error('Failed to load modpack version.')
}
},
{ default: () => null },
)
const projectCardData = computed(() => ({
icon_url: data.value?.project?.icon_url,
title: data.value?.project?.title,
description: data.value?.project?.description,
downloads: data.value?.project?.downloads,
follows: data.value?.project?.followers,
// @ts-ignore
date_modified: currentVersion.value?.date_published || data.value?.project?.updated,
}));
icon_url: data.value?.project?.icon_url,
title: data.value?.project?.title,
description: data.value?.project?.description,
downloads: data.value?.project?.downloads,
follows: data.value?.project?.followers,
// @ts-ignore
date_modified: currentVersion.value?.date_published || data.value?.project?.updated,
}))
const selectLoader = (loader: string) => {
versionSelectModal.value?.show(loader as Loaders);
};
versionSelectModal.value?.show(loader as Loaders)
}
const refreshData = async () => {
await Promise.all([refreshVersions(), refreshCurrentVersion()]);
};
await Promise.all([refreshVersions(), refreshCurrentVersion()])
}
const updateAvailable = computed(() => {
// so sorry
// @ts-ignore
if (!data.value?.upstream || !versions.value?.length || !currentVersion.value) {
return false;
}
// so sorry
// @ts-ignore
if (!data.value?.upstream || !versions.value?.length || !currentVersion.value) {
return false
}
// @ts-ignore
const latestVersion = versions.value[0];
// @ts-ignore
return latestVersion.id !== currentVersion.value.id;
});
// @ts-ignore
const latestVersion = versions.value[0]
// @ts-ignore
return latestVersion.id !== currentVersion.value.id
})
watch(
() => props.server.general?.status,
async (newStatus, oldStatus) => {
if (oldStatus === "installing" && newStatus === "available") {
await Promise.all([
refreshVersions(),
refreshCurrentVersion(),
props.server.refresh(["general"]),
]);
}
},
);
() => props.server.general?.status,
async (newStatus, oldStatus) => {
if (oldStatus === 'installing' && newStatus === 'available') {
await Promise.all([
refreshVersions(),
refreshCurrentVersion(),
props.server.refresh(['general']),
])
}
},
)
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
background: var(--color-accent-contrast) !important;
}
.button-base:active {
scale: none !important;
scale: none !important;
}
</style>

View File

@@ -1,136 +1,137 @@
<template>
<NuxtLink
class="contents"
:to="status === 'suspended' ? '' : `/servers/manage/${props.server_id}`"
>
<div
v-tooltip="
status === 'suspended'
? suspension_reason === 'upgrading'
? 'This server is being transferred to a new node. It will be unavailable until this process finishes.'
: 'This server has been suspended. Please visit your billing settings or contact Modrinth Support for more information.'
: ''
"
class="flex cursor-pointer flex-row items-center overflow-x-hidden rounded-3xl bg-bg-raised p-4 transition-transform duration-100"
:class="status === 'suspended' ? '!rounded-b-none opacity-75' : 'active:scale-95'"
data-pyro-server-listing
:data-pyro-server-listing-id="server_id"
>
<UiServersServerIcon v-if="status !== 'suspended'" :image="image" />
<div
v-else
class="bg-bg-secondary flex size-24 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
>
<LockIcon class="size-20 text-secondary" />
</div>
<div class="ml-8 flex flex-col gap-2.5">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 text-xl font-bold text-contrast">{{ name }}</h2>
<ChevronRightIcon />
</div>
<NuxtLink
class="contents"
:to="status === 'suspended' ? '' : `/servers/manage/${props.server_id}`"
>
<div
v-tooltip="
status === 'suspended'
? suspension_reason === 'upgrading'
? 'This server is being transferred to a new node. It will be unavailable until this process finishes.'
: 'This server has been suspended. Please visit your billing settings or contact Modrinth Support for more information.'
: ''
"
class="flex cursor-pointer flex-row items-center overflow-x-hidden rounded-3xl bg-bg-raised p-4 transition-transform duration-100"
:class="status === 'suspended' ? '!rounded-b-none opacity-75' : 'active:scale-95'"
data-pyro-server-listing
:data-pyro-server-listing-id="server_id"
>
<UiServersServerIcon v-if="status !== 'suspended'" :image="image" />
<div
v-else
class="bg-bg-secondary flex size-24 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
>
<LockIcon class="size-20 text-secondary" />
</div>
<div class="ml-8 flex flex-col gap-2.5">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 text-xl font-bold text-contrast">{{ name }}</h2>
<ChevronRightIcon />
</div>
<div
v-if="projectData?.title"
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
>
<Avatar
:src="iconUrl"
no-shadow
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
alt="Server Icon"
/>
Using {{ projectData?.title || "Unknown" }}
</div>
<div v-else class="min-h-[20px]"></div>
<div
v-if="projectData?.title"
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
>
<Avatar
:src="iconUrl"
no-shadow
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
alt="Server Icon"
/>
Using {{ projectData?.title || 'Unknown' }}
</div>
<div v-else class="min-h-[20px]"></div>
<div
v-if="isConfiguring"
class="flex min-w-0 items-center gap-2 truncate text-sm font-semibold text-brand"
>
<SparklesIcon class="size-5 shrink-0" /> New server
</div>
<UiServersServerInfoLabels
v-else
:server-data="{ game, mc_version, loader, loader_version, net }"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:linked="false"
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
/>
</div>
</div>
<div
v-if="status === 'suspended' && suspension_reason === 'upgrading'"
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"
>
<UiServersPanelSpinner />
Your server's hardware is currently being upgraded and will be back online shortly.
</div>
<div
v-else-if="status === 'suspended' && suspension_reason === 'cancelled'"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been cancelled. Please
update your billing information or contact Modrinth Support for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div
v-else-if="status === 'suspended' && suspension_reason"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended:
{{ suspension_reason }}. Please update your billing information or contact Modrinth Support
for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div
v-else-if="status === 'suspended'"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
update your billing information or contact Modrinth Support for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
</NuxtLink>
<div
v-if="isConfiguring"
class="flex min-w-0 items-center gap-2 truncate text-sm font-semibold text-brand"
>
<SparklesIcon class="size-5 shrink-0" /> New server
</div>
<UiServersServerInfoLabels
v-else
:server-data="{ game, mc_version, loader, loader_version, net }"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:linked="false"
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
/>
</div>
</div>
<div
v-if="status === 'suspended' && suspension_reason === 'upgrading'"
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"
>
<UiServersPanelSpinner />
Your server's hardware is currently being upgraded and will be back online shortly.
</div>
<div
v-else-if="status === 'suspended' && suspension_reason === 'cancelled'"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been cancelled. Please
update your billing information or contact Modrinth Support for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div
v-else-if="status === 'suspended' && suspension_reason"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended:
{{ suspension_reason }}. Please update your billing information or contact Modrinth Support
for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div
v-else-if="status === 'suspended'"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
>
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
update your billing information or contact Modrinth Support for more information.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
</NuxtLink>
</template>
<script setup lang="ts">
import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets";
import type { Project, Server } from "@modrinth/utils";
import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
import { Avatar, CopyCode } from "@modrinth/ui";
import { ChevronRightIcon, LockIcon, SparklesIcon } from '@modrinth/assets'
import { Avatar, CopyCode } from '@modrinth/ui'
import type { Project, Server } from '@modrinth/utils'
const props = defineProps<Partial<Server>>();
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
if (props.server_id && props.status === "available") {
// Necessary only to get server icon
await useModrinthServers(props.server_id, ["general"]);
const props = defineProps<Partial<Server>>()
if (props.server_id && props.status === 'available') {
// Necessary only to get server icon
await useModrinthServers(props.server_id, ['general'])
}
const showGameLabel = computed(() => !!props.game);
const showLoaderLabel = computed(() => !!props.loader);
const showGameLabel = computed(() => !!props.game)
const showLoaderLabel = computed(() => !!props.loader)
let projectData: Ref<Project | null>;
let projectData: Ref<Project | null>
if (props.upstream) {
const { data } = await useAsyncData<Project>(
`server-project-${props.server_id}`,
async (): Promise<Project> => {
const result = await useBaseFetch(`project/${props.upstream?.project_id}`);
return result as Project;
},
);
projectData = data;
const { data } = await useAsyncData<Project>(
`server-project-${props.server_id}`,
async (): Promise<Project> => {
const result = await useBaseFetch(`project/${props.upstream?.project_id}`)
return result as Project
},
)
projectData = data
} else {
projectData = ref(null);
projectData = ref(null)
}
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined);
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
const isConfiguring = computed(() => props.flows?.intro);
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined)
const iconUrl = computed(() => projectData.value?.icon_url || undefined)
const isConfiguring = computed(() => props.flows?.intro)
</script>

View File

@@ -1,20 +1,20 @@
<template>
<div class="flex flex-row items-center gap-8 overflow-x-hidden rounded-3xl bg-bg-raised p-4">
<div class="relative grid place-content-center">
<img
no-shadow
size="lg"
alt="Server Icon"
class="size-[96px] rounded-xl bg-bg-raised opacity-50"
src="~/assets/images/servers/minecraft_server_icon.png"
/>
<div class="absolute inset-0 grid place-content-center">
<UiServersIconsLoadingIcon class="size-8 animate-spin text-contrast" />
</div>
</div>
<div class="flex flex-col gap-4">
<h2 class="m-0 text-contrast">Your new server is being prepared.</h2>
<p class="m-0">It'll appear here once it's ready.</p>
</div>
</div>
<div class="flex flex-row items-center gap-8 overflow-x-hidden rounded-3xl bg-bg-raised p-4">
<div class="relative grid place-content-center">
<img
no-shadow
size="lg"
alt="Server Icon"
class="size-[96px] rounded-xl bg-bg-raised opacity-50"
src="~/assets/images/servers/minecraft_server_icon.png"
/>
<div class="absolute inset-0 grid place-content-center">
<UiServersIconsLoadingIcon class="size-8 animate-spin text-contrast" />
</div>
</div>
<div class="flex flex-col gap-4">
<h2 class="m-0 text-contrast">Your new server is being prepared.</h2>
<p class="m-0">It'll appear here once it's ready.</p>
</div>
</div>
</template>

View File

@@ -1,46 +1,46 @@
<template>
<div v-tooltip="'Change server loader'" class="flex min-w-0 flex-row items-center gap-4 truncate">
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
<div class="flex flex-row items-center gap-2">
<UiServersIconsLoaderIcon v-if="loader" :loader="loader" class="flex shrink-0 [&&]:size-5" />
<div v-else class="size-5 shrink-0 animate-pulse rounded-full bg-button-border"></div>
<NuxtLink
v-if="isLink"
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
class="flex min-w-0 items-center text-sm font-semibold"
:class="serverId ? 'hover:underline' : ''"
>
<span v-if="loader">
{{ loader }}
<span v-if="loaderVersion">{{ loaderVersion }}</span>
</span>
<span v-else class="flex gap-2">
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
</span>
</NuxtLink>
<div v-else class="min-w-0 text-sm font-semibold">
<span v-if="loader">
{{ loader }}
<span v-if="loaderVersion">{{ loaderVersion }}</span>
</span>
<span v-else class="flex gap-2">
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
</span>
</div>
</div>
</div>
<div v-tooltip="'Change server loader'" class="flex min-w-0 flex-row items-center gap-4 truncate">
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
<div class="flex flex-row items-center gap-2">
<UiServersIconsLoaderIcon v-if="loader" :loader="loader" class="flex shrink-0 [&&]:size-5" />
<div v-else class="size-5 shrink-0 animate-pulse rounded-full bg-button-border"></div>
<NuxtLink
v-if="isLink"
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
class="flex min-w-0 items-center text-sm font-semibold"
:class="serverId ? 'hover:underline' : ''"
>
<span v-if="loader">
{{ loader }}
<span v-if="loaderVersion">{{ loaderVersion }}</span>
</span>
<span v-else class="flex gap-2">
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
</span>
</NuxtLink>
<div v-else class="min-w-0 text-sm font-semibold">
<span v-if="loader">
{{ loader }}
<span v-if="loaderVersion">{{ loaderVersion }}</span>
</span>
<span v-else class="flex gap-2">
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
noSeparator?: boolean;
loader?: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
loaderVersion?: string;
isLink?: boolean;
}>();
noSeparator?: boolean
loader?: 'Fabric' | 'Quilt' | 'Forge' | 'NeoForge' | 'Paper' | 'Spigot' | 'Bukkit' | 'Vanilla'
loaderVersion?: string
isLink?: boolean
}>()
const route = useNativeRoute();
const serverId = route.params.id as string;
const route = useNativeRoute()
const serverId = route.params.id as string
</script>

View File

@@ -1,19 +1,19 @@
<template>
<div class="flex h-full flex-col items-center justify-center gap-8">
<img
src="https://cdn.modrinth.com/servers/excitement.webp"
alt=""
class="max-w-[360px]"
style="mask-image: radial-gradient(97% 77% at 50% 25%, #d9d9d9 0, hsla(0, 0%, 45%, 0) 100%)"
/>
<h1 class="m-0 text-contrast">You don't have any servers yet!</h1>
<p class="m-0">Modrinth Servers is a new way to play modded Minecraft with your friends.</p>
<ButtonStyled size="large" type="standard" color="brand">
<NuxtLink to="/servers#plan">Create a Server</NuxtLink>
</ButtonStyled>
</div>
<div class="flex h-full flex-col items-center justify-center gap-8">
<img
src="https://cdn.modrinth.com/servers/excitement.webp"
alt=""
class="max-w-[360px]"
style="mask-image: radial-gradient(97% 77% at 50% 25%, #d9d9d9 0, hsla(0, 0%, 45%, 0) 100%)"
/>
<h1 class="m-0 text-contrast">You don't have any servers yet!</h1>
<p class="m-0">Modrinth Servers is a new way to play modded Minecraft with your friends.</p>
<ButtonStyled size="large" type="standard" color="brand">
<NuxtLink to="/servers#plan">Create a Server</NuxtLink>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { ButtonStyled } from '@modrinth/ui'
</script>

View File

@@ -1,55 +1,62 @@
<template>
<div class="static w-full grid-cols-1 md:relative md:flex">
<div class="static h-full flex-col pb-4 md:flex md:pb-0 md:pr-4">
<div class="z-10 flex select-none flex-col gap-2 rounded-2xl bg-bg-raised p-4 md:w-[16rem]">
<div
v-for="link in navLinks.filter((x) => x.shown === undefined || x.shown)"
:key="link.label"
>
<NuxtLink
:to="link.href"
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
:class="{ 'bg-button-bg text-contrast': route.path === link.href }"
>
<div class="flex items-center gap-2 font-bold">
<component :is="link.icon" class="size-6" />
{{ link.label }}
</div>
<div class="static w-full grid-cols-1 md:relative md:flex">
<div class="static h-full flex-col pb-4 md:flex md:pb-0 md:pr-4">
<div class="z-10 flex select-none flex-col gap-2 rounded-2xl bg-bg-raised p-4 md:w-[16rem]">
<div
v-for="link in navLinks.filter((x) => x.shown === undefined || x.shown)"
:key="link.label"
>
<NuxtLink
:to="link.href"
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
:class="{ 'bg-button-bg text-contrast': route.path === link.href }"
>
<div class="flex items-center gap-2 font-bold">
<component :is="link.icon" class="size-6" />
{{ link.label }}
</div>
<div class="flex-grow" />
<RightArrowIcon v-if="link.external" class="size-4" />
</NuxtLink>
</div>
</div>
</div>
<div class="flex-grow" />
<RightArrowIcon v-if="link.external" class="size-4" />
</NuxtLink>
</div>
</div>
</div>
<div class="h-full w-full">
<NuxtPage
:route="route"
:server="server"
:backup-in-progress="backupInProgress"
@reinstall="onReinstall"
/>
</div>
</div>
<div class="h-full w-full">
<NuxtPage
:route="route"
:server="server"
:backup-in-progress="backupInProgress"
@reinstall="onReinstall"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { RightArrowIcon } from "@modrinth/assets";
import type { RouteLocationNormalized } from "vue-router";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import { RightArrowIcon } from '@modrinth/assets'
import type { RouteLocationNormalized } from 'vue-router'
const emit = defineEmits(["reinstall"]);
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
const emit = defineEmits(['reinstall'])
defineProps<{
navLinks: { label: string; href: string; icon: Component; external?: boolean; shown?: boolean }[];
route: RouteLocationNormalized;
server: ModrinthServer;
backupInProgress?: BackupInProgressReason;
}>();
navLinks: {
label: string
href: string
icon: Component
external?: boolean
shown?: boolean
}[]
route: RouteLocationNormalized
server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
const onReinstall = (...args: any[]) => {
emit("reinstall", ...args);
};
emit('reinstall', ...args)
}
</script>

View File

@@ -1,254 +1,256 @@
<template>
<div
data-pyro-server-stats
style="font-variant-numeric: tabular-nums"
class="flex select-none flex-col items-center gap-6 md:flex-row"
:class="{ 'pointer-events-none': loading }"
:aria-hidden="loading"
>
<div
v-for="(metric, index) in metrics"
:key="index"
class="relative isolate 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="relative z-10">
<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">{{ metric.value }}</h2>
<h3 class="text-sm font-normal text-secondary">/ {{ metric.max }}</h3>
</div>
<h3 class="flex items-center gap-2 text-base font-normal text-secondary">
{{ metric.title }}
<IssuesIcon
v-if="metric.warning && !loading"
v-tooltip="metric.warning"
class="size-5"
:style="{ color: 'var(--color-orange)' }"
/>
</h3>
</div>
<div class="absolute -left-8 -top-4 h-28 w-56 rounded-full bg-bg-raised blur-lg" />
</div>
<div
data-pyro-server-stats
style="font-variant-numeric: tabular-nums"
class="flex select-none flex-col items-center gap-6 md:flex-row"
:class="{ 'pointer-events-none': loading }"
:aria-hidden="loading"
>
<div
v-for="(metric, index) in metrics"
:key="index"
class="relative isolate 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="relative z-10">
<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">
{{ metric.value }}
</h2>
<h3 class="text-sm font-normal text-secondary">/ {{ metric.max }}</h3>
</div>
<h3 class="flex items-center gap-2 text-base font-normal text-secondary">
{{ metric.title }}
<IssuesIcon
v-if="metric.warning && !loading"
v-tooltip="metric.warning"
class="size-5"
:style="{ color: 'var(--color-orange)' }"
/>
</h3>
</div>
<div class="absolute -left-8 -top-4 h-28 w-56 rounded-full bg-bg-raised blur-lg" />
</div>
<component
:is="metric.icon"
class="absolute right-10 top-10 z-10 size-8"
style="width: 2rem; height: 2rem"
/>
<component
:is="metric.icon"
class="absolute right-10 top-10 z-10 size-8"
style="width: 2rem; height: 2rem"
/>
<div class="chart-space absolute bottom-0 left-0 right-0">
<ClientOnly>
<VueApexCharts
v-if="metric.showGraph && !loading"
type="area"
height="142"
:options="getChartOptions(metric.warning, index)"
:series="[{ name: metric.title, data: metric.data }]"
class="chart"
:class="chartsReady.has(index) ? 'opacity-100' : 'opacity-0'"
/>
</ClientOnly>
</div>
</div>
<nuxt-link
:to="loading ? undefined : `/servers/manage/${serverId}/files`"
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
:class="loading ? '' : '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">
{{ loading ? "0 B" : formatBytes(stats.storage_usage_bytes) }}
</h2>
</div>
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
</nuxt-link>
</div>
<div class="chart-space absolute bottom-0 left-0 right-0">
<ClientOnly>
<VueApexCharts
v-if="metric.showGraph && !loading"
type="area"
height="142"
:options="getChartOptions(metric.warning, index)"
:series="[{ name: metric.title, data: metric.data }]"
class="chart"
:class="chartsReady.has(index) ? 'opacity-100' : 'opacity-0'"
/>
</ClientOnly>
</div>
</div>
<nuxt-link
:to="loading ? undefined : `/servers/manage/${serverId}/files`"
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
:class="loading ? '' : '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">
{{ loading ? '0 B' : formatBytes(stats.storage_usage_bytes) }}
</h2>
</div>
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
</nuxt-link>
</div>
</template>
<script setup lang="ts">
import { ref, computed, shallowRef } from "vue";
import { FolderOpenIcon, CpuIcon, DatabaseIcon, IssuesIcon } from "@modrinth/assets";
import { useStorage } from "@vueuse/core";
import type { Stats } from "@modrinth/utils";
import { CpuIcon, DatabaseIcon, FolderOpenIcon, IssuesIcon } from '@modrinth/assets'
import type { Stats } from '@modrinth/utils'
import { useStorage } from '@vueuse/core'
import { computed, ref, shallowRef } from 'vue'
const flags = useFeatureFlags();
const route = useNativeRoute();
const serverId = route.params.id;
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
const flags = useFeatureFlags()
const route = useNativeRoute()
const serverId = route.params.id
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
const chartsReady = ref(new Set<number>());
const chartsReady = ref(new Set<number>())
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
ramAsNumber: false,
});
ramAsNumber: false,
})
const props = withDefaults(defineProps<{ data?: Stats; loading?: boolean }>(), {
loading: false,
});
loading: false,
})
const stats = shallowRef(
props.data?.current || {
cpu_percent: 0,
ram_usage_bytes: 0,
ram_total_bytes: 1, // Avoid division by zero
storage_usage_bytes: 0,
},
);
props.data?.current || {
cpu_percent: 0,
ram_usage_bytes: 0,
ram_total_bytes: 1, // Avoid division by zero
storage_usage_bytes: 0,
},
)
const onChartReady = (index: number) => {
chartsReady.value.add(index);
};
chartsReady.value.add(index)
}
const formatBytes = (bytes: number) => {
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${Math.round(value * 10) / 10} ${units[unit]}`;
};
const units = ['B', 'KB', 'MB', 'GB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit++
}
return `${Math.round(value * 10) / 10} ${units[unit]}`
}
const cpuData = ref<number[]>(Array(20).fill(0));
const ramData = ref<number[]>(Array(20).fill(0));
const cpuData = ref<number[]>(Array(20).fill(0))
const ramData = ref<number[]>(Array(20).fill(0))
const updateGraphData = (arr: number[], newValue: number) => {
arr.push(newValue);
arr.shift();
};
arr.push(newValue)
arr.shift()
}
const metrics = computed(() => {
if (props.loading) {
return [
{
title: "CPU usage",
value: "0.00%",
max: "100%",
icon: CpuIcon,
data: cpuData.value,
showGraph: false,
warning: null,
},
{
title: "Memory usage",
value: "0.00%",
max: "100%",
icon: DatabaseIcon,
data: ramData.value,
showGraph: false,
warning: null,
},
];
}
if (props.loading) {
return [
{
title: 'CPU usage',
value: '0.00%',
max: '100%',
icon: CpuIcon,
data: cpuData.value,
showGraph: false,
warning: null,
},
{
title: 'Memory usage',
value: '0.00%',
max: '100%',
icon: DatabaseIcon,
data: ramData.value,
showGraph: false,
warning: null,
},
]
}
const ramPercent = Math.min(
(stats.value.ram_usage_bytes / stats.value.ram_total_bytes) * 100,
100,
);
const cpuPercent = Math.min(stats.value.cpu_percent, 100);
const ramPercent = Math.min(
(stats.value.ram_usage_bytes / stats.value.ram_total_bytes) * 100,
100,
)
const cpuPercent = Math.min(stats.value.cpu_percent, 100)
updateGraphData(cpuData.value, cpuPercent);
updateGraphData(ramData.value, ramPercent);
updateGraphData(cpuData.value, cpuPercent)
updateGraphData(ramData.value, ramPercent)
return [
{
title: "CPU usage",
value: `${cpuPercent.toFixed(2)}%`,
max: "100%",
icon: CpuIcon,
data: cpuData.value,
showGraph: true,
warning: cpuPercent >= 90 ? "CPU usage is very high" : null,
},
{
title: "Memory usage",
value:
userPreferences.value.ramAsNumber || flags.developerMode
? formatBytes(stats.value.ram_usage_bytes)
: `${ramPercent.toFixed(2)}%`,
max:
userPreferences.value.ramAsNumber || flags.developerMode
? formatBytes(stats.value.ram_total_bytes)
: "100%",
icon: DatabaseIcon,
data: ramData.value,
showGraph: true,
warning: ramPercent >= 90 ? "Memory usage is very high" : null,
},
];
});
return [
{
title: 'CPU usage',
value: `${cpuPercent.toFixed(2)}%`,
max: '100%',
icon: CpuIcon,
data: cpuData.value,
showGraph: true,
warning: cpuPercent >= 90 ? 'CPU usage is very high' : null,
},
{
title: 'Memory usage',
value:
userPreferences.value.ramAsNumber || flags.developerMode
? formatBytes(stats.value.ram_usage_bytes)
: `${ramPercent.toFixed(2)}%`,
max:
userPreferences.value.ramAsNumber || flags.developerMode
? formatBytes(stats.value.ram_total_bytes)
: '100%',
icon: DatabaseIcon,
data: ramData.value,
showGraph: true,
warning: ramPercent >= 90 ? 'Memory usage is very high' : null,
},
]
})
const getChartOptions = (hasWarning: string | null, index: number) => ({
chart: {
type: "area",
animations: { enabled: false },
sparkline: { enabled: true },
toolbar: { show: false },
padding: {
left: -10,
right: -10,
top: 0,
bottom: 0,
},
events: {
mounted: () => onChartReady(index),
updated: () => onChartReady(index),
},
},
stroke: { curve: "smooth", width: 3 },
fill: {
type: "gradient",
gradient: {
shadeIntensity: 1,
opacityFrom: 0.25,
opacityTo: 0.05,
stops: [0, 100],
},
},
tooltip: { enabled: false },
grid: { show: false },
xaxis: {
labels: { show: false },
axisBorder: { show: false },
type: "numeric",
tickAmount: 20,
range: 20,
},
yaxis: {
show: false,
min: 0,
max: 100,
forceNiceScale: false,
},
colors: [hasWarning ? "var(--color-orange)" : "var(--color-brand)"],
dataLabels: {
enabled: false,
},
});
chart: {
type: 'area',
animations: { enabled: false },
sparkline: { enabled: true },
toolbar: { show: false },
padding: {
left: -10,
right: -10,
top: 0,
bottom: 0,
},
events: {
mounted: () => onChartReady(index),
updated: () => onChartReady(index),
},
},
stroke: { curve: 'smooth', width: 3 },
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.25,
opacityTo: 0.05,
stops: [0, 100],
},
},
tooltip: { enabled: false },
grid: { show: false },
xaxis: {
labels: { show: false },
axisBorder: { show: false },
type: 'numeric',
tickAmount: 20,
range: 20,
},
yaxis: {
show: false,
min: 0,
max: 100,
forceNiceScale: false,
},
colors: [hasWarning ? 'var(--color-orange)' : 'var(--color-brand)'],
dataLabels: {
enabled: false,
},
})
watch(
() => props.data?.current,
(newStats) => {
if (newStats) {
stats.value = newStats;
}
},
);
() => props.data?.current,
(newStats) => {
if (newStats) {
stats.value = newStats
}
},
)
</script>
<style scoped>
.chart-space {
height: 142px;
width: calc(100% + 48px);
margin-left: -24px;
margin-right: -24px;
height: 142px;
width: calc(100% + 48px);
margin-left: -24px;
margin-right: -24px;
}
.chart {
width: 100% !important;
height: 142px !important;
transition: opacity 0.3s ease-out;
width: 100% !important;
height: 142px !important;
transition: opacity 0.3s ease-out;
}
</style>

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