From 6328d4d92aea7c8c30407bcd4a1b14acf01aaad9 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Fri, 16 Jan 2026 09:47:13 +0000 Subject: [PATCH 1/5] feat: base content card component --- packages/ui/src/components/index.ts | 1 + .../src/components/instances/ContentCard.vue | 118 ++++ packages/ui/src/components/instances/index.ts | 2 + .../stories/instances/ContentCard.stories.ts | 666 ++++++++++++++++++ 4 files changed, 787 insertions(+) create mode 100644 packages/ui/src/components/instances/ContentCard.vue create mode 100644 packages/ui/src/components/instances/index.ts create mode 100644 packages/ui/src/stories/instances/ContentCard.stories.ts diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 1f00fa63a7..7204b200dc 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -5,6 +5,7 @@ export * from './brand' export * from './changelog' export * from './chart' export * from './content' +export * from './instances' export * from './modal' export * from './nav' export * from './page' diff --git a/packages/ui/src/components/instances/ContentCard.vue b/packages/ui/src/components/instances/ContentCard.vue new file mode 100644 index 0000000000..ac377d9eb1 --- /dev/null +++ b/packages/ui/src/components/instances/ContentCard.vue @@ -0,0 +1,118 @@ + + + diff --git a/packages/ui/src/components/instances/index.ts b/packages/ui/src/components/instances/index.ts new file mode 100644 index 0000000000..7d9dd736a3 --- /dev/null +++ b/packages/ui/src/components/instances/index.ts @@ -0,0 +1,2 @@ +export type { ContentCardOwner, ContentCardProject, ContentCardVersion } from './ContentCard.vue' +export { default as ContentCard } from './ContentCard.vue' diff --git a/packages/ui/src/stories/instances/ContentCard.stories.ts b/packages/ui/src/stories/instances/ContentCard.stories.ts new file mode 100644 index 0000000000..2188053ece --- /dev/null +++ b/packages/ui/src/stories/instances/ContentCard.stories.ts @@ -0,0 +1,666 @@ +import { EditIcon, EyeIcon, FolderOpenIcon, LinkIcon } from '@modrinth/assets' +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { fn } from 'storybook/test' +import { ref } from 'vue' + +import ButtonStyled from '../../components/base/ButtonStyled.vue' +import type { + ContentCardOwner, + ContentCardProject, + ContentCardVersion, +} from '../../components/instances/ContentCard.vue' +import ContentCard from '../../components/instances/ContentCard.vue' + +// Real project data from Modrinth API +const sodiumProject: ContentCardProject = { + id: 'AANobbMI', + slug: 'sodium', + title: 'Sodium', + icon_url: + 'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp', +} + +const modMenuProject: ContentCardProject = { + id: 'mOgUt4GM', + slug: 'modmenu', + title: 'Mod Menu', + icon_url: 'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png', +} + +const fabricApiProject: ContentCardProject = { + id: 'P7dR8mSH', + slug: 'fabric-api', + title: 'Fabric API', + icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png', +} + +// Version data +const sodiumVersion: ContentCardVersion = { + id: '59wygFUQ', + version_number: 'mc1.21.11-0.8.2-fabric', + file_name: 'sodium-fabric-0.8.2+mc1.21.11.jar', +} + +const modMenuVersion: ContentCardVersion = { + id: 'QuU0ciaR', + version_number: '16.0.0', + file_name: 'modmenu-16.0.0.jar', +} + +const fabricApiVersion: ContentCardVersion = { + id: 'Lwa1Q6e4', + version_number: '0.141.3+26.1', + file_name: 'fabric-api-0.141.3+26.1.jar', +} + +// Owner data +const sodiumOwner: ContentCardOwner = { + id: 'DzLrfrbK', + name: 'IMS', + avatar_url: 'https://avatars3.githubusercontent.com/u/31803019?v=4', + type: 'user', +} + +const fabricApiOwner: ContentCardOwner = { + id: 'BZoBsPo6', + name: 'FabricMC', + avatar_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png', + type: 'organization', +} + +const meta = { + title: 'Instances/ContentCard', + component: ContentCard, + parameters: { + layout: 'padded', + }, + argTypes: { + project: { + control: 'object', + description: 'Project information (id, slug, title, icon_url)', + }, + version: { + control: 'object', + description: 'Version information (id, version_number, file_name)', + }, + owner: { + control: 'object', + description: 'Owner/author information', + }, + enabled: { + control: 'boolean', + description: 'Toggle state - toggle hidden if undefined', + }, + disabled: { + control: 'boolean', + description: 'Grays out the card when true', + }, + overflowOptions: { + control: 'object', + description: 'Options for the overflow menu', + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// ============================================ +// All Types Overview +// ============================================ + +export const AllTypes: Story = { + args: { + project: sodiumProject, + }, + render: () => ({ + components: { ContentCard }, + setup() { + const toggleOn = ref(true) + const toggleOff = ref(false) + + const cards = [ + { + label: 'Full featured (all actions)', + project: sodiumProject, + version: sodiumVersion, + owner: sodiumOwner, + enabled: toggleOn, + hasUpdate: true, + hasDelete: true, + hasOverflow: true, + }, + { + label: 'With toggle only', + project: modMenuProject, + version: modMenuVersion, + owner: { id: 'u2', name: 'Prospector', type: 'user' }, + enabled: toggleOn, + }, + { + label: 'With update available', + project: fabricApiProject, + version: fabricApiVersion, + owner: fabricApiOwner, + hasUpdate: true, + }, + { + label: 'Minimal (project only)', + project: sodiumProject, + }, + { + label: 'With version info only', + project: modMenuProject, + version: modMenuVersion, + }, + { + label: 'With owner only', + project: fabricApiProject, + owner: fabricApiOwner, + }, + { + label: 'Disabled state', + project: sodiumProject, + version: sodiumVersion, + owner: sodiumOwner, + enabled: toggleOff, + disabled: true, + }, + { + label: 'Delete button only', + project: modMenuProject, + version: modMenuVersion, + hasDelete: true, + }, + { + label: 'Toggle off', + project: fabricApiProject, + version: fabricApiVersion, + owner: fabricApiOwner, + enabled: toggleOff, + }, + ] + + return { cards } + }, + template: /*html*/ ` +
+ +
+ `, + }), +} + +// ============================================ +// Basic Stories +// ============================================ + +export const Default: Story = { + args: { + project: sodiumProject, + version: sodiumVersion, + owner: sodiumOwner, + enabled: true, + overflowOptions: [ + { id: 'view', action: () => console.log('View clicked') }, + { id: 'edit', action: () => console.log('Edit clicked') }, + { divider: true }, + { id: 'remove', action: () => console.log('Remove clicked'), color: 'red' }, + ], + onDelete: fn(), + onUpdate: fn(), + 'onUpdate:enabled': fn(), + }, +} + +export const MinimalProjectOnly: Story = { + args: { + project: sodiumProject, + }, +} + +export const WithVersion: Story = { + args: { + project: modMenuProject, + version: modMenuVersion, + }, +} + +export const WithOwner: Story = { + args: { + project: fabricApiProject, + owner: fabricApiOwner, + }, +} + +export const WithToggle: Story = { + args: { + project: sodiumProject, + version: sodiumVersion, + enabled: true, + 'onUpdate:enabled': fn(), + }, +} + +export const ToggleDisabled: Story = { + args: { + project: sodiumProject, + version: sodiumVersion, + enabled: false, + 'onUpdate:enabled': fn(), + }, +} + +// ============================================ +// Action Button Stories +// ============================================ + +export const WithDeleteButton: Story = { + args: { + project: modMenuProject, + version: modMenuVersion, + owner: sodiumOwner, + onDelete: fn(), + }, +} + +export const WithUpdateButton: Story = { + args: { + project: fabricApiProject, + version: fabricApiVersion, + owner: fabricApiOwner, + onUpdate: fn(), + }, +} + +export const WithAllActions: Story = { + args: { + project: sodiumProject, + version: sodiumVersion, + owner: sodiumOwner, + enabled: true, + onDelete: fn(), + onUpdate: fn(), + 'onUpdate:enabled': fn(), + overflowOptions: [ + { id: 'view', action: () => console.log('View') }, + { id: 'openFolder', action: () => console.log('Open folder') }, + { divider: true }, + { id: 'copyLink', action: () => console.log('Copy link') }, + ], + }, +} + +// ============================================ +// State Stories +// ============================================ + +export const Disabled: Story = { + args: { + project: sodiumProject, + version: sodiumVersion, + owner: sodiumOwner, + enabled: false, + disabled: true, + 'onUpdate:enabled': fn(), + }, +} + +export const LongProjectName: Story = { + args: { + project: { + id: 'test123', + slug: 'very-long-project-name', + title: '[EMF] Entity Model Features - The Ultimate Entity Rendering Mod', + icon_url: sodiumProject.icon_url, + }, + version: { + id: 'v1', + version_number: '2.4.1', + file_name: 'Entity_model_features_fabric_1.21.1-2.4.1.jar', + }, + owner: { + id: 'u1', + name: 'Traben', + type: 'user', + }, + enabled: true, + onDelete: fn(), + 'onUpdate:enabled': fn(), + }, +} + +// ============================================ +// Overflow Menu Stories +// ============================================ + +export const WithOverflowMenu: Story = { + render: (args) => ({ + components: { ContentCard, EditIcon, EyeIcon, FolderOpenIcon, LinkIcon }, + setup() { + return { args } + }, + template: /*html*/ ` + + + + + + + `, + }), + args: { + project: sodiumProject, + version: sodiumVersion, + owner: sodiumOwner, + overflowOptions: [ + { id: 'view', action: () => console.log('View') }, + { id: 'edit', action: () => console.log('Edit') }, + { id: 'openFolder', action: () => console.log('Open folder') }, + { divider: true }, + { id: 'copyLink', action: () => console.log('Copy link') }, + ], + }, +} + +// ============================================ +// Slot Stories +// ============================================ + +export const WithAdditionalButtons: Story = { + render: (args) => ({ + components: { ContentCard, ButtonStyled, EyeIcon, FolderOpenIcon }, + setup() { + return { args } + }, + template: /*html*/ ` + + + + + `, + }), + args: { + project: modMenuProject, + version: modMenuVersion, + enabled: true, + onDelete: fn(), + 'onUpdate:enabled': fn(), + }, +} + +// ============================================ +// Interactive Stories +// ============================================ + +export const InteractiveToggle: Story = { + args: { + project: sodiumProject, + }, + render: () => ({ + components: { ContentCard }, + setup() { + const enabled = ref(true) + return { + enabled, + project: sodiumProject, + version: sodiumVersion, + owner: sodiumOwner, + } + }, + template: /*html*/ ` +
+ +
+ Mod is currently: {{ enabled ? 'Enabled' : 'Disabled' }} +
+
+ `, + }), +} + +// ============================================ +// List Stories +// ============================================ + +export const ModList: Story = { + args: { + project: sodiumProject, + }, + render: () => ({ + components: { ContentCard }, + setup() { + const mods = ref([ + { project: sodiumProject, version: sodiumVersion, owner: sodiumOwner, enabled: true }, + { + project: modMenuProject, + version: modMenuVersion, + owner: { id: 'u2', name: 'Prospector', type: 'user' as const }, + enabled: true, + }, + { + project: fabricApiProject, + version: fabricApiVersion, + owner: fabricApiOwner, + enabled: true, + }, + ]) + + const handleDelete = (index: number) => { + mods.value.splice(index, 1) + } + + const handleToggle = (index: number, value: boolean) => { + mods.value[index].enabled = value + } + + return { mods, handleDelete, handleToggle } + }, + template: /*html*/ ` +
+ + + + +
+ `, + }), +} + +export const MixedStates: Story = { + args: { + project: sodiumProject, + }, + render: () => ({ + components: { ContentCard }, + setup() { + return { + sodiumProject, + sodiumVersion, + sodiumOwner, + modMenuProject, + modMenuVersion, + fabricApiProject, + fabricApiVersion, + fabricApiOwner, + } + }, + template: /*html*/ ` +
+ + + + + + + + +
+ `, + }), +} + +// ============================================ +// Responsive Stories +// ============================================ + +export const ResponsiveView: Story = { + args: { + project: sodiumProject, + }, + render: () => ({ + components: { ContentCard }, + setup() { + return { + project: sodiumProject, + version: sodiumVersion, + owner: sodiumOwner, + } + }, + template: /*html*/ ` +
+
+

