diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index 86ec06a..47adfd0 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -14,6 +14,9 @@ "test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\"" }, "dependencies": { + "@dnd-kit/core": "6.3.1", + "@dnd-kit/utilities": "3.2.2", + "@dnd-kit/vue": "^0.5.0", "@lobehub/icons-static-svg": "1.94.0", "@modrinth/api-client": "workspace:^", "@modrinth/assets": "workspace:*", diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 19f6881..7146f4e 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -8,6 +8,7 @@ import { FlaskConicalIcon, FolderOpenIcon, HomeIcon, + ImagesIcon, LeftArrowIcon, LibraryIcon, LogInIcon, @@ -763,6 +764,10 @@ const messages = defineMessages({ defaultMessage: 'Library', }, multiplayer: { + screenshots: { + id: 'app.navigation.screenshots', + defaultMessage: 'Screenshots', + }, id: 'app.navigation.multiplayer', defaultMessage: 'Multiplayer', }, @@ -2324,6 +2329,14 @@ provideAppUpdateDownloadProgress(appUpdateDownload) > + + + + + + + + \ No newline at end of file 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 0000000..a2a86a4 --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/drag-gather.vue @@ -0,0 +1,96 @@ + + + \ No newline at end of file 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 0000000..88d8667 --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/drag-preview.vue @@ -0,0 +1,75 @@ + + + \ No newline at end of file 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 0000000..f64937c --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/group.vue @@ -0,0 +1,243 @@ + + + \ No newline at end of file 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 0000000..9875dd2 --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/index.vue @@ -0,0 +1,1706 @@ + + \ No newline at end of file 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 0000000..6d7479f --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/section.vue @@ -0,0 +1,169 @@ + + + \ No newline at end of file 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 0000000..4d40e82 --- /dev/null +++ b/apps/app-frontend/src/components/ui/screenshots-page/toolbar.vue @@ -0,0 +1,87 @@ + + + \ No newline at end of file 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 0000000..a018d79 --- /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, + } +} \ No newline at end of file diff --git a/apps/app-frontend/src/composables/use-app-event.ts b/apps/app-frontend/src/composables/use-app-event.ts new file mode 100644 index 0000000..068e080 --- /dev/null +++ b/apps/app-frontend/src/composables/use-app-event.ts @@ -0,0 +1,17 @@ +import { onUnmounted } from 'vue' + +import { instance_listener } from '@/helpers/events.js' + +export interface InstanceAppEvent { + event: string + instance_id?: string +} + +export function useAppEvent(type: 'instance', callback: (event: InstanceAppEvent) => void) { + if (type !== 'instance') return + let unlisten: (() => void) | undefined + void instance_listener(callback).then((dispose) => { + unlisten = dispose + }) + onUnmounted(() => unlisten?.()) +} \ No newline at end of file diff --git a/apps/app-frontend/src/composables/use-image-thumbnail.ts b/apps/app-frontend/src/composables/use-image-thumbnail.ts new file mode 100644 index 0000000..b1687a6 --- /dev/null +++ b/apps/app-frontend/src/composables/use-image-thumbnail.ts @@ -0,0 +1,154 @@ +import { convertFileSrc, invoke } from '@tauri-apps/api/core' +import { type MaybeRefOrGetter, ref, toValue, watch } from 'vue' + +export type ImageThumbnailSource = { + instanceId: string + filePath: string + path: string +} + +type ThumbnailCacheEntry = { + promise: Promise + url?: string + users: number + lastUsed: number +} + +const MAX_CONCURRENT_THUMBNAILS = 4 +const MAX_CACHED_THUMBNAILS = 256 + +const thumbnailCache = new Map() +const thumbnailQueue: Array<() => void> = [] +let activeThumbnailRequests = 0 + +function runThumbnailQueue() { + while (activeThumbnailRequests < MAX_CONCURRENT_THUMBNAILS && thumbnailQueue.length > 0) { + activeThumbnailRequests += 1 + thumbnailQueue.shift()?.() + } +} + +function enqueueThumbnail(task: () => Promise) { + return new Promise((resolve, reject) => { + thumbnailQueue.push(() => { + void task() + .then(resolve, reject) + .finally(() => { + activeThumbnailRequests -= 1 + runThumbnailQueue() + }) + }) + runThumbnailQueue() + }) +} + +function removeThumbnail(cacheKey: string, entry: ThumbnailCacheEntry) { + if (thumbnailCache.get(cacheKey) !== entry) return + thumbnailCache.delete(cacheKey) + if (entry.url) { + URL.revokeObjectURL(entry.url) + } else { + void entry.promise.then( + (url) => URL.revokeObjectURL(url), + () => undefined, + ) + } +} + +function trimThumbnailCache() { + if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) return + + const unused = [...thumbnailCache.entries()] + .filter(([, entry]) => entry.users === 0) + .sort(([, left], [, right]) => left.lastUsed - right.lastUsed) + for (const [cacheKey, entry] of unused) { + if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) break + removeThumbnail(cacheKey, entry) + } +} + +function acquireThumbnail(source: ImageThumbnailSource, maxDimension: number, revision: unknown) { + const cacheKey = JSON.stringify([ + source.instanceId, + source.filePath, + maxDimension, + revision ?? null, + ]) + let entry = thumbnailCache.get(cacheKey) + if (!entry) { + entry = { + users: 0, + lastUsed: performance.now(), + promise: enqueueThumbnail(async () => { + const bytes = await invoke('plugin:files|screenshot_thumbnail', { + instanceId: source.instanceId, + filePath: source.filePath, + maxDimension, + }) + return URL.createObjectURL(new Blob([bytes])) + }), + } + thumbnailCache.set(cacheKey, entry) + void entry.promise.then( + (url) => { + entry!.url = url + trimThumbnailCache() + }, + () => { + if (thumbnailCache.get(cacheKey) === entry) thumbnailCache.delete(cacheKey) + }, + ) + } + + entry.users += 1 + entry.lastUsed = performance.now() + return { + promise: entry.promise, + release: () => { + entry!.users = Math.max(0, entry!.users - 1) + entry!.lastUsed = performance.now() + trimThumbnailCache() + }, + } +} + +export function useImageThumbnail( + source: MaybeRefOrGetter, + maxDimension: MaybeRefOrGetter = 512, + revision: MaybeRefOrGetter = undefined, +) { + const thumbnail = ref('') + + watch( + [() => toValue(source), () => toValue(maxDimension), () => toValue(revision)], + ([currentSource, currentMaxDimension, currentRevision], _, onCleanup) => { + thumbnail.value = '' + if (!currentSource) return + + let active = true + const fallbackUrl = convertFileSrc(currentSource.path) + const acquired = acquireThumbnail( + currentSource, + Math.max(1, Math.round(currentMaxDimension)), + currentRevision, + ) + onCleanup(() => { + active = false + acquired.release() + }) + + void acquired.promise.then( + (url) => { + if (active) thumbnail.value = url + }, + (error) => { + console.warn('Could not create image thumbnail', error) + if (active) thumbnail.value = fallbackUrl + }, + ) + }, + { immediate: true }, + ) + + return thumbnail +} \ No newline at end of file diff --git a/apps/app-frontend/src/helpers/instance-groups.ts b/apps/app-frontend/src/helpers/instance-groups.ts new file mode 100644 index 0000000..65e53e2 --- /dev/null +++ b/apps/app-frontend/src/helpers/instance-groups.ts @@ -0,0 +1 @@ +export const MAX_INSTANCE_GROUP_NAME_LENGTH = 256 \ No newline at end of file diff --git a/apps/app-frontend/src/helpers/instance.ts b/apps/app-frontend/src/helpers/instance.ts index 37362c3..537dc3d 100644 --- a/apps/app-frontend/src/helpers/instance.ts +++ b/apps/app-frontend/src/helpers/instance.ts @@ -9,7 +9,7 @@ import { type ContentItem, type ContentOwner, } from '@modrinth/ui' -import { invoke } from '@tauri-apps/api/core' +import { convertFileSrc, invoke } from '@tauri-apps/api/core' import { isOfflineMode } from '@/composables/useNetworkStatus' import { buildGcCandidateChain, resolveAutoGcStrategy } from '@/helpers/gc/auto-selector' @@ -1124,3 +1124,90 @@ export async function check_symlink_capability(): Promise { export async function allow_symlink_target(path: string): Promise { return await invoke('allow_symlink_target', { path }) } + +export type ScreenshotKey = { instance_id: string; file_name: string } +export type InstanceScreenshot = { + id: string + instance_id: string + instance_name: string + file_name: string + created_at: string + modified_at: number + group_id: string | null + path: string + url: string +} +export type ScreenshotGroup = { id: string; name: string } +export type ScreenshotGroupImport = ScreenshotGroup & { screenshot_ids: string[] } +export type ScreenshotGroupMembershipUpdate = { screenshot_id: string; group_id: string | null } + +export async function list_screenshots(instanceId: string): Promise { + return await invoke('plugin:instance|instance_list_screenshots', { instanceId }) +} +export async function list_all_screenshots(): Promise { + return await invoke('plugin:instance|instance_list_all_screenshots') +} +export async function list_synced_screenshots(): Promise { + return await invoke('plugin:instance|instance_list_synced_screenshots') +} +export async function list_screenshot_groups(): Promise { + return await invoke('plugin:instance|instance_list_screenshot_groups') +} +export async function create_screenshot_group( + name: string, + screenshotIds: string[], +): Promise { + return await invoke('plugin:instance|instance_create_screenshot_group', { + name, + ids: screenshotIds, + }) +} +export async function rename_screenshot_group( + id: string, + newName: string, +): Promise { + return await invoke('plugin:instance|instance_rename_screenshot_group', { id, newName }) +} +export async function delete_screenshot_group(id: string): Promise { + return await invoke('plugin:instance|instance_delete_screenshot_group', { id }) +} +export async function set_screenshot_group_memberships( + updates: ScreenshotGroupMembershipUpdate[], +): Promise { + return await invoke('plugin:instance|instance_set_screenshot_group_memberships', { updates }) +} +export async function import_screenshot_groups(groups: ScreenshotGroupImport[]): Promise { + return await invoke('plugin:instance|instance_import_screenshot_groups', { groups }) +} +export async function save_edited_screenshot( + key: ScreenshotKey, + pngBytes: Uint8Array, + mode: 'create_copy' | 'replace_edit', +): Promise { + return await invoke('plugin:instance|instance_save_edited_screenshot', { + key, + bytes: Array.from(pngBytes), + mode, + }) +} +export async function delete_screenshots(keys: ScreenshotKey[]): Promise { + return await invoke('plugin:instance|instance_delete_screenshots', { keys }) +} +export async function export_screenshots(keys: ScreenshotKey[], path: string): Promise { + return await invoke('plugin:instance|instance_export_screenshots', { keys, path }) +} +export async function open_screenshot(key: ScreenshotKey): Promise { + return await invoke('plugin:instance|instance_open_screenshot', { key }) +} +export async function move_screenshots( + keys: ScreenshotKey[], + targetInstanceId: string, +): Promise { + return await invoke('plugin:instance|instance_move_screenshots', { + keys, + target: targetInstanceId, + }) +} +export function getInstanceIconUrl(iconPath?: string | null): string | undefined { + return iconPath ? convertFileSrc(iconPath) : undefined +} \ No newline at end of file diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 795d89d..95b10fb 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -1,196 +1,4 @@ { - "app.instance-player.title": { "message": "Choose an instance player" }, - "app.instance-player.remember": { "message": "This instance will keep using your choice. Switch players in instance settings." }, - "app.instance-player.restore": { "message": "Sign in again as {name}. To choose someone else, open instance settings." }, - "app.instance-player.loading": { "message": "Loading signed-in players…" }, - "app.instance-player.licensed": { "message": "Minecraft account" }, - "app.instance-player.skin": { "message": "Skin site player" }, - "app.instance-player.offline": { "message": "Offline player" }, - "app.instance-player.empty": { "message": "No available skin site players. You can create a player on the skin site." }, - "app.instance-player.sign-in": { "message": "Sign in to choose a player." }, - "app.instance-player.retry": { "message": "Retry" }, - "app.instance-player.skin-login": { "message": "Sign in to skin site" }, - "app.instance-player.or": { "message": "Or" }, - "app.instance-player.use-microsoft": { "message": "Use a Minecraft account" }, - "app.instance-player.saving": { "message": "Saving instance player…" }, - "app.instance-player.setting": { "message": "Instance player" }, - "app.instance-player.first-launch": { "message": "Choose a player on first launch" }, - "app.instance-player.change": { "message": "Switch instance player" }, - "app.hosted-mods.title": { "message": "Updating server Mods" }, - "app.hosted-mods.description": { "message": "The game starts after all required updates have been installed." }, - "app.hosted-mods.complete": { "message": "Downloaded" }, - "app.hosted-mods.downloads": { "message": "View downloads" }, - "app.hosted-mods.close": { "message": "Close" }, - "app.hosted-packs.progress.retry": { "message": "Return to the instance to retry" }, - "app.hosted-packs.progress.preparing": { "message": "Preparing modpack installation…" }, - "app.hosted-packs.progress.downloads": { "message": "View downloads" }, - "app.hosted-packs.open-instance": { "message": "Open installed instance" }, - "app.hosted-packs.retry-install": { "message": "Retry installation" }, - "app.hosted-packs.auto-installing": { "message": "Downloading and installing the server modpack…" }, - "app.hosted-packs.auto-description": { "message": "Download and automatically install the modpack from StarLight to play on the StarLight server with one click." }, - "app.hosted-packs.auto-install": { "message": "Install the StarLight modpack" }, - "app.hosted-install.game-dir.header": { "message": "Choose game directory" }, - "app.hosted-install.game-dir.description": { "message": "StarLight instance data (mods, saves, configs, resource packs) is stored in an external game directory. Pick a root folder — the modpack gets its own subfolder inside it." }, - "app.hosted-install.game-dir.label": { "message": "Game directory root" }, - "app.hosted-install.game-dir.browse": { "message": "Browse" }, - "app.hosted-install.game-dir.no-selection": { "message": "No folder selected" }, - "app.hosted-install.game-dir.reset-default": { "message": "Use default location" }, - "app.hosted-install.game-dir.preview": { "message": "Game files will be installed to: {path}" }, - "app.hosted-install.game-dir.confirm": { "message": "Install" }, - "app.onboarding.instance-mode.title": { "message": "Choose your instance type" }, - "app.onboarding.instance-mode.description": { "message": "StarLight automatically installs the administrator-selected modpack and versions, then checks and completes updates before every launch. A StarLight login is required. Choose Local to select your own versions and modpacks for other servers or single-player." }, - "app.instance-mode.title": { "message": "Instance type" }, - "app.instance-mode.starlight": { "message": "StarLight instance" }, - "app.instance-mode.local": { "message": "Local instance" }, - "app.instance-mode.starlight-description": { "message": "Required for playing on StarLight. Automatically installs the server-selected modpack, Minecraft version, and loader. Every launch requires a StarLight login and checks for updates before starting." }, - "app.instance-mode.local-description": { "message": "Does not sync StarLight server changes. Skips sync checks for faster startup and lets you choose your own modpacks. Best for third-party servers and personal single-player worlds." }, - "app.instance-mode.saving": { "message": "Applying instance type and installing the server modpack when needed…" }, - "app.instance-mode.loading": { "message": "Loading instance type…" }, - "app.instance-mode.retry": { "message": "Retry" }, - "app.instance-mode.local-packs-title": { "message": "Local modpacks" }, - "app.instance-mode.local-packs": { "message": "Choose any modpack to install as a local instance. Existing files and worlds are kept when you switch to Local; StarLight changes will no longer be synchronized." }, - "app.instance-mode.browse": { "message": "Browse modpacks" }, - "app.instance-mode.import": { "message": "Import as a local instance" }, - "app.hosted-packs.title": { "message": "Server-managed modpack" }, - "app.hosted-packs.description": { "message": "The administrator selects this modpack and its versions. Every launch checks for updates and downloads changes before starting. A valid StarLight login and network connection are required." }, - "app.hosted-packs.refresh": {"message": "Refresh"}, - "app.hosted-packs.loading": {"message": "Loading published modpacks…"}, - "app.hosted-packs.bound": {"message": "Installed: {name} · {version}"}, - "app.hosted-packs.syncing": {"message": "Comparing files and synchronizing changes…"}, - "app.hosted-packs.complete": {"message": "Synced to {version}. Changed {count} files; downloaded {size} MiB."}, - "app.hosted-packs.preserved": {"message": "Preserved {count} locally modified or personal files"}, - "app.hosted-packs.empty": {"message": "No modpacks have been approved for publication yet."}, - "app.hosted-packs.update": {"message": "Synchronize now"}, - "app.hosted-packs.install": { "message": "Retry automatic installation" }, - "app.hosted-packs.management": {"message": "Content management"}, - "app.hosted-packs.mods": {"message": "Manage mods"}, - "app.hosted-packs.packs": {"message": "Manage modpacks"}, - "app.home.luck.title": { "message": "Daily luck index" }, - "app.home.luck.get": { "message": "Get with one click" }, - "app.home.luck.login-to-get": { "message": "Sign in to get it" }, - "app.home.luck.loading": { "message": "Asking the little sprite..." }, - "app.home.luck.error": { - "message": "The little sprite did not answer. Please try again later." - }, - "app.home.luck.out-of": { "message": "out of 100" }, - "minecraft-account.skin-site.signed-in": { "message": "Signed in to StarLight Skin Site" }, - "minecraft-account.skin-site.checking": { "message": "Checking skin site session…" }, - "minecraft-account.skin-site.sync-error": { - "message": "Could not verify the skin site session. Retrying automatically." - }, - "minecraft-account.skin-site.players": { "message": "Skin site players ({count})" }, - "minecraft-account.skin-site.players.retry": { "message": "Retry" }, - "minecraft-account.skin-site.players.empty": { - "message": "No player profiles are attached to this skin site account." - }, - "minecraft-account.skin-site.players.error": { - "message": "Could not refresh the player list. Previously loaded players are kept." - }, - "app.easteregg.color-mine.title": { - "message": "Starlight Mine: Chromatic Realms" - }, - "app.easteregg.color-mine.easy": { - "message": "Easy" - }, - "app.easteregg.color-mine.normal": { - "message": "Normal" - }, - "app.easteregg.color-mine.hard": { - "message": "Hard" - }, - "app.easteregg.color-mine.newGame": { - "message": "New board" - }, - "app.easteregg.color-mine.close": { - "message": "Close" - }, - "app.easteregg.color-mine.choose": { - "message": "Click a revealed tile to sample its color, then paint a gray tile." - }, - "app.easteregg.color-mine.selected": { - "message": "Color {color} selected · Click a colored tile to change your brush." - }, - "app.easteregg.color-mine.rulesTitle": { - "message": "How to play" - }, - "app.easteregg.color-mine.rules": { - "message": "Numbers count same-color tiles in the eight surrounding spaces, excluding the tile itself. Each color forms one connected region using only horizontal and vertical edges. Sample a revealed tile to select a brush; a correct paint reveals a new number, but one wrong paint ends the game. Every board has a step-by-step deduction path without guessing." - }, - "app.easteregg.color-mine.loading": { - "message": "Generating regions and checking the deduction path…" - }, - "app.easteregg.color-mine.generateError": { - "message": "Could not generate this board. Try again." - }, - "app.easteregg.color-mine.retry": { - "message": "Try again" - }, - "app.easteregg.color-mine.progress": { - "message": "{count} / {total} revealed" - }, - "app.easteregg.color-mine.best": { - "message": "Best · {time}" - }, - "app.easteregg.color-mine.won": { - "message": "All colors restored!" - }, - "app.easteregg.color-mine.lost": { - "message": "Wrong color at row {row}, column {column}: selected {selected}, correct {correct}. Game over." - }, - "app.easteregg.color-mine.review": { - "message": "Unrevealed answers are now shown faded for review." - }, - "app.easteregg.color-mine.leaveTitle": { - "message": "Leave this board?" - }, - "app.easteregg.color-mine.leaveText": { - "message": "Save to continue later, or discard this unfinished board." - }, - "app.easteregg.color-mine.saveLeave": { - "message": "Save and leave" - }, - "app.easteregg.color-mine.discard": { - "message": "Discard" - }, - "app.easteregg.color-mine.continue": { - "message": "Keep playing" - }, - "app.easteregg.color-mine.replaceTitle": { - "message": "Start a new board?" - }, - "app.easteregg.color-mine.replaceText": { - "message": "The current board will be discarded." - }, - "app.easteregg.color-mine.savedTitle": { - "message": "An unfinished board is saved" - }, - "app.easteregg.color-mine.resume": { - "message": "Resume saved game" - }, - "app.easteregg.color-mine.storageError": { - "message": "Could not read or write the saved game. Your current board has been kept. Retry or explicitly discard it." - }, - "app.easteregg.color-mine.recordError": { - "message": "Finished, but the best time could not be saved." - }, - "app.easteregg.color-mine.zoomIn": { - "message": "Zoom in" - }, - "app.easteregg.color-mine.zoomOut": { - "message": "Zoom out" - }, - "app.easteregg.color-mine.map": { - "message": "Board overview. Click to navigate." - }, - "app.easteregg.color-mine.board": { - "message": "{size} by {size} color deduction board" - }, - "app.easteregg.color-mine.hidden": { - "message": "Row {row}, column {column}, unrevealed" - }, - "app.easteregg.color-mine.revealed": { - "message": "Row {row}, column {column}, color {color}, {number} same-color neighbors" - }, "app.account.signed-in-as": { "message": "Signed in as" }, @@ -362,6 +170,9 @@ "app.action-bar.view-logs": { "message": "View logs" }, + "app.action-bar.window-started": { + "message": "Started {time, time, medium}" + }, "app.ai-settings.add-model": { "message": "Add model" }, @@ -773,12 +584,12 @@ "app.appearance-settings.transparent-background.title": { "message": "Transparent background" }, - "app.appearance-settings.ui-scale.title": { - "message": "Launcher scale" - }, "app.appearance-settings.ui-scale.description": { "message": "Scale the whole launcher interface. A smaller scale fits more content, which helps when embedded pages are cut off." }, + "app.appearance-settings.ui-scale.title": { + "message": "Launcher scale" + }, "app.appearance-settings.unknown-pack-warning.description": { "message": "If you attempt to install a Modrinth Pack file (.mrpack) that isn't hosted on Modrinth, we'll make sure you understand the risks before installing it." }, @@ -974,24 +785,6 @@ "app.close-launcher.tray": { "message": "Hide to tray" }, - "app.community-announcement.feedback-middle": { - "message": ", or share them with us through " - }, - "app.community-announcement.feedback-prefix": { - "message": "If you have more ideas or suggestions, please join the player QQ group: " - }, - "app.community-announcement.feedback-suffix": { - "message": ". We look forward to improving Starlight Launcher together!" - }, - "app.community-announcement.response": { - "message": "Since Starlight Launcher was promoted on video platforms, we have received far more love and attention than we expected, along with many thoughtful suggestions and high-quality reports." - }, - "app.community-announcement.thanks": { - "message": "I have done my best to personally reply to every comment on the videos. Thank you sincerely for all your support!" - }, - "app.community-announcement.title": { - "message": "To all our users" - }, "app.content-favorites.add": { "message": "Add to favorites" }, @@ -1865,6 +1658,111 @@ "app.drop.world-imported-title": { "message": "World imported" }, + "app.easteregg.color-mine.best": { + "message": "Best · {time}" + }, + "app.easteregg.color-mine.board": { + "message": "{size} by {size} color deduction board" + }, + "app.easteregg.color-mine.choose": { + "message": "Click a revealed tile to sample its color, then paint a gray tile." + }, + "app.easteregg.color-mine.close": { + "message": "Close" + }, + "app.easteregg.color-mine.continue": { + "message": "Keep playing" + }, + "app.easteregg.color-mine.discard": { + "message": "Discard" + }, + "app.easteregg.color-mine.easy": { + "message": "Easy" + }, + "app.easteregg.color-mine.generateError": { + "message": "Could not generate this board. Try again." + }, + "app.easteregg.color-mine.hard": { + "message": "Hard" + }, + "app.easteregg.color-mine.hidden": { + "message": "Row {row}, column {column}, unrevealed" + }, + "app.easteregg.color-mine.leaveText": { + "message": "Save to continue later, or discard this unfinished board." + }, + "app.easteregg.color-mine.leaveTitle": { + "message": "Leave this board?" + }, + "app.easteregg.color-mine.loading": { + "message": "Generating regions and checking the deduction path…" + }, + "app.easteregg.color-mine.lost": { + "message": "Wrong color at row {row}, column {column}: selected {selected}, correct {correct}. Game over." + }, + "app.easteregg.color-mine.map": { + "message": "Board overview. Click to navigate." + }, + "app.easteregg.color-mine.newGame": { + "message": "New board" + }, + "app.easteregg.color-mine.normal": { + "message": "Normal" + }, + "app.easteregg.color-mine.progress": { + "message": "{count} / {total} revealed" + }, + "app.easteregg.color-mine.recordError": { + "message": "Finished, but the best time could not be saved." + }, + "app.easteregg.color-mine.replaceText": { + "message": "The current board will be discarded." + }, + "app.easteregg.color-mine.replaceTitle": { + "message": "Start a new board?" + }, + "app.easteregg.color-mine.resume": { + "message": "Resume saved game" + }, + "app.easteregg.color-mine.retry": { + "message": "Try again" + }, + "app.easteregg.color-mine.revealed": { + "message": "Row {row}, column {column}, color {color}, {number} same-color neighbors" + }, + "app.easteregg.color-mine.review": { + "message": "Unrevealed answers are now shown faded for review." + }, + "app.easteregg.color-mine.rules": { + "message": "Numbers count same-color tiles in the eight surrounding spaces, excluding the tile itself. Each color forms one connected region using only horizontal and vertical edges. Sample a revealed tile to select a brush; a correct paint reveals a new number, but one wrong paint ends the game. Every board has a step-by-step deduction path without guessing." + }, + "app.easteregg.color-mine.rulesTitle": { + "message": "How to play" + }, + "app.easteregg.color-mine.saveLeave": { + "message": "Save and leave" + }, + "app.easteregg.color-mine.savedTitle": { + "message": "An unfinished board is saved" + }, + "app.easteregg.color-mine.selected": { + "message": "Color {color} selected · Click a colored tile to change your brush." + }, + "app.easteregg.color-mine.storageError": { + "message": "Could not read or write the saved game. Your current board has been kept. Retry or explicitly discard it." + }, + "app.easteregg.color-mine.title": { + "message": "Starlight Mine: Chromatic Realms" + }, + "app.easteregg.color-mine.won": { + "message": "All colors restored!" + }, + "app.easteregg.color-mine.zoomIn": { + "message": "Zoom in" + }, + "app.easteregg.color-mine.zoomOut": { + "message": "Zoom out" + }, "app.error.account-description": { "message": "Check that you signed in with the correct account. You may own Minecraft on another Microsoft account." }, @@ -2204,6 +2102,12 @@ "app.home.instances.view-all": { "message": "View all instances" }, + "app.home.launch-progress.empty": { + "message": "启动游戏后,这里会显示进度。" + }, + "app.home.launch-progress.title": { + "message": "启动进度" + }, "app.home.layout.switch-to-information": { "message": "Switch to Information Home" }, @@ -2213,6 +2117,24 @@ "app.home.layout.toggle": { "message": "Minimal Home" }, + "app.home.luck.error": { + "message": "The little sprite did not answer. Please try again later." + }, + "app.home.luck.get": { + "message": "Get with one click" + }, + "app.home.luck.loading": { + "message": "Asking the little sprite..." + }, + "app.home.luck.login-to-get": { + "message": "Sign in to get it" + }, + "app.home.luck.out-of": { + "message": "out of 100" + }, + "app.home.luck.title": { + "message": "Daily luck index" + }, "app.home.minimal.change-instance": { "message": "Change Home instance" }, @@ -2234,12 +2156,6 @@ "app.home.minimal.picker.title": { "message": "Choose a Home instance" }, - "app.home.news.open-article": { - "message": "Read on minecraft.net" - }, - "app.home.news.title": { - "message": "Minecraft news" - }, "app.home.playtime.hours-minutes": { "message": "{hours}h {minutes}m" }, @@ -2324,12 +2240,6 @@ "app.home.widgets.choose-world": { "message": "Choose a world" }, - "app.home.widgets.customize": { - "message": "Customize widgets" - }, - "app.home.widgets.done": { - "message": "Finish editing" - }, "app.home.widgets.drag": { "message": "Drag to move widget" }, @@ -2357,15 +2267,6 @@ "app.home.widgets.instance-description": { "message": "Choose one instance for a dedicated launch shortcut." }, - "app.home.widgets.layout.switch-to-free": { - "message": "Switch to free widget layout" - }, - "app.home.widgets.layout.switch-to-grid": { - "message": "Switch to grid widget layout" - }, - "app.home.widgets.layout.toggle": { - "message": "Widget layout mode" - }, "app.home.widgets.loading": { "message": "Loading..." }, @@ -2414,12 +2315,6 @@ "app.home.widgets.replace": { "message": "Replace target" }, - "app.home.widgets.reset": { - "message": "Restore default widgets" - }, - "app.home.widgets.reset-confirm": { - "message": "Restore the default widget layout?" - }, "app.home.widgets.search": { "message": "Search" }, @@ -2447,6 +2342,111 @@ "app.home.worlds.pinned": { "message": "Pinned worlds" }, + "app.hosted-install.game-dir.browse": { + "message": "Browse" + }, + "app.hosted-install.game-dir.confirm": { + "message": "Install" + }, + "app.hosted-install.game-dir.description": { + "message": "StarLight instance data (mods, saves, configs, resource packs) is stored in an external game directory. Pick a root folder — the modpack gets its own subfolder inside it." + }, + "app.hosted-install.game-dir.header": { + "message": "Choose game directory" + }, + "app.hosted-install.game-dir.label": { + "message": "Game directory root" + }, + "app.hosted-install.game-dir.no-selection": { + "message": "No folder selected" + }, + "app.hosted-install.game-dir.preview": { + "message": "Game files will be installed to: {path}" + }, + "app.hosted-install.game-dir.reset-default": { + "message": "Use default location" + }, + "app.hosted-mods.close": { + "message": "Close" + }, + "app.hosted-mods.complete": { + "message": "Downloaded" + }, + "app.hosted-mods.description": { + "message": "The game starts after all required updates have been installed." + }, + "app.hosted-mods.downloads": { + "message": "View downloads" + }, + "app.hosted-mods.title": { + "message": "Updating server Mods" + }, + "app.hosted-packs.auto-description": { + "message": "Download and automatically install the modpack from StarLight to play on the StarLight server with one click." + }, + "app.hosted-packs.auto-install": { + "message": "Install the StarLight modpack" + }, + "app.hosted-packs.auto-installing": { + "message": "Downloading and installing the server modpack…" + }, + "app.hosted-packs.bound": { + "message": "Installed: {name} · {version}" + }, + "app.hosted-packs.complete": { + "message": "Synced to {version}. Changed {count} files; downloaded {size} MiB." + }, + "app.hosted-packs.description": { + "message": "The administrator selects this modpack and its versions. Every launch checks for updates and downloads changes before starting. A valid StarLight login and network connection are required." + }, + "app.hosted-packs.empty": { + "message": "No modpacks have been approved for publication yet." + }, + "app.hosted-packs.install": { + "message": "Retry automatic installation" + }, + "app.hosted-packs.loading": { + "message": "Loading published modpacks…" + }, + "app.hosted-packs.management": { + "message": "Content management" + }, + "app.hosted-packs.mods": { + "message": "Manage mods" + }, + "app.hosted-packs.open-instance": { + "message": "Open installed instance" + }, + "app.hosted-packs.packs": { + "message": "Manage modpacks" + }, + "app.hosted-packs.preserved": { + "message": "Preserved {count} locally modified or personal files" + }, + "app.hosted-packs.progress.downloads": { + "message": "View downloads" + }, + "app.hosted-packs.progress.preparing": { + "message": "Preparing modpack installation…" + }, + "app.hosted-packs.progress.retry": { + "message": "Return to the instance to retry" + }, + "app.hosted-packs.refresh": { + "message": "Refresh" + }, + "app.hosted-packs.retry-install": { + "message": "Retry installation" + }, + "app.hosted-packs.syncing": { + "message": "Comparing files and synchronizing changes…" + }, + "app.hosted-packs.title": { + "message": "Server-managed modpack" + }, + "app.hosted-packs.update": { + "message": "Synchronize now" + }, "app.install.phase.downloading_content": { "message": "Downloading content" }, @@ -2501,9 +2501,6 @@ "app.install.phase.running_loader_processors": { "message": "Running loader processors" }, - "app.instance-item.already-open": { - "message": "Instance is already open" - }, "app.instance-item.loading-modpack": { "message": "Loading modpack..." }, @@ -2513,6 +2510,93 @@ "app.instance-item.view-instance": { "message": "View instance" }, + "app.instance-mode.browse": { + "message": "Browse modpacks" + }, + "app.instance-mode.import": { + "message": "Import as a local instance" + }, + "app.instance-mode.loading": { + "message": "Loading instance type…" + }, + "app.instance-mode.local": { + "message": "Local instance" + }, + "app.instance-mode.local-description": { + "message": "Does not sync StarLight server changes. Skips sync checks for faster startup and lets you choose your own modpacks. Best for third-party servers and personal single-player worlds." + }, + "app.instance-mode.local-packs": { + "message": "Choose any modpack to install as a local instance. Existing files and worlds are kept when you switch to Local; StarLight changes will no longer be synchronized." + }, + "app.instance-mode.local-packs-title": { + "message": "Local modpacks" + }, + "app.instance-mode.retry": { + "message": "Retry" + }, + "app.instance-mode.saving": { + "message": "Applying instance type and installing the server modpack when needed…" + }, + "app.instance-mode.starlight": { + "message": "StarLight instance" + }, + "app.instance-mode.starlight-description": { + "message": "Required for playing on StarLight. Automatically installs the server-selected modpack, Minecraft version, and loader. Every launch requires a StarLight login and checks for updates before starting." + }, + "app.instance-mode.title": { + "message": "Instance type" + }, + "app.instance-player.change": { + "message": "Switch instance player" + }, + "app.instance-player.empty": { + "message": "No available skin site players. You can create a player on the skin site." + }, + "app.instance-player.first-launch": { + "message": "Choose a player on first launch" + }, + "app.instance-player.licensed": { + "message": "Minecraft account" + }, + "app.instance-player.loading": { + "message": "Loading signed-in players…" + }, + "app.instance-player.offline": { + "message": "Offline player" + }, + "app.instance-player.or": { + "message": "Or" + }, + "app.instance-player.remember": { + "message": "This instance will keep using your choice. Switch players in instance settings." + }, + "app.instance-player.restore": { + "message": "Sign in again as {name}. To choose someone else, open instance settings." + }, + "app.instance-player.retry": { + "message": "Retry" + }, + "app.instance-player.saving": { + "message": "Saving instance player…" + }, + "app.instance-player.setting": { + "message": "Instance player" + }, + "app.instance-player.sign-in": { + "message": "Sign in to choose a player." + }, + "app.instance-player.skin": { + "message": "Skin site player" + }, + "app.instance-player.skin-login": { + "message": "Sign in to skin site" + }, + "app.instance-player.title": { + "message": "Choose an instance player" + }, + "app.instance-player.use-microsoft": { + "message": "Use a Minecraft account" + }, "app.instance.back": { "message": "Back to instance" }, @@ -2966,17 +3050,11 @@ "app.instance.join-server": { "message": "Join server" }, - "app.instance.launch-instance": { - "message": "Launch instance" - }, "app.instance.launch-another": { "message": "Launch another window" }, - "app.action-bar.window-started": { - "message": "Started {time, time, medium}" - }, - "app.instance.stop-all": { - "message": "Stop all windows of this instance" + "app.instance.launch-instance": { + "message": "Launch instance" }, "app.instance.linked-to": { "message": "Linked to" @@ -3200,6 +3278,9 @@ "app.instance.starting": { "message": "Starting... {seconds, number}s" }, + "app.instance.stop-all": { + "message": "Stop all windows of this instance" + }, "app.instance.stopping": { "message": "Stopping..." }, @@ -5078,12 +5159,12 @@ "app.lab.skin-editor.loading": { "message": "Loading skin editor" }, - "app.lab.skin-editor.retry": { - "message": "Try again" - }, "app.lab.skin-editor.resource-error": { "message": "Skin editor files are missing or damaged. Reinstall the launcher or extract the complete portable archive." }, + "app.lab.skin-editor.retry": { + "message": "Try again" + }, "app.lab.skin-editor.title": { "message": "Skin editor" }, @@ -5114,9 +5195,6 @@ "app.library.tabs.saved": { "message": "Saved" }, - "app.library.tabs.servers": { - "message": "Servers" - }, "app.library.tabs.shared": { "message": "Shared with me" }, @@ -5705,9 +5783,6 @@ "app.multiplayer.tab.rooms": { "message": "Rooms" }, - "app.multiplayer.tab.servers": { - "message": "Servers" - }, "app.multiplayer.terracotta.public-node-invalid": { "message": "Invalid node URI: {node}. Use http, https, tcp, tls, udp, ws, or wss." }, @@ -5777,6 +5852,12 @@ "app.notification.warning": { "message": "Warning" }, + "app.onboarding.account-login.description": { + "message": "Sign in to the StarLight skin site here to use your StarLight players, install hosted modpacks, and sync your account." + }, + "app.onboarding.account-login.title": { + "message": "Sign in to StarLight" + }, "app.onboarding.action.click-create": { "message": "Click Create new instance to continue" }, @@ -5819,6 +5900,12 @@ "app.onboarding.action.start": { "message": "Take the tour" }, + "app.onboarding.announcement.description": { + "message": "Open the announcement entry in the sidebar to read the latest news, rules, and server notices." + }, + "app.onboarding.announcement.title": { + "message": "Latest announcements" + }, "app.onboarding.appearance.description": { "message": "Theme, accent, backgrounds, and window effects all live here. Make the launcher feel familiar." }, @@ -5831,6 +5918,12 @@ "app.onboarding.browse.title": { "message": "Search with receipts" }, + "app.onboarding.create-starlight.description": { + "message": "Use Create new instance here. A StarLight instance installs the server-selected modpack and versions, then keeps them updated before each launch." + }, + "app.onboarding.create-starlight.title": { + "message": "Create a StarLight instance" + }, "app.onboarding.create.description": { "message": "Start from scratch or bring in an existing instance or modpack. Your call." }, @@ -5897,6 +5990,12 @@ "app.onboarding.favorites.title": { "message": "Keep a short list" }, + "app.onboarding.home-intro.description": { + "message": "This is your home page. It shows your instances, recent activity, and quick shortcuts to get playing." + }, + "app.onboarding.home-intro.title": { + "message": "Welcome home" + }, "app.onboarding.home-layout.description": { "message": "Use the bottom-right control to switch between the StarLight skin site home and the focused instance launcher." }, @@ -5909,6 +6008,12 @@ "app.onboarding.instance-actions.title": { "message": "The main controls" }, + "app.onboarding.instance-mode.description": { + "message": "StarLight automatically installs the administrator-selected modpack and versions, then checks and completes updates before every launch. A StarLight login is required. Choose Local to select your own versions and modpacks for other servers or single-player." + }, + "app.onboarding.instance-mode.title": { + "message": "Choose your instance type" + }, "app.onboarding.instance-tabs.description": { "message": "Use these tabs for content, files, screenshots, worlds, and logs. Tidy chaos." }, @@ -5948,6 +6053,12 @@ "app.onboarding.resources.title": { "message": "Do not cook the computer" }, + "app.onboarding.settings-updates.description": { + "message": "The Updates tab controls the update channel, checks for new launcher versions, and shows the changelog." + }, + "app.onboarding.settings-updates.title": { + "message": "Stay up to date" + }, "app.onboarding.settings.description": { "message": "The useful controls live here: launcher preferences, game launch behavior, content downloads, and privacy." }, @@ -5975,36 +6086,6 @@ "app.onboarding.welcome.title": { "message": "Everything is ready" }, - "app.onboarding.home-intro.title": { - "message": "Welcome home" - }, - "app.onboarding.home-intro.description": { - "message": "This is your home page. It shows your instances, recent activity, and quick shortcuts to get playing." - }, - "app.onboarding.account-login.title": { - "message": "Sign in to StarLight" - }, - "app.onboarding.account-login.description": { - "message": "Sign in to the StarLight skin site here to use your StarLight players, install hosted modpacks, and sync your account." - }, - "app.onboarding.announcement.title": { - "message": "Latest announcements" - }, - "app.onboarding.announcement.description": { - "message": "Open the announcement entry in the sidebar to read the latest news, rules, and server notices." - }, - "app.onboarding.create-starlight.title": { - "message": "Create a StarLight instance" - }, - "app.onboarding.create-starlight.description": { - "message": "Use Create new instance here. A StarLight instance installs the server-selected modpack and versions, then keeps them updated before each launch." - }, - "app.onboarding.settings-updates.title": { - "message": "Stay up to date" - }, - "app.onboarding.settings-updates.description": { - "message": "The Updates tab controls the update channel, checks for new launcher versions, and shows the changelog." - }, "app.project.curseforge.loading": { "message": "Loading CurseForge project…" }, @@ -6071,18 +6152,6 @@ "app.project.mcarchive.versions": { "message": "Versions" }, - "app.project.modpack-server.created-description": { - "message": "{name} is ready. Configure and start it in Multiplayer." - }, - "app.project.modpack-server.created-title": { - "message": "Server created" - }, - "app.project.modpack-server.open": { - "message": "Open server" - }, - "app.project.modpack-server.start": { - "message": "Start server" - }, "app.project.open-in-mcmod": { "message": "Open in MC Mod" }, @@ -6149,33 +6218,162 @@ "app.project.versions.already-installed": { "message": "Already installed" }, - "app.project.versions.start-server": { - "message": "Create server" - }, - "app.remote-announcements.preview-action": { - "message": "Visit website" - }, - "app.remote-announcements.preview-content": { - "message": "## Announcement preview\n\nThis is **sample content**, not a published announcement.\n\n- Supports headings, lists, and links\n- Close buttons are always available\n\n> Previewing does not change real announcement read status.\n\n| Type | Display |\n| --- | --- |\n| Modal | Full Markdown content |\n| Popup | Summary, then full content |\n\n[Visit the website](https://axlmc.org)" - }, - "app.remote-announcements.preview-summary": { - "message": "This is a local preview. Select View announcement to preview the full Markdown content." - }, - "app.remote-announcements.preview-title": { - "message": "Announcement style preview" - }, - "app.remote-announcements.read-all": { - "message": "Mark all announcements as read" - }, - "app.remote-announcements.unread": { - "message": "Unread" - }, - "app.remote-announcements.view": { - "message": "View announcement" - }, "app.restarting": { "message": "Restarting..." }, + "app.screenshots.copied": { + "message": "Copied" + }, + "app.screenshots.copy": { + "message": "Copy image" + }, + "app.screenshots.delete-description": { + "message": "Permanently delete {name}? This action cannot be undone." + }, + "app.screenshots.delete-success": { + "message": "Screenshot deleted" + }, + "app.screenshots.delete-title": { + "message": "Delete screenshot" + }, + "app.screenshots.deselect": { + "message": "Deselect {name}" + }, + "app.screenshots.edit": { + "message": "Edit screenshot" + }, + "app.screenshots.empty-description": { + "message": "Screenshots you take in-game will appear here." + }, + "app.screenshots.empty-heading": { + "message": "No screenshots yet" + }, + "app.screenshots.error-heading": { + "message": "Failed to load screenshots" + }, + "app.screenshots.go-to-instance": { + "message": "Go to instance" + }, + "app.screenshots.group-by": { + "message": "Group by" + }, + "app.screenshots.group.all-screenshots": { + "message": "All screenshots" + }, + "app.screenshots.group.collapse": { + "message": "Collapse group" + }, + "app.screenshots.group.custom": { + "message": "Custom group" + }, + "app.screenshots.group.date": { + "message": "Date" + }, + "app.screenshots.group.delete": { + "message": "Delete group" + }, + "app.screenshots.group.delete-description": { + "message": "Screenshots in this group will become ungrouped." + }, + "app.screenshots.group.edit": { + "message": "Edit group name" + }, + "app.screenshots.group.empty": { + "message": "Drag and drop to add screenshots." + }, + "app.screenshots.group.expand": { + "message": "Expand group" + }, + "app.screenshots.group.instance": { + "message": "Instance" + }, + "app.screenshots.group.new": { + "message": "New group" + }, + "app.screenshots.group.none": { + "message": "No grouping" + }, + "app.screenshots.group.this-month": { + "message": "This month" + }, + "app.screenshots.group.this-week": { + "message": "This week" + }, + "app.screenshots.group.today": { + "message": "Today" + }, + "app.screenshots.group.ungrouped": { + "message": "Ungrouped" + }, + "app.screenshots.group.yesterday": { + "message": "Yesterday" + }, + "app.screenshots.heading": { + "message": "Screenshots" + }, + "app.screenshots.more-actions": { + "message": "More actions" + }, + "app.screenshots.no-results-description": { + "message": "Try a different search or filter." + }, + "app.screenshots.no-results-heading": { + "message": "No matching screenshots" + }, + "app.screenshots.preview.instance-and-date": { + "message": "{instance} · {date}" + }, + "app.screenshots.search": { + "message": "Search" + }, + "app.screenshots.select": { + "message": "Select {name}" + }, + "app.screenshots.selection.aria-label": { + "message": "Selected screenshots" + }, + "app.screenshots.selection.delete-description": { + "message": "Delete {count, plural, one {# screenshot} other {# screenshots}}? This action cannot be undone." + }, + "app.screenshots.selection.delete-success": { + "message": "{count, plural, one {# screenshot deleted} other {# screenshots deleted}}" + }, + "app.screenshots.selection.delete-title": { + "message": "Delete selected screenshots" + }, + "app.screenshots.selection.export-zip": { + "message": "Export ZIP" + }, + "app.screenshots.selection.global-export-filename": { + "message": "Modrinth screenshots.zip" + }, + "app.screenshots.selection.instance-export-filename": { + "message": "{instance} screenshots.zip" + }, + "app.screenshots.selection.remove-from-group": { + "message": "Remove from group" + }, + "app.screenshots.selection.selected-count": { + "message": "{count} selected" + }, + "app.screenshots.selection.zip-archive": { + "message": "ZIP archive" + }, + "app.screenshots.show-in-folder": { + "message": "Show in folder" + }, + "app.screenshots.sort-by": { + "message": "Sort by" + }, + "app.screenshots.sort.name": { + "message": "Name" + }, + "app.screenshots.sort.newest": { + "message": "Newest" + }, + "app.screenshots.sort.oldest": { + "message": "Oldest" + }, "app.server.add": { "message": "Add" }, @@ -6194,608 +6392,20 @@ "app.server.search-instance": { "message": "Search for an instance" }, - "app.servers.action.continue-download": { - "message": "Continue download" - }, - "app.servers.action.open-folder": { - "message": "Open folder" - }, - "app.servers.action.retry-download": { - "message": "Retry download" - }, - "app.servers.action.share": { - "message": "Share online" - }, - "app.servers.action.start": { - "message": "Start" - }, - "app.servers.action.stop": { - "message": "Stop" - }, - "app.servers.card.port": { - "message": "Port {port}" - }, - "app.servers.card.type": { - "message": "{type} · {version}" - }, - "app.servers.console.forge-command-placeholder": { - "message": "Send a command - Tab completion supported" - }, - "app.servers.console.not-running": { - "message": "The server is not running" - }, - "app.servers.count": { - "message": "{count, plural, =0 {No servers yet} one {# server} other {# servers}}" - }, - "app.servers.create.title": { - "message": "Create server" - }, - "app.servers.detail.back": { - "message": "Servers" - }, - "app.servers.detail.console": { - "message": "Console" - }, - "app.servers.detail.files": { - "message": "Files" - }, - "app.servers.detail.not-found": { - "message": "This server no longer exists." - }, - "app.servers.detail.settings": { - "message": "Settings" - }, - "app.servers.empty.description": { - "message": "Create a server to play with friends, right from the launcher." - }, - "app.servers.empty.heading": { - "message": "No servers yet" - }, - "app.servers.eula.continue": { - "message": "Continue" - }, - "app.servers.eula.decline": { - "message": "Cancel" - }, - "app.servers.eula.description": { - "message": "By continuing, you agree to the Minecraft End User License Agreement (EULA). Please review the agreement below before proceeding." - }, - "app.servers.eula.title": { - "message": "Minecraft EULA" - }, - "app.servers.files.busy-tooltip": { - "message": "Stop the server to modify files" - }, - "app.servers.files.open-studio": { - "message": "Open Studio" - }, - "app.servers.files.save-as": { - "message": "Save as..." - }, - "app.servers.files.studio-running-title": { - "message": "Stop the server before opening Studio" - }, - "app.servers.icon.change": { - "message": "Change icon" - }, - "app.servers.icon.edit": { - "message": "Edit icon" - }, - "app.servers.icon.remove": { - "message": "Remove icon" - }, - "app.servers.icon.select": { - "message": "Select icon" - }, - "app.servers.loading": { - "message": "Loading servers..." - }, - "app.servers.modpack.background-hint": { - "message": "You can close this window — the download continues in the background." - }, - "app.servers.modpack.current-file": { - "message": "Now installing {file}" - }, - "app.servers.modpack.done": { - "message": "Installation complete" - }, - "app.servers.modpack.download-in-background": { - "message": "Download in background" - }, - "app.servers.modpack.downloading": { - "message": "Downloading modpack files..." - }, - "app.servers.modpack.first-run-crashed": { - "message": "The server crashed during its first start. Check that your selected Java version is compatible, then try again." - }, - "app.servers.modpack.preparing": { - "message": "Preparing server..." - }, - "app.servers.modpack.unsupported-loader-description": { - "message": "This modpack uses {loader}, but Starlight can only start modpack servers with vanilla, Fabric, or Quilt. Support for {loader} is coming soon." - }, - "app.servers.modpack.unsupported-loader-title": { - "message": "{loader} servers are not supported yet" - }, - "app.servers.port.change": { - "message": "Change port" - }, - "app.servers.port.conflict-description": { - "message": "{process} is currently occupying this port, so the server cannot start. Change the server port, or force quit the process below." - }, - "app.servers.port.conflict-title": { - "message": "Port {port} is already in use" - }, - "app.servers.port.force-quit": { - "message": "Force quit process" - }, - "app.servers.port.force-quit-failed": { - "message": "Failed to quit the process occupying the port" - }, - "app.servers.port.recheck": { - "message": "Recheck" - }, - "app.servers.port.unknown-process": { - "message": "Unknown process (PID {pid})" - }, - "app.servers.properties.field.accepts-transfers": { - "message": "Accept player transfers" - }, - "app.servers.properties.field.allow-end": { - "message": "Allow the End" - }, - "app.servers.properties.field.allow-flight": { - "message": "Allow flight" - }, - "app.servers.properties.field.allow-nether": { - "message": "Allow the Nether" - }, - "app.servers.properties.field.announce-player-achievements": { - "message": "Announce player achievements" - }, - "app.servers.properties.field.broadcast-console-to-ops": { - "message": "Broadcast console to operators" - }, - "app.servers.properties.field.broadcast-rcon-to-ops": { - "message": "Broadcast RCON to operators" - }, - "app.servers.properties.field.bug-report-link": { - "message": "Bug report link" - }, - "app.servers.properties.field.chat-spam-threshold-seconds": { - "message": "Chat spam threshold (seconds)" - }, - "app.servers.properties.field.command-spam-threshold-seconds": { - "message": "Command spam threshold (seconds)" - }, - "app.servers.properties.field.difficulty": { - "message": "Difficulty" - }, - "app.servers.properties.field.enable-code-of-conduct": { - "message": "Enable code of conduct" - }, - "app.servers.properties.field.enable-command-block": { - "message": "Enable command blocks" - }, - "app.servers.properties.field.enable-jmx-monitoring": { - "message": "Enable JMX monitoring" - }, - "app.servers.properties.field.enable-lan": { - "message": "Enable LAN" - }, - "app.servers.properties.field.enable-query": { - "message": "Enable query" - }, - "app.servers.properties.field.enable-rcon": { - "message": "Enable RCON" - }, - "app.servers.properties.field.enable-status": { - "message": "Enable status" - }, - "app.servers.properties.field.enforce-secure-profile": { - "message": "Enforce secure profile" - }, - "app.servers.properties.field.enforce-whitelist": { - "message": "Enforce whitelist" - }, - "app.servers.properties.field.entity-broadcast-range-percentage": { - "message": "Entity broadcast range percentage" - }, - "app.servers.properties.field.force-gamemode": { - "message": "Force game mode" - }, - "app.servers.properties.field.function-permission-level": { - "message": "Function permission level" - }, - "app.servers.properties.field.gamemode": { - "message": "Game mode" - }, - "app.servers.properties.field.generate-structures": { - "message": "Generate structures" - }, - "app.servers.properties.field.generator-settings": { - "message": "Generator settings" - }, - "app.servers.properties.field.hardcore": { - "message": "Hardcore" - }, - "app.servers.properties.field.hide-online-players": { - "message": "Hide online players" - }, - "app.servers.properties.field.initial-disabled-packs": { - "message": "Initial disabled packs" - }, - "app.servers.properties.field.initial-enabled-packs": { - "message": "Initial enabled packs" - }, - "app.servers.properties.field.level-name": { - "message": "Level name" - }, - "app.servers.properties.field.level-seed": { - "message": "Level seed" - }, - "app.servers.properties.field.level-type": { - "message": "Level type" - }, - "app.servers.properties.field.log-ips": { - "message": "Log IP addresses" - }, - "app.servers.properties.field.management-server-allowed-origins": { - "message": "Management server allowed origins" - }, - "app.servers.properties.field.management-server-enabled": { - "message": "Enable management server" - }, - "app.servers.properties.field.management-server-host": { - "message": "Management server host" - }, - "app.servers.properties.field.management-server-port": { - "message": "Management server port" - }, - "app.servers.properties.field.management-server-secret": { - "message": "Management server secret" - }, - "app.servers.properties.field.management-server-tls-enabled": { - "message": "Enable management server TLS" - }, - "app.servers.properties.field.management-server-tls-keystore": { - "message": "Management server TLS keystore" - }, - "app.servers.properties.field.management-server-tls-keystore-password": { - "message": "Management server TLS keystore password" - }, - "app.servers.properties.field.max-chained-neighbor-updates": { - "message": "Max chained neighbor updates" - }, - "app.servers.properties.field.max-players": { - "message": "Max players" - }, - "app.servers.properties.field.max-tick-time": { - "message": "Max tick time" - }, - "app.servers.properties.field.max-world-size": { - "message": "Max world size" - }, - "app.servers.properties.field.motd": { - "message": "Message of the day (MOTD)" - }, - "app.servers.properties.field.network-compression-threshold": { - "message": "Network compression threshold" - }, - "app.servers.properties.field.online-mode": { - "message": "Online mode" - }, - "app.servers.properties.field.op-permission-level": { - "message": "OP permission level" - }, - "app.servers.properties.field.pause-when-empty-seconds": { - "message": "Pause when empty (seconds)" - }, - "app.servers.properties.field.player-idle-timeout": { - "message": "Player idle timeout" - }, - "app.servers.properties.field.prevent-proxy-connections": { - "message": "Prevent proxy connections" - }, - "app.servers.properties.field.pvp": { - "message": "Player versus player (PvP)" - }, - "app.servers.properties.field.query.port": { - "message": "Query port" - }, - "app.servers.properties.field.rate-limit": { - "message": "Rate limit" - }, - "app.servers.properties.field.rcon.password": { - "message": "RCON password" - }, - "app.servers.properties.field.rcon.port": { - "message": "RCON port" - }, - "app.servers.properties.field.region-file-compression": { - "message": "Region file compression" - }, - "app.servers.properties.field.require-resource-pack": { - "message": "Require resource pack" - }, - "app.servers.properties.field.resource-pack": { - "message": "Resource pack" - }, - "app.servers.properties.field.resource-pack-id": { - "message": "Resource pack ID" - }, - "app.servers.properties.field.resource-pack-prompt": { - "message": "Resource pack prompt" - }, - "app.servers.properties.field.resource-pack-sha1": { - "message": "Resource pack SHA-1" - }, - "app.servers.properties.field.server-ip": { - "message": "Server IP" - }, - "app.servers.properties.field.server-port": { - "message": "Server port" - }, - "app.servers.properties.field.simulation-distance": { - "message": "Simulation distance" - }, - "app.servers.properties.field.spawn-animals": { - "message": "Spawn animals" - }, - "app.servers.properties.field.spawn-monsters": { - "message": "Spawn monsters" - }, - "app.servers.properties.field.spawn-npcs": { - "message": "Spawn NPCs" - }, - "app.servers.properties.field.spawn-protection": { - "message": "Spawn protection" - }, - "app.servers.properties.field.status-heartbeat-interval": { - "message": "Status heartbeat interval" - }, - "app.servers.properties.field.sync-chunk-writes": { - "message": "Sync chunk writes" - }, - "app.servers.properties.field.text-filtering-config": { - "message": "Text filtering config" - }, - "app.servers.properties.field.text-filtering-version": { - "message": "Text filtering version" - }, - "app.servers.properties.field.use-native-transport": { - "message": "Use native transport" - }, - "app.servers.properties.field.view-distance": { - "message": "View distance" - }, - "app.servers.properties.field.white-list": { - "message": "Whitelist" - }, - "app.servers.properties.load-failed": { - "message": "Failed to load the server configuration." - }, - "app.servers.properties.missing": { - "message": "Start the server once to generate this file." - }, - "app.servers.properties.mode.form": { - "message": "Form" - }, - "app.servers.properties.mode.text": { - "message": "Text" - }, - "app.servers.properties.section.advanced": { - "message": "Advanced" - }, - "app.servers.properties.section.content": { - "message": "Content" - }, - "app.servers.properties.section.gameplay": { - "message": "Gameplay" - }, - "app.servers.properties.section.network": { - "message": "Network & Security" - }, - "app.servers.properties.section.others": { - "message": "Other" - }, - "app.servers.properties.section.world": { - "message": "World" - }, - "app.servers.properties.title": { - "message": "Server properties" - }, - "app.servers.refresh": { - "message": "Refresh" - }, - "app.servers.settings.cancel": { - "message": "Cancel" - }, - "app.servers.settings.config": { - "message": "Configuration" - }, - "app.servers.settings.delete": { - "message": "Delete server" - }, - "app.servers.settings.delete-confirm": { - "message": "Delete {name} and all of its files? This cannot be undone." - }, - "app.servers.settings.delete-hint": { - "message": "Permanently remove this server and all of its files." - }, - "app.servers.settings.delete-proceed": { - "message": "Delete" - }, - "app.servers.settings.general": { - "message": "General" - }, - "app.servers.settings.icon": { - "message": "Icon" - }, - "app.servers.settings.java": { - "message": "Java" - }, - "app.servers.settings.jvm-args": { - "message": "JVM arguments" - }, - "app.servers.settings.jvm-args-hint": { - "message": "Space-separated arguments, e.g. -XX:+UseG1GC" - }, - "app.servers.settings.memory": { - "message": "Memory" - }, - "app.servers.settings.name": { - "message": "Server name" - }, - "app.servers.settings.running-hint": { - "message": "Your changes will take effect the next time the server starts." - }, - "app.servers.settings.running-title": { - "message": "Server is running" - }, - "app.servers.settings.save": { - "message": "Save changes" - }, - "app.servers.settings.saved": { - "message": "Server settings saved" - }, - "app.servers.status.crashed": { - "message": "Crashed" - }, - "app.servers.status.created": { - "message": "Not set up" - }, - "app.servers.status.download-failed": { - "message": "Download failed" - }, - "app.servers.status.download-interrupted": { - "message": "Download interrupted" - }, - "app.servers.status.downloading": { - "message": "Downloading" - }, - "app.servers.status.eula-pending": { - "message": "EULA pending" - }, - "app.servers.status.ready": { - "message": "Ready" - }, - "app.servers.status.running": { - "message": "Running" - }, - "app.servers.status.starting": { - "message": "Starting" - }, - "app.servers.type.fabric": { - "message": "Fabric" - }, - "app.servers.type.forge": { - "message": "Forge" - }, - "app.servers.type.paper": { - "message": "Paper" - }, - "app.servers.type.vanilla": { - "message": "Vanilla" - }, - "app.servers.wizard.background-hint": { - "message": "You can close this window — the download continues in the background." - }, - "app.servers.wizard.configure-heading": { - "message": "Adjust the server settings, or finish to edit them later." - }, - "app.servers.wizard.configure-title": { - "message": "Configure" - }, - "app.servers.wizard.done": { - "message": "Server ready" - }, - "app.servers.wizard.download-in-background": { - "message": "Download in background" - }, - "app.servers.wizard.downloading": { - "message": "Downloading server files..." - }, - "app.servers.wizard.eula-wait": { - "message": "Waiting for EULA confirmation" - }, - "app.servers.wizard.failed": { - "message": "Setup failed" - }, - "app.servers.wizard.finish": { - "message": "Finish" - }, - "app.servers.wizard.first-run": { - "message": "Running first start..." - }, - "app.servers.wizard.game-version": { - "message": "Game version" - }, - "app.servers.wizard.install-title": { - "message": "Install" - }, - "app.servers.wizard.java-too-old": { - "message": "Java {selected} cannot run this game version; Java {required} or newer is required." - }, - "app.servers.wizard.loader-version": { - "message": "Loader version" - }, - "app.servers.wizard.log": { - "message": "Output" - }, - "app.servers.wizard.memory-value": { - "message": "{value} MB" - }, - "app.servers.wizard.name": { - "message": "Server name" - }, - "app.servers.wizard.name-placeholder": { - "message": "Survival server" - }, - "app.servers.wizard.next": { - "message": "Next" - }, - "app.servers.wizard.retry": { - "message": "Retry" - }, - "app.servers.wizard.setup-title": { - "message": "Setup" - }, - "app.servers.wizard.show-snapshots": { - "message": "Show snapshots" - }, - "app.servers.wizard.type-heading": { - "message": "Choose a server core" - }, - "app.servers.wizard.type-title": { - "message": "Server type" - }, - "app.settings.about.afdian": { - "message": "Support on Afdian" - }, - "app.settings.about.afdian-description": { - "message": "Help support continued development" - }, "app.settings.about.attribution": { "message": "Starlight Launcher is a modified version of the Axolotl Launcher, which is based on the open-source Modrinth codebase." }, - "app.settings.about.community-support": { - "message": "Project & community" - }, "app.settings.about.contributors": { "message": "Contributors" }, "app.settings.about.contributors-count": { "message": "{count, plural, one {# contributor} other {# contributors}}" }, - "app.settings.about.copied-qq-group": { - "message": "Group number copied" - }, - "app.settings.about.copy-qq-group": { - "message": "Copy group number" + "app.settings.about.copyright": { + "message": "Copyright © Axolotl All Rights Reserved." }, "app.settings.about.description": { - "message": "Your last launcher." + "message": "Starlight Launcher" }, "app.settings.about.developer-tools": { "message": "Developer tools" @@ -6804,7 +6414,7 @@ "message": "Development team" }, "app.settings.about.easteregg.click-hint": { - "message": "Click to open a hidden Mini Game" + "message": "Click to open a hidden Easter egg" }, "app.settings.about.easteregg.contributors-title": { "message": "贡献者彩蛋" @@ -6812,6 +6422,63 @@ "app.settings.about.easteregg.game-title": { "message": "Mini Game" }, + "app.settings.about.game.best": { + "message": "Best: {score}" + }, + "app.settings.about.game.best-label": { + "message": "Best" + }, + "app.settings.about.game.completed": { + "message": "Nether star!" + }, + "app.settings.about.game.exit": { + "message": "Exit game" + }, + "app.settings.about.game.game-over": { + "message": "The stars fell down!" + }, + "app.settings.about.game.highest-level": { + "message": "Highest: level {level}" + }, + "app.settings.about.game.level-label": { + "message": "Highest level" + }, + "app.settings.about.game.new-record": { + "message": "New record!" + }, + "app.settings.about.game.overtime": { + "message": "Tide surge!" + }, + "app.settings.about.game.overtime-detail": { + "message": "The water rushes back — keep merging!" + }, + "app.settings.about.game.overtimes-label": { + "message": "Overtimes" + }, + "app.settings.about.game.restart": { + "message": "Restart" + }, + "app.settings.about.game.score": { + "message": "Score: {score}" + }, + "app.settings.about.game.score-label": { + "message": "Score" + }, + "app.settings.about.game.settle": { + "message": "Settle" + }, + "app.settings.about.game.settle-title": { + "message": "Run settled!" + }, + "app.settings.about.game.tap-to-drop": { + "message": "Click to drop a star" + }, + "app.settings.about.game.tide-hint": { + "message": "The sky is falling — keep merging!" + }, + "app.settings.about.game.title": { + "message": "Starlight merge" + }, "app.settings.about.license-attribution": { "message": "License & attribution" }, @@ -6830,30 +6497,9 @@ "app.settings.about.project-license": { "message": "Project license (GPL-3.0)" }, - "app.settings.about.project-website": { - "message": "Project website" - }, - "app.settings.about.qq-channel": { - "message": "QQ channel" - }, - "app.settings.about.qq-group": { - "message": "Player QQ group" - }, "app.settings.about.replay-onboarding": { "message": "Replay tour" }, - "app.settings.about.report-issue": { - "message": "Issues & feedback" - }, - "app.settings.about.repository": { - "message": "Source code" - }, - "app.settings.about.survey": { - "message": "Community survey" - }, - "app.settings.about.survey-description": { - "message": "Help us improve Starlight Launcher" - }, "app.settings.about.test-error": { "message": "Trigger test error" }, @@ -6971,21 +6617,6 @@ "app.settings.developer-mode-enabled": { "message": "Developer mode enabled." }, - "app.settings.developer.announcement-preview": { - "message": "Announcement preview" - }, - "app.settings.developer.announcement-preview-description": { - "message": "Preview the real announcement components using local sample content, without fetching or marking real announcements as read." - }, - "app.settings.developer.preview-announcement-modal": { - "message": "Preview startup modal" - }, - "app.settings.developer.preview-announcement-popup": { - "message": "Preview Popup notification" - }, - "app.settings.developer.preview-with-action": { - "message": "Include an optional external-link button" - }, "app.settings.downloading": { "message": "Downloading v{version}" }, @@ -7445,42 +7076,9 @@ "app.settings.storage.world": { "message": "World" }, - "app.settings.tabs.about": { - "message": "About" - }, - "app.settings.tabs.ai": { - "message": "AI" - }, - "app.settings.tabs.content-downloads": { - "message": "Content & downloads" - }, - "app.settings.tabs.home-navigation": { - "message": "Home & navigation" - }, - "app.settings.tabs.interface": { - "message": "Interface & appearance" - }, - "app.settings.tabs.java-performance": { - "message": "Java & performance" - }, - "app.settings.tabs.language-translation": { - "message": "Language & translation" - }, - "app.settings.tabs.launch-defaults": { - "message": "Launch & instance defaults" - }, - "app.settings.tabs.network-multiplayer": { - "message": "Network & multiplayer" - }, "app.settings.tabs.storage": { "message": "Storage" }, - "app.settings.tabs.storage-backups": { - "message": "Storage & backups" - }, - "app.settings.tabs.updates": { - "message": "Updates" - }, "app.settings.updates.announcements.description": { "message": "See what changed in this version and browse previous releases." }, @@ -7646,9 +7244,6 @@ "app.settings.updates.preferences.title": { "message": "Update behavior" }, - "app.settings.updates.preview-announcement": { - "message": "Preview update announcement" - }, "app.settings.updates.security": { "message": "Updates are installed only when their cryptographic signature is valid." }, @@ -7799,6 +7394,18 @@ "app.skins.section.tiny-takeover": { "message": "Tiny Takeover" }, + "app.skins.sign-in.axolotl-alt": { + "message": "Starlight Launcher" + }, + "app.skins.sign-in.button": { + "message": "Sign In" + }, + "app.skins.sign-in.description": { + "message": "Please sign into your Minecraft account to use the skin management features of Starlight Launcher." + }, + "app.skins.sign-in.title": { + "message": "Please sign in" + }, "app.skins.skin-site-account.description": { "message": "Select a skin below and apply it directly to {player}." }, @@ -7820,18 +7427,6 @@ "app.skins.skin-site-account.title": { "message": "StarLight skin site player" }, - "app.skins.sign-in.axolotl-alt": { - "message": "Starlight Launcher" - }, - "app.skins.sign-in.button": { - "message": "Sign In" - }, - "app.skins.sign-in.description": { - "message": "Please sign into your Minecraft account to use the skin management features of Starlight Launcher." - }, - "app.skins.sign-in.title": { - "message": "Please sign in" - }, "app.skins.tabs.default": { "message": "Official skins" }, @@ -7853,20 +7448,8 @@ "app.splash.updating-app-directory": { "message": "Updating app directory..." }, - "app.survey-promotion.defer-hint": { - "message": "If you close this dialog, you can still find the survey later in Settings > About." - }, - "app.survey-promotion.fill-survey": { - "message": "Fill out the survey" - }, - "app.survey-promotion.intro": { - "message": "To help us improve Starlight Launcher, we have prepared a short survey and would love to hear your feedback." - }, - "app.survey-promotion.reward": { - "message": "Lucky participants will be drawn to win a genuine Minecraft account (redeemable for cash)." - }, - "app.survey-promotion.title": { - "message": "Community survey" + "app.starlight-skin.frame-title": { + "message": "StarLight Skin Site" }, "app.symlink-capability.unsupported": { "message": "This system does not support creating symbolic links." @@ -8051,9 +7634,6 @@ "app.update-announcement.category.security": { "message": "Security" }, - "app.update-announcement.modal.header": { - "message": "What's new" - }, "app.update-announcement.notes": { "message": "Notes" }, @@ -10151,12 +9731,6 @@ "minecraft-account.add-microsoft-account": { "message": "Add your own Minecraft account" }, - "minecraft-account.add-offline-account": { - "message": "Add offline account" - }, - "minecraft-account.sign-in-starlight": { - "message": "Sign in to StarLight Skin Site" - }, "minecraft-account.copy-uuid": { "message": "Copy UUID" }, @@ -10232,56 +9806,35 @@ "minecraft-account.select-account": { "message": "Select account" }, - "minecraft-account.sign-in": { - "message": "Sign in to Minecraft" + "minecraft-account.sign-in-starlight": { + "message": "Sign in to StarLight Skin Site" + }, + "minecraft-account.skin-site.checking": { + "message": "Checking skin site session…" + }, + "minecraft-account.skin-site.players": { + "message": "Skin site players ({count})" + }, + "minecraft-account.skin-site.players.empty": { + "message": "No player profiles are attached to this skin site account." + }, + "minecraft-account.skin-site.players.error": { + "message": "Could not refresh the player list. Previously loaded players are kept." + }, + "minecraft-account.skin-site.players.retry": { + "message": "Retry" + }, + "minecraft-account.skin-site.signed-in": { + "message": "Signed in to StarLight Skin Site" + }, + "minecraft-account.skin-site.sync-error": { + "message": "Could not verify the skin site session. Retrying automatically." }, "minecraft-account.third-party-account": { - "message": "Third-party Minecraft account" + "message": "StarLight skin account" }, "minecraft-account.third-party-badge": { - "message": "Third-party" - }, - "minecraft-account.third-party-modal.account": { - "message": "Account or email" - }, - "minecraft-account.third-party-modal.account-placeholder": { - "message": "Enter your account or email" - }, - "minecraft-account.third-party-modal.api-root": { - "message": "Yggdrasil API address" - }, - "minecraft-account.third-party-modal.api-root-placeholder": { - "message": "https://example.com/api/yggdrasil" - }, - "minecraft-account.third-party-modal.description": { - "message": "Use LittleSkin or another compatible Yggdrasil authentication service." - }, - "minecraft-account.third-party-modal.littleskin": { - "message": "Use LittleSkin" - }, - "minecraft-account.third-party-modal.password": { - "message": "Password" - }, - "minecraft-account.third-party-modal.remember-password": { - "message": "Save this login on this device" - }, - "minecraft-account.third-party-modal.remove-saved-login": { - "message": "Remove saved login" - }, - "minecraft-account.third-party-modal.saved-logins": { - "message": "Saved logins" - }, - "minecraft-account.third-party-modal.sign-in": { - "message": "Sign in" - }, - "minecraft-account.third-party-modal.title": { - "message": "Sign in with a third-party service" - }, - "minecraft-account.third-party-profile.description": { - "message": "Choose the Minecraft profile to use with this account." - }, - "minecraft-account.third-party-profile.title": { - "message": "Select a profile" + "message": "StarLight skin" }, "minecraft-login.browser": { "message": "Use browser login" @@ -10349,9 +9902,6 @@ "search.filter.locked.server-loader.title": { "message": "Loader is provided by the server" }, - "settings.feature-flags.title": { - "message": "Feature flags" - }, "unknown-pack-warning-modal.body": { "message": "A file is only reviewed if it’s uploaded to Modrinth, regardless of its file format (including .mrpack)." }, diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index 5bc164e..9cfa1ee 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -10458,5 +10458,227 @@ }, "app.settings.about.easteregg.game-title": { "message": "彩蛋小游戏" + }, + "app.home.launch-progress.empty": { + "message": "启动游戏后,这里会显示进度。" + }, + "app.home.launch-progress.title": { + "message": "启动进度" + }, + "app.screenshots.copied": { + "message": "已复制" + }, + "app.screenshots.copy": { + "message": "复制图像" + }, + "app.screenshots.delete-description": { + "message": "永久删除 {name}?此操作无法撤销。" + }, + "app.screenshots.delete-success": { + "message": "截图已删除" + }, + "app.screenshots.delete-title": { + "message": "删除截图" + }, + "app.screenshots.deselect": { + "message": "取消选择 {name}" + }, + "app.screenshots.edit": { + "message": "编辑截图" + }, + "app.screenshots.empty-description": { + "message": "你在游戏中拍摄的截图会显示在这里。" + }, + "app.screenshots.empty-heading": { + "message": "还没有截图" + }, + "app.screenshots.error-heading": { + "message": "加载截图失败" + }, + "app.screenshots.go-to-instance": { + "message": "前往实例" + }, + "app.screenshots.group-by": { + "message": "分组方式" + }, + "app.screenshots.group.all-screenshots": { + "message": "全部截图" + }, + "app.screenshots.group.collapse": { + "message": "折叠分组" + }, + "app.screenshots.group.custom": { + "message": "自定义分组" + }, + "app.screenshots.group.date": { + "message": "日期" + }, + "app.screenshots.group.delete": { + "message": "删除分组" + }, + "app.screenshots.group.delete-description": { + "message": "此分组中的截图将变为未分组。" + }, + "app.screenshots.group.edit": { + "message": "编辑分组名称" + }, + "app.screenshots.group.empty": { + "message": "拖放以添加截图。" + }, + "app.screenshots.group.expand": { + "message": "展开分组" + }, + "app.screenshots.group.instance": { + "message": "实例" + }, + "app.screenshots.group.new": { + "message": "新建分组" + }, + "app.screenshots.group.none": { + "message": "不分组" + }, + "app.screenshots.group.this-month": { + "message": "本月" + }, + "app.screenshots.group.this-week": { + "message": "本周" + }, + "app.screenshots.group.today": { + "message": "今天" + }, + "app.screenshots.group.ungrouped": { + "message": "未分组" + }, + "app.screenshots.group.yesterday": { + "message": "昨天" + }, + "app.screenshots.heading": { + "message": "截图" + }, + "app.screenshots.more-actions": { + "message": "更多操作" + }, + "app.screenshots.no-results-description": { + "message": "请尝试其他搜索或筛选条件。" + }, + "app.screenshots.no-results-heading": { + "message": "没有匹配的截图" + }, + "app.screenshots.preview.instance-and-date": { + "message": "{instance} · {date}" + }, + "app.screenshots.search": { + "message": "搜索" + }, + "app.screenshots.select": { + "message": "选择 {name}" + }, + "app.screenshots.selection.aria-label": { + "message": "已选中的截图" + }, + "app.screenshots.selection.delete-description": { + "message": "删除 {count, plural, one {# 张截图} other {# 张截图}}?此操作无法撤销。" + }, + "app.screenshots.selection.delete-success": { + "message": "{count, plural, one {已删除 # 张截图} other {已删除 # 张截图}}" + }, + "app.screenshots.selection.delete-title": { + "message": "删除选中的截图" + }, + "app.screenshots.selection.export-zip": { + "message": "导出 ZIP" + }, + "app.screenshots.selection.global-export-filename": { + "message": "Modrinth screenshots.zip" + }, + "app.screenshots.selection.instance-export-filename": { + "message": "{instance} screenshots.zip" + }, + "app.screenshots.selection.remove-from-group": { + "message": "从分组中移除" + }, + "app.screenshots.selection.selected-count": { + "message": "已选中 {count} 张" + }, + "app.screenshots.selection.zip-archive": { + "message": "ZIP 压缩包" + }, + "app.screenshots.show-in-folder": { + "message": "在文件夹中显示" + }, + "app.screenshots.sort-by": { + "message": "排序方式" + }, + "app.screenshots.sort.name": { + "message": "名称" + }, + "app.screenshots.sort.newest": { + "message": "最新" + }, + "app.screenshots.sort.oldest": { + "message": "最旧" + }, + "app.settings.about.copyright": { + "message": "版权所有 © StarLight 保留所有权利。" + }, + "app.settings.about.game.best": { + "message": "最佳:{score}" + }, + "app.settings.about.game.best-label": { + "message": "最佳" + }, + "app.settings.about.game.completed": { + "message": "下界之星!" + }, + "app.settings.about.game.exit": { + "message": "退出游戏" + }, + "app.settings.about.game.game-over": { + "message": "群星坠落!" + }, + "app.settings.about.game.highest-level": { + "message": "最高:等级 {level}" + }, + "app.settings.about.game.level-label": { + "message": "最高等级" + }, + "app.settings.about.game.new-record": { + "message": "新纪录!" + }, + "app.settings.about.game.overtime": { + "message": "涨潮!" + }, + "app.settings.about.game.overtime-detail": { + "message": "潮水涌回——继续合成!" + }, + "app.settings.about.game.overtimes-label": { + "message": "加时次数" + }, + "app.settings.about.game.restart": { + "message": "重新开始" + }, + "app.settings.about.game.score": { + "message": "得分:{score}" + }, + "app.settings.about.game.score-label": { + "message": "得分" + }, + "app.settings.about.game.settle": { + "message": "稳定" + }, + "app.settings.about.game.settle-title": { + "message": "本局已稳定!" + }, + "app.settings.about.game.tap-to-drop": { + "message": "点击投放一颗星" + }, + "app.settings.about.game.tide-hint": { + "message": "天空正在坠落——继续合成!" + }, + "app.settings.about.game.title": { + "message": "Starlight 合成" + }, + "app.starlight-skin.frame-title": { + "message": "StarLight 皮肤站" } } diff --git a/apps/app-frontend/src/pages/Screenshots.vue b/apps/app-frontend/src/pages/Screenshots.vue new file mode 100644 index 0000000..aa2d77b --- /dev/null +++ b/apps/app-frontend/src/pages/Screenshots.vue @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/apps/app-frontend/src/pages/instance/query-options.ts b/apps/app-frontend/src/pages/instance/query-options.ts new file mode 100644 index 0000000..d721865 --- /dev/null +++ b/apps/app-frontend/src/pages/instance/query-options.ts @@ -0,0 +1,57 @@ +import { queryOptions } from '@tanstack/vue-query' + +import { + list, + list_all_screenshots, + list_screenshot_groups, + list_screenshots, + list_synced_screenshots, +} from '@/helpers/instance' + +export const instanceKeys = { + all: ['instances'] as const, + list: () => ['instances', 'list'] as const, +} + +export function instanceListQueryOptions() { + return queryOptions({ + queryKey: instanceKeys.list(), + queryFn: list, + }) +} + +export const screenshotKeys = { + all: ['screenshots'] as const, + global: () => ['screenshots', 'global'] as const, + synced: () => ['screenshots', 'synced'] as const, + instance: (instanceId: string) => ['screenshots', 'instance', instanceId] as const, + groups: () => ['screenshots', 'groups'] as const, +} + +export function instanceScreenshotsQueryOptions(instanceId: string) { + return queryOptions({ + queryKey: screenshotKeys.instance(instanceId), + queryFn: () => list_screenshots(instanceId), + }) +} + +export function allScreenshotsQueryOptions() { + return queryOptions({ + queryKey: screenshotKeys.global(), + queryFn: list_all_screenshots, + }) +} + +export function syncedScreenshotsQueryOptions() { + return queryOptions({ + queryKey: screenshotKeys.synced(), + queryFn: list_synced_screenshots, + }) +} + +export function screenshotGroupsQueryOptions() { + return queryOptions({ + queryKey: screenshotKeys.groups(), + queryFn: list_screenshot_groups, + }) +} \ No newline at end of file diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index 72fb0df..b13a6b9 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -101,6 +101,14 @@ export default new createRouter({ breadcrumb: [{ name: 'Starlight skin' }], }, }, + { + path: '/screenshots', + name: 'Screenshots', + component: () => import('@/pages/Screenshots.vue'), + meta: { + breadcrumb: [{ name: 'Screenshots' }], + }, + }, { path: '/multiplayer', name: 'Multiplayer', diff --git a/apps/app/src/api/files.rs b/apps/app/src/api/files.rs index e1fac14..68ac99a 100644 --- a/apps/app/src/api/files.rs +++ b/apps/app/src/api/files.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; use std::sync::Mutex; use tauri::{Emitter, Runtime}; use tauri_plugin_dialog::DialogExt; -use theseus::instance::get_full_path; +use theseus::instance::{get_full_path, list}; const STUDIO_FILES_CHANGED_EVENT: &str = "studio-files-changed"; diff --git a/apps/app/src/api/instance.rs b/apps/app/src/api/instance/mod.rs similarity index 88% rename from apps/app/src/api/instance.rs rename to apps/app/src/api/instance/mod.rs index 6f48dad..00c1d08 100644 --- a/apps/app/src/api/instance.rs +++ b/apps/app/src/api/instance/mod.rs @@ -6,6 +6,9 @@ use path_util::SafeRelativeUtf8UnixPathBuf; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; +use tauri::Manager; +use tauri_plugin_fs::FsExt; +use tauri_plugin_opener::OpenerExt; use theseus::data::{ AppliedContentSetPatch, ContentItem, Dependency, EditInstance as CoreEditInstance, InstanceInstallCandidate, @@ -103,6 +106,20 @@ pub fn init() -> tauri::plugin::TauriPlugin { instance_edit_icon, instance_export_mrpack, instance_get_pack_export_candidates, + instance_list_screenshots, + instance_list_all_screenshots, + instance_list_synced_screenshots, + instance_save_edited_screenshot, + instance_list_screenshot_groups, + instance_create_screenshot_group, + instance_rename_screenshot_group, + instance_delete_screenshot_group, + instance_set_screenshot_group_memberships, + instance_import_screenshot_groups, + instance_delete_screenshots, + instance_export_screenshots, + instance_move_screenshots, + instance_open_screenshot, ]) .build() } @@ -1490,3 +1507,171 @@ pub async fn instance_edit_icon( theseus::instance::edit_icon(instance_id, icon_path).await?; Ok(()) } + +#[derive(Serialize, Debug, Clone)] +pub struct InstanceScreenshot { + pub id: String, + pub instance_id: String, + pub instance_name: String, + pub file_name: String, + pub created_at: chrono::DateTime, + pub modified_at: i64, + pub group_id: Option, + pub path: PathBuf, + pub url: String, +} + +fn serialize_screenshot( + app: &tauri::AppHandle, + s: theseus::instance::InstanceScreenshot, +) -> Result { + if let Some(parent) = s.path.parent() { + crate::api::files::ensure_browsable(app, parent); + } + let mut url = super::utils::tauri_convert_file_src(&s.path)?; + app.asset_protocol_scope().allow_file(&s.path)?; + app.fs_scope().allow_file(&s.path)?; + url.query_pairs_mut() + .append_pair("revision", &s.modified_at.to_string()); + Ok(InstanceScreenshot { + id: s.id, + instance_id: s.instance_id, + instance_name: s.instance_name, + file_name: s.file_name, + created_at: s.created_at, + modified_at: s.modified_at, + group_id: s.group_id, + path: s.path, + url: url.to_string(), + }) +} + +fn serialize_screenshots( + app: &tauri::AppHandle, + items: Vec, +) -> Result> { + items + .into_iter() + .map(|s| serialize_screenshot(app, s)) + .collect() +} + +#[tauri::command] +pub async fn instance_list_screenshots( + app: tauri::AppHandle, + instance_id: &str, +) -> Result> { + serialize_screenshots( + &app, + theseus::instance::list_screenshots(instance_id).await?, + ) +} + +#[tauri::command] +pub async fn instance_list_all_screenshots( + app: tauri::AppHandle, +) -> Result> { + serialize_screenshots( + &app, + theseus::instance::list_all_screenshots().await?, + ) +} + +#[tauri::command] +pub async fn instance_list_synced_screenshots( + app: tauri::AppHandle, +) -> Result> { + serialize_screenshots( + &app, + theseus::instance::list_synced_screenshots().await?, + ) +} + +#[tauri::command] +pub async fn instance_save_edited_screenshot( + app: tauri::AppHandle, + key: theseus::instance::ScreenshotKey, + bytes: Vec, + mode: theseus::instance::ScreenshotEditSaveMode, +) -> Result { + serialize_screenshot( + &app, + theseus::instance::save_edited_screenshot(key, bytes, mode).await?, + ) +} + +#[tauri::command] +pub async fn instance_list_screenshot_groups() +-> Result> { + Ok(theseus::instance::list_screenshot_groups().await?) +} + +#[tauri::command] +pub async fn instance_create_screenshot_group( + name: String, + ids: Vec, +) -> Result { + Ok(theseus::instance::create_screenshot_group(name, ids).await?) +} + +#[tauri::command] +pub async fn instance_rename_screenshot_group( + id: String, + new_name: String, +) -> Result { + Ok(theseus::instance::rename_screenshot_group(id, new_name).await?) +} + +#[tauri::command] +pub async fn instance_delete_screenshot_group(id: String) -> Result<()> { + Ok(theseus::instance::delete_screenshot_group(id).await?) +} + +#[tauri::command] +pub async fn instance_set_screenshot_group_memberships( + updates: Vec, +) -> Result<()> { + Ok(theseus::instance::set_screenshot_group_memberships(updates).await?) +} + +#[tauri::command] +pub async fn instance_import_screenshot_groups( + groups: Vec, +) -> Result<()> { + Ok(theseus::instance::import_screenshot_groups(groups).await?) +} + +#[tauri::command] +pub async fn instance_delete_screenshots( + keys: Vec, +) -> Result<()> { + Ok(theseus::instance::delete_screenshots(&keys).await?) +} + +#[tauri::command] +pub async fn instance_export_screenshots( + keys: Vec, + path: PathBuf, +) -> Result<()> { + Ok(theseus::instance::export_screenshots(&keys, path).await?) +} + +#[tauri::command] +pub async fn instance_move_screenshots( + keys: Vec, + target: &str, +) -> Result> { + Ok(theseus::instance::move_screenshots(&keys, target).await?) +} + +#[tauri::command] +pub async fn instance_open_screenshot( + app: tauri::AppHandle, + key: theseus::instance::ScreenshotKey, +) -> Result<()> { + let path = theseus::instance::get_screenshot_path(&key).await?; + app.opener() + .reveal_item_in_dir(path) + .map_err(|e| std::io::Error::other(e.to_string()))?; + Ok(()) +} diff --git a/packages/app-lib/.sqlx/query-0a9977b44045c6d0e974770deabc5cb51b41bf5fa94b8bd4b9676f360bf2de7e.json b/packages/app-lib/.sqlx/query-0a9977b44045c6d0e974770deabc5cb51b41bf5fa94b8bd4b9676f360bf2de7e.json new file mode 100644 index 0000000..70ed01f --- /dev/null +++ b/packages/app-lib/.sqlx/query-0a9977b44045c6d0e974770deabc5cb51b41bf5fa94b8bd4b9676f360bf2de7e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM screenshot_groups WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "0a9977b44045c6d0e974770deabc5cb51b41bf5fa94b8bd4b9676f360bf2de7e" +} diff --git a/packages/app-lib/.sqlx/query-247aa979f6f6fd116b832cc5a513bd43f0aaa4b554c2a3cac6fb1079e539bf27.json b/packages/app-lib/.sqlx/query-247aa979f6f6fd116b832cc5a513bd43f0aaa4b554c2a3cac6fb1079e539bf27.json new file mode 100644 index 0000000..3bafddf --- /dev/null +++ b/packages/app-lib/.sqlx/query-247aa979f6f6fd116b832cc5a513bd43f0aaa4b554c2a3cac6fb1079e539bf27.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(SELECT 1 FROM screenshot_groups WHERE id = ?) AS \"exists!: bool\"", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "247aa979f6f6fd116b832cc5a513bd43f0aaa4b554c2a3cac6fb1079e539bf27" +} diff --git a/packages/app-lib/.sqlx/query-2ede94b5eab73255500fbd4b6f7eb85abf6c98d4bdfcd93a9a6054e9d5235099.json b/packages/app-lib/.sqlx/query-2ede94b5eab73255500fbd4b6f7eb85abf6c98d4bdfcd93a9a6054e9d5235099.json new file mode 100644 index 0000000..2ff7afe --- /dev/null +++ b/packages/app-lib/.sqlx/query-2ede94b5eab73255500fbd4b6f7eb85abf6c98d4bdfcd93a9a6054e9d5235099.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM screenshots WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "2ede94b5eab73255500fbd4b6f7eb85abf6c98d4bdfcd93a9a6054e9d5235099" +} diff --git a/packages/app-lib/.sqlx/query-48c53bc771b0c2af2ccef50cd121ab4de4ed309b54239bacb54b0d173c4e5acb.json b/packages/app-lib/.sqlx/query-48c53bc771b0c2af2ccef50cd121ab4de4ed309b54239bacb54b0d173c4e5acb.json new file mode 100644 index 0000000..c7cb7db --- /dev/null +++ b/packages/app-lib/.sqlx/query-48c53bc771b0c2af2ccef50cd121ab4de4ed309b54239bacb54b0d173c4e5acb.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE screenshots\n\t\tSET instance_id = ?, file_name = ?\n\t\tWHERE instance_id = ? AND file_name = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "48c53bc771b0c2af2ccef50cd121ab4de4ed309b54239bacb54b0d173c4e5acb" +} diff --git a/packages/app-lib/.sqlx/query-4e75c5ee789d5a781967fc0845f12e9c53683afbe05ab5f441c5f5fb1bce0472.json b/packages/app-lib/.sqlx/query-4e75c5ee789d5a781967fc0845f12e9c53683afbe05ab5f441c5f5fb1bce0472.json new file mode 100644 index 0000000..61b5461 --- /dev/null +++ b/packages/app-lib/.sqlx/query-4e75c5ee789d5a781967fc0845f12e9c53683afbe05ab5f441c5f5fb1bce0472.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT id, name\n\t\tFROM screenshot_groups\n\t\tORDER BY display_order, name, id\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false + ] + }, + "hash": "4e75c5ee789d5a781967fc0845f12e9c53683afbe05ab5f441c5f5fb1bce0472" +} diff --git a/packages/app-lib/.sqlx/query-5616069c5dd7233ef7cf867ac8367fdc4ff254e929cafce9fd3ff38449e88b89.json b/packages/app-lib/.sqlx/query-5616069c5dd7233ef7cf867ac8367fdc4ff254e929cafce9fd3ff38449e88b89.json new file mode 100644 index 0000000..bb28873 --- /dev/null +++ b/packages/app-lib/.sqlx/query-5616069c5dd7233ef7cf867ac8367fdc4ff254e929cafce9fd3ff38449e88b89.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO screenshot_group_memberships (screenshot_id, group_id)\n\t\tSELECT ?, group_id\n\t\tFROM screenshot_group_memberships\n\t\tWHERE screenshot_id = ?\n\t\tON CONFLICT (screenshot_id) DO UPDATE SET group_id = excluded.group_id\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "5616069c5dd7233ef7cf867ac8367fdc4ff254e929cafce9fd3ff38449e88b89" +} diff --git a/packages/app-lib/.sqlx/query-607eac5be3e2a29bb81cce3d0ff14690b61e08f5cb3316c4349c78dc0e4d81f7.json b/packages/app-lib/.sqlx/query-607eac5be3e2a29bb81cce3d0ff14690b61e08f5cb3316c4349c78dc0e4d81f7.json new file mode 100644 index 0000000..182b209 --- /dev/null +++ b/packages/app-lib/.sqlx/query-607eac5be3e2a29bb81cce3d0ff14690b61e08f5cb3316c4349c78dc0e4d81f7.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE screenshot_groups SET name = ? WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "607eac5be3e2a29bb81cce3d0ff14690b61e08f5cb3316c4349c78dc0e4d81f7" +} diff --git a/packages/app-lib/.sqlx/query-6c58c5daf14ed5198c14799f4a2dd02ebf24d7d61bb74b22abdf46e53b4efdfa.json b/packages/app-lib/.sqlx/query-6c58c5daf14ed5198c14799f4a2dd02ebf24d7d61bb74b22abdf46e53b4efdfa.json new file mode 100644 index 0000000..c53d1b9 --- /dev/null +++ b/packages/app-lib/.sqlx/query-6c58c5daf14ed5198c14799f4a2dd02ebf24d7d61bb74b22abdf46e53b4efdfa.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM screenshot_group_memberships WHERE screenshot_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "6c58c5daf14ed5198c14799f4a2dd02ebf24d7d61bb74b22abdf46e53b4efdfa" +} diff --git a/packages/app-lib/.sqlx/query-6f17b1880668e6efb235eaa3808bd64a1c6bb5fc5f3c0082500afdcfa7340efa.json b/packages/app-lib/.sqlx/query-6f17b1880668e6efb235eaa3808bd64a1c6bb5fc5f3c0082500afdcfa7340efa.json new file mode 100644 index 0000000..c59ac31 --- /dev/null +++ b/packages/app-lib/.sqlx/query-6f17b1880668e6efb235eaa3808bd64a1c6bb5fc5f3c0082500afdcfa7340efa.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tscreenshots.id,\n\t\t\tscreenshots.instance_id,\n\t\t\tscreenshots.file_name,\n\t\t\tscreenshots.content_hash,\n\t\t\tscreenshots.file_size,\n\t\t\tscreenshots.modified_at,\n\t\t\tscreenshots.created_at,\n\t\t\tmemberships.group_id AS \"group_id?\"\n\t\tFROM screenshots\n\t\tLEFT JOIN screenshot_group_memberships memberships\n\t\t\tON memberships.screenshot_id = screenshots.id\n\t\tWHERE screenshots.instance_id = ? AND screenshots.file_name = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "instance_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "file_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "content_hash", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "file_size", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "modified_at", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "created_at", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "group_id?", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "6f17b1880668e6efb235eaa3808bd64a1c6bb5fc5f3c0082500afdcfa7340efa" +} diff --git a/packages/app-lib/.sqlx/query-743f351ba5f612262b98b0a12dc2a75e1c92cece3b2715f9a3392c058e9347a8.json b/packages/app-lib/.sqlx/query-743f351ba5f612262b98b0a12dc2a75e1c92cece3b2715f9a3392c058e9347a8.json new file mode 100644 index 0000000..a4ed6ce --- /dev/null +++ b/packages/app-lib/.sqlx/query-743f351ba5f612262b98b0a12dc2a75e1c92cece3b2715f9a3392c058e9347a8.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT DISTINCT screenshots.instance_id\n\t\tFROM screenshot_group_memberships memberships\n\t\tINNER JOIN screenshots ON screenshots.id = memberships.screenshot_id\n\t\tWHERE memberships.group_id = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "instance_id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "743f351ba5f612262b98b0a12dc2a75e1c92cece3b2715f9a3392c058e9347a8" +} diff --git a/packages/app-lib/.sqlx/query-7567783dcac147378086ed2bd7eab5a059f3d1b868a5c45dbb9de86dcd177085.json b/packages/app-lib/.sqlx/query-7567783dcac147378086ed2bd7eab5a059f3d1b868a5c45dbb9de86dcd177085.json new file mode 100644 index 0000000..4232998 --- /dev/null +++ b/packages/app-lib/.sqlx/query-7567783dcac147378086ed2bd7eab5a059f3d1b868a5c45dbb9de86dcd177085.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\t\tINSERT INTO screenshot_group_memberships (screenshot_id, group_id)\n\t\t\tVALUES (?, ?)\n\t\t\tON CONFLICT(screenshot_id) DO UPDATE SET group_id = excluded.group_id\n\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "7567783dcac147378086ed2bd7eab5a059f3d1b868a5c45dbb9de86dcd177085" +} diff --git a/packages/app-lib/.sqlx/query-778ef883e1e68bb549dd1b82761f674df6125b03954683a70431257cc8c923e9.json b/packages/app-lib/.sqlx/query-778ef883e1e68bb549dd1b82761f674df6125b03954683a70431257cc8c923e9.json new file mode 100644 index 0000000..c828e19 --- /dev/null +++ b/packages/app-lib/.sqlx/query-778ef883e1e68bb549dd1b82761f674df6125b03954683a70431257cc8c923e9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE screenshot_groups SET display_order = display_order + 1", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "778ef883e1e68bb549dd1b82761f674df6125b03954683a70431257cc8c923e9" +} diff --git a/packages/app-lib/.sqlx/query-7ea54f8960b92b275b5f9d4190c44bedb489b84d81eba304af25523c8d38dcd2.json b/packages/app-lib/.sqlx/query-7ea54f8960b92b275b5f9d4190c44bedb489b84d81eba304af25523c8d38dcd2.json new file mode 100644 index 0000000..e97640f --- /dev/null +++ b/packages/app-lib/.sqlx/query-7ea54f8960b92b275b5f9d4190c44bedb489b84d81eba304af25523c8d38dcd2.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO screenshots (\n\t\t\tid,\n\t\t\tinstance_id,\n\t\t\tfile_name,\n\t\t\tcontent_hash,\n\t\t\tfile_size,\n\t\t\tmodified_at,\n\t\t\tcreated_at\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?)\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "7ea54f8960b92b275b5f9d4190c44bedb489b84d81eba304af25523c8d38dcd2" +} diff --git a/packages/app-lib/.sqlx/query-8aae262e7a2e6375164af3b296465d017d21a6c6476d0f2ae09c076d75f0dda3.json b/packages/app-lib/.sqlx/query-8aae262e7a2e6375164af3b296465d017d21a6c6476d0f2ae09c076d75f0dda3.json new file mode 100644 index 0000000..792ed44 --- /dev/null +++ b/packages/app-lib/.sqlx/query-8aae262e7a2e6375164af3b296465d017d21a6c6476d0f2ae09c076d75f0dda3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE screenshots\n\t\tSET\n\t\t\tinstance_id = ?,\n\t\t\tfile_name = ?,\n\t\t\tcontent_hash = ?,\n\t\t\tfile_size = ?,\n\t\t\tmodified_at = ?,\n\t\t\tcreated_at = ?\n\t\tWHERE id = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "8aae262e7a2e6375164af3b296465d017d21a6c6476d0f2ae09c076d75f0dda3" +} diff --git a/packages/app-lib/.sqlx/query-98fbd284e94d1c509ded25c1c17004b3283447e78920e762bcac2765fca6f3eb.json b/packages/app-lib/.sqlx/query-98fbd284e94d1c509ded25c1c17004b3283447e78920e762bcac2765fca6f3eb.json new file mode 100644 index 0000000..78605fb --- /dev/null +++ b/packages/app-lib/.sqlx/query-98fbd284e94d1c509ded25c1c17004b3283447e78920e762bcac2765fca6f3eb.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT instance_id FROM screenshots WHERE id = ?", + "describe": { + "columns": [ + { + "name": "instance_id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "98fbd284e94d1c509ded25c1c17004b3283447e78920e762bcac2765fca6f3eb" +} diff --git a/packages/app-lib/.sqlx/query-a0705b02be354c37f7a1294eca7eba891c23cd79886a97d91bddb7019baab3ce.json b/packages/app-lib/.sqlx/query-a0705b02be354c37f7a1294eca7eba891c23cd79886a97d91bddb7019baab3ce.json new file mode 100644 index 0000000..0cb2b6b --- /dev/null +++ b/packages/app-lib/.sqlx/query-a0705b02be354c37f7a1294eca7eba891c23cd79886a97d91bddb7019baab3ce.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM screenshots WHERE instance_id = ? AND file_name = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "a0705b02be354c37f7a1294eca7eba891c23cd79886a97d91bddb7019baab3ce" +} diff --git a/packages/app-lib/.sqlx/query-a83e168791306f30ee6f03a274398455c6db66d485824f27ab198c3f00de1770.json b/packages/app-lib/.sqlx/query-a83e168791306f30ee6f03a274398455c6db66d485824f27ab198c3f00de1770.json new file mode 100644 index 0000000..42a1914 --- /dev/null +++ b/packages/app-lib/.sqlx/query-a83e168791306f30ee6f03a274398455c6db66d485824f27ab198c3f00de1770.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tscreenshots.id,\n\t\t\tscreenshots.instance_id,\n\t\t\tscreenshots.file_name,\n\t\t\tscreenshots.content_hash,\n\t\t\tscreenshots.file_size,\n\t\t\tscreenshots.modified_at,\n\t\t\tscreenshots.created_at,\n\t\t\tmemberships.group_id AS \"group_id?\"\n\t\tFROM screenshots\n\t\tLEFT JOIN screenshot_group_memberships memberships\n\t\t\tON memberships.screenshot_id = screenshots.id\n\t\tWHERE screenshots.instance_id = ?\n\t\tORDER BY screenshots.file_name, screenshots.id\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "instance_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "file_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "content_hash", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "file_size", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "modified_at", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "created_at", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "group_id?", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "a83e168791306f30ee6f03a274398455c6db66d485824f27ab198c3f00de1770" +} diff --git a/packages/app-lib/.sqlx/query-c3448ac48772d99b4cfd296996a93230488492804bb4f1d5ccec4a90454438f7.json b/packages/app-lib/.sqlx/query-c3448ac48772d99b4cfd296996a93230488492804bb4f1d5ccec4a90454438f7.json new file mode 100644 index 0000000..46844e3 --- /dev/null +++ b/packages/app-lib/.sqlx/query-c3448ac48772d99b4cfd296996a93230488492804bb4f1d5ccec4a90454438f7.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\t\tINSERT INTO screenshot_groups (id, name, display_order)\n\t\t\tVALUES (?, ?, ?)\n\t\t\tON CONFLICT(id) DO UPDATE SET name = excluded.name\n\t\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "c3448ac48772d99b4cfd296996a93230488492804bb4f1d5ccec4a90454438f7" +} diff --git a/packages/app-lib/.sqlx/query-c695093aadc64edd159fce723d1e053f5fb7e204e01e54892168c6c36873d60f.json b/packages/app-lib/.sqlx/query-c695093aadc64edd159fce723d1e053f5fb7e204e01e54892168c6c36873d60f.json new file mode 100644 index 0000000..bbb52a3 --- /dev/null +++ b/packages/app-lib/.sqlx/query-c695093aadc64edd159fce723d1e053f5fb7e204e01e54892168c6c36873d60f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO screenshot_groups (id, name, display_order) VALUES (?, ?, 0)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "c695093aadc64edd159fce723d1e053f5fb7e204e01e54892168c6c36873d60f" +} diff --git a/packages/app-lib/.sqlx/query-cd9918f43f0f30b863ee48a7626782521100fac266da99102379ba01a89e9076.json b/packages/app-lib/.sqlx/query-cd9918f43f0f30b863ee48a7626782521100fac266da99102379ba01a89e9076.json new file mode 100644 index 0000000..71539ad --- /dev/null +++ b/packages/app-lib/.sqlx/query-cd9918f43f0f30b863ee48a7626782521100fac266da99102379ba01a89e9076.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO screenshot_group_memberships (screenshot_id, group_id) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "cd9918f43f0f30b863ee48a7626782521100fac266da99102379ba01a89e9076" +} diff --git a/packages/app-lib/.sqlx/query-fd2eb3ed33e2a0eaca507bbe156640acd542e91c99fa491b282542ec4c58032c.json b/packages/app-lib/.sqlx/query-fd2eb3ed33e2a0eaca507bbe156640acd542e91c99fa491b282542ec4c58032c.json new file mode 100644 index 0000000..5bdd62c --- /dev/null +++ b/packages/app-lib/.sqlx/query-fd2eb3ed33e2a0eaca507bbe156640acd542e91c99fa491b282542ec4c58032c.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' FROM screenshot_groups", + "describe": { + "columns": [ + { + "name": "display_order!: i64", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "fd2eb3ed33e2a0eaca507bbe156640acd542e91c99fa491b282542ec4c58032c" +} diff --git a/packages/app-lib/migrations/20261002000000_screenshot-center.sql b/packages/app-lib/migrations/20261002000000_screenshot-center.sql new file mode 100644 index 0000000..26ceb05 --- /dev/null +++ b/packages/app-lib/migrations/20261002000000_screenshot-center.sql @@ -0,0 +1,38 @@ +-- Screenshot center: unified index of per-instance screenshots plus +-- user-defined groups. Ported from the Axolotl implementation, trimmed of +-- the instance-sync / game-options tables that Starlight does not use. + +CREATE TABLE screenshots ( + id TEXT NOT NULL PRIMARY KEY, + instance_id TEXT NOT NULL, + file_name TEXT NOT NULL, + content_hash TEXT NOT NULL, + file_size INTEGER NOT NULL, + modified_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + editor_state TEXT, + UNIQUE (instance_id, file_name), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE +); + +CREATE INDEX screenshots_instance_id ON screenshots(instance_id); +CREATE INDEX screenshots_instance_hash ON screenshots(instance_id, content_hash, file_size); + +CREATE TABLE screenshot_groups ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + display_order INTEGER NOT NULL DEFAULT 0, + CHECK (length(trim(name)) > 0) +); + +CREATE INDEX screenshot_groups_display_order ON screenshot_groups(display_order); + +CREATE TABLE screenshot_group_memberships ( + screenshot_id TEXT NOT NULL PRIMARY KEY, + group_id TEXT NOT NULL, + FOREIGN KEY (screenshot_id) REFERENCES screenshots(id) ON DELETE CASCADE, + FOREIGN KEY (group_id) REFERENCES screenshot_groups(id) ON DELETE CASCADE +); + +CREATE INDEX screenshot_group_memberships_group_id ON screenshot_group_memberships(group_id); +ALTER TABLE settings ADD COLUMN show_screenshots_tab_in_instances INTEGER NOT NULL DEFAULT FALSE CHECK (show_screenshots_tab_in_instances IN (0, 1)); \ No newline at end of file diff --git a/packages/app-lib/src/api/instance.rs b/packages/app-lib/src/api/instance/mod.rs similarity index 82% rename from packages/app-lib/src/api/instance.rs rename to packages/app-lib/src/api/instance/mod.rs index a4fc619..0886a37 100644 --- a/packages/app-lib/src/api/instance.rs +++ b/packages/app-lib/src/api/instance/mod.rs @@ -12,6 +12,8 @@ mod paths; mod planet_minecraft; mod projects; mod run; +mod screenshot_groups; +mod screenshots; mod upgrade; pub use self::content::{ @@ -70,6 +72,20 @@ pub use self::run::{ GcLaunchIntent, GcLaunchReport, QuickPlayType, kill, run, run_with_extra_launch_args, run_with_extra_launch_args_with_gc, }; +pub use self::screenshot_groups::{ + ScreenshotGroup, ScreenshotGroupImport, ScreenshotGroupMembershipUpdate, + create_screenshot_group, delete_screenshot_group, import_screenshot_groups, + list_screenshot_groups, rename_screenshot_group, + set_screenshot_group_memberships, +}; +#[allow(unused_imports)] +pub(crate) use self::screenshots::reconcile_screenshots; +pub use self::screenshots::{ + InstanceScreenshot, ScreenshotEditSaveMode, ScreenshotKey, + delete_screenshots, export_screenshots, get_screenshot_path, + list_all_screenshots, list_screenshots, list_synced_screenshots, + move_screenshots, save_edited_screenshot, +}; pub use self::upgrade::{ dismiss_instance_post_upgrade_notice, execute_instance_upgrade, get_instance_post_upgrade_notice, get_instance_upgrade_plan, diff --git a/packages/app-lib/src/api/instance/screenshot_groups.rs b/packages/app-lib/src/api/instance/screenshot_groups.rs new file mode 100644 index 0000000..c024e0a --- /dev/null +++ b/packages/app-lib/src/api/instance/screenshot_groups.rs @@ -0,0 +1,349 @@ +use crate::State; +use crate::event::InstancePayloadType; +use crate::event::emit::emit_instance; +use crate::state::instances::adapters::sqlite::screenshot_rows; +use serde::{Deserialize, Serialize}; +use sqlx::{Sqlite, Transaction}; +use std::collections::HashSet; +use uuid::Uuid; + +const MAX_GROUP_NAME_LENGTH: usize = 256; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ScreenshotGroup { + pub id: String, + pub name: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ScreenshotGroupMembershipUpdate { + pub screenshot_id: String, + pub group_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ScreenshotGroupImport { + pub id: String, + pub name: String, + pub screenshot_ids: Vec, +} + +fn validate_group_name(name: &str) -> crate::Result<&str> { + let name = name.trim(); + if name.is_empty() { + return Err(crate::ErrorKind::InputError( + "Group name cannot be empty".to_string(), + ) + .into()); + } + if name.chars().count() > MAX_GROUP_NAME_LENGTH { + return Err(crate::ErrorKind::InputError(format!( + "Group name cannot exceed {MAX_GROUP_NAME_LENGTH} characters" + )) + .into()); + } + Ok(name) +} + +pub async fn list_screenshot_groups() -> crate::Result> { + let state = State::get().await?; + Ok(screenshot_rows::list_groups(&state.pool) + .await? + .into_iter() + .map(|group| ScreenshotGroup { + id: group.id, + name: group.name, + }) + .collect()) +} + +pub async fn create_screenshot_group( + name: String, + screenshot_ids: Vec, +) -> crate::Result { + let name = validate_group_name(&name)?; + let state = State::get().await?; + let id = Uuid::new_v4().to_string(); + let mut tx = state.pool.begin().await?; + + sqlx::query!( + "UPDATE screenshot_groups SET display_order = display_order + 1", + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + "INSERT INTO screenshot_groups (id, name, display_order) VALUES (?, ?, 0)", + id, + name, + ) + .execute(&mut *tx) + .await?; + + let instance_ids = set_group_members( + &id, + normalize_screenshot_ids(screenshot_ids)?, + &mut tx, + ) + .await?; + tx.commit().await?; + emit_screenshot_updates(instance_ids).await; + + Ok(ScreenshotGroup { + id, + name: name.to_string(), + }) +} + +pub async fn rename_screenshot_group( + id: String, + new_name: String, +) -> crate::Result { + let new_name = validate_group_name(&new_name)?; + let state = State::get().await?; + let result = sqlx::query!( + "UPDATE screenshot_groups SET name = ? WHERE id = ?", + new_name, + id, + ) + .execute(&state.pool) + .await?; + if result.rows_affected() == 0 { + return Err(crate::ErrorKind::InputError(format!( + "Unknown screenshot group {id}" + )) + .into()); + } + + Ok(ScreenshotGroup { + id, + name: new_name.to_string(), + }) +} + +pub async fn delete_screenshot_group(id: String) -> crate::Result<()> { + let state = State::get().await?; + let instance_ids = sqlx::query_scalar!( + " + SELECT DISTINCT screenshots.instance_id + FROM screenshot_group_memberships memberships + INNER JOIN screenshots ON screenshots.id = memberships.screenshot_id + WHERE memberships.group_id = ? + ", + id, + ) + .fetch_all(&state.pool) + .await?; + let result = + sqlx::query!("DELETE FROM screenshot_groups WHERE id = ?", id,) + .execute(&state.pool) + .await?; + if result.rows_affected() == 0 { + return Err(crate::ErrorKind::InputError(format!( + "Unknown screenshot group {id}" + )) + .into()); + } + emit_screenshot_updates(instance_ids).await; + Ok(()) +} +pub async fn set_screenshot_group_memberships( + updates: Vec, +) -> crate::Result<()> { + let updates = normalize_membership_updates(updates)?; + if updates.is_empty() { + return Ok(()); + } + + let state = State::get().await?; + let mut tx = state.pool.begin().await?; + let group_ids = updates + .iter() + .filter_map(|update| update.group_id.as_deref()) + .collect::>(); + for group_id in group_ids { + ensure_group_exists(group_id, &mut tx).await?; + } + + let mut instance_ids = HashSet::new(); + for update in updates { + let instance_id = + screenshot_instance_id(&update.screenshot_id, &mut tx).await?; + instance_ids.insert(instance_id); + sqlx::query!( + "DELETE FROM screenshot_group_memberships WHERE screenshot_id = ?", + update.screenshot_id, + ) + .execute(&mut *tx) + .await?; + if let Some(group_id) = update.group_id { + sqlx::query!( + "INSERT INTO screenshot_group_memberships (screenshot_id, group_id) VALUES (?, ?)", + update.screenshot_id, + group_id, + ) + .execute(&mut *tx) + .await?; + } + } + tx.commit().await?; + emit_screenshot_updates(instance_ids).await; + Ok(()) +} + +pub async fn import_screenshot_groups( + groups: Vec, +) -> crate::Result<()> { + if groups.is_empty() { + return Ok(()); + } + + let state = State::get().await?; + let mut tx = state.pool.begin().await?; + let base_order = sqlx::query_scalar!( + "SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' FROM screenshot_groups", + ) + .fetch_one(&mut *tx) + .await?; + let mut group_ids = HashSet::new(); + let mut affected_instance_ids = HashSet::new(); + + for (index, group) in groups.into_iter().enumerate() { + if group.id.trim().is_empty() || !group_ids.insert(group.id.clone()) { + return Err(crate::ErrorKind::InputError( + "Screenshot group import contains an invalid group ID" + .to_string(), + ) + .into()); + } + let name = validate_group_name(&group.name)?; + let display_order = base_order + index as i64; + sqlx::query!( + " + INSERT INTO screenshot_groups (id, name, display_order) + VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET name = excluded.name + ", + group.id, + name, + display_order, + ) + .execute(&mut *tx) + .await?; + affected_instance_ids.extend( + set_group_members( + &group.id, + normalize_screenshot_ids(group.screenshot_ids)?, + &mut tx, + ) + .await?, + ); + } + tx.commit().await?; + emit_screenshot_updates(affected_instance_ids).await; + Ok(()) +} + +fn normalize_screenshot_ids( + screenshot_ids: Vec, +) -> crate::Result> { + let mut unique = HashSet::new(); + if screenshot_ids + .iter() + .all(|screenshot_id| unique.insert(screenshot_id.clone())) + { + return Ok(screenshot_ids); + } + Err(crate::ErrorKind::InputError( + "Screenshot group contains duplicate screenshots".to_string(), + ) + .into()) +} + +fn normalize_membership_updates( + updates: Vec, +) -> crate::Result> { + let mut screenshot_ids = HashSet::new(); + if updates + .iter() + .all(|update| screenshot_ids.insert(update.screenshot_id.clone())) + { + return Ok(updates); + } + Err(crate::ErrorKind::InputError( + "Screenshot membership update contains duplicate screenshots" + .to_string(), + ) + .into()) +} + +async fn set_group_members( + group_id: &str, + screenshot_ids: Vec, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result> { + let mut instance_ids = HashSet::new(); + for screenshot_id in screenshot_ids { + instance_ids.insert(screenshot_instance_id(&screenshot_id, tx).await?); + sqlx::query!( + " + INSERT INTO screenshot_group_memberships (screenshot_id, group_id) + VALUES (?, ?) + ON CONFLICT(screenshot_id) DO UPDATE SET group_id = excluded.group_id + ", + screenshot_id, + group_id, + ) + .execute(&mut **tx) + .await?; + } + Ok(instance_ids) +} + +async fn ensure_group_exists( + group_id: &str, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + let exists = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM screenshot_groups WHERE id = ?) AS "exists!: bool""#, + group_id, + ) + .fetch_one(&mut **tx) + .await?; + if !exists { + return Err(crate::ErrorKind::InputError(format!( + "Unknown screenshot group {group_id}" + )) + .into()); + } + Ok(()) +} + +async fn screenshot_instance_id( + screenshot_id: &str, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result { + sqlx::query_scalar!( + "SELECT instance_id FROM screenshots WHERE id = ?", + screenshot_id, + ) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Unknown screenshot {screenshot_id}" + )) + }) + .map_err(Into::into) +} + +async fn emit_screenshot_updates( + instance_ids: impl IntoIterator, +) { + for instance_id in instance_ids { + let _ = emit_instance( + &instance_id, + InstancePayloadType::ScreenshotsUpdated, + ) + .await; + } +} diff --git a/packages/app-lib/src/api/instance/screenshots/mod.rs b/packages/app-lib/src/api/instance/screenshots/mod.rs new file mode 100644 index 0000000..669a91a --- /dev/null +++ b/packages/app-lib/src/api/instance/screenshots/mod.rs @@ -0,0 +1,10 @@ +mod operations; +mod reconciliation; + +pub use self::operations::{ + InstanceScreenshot, ScreenshotEditSaveMode, ScreenshotKey, + delete_screenshots, export_screenshots, get_screenshot_path, + list_all_screenshots, list_screenshots, list_synced_screenshots, + move_screenshots, save_edited_screenshot, +}; +pub(crate) use self::reconciliation::reconcile_screenshots; diff --git a/packages/app-lib/src/api/instance/screenshots/operations.rs b/packages/app-lib/src/api/instance/screenshots/operations.rs new file mode 100644 index 0000000..6217a70 --- /dev/null +++ b/packages/app-lib/src/api/instance/screenshots/operations.rs @@ -0,0 +1,650 @@ +use chrono::{DateTime, Utc}; +use futures::stream::{self, StreamExt, TryStreamExt}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::io::Cursor; +use std::path::{Component, Path, PathBuf}; +use tokio::sync::OwnedMutexGuard; +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipWriter}; + +use super::reconciliation::{ + list_source_screenshots, reconcile_source_screenshots, + scan_source_screenshots, +}; +use crate::State; +use crate::event::InstancePayloadType; +use crate::event::emit::emit_instance; +use crate::state::instances::adapters::sqlite::{ + instance_rows::{self, InstanceScreenshotSource}, + screenshot_rows, +}; +use crate::util::fetch::sha1_file_async; +use crate::util::io::{self, IOError}; + +const SCREENSHOTS_DIRECTORY: &str = "screenshots"; +const SCREENSHOT_SCAN_CONCURRENCY: usize = 8; + +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct ScreenshotKey { + pub instance_id: String, + pub file_name: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct InstanceScreenshot { + pub id: String, + pub instance_id: String, + pub instance_name: String, + pub file_name: String, + pub created_at: DateTime, + pub modified_at: i64, + pub group_id: Option, + #[serde(skip)] + pub path: PathBuf, +} + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScreenshotEditSaveMode { + CreateCopy, + ReplaceEdit, +} + +pub async fn list_screenshots( + instance_id: &str, +) -> crate::Result> { + let state = State::get().await?; + let source = + instance_rows::get_instance_screenshot_source(instance_id, &state.pool) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + + list_source_screenshots(&state, source).await +} + +pub async fn list_synced_screenshots() -> crate::Result> +{ + list_all_screenshots().await +} + +pub async fn list_all_screenshots() -> crate::Result> { + let state = State::get().await?; + let sources = instance_rows::list_screenshot_sources(&state.pool).await?; + list_source_screenshot_sets(&state, sources).await +} + +async fn list_source_screenshot_sets( + state: &State, + sources: Vec, +) -> crate::Result> { + let mut screenshots = + stream::iter(sources.into_iter().map(|source| async move { + list_source_screenshots(state, source).await + })) + .buffer_unordered(SCREENSHOT_SCAN_CONCURRENCY) + .try_collect::>>() + .await? + .into_iter() + .flatten() + .collect::>(); + + sort_screenshots(&mut screenshots); + Ok(screenshots) +} + +async fn lock_instance_screenshots<'a>( + state: &State, + instance_ids: impl IntoIterator, +) -> Vec> { + let mut locks = Vec::new(); + for instance_id in instance_ids { + locks.push(state.lock_instance_screenshots(instance_id).await); + } + locks +} + +pub async fn delete_screenshots(keys: &[ScreenshotKey]) -> crate::Result<()> { + ensure_unique_keys(keys)?; + let state = State::get().await?; + let mut instance_ids = keys + .iter() + .map(|key| key.instance_id.as_str()) + .collect::>(); + instance_ids.sort_unstable(); + instance_ids.dedup(); + let _locks = lock_instance_screenshots(&state, instance_ids).await; + + for key in keys { + io::remove_file(get_screenshot_path(key).await?).await?; + screenshot_rows::delete_screenshot_by_key( + &key.instance_id, + &key.file_name, + &state.pool, + ) + .await?; + } + + Ok(()) +} + +pub async fn export_screenshots( + keys: &[ScreenshotKey], + export_path: PathBuf, +) -> crate::Result<()> { + ensure_unique_keys(keys)?; + if keys.is_empty() { + return Err(crate::ErrorKind::InputError( + "At least one screenshot must be selected".to_string(), + ) + .into()); + } + + let state = State::get().await?; + let mut sources = HashMap::new(); + let mut archive_folders = HashMap::::new(); + let mut used_archive_folders = HashSet::new(); + let mut screenshots = Vec::with_capacity(keys.len()); + + for key in keys { + let source = match sources.get(&key.instance_id) { + Some(source) => source, + None => { + let source = instance_rows::get_instance_screenshot_source( + &key.instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + sources.insert(key.instance_id.clone(), source); + &sources[&key.instance_id] + } + }; + let archive_folder = match archive_folders.get(&key.instance_id) { + Some(folder) => folder.clone(), + None => { + let folder = unique_archive_folder( + &sanitize_archive_component(&source.name), + &mut used_archive_folders, + ); + archive_folders.insert(key.instance_id.clone(), folder.clone()); + folder + } + }; + let archive_name = format!("{archive_folder}/{}", key.file_name); + screenshots.push((archive_name, get_screenshot_path(key).await?)); + } + + tokio::task::spawn_blocking(move || -> crate::Result<()> { + let file = std::fs::File::create(&export_path) + .map_err(|error| IOError::with_path(error, &export_path))?; + let mut writer = ZipWriter::new(file); + let options = SimpleFileOptions::default() + .compression_method(CompressionMethod::Stored); + + for (archive_name, path) in screenshots { + writer + .start_file(archive_name, options) + .map_err(std::io::Error::from)?; + let mut source = std::fs::File::open(&path) + .map_err(|error| IOError::with_path(error, &path))?; + std::io::copy(&mut source, &mut writer).map_err(IOError::from)?; + } + + writer.finish().map_err(std::io::Error::from)?; + Ok(()) + }) + .await??; + + Ok(()) +} +pub async fn move_screenshots( + keys: &[ScreenshotKey], + target_instance_id: &str, +) -> crate::Result> { + ensure_unique_keys(keys)?; + if keys.is_empty() { + return Ok(Vec::new()); + } + + let state = State::get().await?; + let mut instance_ids = keys + .iter() + .map(|key| key.instance_id.as_str()) + .chain(std::iter::once(target_instance_id)) + .collect::>(); + instance_ids.sort_unstable(); + instance_ids.dedup(); + let _locks = lock_instance_screenshots(&state, instance_ids).await; + let target_source = instance_rows::get_instance_screenshot_source( + target_instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown target instance".to_string()) + })?; + let target_dir = source_screenshots_dir(&state, &target_source).await?; + io::create_dir_all(&target_dir).await?; + ensure_directory_is_not_symlink(&target_dir).await?; + + let mut moved_keys = Vec::with_capacity(keys.len()); + for key in keys { + if key.instance_id == target_instance_id { + continue; + } + + let source_path = get_screenshot_path(key).await?; + let target_path = + available_target_path(&target_dir, &key.file_name).await?; + io::rename_or_move(&source_path, &target_path) + .await + .map_err(|error| { + crate::ErrorKind::InputError(format!( + "Could not move screenshot: {error}" + )) + })?; + let file_name = target_path + .file_name() + .and_then(|file_name| file_name.to_str()) + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Could not determine moved screenshot file name" + .to_string(), + ) + })? + .to_string(); + screenshot_rows::move_screenshot( + &key.instance_id, + &key.file_name, + target_instance_id, + &file_name, + &state.pool, + ) + .await?; + moved_keys.push(ScreenshotKey { + instance_id: target_instance_id.to_string(), + file_name, + }); + } + + Ok(moved_keys) +} + +pub async fn get_screenshot_path( + key: &ScreenshotKey, +) -> crate::Result { + validate_file_name(&key.file_name)?; + + let state = State::get().await?; + let source = instance_rows::get_instance_screenshot_source( + &key.instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let screenshots_dir = source_screenshots_dir(&state, &source).await?; + let canonical_dir = tokio::fs::canonicalize(&screenshots_dir) + .await + .map_err(|error| IOError::with_path(error, &screenshots_dir))?; + let requested_path = screenshots_dir.join(&key.file_name); + let metadata = tokio::fs::symlink_metadata(&requested_path) + .await + .map_err(|error| IOError::with_path(error, &requested_path))?; + + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(crate::ErrorKind::InputError( + "Screenshot must be a regular file".to_string(), + ) + .into()); + } + + let canonical_path = tokio::fs::canonicalize(&requested_path) + .await + .map_err(|error| IOError::with_path(error, &requested_path))?; + if !canonical_path.starts_with(&canonical_dir) { + return Err(crate::ErrorKind::InputError( + "Screenshot path is outside the instance screenshots directory" + .to_string(), + ) + .into()); + } + + Ok(canonical_path) +} + +pub async fn save_edited_screenshot( + key: ScreenshotKey, + png_bytes: Vec, + mode: ScreenshotEditSaveMode, +) -> crate::Result { + validate_file_name(&key.file_name)?; + + let state = State::get().await?; + let source = instance_rows::get_instance_screenshot_source( + &key.instance_id, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown instance".to_string()) + })?; + let _lock = state.lock_instance_screenshots(&source.id).await; + + let scanned = scan_source_screenshots(&state, &source).await?; + let current = + reconcile_source_screenshots(&state, &source, scanned).await?; + let source_screenshot = current + .iter() + .find(|screenshot| screenshot.file_name == key.file_name) + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown screenshot".to_string()) + })?; + let source_row = screenshot_rows::get_screenshot_by_key( + &key.instance_id, + &key.file_name, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError("Unknown screenshot".to_string()) + })?; + + let png_bytes = tokio::task::spawn_blocking(move || { + validate_png_dimensions(&png_bytes)?; + Ok::<_, crate::Error>(png_bytes) + }) + .await??; + + let screenshots_dir = source_screenshots_dir(&state, &source).await?; + let (target_path, copy_group) = match mode { + ScreenshotEditSaveMode::CreateCopy => ( + available_target_path(&screenshots_dir, &source_row.file_name) + .await?, + true, + ), + ScreenshotEditSaveMode::ReplaceEdit => { + (source_screenshot.path.clone(), false) + } + }; + let target_file_name = target_path + .file_name() + .and_then(|file_name| file_name.to_str()) + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Could not determine edited screenshot file name".to_string(), + ) + })? + .to_string(); + + io::write(&target_path, png_bytes).await?; + if !copy_group { + let (file_size, content_hash) = sha1_file_async(&target_path).await?; + let file_size = i64::try_from(file_size).map_err(|_| { + crate::ErrorKind::InputError( + "Screenshot is too large to index".to_string(), + ) + })?; + let mut source_row = source_row.clone(); + source_row.content_hash = content_hash; + source_row.file_size = file_size; + let mut tx = state.pool.begin().await?; + screenshot_rows::update_screenshot(&source_row, &mut tx).await?; + tx.commit().await?; + } + let scanned = scan_source_screenshots(&state, &source).await?; + let reconciled = + reconcile_source_screenshots(&state, &source, scanned).await?; + let mut saved = reconciled + .into_iter() + .find(|screenshot| screenshot.file_name == target_file_name) + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Could not index edited screenshot".to_string(), + ) + })?; + + if copy_group { + let result: crate::Result<()> = async { + let mut tx = state.pool.begin().await?; + screenshot_rows::copy_group_membership( + &source_row.id, + &saved.id, + &mut tx, + ) + .await?; + tx.commit().await?; + Ok(()) + } + .await; + if let Err(error) = result { + let _ = io::remove_file(&target_path).await; + let _ = screenshot_rows::delete_screenshot_by_key( + &source.id, + &target_file_name, + &state.pool, + ) + .await; + return Err(error); + } + } + + let mut saved_row = screenshot_rows::get_screenshot_by_key( + &saved.instance_id, + &saved.file_name, + &state.pool, + ) + .await? + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Could not load edited screenshot".to_string(), + ) + })?; + if !copy_group { + let mut tx = state.pool.begin().await?; + if saved_row.created_at != source_row.created_at { + saved_row.created_at = source_row.created_at; + screenshot_rows::update_screenshot(&saved_row, &mut tx).await?; + saved.created_at = + DateTime::from_timestamp_millis(source_row.created_at) + .ok_or_else(|| { + crate::ErrorKind::InputError( + "Screenshot creation time is out of range" + .to_string(), + ) + })?; + } + tx.commit().await?; + } + saved.modified_at = saved_row.modified_at; + saved.group_id = saved_row.group_id; + + let _ = emit_instance( + &saved.instance_id, + InstancePayloadType::ScreenshotsUpdated, + ) + .await; + + Ok(saved) +} + +pub(super) async fn source_screenshots_dir( + state: &State, + source: &InstanceScreenshotSource, +) -> crate::Result { + // Directly associated instances keep their files in the external launcher's + // resolved game directory (which may be the shared `.minecraft` root or an + // isolated version directory). Use the same runtime resolver as launching + // and content browsing so screenshots are read from the directory the game + // actually uses. + let instance_dir = + match instance_rows::get_instance_by_id(&source.id, &state.pool).await? + { + Some(instance) => crate::launcher::linked_game_dir(&instance) + .unwrap_or_else(|| { + state.directories.instances_dir().join(&source.path) + }), + None => state.directories.instances_dir().join(&source.path), + }; + let canonical_instance_dir = + tokio::fs::canonicalize(&instance_dir) + .await + .map_err(|error| IOError::with_path(error, &instance_dir))?; + let screenshots_dir = canonical_instance_dir.join(SCREENSHOTS_DIRECTORY); + + ensure_directory_is_not_symlink(&screenshots_dir).await?; + Ok(screenshots_dir) +} + +async fn ensure_directory_is_not_symlink(path: &Path) -> crate::Result<()> { + match tokio::fs::symlink_metadata(path).await { + Ok(metadata) if metadata.file_type().is_symlink() => { + Err(crate::ErrorKind::InputError( + "Instance screenshots directory cannot be a symbolic link" + .to_string(), + ) + .into()) + } + Ok(metadata) if !metadata.is_dir() => { + Err(crate::ErrorKind::InputError( + "Instance screenshots path must be a directory".to_string(), + ) + .into()) + } + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(IOError::with_path(error, path).into()), + } +} + +async fn available_target_path( + target_dir: &Path, + file_name: &str, +) -> crate::Result { + validate_file_name(file_name)?; + let original = target_dir.join(file_name); + if !tokio::fs::try_exists(&original) + .await + .map_err(|error| IOError::with_path(error, &original))? + { + return Ok(original); + } + + let path = Path::new(file_name); + let stem = path.file_stem().and_then(|value| value.to_str()).unwrap(); + let extension = path.extension().and_then(|value| value.to_str()).unwrap(); + + for suffix in 1_u32.. { + let candidate = + target_dir.join(format!("{stem} ({suffix}).{extension}")); + if !tokio::fs::try_exists(&candidate) + .await + .map_err(|error| IOError::with_path(error, &candidate))? + { + return Ok(candidate); + } + } + + unreachable!() +} + +fn validate_png_dimensions(bytes: &[u8]) -> crate::Result<(u32, u32)> { + let validate = || -> Result<_, png::DecodingError> { + let mut reader = png::Decoder::new(Cursor::new(bytes)).read_info()?; + let dimensions = (reader.info().width, reader.info().height); + while reader.next_row()?.is_some() {} + reader.finish()?; + Ok(dimensions) + }; + validate().map_err(|error| { + crate::ErrorKind::InputError(format!( + "Could not decode screenshot as PNG: {error}" + )) + .into() + }) +} + +fn ensure_unique_keys(keys: &[ScreenshotKey]) -> crate::Result<()> { + let mut unique = HashSet::new(); + if keys.iter().all(|key| unique.insert(key)) { + return Ok(()); + } + + Err(crate::ErrorKind::InputError( + "Screenshot selection contains duplicates".to_string(), + ) + .into()) +} + +fn validate_file_name(file_name: &str) -> crate::Result<()> { + let path = Path::new(file_name); + let mut components = path.components(); + let is_single_file = + matches!(components.next(), Some(Component::Normal(_))) + && components.next().is_none(); + + if !is_single_file || !has_png_extension(path) { + return Err(crate::ErrorKind::InputError( + "Invalid screenshot file name".to_string(), + ) + .into()); + } + + Ok(()) +} + +pub(super) fn has_png_extension(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("png")) +} + +pub(super) fn sort_screenshots(screenshots: &mut [InstanceScreenshot]) { + screenshots.sort_by(|left, right| { + right + .created_at + .cmp(&left.created_at) + .then_with(|| left.instance_name.cmp(&right.instance_name)) + .then_with(|| left.file_name.cmp(&right.file_name)) + }); +} + +fn sanitize_archive_component(value: &str) -> String { + let sanitized = value + .chars() + .map(|character| match character { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + _ => character, + }) + .collect::(); + + if sanitized.is_empty() || sanitized == "." || sanitized == ".." { + "Instance".to_string() + } else { + sanitized + } +} + +fn unique_archive_folder( + base: &str, + used_folders: &mut HashSet, +) -> String { + if used_folders.insert(base.to_string()) { + return base.to_string(); + } + + for suffix in 2_u32.. { + let candidate = format!("{base} ({suffix})"); + if used_folders.insert(candidate.clone()) { + return candidate; + } + } + + unreachable!() +} diff --git a/packages/app-lib/src/api/instance/screenshots/reconciliation.rs b/packages/app-lib/src/api/instance/screenshots/reconciliation.rs new file mode 100644 index 0000000..50ce724 --- /dev/null +++ b/packages/app-lib/src/api/instance/screenshots/reconciliation.rs @@ -0,0 +1,303 @@ +use super::operations::{ + InstanceScreenshot, has_png_extension, sort_screenshots, + source_screenshots_dir, +}; +use crate::State; +use crate::state::instances::adapters::sqlite::{ + instance_rows::{self, InstanceScreenshotSource}, + screenshot_rows::{self, ScreenshotRow}, +}; +use crate::util::fetch::sha1_file_async; +use crate::util::io::IOError; +use chrono::{DateTime, Utc}; +use futures::stream::{self, StreamExt, TryStreamExt}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +const SCREENSHOT_HASH_CONCURRENCY: usize = 8; + +pub(super) struct ScannedScreenshot { + file_name: String, + created_at: DateTime, + modified_at: i64, + file_size: i64, + path: PathBuf, +} + +struct ResolvedScreenshot { + scanned: ScannedScreenshot, + row: ScreenshotRow, + is_new: bool, + changed: bool, +} + +fn resolve_scanned_screenshot( + source: &InstanceScreenshotSource, + scanned: ScannedScreenshot, + content_hash: String, + matched: Option, +) -> ResolvedScreenshot { + let (mut row, is_new) = match matched { + Some(row) => (row, false), + None => ( + ScreenshotRow { + id: Uuid::new_v4().to_string(), + instance_id: source.id.clone(), + file_name: scanned.file_name.clone(), + content_hash: content_hash.clone(), + file_size: scanned.file_size, + modified_at: scanned.modified_at, + created_at: scanned.created_at.timestamp_millis(), + group_id: None, + }, + true, + ), + }; + let changed = !is_new + && (row.file_name != scanned.file_name + || row.content_hash != content_hash + || row.file_size != scanned.file_size + || row.modified_at != scanned.modified_at + || row.created_at != scanned.created_at.timestamp_millis()); + row.instance_id.clone_from(&source.id); + row.file_name.clone_from(&scanned.file_name); + row.content_hash = content_hash; + row.file_size = scanned.file_size; + row.modified_at = scanned.modified_at; + row.created_at = scanned.created_at.timestamp_millis(); + + ResolvedScreenshot { + scanned, + row, + is_new, + changed, + } +} + +pub(super) async fn list_source_screenshots( + state: &State, + source: InstanceScreenshotSource, +) -> crate::Result> { + let _lock = state.lock_instance_screenshots(&source.id).await; + let scanned = scan_source_screenshots(state, &source).await?; + reconcile_source_screenshots(state, &source, scanned).await +} + +#[allow(dead_code)] +pub(crate) async fn reconcile_screenshots( + instance_id: &str, +) -> crate::Result<()> { + let state = State::get().await?; + let Some(source) = + instance_rows::get_instance_screenshot_source(instance_id, &state.pool) + .await? + else { + return Ok(()); + }; + + list_source_screenshots(&state, source).await?; + Ok(()) +} + +pub(super) async fn scan_source_screenshots( + state: &State, + source: &InstanceScreenshotSource, +) -> crate::Result> { + let screenshots_dir = source_screenshots_dir(state, source).await?; + tokio::task::spawn_blocking(move || scan_screenshots_dir(&screenshots_dir)) + .await? +} + +fn scan_screenshots_dir( + screenshots_dir: &Path, +) -> crate::Result> { + let entries = match std::fs::read_dir(screenshots_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(error) => return Err(error.into()), + }; + let mut screenshots = Vec::new(); + + for entry in entries { + let entry = entry + .map_err(|error| IOError::with_path(error, screenshots_dir))?; + let file_type = entry + .file_type() + .map_err(|error| IOError::with_path(error, entry.path()))?; + if !file_type.is_file() { + continue; + } + + let path = entry.path(); + if !has_png_extension(&path) { + continue; + } + + let Some(file_name) = entry.file_name().to_str().map(str::to_owned) + else { + continue; + }; + let metadata = entry + .metadata() + .map_err(|error| IOError::with_path(error, &path))?; + let created_at = metadata + .created() + .or_else(|_| metadata.modified()) + .map(DateTime::::from) + .map_err(|error| IOError::with_path(error, &path))?; + let modified_at = metadata + .modified() + .map(DateTime::::from) + .map(|value| value.timestamp_millis()) + .map_err(|error| IOError::with_path(error, &path))?; + let file_size = i64::try_from(metadata.len()).map_err(|_| { + crate::ErrorKind::InputError( + "Screenshot is too large to index".to_string(), + ) + })?; + + screenshots.push(ScannedScreenshot { + file_name, + created_at, + modified_at, + file_size, + path, + }); + } + + Ok(screenshots) +} + +pub(super) async fn reconcile_source_screenshots( + state: &State, + source: &InstanceScreenshotSource, + scanned: Vec, +) -> crate::Result> { + let existing = + screenshot_rows::list_screenshots(&source.id, &state.pool).await?; + let mut unmatched_by_name = existing + .into_iter() + .map(|row| (row.file_name.clone(), row)) + .collect::>(); + let mut resolved = Vec::with_capacity(scanned.len()); + let mut needs_hash = Vec::new(); + + for scanned in scanned { + match unmatched_by_name.remove(&scanned.file_name) { + Some(row) + if row.file_size == scanned.file_size + && row.modified_at == scanned.modified_at + && row.created_at + == scanned.created_at.timestamp_millis() => + { + resolved.push(ResolvedScreenshot { + scanned, + row, + is_new: false, + changed: false, + }); + } + matched => needs_hash.push((scanned, matched)), + } + } + + let hashed = stream::iter(needs_hash.into_iter().map( + |(mut scanned, matched)| async move { + let (file_size, content_hash) = + sha1_file_async(&scanned.path).await?; + scanned.file_size = i64::try_from(file_size).map_err(|_| { + crate::ErrorKind::InputError( + "Screenshot is too large to index".to_string(), + ) + })?; + Ok::<_, crate::Error>((scanned, content_hash, matched)) + }, + )) + .buffer_unordered(SCREENSHOT_HASH_CONCURRENCY) + .try_collect::>() + .await?; + + let mut unmatched_by_hash = + HashMap::<(String, i64), Vec>::new(); + for row in unmatched_by_name.into_values() { + unmatched_by_hash + .entry((row.content_hash.clone(), row.file_size)) + .or_default() + .push(row); + } + + for (scanned, content_hash, matched_by_name) in hashed { + if let Some(row) = matched_by_name { + resolved.push(resolve_scanned_screenshot( + source, + scanned, + content_hash, + Some(row), + )); + continue; + } + + let hash_key = (content_hash.clone(), scanned.file_size); + let matched = unmatched_by_hash.get_mut(&hash_key).and_then(|rows| { + if rows.is_empty() { + return None; + } + let created_at = scanned.created_at.timestamp_millis(); + let index = rows + .iter() + .position(|row| { + row.modified_at == scanned.modified_at + && row.created_at == created_at + }) + .unwrap_or(rows.len() - 1); + Some(rows.swap_remove(index)) + }); + resolved.push(resolve_scanned_screenshot( + source, + scanned, + content_hash, + matched, + )); + } + + let mut tx = state.pool.begin().await?; + for row in unmatched_by_hash.into_values().flatten() { + screenshot_rows::delete_screenshot(&row.id, &mut tx).await?; + } + for screenshot in &resolved { + if !screenshot.is_new && screenshot.changed { + let mut row = screenshot.row.clone(); + row.file_name = Uuid::new_v4().to_string(); + screenshot_rows::update_screenshot(&row, &mut tx).await?; + } + } + for screenshot in &resolved { + if screenshot.is_new { + screenshot_rows::insert_screenshot(&screenshot.row, &mut tx) + .await?; + } else if screenshot.changed { + screenshot_rows::update_screenshot(&screenshot.row, &mut tx) + .await?; + } + } + tx.commit().await?; + + let mut screenshots = resolved + .into_iter() + .map(|screenshot| InstanceScreenshot { + id: screenshot.row.id, + instance_id: source.id.clone(), + instance_name: source.name.clone(), + file_name: screenshot.scanned.file_name, + created_at: screenshot.scanned.created_at, + modified_at: screenshot.row.modified_at, + group_id: screenshot.row.group_id, + path: screenshot.scanned.path, + }) + .collect::>(); + sort_screenshots(&mut screenshots); + Ok(screenshots) +} diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs index bcc6886..5532814 100644 --- a/packages/app-lib/src/event/mod.rs +++ b/packages/app-lib/src/event/mod.rs @@ -360,6 +360,7 @@ pub enum InstancePayloadType { revision: u64, }, ServersUpdated, + ScreenshotsUpdated, WorldUpdated { world: String, }, diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs index e943e44..411a5c5 100644 --- a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs +++ b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs @@ -12,6 +12,34 @@ use chrono::{DateTime, TimeZone, Utc}; use serde::de::DeserializeOwned; use sqlx::{Executor, Sqlite, SqlitePool, Transaction}; use uuid::Uuid; +#[derive(Clone, Debug, sqlx::FromRow)] +pub(crate) struct InstanceScreenshotSource { + pub id: String, + pub name: String, + pub path: String, +} + +pub(crate) async fn get_instance_screenshot_source( + instance_id: &str, + pool: &SqlitePool, +) -> crate::Result> { + Ok(sqlx::query_as::<_, InstanceScreenshotSource>( + "SELECT id, name, path FROM instances WHERE id = ?", + ) + .bind(instance_id) + .fetch_optional(pool) + .await?) +} + +pub(crate) async fn list_screenshot_sources( + pool: &SqlitePool, +) -> crate::Result> { + Ok(sqlx::query_as::<_, InstanceScreenshotSource>( + "SELECT id, name, path FROM instances ORDER BY name, id", + ) + .fetch_all(pool) + .await?) +} #[derive(Debug, sqlx::FromRow)] pub(crate) struct InstanceRow { diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/mod.rs b/packages/app-lib/src/state/instances/adapters/sqlite/mod.rs index 5a9bf0a..d6c8e42 100644 --- a/packages/app-lib/src/state/instances/adapters/sqlite/mod.rs +++ b/packages/app-lib/src/state/instances/adapters/sqlite/mod.rs @@ -2,3 +2,4 @@ pub(crate) mod config_sync_rows; pub(crate) mod content_rows; pub(crate) mod instance_rows; pub(crate) mod loader_component_rows; +pub(crate) mod screenshot_rows; diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/screenshot_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/screenshot_rows.rs new file mode 100644 index 0000000..dad3684 --- /dev/null +++ b/packages/app-lib/src/state/instances/adapters/sqlite/screenshot_rows.rs @@ -0,0 +1,236 @@ +use sqlx::{Sqlite, SqlitePool, Transaction}; + +#[derive(Clone, Debug, sqlx::FromRow)] +pub(crate) struct ScreenshotRow { + pub id: String, + pub instance_id: String, + pub file_name: String, + pub content_hash: String, + pub file_size: i64, + pub modified_at: i64, + pub created_at: i64, + pub group_id: Option, +} + +#[derive(Clone, Debug, sqlx::FromRow)] +pub(crate) struct ScreenshotGroupRow { + pub id: String, + pub name: String, +} + +pub(crate) async fn list_screenshots( + instance_id: &str, + pool: &SqlitePool, +) -> crate::Result> { + Ok(sqlx::query_as!( + ScreenshotRow, + r#" + SELECT + screenshots.id, + screenshots.instance_id, + screenshots.file_name, + screenshots.content_hash, + screenshots.file_size, + screenshots.modified_at, + screenshots.created_at, + memberships.group_id AS "group_id?" + FROM screenshots + LEFT JOIN screenshot_group_memberships memberships + ON memberships.screenshot_id = screenshots.id + WHERE screenshots.instance_id = ? + ORDER BY screenshots.file_name, screenshots.id + "#, + instance_id, + ) + .fetch_all(pool) + .await?) +} + +pub(crate) async fn get_screenshot_by_key( + instance_id: &str, + file_name: &str, + pool: &SqlitePool, +) -> crate::Result> { + Ok(sqlx::query_as!( + ScreenshotRow, + r#" + SELECT + screenshots.id, + screenshots.instance_id, + screenshots.file_name, + screenshots.content_hash, + screenshots.file_size, + screenshots.modified_at, + screenshots.created_at, + memberships.group_id AS "group_id?" + FROM screenshots + LEFT JOIN screenshot_group_memberships memberships + ON memberships.screenshot_id = screenshots.id + WHERE screenshots.instance_id = ? AND screenshots.file_name = ? + "#, + instance_id, + file_name, + ) + .fetch_optional(pool) + .await?) +} + +pub(crate) async fn copy_group_membership( + source_id: &str, + edited_id: &str, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + sqlx::query!( + r#" + INSERT INTO screenshot_group_memberships (screenshot_id, group_id) + SELECT ?, group_id + FROM screenshot_group_memberships + WHERE screenshot_id = ? + ON CONFLICT (screenshot_id) DO UPDATE SET group_id = excluded.group_id + "#, + edited_id, + source_id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn insert_screenshot( + row: &ScreenshotRow, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + sqlx::query!( + " + INSERT INTO screenshots ( + id, + instance_id, + file_name, + content_hash, + file_size, + modified_at, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + ", + row.id, + row.instance_id, + row.file_name, + row.content_hash, + row.file_size, + row.modified_at, + row.created_at, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn update_screenshot( + row: &ScreenshotRow, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + sqlx::query!( + " + UPDATE screenshots + SET + instance_id = ?, + file_name = ?, + content_hash = ?, + file_size = ?, + modified_at = ?, + created_at = ? + WHERE id = ? + ", + row.instance_id, + row.file_name, + row.content_hash, + row.file_size, + row.modified_at, + row.created_at, + row.id, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn delete_screenshot( + id: &str, + tx: &mut Transaction<'_, Sqlite>, +) -> crate::Result<()> { + sqlx::query!("DELETE FROM screenshots WHERE id = ?", id) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +pub(crate) async fn move_screenshot( + source_instance_id: &str, + source_file_name: &str, + target_instance_id: &str, + target_file_name: &str, + pool: &SqlitePool, +) -> crate::Result<()> { + let mut tx = pool.begin().await?; + sqlx::query!( + "DELETE FROM screenshots WHERE instance_id = ? AND file_name = ?", + target_instance_id, + target_file_name, + ) + .execute(&mut *tx) + .await?; + + sqlx::query!( + " + UPDATE screenshots + SET instance_id = ?, file_name = ? + WHERE instance_id = ? AND file_name = ? + ", + target_instance_id, + target_file_name, + source_instance_id, + source_file_name, + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) +} + +pub(crate) async fn delete_screenshot_by_key( + instance_id: &str, + file_name: &str, + pool: &SqlitePool, +) -> crate::Result<()> { + sqlx::query!( + "DELETE FROM screenshots WHERE instance_id = ? AND file_name = ?", + instance_id, + file_name, + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub(crate) async fn list_groups( + pool: &SqlitePool, +) -> crate::Result> { + Ok(sqlx::query_as!( + ScreenshotGroupRow, + " + SELECT id, name + FROM screenshot_groups + ORDER BY display_order, name, id + ", + ) + .fetch_all(pool) + .await?) +} diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs index 626b26a..93666e8 100644 --- a/packages/app-lib/src/state/mod.rs +++ b/packages/app-lib/src/state/mod.rs @@ -120,6 +120,7 @@ pub struct State { pub(crate) install_job_cancellations: DashMap, pub(crate) install_job_operation_locks: DashMap>>, + pub(crate) screenshot_locks: DashMap>>, /// Process manager pub process_manager: ProcessManager, @@ -904,6 +905,7 @@ impl State { install_db_semaphore: Semaphore::new(1), install_job_cancellations: DashMap::new(), install_job_operation_locks: DashMap::new(), + screenshot_locks: DashMap::new(), process_manager, friends_socket, restart_after_pending_update: AtomicBool::new(false), @@ -931,6 +933,18 @@ impl State { ) -> InstanceLockGuard { self.instance_locks.lock_exclusive(instance_id).await } + + pub(crate) async fn lock_instance_screenshots( + &self, + instance_id: &str, + ) -> tokio::sync::OwnedMutexGuard<()> { + self.screenshot_locks + .entry(instance_id.to_string()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone() + .lock_owned() + .await + } } #[cfg(test)] @@ -971,6 +985,7 @@ pub(crate) async fn test_state( install_db_semaphore: Semaphore::new(1), install_job_cancellations: DashMap::new(), install_job_operation_locks: DashMap::new(), + screenshot_locks: DashMap::new(), process_manager: ProcessManager::new(), friends_socket: FriendsSocket::new(), restart_after_pending_update: AtomicBool::new(false), diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index 86ce63d..9115326 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -79,6 +79,7 @@ import _ChevronDownIcon from './icons/chevron-down.svg?component' import _ChevronLeftIcon from './icons/chevron-left.svg?component' import _ChevronRightIcon from './icons/chevron-right.svg?component' import _ChevronUpIcon from './icons/chevron-up.svg?component' +import _CircleIcon from './icons/circle.svg?component' import _CircleAlertIcon from './icons/circle-alert.svg?component' import _CircleArrowRightIcon from './icons/circle-arrow-right.svg?component' import _CircleDashedIcon from './icons/circle-dashed.svg?component' @@ -103,6 +104,7 @@ import _CopyIcon from './icons/copy.svg?component' import _CopyrightIcon from './icons/copyright.svg?component' import _CpuIcon from './icons/cpu.svg?component' import _CreateIcon from './icons/create.svg?component' +import _CropIcon from './icons/crop.svg?component' import _CrownIcon from './icons/crown.svg?component' import _CubeIcon from './icons/cube.svg?component' import _CurrencyIcon from './icons/currency.svg?component' @@ -113,6 +115,7 @@ import _DownloadIcon from './icons/download.svg?component' import _DropdownIcon from './icons/dropdown.svg?component' import _EditIcon from './icons/edit.svg?component' import _EllipsisVerticalIcon from './icons/ellipsis-vertical.svg?component' +import _EraserIcon from './icons/eraser.svg?component' import _ExpandIcon from './icons/expand.svg?component' import _ExternalIcon from './icons/external.svg?component' import _EyeIcon from './icons/eye.svg?component' @@ -155,6 +158,7 @@ import _HeartIcon from './icons/heart.svg?component' import _HeartHandshakeIcon from './icons/heart-handshake.svg?component' import _HeartMinusIcon from './icons/heart-minus.svg?component' import _HelpCircleIcon from './icons/help-circle.svg?component' +import _HighlighterIcon from './icons/highlighter.svg?component' import _HistoryIcon from './icons/history.svg?component' import _HomeIcon from './icons/home.svg?component' import _ImageIcon from './icons/image.svg?component' @@ -200,7 +204,9 @@ import _MonitorSmartphoneIcon from './icons/monitor-smartphone.svg?component' import _MoonIcon from './icons/moon.svg?component' import _MoreHorizontalIcon from './icons/more-horizontal.svg?component' import _MoreVerticalIcon from './icons/more-vertical.svg?component' +import _MousePointer2Icon from './icons/mouse-pointer-2.svg?component' import _MoveIcon from './icons/move.svg?component' +import _MoveUpRightIcon from './icons/move-up-right.svg?component' import _NewspaperIcon from './icons/newspaper.svg?component' import _NoSignalIcon from './icons/no-signal.svg?component' import _NotepadTextIcon from './icons/notepad-text.svg?component' @@ -261,6 +267,7 @@ import _SortDescIcon from './icons/sort-desc.svg?component' import _SparklesIcon from './icons/sparkles.svg?component' import _SpinnerIcon from './icons/spinner.svg?component' import _SplitIcon from './icons/split.svg?component' +import _SquareIcon from './icons/square.svg?component' import _StarIcon from './icons/star.svg?component' import _StopCircleIcon from './icons/stop-circle.svg?component' import _StoreIcon from './icons/store.svg?component' @@ -415,6 +422,7 @@ import _TrashExclamationIcon from './icons/trash-exclamation.svg?component' import _TrendingDownIcon from './icons/trending-down.svg?component' import _TrendingUpIcon from './icons/trending-up.svg?component' import _TriangleAlertIcon from './icons/triangle-alert.svg?component' +import _TypeIcon from './icons/type.svg?component' import _UnderlineIcon from './icons/underline.svg?component' import _UndoIcon from './icons/undo.svg?component' import _UnfoldHorizontalIcon from './icons/unfold-horizontal.svg?component' @@ -519,6 +527,7 @@ export const ChevronDownIcon = _ChevronDownIcon export const ChevronLeftIcon = _ChevronLeftIcon export const ChevronRightIcon = _ChevronRightIcon export const ChevronUpIcon = _ChevronUpIcon +export const CircleIcon = _CircleIcon export const CircleAlertIcon = _CircleAlertIcon export const CircleArrowRightIcon = _CircleArrowRightIcon export const CircleDashedIcon = _CircleDashedIcon @@ -543,6 +552,7 @@ export const CopyIcon = _CopyIcon export const CopyrightIcon = _CopyrightIcon export const CpuIcon = _CpuIcon export const CreateIcon = _CreateIcon +export const CropIcon = _CropIcon export const CrownIcon = _CrownIcon export const CubeIcon = _CubeIcon export const CurrencyIcon = _CurrencyIcon @@ -553,6 +563,7 @@ export const DownloadIcon = _DownloadIcon export const DropdownIcon = _DropdownIcon export const EditIcon = _EditIcon export const EllipsisVerticalIcon = _EllipsisVerticalIcon +export const EraserIcon = _EraserIcon export const ExpandIcon = _ExpandIcon export const ExternalIcon = _ExternalIcon export const EyeIcon = _EyeIcon @@ -595,6 +606,7 @@ export const HeartIcon = _HeartIcon export const HeartHandshakeIcon = _HeartHandshakeIcon export const HeartMinusIcon = _HeartMinusIcon export const HelpCircleIcon = _HelpCircleIcon +export const HighlighterIcon = _HighlighterIcon export const HistoryIcon = _HistoryIcon export const HomeIcon = _HomeIcon export const ImageIcon = _ImageIcon @@ -640,7 +652,9 @@ export const MonitorSmartphoneIcon = _MonitorSmartphoneIcon export const MoonIcon = _MoonIcon export const MoreHorizontalIcon = _MoreHorizontalIcon export const MoreVerticalIcon = _MoreVerticalIcon +export const MousePointer2Icon = _MousePointer2Icon export const MoveIcon = _MoveIcon +export const MoveUpRightIcon = _MoveUpRightIcon export const NewspaperIcon = _NewspaperIcon export const NoSignalIcon = _NoSignalIcon export const NotepadTextIcon = _NotepadTextIcon @@ -701,6 +715,7 @@ export const SortDescIcon = _SortDescIcon export const SparklesIcon = _SparklesIcon export const SpinnerIcon = _SpinnerIcon export const SplitIcon = _SplitIcon +export const SquareIcon = _SquareIcon export const StarIcon = _StarIcon export const StopCircleIcon = _StopCircleIcon export const StoreIcon = _StoreIcon @@ -855,6 +870,7 @@ export const TrashExclamationIcon = _TrashExclamationIcon export const TrendingDownIcon = _TrendingDownIcon export const TrendingUpIcon = _TrendingUpIcon export const TriangleAlertIcon = _TriangleAlertIcon +export const TypeIcon = _TypeIcon export const UnderlineIcon = _UnderlineIcon export const UndoIcon = _UndoIcon export const UnfoldHorizontalIcon = _UnfoldHorizontalIcon diff --git a/packages/assets/icons/circle.svg b/packages/assets/icons/circle.svg new file mode 100644 index 0000000..5565586 --- /dev/null +++ b/packages/assets/icons/circle.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/packages/assets/icons/crop.svg b/packages/assets/icons/crop.svg new file mode 100644 index 0000000..48df2d9 --- /dev/null +++ b/packages/assets/icons/crop.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/packages/assets/icons/eraser.svg b/packages/assets/icons/eraser.svg new file mode 100644 index 0000000..2a4cab8 --- /dev/null +++ b/packages/assets/icons/eraser.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/packages/assets/icons/highlighter.svg b/packages/assets/icons/highlighter.svg new file mode 100644 index 0000000..79e23d5 --- /dev/null +++ b/packages/assets/icons/highlighter.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/packages/assets/icons/mouse-pointer-2.svg b/packages/assets/icons/mouse-pointer-2.svg new file mode 100644 index 0000000..e76214d --- /dev/null +++ b/packages/assets/icons/mouse-pointer-2.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/packages/assets/icons/move-up-right.svg b/packages/assets/icons/move-up-right.svg new file mode 100644 index 0000000..1b26065 --- /dev/null +++ b/packages/assets/icons/move-up-right.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/packages/assets/icons/square.svg b/packages/assets/icons/square.svg new file mode 100644 index 0000000..49f6f1b --- /dev/null +++ b/packages/assets/icons/square.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/packages/assets/icons/type.svg b/packages/assets/icons/type.svg new file mode 100644 index 0000000..40b1c4d --- /dev/null +++ b/packages/assets/icons/type.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/assets/package.json b/packages/assets/package.json index 7dff68a..82112a4 100644 --- a/packages/assets/package.json +++ b/packages/assets/package.json @@ -1,22 +1,22 @@ { - "name": "@modrinth/assets", - "version": "0.0.0", - "private": true, - "main": "./index.ts", - "types": "./index.ts", - "scripts": { - "lint": "pnpm run icons:validate && eslint . && prettier --check .", - "fix": "pnpm run icons:generate && eslint . --fix && prettier --write .", - "icons:add": "jiti build/add-icons.ts", - "icons:test": "jiti build/generate-exports.ts --test", - "icons:validate": "jiti build/generate-exports.ts --validate", - "icons:generate": "jiti build/generate-exports.ts" - }, - "devDependencies": { - "@modrinth/tooling-config": "workspace:*", - "@types/node": "^24", - "jiti": "^2.4.2", - "lucide-static": "^0.562.0", - "vue": "^3.5.13" - } + "name": "@modrinth/assets", + "version": "0.0.0", + "private": true, + "main": "./index.ts", + "types": "./index.ts", + "scripts": { + "lint": "pnpm run icons:validate && eslint . && prettier --check .", + "fix": "pnpm run icons:generate && eslint . --fix && prettier --write .", + "icons:add": "jiti build/add-icons.ts", + "icons:test": "jiti build/generate-exports.ts --test", + "icons:validate": "jiti build/generate-exports.ts --validate", + "icons:generate": "jiti build/generate-exports.ts" + }, + "devDependencies": { + "@modrinth/tooling-config": "workspace:*", + "@types/node": "^24", + "jiti": "^2.4.2", + "lucide-static": "^0.562.0", + "vue": "^3.5.13" + } } diff --git a/packages/assets/tsconfig.json b/packages/assets/tsconfig.json index f95eddc..1119d5c 100644 --- a/packages/assets/tsconfig.json +++ b/packages/assets/tsconfig.json @@ -1,3 +1,3 @@ { - "extends": "@modrinth/tooling-config/typescript/vue.json" + "extends": "@modrinth/tooling-config/typescript/vue.json" } diff --git a/packages/ui/package.json b/packages/ui/package.json index 16935ee..b3c443a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -65,6 +65,7 @@ "dayjs": "^1.11.10", "dompurify": "^3.1.7", "es-toolkit": "^1.44.0", + "fabric": "^7.4.0", "flatpickr": "^4.6.13", "floating-vue": "^5.2.2", "fuse.js": "^6.6.2", diff --git a/packages/ui/src/components/base/InlineEditableText.vue b/packages/ui/src/components/base/InlineEditableText.vue new file mode 100644 index 0000000..a78ddac --- /dev/null +++ b/packages/ui/src/components/base/InlineEditableText.vue @@ -0,0 +1,248 @@ + + + \ No newline at end of file diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts index 1983f8c..877f82a 100644 --- a/packages/ui/src/components/base/index.ts +++ b/packages/ui/src/components/base/index.ts @@ -53,6 +53,7 @@ export { default as FloatingActionBar } from './FloatingActionBar.vue' export { default as FloatingPanel } from './FloatingPanel.vue' export { default as FormattedTag } from './FormattedTag.vue' export { default as HeadingLink } from './HeadingLink.vue' +export { default as InlineEditableText } from './InlineEditableText.vue' export { default as HorizontalRule } from './HorizontalRule.vue' export { default as I18nDebugPanel } from './I18nDebugPanel.vue' export { default as IconSelect } from './IconSelect.vue' diff --git a/packages/ui/src/components/image-viewer-editor/controls.vue b/packages/ui/src/components/image-viewer-editor/controls.vue new file mode 100644 index 0000000..d75dc5d --- /dev/null +++ b/packages/ui/src/components/image-viewer-editor/controls.vue @@ -0,0 +1,316 @@ + + + + \ No newline at end of file diff --git a/packages/ui/src/components/image-viewer-editor/editor.vue b/packages/ui/src/components/image-viewer-editor/editor.vue new file mode 100644 index 0000000..7913b08 --- /dev/null +++ b/packages/ui/src/components/image-viewer-editor/editor.vue @@ -0,0 +1,580 @@ + + + + \ No newline at end of file diff --git a/packages/ui/src/components/image-viewer-editor/image-viewer-editor-censor-object.ts b/packages/ui/src/components/image-viewer-editor/image-viewer-editor-censor-object.ts new file mode 100644 index 0000000..70ba446 --- /dev/null +++ b/packages/ui/src/components/image-viewer-editor/image-viewer-editor-censor-object.ts @@ -0,0 +1,15 @@ +import type { ScreenshotEditorSourceRect } from './image-viewer-editor-types' + +export function renderCensorRegion(sourceRect: ScreenshotEditorSourceRect, solidColor: string) { + const width = Math.max(1, Math.round(sourceRect.width)) + const height = Math.max(1, Math.round(sourceRect.height)) + const output = document.createElement('canvas') + output.width = width + output.height = height + const outputContext = output.getContext('2d') + if (!outputContext) throw new Error('Could not create censor canvas') + + outputContext.fillStyle = solidColor + outputContext.fillRect(0, 0, width, height) + return output +} \ No newline at end of file diff --git a/packages/ui/src/components/image-viewer-editor/image-viewer-editor-messages.ts b/packages/ui/src/components/image-viewer-editor/image-viewer-editor-messages.ts new file mode 100644 index 0000000..e1af6f8 --- /dev/null +++ b/packages/ui/src/components/image-viewer-editor/image-viewer-editor-messages.ts @@ -0,0 +1,57 @@ +import { defineMessages } from '#ui/composables/i18n' + +export const imageViewerEditorMessages = defineMessages({ + close: { id: 'image-viewer.close', defaultMessage: 'Close' }, + previous: { id: 'image-viewer.previous', defaultMessage: 'Previous image' }, + next: { id: 'image-viewer.next', defaultMessage: 'Next image' }, + edit: { id: 'image-viewer.edit', defaultMessage: 'Edit' }, + metadata: { + id: 'image-viewer.metadata', + defaultMessage: '{size} · {width} × {height} px', + }, + saveAsCopy: { id: 'image-viewer.editor.save-as-new', defaultMessage: 'Save copy' }, + overwrite: { id: 'image-viewer.editor.overwrite', defaultMessage: 'Overwrite' }, + moreSaveOptions: { + id: 'image-viewer.editor.more-save-options', + defaultMessage: 'More save options', + }, + saveImage: { id: 'image-viewer.editor.save-image', defaultMessage: 'Save image' }, + select: { id: 'image-viewer.editor.tool.select', defaultMessage: 'Select' }, + pen: { id: 'image-viewer.editor.tool.pen', defaultMessage: 'Pen' }, + highlight: { id: 'image-viewer.editor.tool.highlight', defaultMessage: 'Highlight' }, + eraser: { id: 'image-viewer.editor.tool.eraser', defaultMessage: 'Eraser' }, + crop: { id: 'image-viewer.editor.tool.crop', defaultMessage: 'Crop' }, + text: { id: 'image-viewer.editor.tool.text', defaultMessage: 'Text' }, + arrow: { id: 'image-viewer.editor.tool.arrow', defaultMessage: 'Arrow' }, + rectangle: { id: 'image-viewer.editor.tool.rectangle', defaultMessage: 'Rectangle' }, + ellipse: { id: 'image-viewer.editor.tool.ellipse', defaultMessage: 'Ellipse' }, + censor: { id: 'image-viewer.editor.tool.censor', defaultMessage: 'Censor' }, + eraserMode: { id: 'image-viewer.editor.eraser.mode', defaultMessage: 'Eraser mode' }, + element: { id: 'image-viewer.editor.eraser.element', defaultMessage: 'Element' }, + area: { id: 'image-viewer.editor.eraser.area', defaultMessage: 'Area' }, + cropDimensions: { + id: 'image-viewer.editor.crop.dimensions', + defaultMessage: '{width} × {height} px', + }, + resetCrop: { id: 'image-viewer.editor.crop.reset', defaultMessage: 'Reset crop' }, + colour: { id: 'image-viewer.editor.colour', defaultMessage: 'Colour' }, + width: { id: 'image-viewer.editor.width', defaultMessage: 'Width' }, + size: { id: 'image-viewer.editor.size', defaultMessage: 'Size' }, + undo: { id: 'image-viewer.editor.undo', defaultMessage: 'Undo' }, + redo: { id: 'image-viewer.editor.redo', defaultMessage: 'Redo' }, + deleteSelection: { + id: 'image-viewer.editor.delete-selection', + defaultMessage: 'Delete selected annotation', + }, + zoomIn: { id: 'image-viewer.editor.zoom-in', defaultMessage: 'Zoom in' }, + zoomOut: { id: 'image-viewer.editor.zoom-out', defaultMessage: 'Zoom out' }, + fit: { id: 'image-viewer.editor.zoom-fit', defaultMessage: 'Fit' }, + fitToWorkspace: { + id: 'image-viewer.editor.zoom-fit-workspace', + defaultMessage: 'Fit to workspace', + }, + annotationTools: { + id: 'image-viewer.editor.image-editing-tools', + defaultMessage: 'Image editing tools', + }, +}) \ No newline at end of file diff --git a/packages/ui/src/components/image-viewer-editor/image-viewer-editor-types.ts b/packages/ui/src/components/image-viewer-editor/image-viewer-editor-types.ts new file mode 100644 index 0000000..97f7c2c --- /dev/null +++ b/packages/ui/src/components/image-viewer-editor/image-viewer-editor-types.ts @@ -0,0 +1,49 @@ +export type ImageViewerEditorMode = 'view' | 'edit' + +export type ScreenshotEditorTool = + | 'select' + | 'pen' + | 'highlight' + | 'eraser' + | 'crop' + | 'text' + | 'arrow' + | 'rectangle' + | 'ellipse' + | 'censor' + +export type ScreenshotEraserMode = 'element' | 'area' + +export type ScreenshotEditorObjectKind = + | 'annotation' + | 'arrow' + | 'background' + | 'censor' + | 'ellipse' + | 'highlight' + | 'pen' + | 'rectangle' + | 'text' + +export type ScreenshotEditorPropertyKind = Exclude< + ScreenshotEditorObjectKind, + 'annotation' | 'background' +> + +export type ScreenshotEditorSourceRect = { + left: number + top: number + width: number + height: number +} + +export type ScreenshotEditorObjectState = Record & { + editorKind?: ScreenshotEditorObjectKind + sourceRect?: ScreenshotEditorSourceRect + censorColor?: string +} + +export type EditorHistoryEntry = { + objects: ScreenshotEditorObjectState[] + crop: ScreenshotEditorSourceRect +} \ No newline at end of file diff --git a/packages/ui/src/components/image-viewer-editor/index.vue b/packages/ui/src/components/image-viewer-editor/index.vue new file mode 100644 index 0000000..d2d0334 --- /dev/null +++ b/packages/ui/src/components/image-viewer-editor/index.vue @@ -0,0 +1,319 @@ + + + + \ No newline at end of file diff --git a/packages/ui/src/components/image-viewer-editor/toolbar.vue b/packages/ui/src/components/image-viewer-editor/toolbar.vue new file mode 100644 index 0000000..0d50630 --- /dev/null +++ b/packages/ui/src/components/image-viewer-editor/toolbar.vue @@ -0,0 +1,92 @@ + + + \ No newline at end of file diff --git a/packages/ui/src/components/image-viewer-editor/types.ts b/packages/ui/src/components/image-viewer-editor/types.ts new file mode 100644 index 0000000..e910675 --- /dev/null +++ b/packages/ui/src/components/image-viewer-editor/types.ts @@ -0,0 +1,29 @@ +export type ImageViewerEditorSource = { + id: string + path: string +} + +export type ImageViewerEditorItem = { + id: string + src: string + alt: string + title?: string + description?: string + editorSource?: ImageViewerEditorSource +} + +export type ImageViewerEditorData = { + source: Blob +} + +export type ImageViewerEditorMetadata = { + size: number + width: number + height: number +} + +export type ImageViewerEditorSavePayload = { + item: ImageViewerEditorItem + pngBytes: Uint8Array + mode: 'create_copy' | 'replace_edit' +} \ No newline at end of file diff --git a/packages/ui/src/components/image-viewer-editor/use-image-editor.ts b/packages/ui/src/components/image-viewer-editor/use-image-editor.ts new file mode 100644 index 0000000..055c955 --- /dev/null +++ b/packages/ui/src/components/image-viewer-editor/use-image-editor.ts @@ -0,0 +1,1700 @@ +import { computed, ref, shallowRef, watch } from 'vue' + +import { useDebugLogger } from '#ui/composables/debug-logger' + +import { renderCensorRegion } from './image-viewer-editor-censor-object' +import type { + EditorHistoryEntry, + ScreenshotEditorObjectKind, + ScreenshotEditorObjectState, + ScreenshotEditorPropertyKind, + ScreenshotEditorSourceRect, + ScreenshotEditorTool, + ScreenshotEraserMode, +} from './image-viewer-editor-types' +import type { ImageViewerEditorData } from './types' + +type FabricModule = typeof import('fabric') +type FabricCanvas = import('fabric').Canvas +type FabricObject = import('fabric').FabricObject +type FabricPoint = import('fabric').Point +type FabricPath = import('fabric').Path +type FabricPathData = Exclude[0], string> +type FabricImage = import('fabric').FabricImage +type FabricGroup = import('fabric').Group +type FabricRect = import('fabric').Rect +type FabricImageOptions = ConstructorParameters[1] +type EditorFabricObject = FabricObject & { + editorKind?: ScreenshotEditorObjectKind + fontSize?: number + sourceRect?: ScreenshotEditorSourceRect + censorColor?: string +} + +type FabricPointerEvent = { + e: MouseEvent | TouchEvent | PointerEvent + target?: FabricObject + transform?: { corner?: string } +} + +const MIN_ZOOM = 0.25 +const MAX_ZOOM = 4 +const CONTROL_SIZE = 14 +const CONTROL_TOUCH_SIZE = 24 +const MAX_RENDERED_CANVAS_PIXELS = 16_777_216 +const MIN_CROP_SIZE = 1 + +function getSelectionColor() { + if (typeof document === 'undefined') return '#ffffff' + return ( + getComputedStyle(document.documentElement).getPropertyValue('--color-brand').trim() || '#ffffff' + ) +} +const CENSOR_REGENERATED_PROPERTIES = new Set([ + 'type', + 'version', + 'src', + 'filters', + 'resizeFilter', + 'clipPath', + 'editorKind', + 'sourceRect', + 'censorColor', +]) +const TOOL_SHORTCUTS: Partial> = { + a: 'arrow', + c: 'censor', + e: 'eraser', + h: 'highlight', + k: 'crop', + o: 'ellipse', + p: 'pen', + r: 'rectangle', + t: 'text', + v: 'select', +} + +type PropertyValueKind = 'size' | 'width' + +export function useImageEditor() { + const debugEraser = useDebugLogger('ImageEditor:Eraser') + const canvas = shallowRef() + const loading = ref(false) + const tool = ref('select') + const color = ref('#ffffff') + const strokeWidth = ref(6) + const fontSize = ref(30) + const eraserMode = ref('element') + const zoom = ref(1) + const fitScale = ref(1) + const isFit = ref(true) + const originalWidth = ref(0) + const originalHeight = ref(0) + const cropRect = ref({ left: 0, top: 0, width: 0, height: 0 }) + const history = ref([]) + const historyIndex = ref(-1) + const selectedPropertyKind = ref() + const selectionCount = ref(0) + const canUndo = computed(() => historyIndex.value > 0) + const canRedo = computed(() => historyIndex.value < history.value.length - 1) + const canDelete = computed(() => selectionCount.value > 0) + const propertyValueKind = computed(() => { + const kind = selectedPropertyKind.value ?? (selectionCount.value === 0 ? tool.value : undefined) + if (kind === 'text') return 'size' + if (kind === 'eraser' && eraserMode.value === 'area') return 'width' + if ( + kind === 'pen' || + kind === 'highlight' || + kind === 'arrow' || + kind === 'rectangle' || + kind === 'ellipse' + ) { + return 'width' + } + return undefined + }) + const hasColorProperty = computed(() => { + const kind = selectedPropertyKind.value ?? (selectionCount.value === 0 ? tool.value : undefined) + return ( + kind === 'pen' || + kind === 'highlight' || + kind === 'arrow' || + kind === 'rectangle' || + kind === 'ellipse' || + kind === 'text' || + (kind === 'censor' && selectionCount.value === 0) + ) + }) + const showEraserMode = computed(() => selectionCount.value === 0 && tool.value === 'eraser') + const canZoomOut = computed(() => zoom.value > MIN_ZOOM) + const canZoomIn = computed(() => zoom.value < MAX_ZOOM) + const showCropControls = computed(() => selectionCount.value === 0 && tool.value === 'crop') + const cropWidth = computed(() => cropRect.value.width) + const cropHeight = computed(() => cropRect.value.height) + const canResetCrop = computed(() => !isFullCrop()) + + const defaultWidths: Record<'width' | 'highlight' | 'eraser', number> = { + width: 6, + highlight: 24, + eraser: 24, + } + let defaultColor = '#ffffff' + let defaultCensorColor = '#000000' + let defaultFontSize = 30 + + let fabric: FabricModule | undefined + let background: FabricImage | undefined + let cropSelection: FabricRect | undefined + let cropShade: FabricRect[] = [] + let sourceImage: HTMLImageElement | undefined + let sourceUrl: string | undefined + let drawingStart: { x: number; y: number } | undefined + let drawingObject: EditorFabricObject | undefined + let cropDrawingStart: FabricPoint | undefined + let cropDrawingPrevious: ScreenshotEditorSourceRect | undefined + let restoringHistory = false + let constructingObject = false + let propertyEditStart: string | undefined + let erasing = false + let erasedDuringGesture = false + let interactionEnabled = true + + watch(eraserMode, () => { + finishErasing() + if (tool.value === 'eraser') setTool('eraser') + }) + async function initialize( + element: HTMLCanvasElement, + editorData: ImageViewerEditorData, + viewportSize?: { width: number; height: number }, + ) { + await dispose() + loading.value = true + try { + fabric = await import('fabric') + sourceUrl = URL.createObjectURL(editorData.source) + sourceImage = await loadImage(sourceUrl) + originalWidth.value = sourceImage.naturalWidth + originalHeight.value = sourceImage.naturalHeight + + const nextCanvas = new fabric.Canvas(element, { + width: originalWidth.value, + height: originalHeight.value, + enableRetinaScaling: false, + backgroundColor: '#111827', + preserveObjectStacking: true, + selection: true, + }) + canvas.value = nextCanvas + if (viewportSize) fitToViewport(viewportSize.width, viewportSize.height) + background = new fabric.FabricImage(sourceImage, { + left: 0, + top: 0, + originX: 'left', + originY: 'top', + width: originalWidth.value, + height: originalHeight.value, + selectable: false, + evented: false, + excludeFromExport: false, + }) + setEditorMetadata(background, 'background') + nextCanvas.add(background) + nextCanvas.sendObjectToBack(background) + createCropUi(nextCanvas) + bindCanvasEvents(nextCanvas) + resetHistory() + setTool('select') + } finally { + loading.value = false + } + } + + function bindCanvasEvents(editorCanvas: FabricCanvas) { + editorCanvas.on('mouse:down', handleMouseDown) + editorCanvas.on('mouse:move', handleMouseMove) + editorCanvas.on('mouse:up', handleMouseUp) + editorCanvas.on('object:added', () => { + if (!constructingObject && !restoringHistory && !editorCanvas.isDrawingMode) { + recordHistory() + } + keepCropUiOnTop() + }) + editorCanvas.on('path:created', ({ path }) => { + if (tool.value === 'eraser' && eraserMode.value === 'area') { + void eraseAreaWithPath(path).catch((error) => { + debugEraser('area:error', error) + }) + return + } + setEditorMetadata(path, tool.value === 'highlight' ? 'highlight' : 'pen') + styleObjectControls(path) + recordHistory() + syncSelectionProperties() + }) + editorCanvas.on('object:moving', ({ target }) => { + if (target === cropSelection) { + constrainCropMove() + syncCropFromSelection() + } + }) + editorCanvas.on('object:scaling', ({ target }) => { + if (target === cropSelection) syncCropFromSelection() + }) + editorCanvas.on('object:modified', ({ target }) => { + if (target === cropSelection) { + normalizeCropSelection() + recordHistory() + return + } + if (target) editorCanvas.requestRenderAll() + recordHistory() + syncSelectionProperties() + }) + editorCanvas.on('object:removed', () => { + if (!constructingObject && !restoringHistory) recordHistory() + }) + editorCanvas.on('selection:created', syncSelectionProperties) + editorCanvas.on('selection:updated', syncSelectionProperties) + editorCanvas.on('selection:cleared', syncSelectionProperties) + editorCanvas.on('text:editing:exited', () => { + recordHistory() + syncSelectionProperties() + }) + } + + function createCropUi(editorCanvas: FabricCanvas) { + const fabricModule = fabric + if (!fabricModule) return + cropRect.value = fullCropRect() + cropShade = Array.from( + { length: 4 }, + () => + new fabricModule.Rect({ + left: 0, + top: 0, + originX: 'left', + originY: 'top', + width: 0, + height: 0, + fill: 'rgba(0, 0, 0, 0.55)', + strokeWidth: 0, + selectable: false, + evented: false, + excludeFromExport: true, + visible: false, + }), + ) + cropSelection = new fabricModule.Rect({ + ...cropRect.value, + originX: 'left', + originY: 'top', + fill: 'transparent', + stroke: getSelectionColor(), + strokeUniform: true, + strokeWidth: 2, + lockRotation: true, + lockScalingFlip: true, + selectable: false, + evented: false, + excludeFromExport: true, + perPixelTargetFind: true, + visible: false, + }) + styleCropControls() + editorCanvas.add(...cropShade, cropSelection) + updateCropUi() + } + + function fullCropRect(): ScreenshotEditorSourceRect { + return { + left: 0, + top: 0, + width: originalWidth.value, + height: originalHeight.value, + } + } + + function isFullCrop() { + const rect = cropRect.value + return ( + rect.left === 0 && + rect.top === 0 && + rect.width === originalWidth.value && + rect.height === originalHeight.value + ) + } + + function updateCropUi() { + if (!cropSelection || cropShade.length !== 4) return + const rect = cropRect.value + const right = rect.left + rect.width + const bottom = rect.top + rect.height + cropSelection.set({ + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + scaleX: 1, + scaleY: 1, + }) + cropShade[0].set({ left: 0, top: 0, width: originalWidth.value, height: rect.top }) + cropShade[1].set({ left: 0, top: rect.top, width: rect.left, height: rect.height }) + cropShade[2].set({ + left: right, + top: rect.top, + width: originalWidth.value - right, + height: rect.height, + }) + cropShade[3].set({ + left: 0, + top: bottom, + width: originalWidth.value, + height: originalHeight.value - bottom, + }) + const cropToolActive = interactionEnabled && tool.value === 'crop' + const hasCropBounds = !isFullCrop() + const shadeVisible = interactionEnabled && hasCropBounds + for (const shade of cropShade) { + shade.visible = shadeVisible + shade.setCoords() + } + cropSelection.set({ + visible: cropToolActive && hasCropBounds, + selectable: cropToolActive && hasCropBounds, + evented: cropToolActive && hasCropBounds, + }) + cropSelection.setCoords() + keepCropUiOnTop() + canvas.value?.requestRenderAll() + } + + function keepCropUiOnTop() { + const editorCanvas = canvas.value + if (!editorCanvas || !cropSelection) return + for (const shade of cropShade) editorCanvas.bringObjectToFront(shade) + editorCanvas.bringObjectToFront(cropSelection) + } + + function cropSelectionRect(): ScreenshotEditorSourceRect | undefined { + if (!cropSelection) return undefined + return { + left: cropSelection.left, + top: cropSelection.top, + width: cropSelection.width * cropSelection.scaleX, + height: cropSelection.height * cropSelection.scaleY, + } + } + + function sanitizeCropRect(rect: ScreenshotEditorSourceRect): ScreenshotEditorSourceRect { + const left = Math.min(originalWidth.value - MIN_CROP_SIZE, Math.max(0, Math.round(rect.left))) + const top = Math.min(originalHeight.value - MIN_CROP_SIZE, Math.max(0, Math.round(rect.top))) + const right = Math.min( + originalWidth.value, + Math.max(left + MIN_CROP_SIZE, Math.round(rect.left + rect.width)), + ) + const bottom = Math.min( + originalHeight.value, + Math.max(top + MIN_CROP_SIZE, Math.round(rect.top + rect.height)), + ) + return { left, top, width: right - left, height: bottom - top } + } + + function syncCropFromSelection() { + const rect = cropSelectionRect() + if (!rect) return + cropRect.value = sanitizeCropRect(rect) + const nextRect = cropRect.value + const right = nextRect.left + nextRect.width + const bottom = nextRect.top + nextRect.height + cropShade[0]?.set({ height: nextRect.top }) + cropShade[1]?.set({ top: nextRect.top, width: nextRect.left, height: nextRect.height }) + cropShade[2]?.set({ + left: right, + top: nextRect.top, + width: originalWidth.value - right, + height: nextRect.height, + }) + cropShade[3]?.set({ + top: bottom, + height: originalHeight.value - bottom, + }) + canvas.value?.requestRenderAll() + } + + function normalizeCropSelection() { + const rect = cropSelectionRect() + if (!rect) return + cropRect.value = sanitizeCropRect(rect) + updateCropUi() + updateActiveCropSelection() + } + + function constrainCropMove() { + if (!cropSelection) return + const width = cropSelection.width * cropSelection.scaleX + const height = cropSelection.height * cropSelection.scaleY + cropSelection.set({ + left: Math.min(originalWidth.value - width, Math.max(0, cropSelection.left)), + top: Math.min(originalHeight.value - height, Math.max(0, cropSelection.top)), + }) + } + + function setCropRect(nextRect: ScreenshotEditorSourceRect) { + cropRect.value = sanitizeCropRect(nextRect) + updateCropUi() + } + + function resetCrop() { + if (isFullCrop()) return + setCropRect(fullCropRect()) + updateActiveCropSelection() + recordHistory() + } + function updateActiveCropSelection() { + const editorCanvas = canvas.value + if (!editorCanvas || !cropSelection) return + if (tool.value !== 'crop' || isFullCrop()) { + if (editorCanvas.getActiveObject() === cropSelection) editorCanvas.discardActiveObject() + return + } + editorCanvas.setActiveObject(cropSelection) + } + + function styleCropControls() { + if (!cropSelection) return + styleObjectControls(cropSelection) + cropSelection.set({ + lockRotation: true, + strokeWidth: 2 / Math.max(zoom.value, 0.01), + }) + cropSelection.setControlsVisibility({ mtr: false }) + cropSelection.setCoords() + } + + function setTool(nextTool: ScreenshotEditorTool) { + if (nextTool !== 'eraser') finishErasing() + if (nextTool !== 'crop') cancelCropDrawing() + tool.value = nextTool + const editorCanvas = canvas.value + if (!editorCanvas || !fabric) return + const activeObject = editorCanvas.getActiveObject() as + (EditorFabricObject & { isEditing?: boolean; exitEditing?: () => void }) | undefined + if (activeObject?.isEditing && nextTool !== 'text') activeObject.exitEditing?.() + + editorCanvas.isDrawingMode = + nextTool === 'pen' || + nextTool === 'highlight' || + (nextTool === 'eraser' && eraserMode.value === 'area') + editorCanvas.selection = nextTool === 'select' + for (const object of annotationObjects()) { + object.selectable = nextTool === 'select' + object.evented = + nextTool === 'select' || (nextTool === 'eraser' && eraserMode.value === 'element') + styleObjectControls(object) + } + if (nextTool !== 'select') editorCanvas.discardActiveObject() + updateCropUi() + updateActiveCropSelection() + syncSelectionProperties() + configureCursor(editorCanvas, nextTool) + configureBrush(editorCanvas) + editorCanvas.requestRenderAll() + } + + function refreshToolSettings() { + const editorCanvas = canvas.value + if (!editorCanvas) return + configureBrush(editorCanvas) + editorCanvas.requestRenderAll() + } + + function configureBrush(editorCanvas: FabricCanvas) { + if (!fabric || !editorCanvas.isDrawingMode) return + const brush = + tool.value === 'eraser' + ? createAreaEraserBrush(editorCanvas, fabric) + : new fabric.PencilBrush(editorCanvas) + brush.color = + tool.value === 'eraser' + ? 'rgba(255, 255, 255, 0.45)' + : tool.value === 'highlight' + ? hexToRgba(color.value, 0.35) + : color.value + brush.width = strokeWidth.value + editorCanvas.freeDrawingBrush = brush + } + + function createAreaEraserBrush(editorCanvas: FabricCanvas, fabricModule: FabricModule) { + const editorSourceImage = sourceImage + return new (class extends fabricModule.PencilBrush { + override _setBrushStyles(context: CanvasRenderingContext2D) { + super._setBrushStyles(context) + const backgroundPattern = editorSourceImage + ? context.createPattern(editorSourceImage, 'no-repeat') + : null + if (backgroundPattern) context.strokeStyle = backgroundPattern + } + })(editorCanvas) + } + + function configureCursor(editorCanvas: FabricCanvas, nextTool: ScreenshotEditorTool) { + const hasBrushCursor = + nextTool === 'pen' || + nextTool === 'highlight' || + (nextTool === 'eraser' && eraserMode.value === 'area') + const cursor = hasBrushCursor + ? 'none' + : nextTool === 'select' + ? 'default' + : nextTool === 'text' + ? 'text' + : 'crosshair' + editorCanvas.defaultCursor = cursor + editorCanvas.freeDrawingCursor = cursor + editorCanvas.hoverCursor = nextTool === 'select' ? 'move' : cursor + editorCanvas.moveCursor = nextTool === 'select' ? 'move' : cursor + if (cropSelection) cropSelection.hoverCursor = nextTool === 'crop' ? 'move' : cursor + } + + function syncSelectionProperties() { + commitPropertyEdit() + const activeObjects = (canvas.value?.getActiveObjects() ?? []).filter( + isAnnotationObject, + ) as EditorFabricObject[] + selectionCount.value = activeObjects.length + const selected = activeObjects.length === 1 ? activeObjects[0] : undefined + selectedPropertyKind.value = isPropertyKind(selected?.editorKind) + ? selected.editorKind + : undefined + + if (selected && selectedPropertyKind.value) { + const selectedColor = getObjectColor(selected, selectedPropertyKind.value) + if (selectedColor) color.value = selectedColor + if (selectedPropertyKind.value === 'text') { + fontSize.value = Math.round(selected.fontSize ?? defaultFontSize) + } else { + const selectedWidth = getObjectStrokeWidth(selected, selectedPropertyKind.value) + if (selectedWidth) strokeWidth.value = Math.round(selectedWidth) + } + return + } + + if (activeObjects.length > 0) return + color.value = tool.value === 'censor' ? defaultCensorColor : defaultColor + if (tool.value === 'text') { + fontSize.value = defaultFontSize + } else if (tool.value === 'highlight') { + strokeWidth.value = defaultWidths.highlight + } else if (tool.value === 'eraser') { + strokeWidth.value = defaultWidths.eraser + } else { + strokeWidth.value = defaultWidths.width + } + } + function updateColor(nextColor: string) { + if (selectionCount.value === 1) beginPropertyEdit() + color.value = nextColor + const selected = singleSelectedObject() + const kind = selected && isPropertyKind(selected.editorKind) ? selected.editorKind : undefined + if (selected && kind && kind !== 'censor') { + setObjectColor(selected, kind, nextColor) + } else if (selectionCount.value === 0) { + if (tool.value === 'censor') defaultCensorColor = nextColor + else defaultColor = nextColor + refreshToolSettings() + } + } + + function updateStrokeWidth(nextWidth: number) { + if (selectionCount.value === 1) beginPropertyEdit() + strokeWidth.value = nextWidth + const selected = singleSelectedObject() + const kind = selected && isPropertyKind(selected.editorKind) ? selected.editorKind : undefined + if (selected && kind && kind !== 'censor' && kind !== 'text') { + setObjectStrokeWidth(selected, kind, nextWidth) + } else if (selectionCount.value === 0) { + if (tool.value === 'highlight') defaultWidths.highlight = nextWidth + else if (tool.value === 'eraser') defaultWidths.eraser = nextWidth + else defaultWidths.width = nextWidth + refreshToolSettings() + } + } + + function updateFontSize(nextSize: number) { + if (selectionCount.value === 1) beginPropertyEdit() + fontSize.value = nextSize + const selected = singleSelectedObject() + if (selected?.editorKind === 'text') { + selected.set({ fontSize: nextSize }) + selected.setCoords() + selected.dirty = true + canvas.value?.requestRenderAll() + } else if (selectionCount.value === 0) { + defaultFontSize = nextSize + } + } + + function singleSelectedObject() { + const activeObjects = (canvas.value?.getActiveObjects() ?? []).filter( + isAnnotationObject, + ) as EditorFabricObject[] + return activeObjects.length === 1 ? activeObjects[0] : undefined + } + + function getObjectColor(object: EditorFabricObject, kind: ScreenshotEditorPropertyKind) { + if (kind === 'arrow') { + const [line] = (object as FabricGroup).getObjects() + return colorToHex(line?.stroke) + } + return colorToHex(kind === 'text' ? object.fill : object.stroke) + } + + function getObjectStrokeWidth(object: EditorFabricObject, kind: ScreenshotEditorPropertyKind) { + if (kind === 'arrow') { + const [line] = (object as FabricGroup).getObjects() + return line?.strokeWidth + } + return object.strokeWidth + } + + function setObjectColor( + object: EditorFabricObject, + kind: ScreenshotEditorPropertyKind, + nextColor: string, + ) { + if (kind === 'arrow') { + const [line, head] = (object as FabricGroup).getObjects() + line?.set({ stroke: nextColor }) + head?.set({ fill: nextColor }) + } else if (kind === 'text') { + object.set({ fill: nextColor }) + } else { + object.set({ stroke: kind === 'highlight' ? hexToRgba(nextColor, 0.35) : nextColor }) + } + object.dirty = true + object.setCoords() + canvas.value?.requestRenderAll() + } + + function setObjectStrokeWidth( + object: EditorFabricObject, + kind: ScreenshotEditorPropertyKind, + nextWidth: number, + ) { + if (kind === 'arrow') { + const group = object as FabricGroup + const [line, head] = group.getObjects() + const linePosition = line?.getXY() + line?.set({ strokeWidth: nextWidth }) + head?.set({ width: nextWidth * 4, height: nextWidth * 5 }) + group.triggerLayout({ deep: true }) + if (line && linePosition) { + const nextLinePosition = line.getXY() + group.set({ + left: group.left + linePosition.x - nextLinePosition.x, + top: group.top + linePosition.y - nextLinePosition.y, + }) + } + } else { + object.set({ strokeWidth: nextWidth }) + } + object.dirty = true + object.setCoords() + canvas.value?.requestRenderAll() + } + + function beginErasing(point: FabricPoint) { + erasing = true + erasedDuringGesture = false + constructingObject = true + eraseAtPoint(point) + } + + function eraseAtPoint(point: FabricPoint) { + const editorCanvas = canvas.value + if (!editorCanvas) return + const { target } = editorCanvas.searchPossibleTargets(annotationObjects(), point) + if (!target || target === background) return + editorCanvas.remove(target) + erasedDuringGesture = true + editorCanvas.requestRenderAll() + } + + function finishErasing() { + if (!erasing) return + erasing = false + constructingObject = false + if (erasedDuringGesture) recordHistory() + erasedDuringGesture = false + syncSelectionProperties() + canvas.value?.requestRenderAll() + } + + async function eraseAreaWithPath(eraserPath: FabricPath) { + const editorCanvas = canvas.value + const fabricModule = fabric + if (!editorCanvas || !fabricModule) return + + constructingObject = true + const eraserBounds = eraserPath.getBoundingRect() + const eraserMask = createAreaEraserMask(eraserPath, fabricModule) + editorCanvas.remove(eraserPath) + const targets = annotationObjects().filter((object) => + boundingRectsOverlap(eraserBounds, object.getBoundingRect()), + ) + + try { + await Promise.all( + targets.map(async (object) => { + const clipPath = await eraserMask.clone() + fabricModule.util.sendObjectToPlane(clipPath, undefined, object.calcTransformMatrix()) + const nextClipPath = object.clipPath + ? fabricModule.util.mergeClipPaths(object.clipPath, clipPath) + : clipPath + object.set('clipPath', nextClipPath) + }), + ) + } finally { + constructingObject = false + } + + if (targets.length > 0) recordHistory() + editorCanvas.requestRenderAll() + } + + function createAreaEraserMask(eraserPath: FabricPath, fabricModule: FabricModule) { + const points = samplePathPoints(eraserPath, fabricModule) + const width = Math.max(1, eraserPath.strokeWidth) + const radius = width / 2 + const maskPath: FabricPathData = [] + + if (points.length === 1) { + const point = points[0] + maskPath.push( + ['M', point.x + radius, point.y], + ['A', radius, radius, 0, 1, 0, point.x - radius, point.y], + ['A', radius, radius, 0, 1, 0, point.x + radius, point.y], + ['Z'], + ) + } else { + for (let index = 1; index < points.length; index++) { + const start = points[index - 1] + const end = points[index] + const deltaX = end.x - start.x + const deltaY = end.y - start.y + const length = Math.hypot(deltaX, deltaY) + if (length === 0) continue + const normalX = (-deltaY / length) * radius + const normalY = (deltaX / length) * radius + maskPath.push( + ['M', start.x + normalX, start.y + normalY], + ['L', end.x + normalX, end.y + normalY], + ['A', radius, radius, 0, 0, 1, end.x - normalX, end.y - normalY], + ['L', start.x - normalX, start.y - normalY], + ['A', radius, radius, 0, 0, 1, start.x + normalX, start.y + normalY], + ['Z'], + ) + } + } + + const brushMask = new fabricModule.Path(maskPath, { + absolutePositioned: false, + fill: '#000000', + inverted: true, + opacity: 1, + strokeWidth: 0, + }) + const outerMask = new fabricModule.Rect({ + left: 0, + top: 0, + originX: 'left', + originY: 'top', + width: originalWidth.value, + height: originalHeight.value, + fill: '#000000', + strokeWidth: 0, + }) + fabricModule.util.sendObjectToPlane(brushMask, undefined, outerMask.calcTransformMatrix()) + outerMask.set('clipPath', brushMask) + return outerMask + } + + function samplePathPoints(eraserPath: FabricPath, fabricModule: FabricModule) { + const sampled: FabricPoint[] = [] + const transform = eraserPath.calcTransformMatrix() + const pathOffset = eraserPath.pathOffset + const spacing = Math.max(2, eraserPath.strokeWidth / 3) + let current = { x: 0, y: 0 } + + const addPoint = (x: number, y: number) => { + const point = new fabricModule.Point(x - pathOffset.x, y - pathOffset.y).transform(transform) + const previous = sampled.at(-1) + if (!previous || !previous.eq(point)) sampled.push(point) + } + const addLine = (end: { x: number; y: number }) => { + const steps = Math.max( + 1, + Math.ceil(Math.hypot(end.x - current.x, end.y - current.y) / spacing), + ) + for (let step = 1; step <= steps; step++) { + const progress = step / steps + addPoint( + current.x + (end.x - current.x) * progress, + current.y + (end.y - current.y) * progress, + ) + } + current = end + } + + for (const pathCommand of eraserPath.path) { + const command = pathCommand[0] + if (command === 'M') { + current = { x: Number(pathCommand[1]), y: Number(pathCommand[2]) } + addPoint(current.x, current.y) + } else if (command === 'L') { + addLine({ x: Number(pathCommand[1]), y: Number(pathCommand[2]) }) + } else if (command === 'Q') { + const start = current + const control = { x: Number(pathCommand[1]), y: Number(pathCommand[2]) } + const end = { x: Number(pathCommand[3]), y: Number(pathCommand[4]) } + const estimatedLength = + Math.hypot(control.x - start.x, control.y - start.y) + + Math.hypot(end.x - control.x, end.y - control.y) + const steps = Math.max(2, Math.ceil(estimatedLength / spacing)) + for (let step = 1; step <= steps; step++) { + const progress = step / steps + const inverse = 1 - progress + addPoint( + inverse * inverse * start.x + + 2 * inverse * progress * control.x + + progress * progress * end.x, + inverse * inverse * start.y + + 2 * inverse * progress * control.y + + progress * progress * end.y, + ) + } + current = end + } + } + + if (sampled.length === 0) addPoint(0, 0) + return sampled + } + function handleMouseDown(event: FabricPointerEvent) { + const editorCanvas = canvas.value + if (!editorCanvas || !fabric || editorCanvas.isDrawingMode || tool.value === 'select') return + const point = editorCanvas.getScenePoint(event.e) + if (tool.value === 'crop') { + if ( + event.target === cropSelection && + (event.transform?.corner || isPointNearCropBorder(point)) + ) { + return + } + editorCanvas._currentTransform = null + cropDrawingStart = point + cropDrawingPrevious = { ...cropRect.value } + constructingObject = true + setCropRect({ left: point.x, top: point.y, width: 1, height: 1 }) + return + } + if (tool.value === 'eraser' && eraserMode.value === 'element') { + beginErasing(point) + return + } + + if (tool.value === 'text') { + constructingObject = true + const text = new fabric.IText('Text', { + left: point.x, + top: point.y, + originX: 'left', + originY: 'top', + fill: color.value, + fontFamily: 'Inter, sans-serif', + fontSize: fontSize.value, + }) + setEditorMetadata(text, 'text') + styleObjectControls(text) + editorCanvas.add(text) + editorCanvas.setActiveObject(text) + constructingObject = false + text.enterEditing() + text.selectAll() + editorCanvas.requestRenderAll() + return + } + + drawingStart = point + constructingObject = true + if (tool.value === 'rectangle' || tool.value === 'censor') { + drawingObject = new fabric.Rect({ + left: point.x, + top: point.y, + originX: 'left', + originY: 'top', + width: 1, + height: 1, + fill: 'transparent', + stroke: tool.value === 'censor' ? '#ffffff' : color.value, + strokeDashArray: tool.value === 'censor' ? [10, 8] : undefined, + strokeWidth: tool.value === 'censor' ? 2 : strokeWidth.value, + selectable: false, + evented: false, + }) + } else if (tool.value === 'ellipse') { + drawingObject = new fabric.Ellipse({ + left: point.x, + top: point.y, + originX: 'left', + originY: 'top', + rx: 1, + ry: 1, + fill: 'transparent', + stroke: color.value, + strokeWidth: strokeWidth.value, + selectable: false, + evented: false, + }) + } else if (tool.value === 'arrow') { + drawingObject = new fabric.Line([point.x, point.y, point.x, point.y], { + stroke: color.value, + strokeWidth: strokeWidth.value, + selectable: false, + evented: false, + }) + } + if (drawingObject) { + styleObjectControls(drawingObject) + editorCanvas.add(drawingObject) + } + } + + function isPointNearCropBorder(point: FabricPoint) { + const rect = cropRect.value + const tolerance = 10 / Math.max(zoom.value, 0.01) + return ( + Math.abs(point.x - rect.left) <= tolerance || + Math.abs(point.x - (rect.left + rect.width)) <= tolerance || + Math.abs(point.y - rect.top) <= tolerance || + Math.abs(point.y - (rect.top + rect.height)) <= tolerance + ) + } + + function handleMouseMove(event: FabricPointerEvent) { + const editorCanvas = canvas.value + if (!editorCanvas) return + if (cropDrawingStart) { + const rect = normalizedRect(cropDrawingStart, editorCanvas.getScenePoint(event.e)) + setCropRect(rect) + return + } + if (tool.value === 'eraser' && eraserMode.value === 'element') { + if (erasing) eraseAtPoint(editorCanvas.getScenePoint(event.e)) + return + } + if (!drawingStart || !drawingObject) return + const point = editorCanvas.getScenePoint(event.e) + const rect = normalizedRect(drawingStart, point) + + if (tool.value === 'ellipse') { + drawingObject.set({ + left: rect.left, + top: rect.top, + rx: rect.width / 2, + ry: rect.height / 2, + }) + } else if (tool.value === 'arrow') { + drawingObject.set({ x2: point.x, y2: point.y }) + } else { + drawingObject.set(rect) + } + drawingObject.setCoords() + editorCanvas.requestRenderAll() + } + + function handleMouseUp(event: FabricPointerEvent) { + const editorCanvas = canvas.value + if (!editorCanvas) return + if (cropDrawingStart) { + const rect = normalizedRect(cropDrawingStart, editorCanvas.getScenePoint(event.e)) + const previous = cropDrawingPrevious + cropDrawingStart = undefined + cropDrawingPrevious = undefined + constructingObject = false + if (rect.width < 2 || rect.height < 2) { + if (previous) setCropRect(previous) + } else { + setCropRect(rect) + recordHistory() + } + updateActiveCropSelection() + editorCanvas.requestRenderAll() + return + } + if (erasing) { + eraseAtPoint(editorCanvas.getScenePoint(event.e)) + finishErasing() + return + } + if (!fabric || !drawingStart || !drawingObject) return + const start = drawingStart + const point = editorCanvas.getScenePoint(event.e) + const rect = normalizedRect(start, point) + const preview = drawingObject + drawingStart = undefined + drawingObject = undefined + + const isTooSmall = + tool.value === 'arrow' + ? Math.hypot(point.x - start.x, point.y - start.y) < 2 + : rect.width < 2 || rect.height < 2 + if (isTooSmall) { + editorCanvas.remove(preview) + constructingObject = false + editorCanvas.requestRenderAll() + return + } + + if (tool.value === 'censor') { + editorCanvas.remove(preview) + const censorCanvas = renderCensorRegion(rect, color.value) + const censor = new fabric.FabricImage(censorCanvas, { + left: rect.left, + top: rect.top, + originX: 'left', + originY: 'top', + selectable: false, + evented: false, + }) + setEditorMetadata(censor, 'censor', rect) + const editorCensor = censor as EditorFabricObject + editorCensor.censorColor = color.value + styleObjectControls(censor) + const censorCount = annotationObjects().filter( + (object) => object.editorKind === 'censor', + ).length + editorCanvas.insertAt(1 + censorCount, censor) + } else if (tool.value === 'arrow') { + editorCanvas.remove(preview) + const line = new fabric.Line([start.x, start.y, point.x, point.y], { + stroke: color.value, + strokeWidth: strokeWidth.value, + }) + const angle = (Math.atan2(point.y - start.y, point.x - start.x) * 180) / Math.PI + 90 + const head = new fabric.Triangle({ + left: point.x, + top: point.y, + originX: 'center', + originY: 'center', + width: strokeWidth.value * 4, + height: strokeWidth.value * 5, + fill: color.value, + angle, + }) + const arrow = new fabric.Group([line, head]) + setEditorMetadata(arrow, 'arrow') + styleObjectControls(arrow) + editorCanvas.add(arrow) + } else { + setEditorMetadata(preview, tool.value === 'ellipse' ? 'ellipse' : 'rectangle') + styleObjectControls(preview) + } + + constructingObject = false + setTool(tool.value) + recordHistory() + editorCanvas.requestRenderAll() + } + function cancelCropDrawing() { + if (!cropDrawingStart) return + if (cropDrawingPrevious) setCropRect(cropDrawingPrevious) + cropDrawingStart = undefined + cropDrawingPrevious = undefined + constructingObject = false + } + + function deleteSelection() { + const editorCanvas = canvas.value + if (!editorCanvas) return false + const active = editorCanvas.getActiveObjects().filter(isAnnotationObject) + if (active.length === 0) return false + editorCanvas.discardActiveObject() + constructingObject = true + for (const object of active) editorCanvas.remove(object) + constructingObject = false + recordHistory() + syncSelectionProperties() + editorCanvas.requestRenderAll() + return true + } + + function recordHistory() { + if (restoringHistory || constructingObject || !canvas.value) return + const entry = snapshot() + const nextHistory = history.value.slice(0, historyIndex.value + 1) + const currentEntry = nextHistory.at(-1) + if (currentEntry && serializeSnapshot(currentEntry) === serializeSnapshot(entry)) return + nextHistory.push(entry) + history.value = nextHistory + historyIndex.value = nextHistory.length - 1 + } + + function beginPropertyEdit() { + propertyEditStart ??= serializeSnapshot(snapshot()) + } + + function commitPropertyEdit() { + if (!propertyEditStart) return + const hasChanged = propertyEditStart !== serializeSnapshot(snapshot()) + propertyEditStart = undefined + if (hasChanged) recordHistory() + } + + function resetHistory() { + history.value = [snapshot()] + historyIndex.value = 0 + } + + function snapshot(): EditorHistoryEntry { + return { + crop: { ...cropRect.value }, + objects: annotationObjects().map((object) => { + const state = object.toObject([ + 'editorKind', + 'sourceRect', + 'censorColor', + ]) as ScreenshotEditorObjectState + if (object.editorKind === 'censor') delete state.src + return state + }), + } + } + + async function enlivenEditorObjects(states: ScreenshotEditorObjectState[]) { + const fabricModule = fabric + if (!fabricModule) return [] + const enlivenObjects = fabricModule.util.enlivenObjects as unknown as ( + objects: ScreenshotEditorObjectState[], + ) => Promise + + return await Promise.all( + states.map(async (state) => { + if (state.editorKind !== 'censor') { + const [object] = await enlivenObjects([state]) + if (!object) throw new Error('Could not restore screenshot annotation') + return object + } + + if (!isSourceRect(state.sourceRect)) { + throw new Error('Could not restore screenshot censor region') + } + const restoredColor = typeof state.censorColor === 'string' ? state.censorColor : '#000000' + const serializedClipPath = + state.clipPath && typeof state.clipPath === 'object' + ? (state.clipPath as ScreenshotEditorObjectState) + : undefined + const censorCanvas = renderCensorRegion(state.sourceRect, restoredColor) + const objectOptions = Object.fromEntries( + Object.entries(state).filter( + ([property]) => !CENSOR_REGENERATED_PROPERTIES.has(property), + ), + ) as ScreenshotEditorObjectState + const censor = new fabricModule.FabricImage(censorCanvas, { + ...objectOptions, + } as unknown as FabricImageOptions) as EditorFabricObject + setEditorMetadata(censor, 'censor', state.sourceRect) + censor.censorColor = restoredColor + if (serializedClipPath) { + const [clipPath] = await enlivenObjects([serializedClipPath]) + if (clipPath) censor.clipPath = clipPath + } + return censor + }), + ) + } + + async function restoreHistory(index: number) { + const editorCanvas = canvas.value + const entry = history.value[index] + if (!editorCanvas || !entry || !fabric) return + restoringHistory = true + try { + editorCanvas.discardActiveObject() + editorCanvas.remove(...annotationObjects()) + setCropRect(entry.crop) + const restored = await enlivenEditorObjects(entry.objects) + for (const object of restored) { + styleObjectControls(object) + editorCanvas.add(object) + } + historyIndex.value = index + setTool(tool.value) + syncSelectionProperties() + editorCanvas.requestRenderAll() + } finally { + restoringHistory = false + } + } + + async function undo() { + if (canUndo.value) await restoreHistory(historyIndex.value - 1) + } + + async function redo() { + if (canRedo.value) await restoreHistory(historyIndex.value + 1) + } + + async function discardChanges() { + if (history.value[0]) await restoreHistory(0) + resetHistory() + } + + function setInteractionEnabled(enabled: boolean) { + const editorCanvas = canvas.value + if (!editorCanvas) return + interactionEnabled = enabled + if (enabled) { + editorCanvas.skipTargetFind = false + setTool(tool.value) + return + } + + setTool('select') + editorCanvas.discardActiveObject() + editorCanvas.isDrawingMode = false + editorCanvas.selection = false + editorCanvas.skipTargetFind = true + for (const object of annotationObjects()) { + object.selectable = false + object.evented = false + } + updateCropUi() + editorCanvas.defaultCursor = 'default' + editorCanvas.hoverCursor = 'default' + editorCanvas.moveCursor = 'default' + syncSelectionProperties() + editorCanvas.requestRenderAll() + } + + function fitToViewport(width: number, height: number) { + if (!canvas.value || !originalWidth.value || !originalHeight.value) return + fitScale.value = Math.min( + Math.max(1, width) / originalWidth.value, + Math.max(1, height) / originalHeight.value, + ) + if (isFit.value) { + zoom.value = fitScale.value + applyDisplayScale() + } + } + + function setZoom(nextZoom: number) { + isFit.value = false + zoom.value = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, nextZoom)) + applyDisplayScale() + } + + function setFit() { + isFit.value = true + zoom.value = fitScale.value + applyDisplayScale() + } + + function waitForRender() { + const editorCanvas = canvas.value + if (!editorCanvas) return Promise.resolve() + + return new Promise((resolve) => { + editorCanvas.once('after:render', () => resolve()) + editorCanvas.requestRenderAll() + }) + } + + function applyDisplayScale() { + const editorCanvas = canvas.value + if (!editorCanvas) return + const displayScale = zoom.value + const maximumRenderScale = Math.sqrt( + MAX_RENDERED_CANVAS_PIXELS / (originalWidth.value * originalHeight.value), + ) + const renderScale = Math.min(displayScale, maximumRenderScale) + editorCanvas.setDimensions( + { + width: Math.max(1, Math.round(originalWidth.value * renderScale)), + height: Math.max(1, Math.round(originalHeight.value * renderScale)), + }, + { backstoreOnly: true }, + ) + editorCanvas.setDimensions( + { + width: Math.max(1, Math.round(originalWidth.value * displayScale)), + height: Math.max(1, Math.round(originalHeight.value * displayScale)), + }, + { cssOnly: true }, + ) + editorCanvas.setViewportTransform([renderScale, 0, 0, renderScale, 0, 0]) + for (const object of annotationObjects()) styleObjectControls(object) + styleCropControls() + editorCanvas.calcOffset() + editorCanvas.requestRenderAll() + } + + function styleObjectControls(object: FabricObject) { + const displayScale = Math.max(zoom.value, 0.01) + const renderScale = Math.max(canvas.value?.getZoom() ?? 1, 0.01) + const controlScale = renderScale / displayScale + object.set({ + borderColor: getSelectionColor(), + borderScaleFactor: 2 * controlScale, + cornerColor: getSelectionColor(), + cornerSize: CONTROL_SIZE * controlScale, + cornerStrokeColor: '#ffffff', + cornerStyle: 'circle', + padding: 3 * controlScale, + touchCornerSize: CONTROL_TOUCH_SIZE * controlScale, + transparentCorners: false, + }) + object.setControlsVisibility({ + mb: true, + ml: true, + mr: true, + mt: true, + mtr: true, + }) + if (fabric) { + for (const key of ['ml', 'mr'] as const) { + object.controls[key].actionHandler = fabric.controlsUtils.scalingX + object.controls[key].cursorStyleHandler = fabric.controlsUtils.scaleCursorStyleHandler + object.controls[key].getActionName = () => 'scaleX' + } + for (const key of ['mt', 'mb'] as const) { + object.controls[key].actionHandler = fabric.controlsUtils.scalingY + object.controls[key].cursorStyleHandler = fabric.controlsUtils.scaleCursorStyleHandler + object.controls[key].getActionName = () => 'scaleY' + } + } + if (object.controls.mtr) { + object.controls.mtr.offsetY = -28 * controlScale + object.controls.mtr.sizeX = 10 * controlScale + object.controls.mtr.sizeY = 10 * controlScale + } + object.setCoords() + } + + async function exportPng() { + const editorCanvas = canvas.value + if (!editorCanvas) throw new Error('Screenshot editor is not ready') + const activeObject = editorCanvas.getActiveObject() + editorCanvas.discardActiveObject() + for (const shade of cropShade) shade.visible = false + if (cropSelection) cropSelection.visible = false + editorCanvas.requestRenderAll() + const crop = cropRect.value + let blob: Blob | null = null + try { + const displayViewportTransform = editorCanvas.viewportTransform + const blobPromise = (() => { + editorCanvas.setViewportTransform([1, 0, 0, 1, 0, 0]) + try { + return editorCanvas.toBlob({ + format: 'png', + multiplier: 1, + left: crop.left, + top: crop.top, + width: crop.width, + height: crop.height, + }) + } finally { + editorCanvas.setViewportTransform(displayViewportTransform) + } + })() + blob = await blobPromise + } finally { + updateCropUi() + if (activeObject && activeObject !== cropSelection) { + editorCanvas.setActiveObject(activeObject) + } else { + updateActiveCropSelection() + } + editorCanvas.requestRenderAll() + } + if (!blob) throw new Error('Could not export edited screenshot') + return new Uint8Array(await blob.arrayBuffer()) + } + + function handleKeyboardShortcut(event: KeyboardEvent) { + const activeObject = canvas.value?.getActiveObject() as + (EditorFabricObject & { isEditing?: boolean; exitEditing?: () => void }) | undefined + if (activeObject?.isEditing && event.key === 'Escape') { + event.preventDefault() + activeObject.exitEditing?.() + canvas.value?.discardActiveObject() + canvas.value?.requestRenderAll() + return true + } + if (event.key === 'Escape') { + event.preventDefault() + if (cropDrawingStart) { + cancelCropDrawing() + canvas.value?.requestRenderAll() + return true + } + if (erasing) { + finishErasing() + return true + } + if (drawingObject) { + canvas.value?.remove(drawingObject) + drawingObject = undefined + drawingStart = undefined + constructingObject = false + canvas.value?.requestRenderAll() + return true + } + if (canvas.value?.getActiveObjects().length) { + canvas.value.discardActiveObject() + canvas.value.requestRenderAll() + return true + } + if (tool.value !== 'select') { + setTool('select') + return true + } + return false + } + if (activeObject?.isEditing) return false + if (isTypingTarget(event.target)) return false + + const modifier = event.metaKey || event.ctrlKey + const key = event.key.toLowerCase() + if (modifier && key === 'z') { + event.preventDefault() + void (event.shiftKey ? redo() : undo()) + return true + } + if (event.ctrlKey && key === 'y') { + event.preventDefault() + void redo() + return true + } + if (event.key === 'Delete' || event.key === 'Backspace') { + event.preventDefault() + deleteSelection() + return true + } + if (!modifier && !event.altKey) { + const shortcutTool = TOOL_SHORTCUTS[key] + if (shortcutTool) { + event.preventDefault() + setTool(shortcutTool) + return true + } + } + return false + } + + function isTextEditing() { + return Boolean( + (canvas.value?.getActiveObject() as { isEditing?: boolean } | undefined)?.isEditing, + ) + } + + function annotationObjects() { + return (canvas.value?.getObjects() ?? []).filter(isAnnotationObject) as EditorFabricObject[] + } + + function isAnnotationObject(object: FabricObject) { + return ( + object !== background && object !== cropSelection && !cropShade.includes(object as FabricRect) + ) + } + + async function dispose() { + await canvas.value?.dispose() + canvas.value = undefined + background = undefined + cropSelection = undefined + cropShade = [] + sourceImage = undefined + if (sourceUrl) URL.revokeObjectURL(sourceUrl) + sourceUrl = undefined + history.value = [] + historyIndex.value = -1 + zoom.value = 1 + fitScale.value = 1 + isFit.value = true + originalWidth.value = 0 + originalHeight.value = 0 + cropRect.value = { left: 0, top: 0, width: 0, height: 0 } + selectedPropertyKind.value = undefined + selectionCount.value = 0 + propertyEditStart = undefined + erasing = false + erasedDuringGesture = false + cropDrawingStart = undefined + cropDrawingPrevious = undefined + interactionEnabled = true + } + + return { + loading, + tool, + color, + strokeWidth, + fontSize, + eraserMode, + zoom, + fitScale, + isFit, + originalWidth, + originalHeight, + canUndo, + canRedo, + canDelete, + canZoomOut, + canZoomIn, + hasColorProperty, + propertyValueKind, + showEraserMode, + showCropControls, + cropWidth, + cropHeight, + canResetCrop, + initialize, + dispose, + setTool, + updateColor, + updateStrokeWidth, + updateFontSize, + beginPropertyEdit, + commitPropertyEdit, + deleteSelection, + resetCrop, + undo, + redo, + discardChanges, + setInteractionEnabled, + fitToViewport, + setZoom, + setFit, + waitForRender, + exportPng, + handleKeyboardShortcut, + isTextEditing, + resetHistory, + } +} + +function isSourceRect(value: unknown): value is ScreenshotEditorSourceRect { + if (!value || typeof value !== 'object') return false + const rect = value as Partial + return ( + typeof rect.left === 'number' && + Number.isFinite(rect.left) && + typeof rect.top === 'number' && + Number.isFinite(rect.top) && + typeof rect.width === 'number' && + Number.isFinite(rect.width) && + rect.width > 0 && + typeof rect.height === 'number' && + Number.isFinite(rect.height) && + rect.height > 0 + ) +} + +function setEditorMetadata( + object: FabricObject, + kind: ScreenshotEditorObjectKind, + sourceRect?: ScreenshotEditorSourceRect, +) { + const editorObject = object as EditorFabricObject + editorObject.editorKind = kind + editorObject.sourceRect = sourceRect +} + +function boundingRectsOverlap( + left: { left: number; top: number; width: number; height: number }, + right: { left: number; top: number; width: number; height: number }, +) { + return ( + left.left <= right.left + right.width && + left.left + left.width >= right.left && + left.top <= right.top + right.height && + left.top + left.height >= right.top + ) +} + +function normalizedRect(start: { x: number; y: number }, end: { x: number; y: number }) { + return { + left: Math.min(start.x, end.x), + top: Math.min(start.y, end.y), + width: Math.abs(end.x - start.x), + height: Math.abs(end.y - start.y), + } +} + +function loadImage(url: string) { + return new Promise((resolve, reject) => { + const image = new Image() + image.onload = () => resolve(image) + image.onerror = () => reject(new Error('Could not load screenshot image')) + image.src = url + }) +} + +function hexToRgba(hex: string, alpha: number) { + const normalized = hex.replace('#', '') + const red = Number.parseInt(normalized.slice(0, 2), 16) + const green = Number.parseInt(normalized.slice(2, 4), 16) + const blue = Number.parseInt(normalized.slice(4, 6), 16) + return `rgba(${red}, ${green}, ${blue}, ${alpha})` +} + +function colorToHex(color: unknown) { + if (typeof color !== 'string') return undefined + if (/^#[\da-f]{6}$/i.test(color)) return color + if (/^#[\da-f]{3}$/i.test(color)) { + return `#${color + .slice(1) + .split('') + .map((character) => character.repeat(2)) + .join('')}` + } + const components = color + .match(/[\d.]+/g) + ?.slice(0, 3) + .map(Number) + if (!components || components.length !== 3) return undefined + return `#${components + .map((component) => Math.round(component).toString(16).padStart(2, '0')) + .join('')}` +} + +function isPropertyKind( + kind: ScreenshotEditorObjectKind | undefined, +): kind is ScreenshotEditorPropertyKind { + return ( + kind === 'arrow' || + kind === 'censor' || + kind === 'ellipse' || + kind === 'highlight' || + kind === 'pen' || + kind === 'rectangle' || + kind === 'text' + ) +} + +function isTypingTarget(target: EventTarget | null) { + return ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + (target instanceof HTMLElement && target.isContentEditable) + ) +} + +function serializeSnapshot(entry: EditorHistoryEntry) { + return JSON.stringify(entry) +} \ No newline at end of file diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 7c318da..1061764 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -1,6 +1,14 @@ export * from './base' export * from './brand' export * from './flows' +export { default as ImageViewerEditor } from './image-viewer-editor/index.vue' +export type { + ImageViewerEditorData, + ImageViewerEditorItem, + ImageViewerEditorMetadata, + ImageViewerEditorSavePayload, + ImageViewerEditorSource, +} from './image-viewer-editor/types' export * from './modal' export * from './nav' export * from './notifications' diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json index 5dae4df..948c00a 100644 --- a/packages/ui/src/locales/en-US/index.json +++ b/packages/ui/src/locales/en-US/index.json @@ -305,6 +305,12 @@ "console.action.share": { "defaultMessage": "Share" }, + "console.command-completions.no-results": { + "defaultMessage": "No matching completions" + }, + "console.command-completions.search": { + "defaultMessage": "Search command completions" + }, "console.command.disabled-placeholder": { "defaultMessage": "Command input disabled" }, @@ -314,12 +320,6 @@ "console.command.server-not-running-placeholder": { "defaultMessage": "Server is not running" }, - "console.command-completions.no-results": { - "defaultMessage": "No matching completions" - }, - "console.command-completions.search": { - "defaultMessage": "Search command completions" - }, "console.crash.export-context": { "defaultMessage": "Export crash context" }, @@ -860,6 +860,12 @@ "content.diff-modal.updated-count": { "defaultMessage": "{count} updated" }, + "content.filter.client-only": { + "defaultMessage": "Client only" + }, + "content.filter.client-only-description": { + "defaultMessage": "Includes client-only or singleplayer-only mods and mods that depend on them. These may be incompatible with dedicated servers." + }, "content.filter.disabled": { "defaultMessage": "Disabled" }, @@ -872,15 +878,6 @@ "content.filter.updates": { "defaultMessage": "Update available" }, - "content.filter.client-only": { - "defaultMessage": "Client only" - }, - "content.filter.client-only-description": { - "defaultMessage": "Includes client-only or singleplayer-only mods and mods that depend on them. These may be incompatible with dedicated servers." - }, - "content.filter.warnings": { - "defaultMessage": "Warnings" - }, "content.metadata-filter.author": { "defaultMessage": "Author" }, @@ -968,15 +965,6 @@ "content.metadata-filter.source.shared-instance": { "defaultMessage": "Shared instance" }, - "content.metadata-filter.state": { - "defaultMessage": "Status" - }, - "content.metadata-filter.state.disabled": { - "defaultMessage": "Disabled" - }, - "content.metadata-filter.state.enabled": { - "defaultMessage": "Enabled" - }, "content.metadata-filter.toggle": { "defaultMessage": "Filter" }, @@ -986,15 +974,6 @@ "content.metadata-filter.unknown": { "defaultMessage": "Unknown" }, - "content.metadata-filter.update.available": { - "defaultMessage": "Update available" - }, - "content.metadata-filter.update.up-to-date": { - "defaultMessage": "Up to date" - }, - "content.metadata-filter.updates": { - "defaultMessage": "Updates" - }, "content.page-layout.browse-content": { "defaultMessage": "Browse content" }, @@ -2081,6 +2060,111 @@ "icon-select.select": { "defaultMessage": "Select icon" }, + "image-viewer.close": { + "defaultMessage": "Close" + }, + "image-viewer.edit": { + "defaultMessage": "Edit" + }, + "image-viewer.editor.colour": { + "defaultMessage": "Colour" + }, + "image-viewer.editor.crop.dimensions": { + "defaultMessage": "{width} × {height} px" + }, + "image-viewer.editor.crop.reset": { + "defaultMessage": "Reset crop" + }, + "image-viewer.editor.delete-selection": { + "defaultMessage": "Delete selected annotation" + }, + "image-viewer.editor.eraser.area": { + "defaultMessage": "Area" + }, + "image-viewer.editor.eraser.element": { + "defaultMessage": "Element" + }, + "image-viewer.editor.eraser.mode": { + "defaultMessage": "Eraser mode" + }, + "image-viewer.editor.image-editing-tools": { + "defaultMessage": "Image editing tools" + }, + "image-viewer.editor.more-save-options": { + "defaultMessage": "More save options" + }, + "image-viewer.editor.overwrite": { + "defaultMessage": "Overwrite" + }, + "image-viewer.editor.redo": { + "defaultMessage": "Redo" + }, + "image-viewer.editor.save-as-new": { + "defaultMessage": "Save copy" + }, + "image-viewer.editor.save-image": { + "defaultMessage": "Save image" + }, + "image-viewer.editor.size": { + "defaultMessage": "Size" + }, + "image-viewer.editor.tool.arrow": { + "defaultMessage": "Arrow" + }, + "image-viewer.editor.tool.censor": { + "defaultMessage": "Censor" + }, + "image-viewer.editor.tool.crop": { + "defaultMessage": "Crop" + }, + "image-viewer.editor.tool.ellipse": { + "defaultMessage": "Ellipse" + }, + "image-viewer.editor.tool.eraser": { + "defaultMessage": "Eraser" + }, + "image-viewer.editor.tool.highlight": { + "defaultMessage": "Highlight" + }, + "image-viewer.editor.tool.pen": { + "defaultMessage": "Pen" + }, + "image-viewer.editor.tool.rectangle": { + "defaultMessage": "Rectangle" + }, + "image-viewer.editor.tool.select": { + "defaultMessage": "Select" + }, + "image-viewer.editor.tool.text": { + "defaultMessage": "Text" + }, + "image-viewer.editor.undo": { + "defaultMessage": "Undo" + }, + "image-viewer.editor.width": { + "defaultMessage": "Width" + }, + "image-viewer.editor.zoom-fit": { + "defaultMessage": "Fit" + }, + "image-viewer.editor.zoom-fit-workspace": { + "defaultMessage": "Fit to workspace" + }, + "image-viewer.editor.zoom-in": { + "defaultMessage": "Zoom in" + }, + "image-viewer.editor.zoom-out": { + "defaultMessage": "Zoom out" + }, + "image-viewer.metadata": { + "defaultMessage": "{size} · {width} × {height} px" + }, + "image-viewer.next": { + "defaultMessage": "Next image" + }, + "image-viewer.previous": { + "defaultMessage": "Previous image" + }, "input.search-version.placeholder": { "defaultMessage": "Search version..." }, @@ -2537,18 +2621,6 @@ "label.no-items": { "defaultMessage": "No items" }, - "notification.panel.copy": { - "defaultMessage": "Copy to clipboard" - }, - "notification.panel.copy-support": { - "defaultMessage": "Copy error details for support" - }, - "notification.panel.dismiss": { - "defaultMessage": "Dismiss" - }, - "notification.panel.export-error-logs": { - "defaultMessage": "Export error logs" - }, "label.notifications": { "defaultMessage": "Notifications" }, @@ -2939,6 +3011,18 @@ "notification.error.title": { "defaultMessage": "An error occurred" }, + "notification.panel.copy": { + "defaultMessage": "Copy to clipboard" + }, + "notification.panel.copy-support": { + "defaultMessage": "Copy error details for support" + }, + "notification.panel.dismiss": { + "defaultMessage": "Dismiss" + }, + "notification.panel.export-error-logs": { + "defaultMessage": "Export error logs" + }, "omorphia.component.badge.label.accepted": { "defaultMessage": "Accepted" }, diff --git a/packages/ui/src/locales/zh-CN/index.json b/packages/ui/src/locales/zh-CN/index.json index fb34eef..cada412 100644 --- a/packages/ui/src/locales/zh-CN/index.json +++ b/packages/ui/src/locales/zh-CN/index.json @@ -4639,5 +4639,110 @@ }, "drop.confirm.as-data-pack-desc": { "defaultMessage": "将数据包安装到存档" + }, + "image-viewer.close": { + "defaultMessage": "关闭" + }, + "image-viewer.edit": { + "defaultMessage": "编辑" + }, + "image-viewer.editor.colour": { + "defaultMessage": "颜色" + }, + "image-viewer.editor.crop.dimensions": { + "defaultMessage": "{width} × {height} 像素" + }, + "image-viewer.editor.crop.reset": { + "defaultMessage": "重置裁剪" + }, + "image-viewer.editor.delete-selection": { + "defaultMessage": "删除选中的标注" + }, + "image-viewer.editor.eraser.area": { + "defaultMessage": "区域" + }, + "image-viewer.editor.eraser.element": { + "defaultMessage": "元素" + }, + "image-viewer.editor.eraser.mode": { + "defaultMessage": "橡皮擦模式" + }, + "image-viewer.editor.image-editing-tools": { + "defaultMessage": "图像编辑工具" + }, + "image-viewer.editor.more-save-options": { + "defaultMessage": "更多保存选项" + }, + "image-viewer.editor.overwrite": { + "defaultMessage": "覆盖" + }, + "image-viewer.editor.redo": { + "defaultMessage": "重做" + }, + "image-viewer.editor.save-as-new": { + "defaultMessage": "保存副本" + }, + "image-viewer.editor.save-image": { + "defaultMessage": "保存图像" + }, + "image-viewer.editor.size": { + "defaultMessage": "大小" + }, + "image-viewer.editor.tool.arrow": { + "defaultMessage": "箭头" + }, + "image-viewer.editor.tool.censor": { + "defaultMessage": "打码" + }, + "image-viewer.editor.tool.crop": { + "defaultMessage": "裁剪" + }, + "image-viewer.editor.tool.ellipse": { + "defaultMessage": "椭圆" + }, + "image-viewer.editor.tool.eraser": { + "defaultMessage": "橡皮擦" + }, + "image-viewer.editor.tool.highlight": { + "defaultMessage": "高亮" + }, + "image-viewer.editor.tool.pen": { + "defaultMessage": "画笔" + }, + "image-viewer.editor.tool.rectangle": { + "defaultMessage": "矩形" + }, + "image-viewer.editor.tool.select": { + "defaultMessage": "选择" + }, + "image-viewer.editor.tool.text": { + "defaultMessage": "文字" + }, + "image-viewer.editor.undo": { + "defaultMessage": "撤销" + }, + "image-viewer.editor.width": { + "defaultMessage": "宽度" + }, + "image-viewer.editor.zoom-fit": { + "defaultMessage": "适应" + }, + "image-viewer.editor.zoom-fit-workspace": { + "defaultMessage": "适应工作区" + }, + "image-viewer.editor.zoom-in": { + "defaultMessage": "放大" + }, + "image-viewer.editor.zoom-out": { + "defaultMessage": "缩小" + }, + "image-viewer.metadata": { + "defaultMessage": "{size} · {width} × {height} 像素" + }, + "image-viewer.next": { + "defaultMessage": "下一张图像" + }, + "image-viewer.previous": { + "defaultMessage": "上一张图像" } } diff --git a/packages/ui/src/providers/image-viewer-editor.ts b/packages/ui/src/providers/image-viewer-editor.ts new file mode 100644 index 0000000..6b587b9 --- /dev/null +++ b/packages/ui/src/providers/image-viewer-editor.ts @@ -0,0 +1,15 @@ +import type { + ImageViewerEditorData, + ImageViewerEditorSource, +} from '#ui/components/image-viewer-editor/types' + +import { createContext } from '.' + +export interface ImageViewerEditorContext { + loadEditorData: (source: ImageViewerEditorSource) => Promise + onShow?: () => void + onHide?: () => void +} + +export const [injectImageViewerEditor, provideImageViewerEditor] = + createContext('ImageViewerEditor') \ No newline at end of file diff --git a/packages/ui/src/providers/index.ts b/packages/ui/src/providers/index.ts index dc4a4f4..89c652a 100644 --- a/packages/ui/src/providers/index.ts +++ b/packages/ui/src/providers/index.ts @@ -6,6 +6,7 @@ export { createContext } from './create-context' export * from './file-drop' export * from './file-picker' export * from './i18n' +export * from './image-viewer-editor' export * from './instance-import' export * from './loading-state' export * from './modal-behavior' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce99f54..f6c8ba0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,15 @@ importers: apps/app-frontend: dependencies: + '@dnd-kit/core': + specifier: 6.3.1 + version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': + specifier: 3.2.2 + version: 3.2.2(react@19.2.4) + '@dnd-kit/vue': + specifier: ^0.5.0 + version: 0.5.0(vue@3.5.27(typescript@5.9.3)) '@lobehub/icons-static-svg': specifier: 1.94.0 version: 1.94.0 @@ -475,6 +484,9 @@ importers: es-toolkit: specifier: ^1.44.0 version: 1.44.0 + fabric: + specifier: ^7.4.0 + version: 7.4.0 flatpickr: specifier: ^4.6.13 version: 4.6.13 @@ -634,6 +646,9 @@ packages: '@alvarosabu/utils@3.2.0': resolution: {integrity: sha512-aoGWRfaQjOo9TUwrBA6W0zwTHktgrXy69GIFNILT4gHsqscw6+X8P6uoSlZVQFr887SPm8x3aDin5EBVq8y4pw==} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -908,6 +923,70 @@ packages: resolution: {integrity: sha512-kEMiz25j9tSfhsyj8dZWdhKPdtTeVu9lWZFBIc+adHXZ8vs6zd4/6xaiYr3PamYhV3rIOBEv8ElyzpUSSO5LGg==} engines: {node: '>=12.9.0'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@dnd-kit/abstract@0.5.0': + resolution: {integrity: sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA==} + + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/collision@0.5.0': + resolution: {integrity: sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ==} + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/dom@0.5.0': + resolution: {integrity: sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw==} + + '@dnd-kit/geometry@0.5.0': + resolution: {integrity: sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A==} + + '@dnd-kit/state@0.5.0': + resolution: {integrity: sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w==} + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/vue@0.5.0': + resolution: {integrity: sha512-dM5wtnFNmOvzpIyLSLB8SL3K7Axz1NxyNzRnNAldGwV0ZKtUcss9ZXXd+dFwgwoE9VA+vMW81UxyJh2Ax+9Icw==} + peerDependencies: + vue: ^3.5.0 + '@dxup/nuxt@0.2.2': resolution: {integrity: sha512-RNpJjDZs9+JcT9N87AnOuHsNM75DEd58itADNd/s1LIF6BZbTLZV0xxilJZb55lntn4TYvscTaXLCBX2fq9CXg==} @@ -2107,6 +2186,9 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@preact/signals-core@1.14.4': + resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} + '@prettier/plugin-xml@3.4.2': resolution: {integrity: sha512-/UyNlHfkuLXG6Ed85KB0WBF283xn2yavR+UtRibBRUcvEJId2DSLdGXwJ/cDa1X++SWDPzq3+GSFniHjkNy7yg==} peerDependencies: @@ -3323,6 +3405,9 @@ packages: birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -3348,6 +3433,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -3398,6 +3486,10 @@ packages: caniuse-lite@1.0.30001767: resolution: {integrity: sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==} + canvas@3.2.3: + resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==} + engines: {node: ^18.12.0 || >= 20.9.0} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3424,6 +3516,9 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -3624,9 +3719,17 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} @@ -3671,6 +3774,14 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -3814,6 +3925,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -4062,12 +4177,20 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} externality@1.0.2: resolution: {integrity: sha512-LyExtJWKxtgVzmgtEHyQtLFpw1KFhQphF9nTG8TpAIVkiI/xQ3FJh75tRFLYl4hkn7BNIIdLJInuDAavX35pMw==} + fabric@7.4.0: + resolution: {integrity: sha512-NalYDc3eifTl1C33zryQwpH6+XA/2ClxQrH9vkASkZw3tbkRmorpikhYMmxhUTmi7O3e9ODz0vOT8qfaCh9IVA==} + engines: {node: '>=20.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -4188,6 +4311,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4244,6 +4370,9 @@ packages: git-url-parse@16.1.0: resolution: {integrity: sha512-cPLz4HuK86wClEW7iDdeAKcCVlWXmrLpb2L+G9goW0Z1dtpNS6BXXSOckUTlJT/LDQViE1QZKstNORzHsLnobw==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + gl-matrix@3.4.4: resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} @@ -4351,10 +4480,18 @@ packages: hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + http-shutdown@1.2.2: resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -4378,6 +4515,10 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -4421,6 +4562,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ini@4.1.1: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -4506,6 +4650,9 @@ packages: resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -4582,6 +4729,15 @@ packages: resolution: {integrity: sha512-iZ8Bdb84lWRuGHamRXFyML07r21pcwBrLkHEuHgEY5UbCouBwv7ECknDRKzsQIXMiqpPymqtIf8TC/shYKB5rw==} engines: {node: '>=12.0.0'} + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@0.5.0: resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} hasBin: true @@ -4949,6 +5105,10 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -4968,6 +5128,9 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -4979,6 +5142,9 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -5026,6 +5192,9 @@ packages: nanotar@0.2.0: resolution: {integrity: sha512-9ca1h0Xjvo9bEkE4UOxgAzLV0jHKe6LMaxo37ND2DAhhAtd0j8pR1Wxz+/goMrZO8AEZTWCmyaOsFI/W5AdpCQ==} + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -5047,6 +5216,10 @@ packages: xml2js: optional: true + node-abi@3.96.0: + resolution: {integrity: sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==} + engines: {node: '>=10'} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -5116,6 +5289,9 @@ packages: '@types/node': optional: true + nwsapi@2.2.28: + resolution: {integrity: sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==} + nypm@0.6.4: resolution: {integrity: sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw==} engines: {node: '>=18'} @@ -5246,6 +5422,9 @@ packages: resolution: {integrity: sha512-bCgsFI+GeGWPAvAiUv63ZorMeif3/U0zaXABGJbOWt5OH2KCaPHF6S+0ok4aqM9RuIPGyZdx9tR9l13PsW4AYQ==} engines: {node: '>=14.13.0'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -5555,6 +5734,12 @@ packages: potpack@1.0.2: resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -5699,6 +5884,10 @@ packages: rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -5725,6 +5914,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readable-stream@4.7.0: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -5827,6 +6020,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -5852,6 +6048,10 @@ packages: resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -5952,6 +6152,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-git@3.30.0: resolution: {integrity: sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg==} @@ -6077,6 +6283,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -6136,6 +6346,9 @@ packages: engines: {node: '>=16'} hasBin: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.11.12: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -6160,6 +6373,13 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} @@ -6217,6 +6437,13 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -6229,9 +6456,17 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -6244,6 +6479,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + turbo-darwin-64@2.8.2: resolution: {integrity: sha512-sumREbxABHxrWIwlK67sZEaDRE7+BFSU8gZj8OK+X7dLpgW1vTjsHzTECB5m2qzWlXHRdueAk8sKv7wyHqv9jw==} cpu: [x64] @@ -6762,12 +7000,33 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -6820,6 +7079,10 @@ packages: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -6828,6 +7091,9 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xss@1.0.15: resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} engines: {node: '>= 0.10.0'} @@ -6881,6 +7147,15 @@ snapshots: '@alvarosabu/utils@3.2.0': {} + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + optional: true + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -7279,6 +7554,87 @@ snapshots: - debug - supports-color + '@csstools/color-helpers@5.1.0': + optional: true + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + optional: true + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + optional: true + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + optional: true + + '@csstools/css-tokenizer@3.0.4': + optional: true + + '@dnd-kit/abstract@0.5.0': + dependencies: + '@dnd-kit/geometry': 0.5.0 + '@dnd-kit/state': 0.5.0 + tslib: 2.8.1 + + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/collision@0.5.0': + dependencies: + '@dnd-kit/abstract': 0.5.0 + '@dnd-kit/geometry': 0.5.0 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tslib: 2.8.1 + + '@dnd-kit/dom@0.5.0': + dependencies: + '@dnd-kit/abstract': 0.5.0 + '@dnd-kit/collision': 0.5.0 + '@dnd-kit/geometry': 0.5.0 + '@dnd-kit/state': 0.5.0 + tslib: 2.8.1 + + '@dnd-kit/geometry@0.5.0': + dependencies: + '@dnd-kit/state': 0.5.0 + tslib: 2.8.1 + + '@dnd-kit/state@0.5.0': + dependencies: + '@preact/signals-core': 1.14.4 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/vue@0.5.0(vue@3.5.27(typescript@5.9.3))': + dependencies: + '@dnd-kit/abstract': 0.5.0 + '@dnd-kit/dom': 0.5.0 + '@dnd-kit/state': 0.5.0 + tslib: 2.8.1 + vue: 3.5.27(typescript@5.9.3) + '@dxup/nuxt@0.2.2(magicast@0.5.1)': dependencies: '@dxup/unimport': 0.1.2 @@ -8469,6 +8825,8 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@preact/signals-core@1.14.4': {} + '@prettier/plugin-xml@3.4.2(prettier@3.8.1)': dependencies: '@xml-tools/parser': 1.0.11 @@ -9705,6 +10063,13 @@ snapshots: birpc@2.9.0: {} + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + boolbase@1.0.0: {} brace-expansion@1.1.12: @@ -9732,6 +10097,12 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + optional: true + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -9789,6 +10160,12 @@ snapshots: caniuse-lite@1.0.30001767: {} + canvas@3.2.3: + dependencies: + node-addon-api: 7.1.1 + prebuild-install: 7.1.3 + optional: true + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -9822,6 +10199,9 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@1.1.4: + optional: true + chownr@3.0.0: {} ci-info@2.0.0: {} @@ -10028,8 +10408,20 @@ snapshots: dependencies: css-tree: 2.2.1 + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + optional: true + csstype@3.2.3: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + optional: true + dayjs@1.11.19: {} db0@0.3.4: {} @@ -10044,6 +10436,14 @@ snapshots: decimal.js@10.6.0: {} + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + optional: true + + deep-extend@0.6.0: + optional: true + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -10158,6 +10558,9 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: + optional: true + entities@7.0.1: {} error-ex@1.3.4: @@ -10664,6 +11067,9 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + expand-template@2.0.3: + optional: true + exsolve@1.0.8: {} externality@1.0.2: @@ -10673,6 +11079,15 @@ snapshots: pathe: 1.1.2 ufo: 1.6.3 + fabric@7.4.0: + optionalDependencies: + canvas: 3.2.3 + jsdom: 26.1.0(canvas@3.2.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -10772,6 +11187,9 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: + optional: true + fsevents@2.3.3: optional: true @@ -10833,6 +11251,9 @@ snapshots: dependencies: git-up: 8.1.1 + github-from-package@0.0.0: + optional: true + gl-matrix@3.4.4: {} glob-parent@5.1.2: @@ -10935,6 +11356,11 @@ snapshots: hosted-git-info@2.8.9: {} + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + optional: true + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -10943,6 +11369,14 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + http-shutdown@1.2.2: {} https-proxy-agent@5.0.1: @@ -10967,6 +11401,11 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + ieee754@1.2.1: {} if-ci@3.0.0: @@ -11003,6 +11442,9 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: + optional: true + ini@4.1.1: {} ini@6.0.0: {} @@ -11084,6 +11526,9 @@ snapshots: is-path-inside@4.0.0: {} + is-potential-custom-element-name@1.0.1: + optional: true + is-reference@1.2.1: dependencies: '@types/estree': 1.0.8 @@ -11142,6 +11587,36 @@ snapshots: jsdoc-type-pratt-parser@4.8.0: {} + jsdom@26.1.0(canvas@3.2.3): + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.28 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.0 + xml-name-validator: 5.0.0 + optionalDependencies: + canvas: 3.2.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + optional: true + jsesc@0.5.0: {} jsesc@3.1.0: {} @@ -11460,6 +11935,9 @@ snapshots: mimic-fn@4.0.0: {} + mimic-response@3.1.0: + optional: true + min-indent@1.0.1: {} minimatch@10.1.2: @@ -11478,6 +11956,9 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimist@1.2.8: + optional: true + minipass@7.1.2: {} minizlib@3.1.0: @@ -11486,6 +11967,9 @@ snapshots: mitt@3.0.1: {} + mkdirp-classic@0.5.3: + optional: true + mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -11537,6 +12021,9 @@ snapshots: nanotar@0.2.0: {} + napi-build-utils@2.0.0: + optional: true + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -11647,6 +12134,11 @@ snapshots: - supports-color - uploadthing + node-abi@3.96.0: + dependencies: + semver: 7.8.5 + optional: true + node-addon-api@7.1.1: {} node-fetch-native@1.6.7: {} @@ -11816,6 +12308,9 @@ snapshots: - xml2js - yaml + nwsapi@2.2.28: + optional: true + nypm@0.6.4: dependencies: citty: 0.2.0 @@ -11991,6 +12486,11 @@ snapshots: '@types/parse-path': 7.1.0 parse-path: 7.1.0 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + optional: true + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -12258,6 +12758,22 @@ snapshots: potpack@1.0.2: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.96.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + optional: true + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -12329,14 +12845,20 @@ snapshots: defu: 6.1.7 destr: 2.0.5 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + optional: true + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 scheduler: 0.27.0 - optional: true - react@19.2.4: - optional: true + react@19.2.4: {} read-cache@1.0.0: dependencies: @@ -12372,6 +12894,13 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + optional: true + readable-stream@4.7.0: dependencies: abort-controller: 3.0.0 @@ -12499,6 +13028,9 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 + rrweb-cssom@0.8.0: + optional: true + run-applescript@7.1.0: {} run-parallel@1.2.0: @@ -12521,9 +13053,13 @@ snapshots: sax@1.4.4: {} - scheduler@0.27.0: + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 optional: true + scheduler@0.27.0: {} + scslre@0.3.0: dependencies: '@eslint-community/regexpp': 4.12.2 @@ -12627,6 +13163,16 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: + optional: true + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + optional: true + simple-git@3.30.0: dependencies: '@kwsites/file-exists': 1.1.1 @@ -12747,6 +13293,9 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-json-comments@2.0.1: + optional: true + strip-json-comments@3.1.1: {} strip-literal@3.1.0: @@ -12811,6 +13360,9 @@ snapshots: picocolors: 1.1.1 sax: 1.4.4 + symbol-tree@3.2.4: + optional: true + synckit@0.11.12: dependencies: '@pkgr/core': 0.2.9 @@ -12851,6 +13403,23 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + optional: true + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + tar-stream@3.1.7: dependencies: b4a: 1.7.3 @@ -12925,6 +13494,14 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tldts-core@6.1.86: + optional: true + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + optional: true + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -12933,8 +13510,18 @@ snapshots: totalist@3.0.1: {} + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + optional: true + tr46@0.0.3: {} + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + optional: true + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -12943,6 +13530,11 @@ snapshots: tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + optional: true + turbo-darwin-64@2.8.2: optional: true @@ -13479,10 +14071,32 @@ snapshots: w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + optional: true + webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: + optional: true + webpack-virtual-modules@0.6.2: {} + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + optional: true + + whatwg-mimetype@4.0.0: + optional: true + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + optional: true + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -13524,6 +14138,9 @@ snapshots: xml-name-validator@4.0.0: {} + xml-name-validator@5.0.0: + optional: true + xml2js@0.6.2: dependencies: sax: 1.4.4 @@ -13533,6 +14150,9 @@ snapshots: xmlbuilder@11.0.1: optional: true + xmlchars@2.2.0: + optional: true + xss@1.0.15: dependencies: commander: 2.20.3 diff --git a/scripts/axolotl/i18n-check.mjs b/scripts/axolotl/i18n-check.mjs index f60ffc8..5625955 100644 --- a/scripts/axolotl/i18n-check.mjs +++ b/scripts/axolotl/i18n-check.mjs @@ -63,6 +63,9 @@ const allowedUntranslatedMessages = new Set([ '> {command}', 'Beta', 'Release', + '{instance} · {date}', + 'Modrinth screenshots.zip', + '{instance} screenshots.zip', ]) function messageText(value) {