diff --git a/CLAUDE.md b/CLAUDE.md index 7cb1dad1b6..da0ec7725a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,10 +67,22 @@ Each project may have its own `CLAUDE.md` with detailed instructions: - [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API - [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website +## Skills (`.claude/skills/`) + +Project-specific skill files with detailed patterns. Use them when the task matches: + +- **`api-module`** — Adding a new API endpoint module to `packages/api-client` (types, module class, registry registration) +- **`cross-platform-pages`** — Building a page that needs to work in both the website (`apps/frontend`) and the desktop app (`apps/app-frontend`) +- **`dependency-injection`** — Creating or wiring up a `provide`/`inject` context for platform abstraction or deep component state sharing +- **`figma-mcp`** — Translating a Figma design into Vue components using the Figma MCP tools +- **`i18n-convert`** — Converting hardcoded English strings in Vue SFCs into the `@modrinth/ui` i18n system (`defineMessages`, `formatMessage`, `IntlFormatted`) +- **`multistage-modals`** — Building a wizard-like modal with multiple stages, progress tracking, and per-stage buttons using `MultiStageModal` +- **`tanstack-query`** — Fetching, caching, or mutating server data with `@tanstack/vue-query` (queries, mutations, invalidation, optimistic updates) + ## Code Guidelines ### Comments -- DO NOT use "heading" comments like: // === Helper methods === . +- DO NOT use "heading" comments like: `=== Helper methods ===`. - Use doc comments, but avoid inline comments unless ABSOLUTELY necessary for clarity. Code should aim to be self documenting! ## Bash Guidelines @@ -78,12 +90,13 @@ Each project may have its own `CLAUDE.md` with detailed instructions: ### Output handling - DO NOT pipe output through `head`, `tail`, `less`, or `more` - NEVER use `| head -n X` or `| tail -n X` to truncate output -- Run commands directly without pipes when possible -- If you need to limit output, use command-specific flags (e.g. `git log -n 10` instead of `git log | head -10`) +- IMPORTANT: Run commands directly without pipes when possible +- IMPORTANT: If you need to limit output, use command-specific flags (e.g. `git log -n 10` instead of `git log | head -10`) - ALWAYS read the full output — never pipe through filters ### General - Do not create new non-source code files (e.g. Bash scripts, SQL scripts) unless explicitly prompted to +- For Frontend, when doing lint checks, only use the `prepr` commands, do not use `typecheck` or `tsc` etc. ## Skills diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index c7561a588b..2352839df3 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -28,17 +28,18 @@ "@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-window-state": "^2.2.2", "@types/three": "^0.172.0", - "intl-messageformat": "^10.7.7", - "vue-i18n": "^10.0.0", "@vueuse/core": "^11.1.0", "dayjs": "^1.11.10", "floating-vue": "^5.2.2", + "fuse.js": "^6.6.2", + "intl-messageformat": "^10.7.7", "ofetch": "^1.3.4", "pinia": "^3.0.0", "posthog-js": "^1.158.2", "three": "^0.172.0", "vite-svg-loader": "^5.1.0", "vue": "^3.5.13", + "vue-i18n": "^10.0.0", "vue-multiselect": "3.0.0", "vue-router": "^4.6.0", "vue-virtual-scroller": "v2.0.0-beta.8" diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index eed52f8356..872ad5dc16 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -31,6 +31,8 @@ import { Button, ButtonStyled, commonMessages, + ContentInstallModal, + CreationFlowModal, defineMessages, I18nDebugPanel, NewsArticleCard, @@ -38,6 +40,7 @@ import { OverflowMenu, PopupNotificationPanel, ProgressSpinner, + provideModalBehavior, provideModrinthClient, provideNotificationManager, providePageContext, @@ -66,8 +69,6 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue' import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue' import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue' import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue' -import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue' -import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue' import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue' import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue' import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue' @@ -86,6 +87,7 @@ import { check_reachable } from '@/helpers/auth.js' import { get_user } from '@/helpers/cache.js' import { command_listener, warning_listener } from '@/helpers/events.js' import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts' +import { create_profile_and_install_from_file } from '@/helpers/pack' import { list } from '@/helpers/profile.js' import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get_opening_command, initialize_state } from '@/helpers/state' @@ -98,15 +100,16 @@ import { isNetworkMetered, } from '@/helpers/utils.js' import i18n from '@/i18n.config' +import { createContentInstall, provideContentInstall } from '@/providers/content-install' import { provideAppUpdateDownloadProgress, subscribeToDownloadProgress, } from '@/providers/download-progress.ts' +import { createServerInstall, provideServerInstall } from '@/providers/server-install' +import { setupProviders } from '@/providers/setup' import { useError } from '@/store/error.js' -import { playServerProject, useInstall } from '@/store/install.js' import { useLoading, useTheming } from '@/store/state' -import { create_profile_and_install_from_file } from './helpers/pack' import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer' import { get_available_capes, get_available_skins } from './helpers/skins' import { AppNotificationManager } from './providers/app-notifications' @@ -136,6 +139,15 @@ providePageContext({ hierarchicalSidebarAvailable: ref(true), showAds: ref(false), }) +provideModalBehavior({ + noblur: computed(() => !themeStore.advancedRendering), + onShow: () => hide_ads_window(), + onHide: () => show_ads_window(), +}) + +const { installationModal, handleCreate, handleBrowseModpacks } = + setupProviders(notificationManager) + const news = ref([]) const availableSurvey = ref(false) @@ -392,7 +404,33 @@ const error = useError() const errorModal = ref() const minecraftAuthErrorModal = ref() -const install = useInstall() +const contentInstall = createContentInstall({ router, handleError }) +provideContentInstall(contentInstall) +const { + instances: contentInstallInstances, + compatibleLoaders: contentInstallLoaders, + gameVersions: contentInstallGameVersions, + loading: contentInstallLoading, + defaultTab: contentInstallDefaultTab, + preferredLoader: contentInstallPreferredLoader, + preferredGameVersion: contentInstallPreferredGameVersion, + releaseGameVersions: contentInstallReleaseGameVersions, + handleInstallToInstance, + handleCreateAndInstall, + handleCancel: handleContentInstallCancel, + setContentInstallModal, + setInstallConfirmModal: setContentInstallConfirmModal, + setIncompatibilityWarningModal: setContentIncompatibilityWarningModal, +} = contentInstall + +const serverInstall = createServerInstall({ router, handleError, popupNotificationManager }) +provideServerInstall(serverInstall) +const { + setInstallToPlayModal: setServerInstallToPlayModal, + setUpdateToPlayModal: setServerUpdateToPlayModal, + setAddServerToInstanceModal: setServerAddServerToInstanceModal, +} = serverInstall + const modInstallModal = ref() const addServerToInstanceModal = ref() const installConfirmModal = ref() @@ -474,13 +512,12 @@ onMounted(() => { error.setErrorModal(errorModal.value) error.setMinecraftAuthErrorModal(minecraftAuthErrorModal.value) - install.setIncompatibilityWarningModal(incompatibilityWarningModal) - install.setInstallConfirmModal(installConfirmModal) - install.setModInstallModal(modInstallModal) - install.setAddServerToInstanceModal(addServerToInstanceModal) - install.setInstallToPlayModal(installToPlayModal) - install.setUpdateToPlayModal(updateToPlayModal) - install.setPopupNotificationManager(popupNotificationManager) + setContentIncompatibilityWarningModal(incompatibilityWarningModal.value) + setContentInstallConfirmModal(installConfirmModal.value) + setContentInstallModal(modInstallModal.value) + setServerAddServerToInstanceModal(addServerToInstanceModal.value) + setServerInstallToPlayModal(installToPlayModal.value) + setServerUpdateToPlayModal(updateToPlayModal.value) }) const accounts = ref(null) @@ -898,9 +935,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload) - - - +
@@ -946,7 +987,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) @@ -1021,9 +1062,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
-
- -
+
+ +
-
+
- + diff --git a/apps/app-frontend/src/components/RowDisplay.vue b/apps/app-frontend/src/components/RowDisplay.vue index a8e8970aed..56c65fa0b4 100644 --- a/apps/app-frontend/src/components/RowDisplay.vue +++ b/apps/app-frontend/src/components/RowDisplay.vue @@ -24,10 +24,11 @@ import { trackEvent } from '@/helpers/analytics' import { get_by_profile_path } from '@/helpers/process.js' import { duplicate, kill, remove, run } from '@/helpers/profile.js' import { showProfileInFolder } from '@/helpers/utils.js' +import { injectContentInstall } from '@/providers/content-install' import { handleSevereError } from '@/store/error.js' -import { install as installVersion } from '@/store/install.js' const { handleError } = injectNotificationManager() +const { install: installVersion } = injectContentInstall() const router = useRouter() diff --git a/apps/app-frontend/src/components/ui/Breadcrumbs.vue b/apps/app-frontend/src/components/ui/Breadcrumbs.vue index ee12e17ea7..144ed5ebc0 100644 --- a/apps/app-frontend/src/components/ui/Breadcrumbs.vue +++ b/apps/app-frontend/src/components/ui/Breadcrumbs.vue @@ -1,64 +1,147 @@ - + + diff --git a/apps/app-frontend/src/components/ui/ExportModal.vue b/apps/app-frontend/src/components/ui/ExportModal.vue index 4966b1c7bd..fa605f3206 100644 --- a/apps/app-frontend/src/components/ui/ExportModal.vue +++ b/apps/app-frontend/src/components/ui/ExportModal.vue @@ -1,6 +1,14 @@ diff --git a/apps/frontend/src/components/ui/servers/ServerInstallation.vue b/apps/frontend/src/components/ui/servers/ServerInstallation.vue deleted file mode 100644 index 0168d4f203..0000000000 --- a/apps/frontend/src/components/ui/servers/ServerInstallation.vue +++ /dev/null @@ -1,286 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/ui/servers/ServerSidebar.vue b/apps/frontend/src/components/ui/servers/ServerSidebar.vue index 32534c0a52..1f517a9645 100644 --- a/apps/frontend/src/components/ui/servers/ServerSidebar.vue +++ b/apps/frontend/src/components/ui/servers/ServerSidebar.vue @@ -24,12 +24,7 @@
- +
@@ -38,7 +33,6 @@ import { RightArrowIcon } from '@modrinth/assets' import type { RouteLocationNormalized } from 'vue-router' -import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts' import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue' const emit = defineEmits(['reinstall']) @@ -52,7 +46,6 @@ defineProps<{ shown?: boolean }[] route: RouteLocationNormalized - server: ModrinthServer backupInProgress?: BackupInProgressReason }>() diff --git a/apps/frontend/src/composables/featureFlags.ts b/apps/frontend/src/composables/featureFlags.ts index 96748eec55..3be4de5ba1 100644 --- a/apps/frontend/src/composables/featureFlags.ts +++ b/apps/frontend/src/composables/featureFlags.ts @@ -42,6 +42,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({ hidePreviewBanner: false, i18nDebug: false, showDiscoverProjectButtons: false, + useV1ContentTabAPI: true, } as const) export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS diff --git a/apps/frontend/src/composables/servers/modrinth-servers.ts b/apps/frontend/src/composables/servers/modrinth-servers.ts deleted file mode 100644 index 2b02457a28..0000000000 --- a/apps/frontend/src/composables/servers/modrinth-servers.ts +++ /dev/null @@ -1,287 +0,0 @@ -import type { AbstractWebNotificationManager } from '@modrinth/ui' -import type { JWTAuth, ModuleError, ModuleName } from '@modrinth/utils' -import { ModrinthServerError } from '@modrinth/utils' - -import { ContentModule, GeneralModule, NetworkModule, StartupModule } from './modules/index.ts' -import { useServersFetch } from './servers-fetch.ts' - -export function handleServersError(err: any, notifications: AbstractWebNotificationManager) { - if (err instanceof ModrinthServerError && err.v1Error) { - notifications.addNotification({ - title: err.v1Error?.context ?? `An error occurred`, - type: 'error', - text: err.v1Error.description, - errorCode: err.v1Error.error, - }) - } else { - notifications.addNotification({ - title: 'An error occurred', - type: 'error', - text: err.message ?? (err.data ? err.data.description : err), - }) - } -} - -export class ModrinthServer { - readonly serverId: string - private errors: Partial> = {} - - readonly general: GeneralModule - readonly content: ContentModule - readonly network: NetworkModule - readonly startup: StartupModule - - constructor(serverId: string) { - this.serverId = serverId - - this.general = new GeneralModule(this) - this.content = new ContentModule(this) - this.network = new NetworkModule(this) - this.startup = new StartupModule(this) - } - - async fetchConfigFile(fileName: string): Promise { - return await useServersFetch(`servers/${this.serverId}/config/${fileName}`) - } - - constructServerProperties(properties: any): string { - let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n` - - for (const [key, value] of Object.entries(properties)) { - if (typeof value === 'object') { - fileContent += `${key}=${JSON.stringify(value)}\n` - } else if (typeof value === 'boolean') { - fileContent += `${key}=${value ? 'true' : 'false'}\n` - } else { - fileContent += `${key}=${value}\n` - } - } - - return fileContent - } - - async processImage(iconUrl: string | undefined): Promise { - const sharedImage = useState(`server-icon-${this.serverId}`) - - if (sharedImage.value) { - return sharedImage.value - } - - try { - const auth = await useServersFetch(`servers/${this.serverId}/fs`) - try { - const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, { - override: auth, - retry: 1, // Reduce retries for optional resources - }) - - if (fileData instanceof Blob && 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 ModrinthServerError) { - if (error.statusCode && error.statusCode >= 500) { - console.debug('Service unavailable, skipping icon processing') - sharedImage.value = undefined - return undefined - } - - if (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 useServersFetch(`/create?path=/server-icon.png&type=file`, { - method: 'POST', - contentType: 'application/octet-stream', - body: scaledFile, - override: auth, - }) - await useServersFetch(`/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 (externalError: any) { - console.debug('Could not process external icon:', externalError.message) - } - } - } else { - throw error - } - } - } catch (error: any) { - console.debug('Icon processing failed:', error.message) - } - - sharedImage.value = undefined - return undefined - } - - async testNodeReachability(): Promise { - if (!this.general?.node?.instance) { - console.warn('No node instance available for ping test') - return false - } - - const wsUrl = `wss://${this.general.node.instance}/pingtest` - - try { - return await new Promise((resolve) => { - const socket = new WebSocket(wsUrl) - const timeout = setTimeout(() => { - socket.close() - resolve(false) - }, 5000) - - socket.onopen = () => { - clearTimeout(timeout) - socket.send(performance.now().toString()) - } - - socket.onmessage = () => { - clearTimeout(timeout) - socket.close() - resolve(true) - } - - socket.onerror = () => { - clearTimeout(timeout) - resolve(false) - } - }) - } catch (error) { - console.error(`Failed to ping node ${wsUrl}:`, error) - return false - } - } - - async refresh( - modules: ModuleName[] = [], - options?: { - preserveConnection?: boolean - preserveInstallState?: boolean - }, - ): Promise { - const modulesToRefresh = - modules.length > 0 ? modules : (['general', 'content', 'network', 'startup'] as ModuleName[]) - - for (const module of modulesToRefresh) { - this.errors[module] = undefined - - try { - switch (module) { - case 'general': { - if (options?.preserveConnection) { - const currentImage = this.general.image - const currentMotd = this.general.motd - const currentStatus = this.general.status - - await this.general.fetch() - - if (currentImage) { - this.general.image = currentImage - } - if (currentMotd) { - this.general.motd = currentMotd - } - if (options.preserveInstallState && currentStatus === 'installing') { - this.general.status = 'installing' - } - } else { - await this.general.fetch() - } - break - } - case 'content': - await this.content.fetch() - break - case 'network': - await this.network.fetch() - break - case 'startup': - await this.startup.fetch() - break - } - } catch (error) { - if (error instanceof ModrinthServerError) { - if (error.statusCode === 404 && module === 'content') { - console.debug(`Optional ${module} resource not found:`, error.message) - continue - } - - if (error.statusCode && error.statusCode >= 500) { - console.debug(`Temporary ${module} unavailable:`, error.message) - continue - } - } - - this.errors[module] = { - error: - error instanceof ModrinthServerError - ? error - : new ModrinthServerError('Unknown error', undefined, error as Error), - timestamp: Date.now(), - } - } - } - } - - get moduleErrors() { - return this.errors - } -} - -export const useModrinthServers = async ( - serverId: string, - includedModules: ModuleName[] = ['general'], -) => { - const server = new ModrinthServer(serverId) - await server.refresh(includedModules) - return reactive(server) -} diff --git a/apps/frontend/src/composables/servers/modules/backups.ts b/apps/frontend/src/composables/servers/modules/backups.ts deleted file mode 100644 index 921b9a350e..0000000000 --- a/apps/frontend/src/composables/servers/modules/backups.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { AutoBackupSettings, Backup } from '@modrinth/utils' - -import { useServersFetch } from '../servers-fetch.ts' -import { ServerModule } from './base.ts' - -export class BackupsModule extends ServerModule { - data: Backup[] = [] - - async fetch(): Promise { - this.data = await useServersFetch(`servers/${this.serverId}/backups`, {}, 'backups') - } - - async create(backupName: string): Promise { - const tempId = `temp-${Date.now()}-${Math.random().toString(36).substring(7)}` - const tempBackup: Backup = { - id: tempId, - name: backupName, - created_at: new Date().toISOString(), - locked: false, - automated: false, - interrupted: false, - ongoing: true, - task: { create: { progress: 0, state: 'ongoing' } }, - } - this.data.push(tempBackup) - - try { - const response = await useServersFetch<{ id: string }>(`servers/${this.serverId}/backups`, { - method: 'POST', - body: { name: backupName }, - }) - - const backup = this.data.find((b) => b.id === tempId) - if (backup) { - backup.id = response.id - } - - return response.id - } catch (error) { - this.data = this.data.filter((b) => b.id !== tempId) - throw error - } - } - - async rename(backupId: string, newName: string): Promise { - await useServersFetch(`servers/${this.serverId}/backups/${backupId}/rename`, { - method: 'POST', - body: { name: newName }, - }) - await this.fetch() - } - - async delete(backupId: string): Promise { - await useServersFetch(`servers/${this.serverId}/backups/${backupId}`, { - method: 'DELETE', - }) - await this.fetch() - } - - async restore(backupId: string): Promise { - const backup = this.data.find((b) => b.id === backupId) - if (backup) { - if (!backup.task) backup.task = {} - backup.task.restore = { progress: 0, state: 'ongoing' } - } - - try { - await useServersFetch(`servers/${this.serverId}/backups/${backupId}/restore`, { - method: 'POST', - }) - } catch (error) { - if (backup?.task?.restore) { - delete backup.task.restore - } - throw error - } - } - - async lock(backupId: string): Promise { - await useServersFetch(`servers/${this.serverId}/backups/${backupId}/lock`, { - method: 'POST', - }) - await this.fetch() - } - - async unlock(backupId: string): Promise { - await useServersFetch(`servers/${this.serverId}/backups/${backupId}/unlock`, { - method: 'POST', - }) - await this.fetch() - } - - async retry(backupId: string): Promise { - await useServersFetch(`servers/${this.serverId}/backups/${backupId}/retry`, { - method: 'POST', - }) - } - - async updateAutoBackup(autoBackup: 'enable' | 'disable', interval: number): Promise { - await useServersFetch(`servers/${this.serverId}/autobackup`, { - method: 'POST', - body: { set: autoBackup, interval }, - }) - } - - async getAutoBackup(): Promise { - return await useServersFetch(`servers/${this.serverId}/autobackup`) - } -} diff --git a/apps/frontend/src/composables/servers/modules/base.ts b/apps/frontend/src/composables/servers/modules/base.ts deleted file mode 100644 index 151fadf231..0000000000 --- a/apps/frontend/src/composables/servers/modules/base.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ModrinthServer } from '../modrinth-servers.ts' - -export abstract class ServerModule { - protected server: ModrinthServer - - constructor(server: ModrinthServer) { - this.server = server - } - - protected get serverId(): string { - return this.server.serverId - } - - abstract fetch(): Promise -} diff --git a/apps/frontend/src/composables/servers/modules/content.ts b/apps/frontend/src/composables/servers/modules/content.ts deleted file mode 100644 index 2db34b7691..0000000000 --- a/apps/frontend/src/composables/servers/modules/content.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ContentType, Mod } from '@modrinth/utils' - -import { useServersFetch } from '../servers-fetch.ts' -import { ServerModule } from './base.ts' - -export class ContentModule extends ServerModule { - data: Mod[] = [] - - async fetch(): Promise { - const mods = await useServersFetch(`servers/${this.serverId}/mods`, {}, 'content') - this.data = mods.sort((a, b) => (a?.name ?? '').localeCompare(b?.name ?? '')) - } - - async install(contentType: ContentType, projectId: string, versionId: string): Promise { - await useServersFetch(`servers/${this.serverId}/mods`, { - method: 'POST', - body: { - rinth_ids: { project_id: projectId, version_id: versionId }, - install_as: contentType, - }, - }) - } - - async remove(path: string): Promise { - await useServersFetch(`servers/${this.serverId}/deleteMod`, { - method: 'POST', - body: { path }, - }) - } - - async reinstall(replace: string, projectId: string, versionId: string): Promise { - await useServersFetch(`servers/${this.serverId}/mods/update`, { - method: 'POST', - body: { replace, project_id: projectId, version_id: versionId }, - }) - } -} diff --git a/apps/frontend/src/composables/servers/modules/general.ts b/apps/frontend/src/composables/servers/modules/general.ts deleted file mode 100644 index 77776b78d0..0000000000 --- a/apps/frontend/src/composables/servers/modules/general.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { JWTAuth, PowerAction, Project, ServerGeneral } from '@modrinth/utils' -import { $fetch } from 'ofetch' - -import { useServersFetch } from '../servers-fetch.ts' -import { ServerModule } from './base.ts' - -export class GeneralModule extends ServerModule implements ServerGeneral { - server_id!: string - name!: string - owner_id!: string - net!: { ip: string; port: number; domain: string } - game!: string - backup_quota!: number - used_backup_quota!: number - status!: string - suspension_reason!: string - loader!: string - loader_version!: string - mc_version!: string - upstream!: { - kind: 'modpack' | 'mod' | 'resourcepack' - version_id: string - project_id: string - } | null - - motd?: string - image?: string - project?: Project - sftp_username!: string - sftp_password!: string - sftp_host!: string - datacenter?: string - notices?: any[] - node!: { token: string; instance: string } - flows?: { intro?: boolean } - - is_medal?: boolean - - async fetch(): Promise { - const data = await useServersFetch(`servers/${this.serverId}`, {}, 'general') - - if (data.upstream?.project_id) { - const project = await $fetch( - `https://api.modrinth.com/v2/project/${data.upstream.project_id}`, - ) - data.project = project as Project - } - - if (import.meta.client) { - data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined - } - - // Copy data to this module - Object.assign(this, data) - } - - async updateName(newName: string): Promise { - await useServersFetch(`servers/${this.serverId}/name`, { - method: 'POST', - body: { name: newName }, - }) - } - - async power(action: PowerAction): Promise { - await useServersFetch(`servers/${this.serverId}/power`, { - method: 'POST', - body: { action }, - }) - await new Promise((resolve) => setTimeout(resolve, 1000)) - await this.fetch() // Refresh this module - } - - async reinstall( - loader: boolean, - projectId: string, - versionId?: string, - loaderVersionId?: string, - hardReset: boolean = false, - ): Promise { - const hardResetParam = hardReset ? 'true' : 'false' - if (loader) { - if (projectId.toLowerCase() === 'neoforge') { - projectId = 'NeoForge' - } - await useServersFetch(`servers/${this.serverId}/reinstall?hard=${hardResetParam}`, { - method: 'POST', - body: { - loader: projectId, - loader_version: loaderVersionId, - game_version: versionId, - }, - }) - } else { - await useServersFetch(`servers/${this.serverId}/reinstall?hard=${hardResetParam}`, { - method: 'POST', - body: { project_id: projectId, version_id: versionId }, - }) - } - } - - reinstallFromMrpack( - mrpack: File, - hardReset: boolean = false, - ): { - promise: Promise - onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => void - } { - const hardResetParam = hardReset ? 'true' : 'false' - - const progressSubject = new EventTarget() - - const uploadPromise = (async () => { - try { - const auth = await useServersFetch(`servers/${this.serverId}/reinstallFromMrpack`) - - await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() - - xhr.upload.addEventListener('progress', (e) => { - if (e.lengthComputable) { - progressSubject.dispatchEvent( - new CustomEvent('progress', { - detail: { - loaded: e.loaded, - total: e.total, - progress: (e.loaded / e.total) * 100, - }, - }), - ) - } - }) - - xhr.onload = () => - xhr.status >= 200 && xhr.status < 300 - ? resolve() - : reject(new Error(`[pyroservers] XHR error status: ${xhr.status}`)) - - xhr.onerror = () => reject(new Error('[pyroservers] .mrpack upload failed')) - xhr.onabort = () => reject(new Error('[pyroservers] .mrpack upload cancelled')) - xhr.ontimeout = () => reject(new Error('[pyroservers] .mrpack upload timed out')) - xhr.timeout = 30 * 60 * 1000 - - xhr.open('POST', `https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`) - xhr.setRequestHeader('Authorization', `Bearer ${auth.token}`) - - const formData = new FormData() - formData.append('file', mrpack) - xhr.send(formData) - }) - } catch (err) { - console.error('Error reinstalling from mrpack:', err) - throw err - } - })() - - return { - promise: uploadPromise, - onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => - progressSubject.addEventListener('progress', ((e: CustomEvent) => - cb(e.detail)) as EventListener), - } - } - - async suspend(status: boolean): Promise { - await useServersFetch(`servers/${this.serverId}/suspend`, { - method: 'POST', - body: { suspended: status }, - }) - } - - async endIntro(): Promise { - await useServersFetch(`servers/${this.serverId}/flows/intro`, { - method: 'DELETE', - version: 1, - }) - await this.fetch() // Refresh this module - } - - async setMotd(motd: string): Promise { - try { - const props = (await this.server.fetchConfigFile('ServerProperties')) as any - if (props) { - props.motd = motd - const newProps = this.server.constructServerProperties(props) - const octetStream = new Blob([newProps], { type: 'application/octet-stream' }) - const auth = await useServersFetch(`servers/${this.serverId}/fs`) - - await useServersFetch(`/update?path=/server.properties`, { - method: 'PUT', - contentType: 'application/octet-stream', - body: octetStream, - override: auth, - }) - } - } catch { - console.error( - '[Modrinth Hosting] [General] Failed to set MOTD due to lack of server properties file.', - ) - } - } -} diff --git a/apps/frontend/src/composables/servers/modules/index.ts b/apps/frontend/src/composables/servers/modules/index.ts deleted file mode 100644 index 62fe2c45f8..0000000000 --- a/apps/frontend/src/composables/servers/modules/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './backups.ts' -export * from './base.ts' -export * from './content.ts' -export * from './general.ts' -export * from './network.ts' -export * from './startup.ts' -export * from './ws.ts' diff --git a/apps/frontend/src/composables/servers/modules/network.ts b/apps/frontend/src/composables/servers/modules/network.ts deleted file mode 100644 index d434f77001..0000000000 --- a/apps/frontend/src/composables/servers/modules/network.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Allocation } from '@modrinth/utils' - -import { useServersFetch } from '../servers-fetch.ts' -import { ServerModule } from './base.ts' - -export class NetworkModule extends ServerModule { - allocations: Allocation[] = [] - - async fetch(): Promise { - this.allocations = await useServersFetch( - `servers/${this.serverId}/allocations`, - {}, - 'network', - ) - } - - async reserveAllocation(name: string): Promise { - return await useServersFetch(`servers/${this.serverId}/allocations?name=${name}`, { - method: 'POST', - }) - } - - async updateAllocation(port: number, name: string): Promise { - await useServersFetch(`servers/${this.serverId}/allocations/${port}?name=${name}`, { - method: 'PUT', - }) - } - - async deleteAllocation(port: number): Promise { - await useServersFetch(`servers/${this.serverId}/allocations/${port}`, { - method: 'DELETE', - }) - } - - async checkSubdomainAvailability(subdomain: string): Promise { - const result = (await useServersFetch(`subdomains/${subdomain}/isavailable`)) as { - available: boolean - } - return result.available - } - - async changeSubdomain(subdomain: string): Promise { - await useServersFetch(`servers/${this.serverId}/subdomain`, { - method: 'POST', - body: { subdomain }, - }) - } -} diff --git a/apps/frontend/src/composables/servers/modules/startup.ts b/apps/frontend/src/composables/servers/modules/startup.ts deleted file mode 100644 index a47c031f69..0000000000 --- a/apps/frontend/src/composables/servers/modules/startup.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { JDKBuild, JDKVersion, Startup } from '@modrinth/utils' - -import { useServersFetch } from '../servers-fetch.ts' -import { ServerModule } from './base.ts' - -export class StartupModule extends ServerModule implements Startup { - invocation!: string - original_invocation!: string - jdk_version!: JDKVersion - jdk_build!: JDKBuild - - async fetch(): Promise { - const data = await useServersFetch(`servers/${this.serverId}/startup`, {}, 'startup') - Object.assign(this, data) - } - - async update(invocation: string, jdkVersion: JDKVersion, jdkBuild: JDKBuild): Promise { - await useServersFetch(`servers/${this.serverId}/startup`, { - method: 'POST', - body: { - invocation: invocation || null, - jdk_version: jdkVersion || null, - jdk_build: jdkBuild || null, - }, - }) - } -} diff --git a/apps/frontend/src/composables/servers/modules/ws.ts b/apps/frontend/src/composables/servers/modules/ws.ts deleted file mode 100644 index aa10a30294..0000000000 --- a/apps/frontend/src/composables/servers/modules/ws.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { JWTAuth } from '@modrinth/utils' - -import { useServersFetch } from '../servers-fetch.ts' -import { ServerModule } from './base.ts' - -export class WSModule extends ServerModule implements JWTAuth { - url!: string - token!: string - - async fetch(): Promise { - const data = await useServersFetch(`servers/${this.serverId}/ws`, {}, 'ws') - Object.assign(this, data) - } -} diff --git a/apps/frontend/src/composables/servers/use-server-image.ts b/apps/frontend/src/composables/servers/use-server-image.ts new file mode 100644 index 0000000000..c613a79ae5 --- /dev/null +++ b/apps/frontend/src/composables/servers/use-server-image.ts @@ -0,0 +1,131 @@ +import type { Archon } from '@modrinth/api-client' +import { injectModrinthClient } from '@modrinth/ui' +import { type ComputedRef, ref, watch } from 'vue' + +// TODO: Remove and use V1 when available +export function useServerImage( + serverId: string, + upstream: ComputedRef, +) { + const client = injectModrinthClient() + const image = ref() + + const sharedImage = useState(`server-icon-${serverId}`) + if (sharedImage.value) { + image.value = sharedImage.value + } + + async function loadImage() { + if (sharedImage.value) { + image.value = sharedImage.value + return + } + + if (import.meta.server) return + + const cached = localStorage.getItem(`server-icon-${serverId}`) + if (cached) { + sharedImage.value = cached + image.value = cached + return + } + + let projectIconUrl: string | undefined + const upstreamVal = upstream.value + if (upstreamVal?.project_id) { + try { + const project = await $fetch<{ icon_url?: string }>( + `https://api.modrinth.com/v2/project/${upstreamVal.project_id}`, + ) + projectIconUrl = project.icon_url + } catch { + // project fetch failed, continue without icon url + } + } + + try { + const fileData = await client.kyros.files_v0.downloadFile('/server-icon-original.png') + + if (fileData instanceof Blob) { + const dataURL = await resizeImage(fileData, 512) + sharedImage.value = dataURL + localStorage.setItem(`server-icon-${serverId}`, dataURL) + image.value = dataURL + return + } + } catch (error: any) { + if (error?.statusCode >= 500) { + image.value = undefined + return + } + + if (error?.statusCode === 404 && projectIconUrl) { + try { + const response = await fetch(projectIconUrl) + 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', + }) + + 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', + }) + client.kyros.files_v0 + .uploadFile('/server-icon.png', scaledFile) + .promise.catch(() => {}) + client.kyros.files_v0 + .uploadFile('/server-icon-original.png', originalFile) + .promise.catch(() => {}) + } + }, 'image/png') + const result = canvas.toDataURL('image/png') + sharedImage.value = result + localStorage.setItem(`server-icon-${serverId}`, result) + resolve(result) + URL.revokeObjectURL(img.src) + } + img.src = URL.createObjectURL(file) + }) + image.value = dataURL + return + } catch (externalError: any) { + console.debug('Could not process external icon:', externalError.message) + } + } + } + + image.value = undefined + } + + watch(upstream, () => loadImage(), { immediate: true }) + + return image +} + +function resizeImage(blob: Blob, size: number): Promise { + return new Promise((resolve) => { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + const img = new Image() + img.onload = () => { + canvas.width = size + canvas.height = size + ctx?.drawImage(img, 0, 0, size, size) + const dataURL = canvas.toDataURL('image/png') + resolve(dataURL) + URL.revokeObjectURL(img.src) + } + img.src = URL.createObjectURL(blob) + }) +} diff --git a/apps/frontend/src/composables/servers/use-server-project.ts b/apps/frontend/src/composables/servers/use-server-project.ts new file mode 100644 index 0000000000..eeb22a8b52 --- /dev/null +++ b/apps/frontend/src/composables/servers/use-server-project.ts @@ -0,0 +1,17 @@ +import type { Archon } from '@modrinth/api-client' +import type { Project } from '@modrinth/utils' +import { useQuery } from '@tanstack/vue-query' +import { $fetch } from 'ofetch' +import { computed, type ComputedRef } from 'vue' + +// TODO: Remove and use v1 +export function useServerProject( + upstream: ComputedRef, +) { + return useQuery({ + queryKey: computed(() => ['servers', 'project', upstream.value?.project_id ?? null]), + queryFn: () => + $fetch(`https://api.modrinth.com/v2/project/${upstream.value!.project_id}`), + enabled: computed(() => !!upstream.value?.project_id), + }) +} diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index 68a0e1c992..c79718c514 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -1292,6 +1292,33 @@ "hosting-marketing.why.your-favorite-mods.description": { "message": "Choose between Vanilla, Fabric, Forge, Quilt and NeoForge. If it's on Modrinth, it can run on your server." }, + "hosting.loader.failed-to-change-version": { + "message": "Failed to change modpack version" + }, + "hosting.loader.failed-to-load-versions": { + "message": "Failed to load versions" + }, + "hosting.loader.failed-to-reinstall": { + "message": "Failed to reinstall modpack" + }, + "hosting.loader.failed-to-repair": { + "message": "Failed to repair server" + }, + "hosting.loader.failed-to-save-settings": { + "message": "Failed to save installation settings" + }, + "hosting.loader.failed-to-unlink": { + "message": "Failed to unlink modpack" + }, + "hosting.loader.loader-version": { + "message": "{loader} version" + }, + "hosting.loader.reset-server": { + "message": "Reset server" + }, + "hosting.loader.reset-server-description": { + "message": "Removes all data on your server, including your worlds, mods, and configuration files. Backups will remain and can be restored." + }, "hosting.plan.out-of-stock": { "message": "Out of stock" }, diff --git a/apps/frontend/src/pages/[type]/[id]/settings/members.vue b/apps/frontend/src/pages/[type]/[id]/settings/members.vue index a80d7cb676..962b1f10d8 100644 --- a/apps/frontend/src/pages/[type]/[id]/settings/members.vue +++ b/apps/frontend/src/pages/[type]/[id]/settings/members.vue @@ -5,7 +5,6 @@ title="Are you sure you want to remove this project from the organization?" description="If you proceed, this project will no longer be managed by the organization." proceed-label="Remove" - :noblur="!(cosmetics?.advancedRendering ?? true)" @proceed="onRemoveFromOrg" /> @@ -352,7 +351,7 @@
@@ -563,7 +562,6 @@ const { invalidate, } = injectProjectPageContext() -const cosmetics = useCosmetics() const auth = await useAuth() const allTeamMembers = ref([]) diff --git a/apps/frontend/src/pages/collection/[id].vue b/apps/frontend/src/pages/collection/[id].vue index 219affc498..a7fdedf8d0 100644 --- a/apps/frontend/src/pages/collection/[id].vue +++ b/apps/frontend/src/pages/collection/[id].vue @@ -347,24 +347,16 @@ -
-
- -
-
- {{ - formatMessage(messages.noProjectsLabel) - }} -
- - - - Discover mods - - -
-
-
+ + + @@ -374,7 +366,6 @@ import { ChevronLeftIcon, CompassIcon, EditIcon, - EmptyIllustration, GlobeIcon, HeartMinusIcon, LinkIcon, @@ -395,6 +386,7 @@ import { ConfirmModal, defineMessage, defineMessages, + EmptyState, FileInput, HorizontalRule, injectModrinthClient, diff --git a/apps/frontend/src/pages/dashboard/revenue/transfers.vue b/apps/frontend/src/pages/dashboard/revenue/transfers.vue index 3b08a8195a..93dd0b4c50 100644 --- a/apps/frontend/src/pages/dashboard/revenue/transfers.vue +++ b/apps/frontend/src/pages/dashboard/revenue/transfers.vue @@ -72,14 +72,11 @@
-
- {{ - formatMessage(messages.noTransactions) - }} - {{ - formatMessage(messages.noTransactionsDesc) - }} -
+