-
+
+
diff --git a/packages/ui/src/components/servers/labels/ServerSubdomainLabel.vue b/packages/ui/src/components/servers/labels/ServerSubdomainLabel.vue
index 2b528ce8c6..61afa973ba 100644
--- a/packages/ui/src/components/servers/labels/ServerSubdomainLabel.vue
+++ b/packages/ui/src/components/servers/labels/ServerSubdomainLabel.vue
@@ -2,9 +2,9 @@
-
+
@@ -26,7 +26,22 @@
diff --git a/packages/ui/src/layouts/shared/files-tab/components/FileTableHeader.vue b/packages/ui/src/layouts/shared/files-tab/components/FileTableHeader.vue
new file mode 100644
index 0000000000..490ee14d71
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/components/FileTableHeader.vue
@@ -0,0 +1,136 @@
+
+
+
+
+
+ {{ formatMessage(messages.name) }}
+
+
+
+
+
+
+ {{ formatMessage(messages.size) }}
+
+
+
+
+ {{ formatMessage(messages.created) }}
+
+
+
+
+ {{ formatMessage(messages.modified) }}
+
+
+
+ {{
+ formatMessage(commonMessages.actionsLabel)
+ }}
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/files-tab/components/FileTableRow.vue b/packages/ui/src/layouts/shared/files-tab/components/FileTableRow.vue
new file mode 100644
index 0000000000..cf655b5b1f
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/components/FileTableRow.vue
@@ -0,0 +1,383 @@
+
+ e.key === 'Enter' && selectItem()"
+ @mouseenter="handleMouseEnter"
+ @pointerdown="handlePointerDown"
+ >
+
+
+
+
+
+
+
+ {{ name }}
+
+
+
+
+
+ {{ formattedSize }}
+
+
+ {{ formattedCreationDate }}
+
+
+ {{ formattedModifiedDate }}
+
+
+
+
+
+ {{ formatMessage(messages.copyFilename) }}
+ {{ formatMessage(messages.copyFullPath) }}
+ {{ formatMessage(messages.openInFolder) }}
+ {{ formatMessage(messages.extractLabel) }}
+ {{ formatMessage(messages.renameLabel) }}
+ {{ formatMessage(messages.moveLabel) }}
+
+ {{
+ ctx.downloadButtonLabel ?? formatMessage(commonMessages.downloadButton)
+ }}
+ {{ formatMessage(commonMessages.deleteLabel) }}
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/files-tab/components/editor/FileEditor.vue b/packages/ui/src/layouts/shared/files-tab/components/editor/FileEditor.vue
new file mode 100644
index 0000000000..e3f4ada2c9
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/components/editor/FileEditor.vue
@@ -0,0 +1,288 @@
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/files-tab/components/editor/FileImageViewer.vue b/packages/ui/src/layouts/shared/files-tab/components/editor/FileImageViewer.vue
new file mode 100644
index 0000000000..6bd8bfec48
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/components/editor/FileImageViewer.vue
@@ -0,0 +1,178 @@
+
+
+
+
+
+ {{ state.errorMessage || formatMessage(messages.invalidImage) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ Math.round(scale * 100) }}%
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/files-tab/components/modals/FileCreateItemModal.vue b/packages/ui/src/layouts/shared/files-tab/components/modals/FileCreateItemModal.vue
new file mode 100644
index 0000000000..8811c1a2b9
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/components/modals/FileCreateItemModal.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
+ {{ formatMessage(messages.createButton, { type }) }}
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/files-tab/components/modals/FileDeleteItemModal.vue b/packages/ui/src/layouts/shared/files-tab/components/modals/FileDeleteItemModal.vue
new file mode 100644
index 0000000000..7c705476dc
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/components/modals/FileDeleteItemModal.vue
@@ -0,0 +1,80 @@
+
+
+
+ {{ formatMessage(messages.deletingName, { name: item?.name }) }}
+ {{ formatMessage(messages.deleteWarning, { type: item?.type }) }}
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
+ {{ formatMessage(commonMessages.deleteLabel) }}
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/files-tab/components/modals/FileMoveItemModal.vue b/packages/ui/src/layouts/shared/files-tab/components/modals/FileMoveItemModal.vue
new file mode 100644
index 0000000000..40d9cdd9b8
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/components/modals/FileMoveItemModal.vue
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
+ {{ formatMessage(messages.moveButton) }}
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/files-tab/components/modals/FileRenameItemModal.vue b/packages/ui/src/layouts/shared/files-tab/components/modals/FileRenameItemModal.vue
new file mode 100644
index 0000000000..fbeab3fb0c
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/components/modals/FileRenameItemModal.vue
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
+ {{ formatMessage(messages.renameButton) }}
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/files-tab/components/modals/FileUploadConflictModal.vue b/packages/ui/src/layouts/shared/files-tab/components/modals/FileUploadConflictModal.vue
new file mode 100644
index 0000000000..9050d67392
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/components/modals/FileUploadConflictModal.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+ {{ formatMessage(messages.overwriteManyWarning) }}
+
+
+ {{ formatMessage(messages.overwriteWarning, { count: files.length }) }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.overwrittenCount, { count: files.length }) }}
+
+
+
+
+
+
+
+
{{
+ formatMessage(messages.overwrittenLabel)
+ }}
+
+ {{ file }}
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
+ {{ formatMessage(messages.overwriteButton) }}
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/files-tab/components/modals/FileUploadZipUrlModal.vue b/packages/ui/src/layouts/shared/files-tab/components/modals/FileUploadZipUrlModal.vue
new file mode 100644
index 0000000000..1a6824ae8c
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/components/modals/FileUploadZipUrlModal.vue
@@ -0,0 +1,291 @@
+
+
+
+
+
+
+
+
+
+ {{
+ submitted
+ ? formatMessage(commonMessages.closeButton)
+ : formatMessage(commonMessages.cancelButton)
+ }}
+
+
+
+
+
+
+ {{
+ submitted
+ ? formatMessage(commonMessages.installingLabel)
+ : formatMessage(messages.installButton)
+ }}
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/shared/files-tab/components/modals/file-validation-messages.ts b/packages/ui/src/layouts/shared/files-tab/components/modals/file-validation-messages.ts
new file mode 100644
index 0000000000..f5d7db1147
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/components/modals/file-validation-messages.ts
@@ -0,0 +1,22 @@
+import { defineMessages } from '#ui/composables/i18n'
+
+export const fileValidationMessages = defineMessages({
+ nameLabel: {
+ id: 'files.validation.name-label',
+ defaultMessage: 'Name',
+ },
+ nameRequired: {
+ id: 'files.validation.name-required',
+ defaultMessage: 'Name is required.',
+ },
+ nameInvalidFile: {
+ id: 'files.validation.name-invalid-file',
+ defaultMessage:
+ 'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.',
+ },
+ nameInvalidDirectory: {
+ id: 'files.validation.name-invalid-directory',
+ defaultMessage:
+ 'Name must contain only alphanumeric characters, dashes, underscores, or spaces.',
+ },
+})
diff --git a/packages/ui/src/components/servers/files/upload/FileUploadDragAndDrop.vue b/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDragAndDrop.vue
similarity index 73%
rename from packages/ui/src/components/servers/files/upload/FileUploadDragAndDrop.vue
rename to packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDragAndDrop.vue
index 3d03699feb..6a9900fcf0 100644
--- a/packages/ui/src/components/servers/files/upload/FileUploadDragAndDrop.vue
+++ b/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDragAndDrop.vue
@@ -16,7 +16,11 @@
- Drop {{ type ? type.toLocaleLowerCase() : 'file' }}s here to upload
+ {{
+ formatMessage(messages.dropToUpload, {
+ type: type ? type.toLocaleLowerCase() : undefined,
+ })
+ }}
@@ -27,6 +31,10 @@
import { UploadIcon } from '@modrinth/assets'
import { ref } from 'vue'
+import { defineMessages, useVIntl } from '#ui/composables/i18n'
+
+const { formatMessage } = useVIntl()
+
const emit = defineEmits<{
filesDropped: [files: File[]]
}>()
@@ -36,15 +44,20 @@ defineProps<{
type?: string
}>()
+const messages = defineMessages({
+ dropToUpload: {
+ id: 'files.upload.drag-and-drop.drop-to-upload',
+ defaultMessage: 'Drop {type, select, undefined {files} other {{type}s}} here to upload',
+ },
+})
+
const isDragging = ref(false)
const dragCounter = ref(0)
const handleDragEnter = (event: DragEvent) => {
event.preventDefault()
- if (!event.dataTransfer?.types.includes('application/modrinth-file-move')) {
- dragCounter.value++
- isDragging.value = true
- }
+ dragCounter.value++
+ isDragging.value = true
}
const handleDragOver = (event: DragEvent) => {
@@ -64,9 +77,6 @@ const handleDrop = (event: DragEvent) => {
isDragging.value = false
dragCounter.value = 0
- const isInternalMove = event.dataTransfer?.types.includes('application/modrinth-file-move')
- if (isInternalMove) return
-
const files = event.dataTransfer?.files
if (files) {
emit('filesDropped', Array.from(files))
diff --git a/packages/ui/src/components/servers/files/upload/FileUploadDropdown.vue b/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDropdown.vue
similarity index 75%
rename from packages/ui/src/components/servers/files/upload/FileUploadDropdown.vue
rename to packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDropdown.vue
index 580754c31f..c12bbae205 100644
--- a/packages/ui/src/components/servers/files/upload/FileUploadDropdown.vue
+++ b/packages/ui/src/layouts/shared/files-tab/components/upload/FileUploadDropdown.vue
@@ -12,9 +12,17 @@
- {{ props.fileType ? props.fileType : 'File' }} uploads
+ {{
+ formatMessage(messages.fileUploads, {
+ fileType: props.fileType ? props.fileType : formatMessage(messages.file),
+ })
+ }}
- {{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : '' }}
+ {{
+ activeUploads.length > 0
+ ? formatMessage(messages.uploadsLeft, { count: activeUploads.length })
+ : ''
+ }}
@@ -52,18 +60,20 @@
- Done
+ {{ formatMessage(messages.done) }}
- Failed - File already exists
+ {{ formatMessage(messages.failedFileExists) }}
- Failed - {{ item.error?.message || 'An unexpected error occured.' }}
+ {{
+ formatMessage(messages.failedGeneric, {
+ error: item.error?.message || formatMessage(messages.unexpectedError),
+ })
+ }}
- Failed - Incorrect file type
+ {{ formatMessage(messages.failedIncorrectType) }}
@@ -75,11 +85,11 @@
/>
- Cancel
+ {{ formatMessage(commonMessages.cancelButton) }}
- Cancelled
+ {{ formatMessage(messages.cancelled) }}
{{ item.progress }}%
@@ -102,12 +112,69 @@
+
+
diff --git a/packages/ui/src/layouts/shared/files-tab/providers/file-manager.ts b/packages/ui/src/layouts/shared/files-tab/providers/file-manager.ts
new file mode 100644
index 0000000000..4eb5ba6e35
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/providers/file-manager.ts
@@ -0,0 +1,72 @@
+import type { ComputedRef, Ref } from 'vue'
+
+import { createContext } from '#ui/providers/create-context'
+
+import type {
+ EditingFile,
+ ExtractDryRunResult,
+ FileItem,
+ FileOperation,
+ UploadState,
+} from '../types'
+
+export interface FileManagerContext {
+ items: Ref
+ loading: Ref
+ error: Ref
+
+ currentPath: Ref
+ navigateTo: (path: string) => void
+
+ editingFile: Ref
+ startEditing: (file: EditingFile) => void
+ stopEditing: () => void
+
+ createItem: (name: string, type: 'file' | 'directory') => Promise
+ renameItem: (path: string, newName: string) => Promise
+ moveItem: (source: string, destination: string) => Promise
+ deleteItem: (path: string, recursive: boolean) => Promise
+
+ readFile: (path: string) => Promise
+ readFileAsBlob: (path: string) => Promise
+ writeFile: (path: string, content: string) => Promise
+ downloadFile: (path: string, fileName: string) => Promise
+
+ uploadFiles: (files: File[]) => void
+ cancelUpload?: () => void
+ uploadState?: Ref | ComputedRef
+
+ refresh: () => void
+
+ isBusy?: Ref | ComputedRef
+ busyTooltip?: Ref | ComputedRef
+ busyWarning?: Ref | ComputedRef
+
+ extractFile?: (
+ path: string,
+ override: boolean,
+ dry: boolean,
+ ) => Promise
+ activeOperations?: Ref | ComputedRef
+ dismissOperation?: (id: string, action: 'dismiss' | 'cancel') => void
+
+ prefetchDirectory?: (path: string) => void
+ prefetchFile?: (path: string) => void
+
+ showInstallFromUrl?: boolean
+ basePath?: Ref | ComputedRef
+ openInFolder?: (path: string) => void
+
+ downloadButtonLabel?: string
+ uploadingLabel?: (completed: number, total: number) => string
+
+ canRestart?: boolean
+ restartServer?: () => Promise
+ canShareToMclogs?: boolean
+ shareToMclogs?: (content: string) => Promise
+}
+
+export const [injectFileManager, provideFileManager] = createContext(
+ 'FilePageLayout',
+ 'fileManagerContext',
+)
diff --git a/packages/ui/src/layouts/shared/files-tab/providers/index.ts b/packages/ui/src/layouts/shared/files-tab/providers/index.ts
new file mode 100644
index 0000000000..8772a59621
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/providers/index.ts
@@ -0,0 +1,2 @@
+export type { FileManagerContext } from './file-manager'
+export { injectFileManager, provideFileManager } from './file-manager'
diff --git a/packages/ui/src/layouts/shared/files-tab/types.ts b/packages/ui/src/layouts/shared/files-tab/types.ts
new file mode 100644
index 0000000000..12416aa5e0
--- /dev/null
+++ b/packages/ui/src/layouts/shared/files-tab/types.ts
@@ -0,0 +1,69 @@
+export interface FileItem {
+ name: string
+ type: 'file' | 'directory' | 'symlink'
+ path: string
+ modified: number
+ created: number
+ size?: number
+ count?: number
+ target?: string
+}
+
+export interface EditingFile {
+ name: string
+ path: string
+}
+
+export type FileSortField = 'name' | 'size' | 'created' | 'modified'
+
+export type FileViewFilter = 'all' | 'filesOnly' | 'foldersOnly'
+
+export type FileContextMenuOption =
+ | {
+ id: string
+ action?: () => void
+ disabled?: boolean
+ tooltip?: string
+ color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'medal-promo'
+ shown?: boolean
+ }
+ | { divider: true; shown?: boolean }
+
+export interface FileOperation {
+ id?: string
+ op: string
+ src: string
+ state: string
+ progress?: number
+ bytes_processed?: number
+ files_processed?: number
+ current_file?: string
+}
+
+export interface UndoableOperation {
+ type: 'move' | 'rename'
+ itemType: string
+ fileName: string
+}
+
+export interface MoveOperation extends UndoableOperation {
+ type: 'move'
+ sourcePath: string
+ destinationPath: string
+}
+
+export interface RenameOperation extends UndoableOperation {
+ type: 'rename'
+ path: string
+ oldName: string
+ newName: string
+}
+
+export type Operation = MoveOperation | RenameOperation
+
+export interface ExtractDryRunResult {
+ modpack_name: string | null
+ conflicting_files: string[]
+}
+
+export type { UploadState } from '@modrinth/api-client'
diff --git a/packages/ui/src/layouts/shared/installation-settings/layout.vue b/packages/ui/src/layouts/shared/installation-settings/layout.vue
index d519bfc11f..d23d53d726 100644
--- a/packages/ui/src/layouts/shared/installation-settings/layout.vue
+++ b/packages/ui/src/layouts/shared/installation-settings/layout.vue
@@ -21,10 +21,10 @@ import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Chips from '#ui/components/base/Chips.vue'
import Combobox from '#ui/components/base/Combobox.vue'
+import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
-import ConfirmLeaveModal from '../content-tab/components/modals/ConfirmLeaveModal.vue'
import ConfirmModpackUpdateModal from '../content-tab/components/modals/ConfirmModpackUpdateModal.vue'
import ConfirmReinstallModal from '../content-tab/components/modals/ConfirmReinstallModal.vue'
import ConfirmRepairModal from '../content-tab/components/modals/ConfirmRepairModal.vue'
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/content.vue b/packages/ui/src/layouts/wrapped/hosting/manage/content.vue
index 077a69604d..5b0751f9af 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/content.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/content.vue
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
+import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
@@ -13,7 +14,6 @@ import {
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
-import ConfirmLeaveModal from '../../../shared/content-tab/components/modals/ConfirmLeaveModal.vue'
import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue'
import ConfirmUnlinkModal from '../../../shared/content-tab/components/modals/ConfirmUnlinkModal.vue'
import ContentUpdaterModal from '../../../shared/content-tab/components/modals/ContentUpdaterModal.vue'
@@ -88,6 +88,18 @@ const messages = defineMessages({
},
})
+const leaveMessages = defineMessages({
+ uploadInProgress: {
+ id: 'instances.confirm-leave-modal.upload-in-progress',
+ defaultMessage: 'Upload in progress',
+ },
+ leavePageBody: {
+ id: 'instances.confirm-leave-modal.body',
+ defaultMessage:
+ 'Files are still being uploaded. Leaving this page will cancel the upload and your changes may be lost.',
+ },
+})
+
const props = withDefaults(
defineProps<{
showClientOnlyFilter?: boolean
@@ -971,5 +983,10 @@ provideContentManager({
@confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel"
/>
-
+
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/files.vue b/packages/ui/src/layouts/wrapped/hosting/manage/files.vue
index 70ade05d78..59319f2ae5 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/files.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/files.vue
@@ -1,328 +1,40 @@
-
-
-
-
-
-
-
-
-
-
- Loading files...
-
-
-
-
- {{ formatMessage(nonBackupBusyReasons[0].reason) }}
- File operations are disabled while the operation is in progress.
-
-
-
-
navigateToSegment(-1)"
- @prefetch-home="handlePrefetchHome"
- @update:search-query="searchQuery = $event"
- @create="showCreateModal"
- @upload="initiateFileUpload"
- @upload-zip="() => {}"
- @unzip-from-url="showUnzipFromUrlModal"
- @refresh="refreshList"
- @save="() => fileEditorRef?.saveFileContent(true)"
- @save-as="() => fileEditorRef?.saveFileContent(false)"
- @save-restart="() => fileEditorRef?.saveAndRestart()"
- @share="() => fileEditorRef?.shareToMclogs()"
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
This folder is empty
-
There are no files or folders.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ selectedItems.size }} selected
-
-
-
-
- Move
-
-
-
-
-
- Delete
-
-
-
-
-
-
-
-
-
+// Provide the file manager context
+provideFileManager({
+ items,
+ loading: computed(() => isLoading.value),
+ error: computed(() => loadError.value ?? null),
+ currentPath,
+ navigateTo,
+ editingFile,
+ startEditing,
+ stopEditing,
+ createItem: async (name, type) => {
+ const path = `${currentPath.value}/${name}`.replace('//', '/')
+ await createMutation.mutateAsync({ path, type })
+ },
+ renameItem: async (path, newName) => {
+ await renameMutation.mutateAsync({ path, newName })
+ },
+ moveItem: async (source, destination) => {
+ await moveMutation.mutateAsync({ source, destination })
+ },
+ deleteItem: async (path, recursive) => {
+ await deleteMutation.mutateAsync({ path, recursive })
+ },
+ readFile,
+ readFileAsBlob,
+ writeFile,
+ downloadFile,
+ uploadFiles,
+ cancelUpload,
+ uploadState,
+ refresh: refreshList,
+ isBusy: serverBusy,
+ busyTooltip,
+ busyWarning,
+ extractFile,
+ activeOperations: activeOps,
+ dismissOperation,
+ prefetchDirectory,
+ prefetchFile,
+ showInstallFromUrl: true,
+ canRestart: true,
+ restartServer,
+ canShareToMclogs: true,
+})
+
+
+
+
+
diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json
index a94407d1f4..01eac741ff 100644
--- a/packages/ui/src/locales/en-US/index.json
+++ b/packages/ui/src/locales/en-US/index.json
@@ -416,6 +416,411 @@
"content.selection-bar.selected-count-simple": {
"defaultMessage": "{count, number} selected"
},
+ "files.action.extract": {
+ "defaultMessage": "Extract"
+ },
+ "files.action.move": {
+ "defaultMessage": "Move"
+ },
+ "files.action.rename": {
+ "defaultMessage": "Rename"
+ },
+ "files.conflict-modal.header": {
+ "defaultMessage": "Extract summary"
+ },
+ "files.conflict-modal.overwrite-button": {
+ "defaultMessage": "Overwrite"
+ },
+ "files.conflict-modal.overwrite-many-warning": {
+ "defaultMessage": "Over 100 files will be overwritten if you proceed with extraction; here are some of them."
+ },
+ "files.conflict-modal.overwrite-warning": {
+ "defaultMessage": "The following {count} files already exist on your server, and will be overwritten if you proceed with extraction."
+ },
+ "files.conflict-modal.overwritten-count": {
+ "defaultMessage": "{count} overwritten"
+ },
+ "files.conflict-modal.overwritten-label": {
+ "defaultMessage": "Overwritten"
+ },
+ "files.conflict-modal.warning-header": {
+ "defaultMessage": "Files will be overwritten"
+ },
+ "files.context-menu.copied-filename": {
+ "defaultMessage": "Copied filename"
+ },
+ "files.context-menu.copied-path": {
+ "defaultMessage": "Copied path"
+ },
+ "files.context-menu.copy-filename": {
+ "defaultMessage": "Copy filename"
+ },
+ "files.context-menu.copy-full-path": {
+ "defaultMessage": "Copy full path"
+ },
+ "files.context-menu.open-in-folder": {
+ "defaultMessage": "Open in folder"
+ },
+ "files.create-modal.create-button": {
+ "defaultMessage": "Create {type, select, directory {folder} other {file}}"
+ },
+ "files.create-modal.header": {
+ "defaultMessage": "Create a {type, select, directory {folder} other {file}}"
+ },
+ "files.create-modal.placeholder-directory": {
+ "defaultMessage": "e.g. plugins"
+ },
+ "files.create-modal.placeholder-file": {
+ "defaultMessage": "e.g. config.yml"
+ },
+ "files.delete-modal.deleting-name": {
+ "defaultMessage": "Deleting \"{name}\""
+ },
+ "files.delete-modal.header": {
+ "defaultMessage": "Delete file"
+ },
+ "files.delete-modal.warning": {
+ "defaultMessage": "{type, select, directory {This folder and all its contents will be permanently deleted. This action cannot be undone.} other {This file will be permanently deleted. This action cannot be undone.}}"
+ },
+ "files.editor.failed-to-open-text": {
+ "defaultMessage": "Could not load file contents."
+ },
+ "files.editor.failed-to-open-title": {
+ "defaultMessage": "Failed to open file"
+ },
+ "files.editor.failed-to-share-text": {
+ "defaultMessage": "Could not upload to mclo.gs."
+ },
+ "files.editor.failed-to-share-title": {
+ "defaultMessage": "Failed to share file"
+ },
+ "files.editor.file-saved-text": {
+ "defaultMessage": "Your file has been saved."
+ },
+ "files.editor.file-saved-title": {
+ "defaultMessage": "File saved"
+ },
+ "files.editor.log-url-copied-text": {
+ "defaultMessage": "Your log file URL has been copied to your clipboard."
+ },
+ "files.editor.log-url-copied-title": {
+ "defaultMessage": "Log URL copied"
+ },
+ "files.editor.save-failed-text": {
+ "defaultMessage": "Could not save the file."
+ },
+ "files.editor.save-failed-title": {
+ "defaultMessage": "Save failed"
+ },
+ "files.error.go-to-home": {
+ "defaultMessage": "Go to home folder"
+ },
+ "files.error.try-again": {
+ "defaultMessage": "Try again"
+ },
+ "files.image_viewer.image_too_large": {
+ "defaultMessage": "Image too large to view (max {maxDimension}x{maxDimension} pixels)"
+ },
+ "files.image_viewer.invalid_image": {
+ "defaultMessage": "Invalid or empty image file."
+ },
+ "files.image_viewer.load_failed": {
+ "defaultMessage": "Failed to load image"
+ },
+ "files.image_viewer.reset_zoom": {
+ "defaultMessage": "Reset zoom"
+ },
+ "files.image_viewer.viewed_image_alt": {
+ "defaultMessage": "Viewed image"
+ },
+ "files.image_viewer.zoom_in": {
+ "defaultMessage": "Zoom in"
+ },
+ "files.image_viewer.zoom_out": {
+ "defaultMessage": "Zoom out"
+ },
+ "files.layout.busy-warning": {
+ "defaultMessage": "File operations are disabled while the operation is in progress."
+ },
+ "files.layout.dry-run-failed-text": {
+ "defaultMessage": "Error running dry run"
+ },
+ "files.layout.dry-run-failed-title": {
+ "defaultMessage": "Dry run failed"
+ },
+ "files.layout.empty-folder-description": {
+ "defaultMessage": "There are no files or folders."
+ },
+ "files.layout.empty-folder-title": {
+ "defaultMessage": "This folder is empty"
+ },
+ "files.layout.error-message": {
+ "defaultMessage": "The folder may not exist."
+ },
+ "files.layout.error-title": {
+ "defaultMessage": "Unable to load files"
+ },
+ "files.layout.extract-failed-title": {
+ "defaultMessage": "Extract failed"
+ },
+ "files.layout.extraction-started-title": {
+ "defaultMessage": "Extraction started"
+ },
+ "files.layout.leave-editor-body": {
+ "defaultMessage": "You have unsaved changes that will be lost if you leave the editor."
+ },
+ "files.layout.leave-editor-leave": {
+ "defaultMessage": "Leave editor"
+ },
+ "files.layout.leave-editor-stay": {
+ "defaultMessage": "Stay in editor"
+ },
+ "files.layout.leave-editor-title": {
+ "defaultMessage": "Leave editor?"
+ },
+ "files.layout.loading": {
+ "defaultMessage": "Loading files..."
+ },
+ "files.layout.save": {
+ "defaultMessage": "Save"
+ },
+ "files.layout.selected-count": {
+ "defaultMessage": "{count} selected"
+ },
+ "files.layout.unsaved-changes": {
+ "defaultMessage": "You have unsaved changes."
+ },
+ "files.move-modal.current-location": {
+ "defaultMessage": "Current location"
+ },
+ "files.move-modal.destination-path": {
+ "defaultMessage": "Destination path"
+ },
+ "files.move-modal.destination-placeholder": {
+ "defaultMessage": "e.g. /mods"
+ },
+ "files.move-modal.header": {
+ "defaultMessage": "{type, select, directory {Move folder} other {Move file}}"
+ },
+ "files.move-modal.move-button": {
+ "defaultMessage": "Move"
+ },
+ "files.navbar.back-to-home": {
+ "defaultMessage": "Back to home"
+ },
+ "files.navbar.breadcrumb-navigation": {
+ "defaultMessage": "Breadcrumb navigation"
+ },
+ "files.navbar.create-new": {
+ "defaultMessage": "Create new..."
+ },
+ "files.navbar.file-navigation": {
+ "defaultMessage": "File navigation"
+ },
+ "files.navbar.home": {
+ "defaultMessage": "Home"
+ },
+ "files.navbar.install-curseforge-pack": {
+ "defaultMessage": "Install CurseForge pack"
+ },
+ "files.navbar.new-file": {
+ "defaultMessage": "New file"
+ },
+ "files.navbar.new-folder": {
+ "defaultMessage": "New folder"
+ },
+ "files.navbar.search-files": {
+ "defaultMessage": "Search files"
+ },
+ "files.navbar.share-to-mclogs": {
+ "defaultMessage": "Share to mclo.gs"
+ },
+ "files.navbar.upload-file": {
+ "defaultMessage": "Upload file"
+ },
+ "files.navbar.upload-from-zip": {
+ "defaultMessage": "Upload from .zip file"
+ },
+ "files.navbar.upload-from-zip-url": {
+ "defaultMessage": "Upload from .zip URL"
+ },
+ "files.operations.done": {
+ "defaultMessage": "Done"
+ },
+ "files.operations.extracted": {
+ "defaultMessage": "{size} extracted"
+ },
+ "files.operations.extracting": {
+ "defaultMessage": "Extracting {source}"
+ },
+ "files.operations.failed": {
+ "defaultMessage": "Failed"
+ },
+ "files.operations.modpack-from-url": {
+ "defaultMessage": "modpack from URL"
+ },
+ "files.operations.upload-progress": {
+ "defaultMessage": "{uploaded} / {total} ({percent}%)"
+ },
+ "files.operations.uploading-files": {
+ "defaultMessage": "Uploading files ({completed}/{total})"
+ },
+ "files.rename-modal.header": {
+ "defaultMessage": "Rename {name}"
+ },
+ "files.rename-modal.new-name-label": {
+ "defaultMessage": "New name"
+ },
+ "files.rename-modal.rename-button": {
+ "defaultMessage": "Rename"
+ },
+ "files.row.copied-filename": {
+ "defaultMessage": "Copied filename"
+ },
+ "files.row.copied-path": {
+ "defaultMessage": "Copied path"
+ },
+ "files.row.copy-filename": {
+ "defaultMessage": "Copy filename"
+ },
+ "files.row.copy-full-path": {
+ "defaultMessage": "Copy full path"
+ },
+ "files.row.extract": {
+ "defaultMessage": "Extract"
+ },
+ "files.row.item-count": {
+ "defaultMessage": "{count, plural, one {# item} other {# items}}"
+ },
+ "files.row.move": {
+ "defaultMessage": "Move"
+ },
+ "files.row.open-in-folder": {
+ "defaultMessage": "Open in folder"
+ },
+ "files.row.rename": {
+ "defaultMessage": "Rename"
+ },
+ "files.table-header.created": {
+ "defaultMessage": "Created"
+ },
+ "files.table-header.modified": {
+ "defaultMessage": "Modified"
+ },
+ "files.table-header.name": {
+ "defaultMessage": "Name"
+ },
+ "files.table-header.size": {
+ "defaultMessage": "Size"
+ },
+ "files.upload-dropdown.cancelled": {
+ "defaultMessage": "Cancelled"
+ },
+ "files.upload-dropdown.done": {
+ "defaultMessage": "Done"
+ },
+ "files.upload-dropdown.failed-file-exists": {
+ "defaultMessage": "Failed - File already exists"
+ },
+ "files.upload-dropdown.failed-generic": {
+ "defaultMessage": "Failed - {error}"
+ },
+ "files.upload-dropdown.failed-incorrect-type": {
+ "defaultMessage": "Failed - Incorrect file type"
+ },
+ "files.upload-dropdown.failed-to-upload": {
+ "defaultMessage": "Failed to upload {fileName}"
+ },
+ "files.upload-dropdown.file": {
+ "defaultMessage": "File"
+ },
+ "files.upload-dropdown.file-uploads": {
+ "defaultMessage": "{fileType} uploads"
+ },
+ "files.upload-dropdown.incorrect-file-type": {
+ "defaultMessage": "Upload had incorrect file type"
+ },
+ "files.upload-dropdown.unexpected-error": {
+ "defaultMessage": "An unexpected error occurred."
+ },
+ "files.upload-dropdown.upload-failed": {
+ "defaultMessage": "Upload failed"
+ },
+ "files.upload-dropdown.uploads-left": {
+ "defaultMessage": " - {count} left"
+ },
+ "files.upload.drag-and-drop.drop-to-upload": {
+ "defaultMessage": "Drop {type, select, undefined {files} other {{type}s}} here to upload"
+ },
+ "files.validation.name-invalid-directory": {
+ "defaultMessage": "Name must contain only alphanumeric characters, dashes, underscores, or spaces."
+ },
+ "files.validation.name-invalid-file": {
+ "defaultMessage": "Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces."
+ },
+ "files.validation.name-label": {
+ "defaultMessage": "Name"
+ },
+ "files.validation.name-required": {
+ "defaultMessage": "Name is required."
+ },
+ "files.zip-url-modal.backup-name": {
+ "defaultMessage": "CurseForge modpack install"
+ },
+ "files.zip-url-modal.cf-header": {
+ "defaultMessage": "Install a CurseForge modpack"
+ },
+ "files.zip-url-modal.cf-not-found-text": {
+ "defaultMessage": "Could not find CurseForge modpack at that URL."
+ },
+ "files.zip-url-modal.cf-not-found-title": {
+ "defaultMessage": "CurseForge modpack not found"
+ },
+ "files.zip-url-modal.enter-link": {
+ "defaultMessage": "Enter link"
+ },
+ "files.zip-url-modal.error-cf-url": {
+ "defaultMessage": "URL must be a CurseForge modpack version URL."
+ },
+ "files.zip-url-modal.error-url-invalid": {
+ "defaultMessage": "URL must be valid."
+ },
+ "files.zip-url-modal.error-url-required": {
+ "defaultMessage": "URL is required."
+ },
+ "files.zip-url-modal.install-button": {
+ "defaultMessage": "Install"
+ },
+ "files.zip-url-modal.install-failed-title": {
+ "defaultMessage": "Installation failed"
+ },
+ "files.zip-url-modal.step-copy-description": {
+ "defaultMessage": "Copy the version page URL and paste it below."
+ },
+ "files.zip-url-modal.step-copy-title": {
+ "defaultMessage": "Copy the URL"
+ },
+ "files.zip-url-modal.step-find-description": {
+ "defaultMessage": "Browse CurseForge and locate the modpack you want."
+ },
+ "files.zip-url-modal.step-find-title": {
+ "defaultMessage": "Find the modpack"
+ },
+ "files.zip-url-modal.step-select-description": {
+ "defaultMessage": "Go to the \"Files\" tab and pick the version to install."
+ },
+ "files.zip-url-modal.step-select-title": {
+ "defaultMessage": "Select a version"
+ },
+ "files.zip-url-modal.unknown-error": {
+ "defaultMessage": "An unknown error occurred"
+ },
+ "files.zip-url-modal.zip-description": {
+ "defaultMessage": "Copy and paste the direct download URL of a .zip file."
+ },
+ "files.zip-url-modal.zip-header": {
+ "defaultMessage": "Uploading .zip contents from URL"
+ },
"form.label.address-line": {
"defaultMessage": "Address line"
},
@@ -716,15 +1121,6 @@
"instances.confirm-leave-modal.body": {
"defaultMessage": "Files are still being uploaded. Leaving this page will cancel the upload and your changes may be lost."
},
- "instances.confirm-leave-modal.leave": {
- "defaultMessage": "Leave page"
- },
- "instances.confirm-leave-modal.stay": {
- "defaultMessage": "Stay on page"
- },
- "instances.confirm-leave-modal.title": {
- "defaultMessage": "Leave page?"
- },
"instances.confirm-leave-modal.upload-in-progress": {
"defaultMessage": "Upload in progress"
},
@@ -2824,5 +3220,20 @@
},
"ui.component.unsaved-changes-popup.body": {
"defaultMessage": "You have unsaved changes."
+ },
+ "ui.confirm-leave-modal.body": {
+ "defaultMessage": "You have unsaved changes that will be lost if you leave this page."
+ },
+ "ui.confirm-leave-modal.header": {
+ "defaultMessage": "You have unsaved changes"
+ },
+ "ui.confirm-leave-modal.leave": {
+ "defaultMessage": "Leave page"
+ },
+ "ui.confirm-leave-modal.stay": {
+ "defaultMessage": "Stay on page"
+ },
+ "ui.confirm-leave-modal.title": {
+ "defaultMessage": "Leave page?"
}
}
diff --git a/packages/ui/src/stories/base/Admonition.stories.ts b/packages/ui/src/stories/base/Admonition.stories.ts
index 7ec3c77ff2..f04ea9e81f 100644
--- a/packages/ui/src/stories/base/Admonition.stories.ts
+++ b/packages/ui/src/stories/base/Admonition.stories.ts
@@ -1,6 +1,8 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Admonition from '../../components/base/Admonition.vue'
+import ButtonStyled from '../../components/base/ButtonStyled.vue'
+import ProgressBar from '../../components/base/ProgressBar.vue'
const meta = {
title: 'Base/Admonition',
@@ -45,3 +47,92 @@ export const Success: Story = {
body: 'Everything went smoothly.',
},
}
+
+export const Dismissible: Story = {
+ args: {
+ type: 'info',
+ header: 'Dismissible Notice',
+ body: 'This admonition can be dismissed by clicking the X button.',
+ dismissible: true,
+ },
+}
+
+export const WithTopRightActions: Story = {
+ render: () => ({
+ components: { Admonition, ButtonStyled },
+ template: /*html*/ `
+
+
+ Uploading server files...
+
+
+ Cancel
+
+
+
+
+ Something went wrong while extracting the archive.
+
+
+ Retry
+
+
+ ✕
+
+
+
+
+ All files have been extracted successfully.
+
+
+ ✕
+
+
+
+
+ `,
+ }),
+}
+
+export const WithProgressBar: Story = {
+ render: () => ({
+ components: { Admonition, ButtonStyled, ProgressBar },
+ template: /*html*/ `
+
+
+ 128 KB / 1.2 MB (45%)
+
+
+ Cancel
+
+
+
+
+
+
+
+ 24 MB extracted — config/settings.yml
+
+
+ Cancel
+
+
+
+
+
+
+
+ 56 MB extracted
+
+
+ ✕
+
+
+
+
+
+
+
+ `,
+ }),
+}
diff --git a/packages/ui/src/stories/servers/BackupProgressAdmonition.stories.ts b/packages/ui/src/stories/servers/BackupProgressAdmonition.stories.ts
deleted file mode 100644
index 83a3a96515..0000000000
--- a/packages/ui/src/stories/servers/BackupProgressAdmonition.stories.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/vue3-vite'
-
-import BackupProgressAdmonition from '../../components/servers/backups/BackupProgressAdmonition.vue'
-
-const meta = {
- title: 'Servers/BackupProgressAdmonition',
- component: BackupProgressAdmonition,
- parameters: {
- layout: 'padded',
- },
-} satisfies Meta
-
-export default meta
-type Story = StoryObj
-
-const justNow = new Date().toISOString()
-const eightMinsAgo = new Date(Date.now() - 8 * 60 * 1000).toISOString()
-const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString()
-
-export const AllStates: Story = {
- render: () => ({
- components: { BackupProgressAdmonition },
- setup() {
- const now = new Date().toISOString()
- const mins8 = new Date(Date.now() - 8 * 60 * 1000).toISOString()
- const hours5 = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString()
- return { now, mins8, hours5 }
- },
- template: /*html*/ `
-
-
Backup Creation
-
-
-
-
- Backup Restoration
-
-
-
-
-
- `,
- }),
-}
-
-export const BackupQueued: Story = {
- args: {
- type: 'create',
- state: 'ongoing',
- progress: 0,
- backupName: 'World Backup 1',
- createdAt: justNow,
- },
-}
-
-export const CreatingBackup: Story = {
- args: {
- type: 'create',
- state: 'ongoing',
- progress: 0.33,
- backupName: 'World Backup 1',
- createdAt: eightMinsAgo,
- },
-}
-
-export const BackupFailed: Story = {
- args: {
- type: 'create',
- state: 'failed',
- progress: 0,
- backupName: 'World Backup 1',
- createdAt: fiveHoursAgo,
- },
-}
-
-export const RestoreQueued: Story = {
- args: {
- type: 'restore',
- state: 'ongoing',
- progress: 0,
- backupName: 'World Backup 1',
- createdAt: justNow,
- },
-}
-
-export const RestoringBackup: Story = {
- args: {
- type: 'restore',
- state: 'ongoing',
- progress: 0.33,
- backupName: 'World Backup 1',
- createdAt: eightMinsAgo,
- },
-}
-
-export const RestoreSuccessful: Story = {
- args: {
- type: 'restore',
- state: 'done',
- progress: 1,
- backupName: 'World Backup 1',
- createdAt: fiveHoursAgo,
- },
-}
-
-export const RestoreFailed: Story = {
- args: {
- type: 'restore',
- state: 'failed',
- progress: 0,
- backupName: 'World Backup 1',
- createdAt: fiveHoursAgo,
- },
-}
diff --git a/packages/ui/src/utils/ace-mode-log.ts b/packages/ui/src/utils/ace-mode-log.ts
new file mode 100644
index 0000000000..720ff6f30e
--- /dev/null
+++ b/packages/ui/src/utils/ace-mode-log.ts
@@ -0,0 +1,71 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import ace from 'ace-builds'
+
+ace['define'](
+ 'ace/mode/mclog_highlight_rules',
+ ['require', 'exports', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
+ function (require: any, exports: any) {
+ const oop = require('ace/lib/oop')
+ const TextHighlightRules = require('ace/mode/text_highlight_rules').TextHighlightRules
+
+ const MclogHighlightRules = function (this: any) {
+ this.$rules = {
+ start: [
+ {
+ token: 'comment.timestamp',
+ regex: /^\[\d\d:\d\d:\d\d\]/.source,
+ },
+ {
+ token: 'invalid.error',
+ regex: /\[.+?\/ERROR\]:?/.source,
+ },
+ {
+ token: 'keyword.warn',
+ regex: /\[.+?\/WARN\]:?/.source,
+ },
+ {
+ token: 'string.info',
+ regex: /\[.+?\/INFO\]:/.source,
+ },
+ {
+ token: 'support.command',
+ regex: /: \/.+/.source,
+ },
+ {
+ token: 'comment.stacktrace',
+ regex: /\tat\s.+/.source,
+ },
+ {
+ token: 'entity.name.function',
+ regex: /\w+?\[\/\d+?\.\d+?\.\d+?\.\d+?:\d+?\]/.source,
+ },
+ {
+ token: 'storage.chat',
+ regex: /\[CHAT\]/.source,
+ },
+ ],
+ }
+ this.normalizeRules()
+ }
+
+ oop.inherits(MclogHighlightRules, TextHighlightRules)
+ exports.MclogHighlightRules = MclogHighlightRules
+ },
+)
+
+ace['define'](
+ 'ace/mode/mclog',
+ ['require', 'exports', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/mclog_highlight_rules'],
+ function (require: any, exports: any) {
+ const oop = require('ace/lib/oop')
+ const TextMode = require('ace/mode/text').Mode
+ const MclogHighlightRules = require('ace/mode/mclog_highlight_rules').MclogHighlightRules
+
+ const Mode = function (this: any) {
+ this.HighlightRules = MclogHighlightRules
+ }
+
+ oop.inherits(Mode, TextMode)
+ exports.Mode = Mode
+ },
+)
diff --git a/packages/ui/src/utils/ace-theme.ts b/packages/ui/src/utils/ace-theme.ts
index a1cde94088..e78e42d483 100644
--- a/packages/ui/src/utils/ace-theme.ts
+++ b/packages/ui/src/utils/ace-theme.ts
@@ -1,3 +1,5 @@
+import 'ace-builds/esm-resolver'
+
import cssText from '@modrinth/assets/styles/ace.css?raw'
import ace from 'ace-builds'
diff --git a/packages/ui/src/utils/file-extensions.ts b/packages/ui/src/utils/file-extensions.ts
index 7908f52b06..78b88c067a 100644
--- a/packages/ui/src/utils/file-extensions.ts
+++ b/packages/ui/src/utils/file-extensions.ts
@@ -146,6 +146,8 @@ export function getEditorLanguage(ext: string): string {
case 'cfg':
case 'conf':
return 'ini'
+ case 'log':
+ return 'mclog'
default:
return 'text'
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c1bfa6eb79..819cb44680 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -89,6 +89,9 @@ importers:
'@tauri-apps/plugin-dialog':
specifier: ^2.2.1
version: 2.6.0
+ '@tauri-apps/plugin-fs':
+ specifier: ^2.4.5
+ version: 2.4.5
'@tauri-apps/plugin-http':
specifier: ~2.5.7
version: 2.5.7
@@ -4088,6 +4091,9 @@ packages:
'@tauri-apps/plugin-dialog@2.6.0':
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
+ '@tauri-apps/plugin-fs@2.4.5':
+ resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==}
+
'@tauri-apps/plugin-http@2.5.7':
resolution: {integrity: sha512-+F2lEH/c9b0zSsOXKq+5hZNcd9F4IIKCK1T17RqMwpCmVnx2aoqY8yIBccCd25HTYUb3j6NPVbRax/m00hKG8A==}
@@ -12957,6 +12963,10 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.10.1
+ '@tauri-apps/plugin-fs@2.4.5':
+ dependencies:
+ '@tauri-apps/api': 2.10.1
+
'@tauri-apps/plugin-http@2.5.7':
dependencies:
'@tauri-apps/api': 2.10.1
diff --git a/standards/frontend/INTERNATIONALIZATION.md b/standards/frontend/INTERNATIONALIZATION.md
index a33ee79aa6..b87471ef71 100644
--- a/standards/frontend/INTERNATIONALIZATION.md
+++ b/standards/frontend/INTERNATIONALIZATION.md
@@ -3,6 +3,7 @@
- [Message Definitions](#message-definitions)
- [Rendering Messages](#rendering-messages)
- [ICU Message Format](#icu-message-format)
+ - [Writing Translation-Friendly Strings](#writing-translation-friendly-strings)
- [Rich-Text Messages](#rich-text-messages)
- [Vue/ICU Delimiter Collisions](#vueicu-delimiter-collisions)
- [Imports](#imports)
@@ -52,6 +53,36 @@ Dynamic values use ICU placeholders in `defaultMessage`:
- **Numbers/dates/times:** `'{price, number, ::currency/USD}'`
- **Plurals/selects:** `'{count, plural, one {# message} other {# messages}}'`
+## Writing Translation-Friendly Strings
+
+ICU gives you powerful tools (plurals, selects, nested expressions), but translators in other languages face constraints that English doesn't have:
+
+- **Word order varies by language.** Don't assume `{action} {noun}` works everywhere — some languages need `{noun} {action}` or require prepositions between them.
+- **Plurals aren't just "add an s".** Many languages change internal parts of a word or phrase for pluralization, not just the ending. A simple `{count} {itemType}` breaks if `itemType` is always singular.
+- **Grammatical gender affects surrounding words.** Articles, adjectives, and verbs may change based on whether a noun is masculine or feminine. If a variable like `{contentType}` can be "shader" or "mod", translators may need to inflect surrounding text differently for each.
+
+### Guidelines
+
+1. **Use `select` for content types, not bare variables.** When a variable represents different content types (mod, shader, modpack, etc.), pass a key and use ICU `select` so translators can write type-specific forms:
+
+```
+// Bad — translators can't inflect around a pre-rendered noun
+'Delete {count} {itemType}'
+
+// Good — translators can write entirely different phrases per type
+'Delete {count} {contentType, select, mod {{count, plural, one {mod} other {mods}}} shader {{count, plural, one {shader} other {shaders}}} other {items}}'
+```
+
+This lets translators write entirely different noun forms per branch, which many languages require.
+
+2. **Prefer separate messages over complex ICU when branches diverge significantly.** If the singular and plural versions of a string are structurally different (not just a noun change), use two separate message IDs rather than one complex ICU expression.
+
+3. **Don't concatenate translated strings.** Never build a sentence by joining multiple `formatMessage` calls — the word order may be wrong in other languages. Put the entire sentence in one message.
+
+4. **Keep variables semantic.** Pass `contentType: 'mod'` (a key), not `contentType: 'Mod'` (a pre-rendered display string). Translators can then map each key to the correct form in their language.
+
+5. **Test with long strings.** German and Finnish words can be 2-3x longer than English equivalents. Ensure UI layouts don't break with longer text.
+
## Rich-Text Messages
When a message contains links or markup, wrap the relevant ranges with named tags in `defaultMessage`: