diff --git a/apps/app-frontend/src/components/ui/ErrorModal.vue b/apps/app-frontend/src/components/ui/ErrorModal.vue index d9a71608..c3beffa9 100644 --- a/apps/app-frontend/src/components/ui/ErrorModal.vue +++ b/apps/app-frontend/src/components/ui/ErrorModal.vue @@ -1,7 +1,16 @@ - diff --git a/apps/app-frontend/src/components/ui/instance_settings/WindowSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/WindowSettings.vue index 735d6c08..53820da6 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/WindowSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/WindowSettings.vue @@ -5,7 +5,7 @@ import { handleError } from '@/store/notifications' import { defineMessages, useVIntl } from '@vintl/vintl' import { get } from '@/helpers/settings' import { edit } from '@/helpers/profile' -import type { InstanceSettingsTabProps, AppSettings } from '../../../helpers/types' +import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types' const { formatMessage } = useVIntl() @@ -114,7 +114,6 @@ const messages = defineMessages({ -import { Toggle, ThemeSelector, TeleportDropdownMenu } from '@modrinth/ui' +import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui' import { useTheming } from '@/store/state' import { get, set } from '@/helpers/settings' -import { watch, ref } from 'vue' +import { ref, watch } from 'vue' import { getOS } from '@/helpers/utils' const themeStore = useTheming() @@ -46,7 +46,6 @@ watch( Native Decorations

Use system window frame (app restart required).

- +
@@ -78,16 +68,7 @@ watch(

Minimize launcher

Minimize the launcher when a Minecraft process starts.

- +
@@ -111,7 +92,6 @@ watch( +
diff --git a/apps/app-frontend/src/components/ui/settings/FeatureFlagSettings.vue b/apps/app-frontend/src/components/ui/settings/FeatureFlagSettings.vue index fde4a5c1..24d27feb 100644 --- a/apps/app-frontend/src/components/ui/settings/FeatureFlagSettings.vue +++ b/apps/app-frontend/src/components/ui/settings/FeatureFlagSettings.vue @@ -37,7 +37,6 @@ watch(
diff --git a/apps/app-frontend/src/components/ui/settings/PrivacySettings.vue b/apps/app-frontend/src/components/ui/settings/PrivacySettings.vue index 41944e79..b2f959d7 100644 --- a/apps/app-frontend/src/components/ui/settings/PrivacySettings.vue +++ b/apps/app-frontend/src/components/ui/settings/PrivacySettings.vue @@ -30,17 +30,8 @@ watch( option, you opt out and ads will no longer be shown based on your interests.

- + +
@@ -52,17 +43,8 @@ watch( longer be collected.

- + +
@@ -77,10 +59,6 @@ watch( as those added by mods. (app restart required to take effect)

- + diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue index 45ef2372..e49f2d9c 100644 --- a/apps/app-frontend/src/pages/instance/Mods.vue +++ b/apps/app-frontend/src/pages/instance/Mods.vue @@ -179,7 +179,6 @@ diff --git a/apps/app/tauri.conf.json b/apps/app/tauri.conf.json index 59794f5e..261df1a6 100644 --- a/apps/app/tauri.conf.json +++ b/apps/app/tauri.conf.json @@ -44,7 +44,7 @@ ] }, "productName": "AstralRinth App", - "version": "0.9.301", + "version": "0.9.302", "mainBinaryName": "AstralRinth App", "identifier": "AstralRinthApp", "plugins": { diff --git a/apps/frontend/src/assets/styles/layout.scss b/apps/frontend/src/assets/styles/layout.scss index d9924ac1..15106666 100644 --- a/apps/frontend/src/assets/styles/layout.scss +++ b/apps/frontend/src/assets/styles/layout.scss @@ -133,6 +133,19 @@ "sidebar" / 100%; + .normal-page__ultimate-sidebar { + grid-area: ultimate-sidebar; + position: fixed; + bottom: 1rem; + right: 1rem; + z-index: 100; + max-width: calc(100% - 2rem); + + > div { + box-shadow: 0 0 15px rgba(0, 0, 0, 0.3); + } + } + @media screen and (min-width: 1024px) { &.sidebar { grid-template: @@ -156,6 +169,45 @@ } } + @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; + + .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); + + > 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; + } + } + } + .normal-page__sidebar { grid-area: sidebar; } diff --git a/apps/frontend/src/components/ui/CollectionCreateModal.vue b/apps/frontend/src/components/ui/CollectionCreateModal.vue index e74934fe..02b07560 100644 --- a/apps/frontend/src/components/ui/CollectionCreateModal.vue +++ b/apps/frontend/src/components/ui/CollectionCreateModal.vue @@ -19,10 +19,7 @@
@@ -52,8 +49,8 @@ diff --git a/apps/frontend/src/components/ui/servers/Globe.vue b/apps/frontend/src/components/ui/servers/Globe.vue index 65040d9a..56c9b826 100644 --- a/apps/frontend/src/components/ui/servers/Globe.vue +++ b/apps/frontend/src/components/ui/servers/Globe.vue @@ -54,7 +54,8 @@ const locations = ref([ { name: "New York", lat: 40.7128, lng: -74.006, active: true, clicked: false }, { name: "Los Angeles", lat: 34.0522, lng: -118.2437, active: true, clicked: false }, { name: "Miami", lat: 25.7617, lng: -80.1918, active: true, clicked: false }, - { name: "Seattle", lat: 47.608013, lng: -122.3321, active: true, clicked: false }, + { name: "Spokane", lat: 47.667309, lng: -117.411922, active: true, clicked: false }, + { name: "Dallas", lat: 32.78372, lng: -96.7947, active: true, clicked: false }, // Future Locations // { name: "London", lat: 51.5074, lng: -0.1278, active: false, clicked: false }, // { name: "Frankfurt", lat: 50.1109, lng: 8.6821, active: false, clicked: false }, diff --git a/apps/frontend/src/components/ui/servers/OverviewLoading.vue b/apps/frontend/src/components/ui/servers/OverviewLoading.vue new file mode 100644 index 00000000..a3d5135f --- /dev/null +++ b/apps/frontend/src/components/ui/servers/OverviewLoading.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue b/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue index ebb5cf0b..073909fd 100644 --- a/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue +++ b/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue @@ -1,23 +1,25 @@ diff --git a/apps/frontend/src/components/ui/servers/PanelServerStatus.vue b/apps/frontend/src/components/ui/servers/PanelServerStatus.vue index 81e6ebc6..9f6674b0 100644 --- a/apps/frontend/src/components/ui/servers/PanelServerStatus.vue +++ b/apps/frontend/src/components/ui/servers/PanelServerStatus.vue @@ -1,66 +1,72 @@ diff --git a/apps/frontend/src/components/ui/servers/PanelTerminal.vue b/apps/frontend/src/components/ui/servers/PanelTerminal.vue index 138baa50..02d7edb9 100644 --- a/apps/frontend/src/components/ui/servers/PanelTerminal.vue +++ b/apps/frontend/src/components/ui/servers/PanelTerminal.vue @@ -260,7 +260,25 @@
-
{{ selectedLog }}
+

+        
+

Detected Links

+ +
@@ -272,6 +290,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue"; import { useDebounceFn } from "@vueuse/core"; import { NewModal } from "@modrinth/ui"; import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue"; +import DOMPurify from "dompurify"; import { usePyroConsole } from "~/store/console.ts"; const { $cosmetics } = useNuxtApp(); @@ -984,6 +1003,38 @@ const jumpToLine = (line: string, event?: MouseEvent) => { }); }; +const sanitizeUrl = (url: string): string => { + try { + const parsed = new URL(url); + if (!["http:", "https:"].includes(parsed.protocol)) { + return "#"; + } + return parsed.toString(); + } catch { + return "#"; + } +}; + +const detectedLinks = computed(() => { + const urlRegex = /(https?:\/\/[^\s,<]+(?=[,\s<]|$))/g; + const matches = [...selectedLog.value.matchAll(urlRegex)].map((match) => match[0]); + return matches.filter((url) => sanitizeUrl(url) !== "#"); +}); + +const processedLogWithLinks = computed(() => { + const urlRegex = /(https?:\/\/[^\s,<]+(?=[,\s<]|$))/g; + const sanitizedLog = DOMPurify.sanitize(selectedLog.value, { + ALLOWED_TAGS: [], + ALLOWED_ATTR: [], + }); + + return sanitizedLog.replace(urlRegex, (url) => { + const safeUrl = sanitizeUrl(url); + if (safeUrl === "#") return url; + return `${url}`; + }); +}); + watch( () => pyroConsole.filteredOutput.value, () => { diff --git a/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue b/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue index 6bcaef3e..a7dc1083 100644 --- a/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue +++ b/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue @@ -337,7 +337,7 @@ watch( selectedLoaderVersions, (newVersions) => { if (newVersions.length > 0 && !selectedLoaderVersion.value) { - selectedLoaderVersion.value = String(newVersions[0]); // Ensure string type + selectedLoaderVersion.value = String(newVersions[0]); } }, { immediate: true }, @@ -516,8 +516,6 @@ const handleReinstall = async () => { const onShow = () => { selectedMCVersion.value = props.server.general?.mc_version || ""; - selectedLoaderVersion.value = ""; - hardReset.value = false; }; const onHide = () => { @@ -528,13 +526,15 @@ const onHide = () => { loadingServerCheck.value = false; isLoading.value = false; selectedMCVersion.value = ""; - selectedLoaderVersion.value = ""; serverCheckError.value = ""; paperVersions.value = {}; purpurVersions.value = {}; }; const show = (loader: Loaders) => { + if (selectedLoader.value !== loader) { + selectedLoaderVersion.value = ""; + } selectedLoader.value = loader; selectedMCVersion.value = props.server.general?.mc_version || ""; versionSelectModal.value?.show(); diff --git a/apps/frontend/src/components/ui/servers/ServerListing.vue b/apps/frontend/src/components/ui/servers/ServerListing.vue index 104891aa..a8bfeece 100644 --- a/apps/frontend/src/components/ui/servers/ServerListing.vue +++ b/apps/frontend/src/components/ui/servers/ServerListing.vue @@ -69,11 +69,13 @@
- - Your server has been suspended due to a billing issue. Please visit your billing settings or - contact Modrinth Support for more information. +
+ Your server has been suspended. Please + update your billing information or contact Modrinth Support for more information. +
+
diff --git a/apps/frontend/src/components/ui/servers/ServerUptimeLabel.vue b/apps/frontend/src/components/ui/servers/ServerUptimeLabel.vue index 1ffc5c92..f90be664 100644 --- a/apps/frontend/src/components/ui/servers/ServerUptimeLabel.vue +++ b/apps/frontend/src/components/ui/servers/ServerUptimeLabel.vue @@ -2,7 +2,7 @@
diff --git a/apps/frontend/src/composables/featureFlags.ts b/apps/frontend/src/composables/featureFlags.ts index 57897178..3a092019 100644 --- a/apps/frontend/src/composables/featureFlags.ts +++ b/apps/frontend/src/composables/featureFlags.ts @@ -20,7 +20,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({ // Developer flags developerMode: false, showVersionFilesInTable: false, - // showAdsWithPlus: false, +// showAdsWithPlus: false, + alwaysShowChecklistAsPopup: true, // Feature toggles projectTypesPrimaryNav: false, diff --git a/apps/frontend/src/composables/pyroServers.ts b/apps/frontend/src/composables/pyroServers.ts index 7130e6d2..206063a2 100644 --- a/apps/frontend/src/composables/pyroServers.ts +++ b/apps/frontend/src/composables/pyroServers.ts @@ -10,19 +10,111 @@ interface PyroFetchOptions { url?: string; token?: string; }; - retry?: boolean; + retry?: number | boolean; } -async function PyroFetch(path: string, options: PyroFetchOptions = {}): Promise { +class PyroServerError extends Error { + public readonly errors: Map = new Map(); + public readonly timestamp: number = Date.now(); + + constructor(message?: string) { + super(message || "Multiple errors occurred"); + this.name = "PyroServerError"; + } + + addError(module: string, error: Error) { + this.errors.set(module, error); + this.message = this.buildErrorMessage(); + } + + hasErrors() { + return this.errors.size > 0; + } + + private buildErrorMessage(): string { + return Array.from(this.errors.entries()) + .map(([_module, error]) => error.message) + .join("\n"); + } +} + +export class PyroServersFetchError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + public readonly originalError?: Error, + public readonly module?: string, + ) { + let errorMessage = message; + let method = "GET"; + let path = ""; + + if (originalError instanceof FetchError) { + const matches = message.match(/\[([A-Z]+)\]\s+"([^"]+)":/); + if (matches) { + method = matches[1]; + path = matches[2].replace(/https?:\/\/[^/]+\/[^/]+\/v\d+\//, ""); + } + + const statusMessage = (() => { + if (!statusCode) return "Unknown Error"; + switch (statusCode) { + case 400: + return "Bad Request"; + case 401: + return "Unauthorized"; + case 403: + return "Forbidden"; + case 404: + return "Not Found"; + case 408: + return "Request Timeout"; + case 429: + return "Too Many Requests"; + case 500: + return "Internal Server Error"; + case 502: + return "Bad Gateway"; + case 503: + return "Service Unavailable"; + case 504: + return "Gateway Timeout"; + default: + return `HTTP ${statusCode}`; + } + })(); + + errorMessage = `[${method}] ${statusMessage} (${statusCode}) while fetching ${path}${module ? ` in ${module}` : ""}`; + } else { + errorMessage = `${message}${statusCode ? ` (${statusCode})` : ""}${module ? ` in ${module}` : ""}`; + } + + super(errorMessage); + this.name = "PyroServersFetchError"; + } +} + +async function PyroFetch( + path: string, + options: PyroFetchOptions = {}, + module?: string, +): Promise { const config = useRuntimeConfig(); const auth = await useAuth(); const authToken = auth.value?.token; if (!authToken) { - throw new PyroFetchError("Cannot pyrofetch without auth", 10000); + throw new PyroServersFetchError("Missing auth token", 401, undefined, module); } - const { method = "GET", contentType = "application/json", body, version = 0, override } = options; + const { + method = "GET", + contentType = "application/json", + body, + version = 0, + override, + retry = method === "GET" ? 3 : 0, + } = options; const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace( /\/$/, @@ -30,9 +122,11 @@ async function PyroFetch(path: string, options: PyroFetchOptions = {}): Promi ); if (!base) { - throw new PyroFetchError( - "Cannot pyrofetch without base url. Make sure to set a PYRO_BASE_URL in environment variables", - 10001, + throw new PyroServersFetchError( + "Configuration error: Missing PYRO_BASE_URL", + 500, + undefined, + module, ); } @@ -40,9 +134,7 @@ async function PyroFetch(path: string, options: PyroFetchOptions = {}): Promi ? `https://${override.url}/${path.replace(/^\//, "")}` : `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`; - type HeadersRecord = Record; - - const headers: HeadersRecord = { + const headers: Record = { Authorization: `Bearer ${override?.token ?? authToken}`, "Access-Control-Allow-Headers": "Authorization", "User-Agent": "Pyro/1.0 (https://pyro.host)", @@ -57,43 +149,47 @@ async function PyroFetch(path: string, options: PyroFetchOptions = {}): Promi headers.Origin = window.location.origin; } - try { - const response = await $fetch(fullUrl, { - method, - headers, - body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined, - timeout: 10000, - retry: options.retry !== false ? (method === "GET" ? 3 : 0) : 0, - }); - return response; - } catch (error) { - console.error("[PyroServers/PyroFetch]:", error); - if (error instanceof FetchError) { - const statusCode = error.response?.status; - const statusText = error.response?.statusText || "[no status text available]"; - const errorMessages: { [key: number]: string } = { - 400: "Bad Request", - 401: "Unauthorized", - 403: "Forbidden", - 404: "Not Found", - 405: "Method Not Allowed", - 429: "Too Many Requests", - 500: "Internal Server Error", - 502: "Bad Gateway", - 503: "Service Unavailable", - }; - const message = - statusCode && statusCode in errorMessages - ? errorMessages[statusCode] - : `HTTP Error: ${statusCode || "[unhandled status code]"} ${statusText}`; - throw new PyroFetchError(`[PyroServers/PyroFetch] ${message}`, statusCode, error); + let attempts = 0; + const maxAttempts = (typeof retry === "boolean" ? (retry ? 1 : 0) : retry) + 1; + let lastError: Error | null = null; + + while (attempts < maxAttempts) { + try { + const response = await $fetch(fullUrl, { + method, + headers, + body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined, + timeout: 10000, + }); + + return response; + } catch (error) { + lastError = error as Error; + attempts++; + + if (error instanceof FetchError) { + const statusCode = error.response?.status; + const isRetryable = statusCode ? [408, 429, 500, 502, 503, 504].includes(statusCode) : true; + + if (!isRetryable || attempts >= maxAttempts) { + throw new PyroServersFetchError(error.message, statusCode, error, module); + } + + const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + + throw new PyroServersFetchError( + "Unexpected error during fetch operation", + undefined, + error as Error, + module, + ); } - throw new PyroFetchError( - "[PyroServers/PyroFetch] An unexpected error occurred during the fetch operation.", - undefined, - error as Error, - ); } + + throw lastError || new Error("Maximum retry attempts reached"); } const internalServerRefrence = ref(null); @@ -271,100 +367,96 @@ const constructServerProperties = (properties: any): string => { }; const processImage = async (iconUrl: string | undefined) => { - const image = ref(null); const sharedImage = useState( `server-icon-${internalServerRefrence.value.serverId}`, - () => undefined, ); - const auth = await PyroFetch(`servers/${internalServerRefrence.value.serverId}/fs`); - try { - const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, { - override: auth, - retry: false, - }); - if (fileData instanceof Blob) { - if (import.meta.client) { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - const img = new Image(); - img.src = URL.createObjectURL(fileData); - await new Promise((resolve) => { - img.onload = () => { - canvas.width = 512; - canvas.height = 512; - ctx?.drawImage(img, 0, 0, 512, 512); - const dataURL = canvas.toDataURL("image/png"); - internalServerRefrence.value.general.image = dataURL; - image.value = dataURL; - sharedImage.value = dataURL; // Store in useState - resolve(); - }; - }); - } - } - } catch (error) { - if (error instanceof PyroFetchError && error.statusCode === 404) { - sharedImage.value = undefined; - } else { - console.error(error); - } + if (sharedImage.value) { + return sharedImage.value; } - if (image.value === null && iconUrl) { - console.log("iconUrl", iconUrl); + try { + const auth = await PyroFetch(`servers/${internalServerRefrence.value.serverId}/fs`); try { - const response = await fetch(iconUrl); - const file = await response.blob(); - const originalfile = new File([file], "server-icon-original.png", { - type: "image/png", + const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, { + override: auth, + retry: false, }); - if (import.meta.client) { - const scaledFile = await new Promise((resolve, reject) => { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - const img = new Image(); - img.src = URL.createObjectURL(file); - img.onload = () => { - canvas.width = 64; - canvas.height = 64; - ctx?.drawImage(img, 0, 0, 64, 64); - canvas.toBlob((blob) => { - if (blob) { - const data = new File([blob], "server-icon.png", { type: "image/png" }); - resolve(data); - } else { - reject(new Error("Canvas toBlob failed")); - } - }, "image/png"); - }; - img.onerror = reject; - }); - if (scaledFile) { - await PyroFetch(`/create?path=/server-icon.png&type=file`, { - method: "POST", - contentType: "application/octet-stream", - body: scaledFile, - override: auth, - }); - await PyroFetch(`/create?path=/server-icon-original.png&type=file`, { - method: "POST", - contentType: "application/octet-stream", - body: originalfile, - override: auth, + if (fileData instanceof Blob) { + if (import.meta.client) { + const dataURL = await new Promise((resolve) => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const img = new Image(); + img.onload = () => { + canvas.width = 512; + canvas.height = 512; + ctx?.drawImage(img, 0, 0, 512, 512); + const dataURL = canvas.toDataURL("image/png"); + sharedImage.value = dataURL; + resolve(dataURL); + URL.revokeObjectURL(img.src); + }; + img.src = URL.createObjectURL(fileData); }); + return dataURL; } } } catch (error) { - if (error instanceof PyroFetchError && error.statusCode === 404) { - console.log("[PYROSERVERS] No server icon found"); - } else { - console.error(error); + if (error instanceof PyroServersFetchError && error.statusCode === 404 && iconUrl) { + try { + const response = await fetch(iconUrl); + if (!response.ok) throw new Error("Failed to fetch icon"); + const file = await response.blob(); + const originalFile = new File([file], "server-icon-original.png", { type: "image/png" }); + + if (import.meta.client) { + const dataURL = await new Promise((resolve) => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const img = new Image(); + img.onload = () => { + canvas.width = 64; + canvas.height = 64; + ctx?.drawImage(img, 0, 0, 64, 64); + canvas.toBlob(async (blob) => { + if (blob) { + const scaledFile = new File([blob], "server-icon.png", { type: "image/png" }); + await PyroFetch(`/create?path=/server-icon.png&type=file`, { + method: "POST", + contentType: "application/octet-stream", + body: scaledFile, + override: auth, + }); + await PyroFetch(`/create?path=/server-icon-original.png&type=file`, { + method: "POST", + contentType: "application/octet-stream", + body: originalFile, + override: auth, + }); + } + }, "image/png"); + const dataURL = canvas.toDataURL("image/png"); + sharedImage.value = dataURL; + resolve(dataURL); + URL.revokeObjectURL(img.src); + }; + img.src = URL.createObjectURL(file); + }); + return dataURL; + } + } catch (error) { + console.error("Failed to process external icon:", error); + } } } + } catch (error) { + console.error("Failed to process server icon:", error); } - return image.value; + + sharedImage.value = undefined; + return undefined; }; // ------------------ GENERAL ------------------ // @@ -564,10 +656,14 @@ const reinstallContent = async (replace: string, projectId: string, versionId: s const createBackup = async (backupName: string) => { try { - const response = (await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups`, { - method: "POST", - body: { name: backupName }, - })) as { id: string }; + const response = await PyroFetch<{ id: string }>( + `servers/${internalServerRefrence.value.serverId}/backups`, + { + method: "POST", + body: { name: backupName }, + }, + ); + await internalServerRefrence.value.refresh(["backups"]); return response.id; } catch (error) { console.error("Error creating backup:", error); @@ -581,6 +677,7 @@ const renameBackup = async (backupId: string, newName: string) => { method: "POST", body: { name: newName }, }); + await internalServerRefrence.value.refresh(["backups"]); } catch (error) { console.error("Error renaming backup:", error); throw error; @@ -592,6 +689,7 @@ const deleteBackup = async (backupId: string) => { await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`, { method: "DELETE", }); + await internalServerRefrence.value.refresh(["backups"]); } catch (error) { console.error("Error deleting backup:", error); throw error; @@ -606,6 +704,7 @@ const restoreBackup = async (backupId: string) => { method: "POST", }, ); + await internalServerRefrence.value.refresh(["backups"]); } catch (error) { console.error("Error restoring backup:", error); throw error; @@ -644,12 +743,10 @@ const getAutoBackup = async () => { const lockBackup = async (backupId: string) => { try { - return await PyroFetch( - `servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`, - { - method: "POST", - }, - ); + await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`, { + method: "POST", + }); + await internalServerRefrence.value.refresh(["backups"]); } catch (error) { console.error("Error locking backup:", error); throw error; @@ -658,14 +755,12 @@ const lockBackup = async (backupId: string) => { const unlockBackup = async (backupId: string) => { try { - return await PyroFetch( - `servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`, - { - method: "POST", - }, - ); + await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`, { + method: "POST", + }); + await internalServerRefrence.value.refresh(["backups"]); } catch (error) { - console.error("Error locking backup:", error); + console.error("Error unlocking backup:", error); throw error; } }; @@ -760,7 +855,7 @@ const retryWithAuth = async (requestFn: () => Promise) => { try { return await requestFn(); } catch (error) { - if (error instanceof PyroFetchError && error.statusCode === 401) { + if (error instanceof PyroServersFetchError && error.statusCode === 401) { await internalServerRefrence.value.refresh(["fs"]); return await requestFn(); } @@ -947,17 +1042,18 @@ const modules: any = { general: { get: async (serverId: string) => { try { - const data = await PyroFetch(`servers/${serverId}`); - // TODO: temp hack to fix hydration error + const data = await PyroFetch(`servers/${serverId}`, {}, "general"); if (data.upstream?.project_id) { const res = await $fetch( `https://api.modrinth.com/v2/project/${data.upstream.project_id}`, ); data.project = res as Project; } + if (import.meta.client) { data.image = (await processImage(data.project?.icon_url)) ?? undefined; } + const motd = await getMotd(); if (motd === "A Minecraft Server") { await setMotd( @@ -967,8 +1063,19 @@ const modules: any = { data.motd = motd; return data; } catch (error) { - internalServerRefrence.value.setError(error); - return undefined; + const fetchError = + error instanceof PyroServersFetchError + ? error + : new PyroServersFetchError("Unknown error occurred", undefined, error as Error); + + return { + status: "error", + server_id: serverId, + error: { + error: fetchError, + timestamp: Date.now(), + }, + }; } }, updateName, @@ -982,16 +1089,23 @@ const modules: any = { content: { get: async (serverId: string) => { try { - const mods = await PyroFetch(`servers/${serverId}/mods`); + const mods = await PyroFetch(`servers/${serverId}/mods`, {}, "content"); return { - data: - internalServerRefrence.value.error === undefined - ? mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? "")) - : [], + data: mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? "")), }; } catch (error) { - internalServerRefrence.value.setError(error); - return undefined; + const fetchError = + error instanceof PyroServersFetchError + ? error + : new PyroServersFetchError("Unknown error occurred", undefined, error as Error); + + return { + data: [], + error: { + error: fetchError, + timestamp: Date.now(), + }, + }; } }, install: installContent, @@ -1001,10 +1115,22 @@ const modules: any = { backups: { get: async (serverId: string) => { try { - return { data: await PyroFetch(`servers/${serverId}/backups`) }; + return { + data: await PyroFetch(`servers/${serverId}/backups`, {}, "backups"), + }; } catch (error) { - internalServerRefrence.value.setError(error); - return undefined; + const fetchError = + error instanceof PyroServersFetchError + ? error + : new PyroServersFetchError("Unknown error occurred", undefined, error as Error); + + return { + data: [], + error: { + error: fetchError, + timestamp: Date.now(), + }, + }; } }, create: createBackup, @@ -1020,10 +1146,26 @@ const modules: any = { network: { get: async (serverId: string) => { try { - return { allocations: await PyroFetch(`servers/${serverId}/allocations`) }; + return { + allocations: await PyroFetch( + `servers/${serverId}/allocations`, + {}, + "network", + ), + }; } catch (error) { - internalServerRefrence.value.setError(error); - return undefined; + const fetchError = + error instanceof PyroServersFetchError + ? error + : new PyroServersFetchError("Unknown error occurred", undefined, error as Error); + + return { + allocations: [], + error: { + error: fetchError, + timestamp: Date.now(), + }, + }; } }, reserveAllocation, @@ -1035,10 +1177,19 @@ const modules: any = { startup: { get: async (serverId: string) => { try { - return await PyroFetch(`servers/${serverId}/startup`); + return await PyroFetch(`servers/${serverId}/startup`, {}, "startup"); } catch (error) { - internalServerRefrence.value.setError(error); - return undefined; + const fetchError = + error instanceof PyroServersFetchError + ? error + : new PyroServersFetchError("Unknown error occurred", undefined, error as Error); + + return { + error: { + error: fetchError, + timestamp: Date.now(), + }, + }; } }, update: updateStartupSettings, @@ -1046,20 +1197,39 @@ const modules: any = { ws: { get: async (serverId: string) => { try { - return await PyroFetch(`servers/${serverId}/ws`); + return await PyroFetch(`servers/${serverId}/ws`, {}, "ws"); } catch (error) { - internalServerRefrence.value.setError(error); - return undefined; + const fetchError = + error instanceof PyroServersFetchError + ? error + : new PyroServersFetchError("Unknown error occurred", undefined, error as Error); + + return { + error: { + error: fetchError, + timestamp: Date.now(), + }, + }; } }, }, fs: { get: async (serverId: string) => { try { - return { auth: await PyroFetch(`servers/${serverId}/fs`) }; + return { auth: await PyroFetch(`servers/${serverId}/fs`, {}, "fs") }; } catch (error) { - internalServerRefrence.value.setError(error); - return undefined; + const fetchError = + error instanceof PyroServersFetchError + ? error + : new PyroServersFetchError("Unknown error occurred", undefined, error as Error); + + return { + auth: undefined, + error: { + error: fetchError, + timestamp: Date.now(), + }, + }; } }, listDirContents, @@ -1367,12 +1537,44 @@ type FSFunctions = { downloadFile: (path: string, raw?: boolean) => Promise; }; -type GeneralModule = General & GeneralFunctions; -type ContentModule = { data: Mod[] } & ContentFunctions; -type BackupsModule = { data: Backup[] } & BackupFunctions; -type NetworkModule = { allocations: Allocation[] } & NetworkFunctions; -type StartupModule = Startup & StartupFunctions; -export type FSModule = { auth: JWTAuth } & FSFunctions; +type ModuleError = { + error: PyroServersFetchError; + timestamp: number; +}; + +type GeneralModule = General & + GeneralFunctions & { + error?: ModuleError; + }; + +type ContentModule = { + data: Mod[]; + error?: ModuleError; +} & ContentFunctions; + +type BackupsModule = { + data: Backup[]; + error?: ModuleError; +} & BackupFunctions; + +type NetworkModule = { + allocations: Allocation[]; + error?: ModuleError; +} & NetworkFunctions; + +type StartupModule = Startup & + StartupFunctions & { + error?: ModuleError; + }; + +type WSModule = JWTAuth & { + error?: ModuleError; +}; + +type FSModule = { + auth: JWTAuth; + error?: ModuleError; +} & FSFunctions; type ModulesMap = { general: GeneralModule; @@ -1380,7 +1582,7 @@ type ModulesMap = { backups: BackupsModule; network: NetworkModule; startup: StartupModule; - ws: JWTAuth; + ws: WSModule; fs: FSModule; }; @@ -1401,6 +1603,7 @@ export type Server = { preserveInstallState?: boolean; }, ) => Promise; + loadModules: (modulesToLoad: avaliableModules) => Promise; setError: (error: Error) => void; error?: Error; serverId: string; @@ -1419,58 +1622,92 @@ export const usePyroServer = async (serverId: string, includedModules: avaliable return; } - const modulesToRefresh = refreshModules || includedModules; - const promises: Promise[] = []; + const modulesToRefresh = [...new Set(refreshModules || includedModules)]; + const serverError = new PyroServerError(); - const uniqueModules = [...new Set(modulesToRefresh)]; + const modulePromises = modulesToRefresh.map(async (module) => { + try { + const mods = modules[module]; + if (!mods?.get) return; - for (const module of uniqueModules) { - const mods = modules[module]; - if (mods.get) { - promises.push( - (async () => { - const data = await mods.get(serverId); - if (data) { - if (module === "general" && options?.preserveConnection) { - const updatedData = { - ...server[module], - ...data, - }; - if (server[module]?.image) { - updatedData.image = server[module].image; - } - if (server[module]?.motd) { - updatedData.motd = server[module].motd; - } - if (options.preserveInstallState && server[module]?.status === "installing") { - updatedData.status = "installing"; - } - server[module] = updatedData; - } else { - server[module] = { ...server[module], ...data }; - } - } - })(), - ); + const data = await mods.get(serverId); + if (!data) return; + + if (module === "general" && options?.preserveConnection) { + server[module] = { + ...server[module], + ...data, + image: server[module]?.image || data.image, + motd: server[module]?.motd || data.motd, + status: + options.preserveInstallState && server[module]?.status === "installing" + ? "installing" + : data.status, + }; + } else { + server[module] = { ...server[module], ...data }; + } + } catch (error) { + console.error(`Failed to refresh module ${module}:`, error); + if (error instanceof Error) { + serverError.addError(module, error); + } + } + }); + + await Promise.allSettled(modulePromises); + + if (serverError.hasErrors()) { + if (server.error && server.error instanceof PyroServerError) { + serverError.errors.forEach((error, module) => { + (server.error as PyroServerError).addError(module, error); + }); + } else { + server.setError(serverError); } } + }, + loadModules: async (modulesToLoad: avaliableModules) => { + const newModules = modulesToLoad.filter((module) => !server[module]); + if (newModules.length === 0) return; - await Promise.all(promises); + newModules.forEach((module) => { + server[module] = modules[module]; + }); + + await server.refresh(newModules); }, setError: (error: Error) => { - server.error = error; + if (!server.error) { + server.error = error; + } else if (error instanceof PyroServerError) { + if (!(server.error instanceof PyroServerError)) { + const newError = new PyroServerError(); + newError.addError("previous", server.error); + server.error = newError; + } + error.errors.forEach((err, module) => { + (server.error as PyroServerError).addError(module, err); + }); + } }, + serverId, }); - for (const module of includedModules) { - const mods = modules[module]; - server[module] = mods; - } + const initialModules = includedModules.filter((module) => ["general", "ws"].includes(module)); + const deferredModules = includedModules.filter((module) => !["general", "ws"].includes(module)); + + initialModules.forEach((module) => { + server[module] = modules[module]; + }); internalServerRefrence.value = server; + await server.refresh(initialModules); - await server.refresh(); + if (deferredModules.length > 0) { + await server.loadModules(deferredModules); + } return server as Server; }; diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue index 84426eba..733ffc45 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -294,12 +294,19 @@ - - - - +
@@ -466,102 +473,95 @@
-
+
- @@ -599,6 +599,12 @@ import { GlassesIcon, PaintBrushIcon, PackageOpenIcon, + DiscordIcon, + BlueskyIcon, + TumblrIcon, + TwitterIcon, + MastodonIcon, + GitHubIcon, XIcon as CrossIcon, ScaleIcon as ModerationIcon, BellIcon as NotificationIcon, @@ -708,50 +714,6 @@ const footerMessages = defineMessages({ id: "layout.footer.open-source", defaultMessage: "Modrinth is open source.", }, - companyTitle: { - id: "layout.footer.company.title", - defaultMessage: "Company", - }, - terms: { - id: "layout.footer.company.terms", - defaultMessage: "Terms", - }, - privacy: { - id: "layout.footer.company.privacy", - defaultMessage: "Privacy", - }, - rules: { - id: "layout.footer.company.rules", - defaultMessage: "Rules", - }, - careers: { - id: "layout.footer.company.careers", - defaultMessage: "Careers", - }, - resourcesTitle: { - id: "layout.footer.resources.title", - defaultMessage: "Resources", - }, - support: { - id: "layout.footer.resources.support", - defaultMessage: "Support", - }, - blog: { - id: "layout.footer.resources.blog", - defaultMessage: "Blog", - }, - docs: { - id: "layout.footer.resources.docs", - defaultMessage: "Docs", - }, - status: { - id: "layout.footer.resources.status", - defaultMessage: "Status", - }, - interactTitle: { - id: "layout.footer.interact.title", - defaultMessage: "Interact", - }, legalDisclaimer: { id: "layout.footer.legal-disclaimer", defaultMessage: @@ -1023,6 +985,194 @@ const { cycle: changeTheme } = useTheme(); function hideStagingBanner() { cosmetics.value.hideStagingBanner = true; } + +const socialLinks = [ + { + label: formatMessage( + defineMessage({ id: "layout.footer.social.discord", defaultMessage: "Discord" }), + ), + href: "https://discord.modrinth.com", + icon: DiscordIcon, + }, + { + label: formatMessage( + defineMessage({ id: "layout.footer.social.bluesky", defaultMessage: "Bluesky" }), + ), + href: "https://bsky.app/profile/modrinth.com", + icon: BlueskyIcon, + }, + { + label: formatMessage( + defineMessage({ id: "layout.footer.social.mastodon", defaultMessage: "Mastodon" }), + ), + href: "https://floss.social/@modrinth", + icon: MastodonIcon, + rel: "me", + }, + { + label: formatMessage( + defineMessage({ id: "layout.footer.social.tumblr", defaultMessage: "Tumblr" }), + ), + href: "https://tumblr.com/modrinth", + icon: TumblrIcon, + }, + { + label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })), + href: "https://x.com/modrinth", + icon: TwitterIcon, + }, + { + label: formatMessage( + defineMessage({ id: "layout.footer.social.github", defaultMessage: "GitHub" }), + ), + href: "https://github.com/modrinth", + icon: GitHubIcon, + }, +]; + +const footerLinks = [ + { + label: formatMessage(defineMessage({ id: "layout.footer.about", defaultMessage: "About" })), + links: [ + { + href: "https://blog.modrinth.com", + label: formatMessage( + defineMessage({ id: "layout.footer.about.blog", defaultMessage: "Blog" }), + ), + }, + { + href: "/news/changelog", + label: formatMessage( + defineMessage({ id: "layout.footer.about.changelog", defaultMessage: "Changelog" }), + ), + }, + { + href: "https://status.modrinth.com", + label: formatMessage( + defineMessage({ id: "layout.footer.about.status", defaultMessage: "Status" }), + ), + }, + { + href: "https://careers.modrinth.com", + label: formatMessage( + defineMessage({ id: "layout.footer.about.careers", defaultMessage: "Careers" }), + ), + }, + { + href: "/legal/cmp-info", + label: formatMessage( + defineMessage({ + id: "layout.footer.about.rewards-program", + defaultMessage: "Rewards Program", + }), + ), + }, + ], + }, + { + label: formatMessage( + defineMessage({ id: "layout.footer.products", defaultMessage: "Products" }), + ), + links: [ + { + href: "/plus", + label: formatMessage( + defineMessage({ id: "layout.footer.products.plus", defaultMessage: "Modrinth+" }), + ), + }, + { + href: "/app", + label: formatMessage( + defineMessage({ id: "layout.footer.products.app", defaultMessage: "Modrinth App" }), + ), + }, + { + href: "/servers", + label: formatMessage( + defineMessage({ + id: "layout.footer.products.servers", + defaultMessage: "Modrinth Servers", + }), + ), + }, + ], + }, + { + label: formatMessage( + defineMessage({ id: "layout.footer.resources", defaultMessage: "Resources" }), + ), + links: [ + { + href: "https://support.modrinth.com", + label: formatMessage( + defineMessage({ + id: "layout.footer.resources.help-center", + defaultMessage: "Help Center", + }), + ), + }, + { + href: "https://crowdin.com/project/modrinth", + label: formatMessage( + defineMessage({ id: "layout.footer.resources.translate", defaultMessage: "Translate" }), + ), + }, + { + href: "https://github.com/modrinth/code/issues", + label: formatMessage( + defineMessage({ + id: "layout.footer.resources.report-issues", + defaultMessage: "Report issues", + }), + ), + }, + { + href: "https://docs.modrinth.com/api/", + label: formatMessage( + defineMessage({ + id: "layout.footer.resources.api-docs", + defaultMessage: "API documentation", + }), + ), + }, + ], + }, + { + label: formatMessage(defineMessage({ id: "layout.footer.legal", defaultMessage: "Legal" })), + links: [ + { + href: "/legal/rules", + label: formatMessage( + defineMessage({ id: "layout.footer.legal.rules", defaultMessage: "Content Rules" }), + ), + }, + { + href: "/legal/terms", + label: formatMessage( + defineMessage({ id: "layout.footer.legal.terms-of-use", defaultMessage: "Terms of Use" }), + ), + }, + { + href: "/legal/privacy", + label: formatMessage( + defineMessage({ + id: "layout.footer.legal.privacy-policy", + defaultMessage: "Privacy Policy", + }), + ), + }, + { + href: "/legal/security", + label: formatMessage( + defineMessage({ + id: "layout.footer.legal.security-notice", + defaultMessage: "Security Notice", + }), + ), + }, + ], + }, +]; diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index 773dc25a..478b01b8 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -287,45 +287,90 @@ "layout.banner.verify-email.title": { "message": "For security purposes, please verify your email address on Modrinth." }, - "layout.footer.company.careers": { + "layout.footer.about": { + "message": "About" + }, + "layout.footer.about.blog": { + "message": "Blog" + }, + "layout.footer.about.careers": { "message": "Careers" }, - "layout.footer.company.privacy": { - "message": "Privacy" + "layout.footer.about.changelog": { + "message": "Changelog" }, - "layout.footer.company.rules": { - "message": "Rules" + "layout.footer.about.rewards-program": { + "message": "Rewards Program" }, - "layout.footer.company.terms": { - "message": "Terms" + "layout.footer.about.status": { + "message": "Status" }, - "layout.footer.company.title": { - "message": "Company" - }, - "layout.footer.interact.title": { - "message": "Interact" + "layout.footer.legal": { + "message": "Legal" }, "layout.footer.legal-disclaimer": { "message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT." }, + "layout.footer.legal.privacy-policy": { + "message": "Privacy Policy" + }, + "layout.footer.legal.rules": { + "message": "Content Rules" + }, + "layout.footer.legal.security-notice": { + "message": "Security Notice" + }, + "layout.footer.legal.terms-of-use": { + "message": "Terms of Use" + }, "layout.footer.open-source": { "message": "Modrinth is open source." }, - "layout.footer.resources.blog": { - "message": "Blog" + "layout.footer.products": { + "message": "Products" }, - "layout.footer.resources.docs": { - "message": "Docs" + "layout.footer.products.app": { + "message": "Modrinth App" }, - "layout.footer.resources.status": { - "message": "Status" + "layout.footer.products.plus": { + "message": "Modrinth+" }, - "layout.footer.resources.support": { - "message": "Support" + "layout.footer.products.servers": { + "message": "Modrinth Servers" }, - "layout.footer.resources.title": { + "layout.footer.resources": { "message": "Resources" }, + "layout.footer.resources.api-docs": { + "message": "API documentation" + }, + "layout.footer.resources.help-center": { + "message": "Help Center" + }, + "layout.footer.resources.report-issues": { + "message": "Report issues" + }, + "layout.footer.resources.translate": { + "message": "Translate" + }, + "layout.footer.social.bluesky": { + "message": "Bluesky" + }, + "layout.footer.social.discord": { + "message": "Discord" + }, + "layout.footer.social.github": { + "message": "GitHub" + }, + "layout.footer.social.mastodon": { + "message": "Mastodon" + }, + "layout.footer.social.tumblr": { + "message": "Tumblr" + }, + "layout.footer.social.x": { + "message": "X" + }, "layout.menu-toggle.action": { "message": "Toggle menu" }, diff --git a/apps/frontend/src/pages/[type]/[id].vue b/apps/frontend/src/pages/[type]/[id].vue index 9a448629..d09142cb 100644 --- a/apps/frontend/src/pages/[type]/[id].vue +++ b/apps/frontend/src/pages/[type]/[id].vue @@ -460,6 +460,10 @@ class="new-page sidebar" :class="{ 'alt-layout': cosmetics.leftContentLayout, + 'ultimate-sidebar': + showModerationChecklist && + !collapsedModerationChecklist && + !flags.alwaysShowChecklistAsPopup, }" >
@@ -674,7 +678,7 @@ :auth="auth" :tags="tags" /> - + {{ project.title }} has been archived. {{ project.title }} will not receive any further updates unless the author decides to unarchive the project. @@ -805,13 +809,18 @@ @delete-version="deleteVersion" />
+
+ +
- diff --git a/apps/frontend/src/pages/legal/cmp-info.vue b/apps/frontend/src/pages/legal/cmp-info.vue index a7c6da79..0a10b3be 100644 --- a/apps/frontend/src/pages/legal/cmp-info.vue +++ b/apps/frontend/src/pages/legal/cmp-info.vue @@ -1,7 +1,7 @@ - diff --git a/apps/frontend/src/pages/moderation/review.vue b/apps/frontend/src/pages/moderation/review.vue index 0b94fbe6..446f0eb6 100644 --- a/apps/frontend/src/pages/moderation/review.vue +++ b/apps/frontend/src/pages/moderation/review.vue @@ -101,7 +101,7 @@ - diff --git a/packages/ui/src/components/base/CopyCode.vue b/packages/ui/src/components/base/CopyCode.vue index 9c146779..affe4778 100644 --- a/packages/ui/src/components/base/CopyCode.vue +++ b/packages/ui/src/components/base/CopyCode.vue @@ -29,6 +29,7 @@ async function copyText() {