diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index c7561a588b..2352839df3 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -28,17 +28,18 @@ "@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-window-state": "^2.2.2", "@types/three": "^0.172.0", - "intl-messageformat": "^10.7.7", - "vue-i18n": "^10.0.0", "@vueuse/core": "^11.1.0", "dayjs": "^1.11.10", "floating-vue": "^5.2.2", + "fuse.js": "^6.6.2", + "intl-messageformat": "^10.7.7", "ofetch": "^1.3.4", "pinia": "^3.0.0", "posthog-js": "^1.158.2", "three": "^0.172.0", "vite-svg-loader": "^5.1.0", "vue": "^3.5.13", + "vue-i18n": "^10.0.0", "vue-multiselect": "3.0.0", "vue-router": "^4.6.0", "vue-virtual-scroller": "v2.0.0-beta.8" diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index f0511a1b4a..77a6456e33 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -36,6 +36,7 @@ import { NotificationPanel, OverflowMenu, ProgressSpinner, + provideModalBehavior, provideModrinthClient, provideNotificationManager, providePageContext, @@ -127,6 +128,11 @@ providePageContext({ hierarchicalSidebarAvailable: ref(true), showAds: ref(false), }) +provideModalBehavior({ + noblur: computed(() => !themeStore.advancedRendering), + onShow: () => hide_ads_window(), + onHide: () => show_ads_window(), +}) const news = ref([]) const availableSurvey = ref(false) diff --git a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue index 93cf9bc553..fa1055e514 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue @@ -1,22 +1,18 @@ diff --git a/apps/app-frontend/src/components/ui/modal/ShareModalWrapper.vue b/apps/app-frontend/src/components/ui/modal/ShareModalWrapper.vue index 9358ee908c..3ac556f5a0 100644 --- a/apps/app-frontend/src/components/ui/modal/ShareModalWrapper.vue +++ b/apps/app-frontend/src/components/ui/modal/ShareModalWrapper.vue @@ -1,12 +1,8 @@ + @@ -56,7 +46,5 @@ function onModalHide() { :share-text="shareText" :link="link" :open-in-new-tab="openInNewTab" - :on-hide="onModalHide" - :noblur="!themeStore.advancedRendering" /> diff --git a/apps/app-frontend/src/helpers/cache.js b/apps/app-frontend/src/helpers/cache.js index 6c63126646..d19be21693 100644 --- a/apps/app-frontend/src/helpers/cache.js +++ b/apps/app-frontend/src/helpers/cache.js @@ -51,3 +51,17 @@ export async function get_search_results_many(ids, cacheBehaviour) { export async function purge_cache_types(cacheTypes) { return await invoke('plugin:cache|purge_cache_types', { cacheTypes }) } + +/** + * Get versions for a project (without changelogs for fast loading). + * Uses the cache system - versions are cached for 30 minutes. + * @param {string} projectId - The project ID + * @param {string} [cacheBehaviour] - Cache behaviour ('must_revalidate', etc.) + * @returns {Promise} Array of version objects (without changelogs) or null + */ +export async function get_project_versions(projectId, cacheBehaviour) { + return await invoke('plugin:cache|get_project_versions', { + projectId, + cacheBehaviour, + }) +} diff --git a/apps/app-frontend/src/helpers/profile.js b/apps/app-frontend/src/helpers/profile.ts similarity index 50% rename from apps/app-frontend/src/helpers/profile.js rename to apps/app-frontend/src/helpers/profile.ts index 7c3f1090f4..71f615ecf9 100644 --- a/apps/app-frontend/src/helpers/profile.js +++ b/apps/app-frontend/src/helpers/profile.ts @@ -3,11 +3,21 @@ * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized, * and deserialized into a usable JS object. */ +import type { Labrinth } from '@modrinth/api-client' +import type { ContentItem, ContentOwner } from '@modrinth/ui' import { invoke } from '@tauri-apps/api/core' import { install_to_existing_profile } from '@/helpers/pack.js' -/// Add instance +import type { + CacheBehaviour, + ContentFile, + ContentFileProjectType, + GameInstance, + InstanceLoader, +} from './types' + +// Add instance /* name: String, // the name of the profile, and relative path to create game_version: String, // the game version of the profile @@ -18,8 +28,15 @@ import { install_to_existing_profile } from '@/helpers/pack.js' - icon is a path to an image file, which will be copied into the profile directory */ -export async function create(name, gameVersion, modloader, loaderVersion, icon, skipInstall) { - //Trim string name to avoid "Unable to find directory" +export async function create( + name: string, + gameVersion: string, + modloader: InstanceLoader, + loaderVersion: string | null, + icon: string | null, + skipInstall: boolean, +): Promise { + // Trim string name to avoid "Unable to find directory" name = name.trim() return await invoke('plugin:profile-create|profile_create', { name, @@ -32,83 +49,130 @@ export async function create(name, gameVersion, modloader, loaderVersion, icon, } // duplicate a profile -export async function duplicate(path) { +export async function duplicate(path: string): Promise { return await invoke('plugin:profile-create|profile_duplicate', { path }) } // Remove a profile -export async function remove(path) { +export async function remove(path: string): Promise { return await invoke('plugin:profile|profile_remove', { path }) } // Get a profile by path // Returns a Profile -export async function get(path) { +export async function get(path: string): Promise { return await invoke('plugin:profile|profile_get', { path }) } -export async function get_many(paths) { +export async function get_many(paths: string[]): Promise { return await invoke('plugin:profile|profile_get_many', { paths }) } // Get a profile's projects // Returns a map of a path to profile file -export async function get_projects(path, cacheBehaviour) { +export async function get_projects( + path: string, + cacheBehaviour?: CacheBehaviour, +): Promise> { return await invoke('plugin:profile|profile_get_projects', { path, cacheBehaviour }) } +// Get content items with rich metadata for a profile +// Returns content items filtered to exclude modpack files (if linked), +// sorted alphabetically by project name +export async function get_content_items( + path: string, + cacheBehaviour?: CacheBehaviour, +): Promise { + return await invoke('plugin:profile|profile_get_content_items', { path, cacheBehaviour }) +} + +// Linked modpack info returned from backend +export interface LinkedModpackInfo { + project: Labrinth.Projects.v2.Project + version: Labrinth.Versions.v2.Version + owner: ContentOwner | null + has_update: boolean + update_version_id: string | null + update_version: Labrinth.Versions.v2.Version | null +} + +// Get linked modpack info for a profile +// Returns project, version, and owner information for the linked modpack, +// or null if the profile is not linked to a modpack +export async function get_linked_modpack_info( + path: string, + cacheBehaviour?: CacheBehaviour, +): Promise { + return await invoke('plugin:profile|profile_get_linked_modpack_info', { path, cacheBehaviour }) +} + +// Get content items that are part of the linked modpack +// Returns the modpack's dependencies as ContentItem list +// Returns empty array if the profile is not linked to a modpack +export async function get_linked_modpack_content( + path: string, + cacheBehaviour?: CacheBehaviour, +): Promise { + return await invoke('plugin:profile|profile_get_linked_modpack_content', { path, cacheBehaviour }) +} + // Get a profile's full fs path // Returns a path -export async function get_full_path(path) { +export async function get_full_path(path: string): Promise { return await invoke('plugin:profile|profile_get_full_path', { path }) } // Get's a mod's full fs path // Returns a path -export async function get_mod_full_path(path, projectPath) { +export async function get_mod_full_path(path: string, projectPath: string): Promise { return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath }) } // Get optimal java version from profile // Returns a java version -export async function get_optimal_jre_key(path) { +export async function get_optimal_jre_key(path: string): Promise { return await invoke('plugin:profile|profile_get_optimal_jre_key', { path }) } // Get a copy of the profile set // Returns hashmap of path -> Profile -export async function list() { +export async function list(): Promise { return await invoke('plugin:profile|profile_list') } -export async function check_installed(path, projectId) { +export async function check_installed(path: string, projectId: string): Promise { return await invoke('plugin:profile|profile_check_installed', { path, projectId }) } // Installs/Repairs a profile -export async function install(path, force) { +export async function install(path: string, force: boolean): Promise { return await invoke('plugin:profile|profile_install', { path, force }) } // Updates all of a profile's projects -export async function update_all(path) { +export async function update_all(path: string): Promise> { return await invoke('plugin:profile|profile_update_all', { path }) } // Updates a specified project -export async function update_project(path, projectPath) { +export async function update_project(path: string, projectPath: string): Promise { return await invoke('plugin:profile|profile_update_project', { path, projectPath }) } // Add a project to a profile from a version // Returns a path to the new project file -export async function add_project_from_version(path, versionId) { +export async function add_project_from_version(path: string, versionId: string): Promise { return await invoke('plugin:profile|profile_add_project_from_version', { path, versionId }) } // Add a project to a profile from a path + project_type // Returns a path to the new project file -export async function add_project_from_path(path, projectPath, projectType) { +export async function add_project_from_path( + path: string, + projectPath: string, + projectType?: ContentFileProjectType, +): Promise { return await invoke('plugin:profile|profile_add_project_from_path', { path, projectPath, @@ -117,17 +181,20 @@ export async function add_project_from_path(path, projectPath, projectType) { } // Toggle disabling a project -export async function toggle_disable_project(path, projectPath) { +export async function toggle_disable_project(path: string, projectPath: string): Promise { return await invoke('plugin:profile|profile_toggle_disable_project', { path, projectPath }) } // Remove a project -export async function remove_project(path, projectPath) { +export async function remove_project(path: string, projectPath: string): Promise { return await invoke('plugin:profile|profile_remove_project', { path, projectPath }) } // Update a managed Modrinth profile to a specific version -export async function update_managed_modrinth_version(path, versionId) { +export async function update_managed_modrinth_version( + path: string, + versionId: string, +): Promise { return await invoke('plugin:profile|profile_update_managed_modrinth_version', { path, versionId, @@ -135,21 +202,21 @@ export async function update_managed_modrinth_version(path, versionId) { } // Repair a managed Modrinth profile -export async function update_repair_modrinth(path) { +export async function update_repair_modrinth(path: string): Promise { return await invoke('plugin:profile|profile_repair_managed_modrinth', { path }) } // Export a profile to .mrpack -/// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs') +// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs') // Version id is optional (ie: 1.1.5) export async function export_profile_mrpack( - path, - exportLocation, - includedOverrides, - versionId, - description, - name, -) { + path: string, + exportLocation: string, + includedOverrides: string[], + versionId?: string, + description?: string, + name?: string, +): Promise { return await invoke('plugin:profile|profile_export_mrpack', { path, exportLocation, @@ -168,39 +235,41 @@ export async function export_profile_mrpack( // -- file1 // => [mods, resourcepacks] // allows selection for 'included_overrides' in export_profile_mrpack -export async function get_pack_export_candidates(profilePath) { +export async function get_pack_export_candidates(profilePath: string): Promise { return await invoke('plugin:profile|profile_get_pack_export_candidates', { profilePath }) } // Run Minecraft using a pathed profile // Returns PID of child -export async function run(path) { +export async function run(path: string): Promise { return await invoke('plugin:profile|profile_run', { path }) } -export async function kill(path) { +export async function kill(path: string): Promise { return await invoke('plugin:profile|profile_kill', { path }) } // Edits a profile -export async function edit(path, editProfile) { +export async function edit(path: string, editProfile: Partial): Promise { return await invoke('plugin:profile|profile_edit', { path, editProfile }) } // Edits a profile's icon -export async function edit_icon(path, iconPath) { +export async function edit_icon(path: string, iconPath: string | null): Promise { return await invoke('plugin:profile|profile_edit_icon', { path, iconPath }) } -export async function finish_install(instance) { +export async function finish_install(instance: GameInstance): Promise { if (instance.install_stage !== 'pack_installed') { - let linkedData = instance.linked_data - await install_to_existing_profile( - linkedData.project_id, - linkedData.version_id, - instance.name, - instance.path, - ) + const linkedData = instance.linked_data + if (linkedData) { + await install_to_existing_profile( + linkedData.project_id, + linkedData.version_id, + instance.name, + instance.path, + ) + } } else { await install(instance.path, false) } diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts index 8e6ac8b416..c3b46e3d49 100644 --- a/apps/app-frontend/src/helpers/types.d.ts +++ b/apps/app-frontend/src/helpers/types.d.ts @@ -49,17 +49,10 @@ type LinkedData = { type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge' type ContentFile = { - hash: string - file_name: string - size: number - metadata?: FileMetadata - update_version_id?: string - project_type: ContentFileProjectType -} - -type FileMetadata = { - project_id: string - version_id: string + metadata?: { + project_id: string + version_id: string + } } type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack' diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index d621401151..960b0dde46 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -230,12 +230,6 @@ "instance.edit-world.title": { "message": "Edit world" }, - "instance.filter.disabled": { - "message": "Disabled projects" - }, - "instance.filter.updates-available": { - "message": "Updates available" - }, "instance.server-modal.address": { "message": "Address" }, @@ -350,30 +344,12 @@ "instance.settings.tabs.installation.change-version.already-installed.vanilla": { "message": "Vanilla {game_version} already installed" }, - "instance.settings.tabs.installation.change-version.button": { - "message": "Change version" - }, "instance.settings.tabs.installation.change-version.button.install": { "message": "Install" }, "instance.settings.tabs.installation.change-version.button.installing": { "message": "Installing" }, - "instance.settings.tabs.installation.change-version.cannot-while-fetching": { - "message": "Fetching modpack versions" - }, - "instance.settings.tabs.installation.change-version.in-progress": { - "message": "Installing new version" - }, - "instance.settings.tabs.installation.currently-installed": { - "message": "Currently installed" - }, - "instance.settings.tabs.installation.debug-information": { - "message": "Debug information:" - }, - "instance.settings.tabs.installation.fetching-modpack-details": { - "message": "Fetching modpack details" - }, "instance.settings.tabs.installation.game-version": { "message": "Game version" }, @@ -386,18 +362,9 @@ "instance.settings.tabs.installation.loader-version": { "message": "{loader} version" }, - "instance.settings.tabs.installation.minecraft-version": { - "message": "Minecraft {version}" - }, - "instance.settings.tabs.installation.no-connection": { - "message": "Cannot fetch linked modpack details. Please check your internet connection." - }, "instance.settings.tabs.installation.no-loader-versions": { "message": "{loader} is not available for Minecraft {version}. Try another mod loader." }, - "instance.settings.tabs.installation.no-modpack-found": { - "message": "This instance is linked to a modpack, but the modpack could not be found on Modrinth." - }, "instance.settings.tabs.installation.platform": { "message": "Platform" }, @@ -431,18 +398,21 @@ "instance.settings.tabs.installation.repair.confirm.title": { "message": "Repair instance?" }, + "instance.settings.tabs.installation.repair.description": { + "message": "Reinstalls Minecraft dependencies and checks for corruption. This may resolve issues if your game is not launching due to launcher-related errors." + }, "instance.settings.tabs.installation.repair.in-progress": { "message": "Repair in progress" }, + "instance.settings.tabs.installation.repair.title": { + "message": "Repair instance" + }, "instance.settings.tabs.installation.reset-selections": { "message": "Reset to current" }, "instance.settings.tabs.installation.show-all-versions": { "message": "Show all versions" }, - "instance.settings.tabs.installation.tooltip.action.change-version": { - "message": "change version" - }, "instance.settings.tabs.installation.tooltip.action.install": { "message": "install" }, @@ -464,21 +434,6 @@ "instance.settings.tabs.installation.unknown-version": { "message": "(unknown version)" }, - "instance.settings.tabs.installation.unlink.button": { - "message": "Unlink instance" - }, - "instance.settings.tabs.installation.unlink.confirm.description": { - "message": "If you proceed, you will not be able to re-link it without creating an entirely new instance. You will no longer receive modpack updates and it will become a normal." - }, - "instance.settings.tabs.installation.unlink.confirm.title": { - "message": "Are you sure you want to unlink this instance?" - }, - "instance.settings.tabs.installation.unlink.description": { - "message": "This instance is linked to a modpack, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the modpack." - }, - "instance.settings.tabs.installation.unlink.title": { - "message": "Unlink from modpack" - }, "instance.settings.tabs.java": { "message": "Java and memory" }, diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue index 0c26008d33..8bced7b90c 100644 --- a/apps/app-frontend/src/pages/instance/Index.vue +++ b/apps/app-frontend/src/pages/instance/Index.vue @@ -213,12 +213,11 @@ import { showProfileInFolder } from '@/helpers/utils.js' import { handleSevereError } from '@/store/error.js' import { useBreadcrumbs, useLoading, useTheming } from '@/store/state' -const themeStore = useTheming() - dayjs.extend(duration) dayjs.extend(relativeTime) const { handleError } = injectNotificationManager() +const themeStore = useTheming() const route = useRoute() const router = useRouter() diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue index e91b43f0bd..381f42a64d 100644 --- a/apps/app-frontend/src/pages/instance/Mods.vue +++ b/apps/app-frontend/src/pages/instance/Mods.vue @@ -1,854 +1,568 @@ - - - - - - - (searchFilter = '')"> - - - - - - - - - - {{ filter.formattedName }} - - - (currentPage = page)" - /> - - - + + + - - - - Update - - - - Share - Project names - File names - Project links - Markdown links - - - - Enable - - - Disable - - - Remove - - - - - - - - Refresh - - - - Update all - - - - Update pack - - - - - - - - - - - - - - - - - - - - - Show file - Copy link - - - - - - (currentPage = page)" - /> - + :is-app="true" + :is-modpack="updatingModpack" + :project-icon-url=" + updatingModpack ? linkedModpackProject?.icon_url : updatingProject?.project?.icon_url + " + :project-name=" + updatingModpack + ? (linkedModpackProject?.title ?? 'Modpack') + : (updatingProject?.project?.title ?? updatingProject?.file_name) + " + :loading="loadingVersions" + :loading-changelog="loadingChangelog" + @update="handleModalUpdate" + @version-select="handleVersionSelect" + @version-hover="handleVersionHover" + /> - - - - - You haven't added any content to this instance yet. - - - - - - - - - - + + - - - - diff --git a/apps/app/build.rs b/apps/app/build.rs index aacad57720..d9242d4ea5 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -40,6 +40,7 @@ fn main() { "get_search_results", "get_search_results_many", "purge_cache_types", + "get_project_versions", ]) .default_permission( DefaultPermissionRule::AllowAllCommands, @@ -160,6 +161,9 @@ fn main() { "profile_get", "profile_get_many", "profile_get_projects", + "profile_get_content_items", + "profile_get_linked_modpack_info", + "profile_get_linked_modpack_content", "profile_get_optimal_jre_key", "profile_get_full_path", "profile_get_mod_full_path", diff --git a/apps/app/src/api/cache.rs b/apps/app/src/api/cache.rs index d1e671813f..6197c59c2a 100644 --- a/apps/app/src/api/cache.rs +++ b/apps/app/src/api/cache.rs @@ -53,6 +53,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { get_search_results, get_search_results_many, purge_cache_types, + get_project_versions, ]) .build() } @@ -61,3 +62,14 @@ pub fn init() -> tauri::plugin::TauriPlugin { pub async fn purge_cache_types(cache_types: Vec) -> Result<()> { Ok(theseus::cache::purge_cache_types(&cache_types).await?) } + +#[tauri::command] +pub async fn get_project_versions( + project_id: &str, + cache_behaviour: Option, +) -> Result>> { + Ok( + theseus::cache::get_project_versions(project_id, cache_behaviour) + .await?, + ) +} diff --git a/apps/app/src/api/profile.rs b/apps/app/src/api/profile.rs index 192c63880c..45cd248778 100644 --- a/apps/app/src/api/profile.rs +++ b/apps/app/src/api/profile.rs @@ -4,6 +4,7 @@ use path_util::SafeRelativeUtf8UnixPathBuf; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; +use theseus::data::{ContentItem, LinkedModpackInfo}; use theseus::prelude::*; use theseus::profile::QuickPlayType; @@ -14,6 +15,9 @@ pub fn init() -> tauri::plugin::TauriPlugin { profile_get, profile_get_many, profile_get_projects, + profile_get_content_items, + profile_get_linked_modpack_info, + profile_get_linked_modpack_content, profile_get_optimal_jre_key, profile_get_full_path, profile_get_mod_full_path, @@ -70,6 +74,46 @@ pub async fn profile_get_projects( Ok(res) } +/// Get content items with rich metadata for a profile +/// +/// Returns content items filtered to exclude modpack files (if linked), +/// sorted alphabetically by project name. +#[tauri::command] +pub async fn profile_get_content_items( + path: &str, + cache_behaviour: Option, +) -> Result> { + let res = profile::get_content_items(path, cache_behaviour).await?; + Ok(res) +} + +/// Get linked modpack info for a profile +/// +/// Returns project, version, and owner information for the linked modpack, +/// or None if the profile is not linked to a modpack. +#[tauri::command] +pub async fn profile_get_linked_modpack_info( + path: &str, + cache_behaviour: Option, +) -> Result> { + let res = profile::get_linked_modpack_info(path, cache_behaviour).await?; + Ok(res) +} + +/// Get content items that are part of the linked modpack +/// +/// Returns the modpack's dependencies as ContentItem list. +/// Returns empty vec if the profile is not linked to a modpack. +#[tauri::command] +pub async fn profile_get_linked_modpack_content( + path: &str, + cache_behaviour: Option, +) -> Result> { + let res = + profile::get_linked_modpack_content(path, cache_behaviour).await?; + Ok(res) +} + // Get a profile's full path // invoke('plugin:profile|profile_get_full_path',path) #[tauri::command] diff --git a/apps/frontend/src/app.vue b/apps/frontend/src/app.vue index 2cf651352d..f42f4d8472 100644 --- a/apps/frontend/src/app.vue +++ b/apps/frontend/src/app.vue @@ -9,6 +9,7 @@ diff --git a/apps/frontend/src/components/ui/servers/FileItem.vue b/apps/frontend/src/components/ui/servers/FileItem.vue deleted file mode 100644 index 338d351181..0000000000 --- a/apps/frontend/src/components/ui/servers/FileItem.vue +++ /dev/null @@ -1,352 +0,0 @@ - - e.key === 'Enter' && selectItem()" - @mouseenter="handleMouseEnter" - @dragstart="handleDragStart" - @dragend="handleDragEnd" - @dragenter.prevent="handleDragEnter" - @dragover.prevent="handleDragOver" - @dragleave.prevent="handleDragLeave" - @drop.prevent="handleDrop" - > - - - - - - - - {{ name }} - - - - - - {{ formattedSize }} - - - {{ formattedCreationDate }} - - - {{ formattedModifiedDate }} - - - - - Extract - Rename - Move - Download - Delete - - - - - - - diff --git a/apps/frontend/src/components/ui/servers/FileManagerError.vue b/apps/frontend/src/components/ui/servers/FileManagerError.vue deleted file mode 100644 index 84adf75b66..0000000000 --- a/apps/frontend/src/components/ui/servers/FileManagerError.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - - - {{ title }} - - {{ message }} - - - - - - Try again - - - - - - Go to home folder - - - - - - - - diff --git a/apps/frontend/src/components/ui/servers/FileVirtualList.vue b/apps/frontend/src/components/ui/servers/FileVirtualList.vue deleted file mode 100644 index 800783aa0e..0000000000 --- a/apps/frontend/src/components/ui/servers/FileVirtualList.vue +++ /dev/null @@ -1,128 +0,0 @@ - - - - - $emit('contextmenu', item, x, y)" - @toggle-select="$emit('toggle-select', item.path)" - /> - - - - - - diff --git a/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue b/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue deleted file mode 100644 index 9d02fac539..0000000000 --- a/apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue +++ /dev/null @@ -1,175 +0,0 @@ - - - - - - - - - Home - - - - - - - - - - - {{ segment || '' }} - - - - - - - - - - - - - - - - - - - - - - New file - New folder - Upload file - - Upload from .zip file - - - Upload from .zip URL - - - Install CurseForge pack - - - - - - - - - - diff --git a/apps/frontend/src/components/ui/servers/FilesContextMenu.vue b/apps/frontend/src/components/ui/servers/FilesContextMenu.vue deleted file mode 100644 index 2a3a5aaad1..0000000000 --- a/apps/frontend/src/components/ui/servers/FilesContextMenu.vue +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - Rename - - - - Move - - - - Download - - - - Delete - - - - - - - - - diff --git a/apps/frontend/src/components/ui/servers/FilesCreateItemModal.vue b/apps/frontend/src/components/ui/servers/FilesCreateItemModal.vue deleted file mode 100644 index 0693162204..0000000000 --- a/apps/frontend/src/components/ui/servers/FilesCreateItemModal.vue +++ /dev/null @@ -1,96 +0,0 @@ - - - - - Name - - {{ error }} - - - - - - Create {{ displayType }} - - - - - - Cancel - - - - - - - - diff --git a/apps/frontend/src/components/ui/servers/FilesDeleteItemModal.vue b/apps/frontend/src/components/ui/servers/FilesDeleteItemModal.vue deleted file mode 100644 index d5cd6f1382..0000000000 --- a/apps/frontend/src/components/ui/servers/FilesDeleteItemModal.vue +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - {{ item?.name }} - - {{ item?.count }} items - - - {{ ((item?.size ?? 0) / 1024 / 1024).toFixed(2) }} MB - - - - - - - - Delete {{ item?.type }} - - - - - - Cancel - - - - - - - - diff --git a/apps/frontend/src/components/ui/servers/FilesEditingNavbar.vue b/apps/frontend/src/components/ui/servers/FilesEditingNavbar.vue deleted file mode 100644 index cb5cf5111d..0000000000 --- a/apps/frontend/src/components/ui/servers/FilesEditingNavbar.vue +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - - - - Home - - - - - - - - - {{ segment || '' }} - - - - - - {{ - fileName - }} - - - - - - - - - - - - - - Save - Save as... - - - - - Save & restart - - - - - - - - diff --git a/apps/frontend/src/components/ui/servers/FilesEditor.vue b/apps/frontend/src/components/ui/servers/FilesEditor.vue deleted file mode 100644 index f9961a130a..0000000000 --- a/apps/frontend/src/components/ui/servers/FilesEditor.vue +++ /dev/null @@ -1,260 +0,0 @@ - - - - - saveFileContent(true)" - @save-as="saveFileContentAs" - @save-restart="saveFileContentRestart" - @share="requestShareLink" - @navigate="(index) => emit('navigate', index)" - /> - - - - - - - - - - - - - - diff --git a/apps/frontend/src/components/ui/servers/FilesImageViewer.vue b/apps/frontend/src/components/ui/servers/FilesImageViewer.vue deleted file mode 100644 index e05024c8a5..0000000000 --- a/apps/frontend/src/components/ui/servers/FilesImageViewer.vue +++ /dev/null @@ -1,180 +0,0 @@ - - - - - - - {{ state.errorMessage || 'Invalid or empty image file.' }} - - - - - - - - - - - - - - - - - {{ Math.round(state.scale * 100) }}% - Reset - - - - - - - diff --git a/apps/frontend/src/components/ui/servers/FilesLabelBar.vue b/apps/frontend/src/components/ui/servers/FilesLabelBar.vue deleted file mode 100644 index 72bf21a295..0000000000 --- a/apps/frontend/src/components/ui/servers/FilesLabelBar.vue +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - Name - - - - - - - Size - - - - - Created - - - - - Modified - - - - Actions - - - - - diff --git a/apps/frontend/src/components/ui/servers/FilesMoveItemModal.vue b/apps/frontend/src/components/ui/servers/FilesMoveItemModal.vue deleted file mode 100644 index 0183adc780..0000000000 --- a/apps/frontend/src/components/ui/servers/FilesMoveItemModal.vue +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - New location: - - /root{{ newpath }} - - - - - - - Move - - - - - - Cancel - - - - - - - - diff --git a/apps/frontend/src/components/ui/servers/FilesRenameItemModal.vue b/apps/frontend/src/components/ui/servers/FilesRenameItemModal.vue deleted file mode 100644 index 8a3fd8d070..0000000000 --- a/apps/frontend/src/components/ui/servers/FilesRenameItemModal.vue +++ /dev/null @@ -1,94 +0,0 @@ - - - - - Name - - {{ error }} - - - - - - Rename - - - - - - Cancel - - - - - - - - diff --git a/apps/frontend/src/components/ui/servers/FilesUploadConflictModal.vue b/apps/frontend/src/components/ui/servers/FilesUploadConflictModal.vue deleted file mode 100644 index b655d29d5e..0000000000 --- a/apps/frontend/src/components/ui/servers/FilesUploadConflictModal.vue +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - Over 100 files will be overwritten if you proceed with extraction; here is just some of - them: - - - The following {{ files.length }} files already exist on your server, and will be - overwritten if you proceed with extraction: - - - - - {{ file }} - - - - - - - diff --git a/apps/frontend/src/components/ui/servers/FilesUploadDragAndDrop.vue b/apps/frontend/src/components/ui/servers/FilesUploadDragAndDrop.vue deleted file mode 100644 index 773e015ad1..0000000000 --- a/apps/frontend/src/components/ui/servers/FilesUploadDragAndDrop.vue +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - Drop {{ type ? type.toLocaleLowerCase() : 'file' }}s here to upload - - - - - - - diff --git a/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue b/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue deleted file mode 100644 index 344e3d06c5..0000000000 --- a/apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue +++ /dev/null @@ -1,335 +0,0 @@ - - - - - - - - - - - {{ props.fileType ? props.fileType : 'File' }} uploads - - {{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : '' }} - - - - - - - - - - - - - {{ item.file.name }} - {{ item.size }} - - - - Done - - - Failed - File already exists - - - Failed - {{ item.error?.message || 'An unexpected error occured.' }} - - - Failed - Incorrect file type - - - - {{ item.progress }}% - - - - - Cancel - - - - Cancelled - - - {{ item.progress }}% - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/components/ui/servers/FilesUploadZipUrlModal.vue b/apps/frontend/src/components/ui/servers/FilesUploadZipUrlModal.vue deleted file mode 100644 index 352f3cd568..0000000000 --- a/apps/frontend/src/components/ui/servers/FilesUploadZipUrlModal.vue +++ /dev/null @@ -1,163 +0,0 @@ - - - - - - {{ cf ? `How to get the modpack version's URL` : 'URL of .zip file' }} - - - - - Find the CurseForge modpack - - - you'd like to install on your server. - - - On the modpack's page, go to the - "Files" tab, and - select the version of the modpack you - want to install. - - - Copy the URL of the version you want to - install, and paste it in the box below. - - - Copy and paste the direct download URL of a .zip file. - - {{ error }} - - - - - - - - {{ submitted ? 'Installing...' : 'Install' }} - - - - - - {{ submitted ? 'Close' : 'Cancel' }} - - - - - - - - diff --git a/apps/frontend/src/composables/servers/modrinth-servers.ts b/apps/frontend/src/composables/servers/modrinth-servers.ts index 2b02457a28..b7987ee3fa 100644 --- a/apps/frontend/src/composables/servers/modrinth-servers.ts +++ b/apps/frontend/src/composables/servers/modrinth-servers.ts @@ -2,7 +2,7 @@ import type { AbstractWebNotificationManager } from '@modrinth/ui' import type { JWTAuth, ModuleError, ModuleName } from '@modrinth/utils' import { ModrinthServerError } from '@modrinth/utils' -import { ContentModule, GeneralModule, NetworkModule, StartupModule } from './modules/index.ts' +import { GeneralModule, NetworkModule, StartupModule } from './modules/index.ts' import { useServersFetch } from './servers-fetch.ts' export function handleServersError(err: any, notifications: AbstractWebNotificationManager) { @@ -27,7 +27,6 @@ export class ModrinthServer { private errors: Partial> = {} readonly general: GeneralModule - readonly content: ContentModule readonly network: NetworkModule readonly startup: StartupModule @@ -35,7 +34,6 @@ export class ModrinthServer { this.serverId = serverId this.general = new GeneralModule(this) - this.content = new ContentModule(this) this.network = new NetworkModule(this) this.startup = new StartupModule(this) } @@ -209,7 +207,7 @@ export class ModrinthServer { }, ): Promise { const modulesToRefresh = - modules.length > 0 ? modules : (['general', 'content', 'network', 'startup'] as ModuleName[]) + modules.length > 0 ? modules : (['general', 'network', 'startup'] as ModuleName[]) for (const module of modulesToRefresh) { this.errors[module] = undefined @@ -238,9 +236,6 @@ export class ModrinthServer { } break } - case 'content': - await this.content.fetch() - break case 'network': await this.network.fetch() break @@ -250,11 +245,6 @@ export class ModrinthServer { } } catch (error) { if (error instanceof ModrinthServerError) { - if (error.statusCode === 404 && module === 'content') { - console.debug(`Optional ${module} resource not found:`, error.message) - continue - } - if (error.statusCode && error.statusCode >= 500) { console.debug(`Temporary ${module} unavailable:`, error.message) continue diff --git a/apps/frontend/src/composables/servers/modules/content.ts b/apps/frontend/src/composables/servers/modules/content.ts deleted file mode 100644 index 2db34b7691..0000000000 --- a/apps/frontend/src/composables/servers/modules/content.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ContentType, Mod } from '@modrinth/utils' - -import { useServersFetch } from '../servers-fetch.ts' -import { ServerModule } from './base.ts' - -export class ContentModule extends ServerModule { - data: Mod[] = [] - - async fetch(): Promise { - const mods = await useServersFetch(`servers/${this.serverId}/mods`, {}, 'content') - this.data = mods.sort((a, b) => (a?.name ?? '').localeCompare(b?.name ?? '')) - } - - async install(contentType: ContentType, projectId: string, versionId: string): Promise { - await useServersFetch(`servers/${this.serverId}/mods`, { - method: 'POST', - body: { - rinth_ids: { project_id: projectId, version_id: versionId }, - install_as: contentType, - }, - }) - } - - async remove(path: string): Promise { - await useServersFetch(`servers/${this.serverId}/deleteMod`, { - method: 'POST', - body: { path }, - }) - } - - async reinstall(replace: string, projectId: string, versionId: string): Promise { - await useServersFetch(`servers/${this.serverId}/mods/update`, { - method: 'POST', - body: { replace, project_id: projectId, version_id: versionId }, - }) - } -} diff --git a/apps/frontend/src/composables/servers/modules/index.ts b/apps/frontend/src/composables/servers/modules/index.ts index 62fe2c45f8..ea54fce52d 100644 --- a/apps/frontend/src/composables/servers/modules/index.ts +++ b/apps/frontend/src/composables/servers/modules/index.ts @@ -1,6 +1,5 @@ export * from './backups.ts' export * from './base.ts' -export * from './content.ts' export * from './general.ts' export * from './network.ts' export * from './startup.ts' diff --git a/apps/frontend/src/pages/[type]/[id]/settings/members.vue b/apps/frontend/src/pages/[type]/[id]/settings/members.vue index 78c781abc5..f43b7905fc 100644 --- a/apps/frontend/src/pages/[type]/[id]/settings/members.vue +++ b/apps/frontend/src/pages/[type]/[id]/settings/members.vue @@ -5,7 +5,6 @@ title="Are you sure you want to remove this project from the organization?" description="If you proceed, this project will no longer be managed by the organization." proceed-label="Remove" - :noblur="!(cosmetics?.advancedRendering ?? true)" @proceed="onRemoveFromOrg" /> @@ -354,7 +353,7 @@ @@ -567,7 +566,6 @@ const { refreshMembers, } = injectProjectPageContext() -const cosmetics = useCosmetics() const auth = await useAuth() const allTeamMembers = ref([]) diff --git a/apps/frontend/src/pages/discover/[type]/index.vue b/apps/frontend/src/pages/discover/[type]/index.vue index 8c981b0490..e293eb9062 100644 --- a/apps/frontend/src/pages/discover/[type]/index.vue +++ b/apps/frontend/src/pages/discover/[type]/index.vue @@ -35,8 +35,8 @@ import { useSearch, useVIntl, } from '@modrinth/ui' -import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils' -import { useQueryClient } from '@tanstack/vue-query' +import { capitalizeString, cycleValue } from '@modrinth/utils' +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { useThrottleFn } from '@vueuse/core' import { computed, type Reactive, watch } from 'vue' @@ -49,10 +49,13 @@ import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts' const { formatMessage } = useVIntl() +const client = injectModrinthClient() +const queryClient = useQueryClient() + const filtersMenuOpen = ref(false) -const route = useNativeRoute() -const router = useNativeRouter() +const route = useRoute() +const router = useRouter() const cosmetics = useCosmetics() const tags = useGeneratedState() @@ -60,8 +63,6 @@ const flags = useFeatureFlags() const auth = await useAuth() const { handleError } = injectNotificationManager() -const modrinthClient = injectModrinthClient() -const queryClient = useQueryClient() let prefetchTimeout: ReturnType | null = null @@ -69,11 +70,11 @@ function handleProjectHover(result: Labrinth.Search.v2.ResultSearchProject) { if (prefetchTimeout) clearTimeout(prefetchTimeout) prefetchTimeout = setTimeout(() => { const slug = result.slug || result.project_id - queryClient.prefetchQuery(projectQueryOptions.v2(slug, modrinthClient)) - queryClient.prefetchQuery(projectQueryOptions.v3(result.project_id, modrinthClient)) - queryClient.prefetchQuery(projectQueryOptions.members(result.project_id, modrinthClient)) - queryClient.prefetchQuery(projectQueryOptions.dependencies(result.project_id, modrinthClient)) - queryClient.prefetchQuery(projectQueryOptions.versionsV3(result.project_id, modrinthClient)) + queryClient.prefetchQuery(projectQueryOptions.v2(slug, client)) + queryClient.prefetchQuery(projectQueryOptions.v3(result.project_id, client)) + queryClient.prefetchQuery(projectQueryOptions.members(result.project_id, client)) + queryClient.prefetchQuery(projectQueryOptions.dependencies(result.project_id, client)) + queryClient.prefetchQuery(projectQueryOptions.versionsV3(result.project_id, client)) }, 150) } @@ -97,6 +98,48 @@ const server = ref>() const serverHideInstalled = ref(false) const eraseDataOnInstall = ref(false) +const currentServerId = computed(() => queryAsString(route.query.sid) || null) + +// TanStack Query for server content list +const contentQueryKey = computed(() => ['content', 'list', currentServerId.value ?? ''] as const) +const { data: serverContentData, error: serverContentError } = useQuery({ + queryKey: contentQueryKey, + queryFn: () => client.archon.content_v0.list(currentServerId.value!), + enabled: computed(() => !!currentServerId.value), +}) + +// Watch for errors and notify user +watch(serverContentError, (error) => { + if (error) { + console.error('Failed to load server content:', error) + handleError(error) + } +}) + +// Install content mutation +const installContentMutation = useMutation({ + mutationFn: ({ + serverId, + type, + projectId, + versionId, + }: { + serverId: string + type: 'mod' | 'plugin' + projectId: string + versionId: string + }) => + client.archon.content_v0.install(serverId, { + rinth_ids: { project_id: projectId, version_id: versionId }, + install_as: type, + }), + onSuccess: () => { + if (currentServerId.value) { + queryClient.invalidateQueries({ queryKey: ['content', 'list', currentServerId.value] }) + } + }, +}) + const PERSISTENT_QUERY_PARAMS = ['sid', 'shi'] async function updateServerContext() { @@ -114,7 +157,7 @@ async function updateServerContext() { } if (!server.value || server.value.serverId !== serverId) { - server.value = await useModrinthServers(serverId, ['general', 'content']) + server.value = await useModrinthServers(serverId, ['general']) } if (route.query.shi && projectType.value?.id !== 'modpack' && server.value) { @@ -172,10 +215,10 @@ const serverFilters = computed(() => { }) } - if (serverHideInstalled.value) { - const installedMods = server.value.content?.data - .filter((x: InstallableMod) => x.project_id) - .map((x: InstallableMod) => x.project_id) + if (serverHideInstalled.value && serverContentData.value) { + const installedMods = serverContentData.value + .filter((x) => x.project_id) + .map((x) => x.project_id) .filter((id): id is string => id !== undefined) installedMods @@ -317,12 +360,20 @@ async function serverInstall(project: InstallableSearchResult) { project.installed = true navigateTo(`/hosting/manage/${server.value.serverId}/options/loader`) } else if (projectType.value?.id === 'mod') { - await server.value.content.install('mod', version.project_id, version.id) - await server.value.refresh(['content']) + await installContentMutation.mutateAsync({ + serverId: server.value.serverId, + type: 'mod', + projectId: version.project_id, + versionId: version.id, + }) project.installed = true } else if (projectType.value?.id === 'plugin') { - await server.value.content.install('plugin', version.project_id, version.id) - await server.value.refresh(['content']) + await installContentMutation.mutateAsync({ + serverId: server.value.serverId, + type: 'plugin', + projectId: version.project_id, + versionId: version.id, + }) project.installed = true } } catch (e) { @@ -676,89 +727,87 @@ useSeoMeta({ resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list' " > - - - - - - - Download - - - - - - - - - - - - - - - - - - - - - - - Installed - - - Installing... - - - - Install - - + + + + + + + + Download + + + + + + + + + + + + + + + + + + + + + + + Installed + + + Installing... + + + + Install + + + - - + + diff --git a/apps/frontend/src/pages/hosting/manage/[id]/content.vue b/apps/frontend/src/pages/hosting/manage/[id]/content.vue index 5a0bb5d922..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/index.vue b/apps/frontend/src/pages/hosting/manage/[id]/content/index.vue deleted file mode 100644 index c773d69686..0000000000 --- a/apps/frontend/src/pages/hosting/manage/[id]/content/index.vue +++ /dev/null @@ -1,706 +0,0 @@ - - - - - - - - - - - Failed to load content - - - We couldn't load your server's {{ type.toLowerCase() }}s. Here's what we know: - {{ - JSON.stringify(server.moduleErrors.content.error) - }} - - server.refresh(['content'])"> - Retry - - - - - - - - - - - - - Search - - - - - - - {{ filterMethodLabel }} - - - - All {{ type.toLocaleLowerCase() }}s - Only enabled - Only disabled - - - - - - - Add file - - - - - - Add {{ type.toLocaleLowerCase() }} - - - - - - props.server.refresh(['content'])" - /> - - - - - - - - - - - - {{ friendlyModName(mod) }} - Disabled - - - by {{ mod.owner }} - - {{ mod.version_number || `External ${type.toLocaleLowerCase()}` }} - - - - - - - {{ - mod.version_number || `External ${type.toLocaleLowerCase()}` - }} - - - - {{ mod.filename }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Edit - - - - Delete - - - - - - - - - - - - - - - - - - No {{ type.toLocaleLowerCase() }}s found for your query! - - Try another query, or show everything. - - - - Show everything - - - - - - No {{ type.toLocaleLowerCase() }}s found! - - Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here. - - - - - - Add file - - - - - - Add {{ type.toLocaleLowerCase() }} - - - - - - - - Your server is running Vanilla Minecraft - - Add content to your server by installing a modpack or choosing a different platform that - supports {{ type }}s. - - - - - - Find a modpack - - - or - - - - Change platform - - - - - - - - - - - - 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 4c391ef553..5d36f76e6e 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -469,6 +469,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 @@ -484,7 +490,6 @@ export namespace Labrinth { game_versions?: string[] loaders?: string[] include_changelog?: boolean - apiVersion?: 2 | 3 } 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 1a3a023242..7de277f616 100644 --- a/packages/api-client/src/modules/labrinth/versions/v3.ts +++ b/packages/api-client/src/modules/labrinth/versions/v3.ts @@ -35,8 +35,8 @@ export class LabrinthVersionsV3Module extends AbstractModule { if (options?.loaders?.length) { params.loaders = JSON.stringify(options.loaders) } - if (options?.include_changelog !== undefined) { - params.include_changelog = options.include_changelog + if (options?.include_changelog === false) { + params.include_changelog = 'false' } return this.client.request(`/project/${id}/version`, { 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/packages/app-lib/.sqlx/query-4d66e2bfedb7a31244d24858acf9b73a6289c1a90fb0988c4f5f5f64be01c4ec.json b/packages/app-lib/.sqlx/query-4d66e2bfedb7a31244d24858acf9b73a6289c1a90fb0988c4f5f5f64be01c4ec.json new file mode 100644 index 0000000000..1d65ebe354 --- /dev/null +++ b/packages/app-lib/.sqlx/query-4d66e2bfedb7a31244d24858acf9b73a6289c1a90fb0988c4f5f5f64be01c4ec.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT data as \"data?: sqlx::types::Json\"\n FROM cache\n WHERE data_type = $1 AND id = $2\n ", + "describe": { + "columns": [ + { + "name": "data?: sqlx::types::Json", + "ordinal": 0, + "type_info": "Null" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true + ] + }, + "hash": "4d66e2bfedb7a31244d24858acf9b73a6289c1a90fb0988c4f5f5f64be01c4ec" +} diff --git a/packages/app-lib/src/api/cache.rs b/packages/app-lib/src/api/cache.rs index e1cdbd0339..7eda0cea1d 100644 --- a/packages/app-lib/src/api/cache.rs +++ b/packages/app-lib/src/api/cache.rs @@ -51,3 +51,20 @@ pub async fn purge_cache_types( Ok(()) } + +/// Get versions for a project (without changelogs for fast loading). +/// Uses the cache system with the ProjectVersions cache type. +#[tracing::instrument] +pub async fn get_project_versions( + project_id: &str, + cache_behaviour: Option, +) -> crate::Result>> { + let state = crate::State::get().await?; + CachedEntry::get_project_versions( + project_id, + cache_behaviour, + &state.pool, + &state.api_semaphore, + ) + .await +} diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs index 1243c244f3..6bcea96b04 100644 --- a/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs @@ -18,11 +18,13 @@ pub mod worlds; pub mod data { pub use crate::state::{ - CacheBehaviour, CacheValueType, Credentials, Dependency, DirectoryInfo, - Hooks, JavaVersion, LinkedData, MemorySettings, ModLoader, - ModrinthCredentials, Organization, ProcessMetadata, ProfileFile, - Project, ProjectType, SearchResult, SearchResults, Settings, - TeamMember, Theme, User, UserFriend, Version, WindowSize, + CacheBehaviour, CacheValueType, ContentItem, ContentItemOwner, + ContentItemProject, ContentItemVersion, Credentials, Dependency, + DirectoryInfo, Hooks, JavaVersion, LinkedData, LinkedModpackInfo, + MemorySettings, ModLoader, ModrinthCredentials, Organization, + OwnerType, ProcessMetadata, ProfileFile, Project, ProjectType, + SearchResult, SearchResults, Settings, TeamMember, Theme, User, + UserFriend, Version, WindowSize, }; pub use ariadne::users::UserStatus; } diff --git a/packages/app-lib/src/api/pack/install_mrpack.rs b/packages/app-lib/src/api/pack/install_mrpack.rs index 0fdead311c..908a65b9ac 100644 --- a/packages/app-lib/src/api/pack/install_mrpack.rs +++ b/packages/app-lib/src/api/pack/install_mrpack.rs @@ -8,7 +8,7 @@ use crate::pack::install_from::{ use crate::state::{ CacheBehaviour, CachedEntry, ProfileInstallStage, SideType, cache_file_hash, }; -use crate::util::fetch::{fetch_mirrors, write}; +use crate::util::fetch::{fetch_mirrors, sha1_async, write}; use crate::util::io; use crate::{State, profile}; use async_zip::base::read::seek::ZipFileReader; @@ -115,6 +115,44 @@ pub async fn install_zipped_mrpack_files( .into()); } + // Cache the modpack file hashes for later filtering of user-added content + // Includes both manifest file hashes and computed hashes for override files + if let Some(ref version_id) = version_id { + let mut file_hashes: Vec = pack + .files + .iter() + .filter_map(|f| f.hashes.get(&PackFileHash::Sha1).cloned()) + .collect(); + + // Also hash files from overrides folders (these aren't in modrinth.index.json) + let override_entries: Vec = zip_reader + .file() + .entries() + .iter() + .enumerate() + .filter_map(|(index, entry)| { + let filename = entry.filename().as_str().ok()?; + let is_override = (filename.starts_with("overrides/") + || filename.starts_with("client-overrides/") + || filename.starts_with("server-overrides/")) + && !filename.ends_with('/'); + is_override.then_some(index) + }) + .collect(); + + for index in override_entries { + let mut file_bytes = Vec::new(); + let mut entry_reader = zip_reader.reader_with_entry(index).await?; + entry_reader.read_to_end_checked(&mut file_bytes).await?; + + let hash = sha1_async(bytes::Bytes::from(file_bytes)).await?; + file_hashes.push(hash); + } + + CachedEntry::cache_modpack_files(version_id, file_hashes, &state.pool) + .await?; + } + // Sets generated profile attributes to the pack ones (using profile::edit) set_profile_information( profile_path.clone(), diff --git a/packages/app-lib/src/api/profile/mod.rs b/packages/app-lib/src/api/profile/mod.rs index 935c837a2d..db259987e5 100644 --- a/packages/app-lib/src/api/profile/mod.rs +++ b/packages/app-lib/src/api/profile/mod.rs @@ -8,8 +8,9 @@ use crate::pack::install_from::{ EnvType, PackDependency, PackFile, PackFileHash, PackFormat, }; use crate::state::{ - CacheBehaviour, CachedEntry, Credentials, JavaVersion, ProcessMetadata, - ProfileFile, ProfileInstallStage, ProjectType, SideType, + CacheBehaviour, CachedEntry, ContentItem, Credentials, JavaVersion, + LinkedModpackInfo, ProcessMetadata, ProfileFile, ProfileInstallStage, + ProjectType, SideType, }; use crate::event::{ProfilePayloadType, emit::emit_profile}; @@ -91,6 +92,84 @@ pub async fn get_projects( } } +/// Get content items with rich metadata for a profile +/// +/// Returns content items filtered to exclude modpack files (if linked), +/// sorted alphabetically by project name. +#[tracing::instrument] +pub async fn get_content_items( + path: &str, + cache_behaviour: Option, +) -> crate::Result> { + let state = State::get().await?; + + if let Some(profile) = get(path).await? { + let items = crate::state::get_content_items( + &profile, + cache_behaviour, + &state.pool, + &state.api_semaphore, + ) + .await?; + Ok(items) + } else { + Err(crate::ErrorKind::UnmanagedProfileError(path.to_string()) + .as_error()) + } +} + +/// Get content items that are part of the linked modpack +/// +/// Returns the modpack's dependencies as ContentItem list. +/// Returns empty vec if the profile is not linked to a modpack. +#[tracing::instrument] +pub async fn get_linked_modpack_content( + path: &str, + cache_behaviour: Option, +) -> crate::Result> { + let state = State::get().await?; + + if let Some(profile) = get(path).await? { + let items = crate::state::get_linked_modpack_content( + &profile, + cache_behaviour, + &state.pool, + &state.api_semaphore, + ) + .await?; + Ok(items) + } else { + Err(crate::ErrorKind::UnmanagedProfileError(path.to_string()) + .as_error()) + } +} + +/// Get linked modpack info for a profile +/// +/// Returns project, version, and owner information for the linked modpack, +/// or None if the profile is not linked to a modpack. +#[tracing::instrument] +pub async fn get_linked_modpack_info( + path: &str, + cache_behaviour: Option, +) -> crate::Result> { + let state = State::get().await?; + + if let Some(profile) = get(path).await? { + let info = crate::state::get_linked_modpack_info( + &profile, + cache_behaviour, + &state.pool, + &state.api_semaphore, + ) + .await?; + Ok(info) + } else { + Err(crate::ErrorKind::UnmanagedProfileError(path.to_string()) + .as_error()) + } +} + /// Get profile's full path in the filesystem #[tracing::instrument] pub async fn get_full_path(path: &str) -> crate::Result { diff --git a/packages/app-lib/src/state/cache.rs b/packages/app-lib/src/state/cache.rs index eee2fb108c..9229dc1bb8 100644 --- a/packages/app-lib/src/state/cache.rs +++ b/packages/app-lib/src/state/cache.rs @@ -34,6 +34,9 @@ pub enum CacheValueType { FileHash, FileUpdate, SearchResults, + ModpackFiles, + /// Cached list of versions for a project (without changelogs for fast loading) + ProjectVersions, } impl CacheValueType { @@ -55,6 +58,8 @@ impl CacheValueType { CacheValueType::FileHash => "file_hash", CacheValueType::FileUpdate => "file_update", CacheValueType::SearchResults => "search_results", + CacheValueType::ModpackFiles => "modpack_files", + CacheValueType::ProjectVersions => "project_versions", } } @@ -76,6 +81,8 @@ impl CacheValueType { "file_hash" => CacheValueType::FileHash, "file_update" => CacheValueType::FileUpdate, "search_results" => CacheValueType::SearchResults, + "modpack_files" => CacheValueType::ModpackFiles, + "project_versions" => CacheValueType::ProjectVersions, _ => CacheValueType::Project, } } @@ -85,7 +92,10 @@ impl CacheValueType { match self { CacheValueType::File => 30 * 24 * 60 * 60, // 30 days CacheValueType::FileHash => 30 * 24 * 60 * 60, // 30 days - _ => 30 * 60, // 30 minutes + // ModpackFiles never expire - version_id is immutable so hashes never change + // TODO: There has to be a way to exclude this from the "Purge cache" stuff? + CacheValueType::ModpackFiles => 100 * 365 * 24 * 60 * 60, // 100 years (effectively never) + _ => 30 * 60, // 30 minutes } } @@ -118,11 +128,27 @@ impl CacheValueType { | CacheValueType::File | CacheValueType::LoaderManifest | CacheValueType::FileUpdate - | CacheValueType::SearchResults => None, + | CacheValueType::SearchResults + | CacheValueType::ModpackFiles + | CacheValueType::ProjectVersions => None, } } } +/// Cached modpack file hashes for filtering content +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct CachedModpackFiles { + pub version_id: String, + pub file_hashes: Vec, +} + +/// Cached list of versions for a project (without changelogs for fast loading) +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct CachedProjectVersions { + pub project_id: String, + pub versions: Vec, +} + #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(untagged)] #[allow(clippy::large_enum_variant)] @@ -151,6 +177,8 @@ pub enum CacheValue { FileHash(CachedFileHash), FileUpdate(CachedFileUpdate), SearchResults(SearchResults), + ModpackFiles(CachedModpackFiles), + ProjectVersions(CachedProjectVersions), } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -308,7 +336,8 @@ pub struct Version { pub name: String, pub version_number: String, - pub changelog: String, + #[serde(default)] + pub changelog: Option, pub changelog_url: Option, pub date_published: DateTime, @@ -456,6 +485,8 @@ impl CacheValue { CacheValue::FileHash(_) => CacheValueType::FileHash, CacheValue::FileUpdate(_) => CacheValueType::FileUpdate, CacheValue::SearchResults(_) => CacheValueType::SearchResults, + CacheValue::ModpackFiles(_) => CacheValueType::ModpackFiles, + CacheValue::ProjectVersions(_) => CacheValueType::ProjectVersions, } } @@ -496,6 +527,8 @@ impl CacheValue { ) } CacheValue::SearchResults(search) => search.search.clone(), + CacheValue::ModpackFiles(files) => files.version_id.clone(), + CacheValue::ProjectVersions(pv) => pv.project_id.clone(), } } @@ -520,7 +553,9 @@ impl CacheValue { | CacheValue::File { .. } | CacheValue::LoaderManifest { .. } | CacheValue::FileUpdate(_) - | CacheValue::SearchResults(_) => None, + | CacheValue::SearchResults(_) + | CacheValue::ModpackFiles(_) + | CacheValue::ProjectVersions(_) => None, } } } @@ -1411,6 +1446,56 @@ impl CachedEntry { }) .collect() } + CacheValueType::ModpackFiles => { + // ModpackFiles are only stored locally during modpack installation, + // not fetched from an external API + vec![] + } + CacheValueType::ProjectVersions => { + let mut values = vec![]; + + for key in keys { + let project_id = key.to_string(); + let url = format!( + "{}project/{}/version?include_changelog=false", + env!("MODRINTH_API_URL"), + project_id + ); + + match fetch_json::>( + Method::GET, + &url, + None, + None, + fetch_semaphore, + pool, + ) + .await + { + Ok(versions) => { + values.push(( + CacheValue::ProjectVersions( + CachedProjectVersions { + project_id, + versions, + }, + ) + .get_entry(), + true, + )); + } + Err(e) => { + tracing::warn!( + "Failed to fetch versions for project {}: {:?}", + project_id, + e + ); + } + } + } + + values + } }) } @@ -1463,6 +1548,86 @@ impl CachedEntry { Ok(()) } + + /// Store modpack file hashes in cache + pub async fn cache_modpack_files( + version_id: &str, + file_hashes: Vec, + pool: &SqlitePool, + ) -> crate::Result<()> { + let data = CachedModpackFiles { + version_id: version_id.to_string(), + file_hashes, + }; + + let entry = CachedEntry { + id: version_id.to_string(), + alias: None, + expires: Utc::now().timestamp() + + CacheValueType::ModpackFiles.expiry(), + type_: CacheValueType::ModpackFiles, + data: Some(CacheValue::ModpackFiles(data)), + }; + + Self::upsert_many(&[entry], pool).await + } + + /// Get modpack file hashes from cache + pub async fn get_modpack_files( + version_id: &str, + pool: &SqlitePool, + ) -> crate::Result> { + let type_str = CacheValueType::ModpackFiles.as_str(); + + let result = sqlx::query!( + r#" + SELECT data as "data?: sqlx::types::Json" + FROM cache + WHERE data_type = $1 AND id = $2 + "#, + type_str, + version_id + ) + .fetch_optional(pool) + .await?; + + if let Some(row) = result + && let Some(sqlx::types::Json(CacheValue::ModpackFiles(files))) = + row.data + { + return Ok(Some(files)); + } + + Ok(None) + } + + /// Get versions for a project (without changelogs for fast loading) + #[tracing::instrument(skip(pool, fetch_semaphore))] + pub async fn get_project_versions( + project_id: &str, + cache_behaviour: Option, + pool: &SqlitePool, + fetch_semaphore: &FetchSemaphore, + ) -> crate::Result>> { + let entry = Self::get( + CacheValueType::ProjectVersions, + project_id, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await?; + + if let Some(CachedEntry { + data: Some(CacheValue::ProjectVersions(pv)), + .. + }) = entry + { + return Ok(Some(pv.versions)); + } + + Ok(None) + } } pub async fn cache_file_hash( diff --git a/packages/app-lib/src/state/instances/content.rs b/packages/app-lib/src/state/instances/content.rs new file mode 100644 index 0000000000..35339938a0 --- /dev/null +++ b/packages/app-lib/src/state/instances/content.rs @@ -0,0 +1,816 @@ +//! # Content API +//! +//! ## Data Flow +//! +//! 1. Frontend calls `get_content_items(profile_path)` +//! 2. Backend fetches all installed files via `Profile::get_projects()` +//! 3. If profile is linked to a modpack: +//! - Fetch modpack file hashes from cache (populated during installation) +//! - Fallback: re-download .mrpack if cache miss (cleared/expired) +//! - Filter out files that belong to the modpack +//! 4. For remaining files, fetch project/version/owner metadata in parallel +//! 5. Return sorted `ContentItem` list +//! +//! ## Caching +//! +//! Modpack file hashes are cached in `CacheValueType::ModpackFiles` +//! during modpack installation. The cache never expires (version_id is +//! immutable), so re-download is only needed if cache was cleared or +//! profile predates this caching mechanism. + +use crate::pack::install_from::{PackFileHash, PackFormat}; +use crate::state::profiles::{Profile, ProfileFile, ProjectType}; +use crate::state::{CacheBehaviour, CachedEntry}; +use crate::util::fetch::{FetchSemaphore, fetch_mirrors, sha1_async}; +use async_zip::base::read::seek::ZipFileReader; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; +use std::collections::HashSet; +use std::io::Cursor; + +/// Content item with rich metadata for frontend display +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ContentItem { + /// Unique identifier (the file name) + pub file_name: String, + /// Relative path to the file within the profile + pub file_path: String, + /// SHA1 hash of the file + pub hash: String, + /// File size in bytes + pub size: u64, + /// Whether the file is enabled (not .disabled) + pub enabled: bool, + /// Type of project (mod, resourcepack, etc.) + pub project_type: ProjectType, + /// Modrinth project info if recognized + pub project: Option, + /// Version info if recognized + pub version: Option, + /// Owner info (organization or user) + pub owner: Option, + /// Whether an update is available + pub has_update: bool, + /// The recommended version ID to update to (if has_update is true) + pub update_version_id: Option, + /// When the file was added to the instance (file modification time) + pub date_added: Option, +} + +/// Project information for content item display +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ContentItemProject { + pub id: String, + pub slug: Option, + pub title: String, + pub icon_url: Option, +} + +/// Version information for content item display +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ContentItemVersion { + pub id: String, + pub version_number: String, + pub file_name: String, + pub date_published: Option, +} + +/// Owner information for content item display +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ContentItemOwner { + pub id: String, + pub name: String, + pub avatar_url: Option, + #[serde(rename = "type")] + pub owner_type: OwnerType, +} + +/// Type of content owner +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum OwnerType { + User, + Organization, +} + +use crate::state::{Project, Version}; + +/// Full linked modpack information including owner and update status +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LinkedModpackInfo { + pub project: Project, + pub version: Version, + pub owner: Option, + /// Whether an update is available for this modpack + pub has_update: bool, + /// The version ID to update to (if has_update is true) + pub update_version_id: Option, + /// The full version info for the update (if has_update is true) + pub update_version: Option, +} + +/// Get linked modpack info including project, version, owner, and update status. +/// Returns None if the profile is not linked to a modpack. +pub async fn get_linked_modpack_info( + profile: &Profile, + cache_behaviour: Option, + pool: &SqlitePool, + fetch_semaphore: &FetchSemaphore, +) -> crate::Result> { + let Some(linked_data) = &profile.linked_data else { + return Ok(None); + }; + + // Fetch project, version, and all project versions in parallel + let (project, version, all_versions) = tokio::try_join!( + CachedEntry::get_project( + &linked_data.project_id, + cache_behaviour, + pool, + fetch_semaphore, + ), + CachedEntry::get_version( + &linked_data.version_id, + cache_behaviour, + pool, + fetch_semaphore, + ), + CachedEntry::get_project_versions( + &linked_data.project_id, + cache_behaviour, + pool, + fetch_semaphore, + ), + )?; + + let project = project.ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Linked modpack project {} not found", + linked_data.project_id + )) + })?; + + let version = version.ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Linked modpack version {} not found", + linked_data.version_id + )) + })?; + + // Resolve owner - prefer organization, fall back to team owner + let owner = if let Some(org_id) = &project.organization { + let org = CachedEntry::get_organization( + org_id, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await?; + org.map(|o| ContentItemOwner { + id: o.id, + name: o.name, + avatar_url: o.icon_url, + owner_type: OwnerType::Organization, + }) + } else { + let team = CachedEntry::get_team( + &project.team, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await?; + team.and_then(|t| { + t.into_iter() + .find(|m| m.is_owner) + .map(|m| ContentItemOwner { + id: m.user.id, + name: m.user.username, + avatar_url: m.user.avatar_url, + owner_type: OwnerType::User, + }) + }) + }; + + // Check for updates + let (has_update, update_version_id, update_version) = check_modpack_update( + profile, + &linked_data.version_id, + &version, + all_versions, + ); + + Ok(Some(LinkedModpackInfo { + project, + version, + owner, + has_update, + update_version_id, + update_version, + })) +} + +/// Check if a newer compatible version exists for the linked modpack. +/// Returns (has_update, update_version_id, update_version). +fn check_modpack_update( + profile: &Profile, + installed_version_id: &str, + installed_version: &Version, + all_versions: Option>, +) -> (bool, Option, Option) { + let Some(versions) = all_versions else { + return (false, None, None); + }; + + // Get the loader as a string for comparison + let loader_str = profile.loader.as_str().to_lowercase(); + let game_version = &profile.game_version; + + // Filter to compatible versions + let mut compatible_versions: Vec<&Version> = versions + .iter() + .filter(|v| { + // Must support the profile's game version + let supports_game = v.game_versions.contains(game_version); + + // Must support the profile's loader + // Modpacks list "mrpack" as a loader, but also list actual loaders + let supports_loader = v.loaders.iter().any(|l| { + let l_lower = l.to_lowercase(); + l_lower == loader_str || l_lower == "mrpack" + }); + + supports_game && supports_loader + }) + .collect(); + + // Sort by date_published descending (newest first) + compatible_versions.sort_by(|a, b| b.date_published.cmp(&a.date_published)); + + // Find the newest compatible version + if let Some(newest) = compatible_versions.first() { + // Check if the newest version is different and newer than installed + if newest.id != installed_version_id + && newest.date_published > installed_version.date_published + { + return (true, Some(newest.id.clone()), Some((*newest).clone())); + } + } + + (false, None, None) +} + +/// Get content items with rich metadata, filtered to exclude modpack content. +/// Returns only user-added content (not part of the linked modpack). +pub async fn get_content_items( + profile: &Profile, + cache_behaviour: Option, + pool: &SqlitePool, + fetch_semaphore: &FetchSemaphore, +) -> crate::Result> { + let all_files = profile + .get_projects(cache_behaviour, pool, fetch_semaphore) + .await?; + + let modpack_hashes: HashSet = if let Some(ref linked_data) = + profile.linked_data + { + match get_modpack_file_hashes( + &linked_data.version_id, + pool, + fetch_semaphore, + ) + .await + { + Ok(hashes) => hashes, + Err(e) => { + tracing::warn!("Failed to fetch modpack file hashes: {}", e); + HashSet::new() + } + } + } else { + HashSet::new() + }; + + let user_files: Vec<(String, ProfileFile)> = all_files + .into_iter() + .filter(|(_, file)| !modpack_hashes.contains(&file.hash)) + .collect(); + + let project_ids: HashSet = user_files + .iter() + .filter_map(|(_, f)| f.metadata.as_ref().map(|m| m.project_id.clone())) + .collect(); + + let version_ids: HashSet = user_files + .iter() + .filter_map(|(_, f)| f.metadata.as_ref().map(|m| m.version_id.clone())) + .collect(); + + let project_ids_vec: Vec<&str> = + project_ids.iter().map(|s| s.as_str()).collect(); + let version_ids_vec: Vec<&str> = + version_ids.iter().map(|s| s.as_str()).collect(); + + let (projects, versions) = + if !project_ids.is_empty() || !version_ids.is_empty() { + tokio::try_join!( + async { + if project_ids.is_empty() { + Ok(Vec::new()) + } else { + CachedEntry::get_project_many( + &project_ids_vec, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await + } + }, + async { + if version_ids.is_empty() { + Ok(Vec::new()) + } else { + CachedEntry::get_version_many( + &version_ids_vec, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await + } + } + )? + } else { + (Vec::new(), Vec::new()) + }; + + let team_ids: HashSet = + projects.iter().map(|p| p.team.clone()).collect(); + let org_ids: HashSet = projects + .iter() + .filter_map(|p| p.organization.clone()) + .collect(); + + let team_ids_vec: Vec<&str> = team_ids.iter().map(|s| s.as_str()).collect(); + let org_ids_vec: Vec<&str> = org_ids.iter().map(|s| s.as_str()).collect(); + + let (teams, organizations) = if !team_ids.is_empty() || !org_ids.is_empty() + { + tokio::try_join!( + async { + if team_ids.is_empty() { + Ok(Vec::new()) + } else { + CachedEntry::get_team_many( + &team_ids_vec, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await + } + }, + async { + if org_ids.is_empty() { + Ok(Vec::new()) + } else { + CachedEntry::get_organization_many( + &org_ids_vec, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await + } + } + )? + } else { + (Vec::new(), Vec::new()) + }; + + let profile_base_path = + crate::api::profile::get_full_path(&profile.path).await?; + + let mut items: Vec = user_files + .iter() + .map(|(path, file)| { + let project = file + .metadata + .as_ref() + .and_then(|m| projects.iter().find(|p| p.id == m.project_id)); + + let version = file + .metadata + .as_ref() + .and_then(|m| versions.iter().find(|v| v.id == m.version_id)); + + let owner = project.and_then(|p| { + if let Some(org_id) = &p.organization { + organizations.iter().find(|o| &o.id == org_id).map(|o| { + ContentItemOwner { + id: o.id.clone(), + name: o.name.clone(), + avatar_url: o.icon_url.clone(), + owner_type: OwnerType::Organization, + } + }) + } else { + teams + .iter() + .find(|t| { + t.first().is_some_and(|m| m.team_id == p.team) + }) + .and_then(|t| t.iter().find(|m| m.is_owner)) + .map(|m| ContentItemOwner { + id: m.user.id.clone(), + name: m.user.username.clone(), + avatar_url: m.user.avatar_url.clone(), + owner_type: OwnerType::User, + }) + } + }); + + let date_added = std::fs::metadata(profile_base_path.join(path)) + .and_then(|m| m.modified()) + .ok() + .map(|t| chrono::DateTime::::from(t).to_rfc3339()); + + ContentItem { + file_name: file.file_name.clone(), + file_path: path.clone(), + hash: file.hash.clone(), + size: file.size, + enabled: !file.file_name.ends_with(".disabled"), + project_type: file.project_type, + project: project.map(|p| ContentItemProject { + id: p.id.clone(), + slug: p.slug.clone(), + title: p.title.clone(), + icon_url: p.icon_url.clone(), + }), + version: version.map(|v| ContentItemVersion { + id: v.id.clone(), + version_number: v.version_number.clone(), + file_name: file.file_name.clone(), + date_published: Some(v.date_published.to_rfc3339()), + }), + owner, + has_update: file.update_version_id.is_some(), + update_version_id: file.update_version_id.clone(), + date_added, + } + }) + .collect(); + + items.sort_by(|a, b| { + let name_a = a + .project + .as_ref() + .map(|p| p.title.as_str()) + .unwrap_or(&a.file_name); + let name_b = b + .project + .as_ref() + .map(|p| p.title.as_str()) + .unwrap_or(&b.file_name); + name_a.to_lowercase().cmp(&name_b.to_lowercase()) + }); + + Ok(items) +} + +/// Get content items that are part of the linked modpack (not user-added). +/// Returns the modpack's dependencies as ContentItem list. +/// Returns empty vec if the profile is not linked to a modpack. +pub async fn get_linked_modpack_content( + profile: &Profile, + cache_behaviour: Option, + pool: &SqlitePool, + fetch_semaphore: &FetchSemaphore, +) -> crate::Result> { + let Some(linked_data) = &profile.linked_data else { + return Ok(Vec::new()); + }; + + // Get the modpack version to access its dependencies + let version = CachedEntry::get_version( + &linked_data.version_id, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Linked modpack version {} not found", + linked_data.version_id + )) + })?; + + // Extract project IDs and version IDs from dependencies + let project_ids: HashSet = version + .dependencies + .iter() + .filter_map(|d| d.project_id.clone()) + .collect(); + + let version_ids: HashSet = version + .dependencies + .iter() + .filter_map(|d| d.version_id.clone()) + .collect(); + + if project_ids.is_empty() { + return Ok(Vec::new()); + } + + let project_ids_vec: Vec<&str> = + project_ids.iter().map(|s| s.as_str()).collect(); + let version_ids_vec: Vec<&str> = + version_ids.iter().map(|s| s.as_str()).collect(); + + // Fetch projects and versions in parallel + let (projects, versions) = tokio::try_join!( + CachedEntry::get_project_many( + &project_ids_vec, + cache_behaviour, + pool, + fetch_semaphore, + ), + async { + if version_ids.is_empty() { + Ok(Vec::new()) + } else { + CachedEntry::get_version_many( + &version_ids_vec, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await + } + } + )?; + + // Collect team and org IDs for owner resolution + let team_ids: HashSet = + projects.iter().map(|p| p.team.clone()).collect(); + let org_ids: HashSet = projects + .iter() + .filter_map(|p| p.organization.clone()) + .collect(); + + let team_ids_vec: Vec<&str> = team_ids.iter().map(|s| s.as_str()).collect(); + let org_ids_vec: Vec<&str> = org_ids.iter().map(|s| s.as_str()).collect(); + + // Fetch teams and organizations in parallel + let (teams, organizations) = if !team_ids.is_empty() || !org_ids.is_empty() + { + tokio::try_join!( + async { + if team_ids.is_empty() { + Ok(Vec::new()) + } else { + CachedEntry::get_team_many( + &team_ids_vec, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await + } + }, + async { + if org_ids.is_empty() { + Ok(Vec::new()) + } else { + CachedEntry::get_organization_many( + &org_ids_vec, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await + } + } + )? + } else { + (Vec::new(), Vec::new()) + }; + + // Build ContentItems from dependencies + let mut items: Vec = version + .dependencies + .iter() + .filter_map(|dep| { + let project_id = dep.project_id.as_ref()?; + let project = projects.iter().find(|p| &p.id == project_id)?; + + let version = dep + .version_id + .as_ref() + .and_then(|vid| versions.iter().find(|v| &v.id == vid)); + + let owner = if let Some(org_id) = &project.organization { + organizations.iter().find(|o| &o.id == org_id).map(|o| { + ContentItemOwner { + id: o.id.clone(), + name: o.name.clone(), + avatar_url: o.icon_url.clone(), + owner_type: OwnerType::Organization, + } + }) + } else { + teams + .iter() + .find(|t| { + t.first().is_some_and(|m| m.team_id == project.team) + }) + .and_then(|t| t.iter().find(|m| m.is_owner)) + .map(|m| ContentItemOwner { + id: m.user.id.clone(), + name: m.user.username.clone(), + avatar_url: m.user.avatar_url.clone(), + owner_type: OwnerType::User, + }) + }; + + // Determine project type from the project's project_type field + let project_type = match project.project_type.as_str() { + "mod" => ProjectType::Mod, + "resourcepack" => ProjectType::ResourcePack, + "shader" => ProjectType::ShaderPack, + "datapack" => ProjectType::DataPack, + _ => ProjectType::Mod, + }; + + Some(ContentItem { + file_name: version + .and_then(|v| v.files.first()) + .map(|f| f.filename.clone()) + .unwrap_or_else(|| { + format!( + "{}.jar", + project.slug.as_deref().unwrap_or(&project.id) + ) + }), + file_path: String::new(), // Not applicable for modpack content + hash: String::new(), // Not applicable for modpack content + size: version + .and_then(|v| v.files.first()) + .map(|f| f.size as u64) + .unwrap_or(0), + enabled: true, // Modpack content is always enabled + project_type, + project: Some(ContentItemProject { + id: project.id.clone(), + slug: project.slug.clone(), + title: project.title.clone(), + icon_url: project.icon_url.clone(), + }), + version: version.map(|v| ContentItemVersion { + id: v.id.clone(), + version_number: v.version_number.clone(), + file_name: v + .files + .first() + .map(|f| f.filename.clone()) + .unwrap_or_default(), + date_published: Some(v.date_published.to_rfc3339()), + }), + owner, + has_update: false, // Modpack content updates are managed by the modpack + update_version_id: None, + date_added: None, // Not applicable for modpack content + }) + }) + .collect(); + + // Sort alphabetically by project title + items.sort_by(|a, b| { + let name_a = a + .project + .as_ref() + .map(|p| p.title.as_str()) + .unwrap_or(&a.file_name); + let name_b = b + .project + .as_ref() + .map(|p| p.title.as_str()) + .unwrap_or(&b.file_name); + name_a.to_lowercase().cmp(&name_b.to_lowercase()) + }); + + Ok(items) +} + +/// Gets SHA1 hashes of all files in a modpack version. +/// Checks cache first, falls back to downloading mrpack if not cached. +async fn get_modpack_file_hashes( + version_id: &str, + pool: &SqlitePool, + fetch_semaphore: &FetchSemaphore, +) -> crate::Result> { + if let Some(cached) = + CachedEntry::get_modpack_files(version_id, pool).await? + { + return Ok(cached.file_hashes.into_iter().collect()); + } + + tracing::debug!( + "Modpack files not cached, downloading mrpack for version {}", + version_id + ); + + let version = + CachedEntry::get_version(version_id, None, pool, fetch_semaphore) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Modpack version {version_id} not found" + )) + })?; + + let primary_file = version + .files + .iter() + .find(|f| f.primary) + .or_else(|| version.files.first()) + .ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "No files found for modpack version {version_id}" + )) + })?; + + let mrpack_bytes = fetch_mirrors( + &[&primary_file.url], + primary_file.hashes.get("sha1").map(|s| s.as_str()), + fetch_semaphore, + pool, + ) + .await?; + + let reader = Cursor::new(&mrpack_bytes); + let mut zip_reader = + ZipFileReader::with_tokio(reader).await.map_err(|_| { + crate::ErrorKind::InputError( + "Failed to read modpack zip".to_string(), + ) + })?; + + let manifest_idx = zip_reader + .file() + .entries() + .iter() + .position(|f| { + matches!(f.filename().as_str(), Ok("modrinth.index.json")) + }) + .ok_or_else(|| { + crate::ErrorKind::InputError( + "No modrinth.index.json found in mrpack".to_string(), + ) + })?; + + let mut manifest = String::new(); + let mut entry_reader = zip_reader.reader_with_entry(manifest_idx).await?; + entry_reader.read_to_string_checked(&mut manifest).await?; + + let pack: PackFormat = serde_json::from_str(&manifest)?; + + let mut hashes: Vec = pack + .files + .iter() + .filter_map(|f| f.hashes.get(&PackFileHash::Sha1).cloned()) + .collect(); + + // Also hash files from overrides folders (these aren't in modrinth.index.json) + let override_entries: Vec = zip_reader + .file() + .entries() + .iter() + .enumerate() + .filter_map(|(index, entry)| { + let filename = entry.filename().as_str().ok()?; + let is_override = (filename.starts_with("overrides/") + || filename.starts_with("client-overrides/") + || filename.starts_with("server-overrides/")) + && !filename.ends_with('/'); + is_override.then_some(index) + }) + .collect(); + + for index in override_entries { + let mut file_bytes = Vec::new(); + let mut entry_reader = zip_reader.reader_with_entry(index).await?; + entry_reader.read_to_end_checked(&mut file_bytes).await?; + + let hash = sha1_async(bytes::Bytes::from(file_bytes)).await?; + hashes.push(hash); + } + + CachedEntry::cache_modpack_files(version_id, hashes.clone(), pool).await?; + + Ok(hashes.into_iter().collect()) +} diff --git a/packages/app-lib/src/state/instances/mod.rs b/packages/app-lib/src/state/instances/mod.rs new file mode 100644 index 0000000000..931e32a6c1 --- /dev/null +++ b/packages/app-lib/src/state/instances/mod.rs @@ -0,0 +1,4 @@ +//! Instance-related modules for profile/instance management. + +mod content; +pub use self::content::*; diff --git a/packages/app-lib/src/state/legacy_converter.rs b/packages/app-lib/src/state/legacy_converter.rs index df043de35c..a5b9fdd136 100644 --- a/packages/app-lib/src/state/legacy_converter.rs +++ b/packages/app-lib/src/state/legacy_converter.rs @@ -622,7 +622,7 @@ impl From for Version { featured: value.featured, name: value.name, version_number: value.version_number, - changelog: value.changelog, + changelog: Some(value.changelog), changelog_url: value.changelog_url, date_published: value.date_published, downloads: value.downloads, diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs index 6c3a69126b..b44306f92e 100644 --- a/packages/app-lib/src/state/mod.rs +++ b/packages/app-lib/src/state/mod.rs @@ -13,6 +13,9 @@ pub use self::dirs::*; mod profiles; pub use self::profiles::*; +mod instances; +pub use self::instances::*; + mod settings; pub use self::settings::*; diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index 22569d9b31..2b41f3f18d 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -3,8 +3,6 @@ import type { FunctionalComponent, SVGAttributes } from 'vue' -export type IconComponent = FunctionalComponent - import _AffiliateIcon from './icons/affiliate.svg?component' import _AlignLeftIcon from './icons/align-left.svg?component' import _ArchiveIcon from './icons/archive.svg?component' @@ -15,6 +13,7 @@ import _ArrowDownLeftIcon from './icons/arrow-down-left.svg?component' import _ArrowLeftIcon from './icons/arrow-left.svg?component' import _ArrowLeftRightIcon from './icons/arrow-left-right.svg?component' import _ArrowUpIcon from './icons/arrow-up.svg?component' +import _ArrowUpDownIcon from './icons/arrow-up-down.svg?component' import _ArrowUpRightIcon from './icons/arrow-up-right.svg?component' import _AsteriskIcon from './icons/asterisk.svg?component' import _BadgeCheckIcon from './icons/badge-check.svg?component' @@ -47,6 +46,7 @@ import _ChevronDownIcon from './icons/chevron-down.svg?component' import _ChevronLeftIcon from './icons/chevron-left.svg?component' import _ChevronRightIcon from './icons/chevron-right.svg?component' import _ChevronUpIcon from './icons/chevron-up.svg?component' +import _CircleAlertIcon from './icons/circle-alert.svg?component' import _CircleUserIcon from './icons/circle-user.svg?component' import _ClearIcon from './icons/clear.svg?component' import _ClientIcon from './icons/client.svg?component' @@ -166,6 +166,8 @@ import _PickaxeIcon from './icons/pickaxe.svg?component' import _PlayIcon from './icons/play.svg?component' import _PlugIcon from './icons/plug.svg?component' import _PlusIcon from './icons/plus.svg?component' +import _PowerIcon from './icons/power.svg?component' +import _PowerOffIcon from './icons/power-off.svg?component' import _RadioButtonIcon from './icons/radio-button.svg?component' import _RadioButtonCheckedIcon from './icons/radio-button-checked.svg?component' import _ReceiptTextIcon from './icons/receipt-text.svg?component' @@ -295,6 +297,7 @@ import _TagLoaderVelocityIcon from './icons/tags/loaders/velocity.svg?component' import _TagLoaderWaterfallIcon from './icons/tags/loaders/waterfall.svg?component' import _TerminalSquareIcon from './icons/terminal-square.svg?component' import _TestIcon from './icons/test.svg?component' +import _TextCursorInputIcon from './icons/text-cursor-input.svg?component' import _TextQuoteIcon from './icons/text-quote.svg?component' import _TimerIcon from './icons/timer.svg?component' import _ToggleLeftIcon from './icons/toggle-left.svg?component' @@ -327,6 +330,8 @@ import _XCircleIcon from './icons/x-circle.svg?component' import _ZoomInIcon from './icons/zoom-in.svg?component' import _ZoomOutIcon from './icons/zoom-out.svg?component' +export type IconComponent = FunctionalComponent + export const AffiliateIcon = _AffiliateIcon export const AlignLeftIcon = _AlignLeftIcon export const ArchiveIcon = _ArchiveIcon @@ -337,6 +342,7 @@ export const ArrowDownLeftIcon = _ArrowDownLeftIcon export const ArrowLeftIcon = _ArrowLeftIcon export const ArrowLeftRightIcon = _ArrowLeftRightIcon export const ArrowUpIcon = _ArrowUpIcon +export const ArrowUpDownIcon = _ArrowUpDownIcon export const ArrowUpRightIcon = _ArrowUpRightIcon export const AsteriskIcon = _AsteriskIcon export const BadgeCheckIcon = _BadgeCheckIcon @@ -369,6 +375,7 @@ export const ChevronDownIcon = _ChevronDownIcon export const ChevronLeftIcon = _ChevronLeftIcon export const ChevronRightIcon = _ChevronRightIcon export const ChevronUpIcon = _ChevronUpIcon +export const CircleAlertIcon = _CircleAlertIcon export const CircleUserIcon = _CircleUserIcon export const ClearIcon = _ClearIcon export const ClientIcon = _ClientIcon @@ -488,6 +495,8 @@ export const PickaxeIcon = _PickaxeIcon export const PlayIcon = _PlayIcon export const PlugIcon = _PlugIcon export const PlusIcon = _PlusIcon +export const PowerIcon = _PowerIcon +export const PowerOffIcon = _PowerOffIcon export const RadioButtonIcon = _RadioButtonIcon export const RadioButtonCheckedIcon = _RadioButtonCheckedIcon export const ReceiptTextIcon = _ReceiptTextIcon @@ -617,6 +626,7 @@ export const TagLoaderVelocityIcon = _TagLoaderVelocityIcon export const TagLoaderWaterfallIcon = _TagLoaderWaterfallIcon export const TerminalSquareIcon = _TerminalSquareIcon export const TestIcon = _TestIcon +export const TextCursorInputIcon = _TextCursorInputIcon export const TextQuoteIcon = _TextQuoteIcon export const TimerIcon = _TimerIcon export const ToggleLeftIcon = _ToggleLeftIcon diff --git a/packages/assets/icons/arrow-up-down.svg b/packages/assets/icons/arrow-up-down.svg new file mode 100644 index 0000000000..0607f68e0b --- /dev/null +++ b/packages/assets/icons/arrow-up-down.svg @@ -0,0 +1 @@ + diff --git a/packages/assets/icons/circle-alert.svg b/packages/assets/icons/circle-alert.svg new file mode 100644 index 0000000000..5c87e85c4d --- /dev/null +++ b/packages/assets/icons/circle-alert.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/packages/assets/icons/power-off.svg b/packages/assets/icons/power-off.svg new file mode 100644 index 0000000000..b976bd39b2 --- /dev/null +++ b/packages/assets/icons/power-off.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/assets/icons/power.svg b/packages/assets/icons/power.svg new file mode 100644 index 0000000000..f15b1aeecb --- /dev/null +++ b/packages/assets/icons/power.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/packages/assets/icons/text-cursor-input.svg b/packages/assets/icons/text-cursor-input.svg new file mode 100644 index 0000000000..534a20a791 --- /dev/null +++ b/packages/assets/icons/text-cursor-input.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/packages/ui/src/components/base/FloatingActionBar.vue b/packages/ui/src/components/base/FloatingActionBar.vue index 3585aa9395..d5ee25e078 100644 --- a/packages/ui/src/components/base/FloatingActionBar.vue +++ b/packages/ui/src/components/base/FloatingActionBar.vue @@ -6,9 +6,9 @@ defineProps<{ - + @@ -18,6 +18,8 @@ defineProps<{ + + diff --git a/packages/ui/src/components/instances/ContentCardItem.vue b/packages/ui/src/components/instances/ContentCardItem.vue index 078166a704..5d113cce3f 100644 --- a/packages/ui/src/components/instances/ContentCardItem.vue +++ b/packages/ui/src/components/instances/ContentCardItem.vue @@ -1,6 +1,6 @@ - - - - + - - - {{ project.title }} - + + + + + {{ project.title }} + + + + + + + {{ owner.name }} + + + + + {{ version.version_number }} + + + + + + + + + - - - {{ owner.name }} + {{ + version.version_number.slice(0, Math.ceil(version.version_number.length / 2)) + }} + {{ + version.version_number.slice(Math.ceil(version.version_number.length / 2)) + }} - - - - - - {{ version.version_number }} - {{ truncateMiddle(version.file_name, MAX_FILENAME_LENGTH) }} + {{ + version.file_name.slice(0, Math.ceil(version.file_name.length / 2)) + }} + {{ + version.file_name.slice(Math.ceil(version.file_name.length / 2)) + }} - + @@ -169,10 +215,11 @@ function truncateMiddle(str: string, maxLength: number): string { :model-value="enabled" :disabled="disabled" small + class="mr-2 my-auto" @update:model-value="(val) => emit('update:enabled', val as boolean)" /> - + -import { computed, onMounted, onUnmounted, ref } from 'vue' +import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets' +import { computed, getCurrentInstance, toRef } from 'vue' import { useVIntl } from '../../composables/i18n' +import { useVirtualScroll } from '../../composables/virtual-scroll' import { commonMessages } from '../../utils/common-messages' import Checkbox from '../base/Checkbox.vue' import ContentCardItem from './ContentCardItem.vue' -import type { ContentCardTableItem } from './types' +import type { + ContentCardTableItem, + ContentCardTableSortColumn, + ContentCardTableSortDirection, +} from './types' const { formatMessage } = useVIntl() -const BUFFER_SIZE = 5 - interface Props { items: ContentCardTableItem[] showSelection?: boolean + sortable?: boolean + sortBy?: ContentCardTableSortColumn + sortDirection?: ContentCardTableSortDirection virtualized?: boolean + hideDelete?: boolean + flat?: boolean + isStuck?: boolean } const props = withDefaults(defineProps(), { showSelection: false, + sortable: false, + sortBy: undefined, + sortDirection: 'asc', virtualized: true, + hideDelete: false, + flat: false, + isStuck: false, }) const selectedIds = defineModel('selectedIds', { default: () => [] }) @@ -28,84 +44,43 @@ const emit = defineEmits<{ 'update:enabled': [id: string, value: boolean] delete: [id: string] update: [id: string] + sort: [column: ContentCardTableSortColumn, direction: ContentCardTableSortDirection] }>() -// Virtualization state -const listContainer = ref(null) -const scrollContainer = ref(null) -const scrollTop = ref(0) -const viewportHeight = ref(0) -const itemHeight = 74 - -const totalHeight = computed(() => props.items.length * itemHeight) - -// Find the nearest scrollable ancestor -function findScrollableAncestor(element: HTMLElement | null): HTMLElement | Window { - if (!element) return window - - let current: HTMLElement | null = element.parentElement - while (current) { - const style = getComputedStyle(current) - const overflowY = style.overflowY - const isScrollable = - (overflowY === 'auto' || overflowY === 'scroll') && - current.scrollHeight > current.clientHeight - - if (isScrollable) { - return current - } - current = current.parentElement - } - return window -} - -function getScrollTop(container: HTMLElement | Window): number { - if (container instanceof Window) { - return window.scrollY - } - return container.scrollTop -} - -function getViewportHeight(container: HTMLElement | Window): number { - if (container instanceof Window) { - return window.innerHeight - } - return container.clientHeight -} - -function getContainerOffset(listEl: HTMLElement, container: HTMLElement | Window): number { - if (container instanceof Window) { - return listEl.getBoundingClientRect().top + window.scrollY - } - // For element containers, get the offset relative to the scroll container - const listRect = listEl.getBoundingClientRect() - const containerRect = container.getBoundingClientRect() - return listRect.top - containerRect.top + container.scrollTop -} - -const visibleRange = computed(() => { - if (!props.virtualized) { - return { start: 0, end: props.items.length } - } - - if (!listContainer.value || !scrollContainer.value) return { start: 0, end: 0 } - - const containerOffset = getContainerOffset(listContainer.value, scrollContainer.value) - const relativeScrollTop = Math.max(0, scrollTop.value - containerOffset) - - const start = Math.floor(relativeScrollTop / itemHeight) - const visibleCount = Math.ceil(viewportHeight.value / itemHeight) +// Check if any actions are available +const instance = getCurrentInstance() +const hasDeleteListener = computed(() => typeof instance?.vnode.props?.onDelete === 'function') +const hasUpdateListener = computed(() => typeof instance?.vnode.props?.onUpdate === 'function') +const hasEnabledListener = computed( + () => typeof instance?.vnode.props?.['onUpdate:enabled'] === 'function', +) - return { - start: Math.max(0, start - BUFFER_SIZE), - end: Math.min(props.items.length, start + visibleCount + BUFFER_SIZE * 2), - } +const hasAnyActions = computed(() => { + // Check if there are listeners for actions + const hasListeners = + (hasDeleteListener.value && !props.hideDelete) || + hasUpdateListener.value || + hasEnabledListener.value + + // Check if any items have overflow options or updates + const hasItemActions = props.items.some( + (item) => + (item.overflowOptions && item.overflowOptions.length > 0) || + item.hasUpdate || + item.enabled !== undefined, + ) + + return hasListeners || hasItemActions }) -const visibleTop = computed(() => (props.virtualized ? visibleRange.value.start * itemHeight : 0)) - -const visibleItems = computed(() => - props.items.slice(visibleRange.value.start, visibleRange.value.end), +// Virtualization +const { listContainer, totalHeight, visibleRange, visibleTop, visibleItems } = useVirtualScroll( + toRef(props, 'items'), + { + itemHeight: 74, + bufferSize: 5, + enabled: toRef(props, 'virtualized'), + }, ) // Expose for perf monitoring @@ -114,34 +89,6 @@ defineExpose({ visibleItems, }) -function handleScroll() { - if (scrollContainer.value) { - scrollTop.value = getScrollTop(scrollContainer.value) - } -} - -function handleResize() { - if (scrollContainer.value) { - viewportHeight.value = getViewportHeight(scrollContainer.value) - } -} - -onMounted(() => { - scrollContainer.value = findScrollableAncestor(listContainer.value) - viewportHeight.value = getViewportHeight(scrollContainer.value) - scrollTop.value = getScrollTop(scrollContainer.value) - - scrollContainer.value.addEventListener('scroll', handleScroll, { passive: true }) - window.addEventListener('resize', handleResize, { passive: true }) -}) - -onUnmounted(() => { - if (scrollContainer.value) { - scrollContainer.value.removeEventListener('scroll', handleScroll) - } - window.removeEventListener('resize', handleResize) -}) - // Selection logic const allSelected = computed(() => { if (props.items.length === 0) return false @@ -153,7 +100,7 @@ const someSelected = computed(() => { }) function toggleSelectAll() { - if (allSelected.value) { + if (allSelected.value || someSelected.value) { selectedIds.value = [] } else { selectedIds.value = props.items.map((item) => item.id) @@ -173,35 +120,86 @@ function toggleItemSelection(itemId: string, selected: boolean) { function isItemSelected(itemId: string): boolean { return selectedIds.value.includes(itemId) } + +function handleSort(column: ContentCardTableSortColumn) { + if (!props.sortable) return + + const newDirection: ContentCardTableSortDirection = + props.sortBy === column && props.sortDirection === 'asc' ? 'desc' : 'asc' + + emit('sort', column, newDirection) +} - + - - - - {{ formatMessage(commonMessages.projectLabel) }} - + + + + + {{ formatMessage(commonMessages.projectLabel) }} + + + + {{ + formatMessage(commonMessages.projectLabel) + }} + - - {{ formatMessage(commonMessages.versionLabel) }} - + + + {{ formatMessage(commonMessages.versionLabel) }} + + + + {{ + formatMessage(commonMessages.versionLabel) + }} + - + {{ formatMessage(commonMessages.actionsLabel) }} @@ -211,7 +209,8 @@ function isItemSelected(itemId: string): boolean { @@ -222,17 +221,20 @@ function isItemSelected(itemId: string): boolean { :project="item.project" :project-link="item.projectLink" :version="item.version" + :version-link="item.versionLink" :owner="item.owner" :enabled="item.enabled" :has-update="item.hasUpdate" :overflow-options="item.overflowOptions" :disabled="item.disabled" :show-checkbox="showSelection" + :hide-delete="hideDelete" + :hide-actions="!hasAnyActions" :selected="isItemSelected(item.id)" :class="[ (visibleRange.start + idx) % 2 === 1 ? 'bg-surface-1.5' : 'bg-surface-2', - 'border-t border-solid border-[1px] border-surface-3', - visibleRange.start + idx === items.length - 1 ? 'rounded-b-[20px] !border-none' : '', + 'border-0 border-t border-solid border-surface-4', + visibleRange.start + idx === items.length - 1 && !flat ? 'rounded-b-[20px]' : '', ]" @update:selected="(val) => toggleItemSelection(item.id, val ?? false)" @update:enabled="(val) => emit('update:enabled', item.id, val)" @@ -249,7 +251,7 @@ function isItemSelected(itemId: string): boolean { - + toggleItemSelection(item.id, val ?? false)" @update:enabled="(val) => emit('update:enabled', item.id, val)" @@ -283,7 +288,11 @@ function isItemSelected(itemId: string): boolean { - + {{ formatMessage(commonMessages.noItemsLabel) }} diff --git a/packages/ui/src/components/instances/ContentModpackCard.vue b/packages/ui/src/components/instances/ContentModpackCard.vue index 36373f6202..9dad5b9a7d 100644 --- a/packages/ui/src/components/instances/ContentModpackCard.vue +++ b/packages/ui/src/components/instances/ContentModpackCard.vue @@ -1,10 +1,13 @@ + + + + + + Loading content... + + + + + Failed to load content + {{ ctx.error.value.message }} + + Retry + + + + + + + + + + Additional content + + + + + + + + + + + + + + + + + + Browse content + + + + + + Upload files + + + + + + + + + + + All + + + {{ option.label }} + + + + + + + {{ sortLabels[sortMode] }} + + + + + + + + + Update all + + + + + + + Refresh + + + + + + + + No content found. + + + + + + + + + + + {{ ctx.modpack.value ? 'No extra content added' : 'Your instance is empty' }} + + + {{ + ctx.modpack.value + ? 'You can add content on top of a modpack!' + : 'Add some content to bring it to life!' + }} + + + + + + + Upload files + + + + + + Browse content + + + + + + + + + + + + {{ selectedItems.length }} + {{ ctx.contentTypeLabel.value }}{{ selectedItems.length === 1 ? '' : 's' }} selected + + + + Clear + + + + + + + + Update + + + + + + + Share + + + + Project names + + + + File names + + + + Project links + + + + Markdown links + + + + + + + + Enable + + + + + + Disable + + + + + + + + + Delete + + + + + + + + + {{ + bulkOperation === 'enable' + ? 'Enabling' + : bulkOperation === 'disable' + ? 'Disabling' + : bulkOperation === 'update' + ? 'Updating' + : 'Deleting' + }} + content... ({{ bulkProgress }}/{{ bulkTotal }}) + + + + + + + + + + + + + diff --git a/packages/ui/src/components/instances/index.ts b/packages/ui/src/components/instances/index.ts index fd997a6865..f6438482ee 100644 --- a/packages/ui/src/components/instances/index.ts +++ b/packages/ui/src/components/instances/index.ts @@ -1,16 +1,25 @@ export { default as ContentCardItem } from './ContentCardItem.vue' export { default as ContentCardTable } from './ContentCardTable.vue' +export { default as ContentPageLayout } from './ContentPageLayout.vue' +export { default as ConfirmBulkUpdateModal } from './modals/ConfirmBulkUpdateModal.vue' +export { default as ConfirmDeletionModal } from './modals/ConfirmDeletionModal.vue' +export { default as ConfirmUnlinkModal } from './modals/ConfirmUnlinkModal.vue' /** * @deprecated Use `ContentCardTable` with `ContentCardItem` instead. * This alias is kept for backwards compatibility and will be removed in a future version. */ +export type { ContentModpackData } from '../../providers/content-manager' export { default as ContentCard } from './ContentCardItem.vue' export { default as ContentModpackCard } from './ContentModpackCard.vue' -// export { default as ContentUpdaterModal } from './modals/ContentUpdaterModal.vue' +export { default as ContentUpdaterModal } from './modals/ContentUpdaterModal.vue' +export { default as ModpackContentModal } from './modals/ModpackContentModal.vue' export type { ContentCardProject, ContentCardTableItem, + ContentCardTableSortColumn, + ContentCardTableSortDirection, ContentCardVersion, + ContentItem, ContentModpackCardCategory, ContentModpackCardProject, ContentModpackCardVersion, diff --git a/packages/ui/src/components/instances/modals/ConfirmBulkUpdateModal.vue b/packages/ui/src/components/instances/modals/ConfirmBulkUpdateModal.vue new file mode 100644 index 0000000000..3cfe39ec73 --- /dev/null +++ b/packages/ui/src/components/instances/modals/ConfirmBulkUpdateModal.vue @@ -0,0 +1,63 @@ + + + + + Are you sure you want to update {{ count }} project{{ count === 1 ? '' : 's' }} to their + latest compatible version? + + + Updating can break your instance. New incompatibilities may be introduced. It's recommended + to update content one-by-one. Proceed with caution and back up your instance first. + + + + + + + + Cancel + + + + + + Update {{ count }} project{{ count === 1 ? '' : 's' }} + + + + + + + + diff --git a/packages/ui/src/components/instances/modals/ConfirmDeletionModal.vue b/packages/ui/src/components/instances/modals/ConfirmDeletionModal.vue new file mode 100644 index 0000000000..06e96b1e34 --- /dev/null +++ b/packages/ui/src/components/instances/modals/ConfirmDeletionModal.vue @@ -0,0 +1,69 @@ + + + + + Removing content from your instance may corrupt worlds where they were used. Are you sure + you want to continue? + + + This action is irreversable. Consider making a backup of your worlds before + continuing. + + + + + + + + Cancel + + + + + + Delete {{ count }} {{ itemType }}{{ count === 1 ? '' : 's' }} + + + + + + + + diff --git a/packages/ui/src/components/instances/modals/ConfirmUnlinkModal.vue b/packages/ui/src/components/instances/modals/ConfirmUnlinkModal.vue new file mode 100644 index 0000000000..54f17c8aff --- /dev/null +++ b/packages/ui/src/components/instances/modals/ConfirmUnlinkModal.vue @@ -0,0 +1,59 @@ + + + + + Are you sure you want to unlink the modpack from your instance? Modpack content will remain + installed, but will no longer be managed. + + + This action is irreversable. You will need to create a new instance with the modpack if you + change your mind. + + + + + + + + Cancel + + + + + + Unlink + + + + + + + + diff --git a/packages/ui/src/components/instances/modals/ContentUpdaterModal.vue b/packages/ui/src/components/instances/modals/ContentUpdaterModal.vue index a2cc4bd79c..4b7d10861e 100644 --- a/packages/ui/src/components/instances/modals/ContentUpdaterModal.vue +++ b/packages/ui/src/components/instances/modals/ContentUpdaterModal.vue @@ -1,12 +1,22 @@ - + {{ - header ?? formatMessage(messages.updateVersionHeader) + header ?? + formatMessage( + isModpack ? messages.switchModpackVersionHeader : messages.updateVersionHeader, + ) }} - + @@ -21,42 +31,70 @@ - - - - - v{{ version.version_number }} - - - {{ getBadgeLabel(version) }} - - - - - - {{ formatMessage(messages.noVersionsFound) }} + + + {{ + formatMessage(messages.loadingVersions) + }} + + + + + + + + {{ version.version_number }} + + + + + {{ getBadgeLabel(version) }} + + + + + + {{ formatMessage(messages.noVersionsFound) }} + + - - - + + - + - v{{ selectedVersion.version_number }} + {{ selectedVersion.version_number }} - {{ getBadgeLabel(selectedVersion) }} + {{ capitalizeString(selectedVersion.version_type) }} @@ -116,7 +154,16 @@ + + {{ + formatMessage(messages.loadingChangelog) + }} + + @@ -136,34 +183,35 @@ - - {{ - formatMessage(isApp ? messages.updateWarningApp : messages.updateWarningWeb) - }} - - - - - - - {{ formatMessage(commonMessages.cancelButton) }} - - - - - - {{ - formatMessage(isDowngrade ? messages.downgradeToVersion : messages.updateToVersion, { - version: selectedVersion?.version_number ?? '...', - }) - }} - - + + + {{ + formatMessage(isApp ? messages.updateWarningApp : messages.updateWarningWeb) + }} + + + + + + {{ formatMessage(commonMessages.cancelButton) }} + + + + + + {{ + formatMessage(isDowngrade ? messages.downgradeToVersion : messages.updateToVersion, { + version: selectedVersion?.version_number ?? '...', + }) + }} + + + @@ -171,22 +219,25 @@ + + + + + + + {{ formatMessage(messages.header) }} + + + + + + + + + + + + + + + + + + + + {{ formatMessage(messages.allFilter) }} + + + {{ option.label }} + + + + + + + + + + + {{ formatMessage(messages.loading) }} + + + + + + {{ formatMessage(messages.emptyTitle) }} + + {{ formatMessage(messages.emptyDescription) }} + + + + + {{ formatMessage(messages.noResults) }} + + + + + + + + + + + + + + + + + + + + {{ count }} {{ formatProjectType(type as string) }}{{ count !== 1 ? 's' : '' }} + + + + + + + + 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 @@ + + + + + + Unlinking will merge all mods, resource packs and or plugins associated with this modpack + with your own mods. + + + + We will automatically create a backup + + if you continue. + + + + + + + + + Cancel + + + + + + Unlink modpack + + + + + + + + diff --git a/packages/ui/src/components/instances/types.ts b/packages/ui/src/components/instances/types.ts index 95a44d7e98..31ab71862e 100644 --- a/packages/ui/src/components/instances/types.ts +++ b/packages/ui/src/components/instances/types.ts @@ -10,6 +10,7 @@ export type ContentCardProject = Pick< export type ContentCardVersion = Pick & { file_name: string + date_published?: string } export interface ContentOwner { @@ -25,6 +26,7 @@ export interface ContentCardTableItem { project: ContentCardProject projectLink?: string | RouteLocationRaw version?: ContentCardVersion + versionLink?: string | RouteLocationRaw owner?: ContentOwner enabled?: boolean disabled?: boolean @@ -32,6 +34,24 @@ export interface ContentCardTableItem { overflowOptions?: OverflowMenuOption[] } +export type ContentCardTableSortColumn = 'project' | 'version' +export type ContentCardTableSortDirection = 'asc' | 'desc' + +/** Content item returned from the app backend API - maps to ContentCardTableItem for display */ +export interface ContentItem extends Omit< + ContentCardTableItem, + 'id' | 'projectLink' | 'disabled' | 'overflowOptions' +> { + file_name: string + file_path?: string + hash?: string + size?: number + project_type: string + has_update: boolean + update_version_id: string | null + date_added?: string +} + export type ContentModpackCardProject = Pick< Labrinth.Projects.v2.Project, 'id' | 'slug' | 'title' | 'icon_url' | 'description' | 'downloads' | 'followers' diff --git a/packages/ui/src/components/modal/InstallToPlayModal.vue b/packages/ui/src/components/modal/InstallToPlayModal.vue new file mode 100644 index 0000000000..972e396be2 --- /dev/null +++ b/packages/ui/src/components/modal/InstallToPlayModal.vue @@ -0,0 +1,111 @@ + + + + + This server requires modded content to play. Accept to install the needed files from + Modrinth. + + + + + + {{ sharedBy.name }} + shared this instance with you today. + + + + + Shared instance + + + + {{ project.title }} + + {{ loaderDisplay }} {{ project.game_versions?.[0] }} + ยท {{ modCount }} mods + + + + + + + + + + + + Decline + + + + + + Accept + + + + + + + + diff --git a/packages/ui/src/components/modal/NewModal.vue b/packages/ui/src/components/modal/NewModal.vue index 1a7a6e5df7..c3d6596cd7 100644 --- a/packages/ui/src/components/modal/NewModal.vue +++ b/packages/ui/src/components/modal/NewModal.vue @@ -14,7 +14,7 @@ 'modal-overlay', { shown: visible, - noblur: props.noblur, + noblur: effectiveNoblur, }, computedFade, ]" @@ -38,7 +38,7 @@ > - + {{ header }} @@ -125,9 +125,14 @@ import { XIcon } from '@modrinth/assets' import { computed, ref } from 'vue' +import { useModalStack } from '../../composables/modal-stack' import { useScrollIndicator } from '../../composables/scroll-indicator' +import { injectModalBehavior } from '../../providers' import ButtonStyled from '../base/ButtonStyled.vue' +const modalBehavior = injectModalBehavior(null) +const { push: pushModal, pop: popModal, isTopmost: isTopmostModal } = useModalStack() + const props = withDefaults( defineProps<{ noblur?: boolean @@ -156,6 +161,7 @@ const props = withDefaults( }>(), { type: true, + noblur: undefined, closable: true, danger: false, fade: undefined, @@ -177,6 +183,8 @@ const props = withDefaults( }, ) +const effectiveNoblur = computed(() => props.noblur ?? modalBehavior?.noblur.value ?? false) + const computedFade = computed(() => { if (props.fade) return props.fade if (props.danger) return 'danger' @@ -191,7 +199,9 @@ const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(scr function show(event?: MouseEvent) { props.onShow?.() + modalBehavior?.onShow?.() open.value = true + pushModal() document.body.style.overflow = 'hidden' window.addEventListener('mousedown', updateMousePosition) @@ -210,7 +220,9 @@ function show(event?: MouseEvent) { function hide() { if (props.disableClose) return props.onHide?.() + modalBehavior?.onHide?.() visible.value = false + popModal() document.body.style.overflow = '' window.removeEventListener('mousedown', updateMousePosition) window.removeEventListener('keydown', handleKeyDown) @@ -235,6 +247,7 @@ function updateMousePosition(event: { clientX: number; clientY: number }) { function handleKeyDown(event: KeyboardEvent) { if (props.closeOnEsc && event.key === 'Escape' && props.closable) { + if (!isTopmostModal()) return hide() mouseX.value = window.innerWidth / 2 mouseY.value = window.innerHeight / 2 diff --git a/packages/ui/src/components/modal/index.ts b/packages/ui/src/components/modal/index.ts index 89c4cf0266..eb28a6569b 100644 --- a/packages/ui/src/components/modal/index.ts +++ b/packages/ui/src/components/modal/index.ts @@ -1,4 +1,5 @@ export { default as ConfirmModal } from './ConfirmModal.vue' +export { default as InstallToPlayModal } from './InstallToPlayModal.vue' export { default as Modal } from './Modal.vue' export { default as NewModal } from './NewModal.vue' export { default as ShareModal } from './ShareModal.vue' diff --git a/packages/ui/src/components/project/card/ProjectCardStats.vue b/packages/ui/src/components/project/card/ProjectCardStats.vue index 546c0a1bfc..861f6953f9 100644 --- a/packages/ui/src/components/project/card/ProjectCardStats.vue +++ b/packages/ui/src/components/project/card/ProjectCardStats.vue @@ -1,7 +1,7 @@ diff --git a/apps/frontend/src/components/ui/servers/ContentVersionFilter.vue b/packages/ui/src/components/servers/content/ContentVersionFilter.vue similarity index 88% rename from apps/frontend/src/components/ui/servers/ContentVersionFilter.vue rename to packages/ui/src/components/servers/content/ContentVersionFilter.vue index f59b4549d5..e60cb5f9bb 100644 --- a/apps/frontend/src/components/ui/servers/ContentVersionFilter.vue +++ b/packages/ui/src/components/servers/content/ContentVersionFilter.vue @@ -57,13 +57,12 @@ - - diff --git a/packages/ui/src/components/servers/files/explorer/FileVirtualList.vue b/packages/ui/src/components/servers/files/explorer/FileVirtualList.vue index 085e496629..dc7c3b53cd 100644 --- a/packages/ui/src/components/servers/files/explorer/FileVirtualList.vue +++ b/packages/ui/src/components/servers/files/explorer/FileVirtualList.vue @@ -47,8 +47,9 @@ diff --git a/packages/ui/src/composables/content/bulk-operations.ts b/packages/ui/src/composables/content/bulk-operations.ts new file mode 100644 index 0000000000..9b422930bc --- /dev/null +++ b/packages/ui/src/composables/content/bulk-operations.ts @@ -0,0 +1,64 @@ +import { onBeforeUnmount, ref, watch } from 'vue' +import { onBeforeRouteLeave } from 'vue-router' + +export type BulkOperationType = 'enable' | 'disable' | 'delete' | 'update' + +export function useBulkOperation() { + const isBulkOperating = ref(false) + const bulkProgress = ref(0) + const bulkTotal = ref(0) + const bulkOperation = ref(null) + + async function runBulk( + operation: BulkOperationType, + items: T[], + fn: (item: T) => Promise, + delayMs = 250, + ) { + isBulkOperating.value = true + bulkOperation.value = operation + bulkTotal.value = items.length + bulkProgress.value = 0 + + for (const item of items) { + await fn(item) + bulkProgress.value++ + if (delayMs > 0 && bulkProgress.value < items.length) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + } + + isBulkOperating.value = false + bulkOperation.value = null + } + + function handleBeforeUnload(e: BeforeUnloadEvent) { + if (isBulkOperating.value) { + e.preventDefault() + return '' + } + } + + if (typeof window !== 'undefined') { + watch(isBulkOperating, (operating) => { + if (operating) { + window.addEventListener('beforeunload', handleBeforeUnload) + } else { + window.removeEventListener('beforeunload', handleBeforeUnload) + } + }) + + onBeforeUnmount(() => { + window.removeEventListener('beforeunload', handleBeforeUnload) + }) + } + + onBeforeRouteLeave(() => { + if (isBulkOperating.value) { + return window.confirm('A bulk operation is in progress. Are you sure you want to leave?') + } + return true + }) + + return { isBulkOperating, bulkProgress, bulkTotal, bulkOperation, runBulk } +} diff --git a/packages/ui/src/composables/content/changing-items.ts b/packages/ui/src/composables/content/changing-items.ts new file mode 100644 index 0000000000..33f4ca966f --- /dev/null +++ b/packages/ui/src/composables/content/changing-items.ts @@ -0,0 +1,21 @@ +import { ref } from 'vue' + +export function useChangingItems() { + const changingItems = ref(new Set()) + + function markChanging(id: string) { + changingItems.value = new Set([...changingItems.value, id]) + } + + function unmarkChanging(id: string) { + const next = new Set(changingItems.value) + next.delete(id) + changingItems.value = next + } + + function isChanging(id: string): boolean { + return changingItems.value.has(id) + } + + return { changingItems, markChanging, unmarkChanging, isChanging } +} diff --git a/packages/ui/src/composables/content/content-filtering.ts b/packages/ui/src/composables/content/content-filtering.ts new file mode 100644 index 0000000000..cdf0ade12d --- /dev/null +++ b/packages/ui/src/composables/content/content-filtering.ts @@ -0,0 +1,79 @@ +import type { Ref } from 'vue' +import { computed, ref, watch } from 'vue' + +import type { ContentItem } from '../../components/instances/types' + +export interface FilterOption { + id: string + label: string +} + +export interface ContentFilterConfig { + showTypeFilters?: boolean + showUpdateFilter?: boolean + isPackLocked?: Ref + formatProjectType?: (type: string) => string +} + +export function useContentFilters(items: Ref, config?: ContentFilterConfig) { + const selectedFilters = ref([]) + + const filterOptions = computed(() => { + const options: FilterOption[] = [] + + if (config?.showTypeFilters) { + const frequency = items.value.reduce((map: Record, item) => { + map[item.project_type] = (map[item.project_type] || 0) + 1 + return map + }, {}) + const types = Object.keys(frequency).sort((a, b) => frequency[b] - frequency[a]) + for (const type of types) { + const label = config.formatProjectType ? config.formatProjectType(type) + 's' : type + 's' + options.push({ id: type, label }) + } + } + + if ( + config?.showUpdateFilter && + !config?.isPackLocked?.value && + items.value.some((m) => m.has_update) + ) { + options.push({ id: 'updates', label: 'Updates' }) + } + + if (items.value.some((m) => !m.enabled)) { + options.push({ id: 'disabled', label: 'Disabled' }) + } + + return options + }) + + watch(filterOptions, () => { + selectedFilters.value = selectedFilters.value.filter((f) => + filterOptions.value.some((opt) => opt.id === f), + ) + }) + + function toggleFilter(filterId: string) { + const index = selectedFilters.value.indexOf(filterId) + if (index === -1) { + selectedFilters.value.push(filterId) + } else { + selectedFilters.value.splice(index, 1) + } + } + + function applyFilters(source: ContentItem[]): ContentItem[] { + if (selectedFilters.value.length === 0) return source + return source.filter((item) => { + for (const filter of selectedFilters.value) { + if (filter === 'updates' && item.has_update) return true + if (filter === 'disabled' && !item.enabled) return true + if (item.project_type === filter) return true + } + return false + }) + } + + return { selectedFilters, filterOptions, toggleFilter, applyFilters } +} diff --git a/packages/ui/src/composables/content/content-search.ts b/packages/ui/src/composables/content/content-search.ts new file mode 100644 index 0000000000..ab8cf2f459 --- /dev/null +++ b/packages/ui/src/composables/content/content-search.ts @@ -0,0 +1,25 @@ +import Fuse from 'fuse.js' +import type { Ref } from 'vue' +import { ref, watchSyncEffect } from 'vue' + +export function useContentSearch( + items: Ref, + keys: string[], + options?: { threshold?: number; distance?: number }, +) { + const searchQuery = ref('') + const fuse = new Fuse([], { + keys, + threshold: options?.threshold ?? 0.4, + distance: options?.distance ?? 100, + }) + watchSyncEffect(() => fuse.setCollection(items.value)) + + function search(source: T[]): T[] { + const query = searchQuery.value.trim() + if (!query) return source + return fuse.search(query).map(({ item }) => item) + } + + return { searchQuery, search } +} diff --git a/packages/ui/src/composables/content/content-selection.ts b/packages/ui/src/composables/content/content-selection.ts new file mode 100644 index 0000000000..9c7f8edc5c --- /dev/null +++ b/packages/ui/src/composables/content/content-selection.ts @@ -0,0 +1,25 @@ +import type { Ref } from 'vue' +import { computed, ref } from 'vue' + +import type { ContentItem } from '../../components/instances/types' + +export function useContentSelection( + items: Ref, + getItemId: (item: ContentItem) => string, +) { + const selectedIds = ref([]) + + const selectedItems = computed(() => + items.value.filter((item) => selectedIds.value.includes(getItemId(item))), + ) + + function clearSelection() { + selectedIds.value = [] + } + + function removeFromSelection(id: string) { + selectedIds.value = selectedIds.value.filter((i) => i !== id) + } + + return { selectedIds, selectedItems, clearSelection, removeFromSelection } +} diff --git a/packages/ui/src/composables/content/index.ts b/packages/ui/src/composables/content/index.ts new file mode 100644 index 0000000000..2ec80e72cb --- /dev/null +++ b/packages/ui/src/composables/content/index.ts @@ -0,0 +1,5 @@ +export * from './bulk-operations' +export * from './changing-items' +export * from './content-filtering' +export * from './content-search' +export * from './content-selection' diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts index 0336fd3405..1f137e157d 100644 --- a/packages/ui/src/composables/index.ts +++ b/packages/ui/src/composables/index.ts @@ -1,5 +1,8 @@ +export * from './content' export * from './debug-logger' export * from './dynamic-font-size' export * from './how-ago' export * from './i18n' export * from './scroll-indicator' +export * from './sticky-observer' +export * from './virtual-scroll' diff --git a/packages/ui/src/composables/modal-stack.ts b/packages/ui/src/composables/modal-stack.ts new file mode 100644 index 0000000000..bcb9716859 --- /dev/null +++ b/packages/ui/src/composables/modal-stack.ts @@ -0,0 +1,20 @@ +const stack: symbol[] = [] + +export function useModalStack() { + const id = Symbol() + + function push() { + stack.push(id) + } + + function pop() { + const idx = stack.indexOf(id) + if (idx !== -1) stack.splice(idx, 1) + } + + function isTopmost() { + return stack.length === 0 || stack[stack.length - 1] === id + } + + return { push, pop, isTopmost } +} diff --git a/packages/ui/src/composables/sticky-observer.ts b/packages/ui/src/composables/sticky-observer.ts new file mode 100644 index 0000000000..fa0702e5da --- /dev/null +++ b/packages/ui/src/composables/sticky-observer.ts @@ -0,0 +1,46 @@ +import type { Ref } from 'vue' +import { onUnmounted, ref, watch } from 'vue' + +/** + * Observes when a target element becomes "stuck" (i.e. its natural position has scrolled out of view). + * Injects a zero-height sentinel element before the target and uses IntersectionObserver to detect + * when the sentinel leaves the viewport. + */ +export function useStickyObserver(target: Ref) { + const isStuck = ref(false) + let sentinel: HTMLElement | null = null + let observer: IntersectionObserver | null = null + + watch( + target, + (el) => { + observer?.disconnect() + sentinel?.remove() + observer = null + sentinel = null + + if (el) { + sentinel = document.createElement('div') + sentinel.style.height = '0' + sentinel.setAttribute('aria-hidden', 'true') + el.parentElement?.insertBefore(sentinel, el) + + observer = new IntersectionObserver( + ([entry]) => { + isStuck.value = !entry.isIntersecting + }, + { threshold: 0, rootMargin: '-1px 0px 0px 0px' }, + ) + observer.observe(sentinel) + } + }, + { flush: 'post' }, + ) + + onUnmounted(() => { + observer?.disconnect() + sentinel?.remove() + }) + + return { isStuck } +} diff --git a/packages/ui/src/composables/virtual-scroll.ts b/packages/ui/src/composables/virtual-scroll.ts new file mode 100644 index 0000000000..59d38907aa --- /dev/null +++ b/packages/ui/src/composables/virtual-scroll.ts @@ -0,0 +1,129 @@ +import type { Ref } from 'vue' +import { computed, ref, watchEffect } from 'vue' + +export interface VirtualScrollOptions { + itemHeight: number + bufferSize?: number + enabled?: Ref + onNearEnd?: () => void + nearEndThreshold?: number +} + +export function useVirtualScroll(items: Ref, options: VirtualScrollOptions) { + const { itemHeight, bufferSize = 5, enabled, onNearEnd, nearEndThreshold = 0.2 } = options + + const listContainer = ref(null) + const scrollContainer = ref(null) + const scrollTop = ref(0) + const viewportHeight = ref(0) + + const totalHeight = computed(() => items.value.length * itemHeight) + + function findScrollableAncestor(element: HTMLElement | null): HTMLElement | Window { + if (!element) return window + + let current: HTMLElement | null = element.parentElement + while (current) { + const { overflowY } = getComputedStyle(current) + if (overflowY === 'auto' || overflowY === 'scroll') { + return current + } + current = current.parentElement + } + return window + } + + function getScrollTop(container: HTMLElement | Window): number { + return container instanceof Window ? window.scrollY : container.scrollTop + } + + function getViewportHeight(container: HTMLElement | Window): number { + return container instanceof Window ? window.innerHeight : container.clientHeight + } + + function getContainerOffset(listEl: HTMLElement, container: HTMLElement | Window): number { + if (container instanceof Window) { + return listEl.getBoundingClientRect().top + window.scrollY + } + const listRect = listEl.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + return listRect.top - containerRect.top + container.scrollTop + } + + const visibleRange = computed(() => { + if (enabled && !enabled.value) { + return { start: 0, end: items.value.length } + } + + if (!listContainer.value || !scrollContainer.value) return { start: 0, end: 0 } + + const containerOffset = getContainerOffset(listContainer.value, scrollContainer.value) + const relativeScrollTop = Math.max(0, scrollTop.value - containerOffset) + + const start = Math.floor(relativeScrollTop / itemHeight) + const visibleCount = Math.ceil(viewportHeight.value / itemHeight) + + return { + start: Math.max(0, start - bufferSize), + end: Math.min(items.value.length, start + visibleCount + bufferSize * 2), + } + }) + + const visibleTop = computed(() => + enabled && !enabled.value ? 0 : visibleRange.value.start * itemHeight, + ) + + const visibleItems = computed(() => + items.value.slice(visibleRange.value.start, visibleRange.value.end), + ) + + function checkNearEnd() { + if (!onNearEnd || !listContainer.value) return + + const containerBottom = listContainer.value.getBoundingClientRect().bottom + const remainingScroll = containerBottom - window.innerHeight + + if (remainingScroll < viewportHeight.value * nearEndThreshold) { + onNearEnd() + } + } + + function handleScroll() { + if (scrollContainer.value) { + scrollTop.value = getScrollTop(scrollContainer.value) + } + checkNearEnd() + } + + function handleResize() { + if (scrollContainer.value) { + viewportHeight.value = getViewportHeight(scrollContainer.value) + } + } + + watchEffect((onCleanup) => { + const listEl = listContainer.value + if (!listEl) return + + const container = findScrollableAncestor(listEl) + scrollContainer.value = container + viewportHeight.value = getViewportHeight(container) + scrollTop.value = getScrollTop(container) + + container.addEventListener('scroll', handleScroll, { passive: true }) + window.addEventListener('resize', handleResize, { passive: true }) + + onCleanup(() => { + container.removeEventListener('scroll', handleScroll) + window.removeEventListener('resize', handleResize) + }) + }) + + return { + listContainer, + totalHeight, + visibleRange, + visibleTop, + visibleItems, + } +} diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json index 37502c870d..f8a082a2f1 100644 --- a/packages/ui/src/locales/en-US/index.json +++ b/packages/ui/src/locales/en-US/index.json @@ -143,6 +143,9 @@ "button.stop": { "defaultMessage": "Stop" }, + "button.switch-version": { + "defaultMessage": "Switch version" + }, "button.unfollow": { "defaultMessage": "Unfollow" }, @@ -317,6 +320,33 @@ "instances.modpack-card.unlink": { "defaultMessage": "Unlink modpack" }, + "instances.modpack-content-modal.back-button": { + "defaultMessage": "Back" + }, + "instances.modpack-content-modal.copy-link": { + "defaultMessage": "Copy link" + }, + "instances.modpack-content-modal.empty-description": { + "defaultMessage": "This modpack does not include any additional content." + }, + "instances.modpack-content-modal.empty-title": { + "defaultMessage": "No content found" + }, + "instances.modpack-content-modal.filter-all": { + "defaultMessage": "All" + }, + "instances.modpack-content-modal.header": { + "defaultMessage": "Modpack content" + }, + "instances.modpack-content-modal.loading": { + "defaultMessage": "Loading content..." + }, + "instances.modpack-content-modal.no-results": { + "defaultMessage": "No projects match your search." + }, + "instances.modpack-content-modal.search-placeholder": { + "defaultMessage": "Search {count} projects" + }, "instances.updater-modal.badge.current": { "defaultMessage": "Current" }, @@ -324,14 +354,23 @@ "defaultMessage": "Incompatible" }, "instances.updater-modal.downgrade-to": { - "defaultMessage": "Downgrade to v{version}" + "defaultMessage": "Downgrade to {version}" }, "instances.updater-modal.header": { "defaultMessage": "Update version" }, + "instances.updater-modal.header-modpack": { + "defaultMessage": "Switch modpack version" + }, "instances.updater-modal.hide-incompatible": { "defaultMessage": "Hide incompatible" }, + "instances.updater-modal.loading-changelog": { + "defaultMessage": "Loading changelog..." + }, + "instances.updater-modal.loading-versions": { + "defaultMessage": "Loading versions..." + }, "instances.updater-modal.no-changelog": { "defaultMessage": "No changelog provided for this version." }, @@ -348,13 +387,13 @@ "defaultMessage": "Show incompatible" }, "instances.updater-modal.update-to": { - "defaultMessage": "Update to v{version}" + "defaultMessage": "Update to {version}" }, - "instances.updater-modal.warning.app": { - "defaultMessage": "We can't guarantee updates are safe for your instance. Review the changelog for all intermediate versions and consider a backup." + "instances.updater-modal.warning-app": { + "defaultMessage": "Updating can break your instance. Review version changelogs and back up first." }, - "instances.updater-modal.warning.web": { - "defaultMessage": "We can't guarantee updates are safe for your worlds. Review the changelog for all intermediate versions and consider a backup." + "instances.updater-modal.warning-web": { + "defaultMessage": "Updating can break your world. Review version changelogs and back up first." }, "label.actions": { "defaultMessage": "Actions" 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..e7934d16c7 --- /dev/null +++ b/packages/ui/src/pages/hosting/manage/content.vue @@ -0,0 +1,289 @@ + + + + + + + + + diff --git a/packages/ui/src/pages/hosting/manage/files.vue b/packages/ui/src/pages/hosting/manage/files.vue index 2fdbe27a02..81762e2680 100644 --- a/packages/ui/src/pages/hosting/manage/files.vue +++ b/packages/ui/src/pages/hosting/manage/files.vue @@ -45,8 +45,8 @@ /> - @@ -289,6 +289,7 @@ import { FileRenameItemModal, FileUploadConflictModal, } from '../../../components/servers/files/modals' +import { useStickyObserver } from '../../../composables/sticky-observer' import { injectModrinthClient, injectModrinthServerContext, @@ -404,9 +405,9 @@ const uploadDropdownRef = ref>() const VAceEditor = ref() -const labelBarSentinel = ref() -const isLabelBarStuck = ref(false) -let labelBarObserver: IntersectionObserver | null = null +const fileUploadRef = ref>() +const fileUploadEl = computed(() => fileUploadRef.value?.$el as HTMLElement | null) +const { isStuck: isLabelBarStuck } = useStickyObserver(fileUploadEl) const viewFilter = ref('all') @@ -1120,7 +1121,6 @@ onMounted(async () => { onUnmounted(() => { document.removeEventListener('keydown', onKeydown) - labelBarObserver?.disconnect() }) type QueuedOpWithState = Archon.Websocket.v0.QueuedFilesystemOp & { state: 'queued' } @@ -1146,29 +1146,6 @@ watch( }, ) -watch( - labelBarSentinel, - (newSentinel) => { - // Disconnect any existing observer - if (labelBarObserver) { - labelBarObserver.disconnect() - labelBarObserver = null - } - - // Create new observer when sentinel becomes available - if (newSentinel) { - labelBarObserver = new IntersectionObserver( - ([entry]) => { - isLabelBarStuck.value = !entry.isIntersecting - }, - { threshold: 0 }, - ) - labelBarObserver.observe(newSentinel) - } - }, - { flush: 'post' }, -) - watch( () => route.query, (newQuery, oldQuery) => { 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/providers/content-manager.ts b/packages/ui/src/providers/content-manager.ts new file mode 100644 index 0000000000..63a807bedd --- /dev/null +++ b/packages/ui/src/providers/content-manager.ts @@ -0,0 +1,74 @@ +import type { ComputedRef, Ref } from 'vue' +import type { RouteLocationRaw } from 'vue-router' + +import type { Option as OverflowMenuOption } from '../components/base/OverflowMenu.vue' +import type { + ContentCardTableItem, + ContentItem, + ContentModpackCardCategory, + ContentModpackCardProject, + ContentModpackCardVersion, + ContentOwner, +} from '../components/instances/types' +import { createContext } from '.' + +export interface ContentModpackData { + project: ContentModpackCardProject + projectLink?: string | RouteLocationRaw + version?: ContentModpackCardVersion + versionLink?: string | RouteLocationRaw + owner?: ContentOwner + categories: ContentModpackCardCategory[] + hasUpdate: boolean + disabled?: boolean + disabledText?: string +} + +export interface ContentManagerContext { + // Data + items: Ref | ComputedRef + loading: Ref + error: Ref + + // Modpack + modpack: Ref | ComputedRef + isPackLocked: Ref | ComputedRef + + // Guards + isBusy: Ref | ComputedRef + + // Identity & labelling + getItemId: (item: ContentItem) => string + contentTypeLabel: Ref | ComputedRef + + // Core actions + toggleEnabled: (item: ContentItem) => Promise + deleteItem: (item: ContentItem) => Promise + refresh: () => Promise + browse: () => void + uploadFiles: () => void + + // Update support (optional per-platform) + hasUpdateSupport: boolean + updateItem?: (id: string) => void + bulkUpdateItem?: (item: ContentItem) => Promise + + // Modpack actions (optional) + updateModpack?: () => void + viewModpackContent?: () => void + unlinkModpack?: () => void + + // Per-item overflow menu (optional) + getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[] + + // Share support (optional โ when undefined, share button becomes hidden entirely) + shareItems?: (items: ContentItem[], format: 'names' | 'file-names' | 'urls' | 'markdown') => void + + // Table item mapping (link generation differs per platform) + mapToTableItem: (item: ContentItem) => ContentCardTableItem +} + +export const [injectContentManager, provideContentManager] = createContext( + 'ContentPageLayout', + 'contentManagerContext', +) diff --git a/packages/ui/src/providers/index.ts b/packages/ui/src/providers/index.ts index 8281575551..0b08f9b292 100644 --- a/packages/ui/src/providers/index.ts +++ b/packages/ui/src/providers/index.ts @@ -79,7 +79,9 @@ export function createContext( } export * from './api-client' +export * from './content-manager' export * from './i18n' +export * from './modal-behavior' export * from './page-context' export * from './project-page' export * from './server-context' diff --git a/packages/ui/src/providers/modal-behavior.ts b/packages/ui/src/providers/modal-behavior.ts new file mode 100644 index 0000000000..2f842aeed1 --- /dev/null +++ b/packages/ui/src/providers/modal-behavior.ts @@ -0,0 +1,14 @@ +import type { Ref } from 'vue' + +import { createContext } from './index' + +export interface ModalBehavior { + noblur: Ref + onShow?: () => void + onHide?: () => void +} + +export const [injectModalBehavior, provideModalBehavior] = createContext( + 'root', + 'modalBehavior', +) diff --git a/packages/ui/src/stories/instances/ContentCardTable.stories.ts b/packages/ui/src/stories/instances/ContentCardTable.stories.ts index e1dc89a3ef..ac18dea81b 100644 --- a/packages/ui/src/stories/instances/ContentCardTable.stories.ts +++ b/packages/ui/src/stories/instances/ContentCardTable.stories.ts @@ -7,101 +7,239 @@ import ButtonStyled from '../../components/base/ButtonStyled.vue' import ContentCardTable from '../../components/instances/ContentCardTable.vue' import type { ContentCardTableItem } from '../../components/instances/types' -// ============================================ -// Fixtures -// ============================================ - -const fixtures = { - sodium: { +// Sample data +const sodiumItem: ContentCardTableItem = { + id: 'AANobbMI', + project: { id: 'AANobbMI', - project: { - id: 'AANobbMI', - slug: 'sodium', - title: 'Sodium', - icon_url: - 'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp', - }, - version: { - id: '59wygFUQ', - version_number: 'mc1.21.11-0.8.2-fabric', - file_name: 'sodium-fabric-0.8.2+mc1.21.11.jar', - }, - owner: { - id: 'DzLrfrbK', - name: 'IMS', - avatar_url: 'https://avatars3.githubusercontent.com/u/31803019?v=4', - type: 'user' as const, - }, - enabled: true, + slug: 'sodium', + title: 'Sodium', + icon_url: + 'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp', }, - modMenu: { + version: { + id: '59wygFUQ', + version_number: 'mc1.21.11-0.8.2-fabric', + file_name: 'sodium-fabric-0.8.2+mc1.21.11.jar', + }, + owner: { + id: 'DzLrfrbK', + name: 'IMS', + avatar_url: 'https://avatars3.githubusercontent.com/u/31803019?v=4', + type: 'user', + }, + enabled: true, +} + +const modMenuItem: ContentCardTableItem = { + id: 'mOgUt4GM', + project: { id: 'mOgUt4GM', - project: { - id: 'mOgUt4GM', - slug: 'modmenu', - title: 'Mod Menu', - icon_url: - 'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png', - }, - version: { - id: 'QuU0ciaR', - version_number: '16.0.0', - file_name: 'modmenu-16.0.0.jar', - }, - owner: { id: 'u2', name: 'Prospector', type: 'user' as const }, - enabled: true, + slug: 'modmenu', + title: 'Mod Menu', + icon_url: 'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png', + }, + version: { + id: 'QuU0ciaR', + version_number: '16.0.0', + file_name: 'modmenu-16.0.0.jar', + }, + owner: { + id: 'u2', + name: 'Prospector', + type: 'user', }, - fabricApi: { + enabled: true, +} + +const fabricApiItem: ContentCardTableItem = { + id: 'P7dR8mSH', + project: { id: 'P7dR8mSH', - project: { - id: 'P7dR8mSH', - slug: 'fabric-api', - title: 'Fabric API', - icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png', - }, - version: { - id: 'Lwa1Q6e4', - version_number: '0.141.3+26.1', - file_name: 'fabric-api-0.141.3+26.1.jar', - }, - owner: { - id: 'BZoBsPo6', - name: 'FabricMC', - avatar_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png', - type: 'organization' as const, - }, - enabled: false, + slug: 'fabric-api', + title: 'Fabric API', + icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png', + }, + version: { + id: 'Lwa1Q6e4', + version_number: '0.141.3+26.1', + file_name: 'fabric-api-0.141.3+26.1.jar', + }, + owner: { + id: 'BZoBsPo6', + name: 'FabricMC', + avatar_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png', + type: 'organization', + }, + enabled: false, +} + +const emfItem: ContentCardTableItem = { + id: 'emf123', + project: { + id: 'emf123', + slug: 'entity-model-features', + title: '[EMF] Entity Model Features', + icon_url: + 'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp', + }, + version: { + id: 'v1', + version_number: '2.4.1', + file_name: 'Entity_model_features_fabric_1.21.1-2.4.1.jar', }, -} satisfies Record + owner: { + id: 'u1', + name: 'Traben', + type: 'user', + }, + enabled: true, +} -const defaultItems: ContentCardTableItem[] = [fixtures.sodium, fixtures.modMenu, fixtures.fabricApi] +const etfItem: ContentCardTableItem = { + id: 'etf456', + project: { + id: 'etf456', + slug: 'entity-texture-features', + title: '[ETF] Entity Texture Features', + icon_url: 'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png', + }, + version: { + id: 'v2', + version_number: '6.2.9', + file_name: 'Entity_texture_features_fabric_1.21.1-6.2.9.jar', + }, + owner: { + id: 'u1', + name: 'Traben', + type: 'user', + }, + enabled: true, +} + +const importedModItem: ContentCardTableItem = { + id: 'imported123', + project: { + id: 'imported123', + slug: 'import-mod', + title: 'Import mod', + icon_url: undefined, + }, + version: { + id: 'v3', + version_number: 'Unknown', + file_name: 'Entity_texture_features_fabric_1.21.1-6.2.9.jar', + }, + enabled: false, +} -/** Generate n items for stress testing */ -function generateItems(count: number): ContentCardTableItem[] { - return Array.from({ length: count }, (_, i) => ({ - ...fixtures.sodium, - id: `item-${i}`, - project: { ...fixtures.sodium.project, title: `Mod ${i + 1}` }, - version: { ...fixtures.sodium.version!, version_number: `1.0.${i}` }, - enabled: i % 3 !== 0, - })) +// Edge case items +const longNameItem: ContentCardTableItem = { + id: 'long-name', + project: { + id: 'long-name', + slug: 'very-long-project-name', + title: '[EMF] Entity Model Features - The Ultimate Entity Rendering Mod for Minecraft', + icon_url: sodiumItem.project.icon_url, + }, + version: { + id: 'v1', + version_number: '2.4.1-beta.15+mc1.21.1-fabric-loader0.16.0', + file_name: 'Entity_model_features_fabric_1.21.1-2.4.1-beta.15+mc1.21.1-fabric-loader0.16.0.jar', + }, + owner: { + id: 'u1', + name: 'Traben', + type: 'user', + }, + enabled: true, } -// ============================================ -// Meta -// ============================================ +const noOwnerAvatarItem: ContentCardTableItem = { + id: 'no-avatar', + project: { + id: 'no-avatar', + slug: 'no-avatar-mod', + title: 'Mod Without Owner Avatar', + icon_url: modMenuItem.project.icon_url, + }, + version: { + id: 'v1', + version_number: '1.0.0', + file_name: 'no-avatar-mod-1.0.0.jar', + }, + owner: { + id: 'u1', + name: 'Anonymous User', + avatar_url: undefined, + type: 'user', + }, + enabled: true, +} + +const updateAvailableItem: ContentCardTableItem = { + id: 'update-available', + project: { + id: 'update-available', + slug: 'outdated-mod', + title: 'Outdated Mod', + icon_url: fabricApiItem.project.icon_url, + }, + version: { + id: 'v1', + version_number: '1.0.0', + file_name: 'outdated-mod-1.0.0.jar', + }, + owner: fabricApiItem.owner, + enabled: true, + hasUpdate: true, +} + +const sampleItems: ContentCardTableItem[] = [sodiumItem, modMenuItem, fabricApiItem] + +const figmaDesignItems: ContentCardTableItem[] = [emfItem, etfItem, importedModItem] + +// Comprehensive items showing all possible states +const allStatesItems: ContentCardTableItem[] = [ + { ...sodiumItem, enabled: true, hasUpdate: false }, + { ...modMenuItem, enabled: true, hasUpdate: true }, + { ...fabricApiItem, enabled: false }, + longNameItem, + importedModItem, + noOwnerAvatarItem, + updateAvailableItem, + { ...emfItem, disabled: true, enabled: false }, +] const meta = { title: 'Instances/ContentCardTable', component: ContentCardTable, - parameters: { layout: 'padded' }, - args: { - items: defaultItems, - showSelection: false, - virtualized: true, - 'onUpdate:enabled': fn(), - onDelete: fn(), - onUpdate: fn(), + parameters: { + layout: 'padded', + }, + argTypes: { + items: { + control: 'object', + description: 'Array of items to display in the table', + }, + showSelection: { + control: 'boolean', + description: 'Show checkboxes for selection', + }, + sortable: { + control: 'boolean', + description: 'Enable column sorting', + }, + sortBy: { + control: 'select', + options: ['project', 'version', undefined], + description: 'Current sort column', + }, + sortDirection: { + control: 'select', + options: ['asc', 'desc'], + description: 'Sort direction', + }, }, } satisfies Meta @@ -109,217 +247,255 @@ export default meta type Story = StoryObj // ============================================ -// Core Stories +// Basic Stories // ============================================ -export const Default: Story = {} +export const Default: Story = { + args: { + items: sampleItems, + }, +} -export const WithSelection: Story = { - args: { showSelection: true }, - render: (args) => ({ +/** + * Comprehensive story showing all possible item states in one view: + * - Normal enabled item + * - Item with update available + * - Disabled toggle (enabled: false) + * - Long project name and version (truncation) + * - No project icon + * - No owner avatar + * - Item with hasUpdate flag + * - Fully disabled item (disabled: true) + */ +export const AllStates: Story = { + render: () => ({ components: { ContentCardTable }, setup() { - const selectedIds = ref([]) - return { args, selectedIds } + const items = ref(allStatesItems) + return { items } }, - template: ` + template: /*html*/ ` - - - Selected: {{ selectedIds.length }} - ({{ selectedIds.join(', ') }}) - + + This story demonstrates all possible item states: + + Sodium - Normal enabled item + Mod Menu - Has update available (green button) + Fabric API - Toggle off (enabled: false) + EMF - Long name/version (truncation) + Import mod - No project icon + No avatar - Owner without avatar + Outdated Mod - hasUpdate flag + ETF - Fully disabled (disabled: true, grayed out) + + + console.log('Toggle', id, val)" + @delete="(id) => console.log('Delete', id)" + @update="(id) => console.log('Update', id)" + /> `, }), } -export const Empty: Story = { - args: { items: [] }, -} - -export const EmptyCustom: Story = { - args: { items: [] }, - render: (args) => ({ - components: { ContentCardTable, ButtonStyled }, - setup: () => ({ args }), - template: ` - - - - No mods installed - Browse mods - - - +/** + * Shows items with update available - displays green download button + */ +export const WithUpdatesAvailable: Story = { + render: () => ({ + components: { ContentCardTable }, + setup() { + const items: ContentCardTableItem[] = [ + { ...sodiumItem, hasUpdate: true }, + { ...modMenuItem, hasUpdate: true }, + { ...fabricApiItem, hasUpdate: false }, + ] + return { items } + }, + template: /*html*/ ` + console.log('Toggle', id, val)" + @delete="(id) => console.log('Delete', id)" + @update="(id) => console.log('Update clicked', id)" + /> `, }), } -// ============================================ -// States -// ============================================ - -/** All possible item states in one view */ -export const AllStates: Story = { +/** + * Shows difference between user and organization owners + */ +export const UserVsOrganizationOwners: Story = { args: { - showSelection: true, items: [ - { ...fixtures.sodium, enabled: true }, - { ...fixtures.modMenu, hasUpdate: true }, - { ...fixtures.fabricApi, enabled: false }, - { - id: 'long-name', - project: { - id: 'long-name', - slug: 'long-mod', - title: '[EMF] Entity Model Features - The Ultimate Entity Rendering Mod', - icon_url: fixtures.sodium.project.icon_url, - }, - version: { - id: 'v1', - version_number: '2.4.1-beta.15+mc1.21.1-fabric-loader0.16.0', - file_name: 'emf-2.4.1-beta.15.jar', - }, - owner: { id: 'u1', name: 'Traben', type: 'user' }, - enabled: true, - }, - { - id: 'no-icon', - project: { id: 'no-icon', slug: 'imported', title: 'Imported mod', icon_url: undefined }, - version: { id: 'v1', version_number: 'Unknown', file_name: 'imported.jar' }, - enabled: true, - }, - { - id: 'no-avatar', - project: { - id: 'no-avatar', - slug: 'no-avatar', - title: 'No Owner Avatar', - icon_url: fixtures.modMenu.project.icon_url, - }, - version: { id: 'v1', version_number: '1.0.0', file_name: 'mod.jar' }, - owner: { id: 'u1', name: 'Anonymous', avatar_url: undefined, type: 'user' }, - enabled: true, - }, - { ...fixtures.modMenu, id: 'disabled-item', disabled: true, enabled: false }, + { ...sodiumItem }, // User owner (circular avatar) + { ...fabricApiItem }, // Organization owner (rounded + icon) ], }, - parameters: { - docs: { - description: { - story: - 'Demonstrates: enabled, update available, disabled toggle, long names (truncation), missing icon, missing avatar, fully disabled item.', - }, - }, - }, } -/** Items with update badges */ -export const WithUpdates: Story = { +/** + * Edge cases: long names, missing icons, missing avatars + */ +export const EdgeCases: Story = { args: { - items: [ - { ...fixtures.sodium, hasUpdate: true }, - { ...fixtures.modMenu, hasUpdate: true }, - fixtures.fabricApi, - ], + items: [longNameItem, importedModItem, noOwnerAvatarItem], }, } -/** Per-item disabled state (e.g., during async operations) */ -export const ItemsDisabled: Story = { +export const FigmaDesign: Story = { args: { + items: figmaDesignItems, showSelection: true, - items: [ - fixtures.sodium, - { ...fixtures.modMenu, disabled: true }, - { ...fixtures.fabricApi, disabled: true }, - ], }, - parameters: { - docs: { - description: { story: 'Items with `disabled: true` have all interactions disabled.' }, + render: (args) => ({ + components: { ContentCardTable }, + setup() { + const selectedIds = ref([emfItem.id, etfItem.id]) + return { args, selectedIds } }, - }, + template: /*html*/ ` + console.log('Toggle', id, val)" + @delete="(id) => console.log('Delete', id)" + /> + `, + }), } -// ============================================ -// Slots -// ============================================ +export const WithSelection: Story = { + args: { + items: sampleItems, + showSelection: true, + }, + render: (args) => ({ + components: { ContentCardTable }, + setup() { + const selectedIds = ref([]) + return { args, selectedIds } + }, + template: /*html*/ ` + + console.log('Toggle', id, val)" + @delete="(id) => console.log('Delete', id)" + /> + + Selected: {{ selectedIds.length }} items + ({{ selectedIds.join(', ') }}) + + + `, + }), +} -export const CustomButtons: Story = { - args: { showSelection: true }, +export const WithSorting: Story = { + args: { + items: sampleItems, + sortable: true, + sortBy: 'project', + sortDirection: 'asc', + }, render: (args) => ({ - components: { ContentCardTable, ButtonStyled, EyeIcon, FolderOpenIcon, DownloadIcon }, - setup: () => ({ args }), - template: ` - - - - - - - - - - - - - - - + components: { ContentCardTable }, + setup() { + const sortBy = ref<'project' | 'version' | undefined>(args.sortBy) + const sortDirection = ref<'asc' | 'desc'>(args.sortDirection || 'asc') + + const handleSort = (column: 'project' | 'version', direction: 'asc' | 'desc') => { + sortBy.value = column + sortDirection.value = direction + console.log('Sort:', column, direction) + } + + return { args, sortBy, sortDirection, handleSort } + }, + template: /*html*/ ` + + + + Sorted by: {{ sortBy || 'none' }} ({{ sortDirection }}) + + `, }), } -export const WithOverflowMenu: Story = { +export const WithSelectionAndSorting: Story = { args: { + items: sampleItems, showSelection: true, - items: [ - { - ...fixtures.sodium, - overflowOptions: [ - { id: 'view', action: () => console.log('View') }, - { id: 'folder', action: () => console.log('Folder') }, - { divider: true }, - { id: 'remove', action: () => console.log('Remove'), color: 'red' as const }, - ], - }, - { - ...fixtures.modMenu, - overflowOptions: [ - { id: 'view', action: () => console.log('View') }, - { divider: true }, - { id: 'remove', action: () => console.log('Remove'), color: 'red' as const }, - ], - }, - ], + sortable: true, + sortBy: 'project', + sortDirection: 'asc', }, render: (args) => ({ components: { ContentCardTable }, - setup: () => ({ args }), - template: ` - - View on Modrinth - Open folder - Remove - + setup() { + const selectedIds = ref([]) + const sortBy = ref<'project' | 'version' | undefined>(args.sortBy) + const sortDirection = ref<'asc' | 'desc'>(args.sortDirection || 'asc') + + const handleSort = (column: 'project' | 'version', direction: 'asc' | 'desc') => { + sortBy.value = column + sortDirection.value = direction + } + + return { args, selectedIds, sortBy, sortDirection, handleSort } + }, + template: /*html*/ ` + console.log('Toggle', id, val)" + @delete="(id) => console.log('Delete', id)" + /> `, }), } // ============================================ -// Interactive +// Action Stories // ============================================ -export const Interactive: Story = { - args: { showSelection: true }, - render: (args) => ({ +export const WithActions: Story = { + args: { + items: sampleItems, + showSelection: true, + 'onUpdate:enabled': fn(), + onDelete: fn(), + onUpdate: fn(), + }, +} + +export const InteractiveActions: Story = { + render: () => ({ components: { ContentCardTable }, setup() { - const items = ref( - defaultItems.map((item) => ({ ...item, enabled: item.id !== fixtures.fabricApi.id })), - ) + const items = ref([ + { ...sodiumItem, enabled: true }, + { ...modMenuItem, enabled: true }, + { ...fabricApiItem, enabled: false }, + ]) const selectedIds = ref([]) const handleToggle = (id: string, value: boolean) => { @@ -332,136 +508,340 @@ export const Interactive: Story = { selectedIds.value = selectedIds.value.filter((i) => i !== id) } - return { args, items, selectedIds, handleToggle, handleDelete } + const handleUpdate = (id: string) => { + console.log('Update available clicked for:', id) + } + + return { items, selectedIds, handleToggle, handleDelete, handleUpdate } }, - template: ` + template: /*html*/ ` - - Items: {{ items.length }} ยท Selected: {{ selectedIds.length }} - + + Items: {{ items.length }} + Selected: {{ selectedIds.length }} + `, }), } -export const BulkActions: Story = { +// ============================================ +// Slot Stories +// ============================================ + +export const WithCustomItemButtons: Story = { render: () => ({ - components: { ContentCardTable, ButtonStyled }, + components: { ContentCardTable, ButtonStyled, EyeIcon, FolderOpenIcon, DownloadIcon }, setup() { - const items = ref( - defaultItems.map((item, i) => ({ ...item, enabled: i !== 2 })), - ) - const selectedIds = ref([]) + return { items: sampleItems } + }, + template: /*html*/ ` + console.log('Toggle', id, val)" + @delete="(id) => console.log('Delete', id)" + > + + + + + + + + + + + + + + + + + + + + + `, + }), +} - const setEnabled = (value: boolean) => { - items.value.forEach((item) => { - if (selectedIds.value.includes(item.id)) item.enabled = value - }) - } +export const WithEmptyState: Story = { + args: { + items: [], + }, +} - const deleteSelected = () => { - items.value = items.value.filter((item) => !selectedIds.value.includes(item.id)) - selectedIds.value = [] - } +export const WithCustomEmptyState: Story = { + render: () => ({ + components: { ContentCardTable, ButtonStyled }, + template: /*html*/ ` + + + + No mods installed + + Browse mods + + + + + `, + }), +} - const handleToggle = (id: string, value: boolean) => { - const item = items.value.find((i) => i.id === id) - if (item) item.enabled = value - } +// ============================================ +// State Stories +// ============================================ - return { items, selectedIds, setEnabled, deleteSelected, handleToggle } +export const PerItemDisabled: Story = { + render: () => ({ + components: { ContentCardTable }, + setup() { + // Simulates items being modified (e.g., toggled, deleted) + const items: ContentCardTableItem[] = [ + { ...sodiumItem, enabled: true }, + { ...modMenuItem, enabled: true, disabled: true }, // Being modified + { ...fabricApiItem, enabled: false, disabled: true }, // Being modified + ] + return { items } }, - template: ` + template: /*html*/ ` - - {{ selectedIds.length }} selected - - - Enable - - - Disable - - - Delete - - - + + Items with disabled: true have all interactions disabled (simulating items being modified). + console.log('Toggle', id, val)" + @delete="(id) => console.log('Delete', id)" /> `, }), } -// ============================================ -// Performance -// ============================================ - -export const Virtualization: Story = { - parameters: { - docs: { - description: { - story: - '2000 items with virtualization. Toggle to compare DOM node count. Virtualized should render ~20-30 nodes vs 2000.', - }, - }, +export const SingleItem: Story = { + args: { + items: [sodiumItem], + showSelection: true, }, +} + +export const ManyItems: Story = { render: () => ({ components: { ContentCardTable }, setup() { - const items = ref(generateItems(2000)) + const items = ref( + Array.from({ length: 2000 }, (_, i) => ({ + ...sodiumItem, + id: `item-${i}`, + project: { + ...sodiumItem.project, + title: `Mod ${i + 1}`, + }, + version: { + ...sodiumItem.version!, + version_number: `1.0.${i}`, + }, + enabled: i % 3 !== 0, + })), + ) const selectedIds = ref([]) const virtualized = ref(true) const tableRef = ref | null>(null) + + // Perf monitoring const domNodes = ref(0) - let raf: number + let animationId: number - const updateNodeCount = () => { + const updatePerf = () => { + // Count ContentCardItem elements (they have h-20 class) if (tableRef.value?.$el) { - domNodes.value = (tableRef.value.$el as HTMLElement).querySelectorAll( - '[data-content-card-item]', - ).length + const container = tableRef.value.$el as HTMLElement + domNodes.value = container.querySelectorAll('.h-20').length } - raf = requestAnimationFrame(updateNodeCount) + animationId = requestAnimationFrame(updatePerf) } onMounted(() => { - raf = requestAnimationFrame(updateNodeCount) + animationId = requestAnimationFrame(updatePerf) + }) + + onUnmounted(() => { + cancelAnimationFrame(animationId) }) - onUnmounted(() => cancelAnimationFrame(raf)) - return { items, selectedIds, virtualized, tableRef, domNodes } + return { + items, + selectedIds, + virtualized, + tableRef, + domNodes, + } }, - template: ` + template: /*html*/ ` + - - Virtualization + + Enable Virtualization - - DOM: {{ domNodes }} - / {{ items.length }} - + + + + Performance + + Total Items: + {{ items.length }} + DOM Nodes: + {{ domNodes }} + Mode: + {{ virtualized ? 'Virtual' : 'Full DOM' }} + + + console.log('Toggle', id, val)" + @delete="(id) => console.log('Delete', id)" + /> + + `, + }), +} + +// ============================================ +// With Overflow Menu +// ============================================ + +export const WithOverflowMenu: Story = { + render: () => ({ + components: { ContentCardTable }, + setup() { + const items: ContentCardTableItem[] = [ + { + ...sodiumItem, + overflowOptions: [ + { id: 'view', action: () => console.log('View sodium') }, + { id: 'folder', action: () => console.log('Open folder') }, + { divider: true }, + { id: 'remove', action: () => console.log('Remove'), color: 'red' as const }, + ], + }, + { + ...modMenuItem, + overflowOptions: [ + { id: 'view', action: () => console.log('View modmenu') }, + { divider: true }, + { id: 'remove', action: () => console.log('Remove'), color: 'red' as const }, + ], + }, + ] + + return { items } + }, + template: /*html*/ ` + console.log('Toggle', id, val)" + @delete="(id) => console.log('Delete', id)" + > + View on Modrinth + Open folder + Remove + + `, + }), +} + +// ============================================ +// Bulk Actions Demo +// ============================================ + +export const BulkActionsDemo: Story = { + render: () => ({ + components: { ContentCardTable, ButtonStyled }, + setup() { + const items = ref([ + { ...sodiumItem, enabled: true }, + { ...modMenuItem, enabled: true }, + { ...fabricApiItem, enabled: false }, + { ...emfItem, enabled: true }, + { ...etfItem, enabled: true }, + ]) + const selectedIds = ref([]) + + const enableSelected = () => { + items.value.forEach((item) => { + if (selectedIds.value.includes(item.id)) { + item.enabled = true + } + }) + } + + const disableSelected = () => { + items.value.forEach((item) => { + if (selectedIds.value.includes(item.id)) { + item.enabled = false + } + }) + } + + const deleteSelected = () => { + items.value = items.value.filter((item) => !selectedIds.value.includes(item.id)) + selectedIds.value = [] + } + + const handleToggle = (id: string, value: boolean) => { + const item = items.value.find((i) => i.id === id) + if (item) item.enabled = value + } + + return { items, selectedIds, enableSelected, disableSelected, deleteSelected, handleToggle } + }, + template: /*html*/ ` + + + {{ selectedIds.length }} selected + + + Enable + + + Disable + + + Delete + + + + console.log('Delete', id)" /> `, diff --git a/packages/ui/src/stories/instances/ModpackContentModal.stories.ts b/packages/ui/src/stories/instances/ModpackContentModal.stories.ts new file mode 100644 index 0000000000..d778225539 --- /dev/null +++ b/packages/ui/src/stories/instances/ModpackContentModal.stories.ts @@ -0,0 +1,618 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import ButtonStyled from '../../components/base/ButtonStyled.vue' +import ModpackContentModal from '../../components/instances/modals/ModpackContentModal.vue' +import type { ContentItem } from '../../components/instances/types' + +// Sample modpack content items (representing mods included in a modpack) +const sodiumItem: ContentItem = { + file_name: 'sodium-fabric-0.8.2+mc1.21.1.jar', + file_path: '', + hash: '', + size: 1024000, + enabled: true, + project_type: 'mod', + project: { + id: 'AANobbMI', + slug: 'sodium', + title: 'Sodium', + icon_url: + 'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp', + }, + version: { + id: '59wygFUQ', + version_number: 'mc1.21.1-0.8.2-fabric', + file_name: 'sodium-fabric-0.8.2+mc1.21.1.jar', + }, + owner: { + id: 'DzLrfrbK', + name: 'IMS', + avatar_url: 'https://avatars3.githubusercontent.com/u/31803019?v=4', + type: 'user', + }, + has_update: false, + update_version_id: null, +} + +const lithiumItem: ContentItem = { + file_name: 'lithium-fabric-0.14.3+mc1.21.1.jar', + file_path: '', + hash: '', + size: 512000, + enabled: true, + project_type: 'mod', + project: { + id: 'gvQqBUqZ', + slug: 'lithium', + title: 'Lithium', + icon_url: + 'https://cdn.modrinth.com/data/gvQqBUqZ/d6a1873d52b7d1c82b9a8d9b1889c9c1a29ae92d_96.webp', + }, + version: { + id: 'abc123', + version_number: 'mc1.21.1-0.14.3', + file_name: 'lithium-fabric-0.14.3+mc1.21.1.jar', + }, + owner: { + id: 'DzLrfrbK', + name: 'IMS', + avatar_url: 'https://avatars3.githubusercontent.com/u/31803019?v=4', + type: 'user', + }, + has_update: false, + update_version_id: null, +} + +const fabricApiItem: ContentItem = { + file_name: 'fabric-api-0.141.3+26.1.jar', + file_path: '', + hash: '', + size: 2048000, + enabled: true, + project_type: 'mod', + project: { + id: 'P7dR8mSH', + slug: 'fabric-api', + title: 'Fabric API', + icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png', + }, + version: { + id: 'Lwa1Q6e4', + version_number: '0.141.3+26.1', + file_name: 'fabric-api-0.141.3+26.1.jar', + }, + owner: { + id: 'BZoBsPo6', + name: 'FabricMC', + avatar_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png', + type: 'organization', + }, + has_update: false, + update_version_id: null, +} + +const modMenuItem: ContentItem = { + file_name: 'modmenu-16.0.0.jar', + file_path: '', + hash: '', + size: 256000, + enabled: true, + project_type: 'mod', + project: { + id: 'mOgUt4GM', + slug: 'modmenu', + title: 'Mod Menu', + icon_url: 'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png', + }, + version: { + id: 'QuU0ciaR', + version_number: '16.0.0', + file_name: 'modmenu-16.0.0.jar', + }, + owner: { + id: 'u2', + name: 'Prospector', + type: 'user', + }, + has_update: false, + update_version_id: null, +} + +const irisItem: ContentItem = { + file_name: 'iris-1.8.0+mc1.21.1.jar', + file_path: '', + hash: '', + size: 1536000, + enabled: true, + project_type: 'mod', + project: { + id: 'YL57xq9U', + slug: 'iris', + title: 'Iris Shaders', + icon_url: 'https://cdn.modrinth.com/data/YL57xq9U/icon.png', + }, + version: { + id: 'iris123', + version_number: '1.8.0+mc1.21.1', + file_name: 'iris-1.8.0+mc1.21.1.jar', + }, + owner: { + id: 'coderbot', + name: 'coderbot', + type: 'user', + }, + has_update: false, + update_version_id: null, +} + +const entityModelFeaturesItem: ContentItem = { + file_name: 'entity-model-features-fabric-2.4.1+mc1.21.1.jar', + file_path: '', + hash: '', + size: 768000, + enabled: true, + project_type: 'mod', + project: { + id: 'emf123', + slug: 'entity-model-features', + title: '[EMF] Entity Model Features', + icon_url: + 'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp', + }, + version: { + id: 'emfv1', + version_number: '2.4.1', + file_name: 'Entity_model_features_fabric_1.21.1-2.4.1.jar', + }, + owner: { + id: 'traben', + name: 'Traben', + type: 'user', + }, + has_update: false, + update_version_id: null, +} + +const entityTextureFeaturesItem: ContentItem = { + file_name: 'entity-texture-features-fabric-6.2.9+mc1.21.1.jar', + file_path: '', + hash: '', + size: 640000, + enabled: true, + project_type: 'mod', + project: { + id: 'etf456', + slug: 'entity-texture-features', + title: '[ETF] Entity Texture Features', + icon_url: 'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png', + }, + version: { + id: 'etfv1', + version_number: '6.2.9', + file_name: 'Entity_texture_features_fabric_1.21.1-6.2.9.jar', + }, + owner: { + id: 'traben', + name: 'Traben', + type: 'user', + }, + has_update: false, + update_version_id: null, +} + +// Shader pack item +const complementaryShaderItem: ContentItem = { + file_name: 'ComplementaryReimagined_r5.3.zip', + file_path: '', + hash: '', + size: 2048000, + enabled: true, + project_type: 'shader', + project: { + id: 'shader1', + slug: 'complementary-reimagined', + title: 'Complementary Reimagined', + icon_url: 'https://cdn.modrinth.com/data/HVnmMxH1/icon.png', + }, + version: { + id: 'shaderv1', + version_number: 'r5.3', + file_name: 'ComplementaryReimagined_r5.3.zip', + }, + owner: { + id: 'emin', + name: 'EminGT', + type: 'user', + }, + has_update: false, + update_version_id: null, +} + +const bslShaderItem: ContentItem = { + file_name: 'BSL_v8.2.09.zip', + file_path: '', + hash: '', + size: 1024000, + enabled: true, + project_type: 'shader', + project: { + id: 'shader2', + slug: 'bsl-shaders', + title: 'BSL Shaders', + icon_url: 'https://cdn.modrinth.com/data/Q1vvjJYV/icon.png', + }, + version: { + id: 'shaderv2', + version_number: 'v8.2.09', + file_name: 'BSL_v8.2.09.zip', + }, + owner: { + id: 'capt', + name: 'CaptTatsu', + type: 'user', + }, + has_update: false, + update_version_id: null, +} + +// Resource pack items +const faithfulItem: ContentItem = { + file_name: 'Faithful 32x - 1.21.zip', + file_path: '', + hash: '', + size: 8192000, + enabled: true, + project_type: 'resourcepack', + project: { + id: 'rp1', + slug: 'faithful-32x', + title: 'Faithful 32x', + icon_url: 'https://cdn.modrinth.com/data/tAnpCviC/icon.png', + }, + version: { + id: 'rpv1', + version_number: '1.21', + file_name: 'Faithful 32x - 1.21.zip', + }, + owner: { + id: 'faithful', + name: 'Faithful Resource Pack', + avatar_url: 'https://cdn.modrinth.com/data/tAnpCviC/icon.png', + type: 'organization', + }, + has_update: false, + update_version_id: null, +} + +const vanillaTweaksItem: ContentItem = { + file_name: 'VanillaTweaks_r3.zip', + file_path: '', + hash: '', + size: 512000, + enabled: true, + project_type: 'resourcepack', + project: { + id: 'rp2', + slug: 'vanilla-tweaks', + title: 'Vanilla Tweaks', + icon_url: null, + }, + version: { + id: 'rpv2', + version_number: 'r3', + file_name: 'VanillaTweaks_r3.zip', + }, + owner: { + id: 'xisuma', + name: 'Xisumavoid', + type: 'user', + }, + has_update: false, + update_version_id: null, +} + +const stayTrueItem: ContentItem = { + file_name: 'Stay_True_1.21.zip', + file_path: '', + hash: '', + size: 4096000, + enabled: true, + project_type: 'resourcepack', + project: { + id: 'rp3', + slug: 'stay-true', + title: 'Stay True', + icon_url: 'https://cdn.modrinth.com/data/HVnmMxH1/icon.png', + }, + version: { + id: 'rpv3', + version_number: '1.21', + file_name: 'Stay_True_1.21.zip', + }, + owner: { + id: 'hallowed', + name: 'HallowedST', + type: 'user', + }, + has_update: false, + update_version_id: null, +} + +// Mixed content (mods + shaders + resource packs) +const mixedModpackContent: ContentItem[] = [ + sodiumItem, + lithiumItem, + fabricApiItem, + modMenuItem, + irisItem, + entityModelFeaturesItem, + entityTextureFeaturesItem, + complementaryShaderItem, + bslShaderItem, + faithfulItem, + vanillaTweaksItem, + stayTrueItem, +] + +// Mods only +const modsOnlyContent: ContentItem[] = [ + sodiumItem, + lithiumItem, + fabricApiItem, + modMenuItem, + irisItem, + entityModelFeaturesItem, + entityTextureFeaturesItem, +] + +// Large modpack content (40+ items for testing scrolling) +const largeModpackContent: ContentItem[] = [ + ...mixedModpackContent, + ...Array.from({ length: 35 }, (_, i) => ({ + ...sodiumItem, + file_name: `mod-${i + 1}-1.0.0.jar`, + project: { + id: `mod-${i + 1}`, + slug: `mod-${i + 1}`, + title: `Example Mod ${i + 1}`, + icon_url: + i % 3 === 0 + ? sodiumItem.project!.icon_url + : i % 3 === 1 + ? fabricApiItem.project!.icon_url + : modMenuItem.project!.icon_url, + }, + version: { + id: `v${i + 1}`, + version_number: `1.${i}.0`, + file_name: `mod-${i + 1}-1.0.0.jar`, + }, + owner: i % 2 === 0 ? sodiumItem.owner : fabricApiItem.owner, + })), +] + +const meta = { + title: 'Instances/ModpackContentModal', + component: ModpackContentModal, + parameters: { + layout: 'centered', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// ============================================ +// Basic Examples +// ============================================ + +export const Default: Story = { + render: () => ({ + components: { ModpackContentModal, ButtonStyled }, + setup() { + const modalRef = ref | null>(null) + const openModal = () => modalRef.value?.show(mixedModpackContent) + return { modalRef, openModal } + }, + template: /*html*/ ` + + + View Modpack Content (Mixed) + + + + `, + }), +} + +export const ModsOnly: Story = { + render: () => ({ + components: { ModpackContentModal, ButtonStyled }, + setup() { + const modalRef = ref | null>(null) + const openModal = () => modalRef.value?.show(modsOnlyContent) + return { modalRef, openModal } + }, + template: /*html*/ ` + + +
- {{ message }} -
{{ state.errorMessage || 'Invalid or empty image file.' }}
- - Over 100 files will be overwritten if you proceed with extraction; here is just some of - them: - - - The following {{ files.length }} files already exist on your server, and will be - overwritten if you proceed with extraction: - -
- Drop {{ type ? type.toLocaleLowerCase() : 'file' }}s here to upload -
Copy and paste the direct download URL of a .zip file.
- We couldn't load your server's {{ type.toLowerCase() }}s. Here's what we know: - {{ - JSON.stringify(server.moduleErrors.content.error) - }} -
- No {{ type.toLocaleLowerCase() }}s found for your query! -
Try another query, or show everything.
No {{ type.toLocaleLowerCase() }}s found!
- Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here. -
Your server is running Vanilla Minecraft
- Add content to your server by installing a modpack or choosing a different platform that - supports {{ type }}s. -
{{ ctx.error.value.message }}
- Selected: {{ selectedIds.length }} - ({{ selectedIds.join(', ') }}) -
This story demonstrates all possible item states:
- Items: {{ items.length }} ยท Selected: {{ selectedIds.length }} -
+ Items with disabled: true have all interactions disabled (simulating items being modified). +
disabled: true