From 6328d4d92aea7c8c30407bcd4a1b14acf01aaad9 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Fri, 16 Jan 2026 09:47:13 +0000 Subject: [PATCH 01/37] 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 02/37] 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 03/37] 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 05/37] 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'] From b54276fb1099e2621d8c4beb53531d636832fea3 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 19 Jan 2026 09:32:31 +0000 Subject: [PATCH 06/37] feat: add ContentModpackCard --- packages/ui/.storybook/preview.ts | 13 + .../src/components/instances/ContentCard.vue | 2 +- .../instances/ContentModpackCard.vue | 188 ++++++ packages/ui/src/components/instances/index.ts | 7 + .../instances/ContentModpackCard.stories.ts | 613 ++++++++++++++++++ 5 files changed, 822 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/instances/ContentModpackCard.vue create mode 100644 packages/ui/src/stories/instances/ContentModpackCard.stories.ts diff --git a/packages/ui/.storybook/preview.ts b/packages/ui/.storybook/preview.ts index 2c3f329bcb..94762fffb8 100644 --- a/packages/ui/.storybook/preview.ts +++ b/packages/ui/.storybook/preview.ts @@ -17,6 +17,8 @@ import { } from '../src/composables/i18n' import { AbstractWebNotificationManager, + I18N_INJECTION_KEY, + type I18nContext, type NotificationPanelLocation, provideNotificationManager, type WebNotification, @@ -77,6 +79,17 @@ class StorybookNotificationManager extends AbstractWebNotificationManager { setup((app) => { app.use(i18n) + + // Provide the custom I18nContext for components using injectI18n() + const i18nContext: I18nContext = { + locale: i18n.global.locale, + t: (key, values) => i18n.global.t(key, values ?? {}) as string, + setLocale: (newLocale) => { + i18n.global.locale.value = newLocale + }, + } + app.provide(I18N_INJECTION_KEY, i18nContext) + app.use(FloatingVue, { themes: { 'ribbit-popout': { diff --git a/packages/ui/src/components/instances/ContentCard.vue b/packages/ui/src/components/instances/ContentCard.vue index 9a2823079a..6389ea9d5a 100644 --- a/packages/ui/src/components/instances/ContentCard.vue +++ b/packages/ui/src/components/instances/ContentCard.vue @@ -106,7 +106,7 @@ const hasUpdateListener = computed(() => !!instance?.vnode.props?.onUpdate) diff --git a/packages/ui/src/components/instances/ContentModpackCard.vue b/packages/ui/src/components/instances/ContentModpackCard.vue new file mode 100644 index 0000000000..91f0d16741 --- /dev/null +++ b/packages/ui/src/components/instances/ContentModpackCard.vue @@ -0,0 +1,188 @@ + + + diff --git a/packages/ui/src/components/instances/index.ts b/packages/ui/src/components/instances/index.ts index 7d9dd736a3..4401ca4322 100644 --- a/packages/ui/src/components/instances/index.ts +++ b/packages/ui/src/components/instances/index.ts @@ -1,2 +1,9 @@ export type { ContentCardOwner, ContentCardProject, ContentCardVersion } from './ContentCard.vue' export { default as ContentCard } from './ContentCard.vue' +export type { + ContentModpackCardCategory, + ContentModpackCardOwner, + ContentModpackCardProject, + ContentModpackCardVersion, +} from './ContentModpackCard.vue' +export { default as ContentModpackCard } from './ContentModpackCard.vue' diff --git a/packages/ui/src/stories/instances/ContentModpackCard.stories.ts b/packages/ui/src/stories/instances/ContentModpackCard.stories.ts new file mode 100644 index 0000000000..6f27e7cf6f --- /dev/null +++ b/packages/ui/src/stories/instances/ContentModpackCard.stories.ts @@ -0,0 +1,613 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { fn } from 'storybook/test' +import { ref } from 'vue' + +import ContentCard from '../../components/instances/ContentCard.vue' +import type { + ContentModpackCardCategory, + ContentModpackCardOwner, + ContentModpackCardProject, + ContentModpackCardVersion, +} from '../../components/instances/ContentModpackCard.vue' +import ContentModpackCard from '../../components/instances/ContentModpackCard.vue' +import NewModal from '../../components/modal/NewModal.vue' + +// Real project data from Modrinth API +const fabulouslyOptimizedProject: ContentModpackCardProject = { + id: '1KVo5zza', + slug: 'fabulously-optimized', + title: 'Fabulously Optimized', + icon_url: + 'https://cdn.modrinth.com/data/1KVo5zza/9f1ded4949c2a9db5ca382d3bcc912c7245486b4_96.webp', + description: + 'Beautiful graphics, speedy performance and familiar features in a simple package. 1.21.11 beta!', + downloads: 8708191, + followers: 3762, +} + +const cobblemonProject: ContentModpackCardProject = { + id: '5FFgwNNP', + slug: 'cobblemon-fabric', + title: 'Cobblemon Official Modpack [Fabric]', + icon_url: 'https://cdn.modrinth.com/data/5FFgwNNP/e7f9ee2e9d361623847853fe2ddce42f519ee64f.png', + description: 'The official modpack of the Cobblemon mod, for Fabric!', + downloads: 4940845, + followers: 2051, +} + +const simplyOptimizedProject: ContentModpackCardProject = { + id: 'BYfVnHa7', + slug: 'sop', + title: 'Simply Optimized', + icon_url: 'https://cdn.modrinth.com/data/BYfVnHa7/845e93223da7e8d1ed1a33364b5bdb4c316ac518.png', + description: + 'The leading, well-researched optimization modpack with a focus on pure performance.', + downloads: 2903242, + followers: 1387, +} + +// Version data from Modrinth API +const fabulouslyOptimizedVersion: ContentModpackCardVersion = { + id: 'YEEXo8mO', + version_number: '1.12.1', + date_published: '2022-02-10T06:53:28.379507Z', +} + +const cobblemonVersion: ContentModpackCardVersion = { + id: 'bpaivauC', + version_number: '1.5.2', + date_published: '2024-05-27T07:12:36.043005Z', +} + +// Owner data from Modrinth API +const userOwner: ContentModpackCardOwner = { + id: '2avTeeAE', + name: 'robotkoer', + avatar_url: 'https://cdn.modrinth.com/user/2avTeeAE/icon.png', + type: 'user', +} + +const cobblemonOwner: ContentModpackCardOwner = { + id: 'AEFONbAM', + name: 'Reisen', + avatar_url: + 'https://cdn.modrinth.com/user/AEFONbAM/9e97453507a8245981d5cd825280f23be44f15ac.jpeg', + type: 'user', +} + +// Categories +const optimizationCategories: ContentModpackCardCategory[] = [ + { name: 'Fabric' }, + { name: 'Lightweight' }, + { name: 'Multiplayer' }, + { name: 'Optimization' }, +] + +const cobblemonCategories: ContentModpackCardCategory[] = [ + { name: 'Adventure' }, + { name: 'Fabric' }, + { name: 'Lightweight' }, + { name: 'Multiplayer' }, +] + +const meta = { + title: 'Instances/ContentModpackCard', + component: ContentModpackCard, + parameters: { + layout: 'padded', + }, + argTypes: { + project: { + control: 'object', + description: + 'Project information (id, slug, title, icon_url, description, downloads, followers)', + }, + version: { + control: 'object', + description: 'Version information (id, version_number, date_published)', + }, + owner: { + control: 'object', + description: 'Owner/author information (user or organization)', + }, + categories: { + control: 'object', + description: 'Category tags with optional click actions', + }, + 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: fabulouslyOptimizedProject, + }, + render: () => ({ + components: { ContentModpackCard }, + setup() { + const cards = [ + { + label: 'Full featured (all actions)', + project: fabulouslyOptimizedProject, + version: fabulouslyOptimizedVersion, + owner: userOwner, + categories: optimizationCategories, + hasUpdate: true, + hasContent: true, + hasUnlink: true, + }, + { + label: 'With update available only', + project: cobblemonProject, + version: cobblemonVersion, + owner: cobblemonOwner, + categories: cobblemonCategories, + hasUpdate: true, + }, + { + label: 'With content button only', + project: simplyOptimizedProject, + version: fabulouslyOptimizedVersion, + owner: userOwner, + hasContent: true, + }, + { + label: 'Minimal (project only)', + project: fabulouslyOptimizedProject, + }, + { + label: 'With version info only', + project: cobblemonProject, + version: cobblemonVersion, + }, + { + label: 'With owner only', + project: simplyOptimizedProject, + owner: userOwner, + }, + { + label: 'Disabled state', + project: fabulouslyOptimizedProject, + version: fabulouslyOptimizedVersion, + owner: userOwner, + categories: optimizationCategories, + disabled: true, + }, + ] + + return { cards } + }, + template: /*html*/ ` +
+ +
+ `, + }), +} + +// ============================================ +// Basic Stories +// ============================================ + +export const Default: Story = { + args: { + project: cobblemonProject, + version: cobblemonVersion, + owner: userOwner, + categories: optimizationCategories, + onUpdate: fn(), + onContent: fn(), + onUnlink: fn(), + }, +} + +export const MinimalProjectOnly: Story = { + args: { + project: cobblemonProject, + }, +} + +export const WithVersion: Story = { + args: { + project: simplyOptimizedProject, + version: fabulouslyOptimizedVersion, + }, +} + +export const WithUserOwner: Story = { + args: { + project: simplyOptimizedProject, + version: fabulouslyOptimizedVersion, + owner: userOwner, + categories: [{ name: 'Adventure' }], + }, +} + +export const WithOrganizationOwner: Story = { + args: { + project: cobblemonProject, + version: cobblemonVersion, + owner: userOwner, + categories: optimizationCategories, + }, +} + +// ============================================ +// Action Button Stories +// ============================================ + +export const WithUpdateButton: Story = { + args: { + project: cobblemonProject, + version: cobblemonVersion, + owner: userOwner, + categories: optimizationCategories, + onUpdate: fn(), + }, +} + +export const WithContentButton: Story = { + args: { + project: cobblemonProject, + version: cobblemonVersion, + owner: userOwner, + categories: optimizationCategories, + onContent: fn(), + }, +} + +export const WithUnlinkButton: Story = { + args: { + project: cobblemonProject, + version: cobblemonVersion, + owner: userOwner, + onUnlink: fn(), + }, +} + +export const WithAllActions: Story = { + args: { + project: cobblemonProject, + version: cobblemonVersion, + owner: userOwner, + categories: optimizationCategories, + onUpdate: fn(), + onContent: fn(), + onUnlink: fn(), + overflowOptions: [ + { id: 'view', action: () => console.log('View') }, + { id: 'settings', action: () => console.log('Settings') }, + { divider: true }, + { id: 'remove', action: () => console.log('Remove'), color: 'red' }, + ], + }, +} + +// ============================================ +// State Stories +// ============================================ + +export const Disabled: Story = { + args: { + project: cobblemonProject, + version: cobblemonVersion, + owner: userOwner, + categories: optimizationCategories, + disabled: true, + }, +} + +export const LongTitle: Story = { + args: { + project: { + ...cobblemonProject, + title: 'Super Long Modpack Title That Should Display Properly On All Screen Sizes', + description: + 'This is an extremely long description that should wrap properly and not break the layout. It contains lots of information about what this modpack includes and what makes it special compared to other modpacks available on the platform.', + }, + version: cobblemonVersion, + owner: { + ...userOwner, + name: 'Really Long Organization Name Studios', + }, + categories: [ + { name: 'Adventure' }, + { name: 'Technology' }, + { name: 'Magic' }, + { name: 'Exploration' }, + { name: 'Multiplayer' }, + ], + onUpdate: fn(), + onContent: fn(), + }, +} + +export const NoDescription: Story = { + args: { + project: { + ...cobblemonProject, + description: undefined, + }, + version: cobblemonVersion, + owner: userOwner, + categories: optimizationCategories, + }, +} + +export const NoStats: Story = { + args: { + project: { + ...cobblemonProject, + downloads: undefined, + followers: undefined, + }, + version: cobblemonVersion, + owner: userOwner, + }, +} + +// ============================================ +// Categories Stories +// ============================================ + +export const WithClickableCategories: Story = { + render: (args) => ({ + components: { ContentModpackCard }, + setup() { + const clickedCategory = ref(null) + const categories: ContentModpackCardCategory[] = [ + { name: 'Adventure', action: () => (clickedCategory.value = 'Adventure') }, + { name: 'Lightweight', action: () => (clickedCategory.value = 'Lightweight') }, + { name: 'Multiplayer', action: () => (clickedCategory.value = 'Multiplayer') }, + ] + return { args, categories, clickedCategory } + }, + template: /*html*/ ` +
+ +
+ Clicked category: {{ clickedCategory || 'None' }} +
+
+ `, + }), + args: { + project: cobblemonProject, + version: cobblemonVersion, + owner: userOwner, + }, +} + +// ============================================ +// Overflow Menu Stories +// ============================================ + +export const WithOverflowMenu: Story = { + render: (args) => ({ + components: { ContentModpackCard }, + setup() { + return { args } + }, + template: /*html*/ ` + + + + + + `, + }), + args: { + project: cobblemonProject, + version: cobblemonVersion, + owner: userOwner, + categories: optimizationCategories, + overflowOptions: [ + { id: 'view', action: () => console.log('View') }, + { id: 'settings', action: () => console.log('Settings') }, + { divider: true }, + { id: 'remove', action: () => console.log('Remove'), color: 'red' }, + ], + }, +} + +// ============================================ +// Interactive Stories +// ============================================ + +export const WithContentModal: Story = { + args: { + project: cobblemonProject, + }, + render: () => ({ + components: { ContentModpackCard, NewModal, ContentCard }, + setup() { + const modalRef = ref | null>(null) + const modpackContent = [ + { + project: { + id: '1', + slug: 'sodium', + title: 'Sodium', + icon_url: + 'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp', + }, + version: { id: 'v1', version_number: '0.8.2', file_name: 'sodium-fabric-0.8.2.jar' }, + }, + { + project: { + id: '2', + slug: 'modmenu', + title: 'Mod Menu', + icon_url: + 'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png', + }, + version: { id: 'v2', version_number: '16.0.0', file_name: 'modmenu-16.0.0.jar' }, + }, + { + project: { + id: '3', + slug: 'fabric-api', + title: 'Fabric API', + icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png', + }, + version: { id: 'v3', version_number: '0.141.3', file_name: 'fabric-api-0.141.3.jar' }, + }, + ] + + return { + cobblemonProject, + cobblemonVersion, + userOwner, + optimizationCategories, + modalRef, + modpackContent, + } + }, + template: /*html*/ ` +
+ + +
+ +
+
+
+ `, + }), +} + +// ============================================ +// Responsive Stories +// ============================================ + +export const ResponsiveView: Story = { + args: { + project: cobblemonProject, + }, + render: () => ({ + components: { ContentModpackCard }, + setup() { + return { + cobblemonProject, + cobblemonVersion, + userOwner, + optimizationCategories, + } + }, + template: /*html*/ ` +
+
+