Desktop (version info visible)

+
+ +
+
+
+

Mobile (<768px - version info hidden)

+
+ +
+
+
+ `, + }), +} + +// ============================================ +// Edge Cases +// ============================================ + +export const NoIcon: Story = { + args: { + project: { + id: 'test', + slug: 'no-icon-mod', + title: 'Mod Without Icon', + icon_url: undefined, + }, + version: { + id: 'v1', + version_number: '1.0.0', + file_name: 'no-icon-mod-1.0.0.jar', + }, + enabled: true, + 'onUpdate:enabled': fn(), + }, +} + +export const NoOwnerAvatar: Story = { + args: { + project: sodiumProject, + version: sodiumVersion, + owner: { + id: 'u1', + name: 'Anonymous', + avatar_url: undefined, + type: 'user', + }, + enabled: true, + 'onUpdate:enabled': fn(), + }, +} From 548c1f3be4e0364b37974453ffc8ef9e92afb324 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Fri, 16 Jan 2026 09:57:56 +0000 Subject: [PATCH 2/5] fix: tooltips + colors --- .../ui/src/components/instances/ContentCard.vue | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/instances/ContentCard.vue b/packages/ui/src/components/instances/ContentCard.vue index ac377d9eb1..8c275e045b 100644 --- a/packages/ui/src/components/instances/ContentCard.vue +++ b/packages/ui/src/components/instances/ContentCard.vue @@ -85,12 +85,14 @@ const hasUpdateListener = computed(() => !!instance?.vnode.props?.onUpdate) - @@ -100,9 +102,9 @@ const hasUpdateListener = computed(() => !!instance?.vnode.props?.onUpdate) @update:model-value="(val) => emit('update:enabled', val)" /> - - From 847d752dae26ac65ea6594ed82078de0f6b496af Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Fri, 16 Jan 2026 10:06:56 +0000 Subject: [PATCH 3/5] feat: fix orgs --- packages/ui/src/components/instances/ContentCard.vue | 11 +++++++++-- .../ui/src/stories/instances/ContentCard.stories.ts | 11 ++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/instances/ContentCard.vue b/packages/ui/src/components/instances/ContentCard.vue index 8c275e045b..9a2823079a 100644 --- a/packages/ui/src/components/instances/ContentCard.vue +++ b/packages/ui/src/components/instances/ContentCard.vue @@ -1,6 +1,6 @@ + + diff --git a/apps/frontend/src/pages/hosting/manage/[id]/content/index.vue b/apps/frontend/src/pages/hosting/manage/[id]/content/index.vue deleted file mode 100644 index f4bdc02862..0000000000 --- a/apps/frontend/src/pages/hosting/manage/[id]/content/index.vue +++ /dev/null @@ -1,706 +0,0 @@ - - - - - diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index 82880a682d..7827bbcf4f 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -7,7 +7,7 @@ import { ArchonServersV0Module } from './archon/servers/v0' import { ArchonServersV1Module } from './archon/servers/v1' import { ISO3166Module } from './iso3166' import { KyrosFilesV0Module } from './kyros/files/v0' -import { LabrinthVersionsV3Module } from './labrinth' +import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth' import { LabrinthBillingInternalModule } from './labrinth/billing/internal' import { LabrinthCollectionsModule } from './labrinth/collections' import { LabrinthProjectsV2Module } from './labrinth/projects/v2' @@ -40,6 +40,7 @@ export const MODULE_REGISTRY = { labrinth_projects_v3: LabrinthProjectsV3Module, labrinth_state: LabrinthStateModule, labrinth_tech_review_internal: LabrinthTechReviewInternalModule, + labrinth_versions_v2: LabrinthVersionsV2Module, labrinth_versions_v3: LabrinthVersionsV3Module, } as const satisfies Record diff --git a/packages/api-client/src/modules/labrinth/index.ts b/packages/api-client/src/modules/labrinth/index.ts index 38bc3f22b6..c11216dfe6 100644 --- a/packages/api-client/src/modules/labrinth/index.ts +++ b/packages/api-client/src/modules/labrinth/index.ts @@ -4,4 +4,5 @@ export * from './projects/v2' export * from './projects/v3' export * from './state' export * from './tech-review/internal' +export * from './versions/v2' export * from './versions/v3' diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index c36e1cb1c1..16546fea30 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -423,6 +423,12 @@ export namespace Labrinth { game_versions: string[] loaders: string[] } + + export interface GetProjectVersionsParams { + game_versions?: string[] + loaders?: string[] + include_changelog?: boolean + } } // TODO: consolidate duplicated types between v2 and v3 versions @@ -437,6 +443,7 @@ export namespace Labrinth { export interface GetProjectVersionsParams { game_versions?: string[] loaders?: string[] + include_changelog?: boolean } export type VersionChannel = 'release' | 'beta' | 'alpha' diff --git a/packages/api-client/src/modules/labrinth/versions/v2.ts b/packages/api-client/src/modules/labrinth/versions/v2.ts new file mode 100644 index 0000000000..750d91d53d --- /dev/null +++ b/packages/api-client/src/modules/labrinth/versions/v2.ts @@ -0,0 +1,135 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Labrinth } from '../types' + +export class LabrinthVersionsV2Module extends AbstractModule { + public getModuleID(): string { + return 'labrinth_versions_v2' + } + + /** + * Get versions for a project (v2) + * + * @param id - Project ID or slug (e.g., 'sodium' or 'AANobbMI') + * @param options - Optional query parameters to filter versions + * @returns Promise resolving to an array of v2 versions + * + * @example + * ```typescript + * const versions = await client.labrinth.versions_v2.getProjectVersions('sodium') + * const filteredVersions = await client.labrinth.versions_v2.getProjectVersions('sodium', { + * game_versions: ['1.20.1'], + * loaders: ['fabric'], + * include_changelog: false + * }) + * console.log(versions[0].version_number) + * ``` + */ + public async getProjectVersions( + id: string, + options?: Labrinth.Versions.v2.GetProjectVersionsParams, + ): Promise { + const params: Record = {} + if (options?.game_versions?.length) { + params.game_versions = JSON.stringify(options.game_versions) + } + if (options?.loaders?.length) { + params.loaders = JSON.stringify(options.loaders) + } + if (options?.include_changelog === false) { + params.include_changelog = 'false' + } + + return this.client.request(`/project/${id}/version`, { + api: 'labrinth', + version: 2, + method: 'GET', + params: Object.keys(params).length > 0 ? params : undefined, + }) + } + + /** + * Get a specific version by ID (v2) + * + * @param id - Version ID + * @returns Promise resolving to the v2 version data + * + * @example + * ```typescript + * const version = await client.labrinth.versions_v2.getVersion('DXtmvS8i') + * console.log(version.version_number) + * ``` + */ + public async getVersion(id: string): Promise { + return this.client.request(`/version/${id}`, { + api: 'labrinth', + version: 2, + method: 'GET', + }) + } + + /** + * Get multiple versions by IDs (v2) + * + * @param ids - Array of version IDs + * @returns Promise resolving to an array of v2 versions + * + * @example + * ```typescript + * const versions = await client.labrinth.versions_v2.getVersions(['DXtmvS8i', 'abc123']) + * console.log(versions[0].version_number) + * ``` + */ + public async getVersions(ids: string[]): Promise { + return this.client.request(`/versions`, { + api: 'labrinth', + version: 2, + method: 'GET', + params: { ids: JSON.stringify(ids) }, + }) + } + + /** + * Get a version from a project by version ID or number (v2) + * + * @param projectId - Project ID or slug + * @param versionId - Version ID or version number + * @returns Promise resolving to the v2 version data + * + * @example + * ```typescript + * const version = await client.labrinth.versions_v2.getVersionFromIdOrNumber('sodium', 'DXtmvS8i') + * const versionByNumber = await client.labrinth.versions_v2.getVersionFromIdOrNumber('sodium', '0.4.12') + * ``` + */ + public async getVersionFromIdOrNumber( + projectId: string, + versionId: string, + ): Promise { + return this.client.request( + `/project/${projectId}/version/${versionId}`, + { + api: 'labrinth', + version: 2, + method: 'GET', + }, + ) + } + + /** + * Delete a version by ID (v2) + * + * @param versionId - Version ID + * + * @example + * ```typescript + * await client.labrinth.versions_v2.deleteVersion('DXtmvS8i') + * ``` + */ + public async deleteVersion(versionId: string): Promise { + return this.client.request(`/version/${versionId}`, { + api: 'labrinth', + version: 2, + method: 'DELETE', + }) + } +} diff --git a/packages/api-client/src/modules/labrinth/versions/v3.ts b/packages/api-client/src/modules/labrinth/versions/v3.ts index 914b030de7..e3ef64abd5 100644 --- a/packages/api-client/src/modules/labrinth/versions/v3.ts +++ b/packages/api-client/src/modules/labrinth/versions/v3.ts @@ -35,6 +35,9 @@ export class LabrinthVersionsV3Module extends AbstractModule { if (options?.loaders?.length) { params.loaders = JSON.stringify(options.loaders) } + if (options?.include_changelog === false) { + params.include_changelog = 'false' + } return this.client.request(`/project/${id}/version`, { api: 'labrinth', diff --git a/packages/api-client/src/platform/nuxt.ts b/packages/api-client/src/platform/nuxt.ts index 15a3613399..b74fd0bd4f 100644 --- a/packages/api-client/src/platform/nuxt.ts +++ b/packages/api-client/src/platform/nuxt.ts @@ -13,27 +13,32 @@ import { XHRUploadClient } from './xhr-upload-client' * * This provides cross-request persistence in SSR while also working in client-side. * State is shared between requests in the same Nuxt context. + * + * Note: useState must be called during initialization (in setup context) and cached, + * as it won't work during async operations when the Nuxt context may be lost. */ export class NuxtCircuitBreakerStorage implements CircuitBreakerStorage { - private getState(): Map { + private state: Map + + constructor() { // @ts-expect-error - useState is provided by Nuxt runtime - const state = useState>( + const stateRef = useState>( 'circuit-breaker-state', () => new Map(), ) - return state.value + this.state = stateRef.value } get(key: string): CircuitBreakerState | undefined { - return this.getState().get(key) + return this.state.get(key) } set(key: string, state: CircuitBreakerState): void { - this.getState().set(key, state) + this.state.set(key, state) } clear(key: string): void { - this.getState().delete(key) + this.state.delete(key) } } diff --git a/apps/frontend/src/components/ui/servers/ContentVersionEditModal.vue b/packages/ui/src/components/servers/content/ContentVersionEditModal.vue similarity index 86% rename from apps/frontend/src/components/ui/servers/ContentVersionEditModal.vue rename to packages/ui/src/components/servers/content/ContentVersionEditModal.vue index a87db5e837..9db92267e9 100644 --- a/apps/frontend/src/components/ui/servers/ContentVersionEditModal.vue +++ b/packages/ui/src/components/servers/content/ContentVersionEditModal.vue @@ -27,13 +27,13 @@
{{ type }} version
- - +
diff --git a/apps/frontend/src/components/ui/servers/ContentVersionFilter.vue b/packages/ui/src/components/servers/content/ContentVersionFilter.vue similarity index 84% rename from apps/frontend/src/components/ui/servers/ContentVersionFilter.vue rename to packages/ui/src/components/servers/content/ContentVersionFilter.vue index 4e2a503a1a..3ae2a72ab8 100644 --- a/apps/frontend/src/components/ui/servers/ContentVersionFilter.vue +++ b/packages/ui/src/components/servers/content/ContentVersionFilter.vue @@ -57,12 +57,13 @@ - - diff --git a/packages/ui/src/pages/hosting/manage/content.vue b/packages/ui/src/pages/hosting/manage/content.vue new file mode 100644 index 0000000000..7905404cf0 --- /dev/null +++ b/packages/ui/src/pages/hosting/manage/content.vue @@ -0,0 +1,830 @@ + + + + + diff --git a/packages/ui/src/pages/index.ts b/packages/ui/src/pages/index.ts index 528a1761c5..e6ce8a51d9 100644 --- a/packages/ui/src/pages/index.ts +++ b/packages/ui/src/pages/index.ts @@ -1,3 +1,4 @@ export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue' +export { default as ServersManageContentPage } from './hosting/manage/content.vue' export { default as ServersManageFilesPage } from './hosting/manage/files.vue' export { default as ServersManagePageIndex } from './hosting/manage/index.vue' diff --git a/packages/ui/src/utils/auto-icons.ts b/packages/ui/src/utils/auto-icons.ts index cdcb76df9f..102b559de1 100644 --- a/packages/ui/src/utils/auto-icons.ts +++ b/packages/ui/src/utils/auto-icons.ts @@ -32,6 +32,13 @@ import { import type { ProjectStatus, ProjectType } from '@modrinth/utils' import type { Component } from 'vue' +import { + FILE_ARCHIVE_EXTENSIONS, + FILE_CODE_EXTENSIONS, + FILE_IMAGE_EXTENSIONS, + FILE_TEXT_EXTENSIONS, +} from './file-extensions' + export const PROJECT_TYPE_ICONS: Record = { mod: BoxIcon, modpack: PackageOpenIcon, @@ -88,53 +95,6 @@ const BLOCKCHAIN_CONFIG: Record = { polygon: { icon: PolygonIcon, color: 'text-purple' }, } -export const CODE_EXTENSIONS: readonly string[] = [ - 'json', - 'json5', - 'jsonc', - 'java', - 'kt', - 'kts', - 'sh', - 'bat', - 'ps1', - 'yml', - 'yaml', - 'toml', - 'js', - 'ts', - 'py', - 'rb', - 'php', - 'html', - 'css', - 'cpp', - 'c', - 'h', - 'rs', - 'go', -] as const - -export const TEXT_EXTENSIONS: readonly string[] = [ - 'txt', - 'md', - 'log', - 'cfg', - 'conf', - 'properties', - 'ini', - 'sk', -] as const -export const IMAGE_EXTENSIONS: readonly string[] = [ - 'png', - 'jpg', - 'jpeg', - 'gif', - 'svg', - 'webp', -] as const -const ARCHIVE_EXTENSIONS: string[] = ['zip', 'jar', 'tar', 'gz', 'rar', '7z'] as const - export function getProjectTypeIcon(projectType: ProjectType): Component { return PROJECT_TYPE_ICONS[projectType] ?? BoxIcon } @@ -162,16 +122,16 @@ export function getDirectoryIcon(name: string): Component { export function getFileExtensionIcon(extension: string): Component { const ext: string = extension.toLowerCase() - if (CODE_EXTENSIONS.includes(ext)) { + if ((FILE_CODE_EXTENSIONS as readonly string[]).includes(ext)) { return FileCodeIcon } - if (TEXT_EXTENSIONS.includes(ext)) { + if ((FILE_TEXT_EXTENSIONS as readonly string[]).includes(ext)) { return FileTextIcon } - if (IMAGE_EXTENSIONS.includes(ext)) { + if ((FILE_IMAGE_EXTENSIONS as readonly string[]).includes(ext)) { return FileImageIcon } - if (ARCHIVE_EXTENSIONS.includes(ext)) { + if ((FILE_ARCHIVE_EXTENSIONS as readonly string[]).includes(ext)) { return FileArchiveIcon } diff --git a/packages/ui/src/utils/file-extensions.ts b/packages/ui/src/utils/file-extensions.ts index 38f11b974c..7908f52b06 100644 --- a/packages/ui/src/utils/file-extensions.ts +++ b/packages/ui/src/utils/file-extensions.ts @@ -1,5 +1,5 @@ // File extension constants -export const CODE_EXTENSIONS = [ +export const FILE_CODE_EXTENSIONS = [ 'json', 'json5', 'jsonc', @@ -26,7 +26,7 @@ export const CODE_EXTENSIONS = [ 'go', ] as const -export const TEXT_EXTENSIONS = [ +export const FILE_TEXT_EXTENSIONS = [ 'txt', 'md', 'log', @@ -37,15 +37,15 @@ export const TEXT_EXTENSIONS = [ 'sk', ] as const -export const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'] as const +export const FILE_IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'] as const -export const ARCHIVE_EXTENSIONS = ['zip'] as const +export const FILE_ARCHIVE_EXTENSIONS = ['zip', 'jar', 'tar', 'gz', 'rar', '7z'] as const // Type for extension strings -export type CodeExtension = (typeof CODE_EXTENSIONS)[number] -export type TextExtension = (typeof TEXT_EXTENSIONS)[number] -export type ImageExtension = (typeof IMAGE_EXTENSIONS)[number] -export type ArchiveExtension = (typeof ARCHIVE_EXTENSIONS)[number] +export type CodeExtension = (typeof FILE_CODE_EXTENSIONS)[number] +export type TextExtension = (typeof FILE_TEXT_EXTENSIONS)[number] +export type ImageExtension = (typeof FILE_IMAGE_EXTENSIONS)[number] +export type ArchiveExtension = (typeof FILE_ARCHIVE_EXTENSIONS)[number] /** * Extract file extension from filename (lowercase) @@ -58,28 +58,28 @@ export function getFileExtension(filename: string): string { * Check if extension is a code file */ export function isCodeFile(ext: string): boolean { - return (CODE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase()) + return (FILE_CODE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase()) } /** * Check if extension is a text file */ export function isTextFile(ext: string): boolean { - return (TEXT_EXTENSIONS as readonly string[]).includes(ext.toLowerCase()) + return (FILE_TEXT_EXTENSIONS as readonly string[]).includes(ext.toLowerCase()) } /** * Check if extension is an image file */ export function isImageFile(ext: string): boolean { - return (IMAGE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase()) + return (FILE_IMAGE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase()) } /** * Check if extension is an archive file */ export function isArchiveFile(ext: string): boolean { - return (ARCHIVE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase()) + return (FILE_ARCHIVE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase()) } /** diff --git a/packages/ui/src/utils/formatting.ts b/packages/ui/src/utils/formatting.ts new file mode 100644 index 0000000000..45512ea6b0 --- /dev/null +++ b/packages/ui/src/utils/formatting.ts @@ -0,0 +1,212 @@ +import type { Labrinth } from '@modrinth/api-client' + +export function capitalizeString(name: string) { + return name ? name.charAt(0).toUpperCase() + name.slice(1) : name +} + +export function formatCategory(name: string) { + if (name === 'modloader') return "Risugami's ModLoader" + if (name === 'bungeecord') return 'BungeeCord' + if (name === 'liteloader') return 'LiteLoader' + if (name === 'neoforge') return 'NeoForge' + if (name === 'game-mechanics') return 'Game Mechanics' + if (name === 'worldgen') return 'World Generation' + if (name === 'core-shaders') return 'Core Shaders' + if (name === 'gui') return 'GUI' + if (name === '8x-') return '8x or lower' + if (name === '512x+') return '512x or higher' + if (name === 'kitchen-sink') return 'Kitchen Sink' + if (name === 'path-tracing') return 'Path Tracing' + if (name === 'pbr') return 'PBR' + if (name === 'datapack') return 'Data Pack' + if (name === 'colored-lighting') return 'Colored Lighting' + if (name === 'optifine') return 'OptiFine' + if (name === 'bta-babric') return 'BTA (Babric)' + if (name === 'legacy-fabric') return 'Legacy Fabric' + if (name === 'java-agent') return 'Java Agent' + if (name === 'nilloader') return 'NilLoader' + if (name === 'mrpack') return 'Modpack' + if (name === 'minecraft') return 'Resource Pack' + if (name === 'vanilla') return 'Vanilla Shader' + if (name === 'geyser') return 'Geyser Extension' + return capitalizeString(name) +} + +const mcVersionRegex = /^([0-9]+.[0-9]+)(.[0-9]+)?$/ + +type VersionRange = { + major: string + minor: number[] +} + +function groupVersions(versions: string[], consecutive = false) { + return versions + .slice() + .reverse() + .reduce((ranges: VersionRange[], version: string) => { + const matchesVersion = version.match(mcVersionRegex) + + if (matchesVersion) { + const majorVersion = matchesVersion[1] + const minorVersion = matchesVersion[2] + const minorNumeric = minorVersion ? parseInt(minorVersion.replace('.', '')) : 0 + + const prevInRange = ranges.find( + (x) => x.major === majorVersion && (!consecutive || x.minor.at(-1) === minorNumeric - 1), + ) + if (prevInRange) { + prevInRange.minor.push(minorNumeric) + return ranges + } + + return [...ranges, { major: majorVersion, minor: [minorNumeric] }] + } + + return ranges + }, []) + .reverse() +} + +function groupConsecutiveIndices( + versions: string[], + referenceList: Labrinth.Tags.v2.GameVersion[], +) { + if (!versions || versions.length === 0) { + return [] + } + + const referenceMap = new Map() + referenceList.forEach((item, index) => { + referenceMap.set(item.version, index) + }) + + const sortedList: string[] = versions + .slice() + .sort((a, b) => (referenceMap.get(a) ?? 0) - (referenceMap.get(b) ?? 0)) + + const ranges: string[] = [] + let start = sortedList[0] + let previous = sortedList[0] + + for (let i = 1; i < sortedList.length; i++) { + const current = sortedList[i] + if ((referenceMap.get(current) ?? 0) !== (referenceMap.get(previous) ?? 0) + 1) { + ranges.push(validateRange(`${previous}–${start}`)) + start = current + } + previous = current + } + + ranges.push(validateRange(`${previous}–${start}`)) + + return ranges +} + +function validateRange(range: string): string { + switch (range) { + case 'rd-132211–b1.8.1': + return 'All legacy versions' + case 'a1.0.4–b1.8.1': + return 'All alpha and beta versions' + case 'a1.0.4–a1.2.6': + return 'All alpha versions' + case 'b1.0–b1.8.1': + return 'All beta versions' + case 'rd-132211–inf20100618': + return 'All pre-alpha versions' + } + const splitRange = range.split('–') + if (splitRange && splitRange[0] === splitRange[1]) { + return splitRange[0] + } + return range +} + +function formatMinecraftMinorVersion(major: string, minor: number): string { + return minor === 0 ? major : `${major}.${minor}` +} + +export function formatVersionsForDisplay( + gameVersions: string[], + allGameVersions: Labrinth.Tags.v2.GameVersion[], +) { + const inputVersions = gameVersions.slice() + const allVersions = allGameVersions.slice() + + const allSnapshots = allVersions.filter((version) => version.version_type === 'snapshot') + const allReleases = allVersions.filter((version) => version.version_type === 'release') + const allLegacy = allVersions.filter( + (version) => version.version_type !== 'snapshot' && version.version_type !== 'release', + ) + + { + const indices: Record = allVersions.reduce( + (map, gameVersion, index) => { + map[gameVersion.version] = index + return map + }, + {} as Record, + ) + inputVersions.sort((a, b) => indices[a] - indices[b]) + } + + const releaseVersions = inputVersions.filter((projVer) => + allReleases.some((gameVer) => gameVer.version === projVer), + ) + + const dateString = allReleases.find((version) => version.version === releaseVersions[0])?.date + + const latestReleaseVersionDate = dateString ? Date.parse(dateString) : 0 + const latestSnapshot = inputVersions.find((projVer) => + allSnapshots.some( + (gameVer) => + gameVer.version === projVer && + (!latestReleaseVersionDate || latestReleaseVersionDate < Date.parse(gameVer.date)), + ), + ) + + const allReleasesGrouped = groupVersions( + allReleases.map((release) => release.version), + false, + ) + const projectVersionsGrouped = groupVersions(releaseVersions, true) + + const releaseVersionsAsRanges = projectVersionsGrouped.map(({ major, minor }) => { + if (minor.length === 1) { + return formatMinecraftMinorVersion(major, minor[0]) + } + + const range = allReleasesGrouped.find((x) => x.major === major) + + if (range?.minor.every((value, index) => value === minor[index])) { + return `${major}.x` + } + + return `${formatMinecraftMinorVersion(major, minor[0])}–${formatMinecraftMinorVersion(major, minor[minor.length - 1])}` + }) + + const legacyVersionsAsRanges = groupConsecutiveIndices( + inputVersions.filter((projVer) => allLegacy.some((gameVer) => gameVer.version === projVer)), + allLegacy, + ) + + let output = [...legacyVersionsAsRanges] + + // show all snapshots if there's no release versions + if (releaseVersionsAsRanges.length === 0) { + const snapshotVersionsAsRanges = groupConsecutiveIndices( + inputVersions.filter((projVer) => + allSnapshots.some((gameVer) => gameVer.version === projVer), + ), + allSnapshots, + ) + output = [...snapshotVersionsAsRanges, ...output] + } else { + output = [...releaseVersionsAsRanges, ...output] + } + + if (latestSnapshot && !output.includes(latestSnapshot)) { + output = [latestSnapshot, ...output] + } + return output +} diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index 83b963a0d7..199fff78c6 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -2,6 +2,7 @@ export * from './auto-icons' export * from './common-messages' export * from './events' export * from './file-extensions' +export * from './formatting' export * from './game-modes' export * from './notices' export * from './savable' diff --git a/packages/utils/servers/types/api.ts b/packages/utils/servers/types/api.ts index 8cc6271f10..d589d6034d 100644 --- a/packages/utils/servers/types/api.ts +++ b/packages/utils/servers/types/api.ts @@ -16,4 +16,4 @@ export interface ModuleError { timestamp: number } -export type ModuleName = 'general' | 'content' | 'network' | 'startup' +export type ModuleName = 'general' | 'network' | 'startup' From dd56a60a00942b14485e9add48ac7403a64e19e3 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Sun, 18 Jan 2026 21:01:33 +0000 Subject: [PATCH 5/5] feat: fix invalidmodal --- packages/ui/src/pages/hosting/manage/content.vue | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ui/src/pages/hosting/manage/content.vue b/packages/ui/src/pages/hosting/manage/content.vue index 7905404cf0..573b695be5 100644 --- a/packages/ui/src/pages/hosting/manage/content.vue +++ b/packages/ui/src/pages/hosting/manage/content.vue @@ -491,6 +491,9 @@ const type = computed(() => { // Check if server has a modpack const hasModpack = computed(() => server.value?.upstream?.kind === 'modpack') +// Check if modal cannot be shown (missing required server data) +const invalidModal = computed(() => !server.value?.mc_version || !server.value?.loader) + // Accepted file types for upload const acceptedFileTypes = computed(() => { return type.value.toLowerCase() === 'plugin' ? ['.jar'] : ['.jar', '.zip']