diff --git a/Cargo.lock b/Cargo.lock index 6a53ac4b9b..a15898355b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5347,6 +5347,16 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "json5" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c" +dependencies = [ + "serde", + "ucd-trie", +] + [[package]] name = "jsonptr" version = "0.6.3" @@ -10959,6 +10969,7 @@ dependencies = [ "image", "indicatif", "itertools 0.14.0", + "json5", "modrinth-content-management", "notify", "notify-debouncer-mini", @@ -10988,6 +10999,7 @@ dependencies = [ "thiserror 2.0.17", "tokio", "tokio-util", + "toml 0.9.8", "tracing", "tracing-error", "tracing-subscriber", @@ -11710,6 +11722,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6670a240af..a93d507fe9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,6 +113,7 @@ indicatif = "0.18.0" itertools = "0.14.0" jemalloc_pprof = "0.8.1" json-patch = { version = "4.1.0", default-features = false } +json5 = "1.3.1" lettre = { version = "0.11.19", default-features = false, features = [ "aws-lc-rs", "builder", @@ -224,6 +225,7 @@ tikv-jemallocator = "0.6.0" tokio = "1.47.1" tokio-stream = "0.1.17" tokio-util = "0.7.16" +toml = "0.9.8" totp-rs = "5.7.0" tracing = "0.1.41" tracing-actix-web = { version = "0.7.19", default-features = false } diff --git a/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue b/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue index 16c229706f..a48a1d4c6a 100644 --- a/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue +++ b/apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue @@ -141,10 +141,11 @@ - @@ -159,7 +160,7 @@ import { type ContentItem, defineMessages, formatLoader, - ModpackContentModal, + ManagedContentModal, NewModal, Table, type TableColumn, @@ -269,10 +270,10 @@ function handleReport() { } } -const modpackContentModal = ref>() +const managedContentModal = ref>() async function openViewContents() { - modpackContentModal.value?.showLoading() + managedContentModal.value?.showLoading() try { // Ensure version data is available — the useQuery may not have resolved yet const versionId = modpackVersionId.value @@ -330,10 +331,10 @@ async function openViewContents() { } }, ) - modpackContentModal.value?.show(contentItems) + managedContentModal.value?.show(contentItems) } catch (err) { console.error('Failed to load modpack contents:', err) - modpackContentModal.value?.show([]) + managedContentModal.value?.show([]) } } @@ -363,6 +364,10 @@ function hide() { } const messages = defineMessages({ + modpackContent: { + id: 'app.modal.install-to-play.managed-content.modpack-header', + defaultMessage: 'Modpack content', + }, installToPlay: { id: 'app.modal.install-to-play.header', defaultMessage: 'Install to play', diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue index 9ffa818bcb..85bd391c5e 100644 --- a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue @@ -229,11 +229,11 @@ - @@ -255,8 +255,8 @@ import { injectModrinthClient, injectNotificationManager, IntlFormatted, + ManagedContentModal, MarkdownEditor, - ModpackContentModal, NewModal, Table, type TableColumn, @@ -289,7 +289,7 @@ type SharedInstanceCreator = { } const modal = ref>() -const contentModal = ref>() +const contentModal = ref>() const externalFileTable = ref(null) const preview = ref(null) const creator = ref(null) diff --git a/apps/app-frontend/src/helpers/instance-content.ts b/apps/app-frontend/src/helpers/instance-content.ts index b294603c34..4a5ccc2c90 100644 --- a/apps/app-frontend/src/helpers/instance-content.ts +++ b/apps/app-frontend/src/helpers/instance-content.ts @@ -1,17 +1,10 @@ -import type { - ContentItem, - ContentModpackCardCategory, - ContentModpackCardProject, - ContentModpackCardVersion, - ContentOwner, -} from '@modrinth/ui' +import type { ContentItem, ManagedContentProject, ManagedContentVersion } from '@modrinth/ui' import { get_content_items, get_linked_modpack_info, type LinkedModpackInfo, } from '@/helpers/instance' -import { get_categories } from '@/helpers/tags.js' import type { CacheBehaviour } from '@/helpers/types' export type InstanceContentData = { @@ -21,11 +14,8 @@ export type InstanceContentData = { } export type InstanceContentModpackData = { - project: ContentModpackCardProject - version: ContentModpackCardVersion - owner: ContentOwner | null - categories: ContentModpackCardCategory[] - hasUpdate: boolean + project: ManagedContentProject + version: ManagedContentVersion updateVersionId: string | null } @@ -34,19 +24,15 @@ export async function loadInstanceContentData( cacheBehaviour?: CacheBehaviour, onError?: (error: Error) => unknown, ): Promise { - const [contentItems, modpackInfo, allCategories] = await Promise.all([ + const [contentItems, modpackInfo] = await Promise.all([ get_content_items(path, cacheBehaviour).catch((error) => handleLoadError(error, onError)), get_linked_modpack_info(path, cacheBehaviour).catch((error) => handleLoadError(error, onError)), - get_categories().catch((error) => handleLoadError(error, onError)), ]) return { path, contentItems: (contentItems as ContentItem[] | null | undefined) ?? null, - modpack: normalizeLinkedModpackInfo( - modpackInfo as LinkedModpackInfo | null | undefined, - allCategories as ContentModpackCardCategory[] | null | undefined, - ), + modpack: normalizeLinkedModpackInfo(modpackInfo as LinkedModpackInfo | null | undefined), } } @@ -58,7 +44,6 @@ function handleLoadError(error: unknown, onError?: (error: Error) => unknown) { function normalizeLinkedModpackInfo( modpackInfo: LinkedModpackInfo | null | undefined, - allCategories: ContentModpackCardCategory[] | null | undefined, ): InstanceContentModpackData | null { if (!modpackInfo) return null @@ -72,30 +57,6 @@ function normalizeLinkedModpackInfo( ...modpackInfo.version, date_published: modpackInfo.version.date_published.toString(), }, - owner: modpackInfo.owner - ? { - ...modpackInfo.owner, - avatar_url: modpackInfo.owner.avatar_url ?? undefined, - } - : null, - categories: resolveLinkedModpackCategories(modpackInfo, allCategories), - hasUpdate: modpackInfo.has_update, updateVersionId: modpackInfo.update_version_id, } } - -function resolveLinkedModpackCategories( - modpackInfo: LinkedModpackInfo, - allCategories: ContentModpackCardCategory[] | null | undefined, -) { - if (!allCategories || !modpackInfo.project.categories) return [] - - const seen = new Set() - return allCategories.filter((category) => { - if (modpackInfo.project.categories.includes(category.name) && !seen.has(category.name)) { - seen.add(category.name) - return true - } - return false - }) -} diff --git a/apps/app-frontend/src/helpers/instance.ts b/apps/app-frontend/src/helpers/instance.ts index 57bbb0c75f..3e713c8838 100644 --- a/apps/app-frontend/src/helpers/instance.ts +++ b/apps/app-frontend/src/helpers/instance.ts @@ -5,7 +5,7 @@ */ import type { Labrinth } from '@modrinth/api-client' import type { ContentItem, ContentOwner } from '@modrinth/ui' -import { invoke } from '@tauri-apps/api/core' +import { convertFileSrc, invoke } from '@tauri-apps/api/core' import type { InstallJobSnapshot, SharedInstanceUpdateDiff } from './install' import type { @@ -74,7 +74,11 @@ export async function get_content_items( instanceId: string, cacheBehaviour?: CacheBehaviour, ): Promise { - return await invoke('plugin:instance|instance_get_content_items', { instanceId, cacheBehaviour }) + const items = await invoke('plugin:instance|instance_get_content_items', { + instanceId, + cacheBehaviour, + }) + return adaptContentItems(items) } export async function refresh_content_updates(instanceId: string): Promise { @@ -111,10 +115,11 @@ export async function get_linked_modpack_content( instanceId: string, cacheBehaviour?: CacheBehaviour, ): Promise { - return await invoke('plugin:instance|instance_get_linked_modpack_content', { + const items = await invoke('plugin:instance|instance_get_linked_modpack_content', { instanceId, cacheBehaviour, }) + return adaptContentItems(items) } // Convert a list of dependencies into ContentItems with rich metadata @@ -122,9 +127,28 @@ export async function get_dependencies_as_content_items( dependencies: Labrinth.Versions.v3.Dependency[], cacheBehaviour?: CacheBehaviour, ): Promise { - return await invoke('plugin:instance|instance_get_dependencies_as_content_items', { - dependencies, - cacheBehaviour, + const items = await invoke( + 'plugin:instance|instance_get_dependencies_as_content_items', + { + dependencies, + cacheBehaviour, + }, + ) + return adaptContentItems(items) +} + +function adaptContentItems(items: ContentItem[]): ContentItem[] { + return items.map((item) => { + const embeddedMetadata = item.embedded_metadata + if (!embeddedMetadata?.icon_path) return item + + return { + ...item, + embedded_metadata: { + ...embeddedMetadata, + icon_url: convertFileSrc(embeddedMetadata.icon_path), + }, + } }) } @@ -264,6 +288,18 @@ export async function toggle_disable_project( }) } +export async function set_project_locked( + instanceId: string, + projectPath: string, + locked: boolean, +): Promise { + return await invoke('plugin:instance|instance_set_project_locked', { + instanceId, + projectPath, + locked, + }) +} + // Remove a project export async function remove_project(instanceId: string, projectPath: string): Promise { return await invoke('plugin:instance|instance_remove_project', { instanceId, projectPath }) diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts index 9c16d0219a..dc96b123ee 100644 --- a/apps/app-frontend/src/helpers/types.d.ts +++ b/apps/app-frontend/src/helpers/types.d.ts @@ -128,6 +128,7 @@ export type ContentSourceKind = type ContentFile = { enabled: boolean + locked: boolean source_kind?: ContentSourceKind | null metadata?: { project_id: string diff --git a/apps/app-frontend/src/locales/ar-SA/index.json b/apps/app-frontend/src/locales/ar-SA/index.json index 78db01f67f..aa6bb84803 100644 --- a/apps/app-frontend/src/locales/ar-SA/index.json +++ b/apps/app-frontend/src/locales/ar-SA/index.json @@ -1073,16 +1073,7 @@ "search.filter.locked.instance.sync": { "message": "مزامنة مع النسخة" }, - "search.filter.locked.server": { - "message": "يقدمها الخادم" - }, "search.filter.locked.server-environment.title": { "message": "يمكن إضافة التعديلات **المحليه** فقط إلى نموذج الخادم" - }, - "search.filter.locked.server-game-version.title": { - "message": "يتم توفير نسخة اللعبة من قبل الخادم" - }, - "search.filter.locked.server-loader.title": { - "message": "يتم توفير المحمّل من قبل الخادم" } } diff --git a/apps/app-frontend/src/locales/cs-CZ/index.json b/apps/app-frontend/src/locales/cs-CZ/index.json index 748bd11a92..c54eeaad49 100644 --- a/apps/app-frontend/src/locales/cs-CZ/index.json +++ b/apps/app-frontend/src/locales/cs-CZ/index.json @@ -965,16 +965,7 @@ "search.filter.locked.instance.sync": { "message": "Synchronizováno s instancí" }, - "search.filter.locked.server": { - "message": "Poskytováno serverem" - }, "search.filter.locked.server-environment.title": { "message": "Pouze módy ze strany klientu mohou být přidány na server" - }, - "search.filter.locked.server-game-version.title": { - "message": "Verze hry je poskytována serverem" - }, - "search.filter.locked.server-loader.title": { - "message": "Loader zprostředkovává server" } } diff --git a/apps/app-frontend/src/locales/da-DK/index.json b/apps/app-frontend/src/locales/da-DK/index.json index 830d28558f..d332d4cd52 100644 --- a/apps/app-frontend/src/locales/da-DK/index.json +++ b/apps/app-frontend/src/locales/da-DK/index.json @@ -296,18 +296,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Gennemså ændringer" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "Gennemgå opdatering" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "Gennemgår..." }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "En opdatering er krævet for at spille {name}. Venligst opdater til den seneste version for at køre spillet." - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "En opdatering er tilgængelig" - }, "app.instance.confirm-delete.admonition-body": { "message": "Al' data for din instance vil blive permanent slettet, dette inkludere dine verdener, konfigurationer, og alt installeret indhold." }, @@ -1181,16 +1172,7 @@ "search.filter.locked.instance.sync": { "message": "Synkroniser med instance" }, - "search.filter.locked.server": { - "message": "Givet af serveren" - }, "search.filter.locked.server-environment.title": { "message": "Kun klient-sided mods kan blive tilføjet til denne server instance" - }, - "search.filter.locked.server-game-version.title": { - "message": "Spille version er givet af serveren" - }, - "search.filter.locked.server-loader.title": { - "message": "Loader er givet af serveren" } } diff --git a/apps/app-frontend/src/locales/de-CH/index.json b/apps/app-frontend/src/locales/de-CH/index.json index 2287ffb102..1d615ddde2 100644 --- a/apps/app-frontend/src/locales/de-CH/index.json +++ b/apps/app-frontend/src/locales/de-CH/index.json @@ -377,18 +377,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Änderungen überprüfen" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "Update überprüfen" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "Wird überprüft..." }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "Ein Update ist erforderlich, um {name} zu spielen. Bitte aktualisiere auf die neueste Version, um das Spiel zu starten." - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "Ein Update ist verfügbar" - }, "app.instance.confirm-delete.admonition-body": { "message": "Alle Daten deiner Instanz werden permanent gelöscht, inlusive deiner Welten, Konfigurationen und allen installierten Inhalten." }, @@ -1922,18 +1913,9 @@ "search.filter.locked.instance.sync": { "message": "Mit Instanz synchronisieren" }, - "search.filter.locked.server": { - "message": "Vom Server bereitgestellt" - }, "search.filter.locked.server-environment.title": { "message": "Nur Clientseitige Mods können der Serverinstanz hinzugefügt werden" }, - "search.filter.locked.server-game-version.title": { - "message": "Spielversion wird vom Server bereitgestellt" - }, - "search.filter.locked.server-loader.title": { - "message": "Loader wird vom Server bereitgestellt" - }, "settings.sidebar.label.account": { "message": "Konto" }, diff --git a/apps/app-frontend/src/locales/de-DE/index.json b/apps/app-frontend/src/locales/de-DE/index.json index 368db919e5..a0f7b3e1a8 100644 --- a/apps/app-frontend/src/locales/de-DE/index.json +++ b/apps/app-frontend/src/locales/de-DE/index.json @@ -377,18 +377,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Änderungen überprüfen" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "Update überprüfen" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "Wird überprüft..." }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "Ein Update ist erforderlich, um {name} zu spielen. Bitte aktualisiere auf die neueste Version, um das Spiel zu starten." - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "Ein Update ist verfügbar" - }, "app.instance.confirm-delete.admonition-body": { "message": "Alle Daten deiner Instanz werden permanent gelöscht, einschließlich deiner Welten, Konfigurationen und allen installierten Inhalten." }, @@ -1922,18 +1913,9 @@ "search.filter.locked.instance.sync": { "message": "Mit Instanz synchronisieren" }, - "search.filter.locked.server": { - "message": "Vom Server vorgegeben" - }, "search.filter.locked.server-environment.title": { "message": "Nur clientseitige Mods können der Serverinstanz hinzugefügt werden" }, - "search.filter.locked.server-game-version.title": { - "message": "Spielversion vom Server vorgegeben" - }, - "search.filter.locked.server-loader.title": { - "message": "Loader vom Server vorgegeben" - }, "settings.sidebar.label.account": { "message": "Konto" }, diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 9b5a674d18..745567a6ca 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -267,7 +267,7 @@ "message": "Modpacks" }, "app.browse.server-instance-content-warning": { - "message": "Adding content can break compatibility when joining the server. Any added content will also be lost when you update the server instance content." + "message": "Adding content may prevent you from joining this server. Any content you add will be removed when the managed server content is updated." }, "app.browse.server.installing": { "message": "Installing" @@ -377,18 +377,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Review changes" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "Review update" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "Reviewing..." }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "An update is required to play {name}. Please update to latest version to launch the game." - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "An update is available" - }, "app.instance.confirm-delete.admonition-body": { "message": "All data for your instance will be permanently deleted, including your worlds, configs, and all installed content." }, @@ -401,6 +392,12 @@ "app.instance.confirm-delete.header": { "message": "Delete instance" }, + "app.instance.content.managed-content.modpack-header": { + "message": "Modpack content" + }, + "app.instance.content.managed-content.shared-header": { + "message": "Shared content" + }, "app.instance.modpack-already-installed.body": { "message": "This modpack is already installed in the {instanceName} instance. Are you sure you want to duplicate it?" }, @@ -425,6 +422,9 @@ "app.instance.mods.content-type-project": { "message": "project" }, + "app.instance.mods.freeze-content": { + "message": "Freeze version" + }, "app.instance.mods.locked-content": { "message": "Content in locked instances cannot be changed." }, @@ -443,6 +443,9 @@ "app.instance.mods.successfully-uploaded": { "message": "Successfully uploaded" }, + "app.instance.mods.unfreeze-content": { + "message": "Unfreeze version" + }, "app.instance.share.empty.description": { "message": "You can share this instance with your friends!" }, @@ -686,6 +689,9 @@ "app.modal.install-to-play.invite-warning-with-creator": { "message": "This invite was created by {username}, not Modrinth. Only accept invites from people you trust." }, + "app.modal.install-to-play.managed-content.modpack-header": { + "message": "Modpack content" + }, "app.modal.install-to-play.mod-count": { "message": "{count, plural, one {# mod} other {# mods}}" }, @@ -1457,6 +1463,9 @@ "instance.files.save-as": { "message": "Save as..." }, + "instance.last-played": { + "message": "Last played" + }, "instance.locked.delete-button": { "message": "Delete instance" }, @@ -1922,18 +1931,9 @@ "search.filter.locked.instance.sync": { "message": "Sync with instance" }, - "search.filter.locked.server": { - "message": "Provided by the server" - }, "search.filter.locked.server-environment.title": { "message": "Only client-side mods can be added to the server instance" }, - "search.filter.locked.server-game-version.title": { - "message": "Game version is provided by the server" - }, - "search.filter.locked.server-loader.title": { - "message": "Loader is provided by the server" - }, "settings.sidebar.label.account": { "message": "Account" }, diff --git a/apps/app-frontend/src/locales/es-419/index.json b/apps/app-frontend/src/locales/es-419/index.json index ebac5e2e5f..1d98c24ddb 100644 --- a/apps/app-frontend/src/locales/es-419/index.json +++ b/apps/app-frontend/src/locales/es-419/index.json @@ -377,18 +377,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Revisar cambios" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "Actualizar" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "Actualizando..." }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "Se necesita una actualización para jugar a {name}. Por favor actualiza a la versión más reciente para iniciar el juego." - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "Hay una actualización disponible" - }, "app.instance.confirm-delete.admonition-body": { "message": "Todos los datos de tu instancia se eliminarán permanentemente, incluidos tus mundos, configuraciones y todo el contenido instalado." }, @@ -1922,18 +1913,9 @@ "search.filter.locked.instance.sync": { "message": "Sincronizar con la instancia" }, - "search.filter.locked.server": { - "message": "Proporcionado por el servidor" - }, "search.filter.locked.server-environment.title": { "message": "Solo se pueden añadir mods que sean del lado del cliente a la instancia del servidor" }, - "search.filter.locked.server-game-version.title": { - "message": "La versión del juego es proporcionada por el servidor" - }, - "search.filter.locked.server-loader.title": { - "message": "El loader es proporcionado por el servidor" - }, "settings.sidebar.label.account": { "message": "Cuenta" }, diff --git a/apps/app-frontend/src/locales/es-ES/index.json b/apps/app-frontend/src/locales/es-ES/index.json index ed520f8899..2a02185c1b 100644 --- a/apps/app-frontend/src/locales/es-ES/index.json +++ b/apps/app-frontend/src/locales/es-ES/index.json @@ -878,16 +878,7 @@ "search.filter.locked.instance.sync": { "message": "Sincronizar con la instancia" }, - "search.filter.locked.server": { - "message": "Proporcionado por el servidor" - }, "search.filter.locked.server-environment.title": { "message": "Solo se pueden añadir a la instancia del servidor modificaciones del lado del cliente" - }, - "search.filter.locked.server-game-version.title": { - "message": "La versión del juego es proporcionada por el servidor" - }, - "search.filter.locked.server-loader.title": { - "message": "Loader proporcionado por el servidor" } } diff --git a/apps/app-frontend/src/locales/fi-FI/index.json b/apps/app-frontend/src/locales/fi-FI/index.json index 9a6032d950..19688c6998 100644 --- a/apps/app-frontend/src/locales/fi-FI/index.json +++ b/apps/app-frontend/src/locales/fi-FI/index.json @@ -959,16 +959,7 @@ "search.filter.locked.instance.sync": { "message": "Synkronoi instanssin kanssa" }, - "search.filter.locked.server": { - "message": "Palvelimen tarjoama" - }, "search.filter.locked.server-environment.title": { "message": "Voit lisätä vain paikallisia modeja palvelin instanssiin" - }, - "search.filter.locked.server-game-version.title": { - "message": "Peliversio on palvelimen tarjoama" - }, - "search.filter.locked.server-loader.title": { - "message": "Modialusta on palvelimen tarjoama" } } diff --git a/apps/app-frontend/src/locales/fil-PH/index.json b/apps/app-frontend/src/locales/fil-PH/index.json index 76ae77ea88..0448613185 100644 --- a/apps/app-frontend/src/locales/fil-PH/index.json +++ b/apps/app-frontend/src/locales/fil-PH/index.json @@ -659,16 +659,7 @@ "search.filter.locked.instance.sync": { "message": "Maki-sync sa instansiya" }, - "search.filter.locked.server": { - "message": "Handog ng server" - }, "search.filter.locked.server-environment.title": { "message": "Mga mod sa panig ng client lamang ang maidadaragdag sa instansiyang server" - }, - "search.filter.locked.server-game-version.title": { - "message": "Ang bersiyon ng laro ay handog ng server" - }, - "search.filter.locked.server-loader.title": { - "message": "Ang loader ay handog na ng server" } } diff --git a/apps/app-frontend/src/locales/fr-FR/index.json b/apps/app-frontend/src/locales/fr-FR/index.json index c211e432f0..a595703d58 100644 --- a/apps/app-frontend/src/locales/fr-FR/index.json +++ b/apps/app-frontend/src/locales/fr-FR/index.json @@ -377,18 +377,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Examiner les modifications" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "Télécharger la mise à jour" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "Examination..." }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "Une mise à jour est nécessaire pour jouer à {name}. Veuillez mettre à jour vers la dernière version pour lancer le jeu." - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "Une mise à jour est disponible" - }, "app.instance.confirm-delete.admonition-body": { "message": "Toutes les données pour votre instance seront supprimées à jamais, y compris vos mondes, vos configurations, et le contenu installé." }, @@ -1919,18 +1910,9 @@ "search.filter.locked.instance.sync": { "message": "Synchroniser avec l'instance" }, - "search.filter.locked.server": { - "message": "Fournis par le serveur" - }, "search.filter.locked.server-environment.title": { "message": "Seuls les mods client peuvent être ajoutés à l'instance du serveur" }, - "search.filter.locked.server-game-version.title": { - "message": "Version du jeu est procurée par le serveur" - }, - "search.filter.locked.server-loader.title": { - "message": "Le loader est procuré par le serveur" - }, "settings.sidebar.label.account": { "message": "Compte" }, diff --git a/apps/app-frontend/src/locales/he-IL/index.json b/apps/app-frontend/src/locales/he-IL/index.json index c74fc0dc83..c0603f84df 100644 --- a/apps/app-frontend/src/locales/he-IL/index.json +++ b/apps/app-frontend/src/locales/he-IL/index.json @@ -533,16 +533,7 @@ "search.filter.locked.instance.sync": { "message": "סנכרן עם התקנה" }, - "search.filter.locked.server": { - "message": "מסופק על ידי השרת" - }, "search.filter.locked.server-environment.title": { "message": "ניתן להוסיף רק מודים בצד הלקוח לשרת" - }, - "search.filter.locked.server-game-version.title": { - "message": "גרסת המשחק מסופקת על ידי השרת" - }, - "search.filter.locked.server-loader.title": { - "message": "הטוען מסופק על ידי השרת" } } diff --git a/apps/app-frontend/src/locales/hu-HU/index.json b/apps/app-frontend/src/locales/hu-HU/index.json index 92dccd017d..cc2988ea7a 100644 --- a/apps/app-frontend/src/locales/hu-HU/index.json +++ b/apps/app-frontend/src/locales/hu-HU/index.json @@ -377,18 +377,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Változtatások áttekintése" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "Frissítés áttekintése" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "Áttekintés..." }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "Egy frssítés szükséges a(z) {name} példányhoz. Kérjük, frissítsd a játékot a legújabb verzióra az indításhoz." - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "Frissítés érhető el" - }, "app.instance.confirm-delete.admonition-body": { "message": "A példányodhoz tartozó összes adat véglegesen törlődik, beleértve a világjaidat, a beállításaidat és az összes telepített tartalmat." }, @@ -1907,18 +1898,9 @@ "search.filter.locked.instance.sync": { "message": "Szinkronizálás a példánnyal" }, - "search.filter.locked.server": { - "message": "A szerver által van megadva" - }, "search.filter.locked.server-environment.title": { "message": "Szerverpéldányokhoz csak szerveroldali modok adhatók hozzá" }, - "search.filter.locked.server-game-version.title": { - "message": "A játékverzió a szerver által van megadva" - }, - "search.filter.locked.server-loader.title": { - "message": "A betöltőt az adott szerver biztosítja" - }, "settings.sidebar.label.account": { "message": "Fiók" }, diff --git a/apps/app-frontend/src/locales/id-ID/index.json b/apps/app-frontend/src/locales/id-ID/index.json index 3e426bee82..90d2c4c726 100644 --- a/apps/app-frontend/src/locales/id-ID/index.json +++ b/apps/app-frontend/src/locales/id-ID/index.json @@ -821,16 +821,7 @@ "search.filter.locked.instance.sync": { "message": "Sinkronkan dengan instans" }, - "search.filter.locked.server": { - "message": "Disediakan oleh server" - }, "search.filter.locked.server-environment.title": { "message": "Anda hanya dapat menambahkan mod sisi klien pada instans server" - }, - "search.filter.locked.server-game-version.title": { - "message": "Versi permainan disediakan oleh server" - }, - "search.filter.locked.server-loader.title": { - "message": "Pemuat disediakan oleh server" } } diff --git a/apps/app-frontend/src/locales/it-IT/index.json b/apps/app-frontend/src/locales/it-IT/index.json index a43823a1a4..97b3209b8d 100644 --- a/apps/app-frontend/src/locales/it-IT/index.json +++ b/apps/app-frontend/src/locales/it-IT/index.json @@ -377,18 +377,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Revisiona modifiche" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "Revisiona aggiornamento" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "Revisione..." }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "{name} richiede degli aggiornamenti. Installa l'ultima versione per poter giocare." - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "Aggiornamento disponibile" - }, "app.instance.confirm-delete.admonition-body": { "message": "Tutti i dati della tua istanza verranno eliminati permanentemente, inclusi i tuoi mondi, configurazioni e tutti i contenuti installati." }, @@ -1919,18 +1910,9 @@ "search.filter.locked.instance.sync": { "message": "Sincronizza con l'istanza" }, - "search.filter.locked.server": { - "message": "Determinato dal server" - }, "search.filter.locked.server-environment.title": { "message": "Solo mod lato client possono essere aggiunte all'istanza del server" }, - "search.filter.locked.server-game-version.title": { - "message": "La versione del gioco è determinata dal server" - }, - "search.filter.locked.server-loader.title": { - "message": "Il loader è determinato dal server" - }, "settings.sidebar.label.account": { "message": "Account" }, diff --git a/apps/app-frontend/src/locales/ja-JP/index.json b/apps/app-frontend/src/locales/ja-JP/index.json index 96238bfa95..1c31bbde73 100644 --- a/apps/app-frontend/src/locales/ja-JP/index.json +++ b/apps/app-frontend/src/locales/ja-JP/index.json @@ -377,18 +377,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "変更点を確認" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "アップデート内容を確認" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "審査中…" }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "{name}をプレイするにはアップデートが必要です。ゲームを起動するには最新バージョンにアップデートしてください。" - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "アップデートが利用可能です" - }, "app.instance.confirm-delete.admonition-body": { "message": "インスタンス内のすべてのデータは、ワールド、設定、インストール済みのコンテンツを含め、完全に削除されます。" }, @@ -1922,18 +1913,9 @@ "search.filter.locked.instance.sync": { "message": "インスタンスと同期" }, - "search.filter.locked.server": { - "message": "サーバーによる条件" - }, "search.filter.locked.server-environment.title": { "message": "サーバーインスタンスにはクライアント側Modのみ追加可能" }, - "search.filter.locked.server-game-version.title": { - "message": "ゲームバージョンはサーバーによる条件です" - }, - "search.filter.locked.server-loader.title": { - "message": "ローダーはサーバーによる条件です" - }, "settings.sidebar.label.account": { "message": "アカウント" }, diff --git a/apps/app-frontend/src/locales/ko-KR/index.json b/apps/app-frontend/src/locales/ko-KR/index.json index 1be3efe362..39d9941de7 100644 --- a/apps/app-frontend/src/locales/ko-KR/index.json +++ b/apps/app-frontend/src/locales/ko-KR/index.json @@ -377,18 +377,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Review changes" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "업데이트 검토" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "검토중..." }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "{name} 플레이를 위해 업데이트가 필요합니다. 게임을 실행하려면 최신 버전 업데이트를 진행하세요." - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "업데이트가 있습니다" - }, "app.instance.confirm-delete.admonition-body": { "message": "인스턴스의 모든 데이터가 삭제됩니다. 세계 폴더, 설정 폴더 등 설치된 모든 컨텐츠가 포함됩니다." }, @@ -1895,18 +1886,9 @@ "search.filter.locked.instance.sync": { "message": "인스턴스와 동기화" }, - "search.filter.locked.server": { - "message": "서버에 의해 관리됨" - }, "search.filter.locked.server-environment.title": { "message": "서버 인스턴스에는 클라이언트 전용 모드만 추가할 수 있습니다" }, - "search.filter.locked.server-game-version.title": { - "message": "게임 버전이 서버에 의해 제공됩니다" - }, - "search.filter.locked.server-loader.title": { - "message": "로더가 서버에 의해 제공됩니다" - }, "settings.sidebar.label.account": { "message": "계정" }, diff --git a/apps/app-frontend/src/locales/ms-MY/index.json b/apps/app-frontend/src/locales/ms-MY/index.json index 1d7c91c661..0ef8ef623e 100644 --- a/apps/app-frontend/src/locales/ms-MY/index.json +++ b/apps/app-frontend/src/locales/ms-MY/index.json @@ -818,16 +818,7 @@ "search.filter.locked.instance.sync": { "message": "Selaraskan dengan pemasangan" }, - "search.filter.locked.server": { - "message": "Disediakan oleh pelayan" - }, "search.filter.locked.server-environment.title": { "message": "Hanya mod bahagian pelanggan sahaja yang boleh ditambah pada pemasangan pelayan" - }, - "search.filter.locked.server-game-version.title": { - "message": "Versi permainan adalah disediakan oleh pelayan" - }, - "search.filter.locked.server-loader.title": { - "message": "Pemuat adalah disediakan oleh pelayan" } } diff --git a/apps/app-frontend/src/locales/nl-NL/index.json b/apps/app-frontend/src/locales/nl-NL/index.json index ddef943c54..3e8c271396 100644 --- a/apps/app-frontend/src/locales/nl-NL/index.json +++ b/apps/app-frontend/src/locales/nl-NL/index.json @@ -377,18 +377,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Wijzigingen bekijken" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "Update bekijken" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "Controleren..." }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "Er is een update nodig om {name} te kunnen spelen. Werk het spel bij naar de nieuwste versie om het te kunnen starten." - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "Update is beschikbaar" - }, "app.instance.confirm-delete.admonition-body": { "message": "Alle gegevens van je instantie worden definitief verwijderd, inclusief je werelden, configuraties en alle geïnstalleerde inhoud." }, @@ -1922,18 +1913,9 @@ "search.filter.locked.instance.sync": { "message": "Synchroniseer installatie" }, - "search.filter.locked.server": { - "message": "Aangeleverd door de server" - }, "search.filter.locked.server-environment.title": { "message": "Alleen client-side mods kunnen toegevoegd worden aan de server instantie" }, - "search.filter.locked.server-game-version.title": { - "message": "Spel versie is gegeven door de server" - }, - "search.filter.locked.server-loader.title": { - "message": "Loader is gegeven door de server" - }, "settings.sidebar.label.account": { "message": "Account" }, diff --git a/apps/app-frontend/src/locales/pl-PL/index.json b/apps/app-frontend/src/locales/pl-PL/index.json index d6c98e9068..63f38b3e56 100644 --- a/apps/app-frontend/src/locales/pl-PL/index.json +++ b/apps/app-frontend/src/locales/pl-PL/index.json @@ -371,15 +371,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Sprawdź zmiany" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "Sprawdź aktualizację" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "Trwa przegląd..." }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "Dostępna aktualizacja" - }, "app.instance.confirm-delete.admonition-body": { "message": "Wszystkie dane z Twojej instancji zostaną trwale usunięte, w tym Twoje światy, pliki konfiguracji i jakakolwiek dodana zawartość." }, @@ -1703,18 +1697,9 @@ "search.filter.locked.instance.sync": { "message": "Synchronizuj z instancją" }, - "search.filter.locked.server": { - "message": "Dostarczone przez serwer" - }, "search.filter.locked.server-environment.title": { "message": "Tylko mody po stronie klienta mogą być dodane do instancji serwera" }, - "search.filter.locked.server-game-version.title": { - "message": "Wersja gry jest dostarczona przez serwer" - }, - "search.filter.locked.server-loader.title": { - "message": "Loader jest dostarczony przez serwer" - }, "settings.sidebar.label.account": { "message": "Konto" }, diff --git a/apps/app-frontend/src/locales/pt-BR/index.json b/apps/app-frontend/src/locales/pt-BR/index.json index 6826ef1414..94587788e6 100644 --- a/apps/app-frontend/src/locales/pt-BR/index.json +++ b/apps/app-frontend/src/locales/pt-BR/index.json @@ -377,18 +377,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Revisar alterações" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "Revisar atualização" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "Revisando..." }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "Uma atualização é necessária para jogar {name}. Atualize para a versão mais recente para iniciar o jogo." - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "Uma atualização está disponível" - }, "app.instance.confirm-delete.admonition-body": { "message": "Todos os dados para sua instância serão excluídos permanentemente, incluindo seus mundos, configs e todo o conteúdo instalado." }, @@ -1922,18 +1913,9 @@ "search.filter.locked.instance.sync": { "message": "Sincronizar com a instância" }, - "search.filter.locked.server": { - "message": "Fornecido pelo servidor" - }, "search.filter.locked.server-environment.title": { "message": "Somente mods do cliente podem ser adicionados à instância do servidor" }, - "search.filter.locked.server-game-version.title": { - "message": "A versão do jogo é fornecida pelo servidor" - }, - "search.filter.locked.server-loader.title": { - "message": "O loader é fornecido pelo servidor" - }, "settings.sidebar.label.account": { "message": "Conta" }, diff --git a/apps/app-frontend/src/locales/pt-PT/index.json b/apps/app-frontend/src/locales/pt-PT/index.json index de73fdbd17..cebf26546f 100644 --- a/apps/app-frontend/src/locales/pt-PT/index.json +++ b/apps/app-frontend/src/locales/pt-PT/index.json @@ -872,16 +872,7 @@ "search.filter.locked.instance.sync": { "message": "Sincronizar com a instância" }, - "search.filter.locked.server": { - "message": "Fornecido pelo servidor" - }, "search.filter.locked.server-environment.title": { "message": "Apenas mods no cliente podem ser adicionados à instância de servidor" - }, - "search.filter.locked.server-game-version.title": { - "message": "Versão do jogo é fornecida pelo servidor" - }, - "search.filter.locked.server-loader.title": { - "message": "Carregador é fornecido pelo servidor" } } diff --git a/apps/app-frontend/src/locales/ro-RO/index.json b/apps/app-frontend/src/locales/ro-RO/index.json index 8843b664c1..ccf663e2f7 100644 --- a/apps/app-frontend/src/locales/ro-RO/index.json +++ b/apps/app-frontend/src/locales/ro-RO/index.json @@ -506,16 +506,7 @@ "search.filter.locked.instance.sync": { "message": "Sincronizează cu instanța" }, - "search.filter.locked.server": { - "message": "Furnizat de server" - }, "search.filter.locked.server-environment.title": { "message": "Numai modurile de pe partea clientului pot fi adăugate la instanța serverului" - }, - "search.filter.locked.server-game-version.title": { - "message": "Versiunea jocului este furnizată de server" - }, - "search.filter.locked.server-loader.title": { - "message": "Încărcătorul este furnizat de server" } } diff --git a/apps/app-frontend/src/locales/ru-RU/index.json b/apps/app-frontend/src/locales/ru-RU/index.json index 0f0d7d0ce3..26c200b03e 100644 --- a/apps/app-frontend/src/locales/ru-RU/index.json +++ b/apps/app-frontend/src/locales/ru-RU/index.json @@ -377,18 +377,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Проверьте изменения" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "Обзор обновления" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "Проверка..." }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "Необходимо обновление для игры в {name}. Обновитесь до последней версии и запустите игру." - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "Доступно обновление" - }, "app.instance.confirm-delete.admonition-body": { "message": "Все данные сборки будут удалены навсегда, в том числе миры, настройки и весь установленный контент." }, @@ -1910,18 +1901,9 @@ "search.filter.locked.instance.sync": { "message": "Использовать из сборки" }, - "search.filter.locked.server": { - "message": "Управляется сервером" - }, "search.filter.locked.server-environment.title": { "message": "В серверной сборке доступны только клиентские моды" }, - "search.filter.locked.server-game-version.title": { - "message": "Версия игры управляется сервером" - }, - "search.filter.locked.server-loader.title": { - "message": "Загрузчик управляется сервером" - }, "settings.sidebar.label.account": { "message": "Аккаунт" }, diff --git a/apps/app-frontend/src/locales/sr-CS/index.json b/apps/app-frontend/src/locales/sr-CS/index.json index ec07649d5b..192b946207 100644 --- a/apps/app-frontend/src/locales/sr-CS/index.json +++ b/apps/app-frontend/src/locales/sr-CS/index.json @@ -1100,16 +1100,7 @@ "search.filter.locked.instance.sync": { "message": "Sinhroniziraj sa instancom" }, - "search.filter.locked.server": { - "message": "Obezbeđeno od servera" - }, "search.filter.locked.server-environment.title": { "message": "Samo modovi za kliente mogu biti dodani na instancu servera" - }, - "search.filter.locked.server-game-version.title": { - "message": "Verzije igre je obezbeđena od servera" - }, - "search.filter.locked.server-loader.title": { - "message": "Učitavač je obezbeđen od servera" } } diff --git a/apps/app-frontend/src/locales/sv-SE/index.json b/apps/app-frontend/src/locales/sv-SE/index.json index 0ee4a37823..177ed949d8 100644 --- a/apps/app-frontend/src/locales/sv-SE/index.json +++ b/apps/app-frontend/src/locales/sv-SE/index.json @@ -359,18 +359,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Granskar ändringar" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "Granska uppdatering" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "Granskar..." }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "En uppdatering krävs för att spela {name}. Vänligen uppdatera till den senaste verisoner för att köra spelet." - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "En uppdatering finns tillgänglig" - }, "app.instance.confirm-delete.admonition-body": { "message": "All data från din instans kommer permanent raderas, däribland dina världar, konfigurationer samt allt installerat innehåll." }, @@ -1718,18 +1709,9 @@ "search.filter.locked.instance.sync": { "message": "Synkronisera med instansen" }, - "search.filter.locked.server": { - "message": "Tillhandahållet av servern" - }, "search.filter.locked.server-environment.title": { "message": "Endast klient-sido moddar kan läggas till i serverinstansen" }, - "search.filter.locked.server-game-version.title": { - "message": "Spelversion tillhandahålls av servern" - }, - "search.filter.locked.server-loader.title": { - "message": "Loader tillhandahålls av servern" - }, "settings.sidebar.label.account": { "message": "Konto" }, diff --git a/apps/app-frontend/src/locales/th-TH/index.json b/apps/app-frontend/src/locales/th-TH/index.json index ddedfb4a3c..ef5a69a70e 100644 --- a/apps/app-frontend/src/locales/th-TH/index.json +++ b/apps/app-frontend/src/locales/th-TH/index.json @@ -785,16 +785,7 @@ "search.filter.locked.instance.sync": { "message": "เชื่อมต่อกับโปรแกรม" }, - "search.filter.locked.server": { - "message": "เซิร์ฟเวอร์เป็นผู้กำหนด" - }, "search.filter.locked.server-environment.title": { "message": "มีเพียงม็อดสำหรับฝั่งเครื่องของผู้เล่นเท่านั้นที่สามารถเพิ่มลงในโปรแกรมเซิร์ฟเวอร์ดังกล่าวได้" - }, - "search.filter.locked.server-game-version.title": { - "message": "เวอร์ชันเกมถูกกำหนดโดยเซิร์ฟเวอร์แล้ว" - }, - "search.filter.locked.server-loader.title": { - "message": "ตัวรันถูกกำหนดโดยเซิร์ฟเวอร์แล้ว" } } diff --git a/apps/app-frontend/src/locales/tr-TR/index.json b/apps/app-frontend/src/locales/tr-TR/index.json index f8f49fd86b..f7bfbfa7bb 100644 --- a/apps/app-frontend/src/locales/tr-TR/index.json +++ b/apps/app-frontend/src/locales/tr-TR/index.json @@ -377,18 +377,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Değişimleri inceleniyor" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "Güncellemeyi gözden geçir" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "İnceleniyor..." }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "{name}'i oynamak için bir güncelleme gerekli. Oyunu açabilmek için lütfen son sürüme güncelleyin." - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "Bir güncelleme mevcut" - }, "app.instance.confirm-delete.admonition-body": { "message": "Dünyalarınız, yapılandırmalarınız ve yüklü tüm içerikler ve tüm veriler kalıcı olarak silinecek." }, @@ -1508,18 +1499,9 @@ "search.filter.locked.instance.sync": { "message": "Kurulum ile eşitle" }, - "search.filter.locked.server": { - "message": "Sunucu tarafından sağlanmıştır" - }, "search.filter.locked.server-environment.title": { "message": "Sunucu örneğine yalnızca istemci tarafında çalışan modlar eklenebilir" }, - "search.filter.locked.server-game-version.title": { - "message": "Oyun sürümü sunucu tarafından sağlanıyor" - }, - "search.filter.locked.server-loader.title": { - "message": "Yükleyici sunucu tarafından sağlanıyor" - }, "settings.sidebar.label.account": { "message": "Hesap" }, diff --git a/apps/app-frontend/src/locales/uk-UA/index.json b/apps/app-frontend/src/locales/uk-UA/index.json index 3da3934fef..2fd3de4f0a 100644 --- a/apps/app-frontend/src/locales/uk-UA/index.json +++ b/apps/app-frontend/src/locales/uk-UA/index.json @@ -377,18 +377,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "Огляд змін" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "Переглянути оновлення" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "Оглядаємо…" }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "Для гри в {name} потрібне оновлення. Будь ласка, оновіть до найновішої версії, щоб запустити гру." - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "Доступне оновлення" - }, "app.instance.confirm-delete.admonition-body": { "message": "Усі дані вашого профілю будуть видалені назавжди, включно з вашими світами, налаштуваннями та всім установленим умістом." }, @@ -1922,18 +1913,9 @@ "search.filter.locked.instance.sync": { "message": "Синхронізувати з профілем" }, - "search.filter.locked.server": { - "message": "Надано сервером" - }, "search.filter.locked.server-environment.title": { "message": "До профілю сервера можна додавати лише клієнтські моди" }, - "search.filter.locked.server-game-version.title": { - "message": "Версія гри надана сервером" - }, - "search.filter.locked.server-loader.title": { - "message": "Завантажувач наданий сервером" - }, "settings.sidebar.label.account": { "message": "Обліковий запис" }, diff --git a/apps/app-frontend/src/locales/vi-VN/index.json b/apps/app-frontend/src/locales/vi-VN/index.json index 480de3cc76..71b4262690 100644 --- a/apps/app-frontend/src/locales/vi-VN/index.json +++ b/apps/app-frontend/src/locales/vi-VN/index.json @@ -998,16 +998,7 @@ "search.filter.locked.instance.sync": { "message": "Đồng bộ với phiên bản" }, - "search.filter.locked.server": { - "message": "Do máy chủ cung cấp" - }, "search.filter.locked.server-environment.title": { "message": "Chỉ các mod chạy phía client mới có thể thêm hồ sơ máy chủ" - }, - "search.filter.locked.server-game-version.title": { - "message": "Phiên bản trò chơi được cung cấp bởi máy chủ" - }, - "search.filter.locked.server-loader.title": { - "message": "Loader được cung cấp bởi máy chủ" } } diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index a65c62a8e7..5612a6a1a9 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -377,18 +377,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "查看变更" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "查看更新" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "正在审查……" }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "{name}需要更新。请更新到最新版本以启动游戏。" - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "有更新可用" - }, "app.instance.confirm-delete.admonition-body": { "message": "你实例的所有数据将被永久删除,包括你的世界、配置和所有已安装的内容。" }, @@ -1922,18 +1913,9 @@ "search.filter.locked.instance.sync": { "message": "与实例同步" }, - "search.filter.locked.server": { - "message": "由该服务器提供" - }, "search.filter.locked.server-environment.title": { "message": "只能将客户端模组添加到服务器实例中" }, - "search.filter.locked.server-game-version.title": { - "message": "游戏版本由服务器提供" - }, - "search.filter.locked.server-loader.title": { - "message": "加载器由服务器提供" - }, "settings.sidebar.label.account": { "message": "账户" }, diff --git a/apps/app-frontend/src/locales/zh-TW/index.json b/apps/app-frontend/src/locales/zh-TW/index.json index 095aa8b80c..cd7bd46e9b 100644 --- a/apps/app-frontend/src/locales/zh-TW/index.json +++ b/apps/app-frontend/src/locales/zh-TW/index.json @@ -377,18 +377,9 @@ "app.instance.admonitions.shared-instance.review-header": { "message": "檢視變更" }, - "app.instance.admonitions.shared-instance.review-update-button": { - "message": "檢視更新" - }, "app.instance.admonitions.shared-instance.reviewing-button": { "message": "正在檢視..." }, - "app.instance.admonitions.shared-instance.update-available-body": { - "message": "需要更新才能遊玩「{name}」。請更新至最新版本以啟動遊戲。" - }, - "app.instance.admonitions.shared-instance.update-available-header": { - "message": "有可用的更新" - }, "app.instance.confirm-delete.admonition-body": { "message": "你實例中的所有資料將被永久刪除,包含你的世界、設定檔以及所有已安裝的內容。" }, @@ -1922,18 +1913,9 @@ "search.filter.locked.instance.sync": { "message": "與實例同步" }, - "search.filter.locked.server": { - "message": "由伺服器提供" - }, "search.filter.locked.server-environment.title": { "message": "只有用戶端模組可以被加到伺服器實例" }, - "search.filter.locked.server-game-version.title": { - "message": "遊戲版本由伺服器提供" - }, - "search.filter.locked.server-loader.title": { - "message": "載入器由伺服器提供" - }, "settings.sidebar.label.account": { "message": "帳號" }, diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue index f2a4eb084e..a49b142c9d 100644 --- a/apps/app-frontend/src/pages/Browse.vue +++ b/apps/app-frontend/src/pages/Browse.vue @@ -399,9 +399,10 @@ const hideInstalledModpacks = computed({ const instanceFilters = computed(() => { const filters = [] - if (instance.value) { + if (instance.value && projectType.value !== 'resourcepack') { + const isVanillaShader = projectType.value === 'shader' && instance.value.loader === 'vanilla' const gameVersion = instance.value.game_version - if (gameVersion) { + if (gameVersion && !isVanillaShader) { filters.push({ type: 'game_version', option: gameVersion }) } @@ -411,6 +412,9 @@ const instanceFilters = computed(() => { if (platform && projectType.value === 'mod' && supportedModLoaders.includes(platform)) { filters.push({ type: 'mod_loader', option: platform }) } + if (isVanillaShader) { + filters.push({ type: 'shader_loader', option: 'vanilla' }) + } if (isServerInstance.value) { filters.push({ type: 'environment', option: 'client' }) @@ -550,10 +554,6 @@ const messages = defineMessages({ id: 'search.filter.locked.instance-game-version.title', defaultMessage: 'Game version is provided by the instance', }, - gameVersionProvidedByServer: { - id: 'search.filter.locked.server-game-version.title', - defaultMessage: 'Game version is provided by the server', - }, hideAddedServers: { id: 'app.browse.hide-added-servers', defaultMessage: 'Hide servers already added', @@ -573,7 +573,7 @@ const messages = defineMessages({ serverInstanceContentWarning: { id: 'app.browse.server-instance-content-warning', defaultMessage: - 'Adding content can break compatibility when joining the server. Any added content will also be lost when you update the server instance content.', + 'Adding content may prevent you from joining this server. Any content you add will be removed when the managed server content is updated.', }, modLoaderProvidedByInstance: { id: 'search.filter.locked.instance-loader.title', @@ -583,18 +583,10 @@ const messages = defineMessages({ id: 'app.browse.project-type.modpacks', defaultMessage: 'Modpacks', }, - modLoaderProvidedByServer: { - id: 'search.filter.locked.server-loader.title', - defaultMessage: 'Loader is provided by the server', - }, providedByInstance: { id: 'search.filter.locked.instance', defaultMessage: 'Provided by the instance', }, - providedByServer: { - id: 'search.filter.locked.server', - defaultMessage: 'Provided by the server', - }, syncFilterButton: { id: 'search.filter.locked.instance.sync', defaultMessage: 'Sync with instance', @@ -743,7 +735,7 @@ const installContext = computed(() => { isFromWorlds.value ? messages.addServersToInstance : commonMessages.installingContentLabel, ), warning: - isServerInstance.value && !isFromWorlds.value + isServerInstance.value && instance.value.loader !== 'vanilla' && !isFromWorlds.value ? formatMessage(messages.serverInstanceContentWarning) : undefined, } @@ -1106,24 +1098,12 @@ async function search(requestParams: string) { } } -const isServerFilterContext = computed(() => isServerContext.value || isServerInstance.value) - const lockedFilterMessages = computed(() => ({ - gameVersion: formatMessage( - isServerFilterContext.value - ? messages.gameVersionProvidedByServer - : messages.gameVersionProvidedByInstance, - ), - modLoader: formatMessage( - isServerFilterContext.value - ? messages.modLoaderProvidedByServer - : messages.modLoaderProvidedByInstance, - ), + gameVersion: formatMessage(messages.gameVersionProvidedByInstance), + modLoader: formatMessage(messages.modLoaderProvidedByInstance), environment: formatMessage(messages.environmentProvidedByServer), syncButton: formatMessage(messages.syncFilterButton), - providedBy: formatMessage( - isServerFilterContext.value ? messages.providedByServer : messages.providedByInstance, - ), + providedBy: formatMessage(messages.providedByInstance), })) const searchState = useBrowseSearch({ diff --git a/apps/app-frontend/src/pages/instance/components/admonitions/index.vue b/apps/app-frontend/src/pages/instance/components/admonitions/index.vue index 5e4d996ad6..f294d38df1 100644 --- a/apps/app-frontend/src/pages/instance/components/admonitions/index.vue +++ b/apps/app-frontend/src/pages/instance/components/admonitions/index.vue @@ -6,11 +6,6 @@ :instance="instance" @published="emit('published')" /> - () const emit = defineEmits<{ published: [] delete: [] - 'review-update': [event: MouseEvent] }>() const sharedInstanceWrongAccount = computed(() => props.sharedInstanceWrongAccount ?? false) @@ -76,15 +68,6 @@ const showSharedInstancePublishAdmonition = computed( props.instance.shared_instance?.role === 'owner' && props.instance.shared_instance.status === 'stale', ) -const showSharedInstanceUpdateAdmonition = computed( - () => - !sharedInstanceWrongAccount.value && - !displayedSharedInstanceUnavailableReason.value && - props.instance.install_stage === 'installed' && - props.sharedInstanceRole === 'member' && - props.sharedInstanceUpdateAvailable === true, -) - const stackItems = computed(() => { const items: InstanceAdmonitionItem[] = [] @@ -120,15 +103,6 @@ const stackItems = computed(() => { }) } - if (showSharedInstanceUpdateAdmonition.value) { - items.push({ - id: 'shared-instance-update-available', - type: 'info', - dismissible: false, - kind: 'shared-instance-update-available', - }) - } - return items }) diff --git a/apps/app-frontend/src/pages/instance/components/admonitions/messages.ts b/apps/app-frontend/src/pages/instance/components/admonitions/messages.ts index 011a57acaf..5191ae9b26 100644 --- a/apps/app-frontend/src/pages/instance/components/admonitions/messages.ts +++ b/apps/app-frontend/src/pages/instance/components/admonitions/messages.ts @@ -21,19 +21,6 @@ export const instanceAdmonitionsMessages = defineMessages({ id: 'app.instance.admonitions.shared-instance.reviewing-button', defaultMessage: 'Reviewing...', }, - sharedInstanceUpdateAvailableHeader: { - id: 'app.instance.admonitions.shared-instance.update-available-header', - defaultMessage: 'An update is available', - }, - sharedInstanceUpdateAvailableBody: { - id: 'app.instance.admonitions.shared-instance.update-available-body', - defaultMessage: - 'An update is required to play {name}. Please update to latest version to launch the game.', - }, - sharedInstanceReviewUpdateButton: { - id: 'app.instance.admonitions.shared-instance.review-update-button', - defaultMessage: 'Review update', - }, sharedInstanceReviewHeader: { id: 'app.instance.admonitions.shared-instance.review-header', defaultMessage: 'Review changes', diff --git a/apps/app-frontend/src/pages/instance/components/admonitions/shared-instance-update-available.vue b/apps/app-frontend/src/pages/instance/components/admonitions/shared-instance-update-available.vue deleted file mode 100644 index 780b51ccec..0000000000 --- a/apps/app-frontend/src/pages/instance/components/admonitions/shared-instance-update-available.vue +++ /dev/null @@ -1,32 +0,0 @@ - - - diff --git a/apps/app-frontend/src/pages/instance/components/admonitions/types.ts b/apps/app-frontend/src/pages/instance/components/admonitions/types.ts index 02948b3e8d..ea7eb81be1 100644 --- a/apps/app-frontend/src/pages/instance/components/admonitions/types.ts +++ b/apps/app-frontend/src/pages/instance/components/admonitions/types.ts @@ -2,7 +2,6 @@ import type { StackedAdmonitionItem } from '@modrinth/ui' export type InstanceAdmonitionKind = | 'shared-instance-stale' - | 'shared-instance-update-available' | 'shared-instance-unavailable' | 'shared-instance-wrong-account' diff --git a/apps/app-frontend/src/pages/instance/components/page-header/index.vue b/apps/app-frontend/src/pages/instance/components/page-header/index.vue index 507b03bd63..1107d8a22d 100644 --- a/apps/app-frontend/src/pages/instance/components/page-header/index.vue +++ b/apps/app-frontend/src/pages/instance/components/page-header/index.vue @@ -25,60 +25,39 @@ @@ -185,6 +164,7 @@ diff --git a/apps/app-frontend/src/pages/instance/components/settings-modal/installation-settings.vue b/apps/app-frontend/src/pages/instance/components/settings-modal/installation-settings.vue index b415d92757..95f05b5dc9 100644 --- a/apps/app-frontend/src/pages/instance/components/settings-modal/installation-settings.vue +++ b/apps/app-frontend/src/pages/instance/components/settings-modal/installation-settings.vue @@ -232,6 +232,7 @@ provideInstallationSettings({ () => isModrinthLinkedModpack.value || isImportedModpack.value || + instance.value.link?.type === 'server_project' || isSharedInstanceManagedModpack.value, ), isBusy: installationSettingsBusy, diff --git a/apps/app-frontend/src/pages/instance/content/index.vue b/apps/app-frontend/src/pages/instance/content/index.vue index 080f09026a..c0779fef90 100644 --- a/apps/app-frontend/src/pages/instance/content/index.vue +++ b/apps/app-frontend/src/pages/instance/content/index.vue @@ -15,27 +15,28 @@ :share-text="formatMessage(messages.shareText)" :open-in-new-tab="false" /> - import type { Labrinth } from '@modrinth/api-client' -import { ClipboardCopyIcon, FolderOpenIcon } from '@modrinth/assets' +import { ClipboardCopyIcon, FolderOpenIcon, LockIcon, LockOpenIcon } from '@modrinth/assets' import { type BulkOperationStatus, commonMessages, @@ -96,18 +97,20 @@ import { ConfirmModpackUpdateModal, ContentCardLayout as ContentPageLayout, type ContentItem, - type ContentModpackCardCategory, - type ContentModpackCardProject, - type ContentModpackCardVersion, type ContentOwner, ContentUpdaterModal, + dedupeManagedContentItems, defineMessages, injectNotificationManager, - ModpackContentModal, - type ModpackContentModalState, + type ManagedContentData, + ManagedContentModal, + type ManagedContentModalState, + type ManagedContentProject, + type ManagedContentVersion, type OverflowMenuOption, provideContentManager, ReadyTransition, + summarizeManagedContent, UnknownFileWarningModal, useDebugLogger, useVIntl, @@ -135,8 +138,10 @@ import { add_project_from_path, edit, get_linked_modpack_content, + get_shared_instance_publish_preview, is_file_on_modrinth, remove_project, + set_project_locked, switch_project_version_with_dependencies, toggle_disable_project, update_all, @@ -152,8 +157,17 @@ import type { FeatureFlag } from '@/store/theme' import { injectInstancePage } from '../instance-context' import { instanceContentQueryOptions, instanceKeys } from '../query-options' +import { injectSharedInstance } from '../shared-instance-context' const messages = defineMessages({ + modpackContentHeader: { + id: 'app.instance.content.managed-content.modpack-header', + defaultMessage: 'Modpack content', + }, + sharedContentHeader: { + id: 'app.instance.content.managed-content.shared-header', + defaultMessage: 'Shared content', + }, shareTitle: { id: 'app.instance.mods.share-title', defaultMessage: 'Sharing modpack content', @@ -178,6 +192,14 @@ const messages = defineMessages({ id: 'app.instance.mods.locked-content', defaultMessage: 'Content in locked instances cannot be changed.', }, + freezeContent: { + id: 'app.instance.mods.freeze-content', + defaultMessage: 'Freeze version', + }, + unfreezeContent: { + id: 'app.instance.mods.unfreeze-content', + defaultMessage: 'Unfreeze version', + }, contentTypeProject: { id: 'app.instance.mods.content-type-project', defaultMessage: 'project', @@ -196,7 +218,7 @@ const messages = defineMessages({ }, }) -let savedModalState: ModpackContentModalState | null = null +let savedModalState: ManagedContentModalState | null = null function contentOwnerLink(owner: ContentOwner): NonNullable { if (owner.type === 'user') return `/user/${encodeURIComponent(owner.id)}` @@ -219,6 +241,7 @@ const skipNonEssentialWarnings = computed(() => ) const instancePage = injectInstancePage() +const sharedInstanceState = injectSharedInstance() const instance = instancePage.instance const isServerInstance = instancePage.isServerInstance const openSettings = () => instancePage.openSettings(1) @@ -283,15 +306,12 @@ watch( }, ) -const linkedModpackProject = ref(null) -const linkedModpackVersion = ref(null) -const linkedModpackOwner = ref(null) -const linkedModpackCategories = ref([]) -const linkedModpackHasUpdate = ref(false) +const linkedModpackProject = ref(null) +const linkedModpackVersion = ref(null) const linkedModpackUpdateVersionId = ref(null) const localImportedModpackUnlinked = ref(false) -const localImportedModpackProject = computed(() => { +const localImportedModpackProject = computed(() => { const link = instance.value.link if (localImportedModpackUnlinked.value || link?.type !== 'imported_modpack') return null @@ -300,7 +320,6 @@ const localImportedModpackProject = computed(( slug: link.filename ?? instance.value.id, title: link.name ?? instance.value.name, icon_url: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : undefined, - description: '', filename: link.filename ?? undefined, } }) @@ -319,6 +338,7 @@ watch( const isModpackUpdating = ref(false) const isBulkOperating = ref(false) const isInstanceBusy = computed(() => instance.value?.install_stage !== 'installed') +const showSharedContentFilter = computed(() => instance.value.shared_instance?.role === 'member') const isPackLocked = computed( () => instance.value.quarantined || @@ -329,10 +349,10 @@ const isPackLocked = computed( const shareModal = ref | null>() const exportModal = ref(null) const contentUpdaterModal = ref | null>() -const modpackContentModal = ref | null>() +const managedContentModal = ref | null>() const modpackUpdateConfirmModal = ref | null>() const sharedDisableConfirmModal = ref | null>() -const pendingModpackDisableItems = ref([]) +const pendingManagedContentDisableItems = ref([]) const unknownFileWarningModal = ref | null>() const unknownFileName = ref('') let resolveUnknownFileConfirmation: ((confirmed: boolean) => void) | null = null @@ -349,6 +369,134 @@ const modpackContentQuery = useQuery({ ), }) +const hasSharedManagedContent = computed(() => { + if (instance.value.shared_instance?.role === 'owner') return false + + const linkType = instance.value.link?.type + return ( + !!instance.value.shared_instance || + linkType === 'server_project' || + linkType === 'server_project_modpack' + ) +}) + +const managedContentItems = computed(() => { + const linkedContent = modpackContentQuery.data.value ?? [] + const sourcedContent = hasSharedManagedContent.value + ? projects.value.filter((item) => + ['server_project', 'shared_instance'].includes(item.source_kind ?? ''), + ) + : [] + + return dedupeManagedContentItems([...linkedContent, ...sourcedContent]) +}) + +const managedContentSummary = computed(() => + modpackContentQuery.isLoading.value && modpackContentQuery.data.value === undefined + ? undefined + : summarizeManagedContent(managedContentItems.value), +) + +const managedContent = computed(() => { + const attachment = instance.value.shared_instance + const sharedManager = sharedInstanceState.manager.value + const linkedProject = instancePage.linkedProject.value + const linkType = instance.value.link?.type + const isSharedOwner = attachment?.role === 'owner' + + if ( + !isSharedOwner && + (attachment || linkType === 'server_project' || linkType === 'server_project_modpack') + ) { + const serverManaged = + sharedManager?.type === 'server' || + !!attachment?.server_manager_name || + linkType === 'server_project' || + linkType === 'server_project_modpack' || + (!attachment && isServerInstance.value) + const managerName = serverManaged + ? (sharedManager?.name ?? + attachment?.server_manager_name ?? + linkedProject?.name ?? + instance.value.name) + : (sharedManager?.name ?? instance.value.name) + const managerIcon = serverManaged + ? (sharedManager?.avatarUrl ?? + attachment?.server_manager_icon_url ?? + linkedProject?.icon_url ?? + undefined) + : (sharedManager?.avatarUrl ?? + (instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : undefined)) + const managerLink = serverManaged + ? linkedProject + ? { + path: `/project/${linkedProject.slug ?? linkedProject.id}`, + query: { i: instancePage.instanceId.value }, + } + : undefined + : sharedManager?.type === 'user' + ? `/user/${encodeURIComponent(sharedManager.name)}` + : undefined + + return { + card: { + kind: serverManaged ? 'server' : 'shared-instance', + installing: isInstanceBusy.value, + manager: { + name: managerName, + iconUrl: managerIcon, + link: managerLink, + }, + summary: managedContentSummary.value, + syncedAt: sharedInstanceState.lastUpdateCheckAt.value, + updateAvailable: instancePage.sharedInstanceUpdateAvailable.value, + }, + disabled: attachment?.status === 'applying' || isInstanceBusy.value, + disabledText: formatMessage(commonMessages.updatingLabel), + } + } + + const project = displayedModpackProject.value + if (!project) return null + + return { + card: { + kind: 'modpack', + installing: isInstanceBusy.value, + manager: { + name: project.title, + iconUrl: project.icon_url ?? undefined, + link: linkedModpackProject.value + ? { + path: `/project/${project.slug ?? project.id}`, + query: { i: instancePage.instanceId.value }, + } + : undefined, + }, + summary: managedContentSummary.value, + versionNumber: linkedModpackVersion.value?.version_number, + versionLink: + linkedModpackProject.value && linkedModpackVersion.value + ? { + path: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}/version/${linkedModpackVersion.value.id}`, + query: { i: instancePage.instanceId.value }, + } + : undefined, + updatedAt: linkedModpackVersion.value?.date_published, + }, + disabled: isModpackUpdating.value || isInstanceBusy.value, + disabledText: formatMessage(commonMessages.updatingLabel), + } +}) + +const managedContentModalHeader = computed(() => + formatMessage( + managedContent.value?.card.kind === 'modpack' + ? messages.modpackContentHeader + : messages.sharedContentHeader, + ), +) + // TODO: Extract content operation and updater modal state into composables; this page currently owns file mutations, dependency installs, busy flags, and version selection flow. const updatingProject = ref(null) const updatingProjectVersions = ref([]) @@ -418,14 +566,30 @@ function canDeleteContent(item: ContentItem) { return canMutateContent(item) } +function canToggleContent(item: ContentItem) { + return canMutateContent(item) +} + +function canChangeContentVersion(item: ContentItem) { + return canMutateContent(item) && !item.locked +} + +async function reconcileSharedInstancePublishState() { + if (instance.value.shared_instance?.role !== 'owner') return + + await get_shared_instance_publish_preview(instance.value.id).catch((error) => { + debug('Failed to reconcile shared instance publish state', { error }) + }) +} + function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) { item.installing = busy - modpackContentModal.value?.updateItem(originalFileName, { + managedContentModal.value?.updateItem(originalFileName, { installing: busy, disabled: busy, }) if (item.file_name !== originalFileName) { - modpackContentModal.value?.updateItem(item.file_name, { + managedContentModal.value?.updateItem(item.file_name, { installing: busy, disabled: busy, }) @@ -627,8 +791,12 @@ async function handleUnknownFileContinue(dontShowAgain: boolean) { resolveUnknownFileWarning(true) } -async function toggleDisableMod(mod: ContentItem, desiredEnabled?: boolean) { - if (!mod.file_path) return +async function toggleDisableMod( + mod: ContentItem, + desiredEnabled?: boolean, + reconcileSharedState = true, +) { + if (!mod.file_path || !canToggleContent(mod)) return const operation = beginContentOperation(mod) if (!operation) return const originalFilePath = mod.file_path @@ -640,7 +808,7 @@ async function toggleDisableMod(mod: ContentItem, desiredEnabled?: boolean) { mod.file_path = newPath mod.file_name = newFileName mod.enabled = enabled - modpackContentModal.value?.updateItem(operation.originalFileName, { + managedContentModal.value?.updateItem(operation.originalFileName, { file_path: newPath, file_name: newFileName, enabled, @@ -659,6 +827,10 @@ async function toggleDisableMod(mod: ContentItem, desiredEnabled?: boolean) { project_type: mod.project_type, disabled: !enabled, }) + + if (reconcileSharedState) { + await reconcileSharedInstancePublishState() + } } catch (err) { handleError(err as Error) } finally { @@ -669,7 +841,7 @@ async function toggleDisableMod(mod: ContentItem, desiredEnabled?: boolean) { const toggleDisableDebounced = toggleDisableMod async function removeMod(mod: ContentItem) { - if (!mod.file_path) return + if (!mod.file_path || !canDeleteContent(mod)) return const operation = beginContentOperation(mod) if (!operation) return @@ -800,7 +972,7 @@ async function bulkUpdateAllProjects(onProgress?: (status: BulkOperationStatus) } async function updateProject(mod: ContentItem) { - if (!canUpdateProject(mod)) return + if (!canUpdateProject(mod) || mod.locked) return const operation = beginContentOperation(mod) if (!operation) return @@ -829,7 +1001,7 @@ async function updateProject(mod: ContentItem) { } async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) { - if (!canMutateContent(mod)) return + if (!canChangeContentVersion(mod)) return if (!mod.file_path) return const operation = beginContentOperation(mod) if (!operation) return @@ -856,7 +1028,8 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions async function handleUpdate(id: string) { const item = projects.value.find((p) => getContentItemId(p) === id) - if (!item || !canUpdateProject(item) || !item.project?.id || !item.version?.id) return + if (!item || item.locked || !canUpdateProject(item) || !item.project?.id || !item.version?.id) + return const requestId = beginUpdateRequest() const itemId = getContentItemId(item) @@ -966,7 +1139,7 @@ async function handleUpdate(id: string) { } async function handleSwitchVersion(item: ContentItem) { - if (!canMutateContent(item)) return + if (!canChangeContentVersion(item)) return if (!item.project?.id || !item.version?.id) return const requestId = beginUpdateRequest() @@ -993,9 +1166,9 @@ async function handleSwitchVersion(item: ContentItem) { updatingProjectVersions.value = versions } -async function handleModpackContentToggle(item: ContentItem, enabled: boolean) { +async function handleManagedContentToggle(item: ContentItem, enabled: boolean) { if (!enabled && managedContentPolicy.disableWarning([item])) { - pendingModpackDisableItems.value = [item] + pendingManagedContentDisableItems.value = [item] sharedDisableConfirmModal.value?.show() return } @@ -1003,47 +1176,48 @@ async function handleModpackContentToggle(item: ContentItem, enabled: boolean) { await toggleDisableDebounced(item, enabled) } -async function handleModpackContentBulkToggle(items: ContentItem[], enabled: boolean) { +async function handleManagedContentBulkToggle(items: ContentItem[], enabled: boolean) { if (!enabled && managedContentPolicy.disableWarning(items)) { - pendingModpackDisableItems.value = items + pendingManagedContentDisableItems.value = items sharedDisableConfirmModal.value?.show() return } - await setModpackContentEnabled(items, enabled) + await setManagedContentEnabled(items, enabled) } -async function confirmPendingModpackContentDisable() { - const items = [...pendingModpackDisableItems.value] - pendingModpackDisableItems.value = [] - await setModpackContentEnabled(items, false) +async function confirmPendingManagedContentDisable() { + const items = [...pendingManagedContentDisableItems.value] + pendingManagedContentDisableItems.value = [] + await setManagedContentEnabled(items, false) } -async function setModpackContentEnabled(items: ContentItem[], enabled: boolean) { - await Promise.all(items.map((item) => toggleDisableMod(item, enabled))) +async function setManagedContentEnabled(items: ContentItem[], enabled: boolean) { + await Promise.all(items.map((item) => toggleDisableMod(item, enabled, false))) + await reconcileSharedInstancePublishState() } -async function handleModpackContent() { +async function handleManagedContent() { if (!instance.value?.id) return - if (modpackContentQuery.data.value?.length) { - modpackContentModal.value?.show(modpackContentQuery.data.value) + if (modpackContentQuery.data.value !== undefined) { + managedContentModal.value?.show(managedContentItems.value) return } - modpackContentModal.value?.showLoading() + managedContentModal.value?.showLoading() const { data, error } = await modpackContentQuery.refetch() if (data !== undefined) { - modpackContentModal.value?.show(data) + managedContentModal.value?.show(managedContentItems.value) } else { if (error) handleError(error) - modpackContentModal.value?.hide() + managedContentModal.value?.hide() } } -async function refreshModpackContentItems(cacheBehaviour?: CacheBehaviour) { +async function refreshManagedContentItems(cacheBehaviour?: CacheBehaviour) { if (!instance.value?.id) return const contentItems = await queryClient @@ -1054,13 +1228,13 @@ async function refreshModpackContentItems(cacheBehaviour?: CacheBehaviour) { .catch(handleError) if (contentItems) { - modpackContentModal.value?.setItems(contentItems) + managedContentModal.value?.setItems(managedContentItems.value) } } async function refreshContentState(cacheBehaviour?: CacheBehaviour) { await initProjects(cacheBehaviour) - await refreshModpackContentItems(cacheBehaviour) + await refreshManagedContentItems(cacheBehaviour) } watch( @@ -1093,7 +1267,6 @@ async function handleModpackUpdate() { linkedModpackUpdateVersionId: linkedModpackUpdateVersionId.value, linkedModpackProject: linkedModpackProject.value, linkedModpackVersion: linkedModpackVersion.value, - linkedModpackHasUpdate: linkedModpackHasUpdate.value, instance: { path: instance.value.id, name: instance.value.name, @@ -1262,8 +1435,6 @@ async function unpairInstance() { }) linkedModpackProject.value = null linkedModpackVersion.value = null - linkedModpackOwner.value = null - linkedModpackHasUpdate.value = false linkedModpackUpdateVersionId.value = null localImportedModpackUnlinked.value = true await initProjects() @@ -1326,9 +1497,41 @@ function getOverflowOptions(item: ContentItem): OverflowMenuOption[] { }) } + if (canMutateContent(item)) { + options.push( + { type: 'divider' }, + { + id: item.locked ? 'unfreeze-content' : 'freeze-content', + label: formatMessage(item.locked ? messages.unfreezeContent : messages.freezeContent), + icon: item.locked ? LockOpenIcon : LockIcon, + action: () => handleContentFreeze(item, !item.locked), + }, + ) + } + return options } +async function handleContentFreeze(item: ContentItem, frozen: boolean) { + if (!item.file_path || !canMutateContent(item)) return + const operation = beginContentOperation(item) + if (!operation) return + const originalFilePath = item.file_path + + try { + await set_project_locked(instance.value.id, item.file_path, frozen) + item.locked = frozen + managedContentModal.value?.updateItem(operation.originalFileName, { locked: frozen }) + updateLinkedModpackContentCache(item, operation.originalFileName, originalFilePath, { + locked: frozen, + }) + } catch (err) { + handleError(err as Error) + } finally { + finishContentOperation(item, operation) + } +} + async function initProjects(cacheBehaviour?: CacheBehaviour, staleTime = 0) { if (!instance.value) return @@ -1358,16 +1561,10 @@ function applyContentData(contentData: InstanceContentData) { if (contentData.modpack) { linkedModpackProject.value = contentData.modpack.project linkedModpackVersion.value = contentData.modpack.version - linkedModpackOwner.value = contentData.modpack.owner - linkedModpackCategories.value = contentData.modpack.categories - linkedModpackHasUpdate.value = contentData.modpack.hasUpdate linkedModpackUpdateVersionId.value = contentData.modpack.updateVersionId } else { linkedModpackProject.value = null linkedModpackVersion.value = null - linkedModpackOwner.value = null - linkedModpackCategories.value = [] - linkedModpackHasUpdate.value = false linkedModpackUpdateVersionId.value = null } @@ -1375,55 +1572,16 @@ function applyContentData(contentData: InstanceContentData) { return true } +function contentVersionLabel(item: ContentItem): string { + if (item.embedded_metadata?.version) return item.embedded_metadata.version + return formatMessage(commonMessages.unknownLabel) +} + provideContentManager({ items: mergedProjects, loading, error: ref(null), - modpack: computed(() => { - if (linkedModpackProject.value) { - return { - project: linkedModpackProject.value, - projectLink: { - path: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}`, - query: { i: instancePage.instanceId.value }, - }, - version: linkedModpackVersion.value ?? undefined, - versionLink: - linkedModpackProject.value && linkedModpackVersion.value - ? { - path: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}/version/${linkedModpackVersion.value.id}`, - query: { i: instancePage.instanceId.value }, - } - : undefined, - owner: linkedModpackOwner.value - ? { - ...linkedModpackOwner.value, - link: contentOwnerLink(linkedModpackOwner.value), - } - : undefined, - categories: linkedModpackCategories.value, - hasUpdate: linkedModpackHasUpdate.value, - disabled: isModpackUpdating.value, - disabledText: isModpackUpdating.value - ? formatMessage(commonMessages.updatingLabel) - : formatMessage(commonMessages.installingLabel), - } - } - - if (localImportedModpackProject.value) { - return { - project: localImportedModpackProject.value, - categories: [], - hasUpdate: false, - disabled: isModpackUpdating.value, - disabledText: isModpackUpdating.value - ? formatMessage(commonMessages.updatingLabel) - : formatMessage(commonMessages.installingLabel), - } - } - - return null - }), + managedContent, isPackLocked, isBusy: isInstanceBusy, disableAddContent: isQuarantined, @@ -1432,23 +1590,27 @@ provideContentManager({ skipNonEssentialWarnings, contentTypeLabel: ref(formatMessage(messages.contentTypeProject)), toggleEnabled: toggleDisableDebounced, - bulkEnableItems: (items: ContentItem[]) => - Promise.all( + bulkEnableItems: async (items: ContentItem[]) => { + await Promise.all( items - .filter((item) => canMutateContent(item) && !item.enabled) - .map((item) => toggleDisableMod(item, true)), - ).then(() => {}), - bulkDisableItems: (items: ContentItem[]) => - Promise.all( + .filter((item) => canToggleContent(item) && !item.enabled) + .map((item) => toggleDisableMod(item, true, false)), + ) + await reconcileSharedInstancePublishState() + }, + bulkDisableItems: async (items: ContentItem[]) => { + await Promise.all( items - .filter((item) => canMutateContent(item) && item.enabled) - .map((item) => toggleDisableMod(item, false)), - ).then(() => {}), + .filter((item) => canToggleContent(item) && item.enabled) + .map((item) => toggleDisableMod(item, false, false)), + ) + await reconcileSharedInstancePublishState() + }, deleteItem: removeMod, bulkDeleteItems: (items: ContentItem[]) => - Promise.all(items.filter(canMutateContent).map((item) => removeMod(item))).then(() => {}), + Promise.all(items.filter(canDeleteContent).map((item) => removeMod(item))).then(() => {}), canDeleteItem: canDeleteContent, - canToggleItem: canMutateContent, + canToggleItem: canToggleContent, getDeleteWarning: managedContentPolicy.deleteWarning, getDisableWarning: managedContentPolicy.disableWarning, getDeleteDependencyWarning, @@ -1459,13 +1621,15 @@ provideContentManager({ updateItem: handleUpdate, bulkUpdateAll: bulkUpdateAllProjects, bulkUpdateItem: updateProject, - updateModpack: - isServerInstance.value || isSharedMember.value || isQuarantined.value - ? undefined - : handleModpackUpdate, - viewModpackContent: handleModpackContent, + runManagedContentPrimaryAction: + instance.value.shared_instance?.role === 'member' + ? instancePage.reviewSharedInstanceUpdate + : instance.value.link?.type === 'modrinth_modpack' && !isQuarantined.value + ? handleModpackUpdate + : undefined, + viewManagedContent: handleManagedContent, unlinkModpack: unpairInstance, - openSettings: openSettings, + openManagedContentSettings: openSettings, switchVersion: handleSwitchVersion, getOverflowOptions, shareItems: handleShareItems, @@ -1475,15 +1639,15 @@ provideContentManager({ project: item.project ?? { id: item.file_name, slug: null, - title: item.file_name.replace('.disabled', ''), - icon_url: null, + title: item.embedded_metadata?.name ?? item.file_name.replace('.disabled', ''), + icon_url: item.embedded_metadata?.icon_url ?? null, }, projectLink: item.project?.id ? { path: `/project/${item.project.id}`, query: { i: instancePage.instanceId.value } } : undefined, version: item.version ?? { id: item.file_name, - version_number: formatMessage(commonMessages.unknownLabel), + version_number: contentVersionLabel(item), file_name: item.file_name, }, versionLink: @@ -1499,19 +1663,22 @@ provideContentManager({ link: contentOwnerLink(item.owner), } : undefined, + external: item.external ?? !item.project, enabled: canMutateContent(item) ? item.enabled : undefined, + locked: item.locked, installing: item.installing, hideDelete: !canDeleteContent(item), - hideSwitchVersion: !canMutateContent(item) || !item.project?.id || !item.version?.id, - hasUpdate: canUpdateProject(item), + hideSwitchVersion: !canChangeContentVersion(item) || !item.project?.id || !item.version?.id, + hasUpdate: canUpdateProject(item) && !item.locked, }), + showSharedContentFilter, filterPersistKey: instance.value.id, }) type UnlistenFn = () => void const initialContentReady = loadInitialContent() -void initialContentReady.then(restoreModpackContentModalState).catch(handleError) +void initialContentReady.then(restoreManagedContentModalState).catch(handleError) function getInstallRevision() { return installRevisionByInstance.value.get(instance.value.id) ?? 0 @@ -1541,18 +1708,18 @@ watch(contentQuery.error, (error) => { } }) -async function restoreModpackContentModalState() { +async function restoreManagedContentModalState() { if (!savedModalState) return const stateToRestore = savedModalState savedModalState = null await nextTick() - modpackContentModal.value?.restore(stateToRestore) + managedContentModal.value?.restore(stateToRestore) } // Save modal state when navigating away so it can be restored on back const removeBeforeEach = router.beforeEach(() => { - const state = modpackContentModal.value?.getState() + const state = managedContentModal.value?.getState() savedModalState = state ?? null }) diff --git a/apps/app-frontend/src/pages/instance/instance-context.ts b/apps/app-frontend/src/pages/instance/instance-context.ts index 19223b9664..397922e372 100644 --- a/apps/app-frontend/src/pages/instance/instance-context.ts +++ b/apps/app-frontend/src/pages/instance/instance-context.ts @@ -9,6 +9,7 @@ export interface InstancePageContext { readonly instance: ComputedRef readonly linkedProject: ComputedRef readonly isServerInstance: ComputedRef + readonly sharedInstanceUpdateAvailable: ComputedRef readonly offline: Readonly> readonly playing: ComputedRef readonly loading: Readonly> @@ -21,6 +22,7 @@ export interface InstancePageContext { openSettings: (tab?: number) => void browseContent: (projectType?: string) => Promise browseServers: () => Promise + reviewSharedInstanceUpdate: (event?: MouseEvent) => void } export const [injectInstancePage, provideInstancePage] = diff --git a/apps/app-frontend/src/pages/instance/layout.vue b/apps/app-frontend/src/pages/instance/layout.vue index caffa1404a..c3c9ba2101 100644 --- a/apps/app-frontend/src/pages/instance/layout.vue +++ b/apps/app-frontend/src/pages/instance/layout.vue @@ -37,11 +37,8 @@ :loading-server-ping="loadingServerPing" :players-online="playersOnline" :status-online="statusOnline" - :recent-plays="recentPlays" :ping="ping" :minecraft-server="minecraftServer" - :linked-project-v3="linkedProjectV3" - :shared-instance-manager="sharedInstanceManager" @repair="() => repairInstance()" @stop="() => stopInstance('InstancePage')" @play="() => startInstance('InstancePage')" @@ -64,10 +61,8 @@ :shared-instance-expected-user-id="sharedInstanceExpectedUserId" :shared-instance-role="instance.shared_instance?.role" :shared-instance-signed-out="sharedInstanceSignedOut" - :shared-instance-update-available="showSharedInstanceUpdateAdmonition" @published="refreshInstance" @delete="requestInstanceDeletion" - @review-update="reviewSharedInstanceUpdate" />
@@ -211,7 +206,9 @@ useQuery( retry: false, })), ) -const linkedProjectId = computed(() => instance.value?.link?.project_id ?? '') +const linkedProjectId = computed( + () => instance.value?.link?.server_project_id ?? instance.value?.link?.project_id ?? '', +) const linkedProjectQuery = useQuery( computed(() => ({ ...instanceLinkedProjectQueryOptions(linkedProjectId.value), @@ -302,9 +299,6 @@ const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server) const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data) const liveServerStatusOnline = ref(false) const statusOnline = computed(() => liveServerStatusOnline.value || !!javaServerPingData.value) -const recentPlays = computed( - () => linkedProjectV3.value?.minecraft_java_server?.verified_plays_2w ?? undefined, -) const playersOnline = ref(undefined) const ping = ref(undefined) const loadingServerPing = ref(false) @@ -317,7 +311,6 @@ provideSharedInstance(sharedInstanceState) const { actionsLocked: sharedInstanceActionsLocked, expectedUserId: sharedInstanceExpectedUserId, - manager: sharedInstanceManager, refreshUpdatePreview: refreshSharedInstanceUpdatePreview, setUnavailable: setSharedInstanceUnavailable, signedOut: sharedInstanceSignedOut, @@ -331,7 +324,7 @@ const sharedInstanceUpdateKey = computed(() => { const latestVersion = sharedInstanceUpdatePreview.value?.latestVersion return instanceId && latestVersion !== undefined ? `${instanceId}:${latestVersion}` : null }) -const showSharedInstanceUpdateAdmonition = computed( +const sharedInstanceUpdateAvailable = computed( () => sharedInstanceUpdatePreview.value?.updateAvailable === true && sharedInstanceUpdateKey.value !== hiddenSharedInstanceUpdateKey.value, @@ -515,7 +508,7 @@ async function handleSharedInstanceUnavailable( setSharedInstanceUnavailable(reason) } -function reviewSharedInstanceUpdate(event: MouseEvent) { +function reviewSharedInstanceUpdate(event?: MouseEvent) { const currentInstance = instance.value const preview = sharedInstanceUpdatePreview.value if ( @@ -792,6 +785,7 @@ provideInstancePage({ instance: instance as ComputedRef, linkedProject: linkedProjectV3, isServerInstance, + sharedInstanceUpdateAvailable, offline, playing, loading, @@ -804,6 +798,7 @@ provideInstancePage({ openSettings, browseContent, browseServers, + reviewSharedInstanceUpdate, }) provideInstanceBackup(() => instance.value!) diff --git a/apps/app-frontend/src/pages/instance/shared-instance-context.ts b/apps/app-frontend/src/pages/instance/shared-instance-context.ts index 683a7a0686..57c424d5ca 100644 --- a/apps/app-frontend/src/pages/instance/shared-instance-context.ts +++ b/apps/app-frontend/src/pages/instance/shared-instance-context.ts @@ -99,6 +99,7 @@ export function createSharedInstanceContext( enabled: computed( () => !!instance.value?.id && + instance.value.install_stage === 'installed' && !!instance.value.shared_instance && !actionsLocked.value && !offline.value && @@ -128,6 +129,7 @@ export function createSharedInstanceContext( const updatePreview = computed(() => unavailableReason.value ? null : (updatePreviewQuery.data.value ?? null), ) + const lastUpdateCheckAt = computed(() => updatePreviewQuery.dataUpdatedAt.value || undefined) watch( () => instance.value?.id, @@ -162,6 +164,7 @@ export function createSharedInstanceContext( unavailableManager, manager, updatePreview, + lastUpdateCheckAt, expectedUserId, wrongAccount, signedOut, diff --git a/apps/app/build.rs b/apps/app/build.rs index 0a92cd2346..b6cf8397e9 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -217,6 +217,7 @@ fn main() { "instance_add_project_from_path", "instance_is_file_on_modrinth", "instance_toggle_disable_project", + "instance_set_project_locked", "instance_remove_project", "instance_update_managed_modrinth_version", "instance_repair_managed_modrinth", diff --git a/apps/app/src/api/instance.rs b/apps/app/src/api/instance.rs index 5425415d50..be55c85704 100644 --- a/apps/app/src/api/instance.rs +++ b/apps/app/src/api/instance.rs @@ -46,6 +46,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { instance_add_project_from_path, instance_is_file_on_modrinth, instance_toggle_disable_project, + instance_set_project_locked, instance_remove_project, instance_update_managed_modrinth_version, instance_repair_managed_modrinth, @@ -702,6 +703,17 @@ pub async fn instance_toggle_disable_project( .await?) } +#[tauri::command] +pub async fn instance_set_project_locked( + instance_id: &str, + project_path: &str, + locked: bool, +) -> Result<()> { + theseus::instance::set_project_locked(instance_id, project_path, locked) + .await?; + Ok(()) +} + #[tauri::command] pub async fn instance_remove_project( instance_id: &str, diff --git a/apps/frontend/src/components/ui/moderation/SharedInstanceReportContext.vue b/apps/frontend/src/components/ui/moderation/SharedInstanceReportContext.vue index 247b350a73..e3839dd4a6 100644 --- a/apps/frontend/src/components/ui/moderation/SharedInstanceReportContext.vue +++ b/apps/frontend/src/components/ui/moderation/SharedInstanceReportContext.vue @@ -17,7 +17,7 @@ import { ConfirmLeaveModal, type ContentItem, injectModrinthClient, - ModpackContentModal, + ManagedContentModal, Table, type TableColumn, useFormatDateTime, @@ -76,7 +76,7 @@ const emit = defineEmits<{ contentError: [error: unknown] }>() -const contentModal = ref | null>(null) +const contentModal = ref | null>(null) const banModal = ref | null>(null) const client = injectModrinthClient() const contentByVersion = new Map() @@ -406,11 +406,11 @@ function formattedLoader(version: SharedInstanceReportVersion) {
- , +) -> HashMap { + let (client, server) = match environment + .unwrap_or(VersionEnvironment::Unknown) + { + VersionEnvironment::ClientAndServer + | VersionEnvironment::SingleplayerOnly => { + (SideType::Required, SideType::Required) + } + VersionEnvironment::ClientOnly => { + (SideType::Required, SideType::Unsupported) + } + VersionEnvironment::ClientOnlyServerOptional => { + (SideType::Required, SideType::Optional) + } + VersionEnvironment::ServerOnly + | VersionEnvironment::DedicatedServerOnly => { + (SideType::Unsupported, SideType::Required) + } + VersionEnvironment::ServerOnlyClientOptional => { + (SideType::Optional, SideType::Required) + } + VersionEnvironment::ClientOrServer + | VersionEnvironment::ClientOrServerPrefersBoth => { + (SideType::Optional, SideType::Optional) + } + VersionEnvironment::Unknown => (SideType::Optional, SideType::Optional), + }; + + HashMap::from([(EnvType::Client, client), (EnvType::Server, server)]) +} + #[tracing::instrument(skip_all)] pub async fn create_mrpack_json( metadata: &InstanceMetadata, @@ -461,9 +495,10 @@ pub async fn create_mrpack_json( _ => None, }) .collect::>(); - let versions = CachedEntry::get_version_many( - &projects.iter().map(|x| &*x.1).collect::>(), - None, + let version_ids = projects.iter().map(|x| &*x.1).collect::>(); + let versions = CachedEntry::get_version_v3_many( + &version_ids, + Some(CacheBehaviour::MustRevalidate), &state.pool, &state.api_semaphore, ) @@ -473,9 +508,7 @@ pub async fn create_mrpack_json( .filter_map(|(path, version_id)| { if let Some(version) = versions.iter().find(|x| x.id == version_id) { - let mut env = HashMap::new(); - env.insert(EnvType::Client, SideType::Required); - env.insert(EnvType::Server, SideType::Required); + let env = get_mrpack_environment(version.environment); let Some(primary_file) = version.files.first() else { return Some(Err(crate::ErrorKind::OtherError(format!( "No primary file found for mod at: {path}" diff --git a/packages/app-lib/src/api/instance/projects.rs b/packages/app-lib/src/api/instance/projects.rs index d8dc5f0eea..365e1a1c4e 100644 --- a/packages/app-lib/src/api/instance/projects.rs +++ b/packages/app-lib/src/api/instance/projects.rs @@ -60,6 +60,7 @@ pub async fn update_project( &state, ) .await?; + ensure_project_not_frozen(instance_id, project_path, &state).await?; let path = crate::state::instances::commands::update_project( instance_id, project_path, @@ -197,6 +198,7 @@ pub async fn switch_project_version_with_dependencies( &state, ) .await?; + ensure_project_not_frozen(instance_id, project_path, &state).await?; let metadata = super::get::get(instance_id).await?.ok_or_else(|| { crate::ErrorKind::InputError("Unknown instance".to_string()) })?; @@ -289,6 +291,27 @@ pub async fn remove_project( Ok(()) } +#[tracing::instrument] +pub async fn set_project_locked( + instance_id: &str, + project: &str, + locked: bool, +) -> crate::Result<()> { + let state = State::get().await?; + ensure_shared_instance_can_modify_project(instance_id, project, &state) + .await?; + crate::state::instances::commands::set_project_locked( + instance_id, + project, + locked, + &state, + ) + .await?; + emit_instance(instance_id, InstancePayloadType::Edited).await?; + + Ok(()) +} + async fn ensure_shared_instance_can_modify_project( instance_id: &str, project_path: &str, @@ -328,6 +351,28 @@ async fn ensure_shared_instance_can_modify_project( Ok(()) } +async fn ensure_project_not_frozen( + instance_id: &str, + project_path: &str, + state: &State, +) -> crate::Result<()> { + if crate::state::instances::commands::is_project_locked( + instance_id, + project_path, + state, + ) + .await? + { + return Err(crate::ErrorKind::InputError( + "Frozen content cannot change versions. Unfreeze it first." + .to_string(), + ) + .into()); + } + + Ok(()) +} + #[tracing::instrument] pub async fn update_managed_modrinth_version( instance_id: &str, diff --git a/packages/app-lib/src/state/cache.rs b/packages/app-lib/src/state/cache.rs index 867fc1b624..61b7d89d57 100644 --- a/packages/app-lib/src/state/cache.rs +++ b/packages/app-lib/src/state/cache.rs @@ -1,4 +1,4 @@ -use crate::state::ProjectType; +use crate::state::{EmbeddedContentMetadata, ProjectType}; use crate::util::fetch::{FetchSemaphore, fetch_json, sha1_async}; use chrono::{DateTime, Utc}; use dashmap::DashSet; @@ -21,6 +21,7 @@ pub enum CacheValueType { Project, ProjectV3, Version, + VersionV3, User, Team, Organization, @@ -37,6 +38,7 @@ pub enum CacheValueType { SearchResults, SearchResultsV3, ModpackFiles, + EmbeddedContentMetadata, /// Cached list of versions for a project (without changelogs for fast loading) ProjectVersions, } @@ -47,6 +49,7 @@ impl CacheValueType { CacheValueType::Project => "project", CacheValueType::ProjectV3 => "project_v3", CacheValueType::Version => "version", + CacheValueType::VersionV3 => "version_v3", CacheValueType::User => "user", CacheValueType::Team => "team", CacheValueType::Organization => "organization", @@ -63,6 +66,9 @@ impl CacheValueType { CacheValueType::SearchResults => "search_results", CacheValueType::SearchResultsV3 => "search_results_v3", CacheValueType::ModpackFiles => "modpack_files", + CacheValueType::EmbeddedContentMetadata => { + "embedded_content_metadata" + } CacheValueType::ProjectVersions => "project_versions", } } @@ -72,6 +78,7 @@ impl CacheValueType { "project" => CacheValueType::Project, "project_v3" => CacheValueType::ProjectV3, "version" => CacheValueType::Version, + "version_v3" => CacheValueType::VersionV3, "user" => CacheValueType::User, "team" => CacheValueType::Team, "organization" => CacheValueType::Organization, @@ -88,6 +95,9 @@ impl CacheValueType { "search_results" => CacheValueType::SearchResults, "search_results_v3" => CacheValueType::SearchResultsV3, "modpack_files" => CacheValueType::ModpackFiles, + "embedded_content_metadata" => { + CacheValueType::EmbeddedContentMetadata + } "project_versions" => CacheValueType::ProjectVersions, _ => CacheValueType::Project, } @@ -100,7 +110,10 @@ impl CacheValueType { CacheValueType::FileHash => 30 * 24 * 60 * 60, // 30 days // 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) + CacheValueType::ModpackFiles + | CacheValueType::EmbeddedContentMetadata => { + 100 * 365 * 24 * 60 * 60 // 100 years (effectively never) + } CacheValueType::SearchResults | CacheValueType::SearchResultsV3 => { 10 * 60 // 10 minutes } @@ -134,6 +147,7 @@ impl CacheValueType { | CacheValueType::GameVersions | CacheValueType::DonationPlatforms | CacheValueType::Version + | CacheValueType::VersionV3 | CacheValueType::Team | CacheValueType::File | CacheValueType::LoaderManifest @@ -141,6 +155,7 @@ impl CacheValueType { | CacheValueType::SearchResults | CacheValueType::SearchResultsV3 | CacheValueType::ModpackFiles + | CacheValueType::EmbeddedContentMetadata | CacheValueType::ProjectVersions => None, } } @@ -162,6 +177,13 @@ pub struct CachedProjectVersions { pub versions: Vec, } +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct CachedEmbeddedContentMetadata { + pub cache_key: String, + pub hash: String, + pub metadata: Option, +} + // De/serialization strategy: // - on serialize: // - in the `cache` table, save the `data_type` (variant of this value) alongside @@ -181,6 +203,7 @@ pub struct CachedProjectVersions { pub enum CacheValue { Project(Project), Version(Version), + VersionV3(VersionV3), User(User), Team(Vec), Organization(Organization), @@ -197,6 +220,7 @@ pub enum CacheValue { SearchResults(SearchResults), SearchResultsV3(SearchResultsV3), ModpackFiles(CachedModpackFiles), + EmbeddedContentMetadata(CachedEmbeddedContentMetadata), ProjectVersions(CachedProjectVersions), ProjectV3(ProjectV3), } @@ -543,6 +567,30 @@ pub struct Version { pub loaders: Vec, } +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct VersionV3 { + pub id: String, + pub files: Vec, + #[serde(default)] + pub environment: Option, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +#[serde(rename_all = "snake_case")] +pub enum VersionEnvironment { + ClientAndServer, + ClientOnly, + ClientOnlyServerOptional, + SingleplayerOnly, + ServerOnly, + ServerOnlyClientOptional, + DedicatedServerOnly, + ClientOrServer, + ClientOrServerPrefersBoth, + #[serde(other)] + Unknown, +} + #[derive(Serialize, Deserialize, Clone, Debug)] pub struct VersionFile { pub hashes: HashMap, @@ -661,6 +709,7 @@ impl CacheValue { CacheValue::Project(_) => CacheValueType::Project, CacheValue::ProjectV3(_) => CacheValueType::ProjectV3, CacheValue::Version(_) => CacheValueType::Version, + CacheValue::VersionV3(_) => CacheValueType::VersionV3, CacheValue::User(_) => CacheValueType::User, CacheValue::Team { .. } => CacheValueType::Team, CacheValue::Organization(_) => CacheValueType::Organization, @@ -681,6 +730,9 @@ impl CacheValue { CacheValue::SearchResults(_) => CacheValueType::SearchResults, CacheValue::SearchResultsV3(_) => CacheValueType::SearchResultsV3, CacheValue::ModpackFiles(_) => CacheValueType::ModpackFiles, + CacheValue::EmbeddedContentMetadata(_) => { + CacheValueType::EmbeddedContentMetadata + } CacheValue::ProjectVersions(_) => CacheValueType::ProjectVersions, } } @@ -690,6 +742,7 @@ impl CacheValue { CacheValue::Project(project) => project.id.clone(), CacheValue::ProjectV3(project) => project.id.clone(), CacheValue::Version(version) => version.id.clone(), + CacheValue::VersionV3(version) => version.id.clone(), CacheValue::User(user) => user.id.clone(), CacheValue::Team(members) => members .iter() @@ -724,6 +777,9 @@ impl CacheValue { CacheValue::SearchResults(search) => search.search.clone(), CacheValue::SearchResultsV3(search) => search.search.clone(), CacheValue::ModpackFiles(files) => files.version_id.clone(), + CacheValue::EmbeddedContentMetadata(metadata) => { + metadata.cache_key.clone() + } CacheValue::ProjectVersions(pv) => pv.project_id.clone(), } } @@ -746,6 +802,7 @@ impl CacheValue { | CacheValue::GameVersions(_) | CacheValue::DonationPlatforms(_) | CacheValue::Version(_) + | CacheValue::VersionV3(_) | CacheValue::Team { .. } | CacheValue::File { .. } | CacheValue::LoaderManifest { .. } @@ -753,6 +810,7 @@ impl CacheValue { | CacheValue::SearchResults(_) | CacheValue::SearchResultsV3(_) | CacheValue::ModpackFiles(_) + | CacheValue::EmbeddedContentMetadata(_) | CacheValue::ProjectVersions(_) => None, } } @@ -762,6 +820,7 @@ impl CacheValue { CacheValue::Project(project) => serde_json::to_value(project), CacheValue::ProjectV3(project) => serde_json::to_value(project), CacheValue::Version(version) => serde_json::to_value(version), + CacheValue::VersionV3(version) => serde_json::to_value(version), CacheValue::User(user) => serde_json::to_value(user), CacheValue::Team(members) => serde_json::to_value(members), CacheValue::Organization(org) => serde_json::to_value(org), @@ -788,6 +847,9 @@ impl CacheValue { CacheValue::SearchResults(search) => serde_json::to_value(search), CacheValue::SearchResultsV3(search) => serde_json::to_value(search), CacheValue::ModpackFiles(files) => serde_json::to_value(files), + CacheValue::EmbeddedContentMetadata(metadata) => { + serde_json::to_value(metadata) + } CacheValue::ProjectVersions(pv) => serde_json::to_value(pv), } .map_err(|err| { @@ -898,6 +960,7 @@ impl_cache_methods!( (Project, Project), (ProjectV3, ProjectV3), (Version, Version), + (VersionV3, VersionV3), (User, User), (Team, Vec), (Organization, Organization), @@ -905,6 +968,7 @@ impl_cache_methods!( (LoaderManifest, CachedLoaderManifest), (FileHash, CachedFileHash), (FileUpdate, CachedFileUpdate), + (EmbeddedContentMetadata, CachedEmbeddedContentMetadata), (SearchResults, SearchResults), (SearchResultsV3, SearchResultsV3) ); @@ -1274,6 +1338,15 @@ impl CachedEntry { CacheValue::Version ) } + CacheValueType::VersionV3 => { + fetch_original_values!( + VersionV3, + env!("MODRINTH_API_URL_V3"), + "versions", + Some("/v3/versions"), + CacheValue::VersionV3 + ) + } CacheValueType::User => { fetch_original_values!( User, @@ -1855,6 +1928,10 @@ impl CachedEntry { // not fetched from an external API vec![] } + CacheValueType::EmbeddedContentMetadata => { + // Embedded content metadata is populated from local archives. + vec![] + } CacheValueType::ProjectVersions => { let mut values = vec![]; @@ -1976,6 +2053,9 @@ impl CachedEntry { CacheValueType::Version => { CacheValue::Version(parse(data, id, "version")?) } + CacheValueType::VersionV3 => { + CacheValue::VersionV3(parse(data, id, "version_v3")?) + } CacheValueType::User => CacheValue::User(parse(data, id, "user")?), CacheValueType::Team => CacheValue::Team(parse(data, id, "team")?), CacheValueType::Organization => { @@ -2018,6 +2098,13 @@ impl CachedEntry { CacheValueType::ModpackFiles => { CacheValue::ModpackFiles(parse(data, id, "modpack_files")?) } + CacheValueType::EmbeddedContentMetadata => { + CacheValue::EmbeddedContentMetadata(parse( + data, + id, + "embedded_content_metadata", + )?) + } CacheValueType::ProjectVersions => CacheValue::ProjectVersions( parse(data, id, "project_versions")?, ), diff --git a/packages/app-lib/src/state/instance_types.rs b/packages/app-lib/src/state/instance_types.rs index c3b6567fa3..aee4ae29c3 100644 --- a/packages/app-lib/src/state/instance_types.rs +++ b/packages/app-lib/src/state/instance_types.rs @@ -120,6 +120,7 @@ pub struct ContentFile { pub hash: String, pub file_name: String, pub enabled: bool, + pub locked: bool, pub size: u64, pub metadata: Option, pub update_version_id: Option, diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs index ff51044eb7..3de9b7f54b 100644 --- a/packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs +++ b/packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs @@ -9,6 +9,7 @@ use crate::state::instances::{ use crate::state::{ModLoader, ProjectType, ReleaseChannel}; use chrono::{DateTime, TimeZone, Utc}; use sqlx::{Executor, Sqlite, SqlitePool, Transaction}; +use std::collections::HashSet; use uuid::Uuid; #[derive(Debug, sqlx::FromRow)] @@ -529,6 +530,89 @@ where rows.into_iter().map(TryInto::try_into).collect() } +pub(crate) async fn get_locked_instance_file_ids( + instance_id: &str, + pool: &SqlitePool, +) -> crate::Result> { + let file_ids = sqlx::query_scalar::<_, String>( + " + SELECT content_lock.file_id + FROM instance_content_locks content_lock + INNER JOIN instance_files file ON file.id = content_lock.file_id + WHERE file.instance_id = ? + ", + ) + .bind(instance_id) + .fetch_all(pool) + .await?; + + Ok(file_ids.into_iter().collect()) +} + +pub(crate) async fn is_instance_file_locked( + instance_id: &str, + relative_path: &str, + pool: &SqlitePool, +) -> crate::Result { + let locked = sqlx::query_scalar::<_, i64>( + " + SELECT EXISTS ( + SELECT 1 + FROM instance_content_locks content_lock + INNER JOIN instance_files file ON file.id = content_lock.file_id + WHERE file.instance_id = ? AND file.relative_path = ? + ) + ", + ) + .bind(instance_id) + .bind(relative_path) + .fetch_one(pool) + .await?; + + Ok(locked != 0) +} + +pub(crate) async fn set_instance_file_locked( + instance_id: &str, + relative_path: &str, + locked: bool, + pool: &SqlitePool, +) -> crate::Result<()> { + let file = + get_instance_file_by_relative_path(instance_id, relative_path, pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Unknown content file {relative_path}" + )) + })?; + + if locked { + sqlx::query( + " + INSERT INTO instance_content_locks (file_id) + VALUES (?) + ON CONFLICT (file_id) DO NOTHING + ", + ) + .bind(&file.id) + .execute(pool) + .await?; + } else { + sqlx::query( + " + DELETE FROM instance_content_locks + WHERE file_id = ? + ", + ) + .bind(&file.id) + .execute(pool) + .await?; + } + + Ok(()) +} + pub(crate) async fn set_instance_file_missing( file_id: &str, missing: bool, diff --git a/packages/app-lib/src/state/instances/commands/apply_content_install.rs b/packages/app-lib/src/state/instances/commands/apply_content_install.rs index c3e036d537..b2b9c5b640 100644 --- a/packages/app-lib/src/state/instances/commands/apply_content_install.rs +++ b/packages/app-lib/src/state/instances/commands/apply_content_install.rs @@ -513,7 +513,9 @@ pub(crate) async fn add_project_bytes( let scope = resolve_content_scope(instance_id, None, state).await?; let project_type = match project_type { Some(project_type) => project_type, - None => infer_project_type(&bytes)?, + None => { + super::embedded_content_metadata::infer_project_type_bytes(&bytes)? + } }; let relative_path = format!("{}/{}", project_type.get_folder(), file_name); let full_path = @@ -793,6 +795,37 @@ pub(crate) async fn content_source_kind_for_project_path( })) } +pub(crate) async fn is_project_locked( + instance_id: &str, + project_path: &str, + state: &State, +) -> crate::Result { + let scope = resolve_content_scope(instance_id, None, state).await?; + content_rows::is_instance_file_locked( + &scope.instance.id, + project_path, + &state.pool, + ) + .await +} + +pub(crate) async fn set_project_locked( + instance_id: &str, + project_path: &str, + locked: bool, + state: &State, +) -> crate::Result<()> { + let _content_lock = state.lock_instance_content(instance_id).await; + let scope = resolve_content_scope(instance_id, None, state).await?; + content_rows::set_instance_file_locked( + &scope.instance.id, + project_path, + locked, + &state.pool, + ) + .await +} + pub(crate) async fn rename_project_companion_file( instance_id: &str, old_project_path: &str, @@ -940,37 +973,3 @@ async fn upsert_entry_for_file( Ok(()) } - -fn infer_project_type(bytes: &Bytes) -> crate::Result { - let cursor = std::io::Cursor::new(&**bytes); - let mut archive = zip::ZipArchive::new(cursor).map_err(|_| { - crate::ErrorKind::InputError( - "Unable to infer project type for input file".to_string(), - ) - })?; - - if archive.by_name("fabric.mod.json").is_ok() - || archive.by_name("quilt.mod.json").is_ok() - || archive.by_name("META-INF/neoforge.mods.toml").is_ok() - || archive.by_name("META-INF/mods.toml").is_ok() - || archive.by_name("mcmod.info").is_ok() - { - Ok(ProjectType::Mod) - } else if archive.by_name("pack.mcmeta").is_ok() { - if archive.file_names().any(|name| name.starts_with("data/")) { - Ok(ProjectType::DataPack) - } else { - Ok(ProjectType::ResourcePack) - } - } else if archive - .file_names() - .any(|name| name.starts_with("shaders/")) - { - Ok(ProjectType::ShaderPack) - } else { - Err(crate::ErrorKind::InputError( - "Unable to infer project type for input file".to_string(), - ) - .into()) - } -} diff --git a/packages/app-lib/src/state/instances/commands/apply_content_update.rs b/packages/app-lib/src/state/instances/commands/apply_content_update.rs index 232e361305..beb8a45b80 100644 --- a/packages/app-lib/src/state/instances/commands/apply_content_update.rs +++ b/packages/app-lib/src/state/instances/commands/apply_content_update.rs @@ -461,10 +461,11 @@ async fn bulk_updateable_project_paths( Ok(items .into_iter() .filter(|item| { - !shared_instance_member - || !item - .source_kind - .is_some_and(ContentSourceKind::is_shared_instance_managed) + !item.locked + && (!shared_instance_member + || !item.source_kind.is_some_and( + ContentSourceKind::is_shared_instance_managed, + )) }) .map(|item| item.file_path) .collect()) diff --git a/packages/app-lib/src/state/instances/commands/embedded_content_metadata.rs b/packages/app-lib/src/state/instances/commands/embedded_content_metadata.rs new file mode 100644 index 0000000000..5cbe2083f1 --- /dev/null +++ b/packages/app-lib/src/state/instances/commands/embedded_content_metadata.rs @@ -0,0 +1,611 @@ +use crate::state::{ + CacheValue, CachedEmbeddedContentMetadata, CachedEntry, ContentFile, + EmbeddedContentMetadata, Instance, ModLoader, ProjectType, State, +}; +use bytes::Bytes; +use futures::stream::{self, StreamExt}; +use serde_json::Value as JsonValue; +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::{Cursor, Read, Seek}; +use std::path::Path; +use toml::Value as TomlValue; +use zip::ZipArchive; + +const MAX_METADATA_BYTES: u64 = 1024 * 1024; +const MAX_ICON_BYTES: u64 = 8 * 1024 * 1024; +const PREFERRED_ICON_SIZE: u32 = 96; +const MAX_NAME_CHARS: usize = 256; +const MAX_VERSION_CHARS: usize = 128; + +pub(crate) struct ArchiveInspection { + pub metadata: Option, + pub icon: Option, +} + +#[derive(Clone, Copy)] +enum ModMetadataKind { + Fabric, + Quilt, + NeoForge, + Forge, + LegacyForge, +} + +pub(crate) fn infer_project_type_bytes( + bytes: &Bytes, +) -> crate::Result { + let mut archive = ZipArchive::new(Cursor::new(&**bytes)).map_err(|_| { + crate::ErrorKind::InputError( + "Unable to infer project type for input file".to_string(), + ) + })?; + infer_project_type(&mut archive) +} + +fn inspect_content_file( + path: &Path, + loader: ModLoader, +) -> crate::Result { + let file = File::open(path).map_err(|error| { + crate::ErrorKind::OtherError(format!( + "Could not open content archive {}: {error}", + path.display() + )) + })?; + inspect_content_archive(file, Some(loader)) +} + +fn inspect_content_archive( + reader: R, + loader: Option, +) -> crate::Result { + let mut archive = ZipArchive::new(reader).map_err(|_| { + crate::ErrorKind::InputError( + "Unable to infer project type for input file".to_string(), + ) + })?; + let project_type = infer_project_type(&mut archive)?; + let (metadata, icon) = match project_type { + ProjectType::Mod => inspect_mod(&mut archive, loader), + ProjectType::DataPack | ProjectType::ResourcePack => { + inspect_pack(&mut archive) + } + ProjectType::ShaderPack => (None, None), + }; + + Ok(ArchiveInspection { metadata, icon }) +} + +fn infer_project_type( + archive: &mut ZipArchive, +) -> crate::Result { + if has_entry(archive, "fabric.mod.json") + || has_entry(archive, "quilt.mod.json") + || has_entry(archive, "META-INF/neoforge.mods.toml") + || has_entry(archive, "META-INF/mods.toml") + || has_entry(archive, "mcmod.info") + { + Ok(ProjectType::Mod) + } else if has_entry(archive, "pack.mcmeta") { + if archive.file_names().any(|name| name.starts_with("data/")) { + Ok(ProjectType::DataPack) + } else { + Ok(ProjectType::ResourcePack) + } + } else if archive + .file_names() + .any(|name| name.starts_with("shaders/")) + { + Ok(ProjectType::ShaderPack) + } else { + Err(crate::ErrorKind::InputError( + "Unable to infer project type for input file".to_string(), + ) + .into()) + } +} + +fn inspect_pack( + archive: &mut ZipArchive, +) -> (Option, Option) { + let icon = read_icon_entry(archive, "pack.png"); + let metadata = EmbeddedContentMetadata::default(); + + if metadata.is_empty() && icon.is_none() { + (None, None) + } else { + (Some(metadata), icon) + } +} + +fn inspect_mod( + archive: &mut ZipArchive, + loader: Option, +) -> (Option, Option) { + let manifest = read_manifest(archive); + let order = metadata_order(loader); + let parsed = order.iter().find_map(|kind| match kind { + ModMetadataKind::Fabric => parse_fabric_metadata(archive), + ModMetadataKind::Quilt => parse_quilt_metadata(archive), + ModMetadataKind::NeoForge => parse_toml_metadata( + archive, + "META-INF/neoforge.mods.toml", + &manifest, + ), + ModMetadataKind::Forge => { + parse_toml_metadata(archive, "META-INF/mods.toml", &manifest) + } + ModMetadataKind::LegacyForge => parse_legacy_forge_metadata(archive), + }); + let (mut metadata, icon_path) = + parsed.unwrap_or_else(|| (EmbeddedContentMetadata::default(), None)); + + if metadata.name.is_none() { + metadata.name = manifest + .get("implementation-title") + .and_then(|value| clean_string(value, MAX_NAME_CHARS)); + } + if metadata.version.is_none() { + metadata.version = manifest + .get("implementation-version") + .and_then(|value| clean_string(value, MAX_VERSION_CHARS)); + } + + let icon = icon_path + .as_deref() + .and_then(|path| read_icon_entry(archive, path)); + if metadata.is_empty() && icon.is_none() { + (None, None) + } else { + (Some(metadata), icon) + } +} + +fn metadata_order(loader: Option) -> [ModMetadataKind; 5] { + match loader { + Some(ModLoader::Fabric) => [ + ModMetadataKind::Fabric, + ModMetadataKind::Quilt, + ModMetadataKind::NeoForge, + ModMetadataKind::Forge, + ModMetadataKind::LegacyForge, + ], + Some(ModLoader::Quilt) => [ + ModMetadataKind::Quilt, + ModMetadataKind::Fabric, + ModMetadataKind::NeoForge, + ModMetadataKind::Forge, + ModMetadataKind::LegacyForge, + ], + Some(ModLoader::Forge) => [ + ModMetadataKind::Forge, + ModMetadataKind::LegacyForge, + ModMetadataKind::NeoForge, + ModMetadataKind::Fabric, + ModMetadataKind::Quilt, + ], + Some(ModLoader::NeoForge) => [ + ModMetadataKind::NeoForge, + ModMetadataKind::Forge, + ModMetadataKind::LegacyForge, + ModMetadataKind::Fabric, + ModMetadataKind::Quilt, + ], + Some(ModLoader::Vanilla) | None => [ + ModMetadataKind::Fabric, + ModMetadataKind::Quilt, + ModMetadataKind::NeoForge, + ModMetadataKind::Forge, + ModMetadataKind::LegacyForge, + ], + } +} + +fn parse_fabric_metadata( + archive: &mut ZipArchive, +) -> Option<(EmbeddedContentMetadata, Option)> { + let root = read_json_entry(archive, "fabric.mod.json")?; + let metadata = EmbeddedContentMetadata { + name: root + .get("name") + .and_then(JsonValue::as_str) + .or_else(|| root.get("id").and_then(JsonValue::as_str)) + .and_then(|value| clean_string(value, MAX_NAME_CHARS)), + version: root + .get("version") + .and_then(json_scalar_string) + .and_then(|value| clean_string(&value, MAX_VERSION_CHARS)), + ..Default::default() + }; + let icon = root.get("icon").and_then(json_icon_path); + Some((metadata, icon)) +} + +fn parse_quilt_metadata( + archive: &mut ZipArchive, +) -> Option<(EmbeddedContentMetadata, Option)> { + let text = read_text_entry(archive, "quilt.mod.json")?; + let root = json5::from_str::(&text).ok()?; + let loader = root.get("quilt_loader").unwrap_or(&root); + let display = loader.get("metadata").unwrap_or(loader); + let metadata = EmbeddedContentMetadata { + name: display + .get("name") + .and_then(JsonValue::as_str) + .or_else(|| loader.get("id").and_then(JsonValue::as_str)) + .and_then(|value| clean_string(value, MAX_NAME_CHARS)), + version: loader + .get("version") + .and_then(json_scalar_string) + .and_then(|value| clean_string(&value, MAX_VERSION_CHARS)), + ..Default::default() + }; + let icon = display + .get("icon") + .or_else(|| loader.get("icon")) + .and_then(json_icon_path); + Some((metadata, icon)) +} + +fn parse_toml_metadata( + archive: &mut ZipArchive, + path: &str, + manifest: &HashMap, +) -> Option<(EmbeddedContentMetadata, Option)> { + let text = read_text_entry(archive, path)?; + let root = toml::from_str::(&text).ok()?; + let mod_table = root.get("mods")?.as_array()?.first()?.as_table()?; + let name = mod_table + .get("displayName") + .or_else(|| mod_table.get("modId")) + .and_then(toml_scalar_string) + .and_then(|value| clean_string(&value, MAX_NAME_CHARS)); + let version = mod_table + .get("version") + .and_then(toml_scalar_string) + .and_then(|value| resolve_toml_value(&value, &root, manifest)) + .and_then(|value| clean_string(&value, MAX_VERSION_CHARS)); + let icon = mod_table + .get("logoFile") + .and_then(TomlValue::as_str) + .and_then(safe_archive_path); + + Some(( + EmbeddedContentMetadata { + name, + version, + ..Default::default() + }, + icon, + )) +} + +fn parse_legacy_forge_metadata( + archive: &mut ZipArchive, +) -> Option<(EmbeddedContentMetadata, Option)> { + let root = read_json_entry(archive, "mcmod.info")?; + let entry = root + .as_array() + .and_then(|entries| entries.first()) + .or_else(|| { + root.get("modList") + .and_then(JsonValue::as_array) + .and_then(|entries| entries.first()) + })?; + let metadata = EmbeddedContentMetadata { + name: entry + .get("name") + .and_then(JsonValue::as_str) + .or_else(|| entry.get("modid").and_then(JsonValue::as_str)) + .and_then(|value| clean_string(value, MAX_NAME_CHARS)), + version: entry + .get("version") + .and_then(json_scalar_string) + .and_then(|value| clean_string(&value, MAX_VERSION_CHARS)), + ..Default::default() + }; + let icon = entry + .get("logoFile") + .and_then(JsonValue::as_str) + .and_then(safe_archive_path); + Some((metadata, icon)) +} + +fn resolve_toml_value( + value: &str, + root: &TomlValue, + manifest: &HashMap, +) -> Option { + if value == "${file.jarVersion}" { + return manifest.get("implementation-version").cloned(); + } + if let Some(key) = value + .strip_prefix("${file.") + .and_then(|value| value.strip_suffix('}')) + { + return root + .get("properties") + .and_then(TomlValue::as_table) + .and_then(|properties| properties.get(key)) + .and_then(toml_scalar_string); + } + Some(value.to_string()) +} + +fn json_icon_path(value: &JsonValue) -> Option { + if let Some(path) = value.as_str() { + return safe_archive_path(path); + } + let icons = value.as_object()?; + let mut candidates = icons + .iter() + .filter_map(|(size, path)| { + Some(( + size.parse::().ok()?, + safe_archive_path(path.as_str()?)?, + )) + }) + .collect::>(); + candidates.sort_by_key(|(size, _)| *size); + candidates + .iter() + .find(|(size, _)| *size >= PREFERRED_ICON_SIZE) + .or_else(|| candidates.last()) + .map(|(_, path)| path.clone()) +} + +fn safe_archive_path(path: &str) -> Option { + let path = path.trim().trim_start_matches('/'); + if path.is_empty() + || path.contains('\0') + || path.contains('\\') + || path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return None; + } + Some(path.to_string()) +} + +fn clean_string(value: &str, max_chars: usize) -> Option { + let value = value.trim(); + if value.is_empty() || value.contains("${") { + return None; + } + let value = value + .chars() + .filter(|character| !character.is_control()) + .take(max_chars) + .collect::(); + (!value.is_empty()).then_some(value) +} + +fn json_scalar_string(value: &JsonValue) -> Option { + match value { + JsonValue::String(value) => Some(value.clone()), + JsonValue::Number(value) => Some(value.to_string()), + _ => None, + } +} + +fn toml_scalar_string(value: &TomlValue) -> Option { + match value { + TomlValue::String(value) => Some(value.clone()), + TomlValue::Integer(value) => Some(value.to_string()), + TomlValue::Float(value) => Some(value.to_string()), + _ => None, + } +} + +fn read_manifest( + archive: &mut ZipArchive, +) -> HashMap { + let Some(text) = read_text_entry(archive, "META-INF/MANIFEST.MF") else { + return HashMap::new(); + }; + let mut attributes: HashMap = HashMap::new(); + let mut current_key: Option = None; + for line in text.lines() { + if let Some(continuation) = line.strip_prefix(' ') { + if let Some(key) = current_key.as_ref() + && let Some(value) = attributes.get_mut(key) + { + value.push_str(continuation); + } + continue; + } + let Some((key, value)) = line.split_once(':') else { + current_key = None; + continue; + }; + let key = key.trim().to_ascii_lowercase(); + attributes.insert(key.clone(), value.trim_start().to_string()); + current_key = Some(key); + } + attributes +} + +fn has_entry(archive: &mut ZipArchive, path: &str) -> bool { + archive.by_name(path).is_ok() +} + +fn read_json_entry( + archive: &mut ZipArchive, + path: &str, +) -> Option { + let text = read_text_entry(archive, path)?; + serde_json::from_str(&text).ok() +} + +fn read_text_entry( + archive: &mut ZipArchive, + path: &str, +) -> Option { + let bytes = read_entry(archive, path, MAX_METADATA_BYTES)?; + String::from_utf8(bytes).ok() +} + +fn read_icon_entry( + archive: &mut ZipArchive, + path: &str, +) -> Option { + let path = safe_archive_path(path)?; + read_entry(archive, &path, MAX_ICON_BYTES).map(Bytes::from) +} + +fn read_entry( + archive: &mut ZipArchive, + path: &str, + max_bytes: u64, +) -> Option> { + let entry = archive.by_name(path).ok()?; + if entry.size() > max_bytes { + return None; + } + let mut bytes = Vec::with_capacity(entry.size() as usize); + entry + .take(max_bytes.saturating_add(1)) + .read_to_end(&mut bytes) + .ok()?; + (bytes.len() as u64 <= max_bytes).then_some(bytes) +} + +pub(crate) async fn resolve_embedded_content_metadata( + instance: &Instance, + loader: ModLoader, + files: &[(String, ContentFile)], + state: &State, +) -> crate::Result> { + let candidates = files + .iter() + .filter(|(_, file)| { + file.metadata.is_none() + && matches!( + file.project_type, + ProjectType::Mod + | ProjectType::DataPack + | ProjectType::ResourcePack + ) + }) + .map(|(relative_path, file)| { + ( + file.hash.clone(), + state + .directories + .instances_dir() + .join(&instance.path) + .join(relative_path), + ) + }) + .collect::>(); + if candidates.is_empty() { + return Ok(HashMap::new()); + } + + let cache_keys = candidates + .keys() + .map(|hash| metadata_cache_key(hash, loader)) + .collect::>(); + let cache_key_refs = + cache_keys.iter().map(String::as_str).collect::>(); + let cached = CachedEntry::get_embedded_content_metadata_many( + &cache_key_refs, + None, + &state.pool, + &state.api_semaphore, + ) + .await?; + let mut resolved = HashMap::new(); + let mut resolved_hashes = HashSet::new(); + for cached in cached { + let icon_exists = cached + .metadata + .as_ref() + .and_then(|metadata| metadata.icon_path.as_deref()) + .is_none_or(|path| Path::new(path).is_file()); + if icon_exists { + resolved_hashes.insert(cached.hash.clone()); + if let Some(metadata) = cached.metadata { + resolved.insert(cached.hash, metadata); + } + } + } + + let pending = candidates + .into_iter() + .filter(|(hash, _)| !resolved_hashes.contains(hash)) + .collect::>(); + let inspected_metadata = stream::iter(pending) + .map(|(hash, path)| async move { + let inspection = tokio::task::spawn_blocking(move || { + inspect_content_file(&path, loader) + }) + .await; + let (mut metadata, icon) = match inspection { + Ok(Ok(inspection)) => { + (inspection.metadata.unwrap_or_default(), inspection.icon) + } + Ok(Err(error)) => { + tracing::debug!( + hash, + error = %error, + "Could not inspect content metadata" + ); + return None; + } + Err(error) => { + tracing::debug!( + hash, + error = %error, + "Content metadata inspection task failed" + ); + return None; + } + }; + if let Some(icon) = icon { + match crate::api::instance::cache_icon(icon, state).await { + Ok(path) => { + metadata.icon_path = + Some(path.to_string_lossy().to_string()); + } + Err(error) => { + tracing::debug!( + hash, + error = %error, + "Could not cache embedded content icon" + ); + } + } + } + let metadata = (!metadata.is_empty()).then_some(metadata); + Some((hash, metadata)) + }) + .buffer_unordered(8) + .collect::>() + .await; + let mut entries = Vec::with_capacity(inspected_metadata.len()); + for (hash, metadata) in inspected_metadata.into_iter().flatten() { + if let Some(metadata) = metadata.as_ref() { + resolved.insert(hash.clone(), metadata.clone()); + } + entries.push( + CacheValue::EmbeddedContentMetadata( + CachedEmbeddedContentMetadata { + cache_key: metadata_cache_key(&hash, loader), + hash, + metadata, + }, + ) + .get_entry(), + ); + } + CachedEntry::upsert_many(&entries, &state.pool).await?; + + Ok(resolved) +} + +fn metadata_cache_key(hash: &str, loader: ModLoader) -> String { + format!("{hash}-{}", loader.as_str()) +} diff --git a/packages/app-lib/src/state/instances/commands/list_content.rs b/packages/app-lib/src/state/instances/commands/list_content.rs index 69c2d86468..097acd5fd7 100644 --- a/packages/app-lib/src/state/instances/commands/list_content.rs +++ b/packages/app-lib/src/state/instances/commands/list_content.rs @@ -12,7 +12,8 @@ use crate::state::{ CacheBehaviour, CachedEntry, CachedFile, ContentFile, ContentItem, ContentItemOwner, ContentItemProject, ContentItemVersion, Dependency, LinkedModpackInfo, ModLoader, Organization, OwnerType, Project, - ProjectType, ReleaseChannel, TeamMember, Version, + ProjectType, ReleaseChannel, TeamMember, Version, VersionEnvironment, + VersionV3, }; use crate::util::fetch::{ DownloadMeta, DownloadReason, FetchSemaphore, fetch_mirrors, sha1_async, @@ -247,6 +248,7 @@ pub(crate) async fn list_content( content_files_to_content_items( &resolved.instance, + resolved.content_set.loader, &files, cache_behaviour, state, @@ -287,6 +289,7 @@ pub(crate) async fn list_linked_modpack_content( return content_files_to_content_items( &resolved.instance, + resolved.content_set.loader, &files, cache_behaviour, state, @@ -322,6 +325,7 @@ pub(crate) async fn list_linked_modpack_content( content_files_to_content_items( &resolved.instance, + resolved.content_set.loader, &files, cache_behaviour, state, @@ -507,13 +511,9 @@ pub(crate) async fn dependencies_to_content_items( .map(|file| file.size as u64) .unwrap_or(0), enabled: true, + locked: false, project_type, - project: Some(ContentItemProject { - id: project.id.clone(), - slug: project.slug.clone(), - title: project.title.clone(), - icon_url: project.icon_url.clone(), - }), + project: Some(content_item_project(project)), version: version.map(|version| ContentItemVersion { id: version.id.clone(), version_number: version.version_number.clone(), @@ -524,11 +524,16 @@ pub(crate) async fn dependencies_to_content_items( .unwrap_or_default(), date_published: Some(version.date_published.to_rfc3339()), }), + environment: resolve_environment( + dependency.version_id.as_deref(), + &meta.versions_v3, + ), owner, has_update: false, update_version_id: None, date_added: None, source_kind: None, + embedded_metadata: None, }) }) .collect::>(); @@ -604,6 +609,11 @@ async fn content_projects_for_scope( entry.file_id.as_deref().map(|file_id| (file_id, entry)) }) .collect::>(); + let locked_file_ids = sqlite::content_rows::get_locked_instance_file_ids( + &resolved.instance.id, + &state.pool, + ) + .await?; let hashes = files .iter() .map(|file| file.sha1.as_str()) @@ -736,6 +746,7 @@ async fn content_projects_for_scope( enabled: entry.map_or(file.enabled, |entry| { entry.enabled && file.enabled }), + locked: locked_file_ids.contains(&file.id), size: file.size, metadata: file_metadata_from_entry_or_cache(entry, metadata), project_type, @@ -812,6 +823,7 @@ fn file_update_cache_key( async fn content_files_to_content_items( instance: &Instance, + loader: ModLoader, files: &[(String, ContentFile)], cache_behaviour: Option, state: &State, @@ -840,6 +852,11 @@ async fn content_files_to_content_items( &state.api_semaphore, ) .await?; + let embedded_metadata = + super::embedded_content_metadata::resolve_embedded_content_metadata( + instance, loader, files, state, + ) + .await?; let instance_path = state.directories.instances_dir().join(&instance.path); let paths = files .iter() @@ -885,19 +902,21 @@ async fn content_files_to_content_items( id: file.hash.clone(), size: file.size, enabled: file.enabled, + locked: file.locked, project_type: file.project_type, - project: project.map(|project| ContentItemProject { - id: project.id.clone(), - slug: project.slug.clone(), - title: project.title.clone(), - icon_url: project.icon_url.clone(), - }), + project: project.map(content_item_project), version: version.map(|version| ContentItemVersion { id: version.id.clone(), version_number: version.version_number.clone(), file_name: file.file_name.clone(), date_published: Some(version.date_published.to_rfc3339()), }), + environment: resolve_environment( + file.metadata + .as_ref() + .map(|metadata| metadata.version_id.as_str()), + &meta.versions_v3, + ), owner, has_update: file.update_version_id.is_some() && !file.source_kind.is_some_and( @@ -906,6 +925,7 @@ async fn content_files_to_content_items( update_version_id: file.update_version_id.clone(), date_added: modification_times[index].clone(), source_kind: file.source_kind, + embedded_metadata: embedded_metadata.get(&file.hash).cloned(), } }) .collect::>(); @@ -917,6 +937,7 @@ async fn content_files_to_content_items( struct ResolvedMetadata { projects: Vec, versions: Vec, + versions_v3: Vec, teams: Vec>, organizations: Vec, } @@ -932,7 +953,7 @@ async fn resolve_metadata( project_ids.iter().map(String::as_str).collect::>(); let version_id_refs = version_ids.iter().map(String::as_str).collect::>(); - let (projects, versions) = + let (projects, versions, versions_v3) = if !project_ids.is_empty() || !version_ids.is_empty() { tokio::try_join!( async { @@ -960,10 +981,23 @@ async fn resolve_metadata( ) .await } + }, + async { + if version_ids.is_empty() { + Ok(Vec::new()) + } else { + CachedEntry::get_version_v3_many( + &version_id_refs, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await + } } )? } else { - (Vec::new(), Vec::new()) + (Vec::new(), Vec::new(), Vec::new()) }; let team_ids = projects .iter() @@ -1012,11 +1046,23 @@ async fn resolve_metadata( Ok(ResolvedMetadata { projects, versions, + versions_v3, teams, organizations, }) } +fn resolve_environment( + version_id: Option<&str>, + versions: &[VersionV3], +) -> Option { + let version_id = version_id?; + versions + .iter() + .find(|version| version.id == version_id) + .and_then(|version| version.environment) +} + fn resolve_owner( project: &Project, teams: &[Vec], @@ -1049,6 +1095,18 @@ fn resolve_owner( } } +fn content_item_project(project: &Project) -> ContentItemProject { + ContentItemProject { + id: project.id.clone(), + slug: project.slug.clone(), + title: project.title.clone(), + icon_url: project.icon_url.clone(), + license: project.license.clone(), + categories: project.categories.clone(), + additional_categories: project.additional_categories.clone(), + } +} + fn file_metadata_from_entry_or_cache( entry: Option<&ContentEntry>, cached: Option, diff --git a/packages/app-lib/src/state/instances/commands/mod.rs b/packages/app-lib/src/state/instances/commands/mod.rs index b969a1c44b..5230572e18 100644 --- a/packages/app-lib/src/state/instances/commands/mod.rs +++ b/packages/app-lib/src/state/instances/commands/mod.rs @@ -22,6 +22,8 @@ pub(crate) use self::list_content::{ list_linked_modpack_content, }; +mod embedded_content_metadata; + mod remove_instance; pub(crate) use self::remove_instance::*; diff --git a/packages/app-lib/src/state/instances/content.rs b/packages/app-lib/src/state/instances/content.rs index 6a0838ab6e..e8760d4fe9 100644 --- a/packages/app-lib/src/state/instances/content.rs +++ b/packages/app-lib/src/state/instances/content.rs @@ -1,5 +1,7 @@ use super::ContentSourceKind; -use crate::state::{Project, ProjectType, Version}; +use crate::state::{ + License, Project, ProjectType, Version, VersionEnvironment, +}; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -9,14 +11,32 @@ pub struct ContentItem { pub id: String, pub size: u64, pub enabled: bool, + pub locked: bool, pub project_type: ProjectType, pub project: Option, pub version: Option, + pub environment: Option, pub owner: Option, pub has_update: bool, pub update_version_id: Option, pub date_added: Option, pub source_kind: Option, + pub embedded_metadata: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct EmbeddedContentMetadata { + pub name: Option, + pub version: Option, + pub icon_path: Option, +} + +impl EmbeddedContentMetadata { + pub fn is_empty(&self) -> bool { + self.name.is_none() + && self.version.is_none() + && self.icon_path.is_none() + } } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -25,6 +45,9 @@ pub struct ContentItemProject { pub slug: Option, pub title: String, pub icon_url: Option, + pub license: License, + pub categories: Vec, + pub additional_categories: Vec, } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/packages/app-lib/src/state/instances/mod.rs b/packages/app-lib/src/state/instances/mod.rs index 5469e9e898..3be87083a7 100644 --- a/packages/app-lib/src/state/instances/mod.rs +++ b/packages/app-lib/src/state/instances/mod.rs @@ -11,8 +11,8 @@ pub use self::commands::{ InstanceLaunchOverridesPatch, InstanceMetadata, }; pub(crate) use self::commands::{ - attach_shared_instance, clear_shared_instance, mark_shared_instance_stale, - quarantine_shared_instance, set_shared_instance_sync_status, + attach_shared_instance, clear_shared_instance, quarantine_shared_instance, + set_shared_instance_sync_status, }; pub(crate) use self::commands::{ create_instance, edit_instance, get_instance, get_instances_metadata, diff --git a/packages/app-lib/src/state/instances/watcher.rs b/packages/app-lib/src/state/instances/watcher.rs index b7e6dd86c9..acd0e5524d 100644 --- a/packages/app-lib/src/state/instances/watcher.rs +++ b/packages/app-lib/src/state/instances/watcher.rs @@ -138,7 +138,7 @@ pub async fn init_watcher() -> crate::Result { }; if let Some(event) = event { let emit_instance_id = instance_id.clone(); - let mark_shared_stale = first_file_name + let sync_content = first_file_name .as_ref() .is_some_and(|name| { ProjectType::iterator().any( @@ -150,18 +150,18 @@ pub async fn init_watcher() -> crate::Result { ) }); tokio::spawn(async move { - if mark_shared_stale + if sync_content && let Ok(state) = State::get().await && let Err(error) = - crate::state::mark_shared_instance_stale( + crate::state::sync_content_files( &emit_instance_id, - &state.pool, + &state, ) .await { tracing::error!( - "Failed to mark shared instance stale after filesystem sync: {error}" + "Failed to sync instance content after filesystem change: {error}" ); } let _ = emit_instance( diff --git a/packages/ui/src/components/base/DropdownFilterBar.vue b/packages/ui/src/components/base/DropdownFilterBar.vue index 796b8907f7..131843cc00 100644 --- a/packages/ui/src/components/base/DropdownFilterBar.vue +++ b/packages/ui/src/components/base/DropdownFilterBar.vue @@ -8,103 +8,126 @@ - - - - + + + + +
@@ -417,6 +449,7 @@ export type DropdownFilterBarCategory = { key: string label: string options: DropdownFilterBarItem[] + direct?: boolean syntheticOptions?: DropdownFilterBarOption[] searchable?: boolean disableLocalOptionsFilter?: boolean @@ -461,6 +494,7 @@ type ViewportRect = { type MenuPositionOptions = { triggerRect: DOMRect dropdownRect: DOMRect + dropdownHeight: number viewport: ViewportRect } @@ -1341,7 +1375,42 @@ function getSubmenuWidthInPixels(category: DropdownFilterBarCategory): number { } function getWidestSubmenuWidthInPixels(categories: DropdownFilterBarCategory[]): number { - return Math.max(...categories.map((category) => getSubmenuWidthInPixels(category)), 0) + return Math.max( + ...categories + .filter((category) => !category.direct) + .map((category) => getSubmenuWidthInPixels(category)), + 0, + ) +} + +function getDirectCategoryOption(category: DropdownFilterBarCategory) { + if (!category.direct) return undefined + return category.options.find(isDropdownFilterOption) +} + +function isDirectCategorySelected(category: DropdownFilterBarCategory) { + const option = getDirectCategoryOption(category) + return !!option && isFilterValueSelected(category.key, option.value) +} + +function deactivateCategory() { + clearPendingCategoryTimeout() + pendingCategoryKey.value = null + activeCategoryKey.value = null + categorySearchQuery.value = '' + hasSubmenuPosition.value = false +} + +function handleCategoryClick(category: DropdownFilterBarCategory) { + const option = getDirectCategoryOption(category) + if (!option) { + activateCategory(category.key) + return + } + + deactivateCategory() + toggleFilterOption(category.key, option) + scheduleAddMenuPositionUpdate() } function activateCategory(categoryKey: string) { @@ -1357,18 +1426,27 @@ function activateCategory(categoryKey: string) { } } -function handleCategoryFocus(categoryKey: string) { +function handleCategoryFocus(category: DropdownFilterBarCategory) { + if (category.direct) { + deactivateCategory() + return + } if (isMobileAddMenuLayout.value) { return } - activateCategory(categoryKey) + activateCategory(category.key) } -function handleCategoryMouseEnter(categoryKey: string) { +function handleCategoryMouseEnter(category: DropdownFilterBarCategory) { + if (category.direct) { + deactivateCategory() + return + } if (isMobileAddMenuLayout.value) { return } + const categoryKey = category.key if (!activeCategoryKey.value) { activateCategory(categoryKey) @@ -1448,7 +1526,12 @@ function getViewportRect(): ViewportRect { } } -function getAddMenuPosition({ triggerRect, dropdownRect, viewport }: MenuPositionOptions) { +function getAddMenuPosition({ + triggerRect, + dropdownRect, + dropdownHeight, + viewport, +}: MenuPositionOptions) { const dropdownWidth = Math.max(ADD_MENU_WIDTH, triggerRect.width) const positionedDropdownWidth = Math.max(dropdownRect.width, dropdownWidth) const triggerTop = triggerRect.top + viewport.offsetTop @@ -1463,18 +1546,24 @@ function getAddMenuPosition({ triggerRect, dropdownRect, viewport }: MenuPositio minLeft, viewportRight - positionedDropdownWidth - DROPDOWN_VIEWPORT_MARGIN, ) - const hasSpaceBelow = - triggerBottom + dropdownRect.height + DROPDOWN_GAP + DROPDOWN_VIEWPORT_MARGIN <= viewportBottom - const hasSpaceAbove = - triggerTop - dropdownRect.height - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN > viewportTop - const opensUp = !hasSpaceBelow && hasSpaceAbove + const spaceBelow = Math.max( + 0, + viewportBottom - triggerBottom - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN, + ) + const spaceAbove = Math.max(0, triggerTop - viewportTop - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN) + const hasSpaceBelow = dropdownHeight <= spaceBelow + const hasSpaceAbove = dropdownHeight <= spaceAbove + const opensUp = !hasSpaceBelow && (hasSpaceAbove || spaceAbove > spaceBelow) + const availableHeight = opensUp ? spaceAbove : spaceBelow + const positionedDropdownHeight = Math.min(dropdownHeight, availableHeight) const top = opensUp - ? triggerTop - dropdownRect.height - DROPDOWN_GAP + ? triggerTop - positionedDropdownHeight - DROPDOWN_GAP : triggerBottom + DROPDOWN_GAP const left = Math.min(Math.max(minLeft, triggerLeft), maxLeft) return { left: `${left}px`, + maxHeight: `${availableHeight}px`, minWidth: `${triggerRect.width}px`, top: `${Math.max(viewportTop + DROPDOWN_VIEWPORT_MARGIN, top)}px`, width: `${dropdownWidth}px`, @@ -1601,6 +1690,7 @@ function updateAddMenuPosition(): boolean { addMenuStyle.value = getAddMenuPosition({ triggerRect, dropdownRect, + dropdownHeight: positioningElement.scrollHeight, viewport, }) return true diff --git a/packages/ui/src/components/base/FilterPills.vue b/packages/ui/src/components/base/FilterPills.vue index 4a5d052d98..03c5c678e8 100644 --- a/packages/ui/src/components/base/FilterPills.vue +++ b/packages/ui/src/components/base/FilterPills.vue @@ -1,22 +1,26 @@ @@ -36,10 +40,10 @@ defineProps<{ function pillClass(active: boolean) { return [ - 'cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]', + 'cursor-pointer rounded-xl border border-solid px-3 py-1.5 text-sm font-medium leading-5 transition-all duration-100 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow', active ? 'border-brand bg-brand-highlight text-brand' - : 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5', + : 'border-surface-5 bg-transparent text-primary hover:bg-surface-3', ] } @@ -51,3 +55,9 @@ function toggle(id: string) { } } + + diff --git a/packages/ui/src/components/base/buttons/ButtonFrame.vue b/packages/ui/src/components/base/buttons/ButtonFrame.vue index 81d0c09f8d..0503a3d3d7 100644 --- a/packages/ui/src/components/base/buttons/ButtonFrame.vue +++ b/packages/ui/src/components/base/buttons/ButtonFrame.vue @@ -46,7 +46,7 @@ const typeClasses: Record = { colored: 'button-frame--colored bg-[--button-color] text-[var(--color-accent-contrast)] [&>svg]:text-inherit', outlined: - 'button-frame--outlined bg-transparent text-[var(--button-color,var(--color-contrast))] [&>svg]:text-inherit', + 'button-frame--outlined bg-transparent text-[var(--button-color,var(--color-contrast))] [&>svg]:text-[var(--button-color,var(--color-base))]', quiet: 'button-frame--quiet bg-transparent [&>svg]:text-inherit', } diff --git a/packages/ui/src/layouts/shared/browse-tab/header.vue b/packages/ui/src/layouts/shared/browse-tab/header.vue index 34ee71ef27..94a7de5f3a 100644 --- a/packages/ui/src/layouts/shared/browse-tab/header.vue +++ b/packages/ui/src/layouts/shared/browse-tab/header.vue @@ -178,7 +178,7 @@ async function handleSelectedProjectsLeaveResult( - + {{ installContext.warning }} diff --git a/packages/ui/src/layouts/shared/browse-tab/sidebar.vue b/packages/ui/src/layouts/shared/browse-tab/sidebar.vue index 399d3153b2..ca96076b0c 100644 --- a/packages/ui/src/layouts/shared/browse-tab/sidebar.vue +++ b/packages/ui/src/layouts/shared/browse-tab/sidebar.vue @@ -224,6 +224,9 @@ function getFilterOpenByDefault(filterId: string): boolean { + diff --git a/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue b/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue index 1070c4fc84..b89de16764 100644 --- a/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue +++ b/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue @@ -2,11 +2,13 @@ import { ArrowLeftRightIcon, DownloadIcon, + LockIcon, MoreVerticalIcon, SpinnerIcon, TrashExclamationIcon, TrashIcon, TriangleAlertIcon, + UploadIcon, } from '@modrinth/assets' import { useMagicKeys } from '@vueuse/core' import { computed, getCurrentInstance, ref } from 'vue' @@ -38,6 +40,14 @@ const messages = defineMessages({ id: 'content.card.select-project', defaultMessage: 'Select {project}', }, + uploaded: { + id: 'content.card.uploaded', + defaultMessage: 'Uploaded', + }, + frozen: { + id: 'content.card.frozen', + defaultMessage: 'This project is locked to its current version until unfrozen.', + }, }) interface Props { @@ -47,7 +57,9 @@ interface Props { versionLink?: string | RouteLocationRaw owner?: ContentOwner source?: ContentSource + external?: boolean enabled?: boolean + locked?: boolean installing?: boolean hasUpdate?: boolean isClientOnly?: boolean @@ -58,6 +70,7 @@ interface Props { disabledTooltip?: string | null toggleDisabled?: boolean toggleDisabledTooltip?: string | null + hideToggle?: boolean showCheckbox?: boolean hideDelete?: boolean hideActions?: boolean @@ -70,7 +83,9 @@ const props = withDefaults(defineProps(), { versionLink: undefined, owner: undefined, source: undefined, + external: false, enabled: undefined, + locked: false, installing: false, hasUpdate: false, isClientOnly: false, @@ -81,6 +96,7 @@ const props = withDefaults(defineProps(), { disabledTooltip: undefined, toggleDisabled: false, toggleDisabledTooltip: undefined, + hideToggle: false, showCheckbox: false, hideDelete: false, hideActions: false, @@ -243,7 +259,11 @@ const deleteHovered = ref(false) /> {{ owner.name }} -