diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index abe6e55c0f..7eea9d5397 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -32,6 +32,7 @@ "@tauri-apps/plugin-window-state": "^2.2.2", "@types/three": "^0.172.0", "@vueuse/core": "^11.1.0", + "ace-builds": "^1.43.5", "dayjs": "^1.11.10", "floating-vue": "^5.2.2", "fuse.js": "^6.6.2", @@ -45,6 +46,7 @@ "vue-i18n": "^10.0.0", "vue-router": "^4.6.0", "vue-virtual-scroller": "v2.0.0-beta.8", + "vue3-ace-editor": "^2.2.4", "vuedraggable": "^4.1.0" }, "devDependencies": { diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 28764564ba..668c557a86 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -14,6 +14,7 @@ import { ChevronLeftIcon, ChevronRightIcon, CompassIcon, + ImagesIcon, LogInIcon, LogOutIcon, NewspaperIcon, @@ -64,6 +65,7 @@ import { renderString } from '@modrinth/utils' import { useQuery, useQueryClient } from '@tanstack/vue-query' import { getVersion } from '@tauri-apps/api/app' import { convertFileSrc, invoke } from '@tauri-apps/api/core' +import { listen } from '@tauri-apps/api/event' import { getCurrentWindow } from '@tauri-apps/api/window' import { fetch as tauriFetch } from '@tauri-apps/plugin-http' import { openUrl } from '@tauri-apps/plugin-opener' @@ -118,7 +120,12 @@ import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics' import { check_reachable } from '@/helpers/auth.js' import { get_user, get_user_many, get_version } from '@/helpers/cache.js' import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install' -import { can_current_user_use_shared_instances, get as getInstance, run } from '@/helpers/instance' +import { + can_current_user_use_shared_instances, + get as getInstance, + get_global_synced_options, + run, +} from '@/helpers/instance' import { get as getCreds, getAll as getAllCreds, @@ -173,7 +180,10 @@ import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer' import { get_available_capes, get_available_skins } from './helpers/skins' import { AppNotificationManager } from './providers/app-notifications' import { AppPopupNotificationManager } from './providers/app-popup-notifications' -import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal' +import { + appSettingsModalOpenProfileKey, + appSettingsModalOpenSyncedOptionsKey, +} from './providers/app-settings-modal' const appSettings = useAppSettings() const appTheme = useTheme() @@ -438,6 +448,11 @@ const os = ref('') const isDevEnvironment = ref(false) const stateInitialized = ref(false) +const globalSyncedOptionsQuery = useQuery({ + queryKey: ['global-synced-options'], + queryFn: get_global_synced_options, + enabled: computed(() => stateInitialized.value), +}) const criticalErrorMessage = ref() @@ -469,7 +484,24 @@ const authUnreachable = computed(() => { return false }) +let unlistenEditMenu + +function handleEditMenuAction(action) { + const event = new CustomEvent(`edit-menu:${action}`, { cancelable: true }) + if (document.dispatchEvent(event)) document.execCommand(action) +} + onMounted(async () => { + try { + const listeners = await Promise.all([ + listen('edit-menu://undo', () => handleEditMenuAction('undo')), + listen('edit-menu://redo', () => handleEditMenuAction('redo')), + ]) + unlistenEditMenu = () => listeners.forEach((unlisten) => unlisten()) + } catch (error) { + handleError(error) + } + await useCheckDisableMouseover() try { handleAdsConsentRequired(await should_show_ads_consent_popup()) @@ -490,6 +522,7 @@ onUnmounted(async () => { document.querySelector('body').removeEventListener('auxclick', handleAuxClick) document.querySelector('body').removeEventListener('contextmenu', handleContextMenu) document.removeEventListener('fullscreenchange', handleFullscreenChange) + unlistenEditMenu?.() clearDelayedUpdatePopup() if (fullscreenAdsWindowHold) { @@ -557,6 +590,10 @@ const messages = defineMessages({ id: 'app.nav.modrinth-hosting', defaultMessage: 'Modrinth Hosting', }, + screenshots: { + id: 'app.nav.screenshots', + defaultMessage: 'Screenshots', + }, createNewInstance: { id: 'app.nav.create-new-instance', defaultMessage: 'Create new instance', @@ -999,6 +1036,7 @@ const updateToPlayModal = ref() const modrinthLoginModal = ref() const appSettingsModal = ref() provide(appSettingsModalOpenProfileKey, () => appSettingsModal.value?.showProfile()) +provide(appSettingsModalOpenSyncedOptionsKey, () => appSettingsModal.value?.showSyncedOptions()) watch(incompatibilityWarningModal, (modal) => { if (modal) { @@ -2025,6 +2063,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload) + + + diff --git a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue b/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue index d1dfbec528..78b58fc443 100644 --- a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue +++ b/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue @@ -1,12 +1,12 @@ + + + + diff --git a/apps/app-frontend/src/components/ui/screenshots-page/drag-gather.vue b/apps/app-frontend/src/components/ui/screenshots-page/drag-gather.vue new file mode 100644 index 0000000000..eedee94806 --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/drag-gather.vue @@ -0,0 +1,96 @@ + + + diff --git a/apps/app-frontend/src/components/ui/screenshots-page/drag-preview.vue b/apps/app-frontend/src/components/ui/screenshots-page/drag-preview.vue new file mode 100644 index 0000000000..f658bcad94 --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/drag-preview.vue @@ -0,0 +1,65 @@ + + + diff --git a/apps/app-frontend/src/components/ui/screenshots-page/group.vue b/apps/app-frontend/src/components/ui/screenshots-page/group.vue new file mode 100644 index 0000000000..f9705c6e5f --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/group.vue @@ -0,0 +1,141 @@ + + + diff --git a/apps/app-frontend/src/components/ui/screenshots-page/index.vue b/apps/app-frontend/src/components/ui/screenshots-page/index.vue new file mode 100644 index 0000000000..e688e435f7 --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/index.vue @@ -0,0 +1,1469 @@ + + + diff --git a/apps/app-frontend/src/components/ui/screenshots-page/section.vue b/apps/app-frontend/src/components/ui/screenshots-page/section.vue new file mode 100644 index 0000000000..b5872c4ddf --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/section.vue @@ -0,0 +1,138 @@ + + + diff --git a/apps/app-frontend/src/components/ui/screenshots-page/toolbar.vue b/apps/app-frontend/src/components/ui/screenshots-page/toolbar.vue new file mode 100644 index 0000000000..3b5812ec1c --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/toolbar.vue @@ -0,0 +1,87 @@ + + + diff --git a/apps/app-frontend/src/components/ui/screenshots-page/use-screenshot-drag-gather.ts b/apps/app-frontend/src/components/ui/screenshots-page/use-screenshot-drag-gather.ts new file mode 100644 index 0000000000..9afe8b949a --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/use-screenshot-drag-gather.ts @@ -0,0 +1,130 @@ +import { onBeforeUnmount, type Ref, ref } from 'vue' + +import type { InstanceScreenshot } from '@/helpers/instance' + +type Point = { + x: number + y: number +} + +export type ActiveScreenshotDrag = { + primarySelectionKey: string + selectionKeys: string[] +} + +export const gatherDuration = 500 + +export type ScreenshotDragGatherItem = { + screenshot: InstanceScreenshot + selectionKey: string + rect: { + left: number + top: number + width: number + height: number + } +} + +function getSelectionKey(screenshot: InstanceScreenshot) { + return JSON.stringify([screenshot.instance_id, screenshot.file_name]) +} + +export function useScreenshotDragGather(screenshots: Ref) { + const items = ref([]) + const target = ref({ x: 0, y: 0 }) + const targetOffset = ref({ x: 0, y: 0 }) + const isGathering = ref(false) + let cleanupTimer: number | undefined + + const clearCleanupTimer = () => { + if (cleanupTimer !== undefined) { + clearTimeout(cleanupTimer) + cleanupTimer = undefined + } + } + + const clear = () => { + clearCleanupTimer() + items.value = [] + targetOffset.value = { x: 0, y: 0 } + isGathering.value = false + } + + const updateTarget = (pointer: Point) => { + target.value = { + x: pointer.x + targetOffset.value.x, + y: pointer.y + targetOffset.value.y, + } + } + + const start = (drag: ActiveScreenshotDrag | null, pointer: Point) => { + clear() + + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + if (!drag || drag.selectionKeys.length < 2 || reduceMotion) return + + const screenshotCards = Array.from( + document.querySelectorAll('[data-screenshot-card]'), + ) + const sourceCard = screenshotCards.find( + (card) => card.dataset.selectionKey === drag.primarySelectionKey, + ) + + if (sourceCard) { + const sourceRect = sourceCard.getBoundingClientRect() + targetOffset.value = { + x: sourceRect.left + sourceRect.width / 2 - pointer.x, + y: sourceRect.top + sourceRect.height / 2 - pointer.y, + } + } + + const screenshotsByKey = new Map( + screenshots.value.map((screenshot) => [getSelectionKey(screenshot), screenshot]), + ) + items.value = drag.selectionKeys.flatMap((selectionKey) => { + const screenshot = screenshotsByKey.get(selectionKey) + const card = screenshotCards.find( + (candidate) => + candidate.dataset.selectionKey === selectionKey && candidate.getClientRects().length > 0, + ) + if (!screenshot || !card) return [] + + const rect = card.getBoundingClientRect() + return [ + { + screenshot, + selectionKey, + rect: { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }, + }, + ] + }) + updateTarget(pointer) + isGathering.value = items.value.length > 0 + } + + const finish = () => { + isGathering.value = false + clearCleanupTimer() + cleanupTimer = window.setTimeout(() => { + items.value = [] + cleanupTimer = undefined + }, 400) + } + + onBeforeUnmount(clearCleanupTimer) + + return { + items, + target, + isGathering, + start, + updateTarget, + clear, + finish, + } +} diff --git a/apps/app-frontend/src/components/ui/settings/instances/DefaultInstanceSettings.vue b/apps/app-frontend/src/components/ui/settings/instances/DefaultInstanceSettings.vue deleted file mode 100644 index ebe9e9af7b..0000000000 --- a/apps/app-frontend/src/components/ui/settings/instances/DefaultInstanceSettings.vue +++ /dev/null @@ -1,362 +0,0 @@ - - - diff --git a/apps/app-frontend/src/components/ui/settings/instances/InstancesSyncedSettings.vue b/apps/app-frontend/src/components/ui/settings/instances/InstancesSyncedSettings.vue new file mode 100644 index 0000000000..ecef91748c --- /dev/null +++ b/apps/app-frontend/src/components/ui/settings/instances/InstancesSyncedSettings.vue @@ -0,0 +1,887 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/world/WorldItem.vue b/apps/app-frontend/src/components/ui/world/WorldItem.vue index 509fbdd27d..c226d7be0d 100644 --- a/apps/app-frontend/src/components/ui/world/WorldItem.vue +++ b/apps/app-frontend/src/components/ui/world/WorldItem.vue @@ -67,7 +67,7 @@ const router = useRouter() const { addNotification } = injectNotificationManager() const emit = defineEmits<{ - (e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void + (e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete' | 'desync'): void (e: 'open-folder', world: SingleplayerWorld): void }>() @@ -95,6 +95,8 @@ const props = withDefaults( } managed?: boolean + showPlayButton?: boolean + cardBackground?: 'raised' | 'surface-2' // Instance instanceId?: string @@ -117,6 +119,8 @@ const props = withDefaults( gameMode: undefined, managed: false, + showPlayButton: true, + cardBackground: 'raised', instanceId: undefined, instanceName: undefined, @@ -218,6 +222,10 @@ const messages = defineMessages({ id: 'instance.worlds.copy_address', defaultMessage: 'Copy address', }, + desync: { + id: 'instance.worlds.desync_server', + defaultMessage: 'Desync', + }, viewInstance: { id: 'instance.worlds.view_instance', defaultMessage: 'View instance', @@ -270,7 +278,10 @@ const messages = defineMessages({ const cardOptions = useTemplateRef('cardOptions') const showStop = computed( - () => (props.playingWorld || (locked.value && props.playingInstance)) && !props.startingInstance, + () => + props.showPlayButton && + (props.playingWorld || (locked.value && props.playingInstance)) && + !props.startingInstance, ) const playDisabled = computed( () => @@ -327,6 +338,17 @@ const overflowOptions = computed((): ButtonMenuOption[] => [ shown: props.world.type === 'server', action: () => copyToClipboard((props.world as ServerWorld).address), }, + { + id: 'desync', + label: formatMessage(messages.desync), + icon: XIcon, + action: () => emit('desync'), + shown: + !props.instanceId && + props.world.type === 'server' && + (props.world as ServerWorld).source === 'user_synced' && + !!(props.world as ServerWorld).server_id, + }, { id: 'edit', label: formatMessage(commonMessages.editButton), @@ -404,6 +426,7 @@ const contextMenuOptions = computed((): ButtonMenuOption[] => [ label: formatMessage(commonMessages.stopButton), icon: StopCircleIcon, tone: 'red', + shown: props.showPlayButton, action: () => emit('stop'), } : { @@ -411,11 +434,12 @@ const contextMenuOptions = computed((): ButtonMenuOption[] => [ label: formatMessage(messages.playWorld), icon: PlayIcon, tone: 'brand', + shown: props.showPlayButton, disabled: playDisabled.value, tooltip: playTooltip.value ?? undefined, action: () => emit('play'), }, - { type: 'divider' }, + { type: 'divider', shown: props.showPlayButton }, ...overflowOptions.value, ]) @@ -433,9 +457,11 @@ function openContextMenu(event: MouseEvent) { />
-