Desktop (full width)

+
+ +
+
+
+

Mobile (<640px)

+
+ +
+
+
+ `, + }), +} + +// ============================================ +// Edge Cases +// ============================================ + +export const NoIcon: Story = { + args: { + project: { + ...cobblemonProject, + icon_url: undefined, + }, + version: cobblemonVersion, + owner: userOwner, + categories: optimizationCategories, + }, +} + +export const NoOwnerAvatar: Story = { + args: { + project: cobblemonProject, + version: cobblemonVersion, + owner: { + ...userOwner, + avatar_url: undefined, + }, + categories: optimizationCategories, + }, +} + +export const HighDownloadCounts: Story = { + args: { + project: { + ...cobblemonProject, + downloads: 1234567890, + followers: 9876543, + }, + version: cobblemonVersion, + owner: userOwner, + categories: optimizationCategories, + }, +} From 74cc23054daea41d0e4d7eda8d34f3fc6f111e55 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 19 Jan 2026 09:42:34 +0000 Subject: [PATCH 07/37] fix: extract types --- .../src/components/instances/ContentCard.vue | 22 +------ .../instances/ContentModpackCard.vue | 32 ++------- packages/ui/src/components/instances/index.ts | 9 +-- packages/ui/src/components/instances/types.ts | 33 ++++++++++ .../stories/instances/ContentCard.stories.ts | 6 +- .../instances/ContentModpackCard.stories.ts | 66 ++++++++++++------- 6 files changed, 93 insertions(+), 75 deletions(-) create mode 100644 packages/ui/src/components/instances/types.ts diff --git a/packages/ui/src/components/instances/ContentCard.vue b/packages/ui/src/components/instances/ContentCard.vue index 6389ea9d5a..aab05bbf4b 100644 --- a/packages/ui/src/components/instances/ContentCard.vue +++ b/packages/ui/src/components/instances/ContentCard.vue @@ -1,35 +1,17 @@ + + diff --git a/packages/ui/src/pages/hosting/manage/content_new.vue b/packages/ui/src/pages/hosting/manage/content_new.vue new file mode 100644 index 0000000000..25b1586d8e --- /dev/null +++ b/packages/ui/src/pages/hosting/manage/content_new.vue @@ -0,0 +1,329 @@ + + + diff --git a/packages/ui/src/pages/index.ts b/packages/ui/src/pages/index.ts index e6ce8a51d9..6fb5fdf83e 100644 --- a/packages/ui/src/pages/index.ts +++ b/packages/ui/src/pages/index.ts @@ -1,4 +1,5 @@ export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue' export { default as ServersManageContentPage } from './hosting/manage/content.vue' +export { default as ServersManageContentNewPage } from './hosting/manage/content_new.vue' export { default as ServersManageFilesPage } from './hosting/manage/files.vue' export { default as ServersManagePageIndex } from './hosting/manage/index.vue' From f4484d39aa3a7080ec2e196beee3cb807c2f8bbf Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 19 Jan 2026 11:01:31 +0000 Subject: [PATCH 09/37] feat: unlink modal --- .../instances/modals/ModpackUnlinkModal.vue | 63 +++++++++++++++++++ .../src/pages/hosting/manage/content_new.vue | 11 +++- 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/instances/modals/ModpackUnlinkModal.vue diff --git a/packages/ui/src/components/instances/modals/ModpackUnlinkModal.vue b/packages/ui/src/components/instances/modals/ModpackUnlinkModal.vue new file mode 100644 index 0000000000..9c431dcf51 --- /dev/null +++ b/packages/ui/src/components/instances/modals/ModpackUnlinkModal.vue @@ -0,0 +1,63 @@ + + + diff --git a/packages/ui/src/pages/hosting/manage/content_new.vue b/packages/ui/src/pages/hosting/manage/content_new.vue index 25b1586d8e..755f877d9e 100644 --- a/packages/ui/src/pages/hosting/manage/content_new.vue +++ b/packages/ui/src/pages/hosting/manage/content_new.vue @@ -14,6 +14,7 @@ import Combobox, { type ComboboxOption } from '../../../components/base/Combobox import Pagination from '../../../components/base/Pagination.vue' import ContentCard from '../../../components/instances/ContentCard.vue' import ContentModpackCard from '../../../components/instances/ContentModpackCard.vue' +import ModpackUnlinkModal from '../../../components/instances/modals/ModpackUnlinkModal.vue' import type { ContentCardProject, ContentCardVersion, @@ -126,6 +127,8 @@ const sortType = ref('Newest') const currentPage = ref(1) const itemsPerPage = 10 +const modpackUnlinkModal = ref>() + const filterOptions: ComboboxOption[] = [ { value: 'All', label: 'All' }, { value: 'Mods', label: 'Mods' }, @@ -204,7 +207,11 @@ function handleModpackContent() { } function handleModpackUnlink() { - console.log('Modpack unlink') + modpackUnlinkModal.value?.show() +} + +function handleModpackUnlinkConfirm() { + console.log('Modpack unlink confirmed') } function handleBrowseContent() { @@ -325,5 +332,7 @@ function handleUploadFiles() {
+ + From f767ed669502f5dcb9ed31aa8096d6d9561b5547 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 19 Jan 2026 11:23:26 +0000 Subject: [PATCH 10/37] feat: impl content tab --- .../src/pages/hosting/manage/[id]/content.vue | 10 +- .../pages/hosting/manage/[id]/content_new.vue | 7 - .../pages/hosting/manage/[id]/content_old.vue | 21 + .../ui/src/pages/hosting/manage/content.vue | 1080 ++++++----------- .../src/pages/hosting/manage/content_new.vue | 338 ------ .../src/pages/hosting/manage/content_old.vue | 833 +++++++++++++ packages/ui/src/pages/index.ts | 2 +- 7 files changed, 1242 insertions(+), 1049 deletions(-) delete mode 100644 apps/frontend/src/pages/hosting/manage/[id]/content_new.vue create mode 100644 apps/frontend/src/pages/hosting/manage/[id]/content_old.vue delete mode 100644 packages/ui/src/pages/hosting/manage/content_new.vue create mode 100644 packages/ui/src/pages/hosting/manage/content_old.vue diff --git a/apps/frontend/src/pages/hosting/manage/[id]/content.vue b/apps/frontend/src/pages/hosting/manage/[id]/content.vue index 2e12aed769..2cf5866be1 100644 --- a/apps/frontend/src/pages/hosting/manage/[id]/content.vue +++ b/apps/frontend/src/pages/hosting/manage/[id]/content.vue @@ -1,21 +1,13 @@ diff --git a/apps/frontend/src/pages/hosting/manage/[id]/content_new.vue b/apps/frontend/src/pages/hosting/manage/[id]/content_new.vue deleted file mode 100644 index 7953b952c8..0000000000 --- a/apps/frontend/src/pages/hosting/manage/[id]/content_new.vue +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/apps/frontend/src/pages/hosting/manage/[id]/content_old.vue b/apps/frontend/src/pages/hosting/manage/[id]/content_old.vue new file mode 100644 index 0000000000..6e3098125a --- /dev/null +++ b/apps/frontend/src/pages/hosting/manage/[id]/content_old.vue @@ -0,0 +1,21 @@ + + + diff --git a/packages/ui/src/pages/hosting/manage/content.vue b/packages/ui/src/pages/hosting/manage/content.vue index 573b695be5..3a4c547fd0 100644 --- a/packages/ui/src/pages/hosting/manage/content.vue +++ b/packages/ui/src/pages/hosting/manage/content.vue @@ -1,485 +1,44 @@ - - - +
+
+ + + + + + + +
+ + +
+ +
+
+ No content found. +
+ +
+ +
+ +
+ + + + + diff --git a/packages/ui/src/pages/hosting/manage/content_new.vue b/packages/ui/src/pages/hosting/manage/content_new.vue deleted file mode 100644 index 755f877d9e..0000000000 --- a/packages/ui/src/pages/hosting/manage/content_new.vue +++ /dev/null @@ -1,338 +0,0 @@ - - - diff --git a/packages/ui/src/pages/hosting/manage/content_old.vue b/packages/ui/src/pages/hosting/manage/content_old.vue new file mode 100644 index 0000000000..573b695be5 --- /dev/null +++ b/packages/ui/src/pages/hosting/manage/content_old.vue @@ -0,0 +1,833 @@ + + + + + diff --git a/packages/ui/src/pages/index.ts b/packages/ui/src/pages/index.ts index 6fb5fdf83e..75b60b26e9 100644 --- a/packages/ui/src/pages/index.ts +++ b/packages/ui/src/pages/index.ts @@ -1,5 +1,5 @@ export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue' export { default as ServersManageContentPage } from './hosting/manage/content.vue' -export { default as ServersManageContentNewPage } from './hosting/manage/content_new.vue' +export { default as ServersManageContentOldPage } from './hosting/manage/content_old.vue' export { default as ServersManageFilesPage } from './hosting/manage/files.vue' export { default as ServersManagePageIndex } from './hosting/manage/index.vue' From f9fdf1b39cfb19e9b41912c0da77319574335c05 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 19 Jan 2026 11:39:48 +0000 Subject: [PATCH 11/37] fix: lint --- packages/ui/src/pages/hosting/manage/content.vue | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/pages/hosting/manage/content.vue b/packages/ui/src/pages/hosting/manage/content.vue index 3a4c547fd0..c9aea2a2f6 100644 --- a/packages/ui/src/pages/hosting/manage/content.vue +++ b/packages/ui/src/pages/hosting/manage/content.vue @@ -33,7 +33,6 @@ import { injectNotificationManager, } from '../../../providers' -// Providers const client = injectModrinthClient() const { server } = injectModrinthServerContext() const { addNotification } = injectNotificationManager() @@ -41,30 +40,25 @@ const route = useRoute() const queryClient = useQueryClient() const serverId = route.params.id as string -// Content type based on server loader const type = computed(() => { const loader = server.value?.loader?.toLowerCase() return loader === 'paper' || loader === 'purpur' ? 'Plugin' : 'Mod' }) -// Check if server has a modpack const hasModpack = computed(() => server.value?.upstream?.kind === 'modpack') -// Fetch modpack project details from Labrinth const { data: modpackProject } = useQuery({ queryKey: computed(() => ['project', server.value?.upstream?.project_id]), queryFn: () => client.labrinth.projects_v3.get(server.value!.upstream!.project_id), enabled: hasModpack, }) -// Fetch modpack version details from Labrinth const { data: modpackVersion } = useQuery({ queryKey: computed(() => ['version', server.value?.upstream?.version_id]), - queryFn: () => client.labrinth.versions_v3.get(server.value!.upstream!.version_id), + queryFn: () => client.labrinth.versions_v3.getVersion(server.value!.upstream!.version_id), enabled: hasModpack, }) -// Computed modpack data for ContentModpackCard const modpack = computed(() => { if (!hasModpack.value || !modpackProject.value) return null @@ -170,7 +164,7 @@ const sortType = ref('Newest') const currentPage = ref(1) const itemsPerPage = 10 -const modpackUnlinkModal = ref>() +const _modpackUnlinkModal = ref>() const filterOptions: ComboboxOption[] = [ { value: 'All', label: 'All' }, @@ -271,7 +265,7 @@ const toggleMutation = useMutation({ }) // Update mutation -const updateMutation = useMutation({ +const _updateMutation = useMutation({ mutationFn: ({ replace, project_id, From c1007d4659f8678806f79de21ea030e7178e247c Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 19 Jan 2026 12:21:05 +0000 Subject: [PATCH 12/37] fix: toggling --- .../ui/src/pages/hosting/manage/content.vue | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/pages/hosting/manage/content.vue b/packages/ui/src/pages/hosting/manage/content.vue index c9aea2a2f6..3a5a830f94 100644 --- a/packages/ui/src/pages/hosting/manage/content.vue +++ b/packages/ui/src/pages/hosting/manage/content.vue @@ -223,7 +223,6 @@ function handleSearch() { currentPage.value = 1 } -// Delete mutation const deleteMutation = useMutation({ mutationFn: ({ path }: { path: string; modKey: string }) => client.archon.content_v0.delete(serverId, { path }), @@ -239,20 +238,31 @@ const deleteMutation = useMutation({ }, }) -// Toggle mutation (uses files API to rename) const toggleMutation = useMutation({ - mutationFn: async ({ mod }: { mod: Archon.Content.v0.Mod; modKey: string }) => { - const newFilename = mod.filename.endsWith('.disabled') - ? mod.filename.slice(0, -9) - : `${mod.filename}.disabled` + mutationFn: async ({ mod, modKey }: { mod: Archon.Content.v0.Mod; modKey: string }) => { const folder = `${type.value.toLowerCase()}s` + const currentFilename = mod.disabled ? `${mod.filename}.disabled` : mod.filename + const newFilename = mod.disabled ? mod.filename : `${mod.filename}.disabled` await client.kyros.files_v0.moveFileOrFolder( - `/${folder}/${mod.filename}`, + `/${folder}/${currentFilename}`, `/${folder}/${newFilename}`, ) - return { newFilename } + return { newDisabled: !mod.disabled, modKey } }, - onSuccess: () => { + onSuccess: ({ newDisabled, modKey }) => { + // Optimistically update the local cache immediately + // Archon may take time to sync after Kyros renames the file + queryClient.setQueryData( + contentQueryKey.value, + (oldData: Archon.Content.v0.Mod[] | undefined) => { + if (!oldData) return oldData + return oldData.map((m) => + getStableModKey(m) === modKey ? { ...m, disabled: newDisabled } : m, + ) + }, + ) + changingMods.value.delete(modKey) + // Also invalidate to eventually get the real server state queryClient.invalidateQueries({ queryKey: contentQueryKey.value }) }, onError: (_err, { mod, modKey }) => { @@ -297,14 +307,7 @@ function handleToggleEnabled(item: ContentItem, _value: boolean) { const modKey = getStableModKey(mod) changingMods.value.add(modKey) - toggleMutation.mutate( - { mod, modKey }, - { - onSettled: () => { - changingMods.value.delete(modKey) - }, - }, - ) + toggleMutation.mutate({ mod, modKey }) } function handleDelete(item: ContentItem) { From cf45de2b142b42fde8ccdadfe5e325f92c237198 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 19 Jan 2026 15:19:15 +0000 Subject: [PATCH 13/37] temp: disable updating stuff --- .../ui/src/pages/hosting/manage/content.vue | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/pages/hosting/manage/content.vue b/packages/ui/src/pages/hosting/manage/content.vue index 3a5a830f94..38ae927c3d 100644 --- a/packages/ui/src/pages/hosting/manage/content.vue +++ b/packages/ui/src/pages/hosting/manage/content.vue @@ -325,24 +325,26 @@ function handleDelete(item: ContentItem) { ) } -function handleUpdate(item: ContentItem) { - const mod = item._mod - if (!mod.project_id || !mod.version_id) { - addNotification({ - type: 'error', - text: 'Cannot update content without project information', - }) - return - } - - // TODO: Implement version selection modal or auto-update to latest - console.log('Update:', item.project.title) -} - -function handleModpackUpdate() { - // TODO: Implement modpack update (needs version selection) - console.log('Modpack update') -} +// TODO: implement update checking +// function handleUpdate(item: ContentItem) { +// const mod = item._mod +// if (!mod.project_id || !mod.version_id) { +// addNotification({ +// type: 'error', +// text: 'Cannot update content without project information', +// }) +// return +// } +// +// // TODO: Implement version selection modal or auto-update to latest +// console.log('Update:', item.project.title) +// } + +// TODO: implement modpack update +// function handleModpackUpdate() { +// // TODO: Implement modpack update (needs version selection) +// console.log('Modpack update') +// } function handleModpackContent() { // Navigate to modpack project page @@ -405,17 +407,16 @@ function handleUploadFiles() {