Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b310018c38 | ||
|
|
fbde37f5a0 | ||
|
|
e6f9987694 | ||
|
|
5a08a1ed3a | ||
|
|
e51abeb5ad | ||
|
|
4c13f849eb | ||
|
|
00bfe2de9f | ||
|
|
95b9c044eb | ||
|
|
911a2cb872 | ||
|
|
0a60695dc9 | ||
|
|
643d7276d7 | ||
|
|
e066c6a8d7 | ||
|
|
b5406c2da0 | ||
|
|
300df18aca | ||
|
|
5efbe048eb | ||
|
|
ead5d96b78 | ||
|
|
e646598078 | ||
|
|
f1ff19e4b8 | ||
|
|
34cf186c33 | ||
|
|
81b7219085 | ||
|
|
3cdc4951e0 | ||
|
|
f839796585 | ||
|
|
e25135934f | ||
|
|
02a8004fd5 | ||
|
|
6b1d2e1943 | ||
|
|
6912e0aeb1 | ||
|
|
c7d1e44719 | ||
|
|
0a6fa0eb35 | ||
|
|
79fdddc075 | ||
|
|
7616f60eca | ||
|
|
6f77f13a04 | ||
|
|
b1f4431767 |
No files matched your search
@@ -1,10 +1,3 @@
|
||||
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
|
||||
[target."cfg(windows)"]
|
||||
rustflags = ["-C", "link-args=/STACK:16777220"]
|
||||
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
linker = "rust-lld"
|
||||
|
||||
# Reproducible public-service defaults for downstream desktop builds. Private
|
||||
# Modrinth services remain disabled by Axolotl capabilities at runtime.
|
||||
[env]
|
||||
@@ -12,5 +5,12 @@ MODRINTH_API_BASE_URL = "https://api.modrinth.com"
|
||||
MODRINTH_API_URL = "https://api.modrinth.com/v2/"
|
||||
MODRINTH_API_URL_V3 = "https://api.modrinth.com/v3/"
|
||||
MODRINTH_LAUNCHER_META_URL = "https://launcher-meta.modrinth.com/"
|
||||
MODRINTH_URL = "https://modrinth.com/"
|
||||
MODRINTH_SOCKET_URL = "wss://disabled.invalid/"
|
||||
MODRINTH_URL = "https://modrinth.com/"
|
||||
|
||||
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
|
||||
[target."cfg(windows)"]
|
||||
rustflags = ["-C", "link-args=/STACK:16777220"]
|
||||
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
linker = "rust-lld"
|
||||
@@ -22,8 +22,5 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
|
||||
with:
|
||||
tool: tombi@1.1.2
|
||||
- run: tombi lint
|
||||
- run: tombi fmt --check
|
||||
- run: npx --yes tombi@1.6.1 lint
|
||||
- run: npx --yes tombi@1.6.1 fmt --check
|
||||
@@ -38,6 +38,7 @@ node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
replay_pid*.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
|
||||
@@ -20,9 +20,7 @@ ariadne = { path = "packages/ariadne" }
|
||||
async-minecraft-ping = { path = "packages/async-minecraft-ping" }
|
||||
async-recursion = "1.1.1"
|
||||
async-trait = "0.1.89"
|
||||
async-tungstenite = { version = "0.31.0", default-features = false, features = [
|
||||
"futures-03-sink"
|
||||
] }
|
||||
async-tungstenite = { version = "0.31.0", default-features = false, features = ["futures-03-sink"] }
|
||||
async-walkdir = "2.1.0"
|
||||
async_zip = "0.0.18"
|
||||
base64 = "0.22.1"
|
||||
@@ -32,9 +30,7 @@ bytes = "1.10.1"
|
||||
cc = "1.2.56"
|
||||
chardetng = "0.1.17"
|
||||
chrono = "0.4.42"
|
||||
cidre = { version = "0.15.0", default-features = false, features = [
|
||||
"macos_15_0"
|
||||
] }
|
||||
cidre = { version = "0.15.0", default-features = false, features = ["macos_15_0"] }
|
||||
crc32fast = "1.5.0"
|
||||
daedalus = { path = "packages/daedalus" }
|
||||
dashmap = "6.1.0"
|
||||
@@ -58,11 +54,7 @@ hmac = "0.12.1"
|
||||
http = "1.3.1"
|
||||
hyper = "1.7.0"
|
||||
hyper-util = "0.1.17"
|
||||
image = { version = "0.25.5", default-features = false, features = [
|
||||
"jpeg",
|
||||
"png",
|
||||
"webp"
|
||||
] }
|
||||
image = { version = "0.25.5", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
indicatif = "0.18.0"
|
||||
itertools = "0.14.0"
|
||||
jsonwebtoken = "9.3.1"
|
||||
@@ -88,11 +80,7 @@ rand = "=0.8.5" # Locked on 0.8 until p256 updates to 0.9
|
||||
regex = "1.12.2"
|
||||
reqwest = { version = "0.12.24", default-features = false }
|
||||
rgb = "0.8.52"
|
||||
rustls = { version = "0.23.42", default-features = false, features = [
|
||||
"ring",
|
||||
"std",
|
||||
"tls12"
|
||||
] }
|
||||
rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
rustls-native-certs = "0.8.4"
|
||||
rustls-pki-types = "1.15.0"
|
||||
serde = "1.0.228"
|
||||
@@ -127,10 +115,7 @@ tempfile = "3.23.0"
|
||||
theseus = { path = "packages/app-lib" }
|
||||
thiserror = "2.0.17"
|
||||
tokio = "1.47.1"
|
||||
tokio-rustls = { version = "0.26.4", default-features = false, features = [
|
||||
"ring",
|
||||
"tls12"
|
||||
] }
|
||||
tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"] }
|
||||
tokio-util = "0.7.16"
|
||||
toml = "0.8.19"
|
||||
tracing = "0.1.41"
|
||||
@@ -149,10 +134,7 @@ windows-core = "=0.61.2" # Locked on 0.61 until webview2-com updates to 0.62
|
||||
winreg = "0.55.0"
|
||||
xz2 = { version = "0.1.7", features = ["static"] }
|
||||
zbus = "5.11.0"
|
||||
zhconv = { version = "0.4.1", default-features = false, features = [
|
||||
"compress",
|
||||
"opencc"
|
||||
] }
|
||||
zhconv = { version = "0.4.1", default-features = false, features = ["compress", "opencc"] }
|
||||
zip = { version = "6.0.0", default-features = false, features = [
|
||||
"bzip2",
|
||||
"deflate",
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FlaskConicalIcon,
|
||||
FolderOpenIcon,
|
||||
HomeIcon,
|
||||
ImagesIcon,
|
||||
LeftArrowIcon,
|
||||
LibraryIcon,
|
||||
LogInIcon,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
SettingsIcon,
|
||||
SpinnerIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
WorldIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
@@ -141,7 +143,7 @@ import {
|
||||
isNetworkMetered,
|
||||
setRestartAfterPendingUpdate,
|
||||
} from '@/helpers/utils.js'
|
||||
import { installUiScale } from '@/helpers/ui-scale'
|
||||
import { installUiScale, setUiScale } from '@/helpers/ui-scale'
|
||||
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
|
||||
import i18n, { resolveInitialLocale } from '@/i18n.config'
|
||||
import {
|
||||
@@ -313,6 +315,22 @@ const showOnboarding = ref(false)
|
||||
const onboardingMode = ref('main')
|
||||
const onboardingSettings = ref(null)
|
||||
const onboardingReplay = ref(false)
|
||||
|
||||
// Expand the sidebar while the onboarding tour is visible so steps that point
|
||||
// at sidebar entries (account card, announcement, create instance) can find
|
||||
// their targets. The previous state is restored when the tour ends.
|
||||
let sidebarStateBeforeOnboarding: boolean | undefined
|
||||
watch(showOnboarding, (visible) => {
|
||||
if (visible) {
|
||||
sidebarStateBeforeOnboarding = sidebarToggled.value
|
||||
sidebarToggled.value = true
|
||||
setSidebarExpanded(true)
|
||||
} else if (sidebarStateBeforeOnboarding !== undefined) {
|
||||
sidebarToggled.value = sidebarStateBeforeOnboarding
|
||||
setSidebarExpanded(sidebarStateBeforeOnboarding)
|
||||
sidebarStateBeforeOnboarding = undefined
|
||||
}
|
||||
})
|
||||
const nativeDecorations = ref(false)
|
||||
|
||||
const os = ref('')
|
||||
@@ -749,6 +767,10 @@ const messages = defineMessages({
|
||||
id: 'app.navigation.multiplayer',
|
||||
defaultMessage: 'Multiplayer',
|
||||
},
|
||||
screenshots: {
|
||||
id: 'app.navigation.screenshots',
|
||||
defaultMessage: 'Screenshots',
|
||||
},
|
||||
downloads: {
|
||||
id: 'app.navigation.downloads',
|
||||
defaultMessage: 'Downloads',
|
||||
@@ -1074,6 +1096,7 @@ async function setupApp() {
|
||||
close_behavior,
|
||||
developer_mode,
|
||||
feature_flags,
|
||||
ui_scale,
|
||||
} = initialSettings
|
||||
|
||||
// Initialize locale from saved settings
|
||||
@@ -1104,6 +1127,7 @@ async function setupApp() {
|
||||
nativeDecorations.value = native_decorations
|
||||
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
|
||||
|
||||
setUiScale(ui_scale)
|
||||
themeStore.setThemeState(theme)
|
||||
await initializeSystemAccentColor()
|
||||
themeStore.setAccentColor(accent_color)
|
||||
@@ -2296,6 +2320,23 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<LibraryIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-if="themeStore.featureFlags.multiplayer_in_app"
|
||||
v-tooltip.right="formatMessage(messages.multiplayer)"
|
||||
data-onboarding-id="nav-multiplayer"
|
||||
to="/multiplayer"
|
||||
:is-primary="(r) => r.path.startsWith('/multiplayer')"
|
||||
>
|
||||
<UsersIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="formatMessage(messages.screenshots)"
|
||||
data-onboarding-id="nav-screenshots"
|
||||
to="/screenshots"
|
||||
:is-primary="(r) => r.path.startsWith('/screenshots')"
|
||||
>
|
||||
<ImagesIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="formatMessage(messages.lab)"
|
||||
data-onboarding-id="nav-lab"
|
||||
@@ -2509,7 +2550,10 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<div id="sidebar-teleport-target" class="sidebar-teleport-content contents"></div>
|
||||
<div class="sidebar-default-content hidden" :class="{ 'sidebar-enabled': sidebarVisible }">
|
||||
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||
<div
|
||||
data-onboarding-id="sidebar-accounts"
|
||||
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
|
||||
>
|
||||
<h3 class="text-base text-primary font-medium m-0">
|
||||
{{ formatMessage(messages.userInformation) }}
|
||||
</h3>
|
||||
|
||||
@@ -32,6 +32,157 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
|
||||
]
|
||||
|
||||
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
||||
{
|
||||
id: 'launcher-1.0.1-beta8',
|
||||
version: '1.0.1-beta8',
|
||||
publishedAt: '2026-10-02',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.1-beta8',
|
||||
'zh-CN': 'Starlight Launcher 1.0.1-beta8',
|
||||
},
|
||||
changes: {
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the sidebar navigation entries breaking after the screenshot center was added.',
|
||||
'zh-CN': '修复添加截图中心后侧边栏导航项损坏的问题。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Fixed being unable to install the StarLight hosted pack even after signing in (missing command permission).',
|
||||
'zh-CN': '修复登录后仍无法安装 StarLight 实例的问题(命令权限缺失)。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the launcher UI scaling not applying when changed (missing command permission).',
|
||||
'zh-CN': '修复修改启动器界面缩放后不生效的问题(命令权限缺失)。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Fixed several screenshot center actions failing due to missing command permissions.',
|
||||
'zh-CN': '修复截图中心部分操作因命令权限缺失而失败的问题。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.1-beta7',
|
||||
version: '1.0.1-beta7',
|
||||
publishedAt: '2026-10-02',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.1-beta7',
|
||||
'zh-CN': 'Starlight Launcher 1.0.1-beta7',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US':
|
||||
'Added a screenshot center: browse screenshots across every instance, group them (custom groups, by instance, or by date), preview, edit (crop, rotate, annotate, censor), and export as ZIP.',
|
||||
'zh-CN':
|
||||
'新增截图中心:跨实例浏览截图,支持自定义/按实例/按日期分组,可预览、编辑(裁剪、标注、打码等)并导出为 ZIP。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Added the multiplayer (Terracotta) entry to the sidebar, behind a feature flag.',
|
||||
'zh-CN': '侧边栏新增多人联机(陶瓦)入口,由功能开关控制。',
|
||||
},
|
||||
],
|
||||
changed: [
|
||||
{
|
||||
'en-US':
|
||||
'Launcher UI scaling is now persisted in the backend and applied via native webview zoom, so it scales embedded pages correctly and keeps onboarding anchors aligned.',
|
||||
'zh-CN':
|
||||
'启动器界面缩放改为后端持久化,并通过原生 webview 缩放应用,可正确缩放内嵌页面且导览锚点不再偏移。',
|
||||
},
|
||||
],
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the onboarding tour skipping its first step when the target anchor was missing.',
|
||||
'zh-CN': '修复导览首步在目标锚点缺失时被自动跳过的问题。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.1-beta6',
|
||||
version: '1.0.1-beta6',
|
||||
publishedAt: '2026-10-01',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.1-beta6',
|
||||
'zh-CN': 'Starlight Launcher 1.0.1-beta6',
|
||||
},
|
||||
changes: {
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the onboarding tour skipping steps automatically, getting stuck, or not navigating between steps; the tour bubble no longer covers the left navigation bar.',
|
||||
'zh-CN':
|
||||
'修复新手导览自动跳过、卡住或不跳转的问题;导览气泡不再遮挡左侧导航栏。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the onboarding tour target on the minimal home layout.',
|
||||
'zh-CN': '修复精简主页下导览目标找不到的问题。',
|
||||
},
|
||||
],
|
||||
added: [
|
||||
{
|
||||
'en-US': 'Feature flag names in developer settings are now shown in Chinese.',
|
||||
'zh-CN': '开发者设置中的功能开关名现在显示中文。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.1-beta5',
|
||||
version: '1.0.1-beta5',
|
||||
publishedAt: '2026-10-01',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.1-beta5',
|
||||
'zh-CN': 'Starlight Launcher 1.0.1-beta5',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US':
|
||||
'Reworked the onboarding tour, covering the home page, StarLight login, announcements, instance creation, discovery, skins, library, lab, downloads, and update settings in order.',
|
||||
'zh-CN':
|
||||
'重做了新手导览,依次介绍主页、StarLight 登录、公告、创建实例、发现内容、皮肤、实例库、实验室、下载与更新设置。',
|
||||
},
|
||||
],
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'Fixed the developer feature flags page rendering blank.',
|
||||
'zh-CN': '修复开发者“功能开关”页面空白的问题。',
|
||||
},
|
||||
{
|
||||
'en-US':
|
||||
'Fixed a build error that prevented the launcher from compiling.',
|
||||
'zh-CN': '修复导致启动器无法编译的构建错误。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.1-beta4',
|
||||
version: '1.0.1-beta4',
|
||||
publishedAt: '2026-09-30',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.1-beta4',
|
||||
'zh-CN': 'Starlight Launcher 1.0.1-beta4',
|
||||
},
|
||||
changes: {
|
||||
fixed: [
|
||||
{
|
||||
'en-US':
|
||||
'Fixed a build error in 1.0.1-beta3 that prevented the launcher from compiling.',
|
||||
'zh-CN': '修复 1.0.1-beta3 中导致启动器无法编译的构建错误。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.1-beta3',
|
||||
version: '1.0.1-beta3',
|
||||
|
||||
@@ -11,6 +11,7 @@ function open() {
|
||||
|
||||
<template>
|
||||
<section
|
||||
data-onboarding-id="home-announcement"
|
||||
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -201,7 +201,7 @@ onUnmounted(() => {
|
||||
data-onboarding-id="home-instances"
|
||||
class="minimal-home-stage flex min-w-0 items-center justify-center px-6 pb-14 pt-8"
|
||||
>
|
||||
<div class="flex w-full max-w-3xl flex-col items-center text-center">
|
||||
<div data-onboarding-id="home-widget-grid" class="flex w-full max-w-3xl flex-col items-center text-center">
|
||||
<HomeGreeting :player-name="playerName" variant="minimal" />
|
||||
|
||||
<template v-if="selectedInstance">
|
||||
|
||||
@@ -170,7 +170,7 @@ const {
|
||||
top: auto !important;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0 !important;
|
||||
left: var(--left-bar-width, 4rem) !important;
|
||||
z-index: 2;
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
|
||||
@@ -276,6 +276,48 @@ export const onboardingMessages = defineMessages({
|
||||
},
|
||||
skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' },
|
||||
mascotAlt: { id: 'app.onboarding.mascot-alt', defaultMessage: 'Starlight guide' },
|
||||
homeIntroTitle: { id: 'app.onboarding.home-intro.title', defaultMessage: 'Welcome home' },
|
||||
homeIntroDescription: {
|
||||
id: 'app.onboarding.home-intro.description',
|
||||
defaultMessage:
|
||||
'This is your home page. It shows your instances, recent activity, and quick shortcuts to get playing.',
|
||||
},
|
||||
accountLoginTitle: {
|
||||
id: 'app.onboarding.account-login.title',
|
||||
defaultMessage: 'Sign in to StarLight',
|
||||
},
|
||||
accountLoginDescription: {
|
||||
id: 'app.onboarding.account-login.description',
|
||||
defaultMessage:
|
||||
'Sign in to the StarLight skin site here to use your StarLight players, install hosted modpacks, and sync your account.',
|
||||
},
|
||||
announcementTitle: {
|
||||
id: 'app.onboarding.announcement.title',
|
||||
defaultMessage: 'Latest announcements',
|
||||
},
|
||||
announcementDescription: {
|
||||
id: 'app.onboarding.announcement.description',
|
||||
defaultMessage:
|
||||
'Open the announcement entry in the sidebar to read the latest news, rules, and server notices.',
|
||||
},
|
||||
createStarlightTitle: {
|
||||
id: 'app.onboarding.create-starlight.title',
|
||||
defaultMessage: 'Create a StarLight instance',
|
||||
},
|
||||
createStarlightDescription: {
|
||||
id: 'app.onboarding.create-starlight.description',
|
||||
defaultMessage:
|
||||
'Use Create new instance here. A StarLight instance installs the server-selected modpack and versions, then keeps them updated before each launch.',
|
||||
},
|
||||
settingsUpdatesTitle: {
|
||||
id: 'app.onboarding.settings-updates.title',
|
||||
defaultMessage: 'Stay up to date',
|
||||
},
|
||||
settingsUpdatesDescription: {
|
||||
id: 'app.onboarding.settings-updates.description',
|
||||
defaultMessage:
|
||||
'The Updates tab controls the update channel, checks for new launcher versions, and shows the changelog.',
|
||||
},
|
||||
})
|
||||
|
||||
const step = (
|
||||
@@ -331,20 +373,33 @@ const settingsTourSteps: Array<[string, string, MessageDescriptor, MessageDescri
|
||||
|
||||
export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
main: [
|
||||
step(
|
||||
'welcome',
|
||||
'manual',
|
||||
copy(
|
||||
onboardingMessages.welcomeTitle,
|
||||
onboardingMessages.welcomeDescription,
|
||||
onboardingMessages.start,
|
||||
),
|
||||
inspect(
|
||||
'home-intro',
|
||||
'home-widget-grid',
|
||||
onboardingMessages.homeIntroTitle,
|
||||
onboardingMessages.homeIntroDescription,
|
||||
),
|
||||
inspect(
|
||||
'home-layout-switch',
|
||||
'home-layout-switch',
|
||||
onboardingMessages.homeLayoutTitle,
|
||||
onboardingMessages.homeLayoutDescription,
|
||||
'account-login',
|
||||
'sidebar-accounts',
|
||||
onboardingMessages.accountLoginTitle,
|
||||
onboardingMessages.accountLoginDescription,
|
||||
),
|
||||
inspect(
|
||||
'announcement',
|
||||
'home-announcement',
|
||||
onboardingMessages.announcementTitle,
|
||||
onboardingMessages.announcementDescription,
|
||||
),
|
||||
step(
|
||||
'create-starlight',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.createStarlightTitle,
|
||||
onboardingMessages.createStarlightDescription,
|
||||
onboardingMessages.clickCreate,
|
||||
),
|
||||
control('create-instance', '/create'),
|
||||
),
|
||||
step(
|
||||
'discover-navigation',
|
||||
@@ -354,7 +409,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
onboardingMessages.discoverDescription,
|
||||
onboardingMessages.clickDiscover,
|
||||
),
|
||||
control('nav-discover', '/browse/modpack'),
|
||||
control('nav-discover', '/browse'),
|
||||
),
|
||||
inspect(
|
||||
'discover-content',
|
||||
@@ -362,22 +417,6 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
onboardingMessages.browseTitle,
|
||||
onboardingMessages.browseDescription,
|
||||
),
|
||||
step(
|
||||
'discover-favorites-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.favoritesTitle,
|
||||
onboardingMessages.favoritesDescription,
|
||||
onboardingMessages.clickFavorites,
|
||||
),
|
||||
control('browse-favorites-tab', '/browse/favorites'),
|
||||
),
|
||||
inspect(
|
||||
'discover-favorites-content',
|
||||
'browse-favorites-content',
|
||||
onboardingMessages.favoritesTitle,
|
||||
onboardingMessages.favoritesDescription,
|
||||
),
|
||||
step(
|
||||
'skins-navigation',
|
||||
'navigate',
|
||||
@@ -394,6 +433,22 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
onboardingMessages.skinsPageTitle,
|
||||
onboardingMessages.skinsPageDescription,
|
||||
),
|
||||
step(
|
||||
'library-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.libraryTitle,
|
||||
onboardingMessages.libraryDescription,
|
||||
onboardingMessages.clickLibrary,
|
||||
),
|
||||
control('nav-library', '/library'),
|
||||
),
|
||||
inspect(
|
||||
'library-content',
|
||||
'library-content',
|
||||
onboardingMessages.libraryPageTitle,
|
||||
onboardingMessages.libraryPageDescription,
|
||||
),
|
||||
step(
|
||||
'lab-navigation',
|
||||
'navigate',
|
||||
@@ -428,7 +483,7 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
),
|
||||
step(
|
||||
'settings-navigation',
|
||||
'activate',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.settingsTitle,
|
||||
onboardingMessages.settingsDescription,
|
||||
@@ -436,95 +491,15 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
),
|
||||
control('nav-settings', '/settings'),
|
||||
),
|
||||
...settingsTourSteps.map(([id, targetId, title, description], index) =>
|
||||
step(id, 'activate', copy(title, description, onboardingMessages.clickTab), {
|
||||
...control(targetId),
|
||||
closeSettingsAfter: index === settingsTourSteps.length - 1,
|
||||
}),
|
||||
),
|
||||
step(
|
||||
'library-navigation',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.libraryTitle,
|
||||
onboardingMessages.libraryDescription,
|
||||
onboardingMessages.clickLibrary,
|
||||
),
|
||||
control('nav-library', '/library'),
|
||||
),
|
||||
inspect(
|
||||
'library-content',
|
||||
'library-content',
|
||||
onboardingMessages.libraryPageTitle,
|
||||
onboardingMessages.libraryPageDescription,
|
||||
),
|
||||
step(
|
||||
'create-instance',
|
||||
'navigate',
|
||||
copy(
|
||||
onboardingMessages.createTitle,
|
||||
onboardingMessages.createDescription,
|
||||
onboardingMessages.clickCreate,
|
||||
),
|
||||
control('create-instance', '/create'),
|
||||
),
|
||||
inspect(
|
||||
'creation-instance-mode',
|
||||
'creation-instance-mode',
|
||||
onboardingMessages.instanceModeTitle,
|
||||
onboardingMessages.instanceModeDescription,
|
||||
),
|
||||
step(
|
||||
'creation-flow',
|
||||
'settings-updates',
|
||||
'activate',
|
||||
copy(
|
||||
onboardingMessages.creationTitle,
|
||||
onboardingMessages.creationDescription,
|
||||
onboardingMessages.clickCreationMethod,
|
||||
onboardingMessages.settingsUpdatesTitle,
|
||||
onboardingMessages.settingsUpdatesDescription,
|
||||
onboardingMessages.clickTab,
|
||||
),
|
||||
{
|
||||
targetId: 'creation-methods',
|
||||
branchByTarget: {
|
||||
'creation-method-starlight': { next: 'complete' },
|
||||
'creation-method-custom': { creationPath: 'custom', next: 'creation-name' },
|
||||
'creation-method-import': { next: 'complete' },
|
||||
},
|
||||
},
|
||||
),
|
||||
inspect(
|
||||
'creation-name',
|
||||
'creation-name',
|
||||
onboardingMessages.creationNameTitle,
|
||||
onboardingMessages.creationNameDescription,
|
||||
),
|
||||
inspect(
|
||||
'creation-loader',
|
||||
'creation-loader',
|
||||
onboardingMessages.creationLoaderTitle,
|
||||
onboardingMessages.creationLoaderDescription,
|
||||
),
|
||||
step(
|
||||
'creation-version',
|
||||
'inspect',
|
||||
copy(
|
||||
onboardingMessages.creationVersionTitle,
|
||||
onboardingMessages.creationVersionDescription,
|
||||
onboardingMessages.continueArea,
|
||||
),
|
||||
{
|
||||
targetId: 'creation-game-version',
|
||||
nextByCreationPath: { custom: 'creation-confirm' },
|
||||
},
|
||||
),
|
||||
step(
|
||||
'creation-confirm',
|
||||
'inspect',
|
||||
copy(
|
||||
onboardingMessages.creationConfirmTitle,
|
||||
onboardingMessages.creationConfirmDescription,
|
||||
onboardingMessages.finishArea,
|
||||
),
|
||||
{ targetId: 'creation-confirm' },
|
||||
control('settings-tab-updates'),
|
||||
),
|
||||
],
|
||||
instance: [
|
||||
|
||||
@@ -146,6 +146,14 @@ export function useOnboardingTour(
|
||||
|
||||
function scheduleMissingTargetRetry(stepId: string) {
|
||||
if (targetRetryCount >= missingTargetRetryLimit) {
|
||||
// 超过重试上限:inspect 步(介绍性步骤)不自动跳过,
|
||||
// 保持无锚点状态,让气泡停靠底部等用户手动继续。
|
||||
if (step.value.interaction === 'inspect') {
|
||||
targetElement.value = undefined
|
||||
targetRect.value = null
|
||||
clearModalReservation()
|
||||
return
|
||||
}
|
||||
void advance()
|
||||
return
|
||||
}
|
||||
@@ -284,7 +292,7 @@ export function useOnboardingTour(
|
||||
if (clickedElement && handleBranchClick(clickedElement)) return
|
||||
if (!targetElement.value?.contains(event.target as Node)) return
|
||||
|
||||
if (step.value.expectedPath && route.path !== step.value.expectedPath) {
|
||||
if (step.value.expectedPath && !route.path.startsWith(step.value.expectedPath)) {
|
||||
waitingForRoute.value = true
|
||||
return
|
||||
}
|
||||
@@ -301,7 +309,7 @@ export function useOnboardingTour(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
if (!waitingForRoute.value || !step.value.expectedPath) return
|
||||
if (path !== step.value.expectedPath) return
|
||||
if (!path.startsWith(step.value.expectedPath)) return
|
||||
waitingForRoute.value = false
|
||||
void advance()
|
||||
},
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<script lang="ts"></script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
|
||||
import { CheckIcon, ClipboardCopyIcon, EditIcon, MoreHorizontalIcon } from '@modrinth/assets'
|
||||
import {
|
||||
defineMessages,
|
||||
IconButton,
|
||||
useDebugLogger,
|
||||
useFormatDateTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useImageThumbnail } from '@/composables/use-image-thumbnail'
|
||||
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||
|
||||
const props = defineProps<{
|
||||
screenshot: InstanceScreenshot
|
||||
selectionKey: string
|
||||
selected: boolean
|
||||
selectionActive: boolean
|
||||
activeDragged: boolean
|
||||
canDrag: boolean
|
||||
showInstanceName: boolean
|
||||
highlighted: boolean
|
||||
copied: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'activate', event: MouseEvent | KeyboardEvent): void
|
||||
(e: 'copy' | 'edit' | 'toggle-selection'): void
|
||||
(e: 'more', event: MouseEvent): void
|
||||
}>()
|
||||
|
||||
const card = ref<HTMLElement>()
|
||||
const thumbnail = useImageThumbnail(
|
||||
() => ({
|
||||
instanceId: props.screenshot.instance_id,
|
||||
filePath: `screenshots/${props.screenshot.file_name}`,
|
||||
path: props.screenshot.path,
|
||||
}),
|
||||
512,
|
||||
() => props.screenshot.modified_at,
|
||||
)
|
||||
const image = ref<HTMLImageElement>()
|
||||
const imageReady = ref(false)
|
||||
const { formatMessage } = useVIntl()
|
||||
const debugImage = useDebugLogger('Screenshots:Card')
|
||||
const formatTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
|
||||
let loadStartedAt = performance.now()
|
||||
let loadGeneration = 0
|
||||
const messages = defineMessages({
|
||||
select: { id: 'app.screenshots.select', defaultMessage: 'Select {name}' },
|
||||
deselect: { id: 'app.screenshots.deselect', defaultMessage: 'Deselect {name}' },
|
||||
copy: { id: 'app.screenshots.copy', defaultMessage: 'Copy image' },
|
||||
copied: { id: 'app.screenshots.copied', defaultMessage: 'Copied' },
|
||||
edit: { id: 'app.screenshots.edit', defaultMessage: 'Edit screenshot' },
|
||||
moreActions: { id: 'app.screenshots.more-actions', defaultMessage: 'More actions' },
|
||||
})
|
||||
|
||||
const sensors = [
|
||||
PointerSensor.configure({
|
||||
preventActivation: () => false,
|
||||
}),
|
||||
KeyboardSensor,
|
||||
]
|
||||
|
||||
useDraggable({
|
||||
id: computed(() => `screenshot:${props.selectionKey}`),
|
||||
element: card,
|
||||
disabled: computed(() => !props.canDrag),
|
||||
sensors,
|
||||
data: computed(() => ({
|
||||
selectionKey: props.selectionKey,
|
||||
instanceId: props.screenshot.instance_id,
|
||||
})),
|
||||
})
|
||||
|
||||
function activate(event: MouseEvent | KeyboardEvent) {
|
||||
if (event instanceof KeyboardEvent) {
|
||||
if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
emit('activate', event)
|
||||
}
|
||||
|
||||
async function markImageLoaded() {
|
||||
const loadedImage = image.value
|
||||
const loadedUrl = thumbnail.value
|
||||
const generation = loadGeneration
|
||||
if (!loadedImage) return
|
||||
|
||||
try {
|
||||
await loadedImage.decode()
|
||||
} catch {
|
||||
if (!loadedImage.complete || loadedImage.naturalWidth === 0) return
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
window.requestAnimationFrame(() => window.requestAnimationFrame(() => resolve()))
|
||||
})
|
||||
|
||||
if (
|
||||
generation !== loadGeneration ||
|
||||
image.value !== loadedImage ||
|
||||
thumbnail.value !== loadedUrl
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const wasReady = imageReady.value
|
||||
imageReady.value = true
|
||||
if (!wasReady) {
|
||||
debugImage('image loaded', {
|
||||
id: props.screenshot.id,
|
||||
fileName: props.screenshot.file_name,
|
||||
url: props.screenshot.url,
|
||||
loadDurationMs: performance.now() - loadStartedAt,
|
||||
naturalWidth: image.value?.naturalWidth,
|
||||
naturalHeight: image.value?.naturalHeight,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function markImageFailed(event: Event) {
|
||||
debugImage('image failed', {
|
||||
id: props.screenshot.id,
|
||||
fileName: props.screenshot.file_name,
|
||||
url: props.screenshot.url,
|
||||
loadDurationMs: performance.now() - loadStartedAt,
|
||||
event,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
debugImage('mounted', {
|
||||
id: props.screenshot.id,
|
||||
fileName: props.screenshot.file_name,
|
||||
url: props.screenshot.url,
|
||||
complete: image.value?.complete,
|
||||
})
|
||||
if (image.value?.complete && image.value.naturalWidth > 0) markImageLoaded()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
loadGeneration += 1
|
||||
debugImage('unmounted', {
|
||||
id: props.screenshot.id,
|
||||
fileName: props.screenshot.file_name,
|
||||
url: props.screenshot.url,
|
||||
loaded: imageReady.value,
|
||||
})
|
||||
})
|
||||
|
||||
watch(thumbnail, (url, previousUrl) => {
|
||||
loadGeneration += 1
|
||||
loadStartedAt = performance.now()
|
||||
imageReady.value = false
|
||||
debugImage('source changed', {
|
||||
id: props.screenshot.id,
|
||||
fileName: props.screenshot.file_name,
|
||||
previousUrl,
|
||||
url,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<article
|
||||
ref="card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="group relative isolate aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
|
||||
:class="{
|
||||
'!border-brand brightness-110': selected,
|
||||
'!border-brand ring-2 ring-brand animate-pulse': highlighted,
|
||||
'opacity-50': activeDragged,
|
||||
'cursor-grab active:cursor-grabbing': canDrag,
|
||||
}"
|
||||
data-screenshot-card
|
||||
:data-screenshot-id="screenshot.id"
|
||||
:data-selection-key="selectionKey"
|
||||
:aria-label="
|
||||
selectionActive
|
||||
? formatMessage(selected ? messages.deselect : messages.select, {
|
||||
name: screenshot.file_name,
|
||||
})
|
||||
: screenshot.file_name
|
||||
"
|
||||
:aria-pressed="selectionActive ? selected : undefined"
|
||||
@click="activate"
|
||||
@contextmenu.prevent.stop="emit('more', $event)"
|
||||
@keydown="activate"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="selection-button group/selection absolute right-0.5 top-0 z-[3] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
|
||||
:aria-label="
|
||||
formatMessage(selected ? messages.deselect : messages.select, {
|
||||
name: screenshot.file_name,
|
||||
})
|
||||
"
|
||||
:aria-pressed="selected"
|
||||
@click.stop="emit('toggle-selection')"
|
||||
>
|
||||
<span
|
||||
class="relative flex size-6 items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100 group-focus-within:opacity-100 group-hover/selection:brightness-125"
|
||||
:class="
|
||||
selected ? 'border-0 !opacity-100' : 'border-2 border-solid border-primary bg-transparent'
|
||||
"
|
||||
>
|
||||
<span v-if="selected" class="absolute inset-0 rounded-full bg-brand" />
|
||||
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
|
||||
</span>
|
||||
</button>
|
||||
<img
|
||||
v-if="thumbnail"
|
||||
ref="image"
|
||||
:src="thumbnail"
|
||||
:alt="screenshot.file_name"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
class="screenshot-card-fade absolute inset-0 z-[1] h-full w-full object-cover"
|
||||
:class="imageReady ? 'opacity-100' : 'opacity-0'"
|
||||
@load="markImageLoaded"
|
||||
@error="markImageFailed"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 bg-surface-4"
|
||||
:class="{ 'animate-pulse': !imageReady }"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 z-[2] flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-[var(--color-text-primary)] opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div v-tooltip="screenshot.file_name" class="truncate text-sm font-semibold">
|
||||
{{ screenshot.file_name }}
|
||||
</div>
|
||||
<div class="truncate text-xs text-[var(--color-text-tertiary)]">
|
||||
{{ showInstanceName ? screenshot.instance_name : formatTime(screenshot.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!selectionActive"
|
||||
class="flex shrink-0 translate-y-1 gap-1 opacity-0 transition group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
|
||||
@click.stop
|
||||
>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.edit)"
|
||||
:label="formatMessage(messages.edit)"
|
||||
type="quiet"
|
||||
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
|
||||
@click="emit('edit')"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(copied ? messages.copied : messages.copy)"
|
||||
:label="formatMessage(copied ? messages.copied : messages.copy)"
|
||||
type="quiet"
|
||||
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
|
||||
@click="emit('copy')"
|
||||
>
|
||||
<CheckIcon v-if="copied" class="text-green" />
|
||||
<ClipboardCopyIcon v-else />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.moreActions)"
|
||||
:label="formatMessage(messages.moreActions)"
|
||||
type="quiet"
|
||||
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
|
||||
@click="emit('more', $event)"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.screenshot-card-fade {
|
||||
transition: opacity 350ms ease-in-out;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import ScreenshotDragPreview from './drag-preview.vue'
|
||||
import { gatherDuration, type ScreenshotDragGatherItem } from './use-screenshot-drag-gather'
|
||||
|
||||
const props = defineProps<{
|
||||
items: ScreenshotDragGatherItem[]
|
||||
target: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
}>()
|
||||
|
||||
const gathered = ref(false)
|
||||
const finishing = ref(false)
|
||||
let animationFrame: number | undefined
|
||||
let completionTimer: number | undefined
|
||||
|
||||
const itemContainerStyles = computed(() =>
|
||||
props.items.map((_, index) => ({
|
||||
left: `${props.target.x}px`,
|
||||
top: `${props.target.y}px`,
|
||||
zIndex: `${index}`,
|
||||
})),
|
||||
)
|
||||
|
||||
const itemStyles = computed(() =>
|
||||
props.items.map((item, index) => {
|
||||
const originX = item.rect.left - props.target.x
|
||||
const originY = item.rect.top - props.target.y
|
||||
|
||||
return {
|
||||
width: `${item.rect.width}px`,
|
||||
height: `${item.rect.height}px`,
|
||||
transform: gathered.value
|
||||
? `translate3d(${-item.rect.width / 2}px, ${-item.rect.height / 2}px, 0)`
|
||||
: `translate3d(${originX}px, ${originY}px, 0)`,
|
||||
transitionDuration: `${gatherDuration}ms`,
|
||||
transitionDelay: `${Math.min(index, 8) * 10}ms`,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
gathered.value = true
|
||||
completionTimer = window.setTimeout(
|
||||
() => {
|
||||
finishing.value = true
|
||||
emit('complete')
|
||||
},
|
||||
Math.max(gatherDuration - 250, 0),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (animationFrame !== undefined) {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
}
|
||||
if (completionTimer !== undefined) {
|
||||
clearTimeout(completionTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-[9998] overflow-hidden transition-opacity duration-150 ease-out"
|
||||
:class="finishing ? 'opacity-0' : 'opacity-100'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="item.selectionKey"
|
||||
class="fixed"
|
||||
:style="itemContainerStyles[index]"
|
||||
>
|
||||
<div
|
||||
class="origin-center transition-transform ease-[cubic-bezier(0.2,0.8,0.2,1)]"
|
||||
:style="itemStyles[index]"
|
||||
>
|
||||
<ScreenshotDragPreview :screenshot="item.screenshot" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { TagItem } from '@modrinth/ui'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { useImageThumbnail } from '@/composables/use-image-thumbnail'
|
||||
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||
|
||||
import { gatherDuration } from './use-screenshot-drag-gather'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
screenshot: InstanceScreenshot
|
||||
count?: number
|
||||
}>(),
|
||||
{
|
||||
count: 1,
|
||||
},
|
||||
)
|
||||
|
||||
const thumbnail = useImageThumbnail(
|
||||
() => ({
|
||||
instanceId: props.screenshot.instance_id,
|
||||
filePath: `screenshots/${props.screenshot.file_name}`,
|
||||
path: props.screenshot.path,
|
||||
}),
|
||||
512,
|
||||
() => props.screenshot.modified_at,
|
||||
)
|
||||
const showGatheredCount = ref(false)
|
||||
let countTimer: number | undefined
|
||||
|
||||
onMounted(() => {
|
||||
if (props.count <= 1 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
showGatheredCount.value = true
|
||||
return
|
||||
}
|
||||
|
||||
countTimer = window.setTimeout(() => {
|
||||
showGatheredCount.value = true
|
||||
countTimer = undefined
|
||||
}, gatherDuration - 150)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (countTimer !== undefined) {
|
||||
clearTimeout(countTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div aria-hidden="true" class="relative w-full select-none">
|
||||
<div
|
||||
v-if="count > 1"
|
||||
class="absolute inset-x-3 -bottom-2 top-2 rounded-xl border border-solid border-surface-5 bg-surface-2 shadow-md motion-safe:transition-opacity motion-safe:duration-200 motion-safe:delay-100 motion-safe:ease-out"
|
||||
:class="showGatheredCount ? 'opacity-60' : 'opacity-0'"
|
||||
/>
|
||||
<div
|
||||
v-if="count > 1"
|
||||
class="absolute inset-x-1.5 -bottom-1 top-1 rounded-xl border border-solid border-surface-4 bg-surface-3 shadow-md motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
|
||||
:class="showGatheredCount ? 'opacity-80' : 'opacity-0'"
|
||||
/>
|
||||
<div
|
||||
class="relative aspect-video w-full overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 opacity-90 shadow-lg"
|
||||
>
|
||||
<img v-if="thumbnail" :src="thumbnail" alt="" class="h-full w-full object-cover" />
|
||||
<TagItem
|
||||
v-if="count > 1"
|
||||
class="!absolute right-3 top-3 z-[2] border-surface-5 bg-surface-4 tabular-nums text-[var(--color-text-tertiary)] motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
|
||||
>
|
||||
{{ count }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { useDroppable } from '@dnd-kit/vue'
|
||||
import { defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||
|
||||
import ScreenshotCard from './card.vue'
|
||||
import ScreenshotSection from './section.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
title: string
|
||||
screenshots: InstanceScreenshot[]
|
||||
renderedScreenshots?: InstanceScreenshot[]
|
||||
virtualGridHeight?: number
|
||||
virtualGridTop?: number
|
||||
selectedKeys: ReadonlySet<string>
|
||||
selectionActive: boolean
|
||||
activeDraggedKeys: ReadonlySet<string>
|
||||
showDropOutline: boolean
|
||||
canDrag: boolean
|
||||
dropInstanceId?: string
|
||||
dropCustomGroup?: boolean
|
||||
dropCustomGroupId?: string
|
||||
showInstanceName: boolean
|
||||
highlightedScreenshotId?: string
|
||||
copiedScreenshotIds: ReadonlySet<string>
|
||||
forceOpen: boolean
|
||||
hideHeader?: boolean
|
||||
editableTitle?: boolean
|
||||
startEditingTitle?: boolean
|
||||
maxTitleLength?: number
|
||||
validateTitle?: (value: string) => boolean
|
||||
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
|
||||
}>()
|
||||
|
||||
const collapsed = defineModel<boolean>('collapsed', { required: true })
|
||||
const dropTarget = ref<HTMLElement>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const debugLayout = useDebugLogger('Screenshots:Group')
|
||||
const messages = defineMessages({
|
||||
emptyGroup: {
|
||||
id: 'app.screenshots.group.empty',
|
||||
defaultMessage: 'Drag and drop to add screenshots.',
|
||||
},
|
||||
})
|
||||
|
||||
const shouldShowGrid = computed(() =>
|
||||
Boolean(props.hideHeader || props.forceOpen || !collapsed.value),
|
||||
)
|
||||
const visibleScreenshots = ref<InstanceScreenshot[]>(props.renderedScreenshots ?? props.screenshots)
|
||||
const renderGrid = ref(shouldShowGrid.value)
|
||||
const virtualGridStyle = computed(() =>
|
||||
props.virtualGridHeight === undefined ? undefined : { height: `${props.virtualGridHeight}px` },
|
||||
)
|
||||
const visibleGridStyle = computed(() =>
|
||||
props.virtualGridTop === undefined
|
||||
? undefined
|
||||
: { transform: `translateY(${props.virtualGridTop}px)` },
|
||||
)
|
||||
let unmountGridTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
|
||||
async function logGeometry(reason: string) {
|
||||
await nextTick()
|
||||
const rect = dropTarget.value?.getBoundingClientRect()
|
||||
debugLayout(reason, {
|
||||
id: props.id,
|
||||
title: props.title,
|
||||
collapsed: collapsed.value,
|
||||
shouldShowGrid: shouldShowGrid.value,
|
||||
renderGrid: renderGrid.value,
|
||||
screenshotCount: props.screenshots.length,
|
||||
renderedCount: visibleScreenshots.value.length,
|
||||
firstScreenshotId: visibleScreenshots.value.at(0)?.id,
|
||||
lastScreenshotId: visibleScreenshots.value.at(-1)?.id,
|
||||
virtualGridHeight: props.virtualGridHeight,
|
||||
virtualGridTop: props.virtualGridTop,
|
||||
actual: rect
|
||||
? { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.renderedScreenshots ?? props.screenshots,
|
||||
(screenshots) => {
|
||||
if (shouldShowGrid.value) visibleScreenshots.value = screenshots
|
||||
void logGeometry('rendered screenshots changed')
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.virtualGridHeight, props.virtualGridTop] as const,
|
||||
() => void logGeometry('virtual grid geometry changed'),
|
||||
)
|
||||
|
||||
watch(
|
||||
shouldShowGrid,
|
||||
(showGrid, previouslyShown) => {
|
||||
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
|
||||
if (showGrid) {
|
||||
visibleScreenshots.value = props.renderedScreenshots ?? props.screenshots
|
||||
renderGrid.value = true
|
||||
void logGeometry('grid shown')
|
||||
return
|
||||
}
|
||||
if (!previouslyShown) {
|
||||
renderGrid.value = false
|
||||
return
|
||||
}
|
||||
unmountGridTimeout = setTimeout(() => {
|
||||
renderGrid.value = false
|
||||
void logGeometry('grid unmounted after collapse')
|
||||
unmountGridTimeout = undefined
|
||||
}, 300)
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
debugLayout('mounted', { id: props.id, title: props.title })
|
||||
void logGeometry('mounted geometry')
|
||||
if (dropTarget.value) {
|
||||
resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const rect = entry?.target.getBoundingClientRect()
|
||||
debugLayout('actual size changed', {
|
||||
id: props.id,
|
||||
title: props.title,
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
expectedHeight:
|
||||
(props.hideHeader ? 0 : 40) +
|
||||
(shouldShowGrid.value ? 10 + (props.virtualGridHeight ?? 0) : 0) +
|
||||
12,
|
||||
})
|
||||
})
|
||||
resizeObserver.observe(dropTarget.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
debugLayout('unmounted', {
|
||||
id: props.id,
|
||||
title: props.title,
|
||||
renderedCount: visibleScreenshots.value.length,
|
||||
})
|
||||
resizeObserver?.disconnect()
|
||||
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'activate', screenshot: InstanceScreenshot, event: MouseEvent | KeyboardEvent): void
|
||||
(e: 'toggle-selection' | 'copy' | 'edit', screenshot: InstanceScreenshot): void
|
||||
(e: 'more', screenshot: InstanceScreenshot, event: MouseEvent): void
|
||||
}>()
|
||||
|
||||
useDroppable({
|
||||
id: computed(() => `screenshot-group:${props.id}`),
|
||||
element: dropTarget,
|
||||
disabled: computed(() => !props.dropInstanceId && !props.dropCustomGroup),
|
||||
data: computed(() =>
|
||||
props.dropCustomGroup
|
||||
? { groupId: props.id, customGroupId: props.dropCustomGroupId ?? null }
|
||||
: { groupId: props.id, instanceId: props.dropInstanceId },
|
||||
),
|
||||
})
|
||||
|
||||
function getSelectionKey(screenshot: InstanceScreenshot) {
|
||||
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="dropTarget"
|
||||
class="group/instance-container relative select-none pb-3 transition-colors"
|
||||
>
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150 ease-out"
|
||||
enter-from-class="!opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="!opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDropOutline"
|
||||
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 border-dashed border-brand bg-brand-highlight opacity-40"
|
||||
/>
|
||||
</Transition>
|
||||
<ScreenshotSection
|
||||
v-model:collapsed="collapsed"
|
||||
:title="title"
|
||||
:count="screenshots.length"
|
||||
:force-open="forceOpen"
|
||||
:hide-header="hideHeader"
|
||||
:editable="editableTitle"
|
||||
:start-editing="startEditingTitle"
|
||||
:max-title-length="maxTitleLength"
|
||||
:validate-title="validateTitle"
|
||||
:on-title-change="onTitleChange"
|
||||
>
|
||||
<template #actions="{ startEditing }">
|
||||
<slot name="actions" :start-editing="startEditing" />
|
||||
</template>
|
||||
<div v-if="renderGrid" class="relative min-h-[45px] w-full" :style="virtualGridStyle">
|
||||
<div
|
||||
class="grid min-h-[45px] w-full grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4"
|
||||
:class="{ 'absolute inset-x-0 top-0': virtualGridHeight !== undefined }"
|
||||
:style="visibleGridStyle"
|
||||
>
|
||||
<ScreenshotCard
|
||||
v-for="screenshot in visibleScreenshots"
|
||||
:key="getSelectionKey(screenshot)"
|
||||
:screenshot="screenshot"
|
||||
:selection-key="getSelectionKey(screenshot)"
|
||||
:selected="selectedKeys.has(getSelectionKey(screenshot))"
|
||||
:selection-active="selectionActive"
|
||||
:active-dragged="activeDraggedKeys.has(getSelectionKey(screenshot))"
|
||||
:can-drag="canDrag"
|
||||
:show-instance-name="showInstanceName"
|
||||
:highlighted="highlightedScreenshotId === screenshot.id"
|
||||
:copied="copiedScreenshotIds.has(screenshot.id)"
|
||||
@activate="(event) => emit('activate', screenshot, event)"
|
||||
@toggle-selection="emit('toggle-selection', screenshot)"
|
||||
@copy="emit('copy', screenshot)"
|
||||
@edit="emit('edit', screenshot)"
|
||||
@more="(event) => emit('more', screenshot, event)"
|
||||
/>
|
||||
<p
|
||||
v-if="screenshots.length === 0"
|
||||
key="empty-group"
|
||||
class="col-span-full m-0 pl-0.5 pt-1 text-base font-base text-[var(--color-text-tertiary)] opacity-80"
|
||||
>
|
||||
{{ formatMessage(messages.emptyGroup) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScreenshotSection>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
InlineEditableText,
|
||||
TagItem,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
count: number
|
||||
collapsed?: boolean
|
||||
forceOpen?: boolean
|
||||
hideHeader?: boolean
|
||||
editable?: boolean
|
||||
startEditing?: boolean
|
||||
maxTitleLength?: number
|
||||
validateTitle?: (value: string) => boolean
|
||||
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
|
||||
}>(),
|
||||
{
|
||||
collapsed: false,
|
||||
forceOpen: false,
|
||||
hideHeader: false,
|
||||
editable: false,
|
||||
startEditing: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:collapsed', collapsed: boolean): void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const debugLayout = useDebugLogger('Screenshots:Section')
|
||||
const accordion = ref<InstanceType<typeof Accordion>>()
|
||||
const titleInput = ref<InstanceType<typeof InlineEditableText>>()
|
||||
const titleModel = ref(props.title)
|
||||
const messages = defineMessages({
|
||||
collapse: { id: 'app.screenshots.group.collapse', defaultMessage: 'Collapse group' },
|
||||
expand: { id: 'app.screenshots.group.expand', defaultMessage: 'Expand group' },
|
||||
})
|
||||
|
||||
function toggle() {
|
||||
debugLayout('toggle requested', {
|
||||
title: props.title,
|
||||
count: props.count,
|
||||
isOpen: accordion.value?.isOpen,
|
||||
})
|
||||
if (accordion.value?.isOpen) {
|
||||
accordion.value.close()
|
||||
} else {
|
||||
accordion.value?.open()
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
debugLayout('opened', { title: props.title, count: props.count })
|
||||
emit('update:collapsed', false)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
debugLayout('closed', { title: props.title, count: props.count })
|
||||
emit('update:collapsed', true)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
debugLayout('mounted', {
|
||||
title: props.title,
|
||||
count: props.count,
|
||||
collapsed: props.collapsed,
|
||||
hideHeader: props.hideHeader,
|
||||
forceOpen: props.forceOpen,
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
debugLayout('unmounted', { title: props.title, count: props.count })
|
||||
})
|
||||
|
||||
async function startTitleEditing() {
|
||||
if (!props.editable) return
|
||||
await titleInput.value?.startEditing()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.title,
|
||||
(title) => {
|
||||
titleModel.value = title
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.startEditing,
|
||||
async (shouldStartEditing) => {
|
||||
if (!shouldStartEditing) return
|
||||
await nextTick()
|
||||
await startTitleEditing()
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex w-full flex-col">
|
||||
<div
|
||||
v-if="!hideHeader"
|
||||
class="group/header flex h-10 w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
|
||||
>
|
||||
<div class="group/open-target flex min-w-0 cursor-pointer items-center gap-2" @click="toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0"
|
||||
:aria-expanded="accordion?.isOpen"
|
||||
:aria-label="formatMessage(accordion?.isOpen ? messages.collapse : messages.expand)"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<DropdownIcon
|
||||
class="size-5 shrink-0 text-[var(--color-text-tertiary)] transition-all duration-300 group-hover/open-target:text-[var(--color-text-default)]"
|
||||
:class="{ 'rotate-180': accordion?.isOpen }"
|
||||
/>
|
||||
</button>
|
||||
<InlineEditableText
|
||||
v-if="editable"
|
||||
ref="titleInput"
|
||||
v-model="titleModel"
|
||||
activation-mode="manual"
|
||||
class="!h-10 select-none text-base font-semibold text-[var(--color-text-default)] group-hover/open-target:text-[var(--color-text-primary)]"
|
||||
:edit-label="formatMessage(commonMessages.renameButton)"
|
||||
max-width="24rem"
|
||||
icon-text-class="select-none"
|
||||
:max-length="maxTitleLength"
|
||||
:validate="validateTitle"
|
||||
:on-change="onTitleChange"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="select-none truncate text-base font-semibold text-[var(--color-text-default)] group-hover/open-target:text-[var(--color-text-primary)]"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<TagItem v-if="count" class="shrink-0 border-surface-3 bg-surface-2">
|
||||
{{ count }}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1" />
|
||||
<slot name="actions" :start-editing="startTitleEditing" />
|
||||
</div>
|
||||
<Accordion
|
||||
ref="accordion"
|
||||
:open-by-default="hideHeader || !props.collapsed"
|
||||
:force-open="forceOpen"
|
||||
overflow-visible
|
||||
class="w-full"
|
||||
@on-open="handleOpen"
|
||||
@on-close="handleClose"
|
||||
>
|
||||
<div class="mt-2.5">
|
||||
<slot />
|
||||
</div>
|
||||
</Accordion>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowUpDownIcon, GridIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Button,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
defineMessages,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
const search = defineModel<string>('search', { required: true })
|
||||
const sort = defineModel<string>('sort', { required: true })
|
||||
const group = defineModel<string>('group', { required: true })
|
||||
|
||||
defineProps<{
|
||||
sortOptions: ComboboxOption<string>[]
|
||||
groupOptions: ComboboxOption<string>[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'new-group'): void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
search: { id: 'app.screenshots.search', defaultMessage: 'Search' },
|
||||
newGroup: { id: 'app.screenshots.group.new', defaultMessage: 'New group' },
|
||||
sortBy: { id: 'app.screenshots.sort-by', defaultMessage: 'Sort by' },
|
||||
groupBy: { id: 'app.screenshots.group-by', defaultMessage: 'Group by' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.search)"
|
||||
clearable
|
||||
wrapper-class="min-w-[16rem] flex-1"
|
||||
/>
|
||||
<Button @click="emit('new-group')">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.newGroup) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Combobox
|
||||
v-model="sort"
|
||||
class="w-max"
|
||||
:options="sortOptions"
|
||||
:show-icon-in-selected="false"
|
||||
dropdown-min-width="160px"
|
||||
>
|
||||
<template #prefix>
|
||||
<ArrowUpDownIcon
|
||||
class="size-5 text-[var(--color-text-default)]"
|
||||
:aria-label="formatMessage(messages.sortBy)"
|
||||
/>
|
||||
</template>
|
||||
<template #selected="{ label }">
|
||||
<span>{{ label }}</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
<Combobox
|
||||
v-model="group"
|
||||
class="w-max"
|
||||
:options="groupOptions"
|
||||
:show-icon-in-selected="false"
|
||||
dropdown-min-width="160px"
|
||||
>
|
||||
<template #prefix>
|
||||
<GridIcon
|
||||
class="size-5 text-[var(--color-text-default)]"
|
||||
:aria-label="formatMessage(messages.groupBy)"
|
||||
/>
|
||||
</template>
|
||||
<template #selected="{ label }">
|
||||
<span>{{ label }}</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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<InstanceScreenshot[]>) {
|
||||
const items = ref<ScreenshotDragGatherItem[]>([])
|
||||
const target = ref<Point>({ x: 0, y: 0 })
|
||||
const targetOffset = ref<Point>({ 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<HTMLElement>('[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,
|
||||
}
|
||||
}
|
||||
@@ -373,6 +373,12 @@ const customBackgroundPreview = computed(() =>
|
||||
: null,
|
||||
)
|
||||
|
||||
function updateUiScale(value: number) {
|
||||
const clamped = Math.min(200, Math.max(50, Math.round(value)))
|
||||
setUiScale(clamped)
|
||||
settings.value.ui_scale = clamped
|
||||
}
|
||||
|
||||
const accentColorOptions: Array<{
|
||||
value: AccentColor
|
||||
color: string
|
||||
@@ -1050,7 +1056,7 @@ watch(
|
||||
? 'border-brand bg-brand-highlight text-brand'
|
||||
: 'border-surface-4 bg-surface-3 text-secondary hover:border-surface-5 hover:text-contrast'
|
||||
"
|
||||
@click="setUiScale(preset)"
|
||||
@click="updateUiScale(preset)"
|
||||
>
|
||||
{{ preset }}%
|
||||
</button>
|
||||
@@ -1062,7 +1068,7 @@ watch(
|
||||
:max="200"
|
||||
:step="5"
|
||||
unit="%"
|
||||
@update:model-value="(value) => setUiScale(Number(value))"
|
||||
@update:model-value="(value) => updateUiScale(Number(value))"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { inject, watch } from 'vue'
|
||||
import { inject, ref, watch } from 'vue'
|
||||
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
import { isDev } from '@/helpers/utils'
|
||||
@@ -54,6 +54,25 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const featureFlagLabels: Record<FeatureFlag, string> = {
|
||||
project_background: '项目背景',
|
||||
page_path: '页面路径',
|
||||
worlds_tab: '世界标签页',
|
||||
worlds_in_home: '主页显示世界',
|
||||
server_project_qa: '服务器项目问答',
|
||||
show_version_environment_column: '显示版本环境列',
|
||||
server_ram_as_bytes_always_on: '服务器内存始终按字节显示',
|
||||
always_show_app_controls: '始终显示窗口控件',
|
||||
skip_non_essential_warnings: '跳过非必要警告',
|
||||
skip_unknown_pack_warning: '跳过未知整合包警告',
|
||||
i18n_debug: '国际化调试',
|
||||
show_instance_play_time: '显示实例游玩时间',
|
||||
page_transitions: '页面切换动画',
|
||||
advanced_filters_collapsed: '高级筛选默认折叠',
|
||||
multiplayer_in_app: '多人联机',
|
||||
auto_install_dependencies: '自动安装依赖',
|
||||
}
|
||||
|
||||
const settings = ref(await getSettings())
|
||||
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
|
||||
|
||||
@@ -85,7 +104,7 @@ watch(
|
||||
<template>
|
||||
<SettingsSection>
|
||||
<SettingsRow v-for="option in options" :key="option">
|
||||
<template #label>{{ option.replaceAll('_', ' ') }}</template>
|
||||
<template #label>{{ featureFlagLabels[option] ?? option }}</template>
|
||||
<template #control>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
@@ -124,4 +143,4 @@ watch(
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</template>
|
||||
</template>
|
||||
@@ -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?.())
|
||||
}
|
||||
@@ -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<string>
|
||||
url?: string
|
||||
users: number
|
||||
lastUsed: number
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_THUMBNAILS = 4
|
||||
const MAX_CACHED_THUMBNAILS = 256
|
||||
|
||||
const thumbnailCache = new Map<string, ThumbnailCacheEntry>()
|
||||
const thumbnailQueue: Array<() => void> = []
|
||||
let activeThumbnailRequests = 0
|
||||
|
||||
function runThumbnailQueue() {
|
||||
while (activeThumbnailRequests < MAX_CONCURRENT_THUMBNAILS && thumbnailQueue.length > 0) {
|
||||
activeThumbnailRequests += 1
|
||||
thumbnailQueue.shift()?.()
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueThumbnail<T>(task: () => Promise<T>) {
|
||||
return new Promise<T>((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<ArrayBuffer>('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<ImageThumbnailSource | null | undefined>,
|
||||
maxDimension: MaybeRefOrGetter<number> = 512,
|
||||
revision: MaybeRefOrGetter<string | number | undefined> = 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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const MAX_INSTANCE_GROUP_NAME_LENGTH = 256
|
||||
@@ -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<SymlinkCapability> {
|
||||
export async function allow_symlink_target(path: string): Promise<void> {
|
||||
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<InstanceScreenshot[]> {
|
||||
return await invoke('plugin:instance|instance_list_screenshots', { instanceId })
|
||||
}
|
||||
export async function list_all_screenshots(): Promise<InstanceScreenshot[]> {
|
||||
return await invoke('plugin:instance|instance_list_all_screenshots')
|
||||
}
|
||||
export async function list_synced_screenshots(): Promise<InstanceScreenshot[]> {
|
||||
return await invoke('plugin:instance|instance_list_synced_screenshots')
|
||||
}
|
||||
export async function list_screenshot_groups(): Promise<ScreenshotGroup[]> {
|
||||
return await invoke('plugin:instance|instance_list_screenshot_groups')
|
||||
}
|
||||
export async function create_screenshot_group(
|
||||
name: string,
|
||||
screenshotIds: string[],
|
||||
): Promise<ScreenshotGroup> {
|
||||
return await invoke('plugin:instance|instance_create_screenshot_group', {
|
||||
name,
|
||||
ids: screenshotIds,
|
||||
})
|
||||
}
|
||||
export async function rename_screenshot_group(
|
||||
id: string,
|
||||
newName: string,
|
||||
): Promise<ScreenshotGroup> {
|
||||
return await invoke('plugin:instance|instance_rename_screenshot_group', { id, newName })
|
||||
}
|
||||
export async function delete_screenshot_group(id: string): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_delete_screenshot_group', { id })
|
||||
}
|
||||
export async function set_screenshot_group_memberships(
|
||||
updates: ScreenshotGroupMembershipUpdate[],
|
||||
): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_set_screenshot_group_memberships', { updates })
|
||||
}
|
||||
export async function import_screenshot_groups(groups: ScreenshotGroupImport[]): Promise<void> {
|
||||
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<InstanceScreenshot> {
|
||||
return await invoke('plugin:instance|instance_save_edited_screenshot', {
|
||||
key,
|
||||
bytes: Array.from(pngBytes),
|
||||
mode,
|
||||
})
|
||||
}
|
||||
export async function delete_screenshots(keys: ScreenshotKey[]): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_delete_screenshots', { keys })
|
||||
}
|
||||
export async function export_screenshots(keys: ScreenshotKey[], path: string): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_export_screenshots', { keys, path })
|
||||
}
|
||||
export async function open_screenshot(key: ScreenshotKey): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_open_screenshot', { key })
|
||||
}
|
||||
export async function move_screenshots(
|
||||
keys: ScreenshotKey[],
|
||||
targetInstanceId: string,
|
||||
): Promise<ScreenshotKey[]> {
|
||||
return await invoke('plugin:instance|instance_move_screenshots', {
|
||||
keys,
|
||||
target: targetInstanceId,
|
||||
})
|
||||
}
|
||||
export function getInstanceIconUrl(iconPath?: string | null): string | undefined {
|
||||
return iconPath ? convertFileSrc(iconPath) : undefined
|
||||
}
|
||||
@@ -172,6 +172,7 @@ export type AppSettings = {
|
||||
home_layout: HomeLayout
|
||||
minimal_home_instance_id: string | null
|
||||
close_behavior: 'ask' | 'close' | 'lightweight'
|
||||
ui_scale: number
|
||||
home_widgets: HomeDashboardConfig | null
|
||||
terracotta_public_nodes: string[]
|
||||
|
||||
@@ -213,6 +214,7 @@ type LegacyMirrorSettings = {
|
||||
|
||||
function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings): AppSettings {
|
||||
settings.close_behavior ??= 'ask'
|
||||
settings.ui_scale ??= 100
|
||||
const hasLegacySettings =
|
||||
typeof settings.use_minecraft_mirror === 'boolean' &&
|
||||
typeof settings.use_modrinth_mirror === 'boolean' &&
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
/**
|
||||
* 启动器界面缩放(百分比)。
|
||||
* 这是纯本地的显示偏好,用 localStorage 保存即可,无需写入后端设置数据库。
|
||||
* 通过 Tauri 原生 webview zoom(Rust 侧 set_zoom)应用,
|
||||
* 缩放值持久化到后端设置数据库(settings.ui_scale)。
|
||||
*/
|
||||
const UI_SCALE_STORAGE_KEY = 'starlight-ui-scale'
|
||||
const MIN_UI_SCALE = 50
|
||||
const MAX_UI_SCALE = 200
|
||||
const DEFAULT_UI_SCALE = 100
|
||||
@@ -15,18 +16,7 @@ function clampUiScale(value: number): number {
|
||||
return Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Math.round(value)))
|
||||
}
|
||||
|
||||
/** 从 localStorage 读取已保存的缩放值。 */
|
||||
function readStoredUiScale(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(UI_SCALE_STORAGE_KEY)
|
||||
if (raw === null) return DEFAULT_UI_SCALE
|
||||
return clampUiScale(Number(raw))
|
||||
} catch {
|
||||
return DEFAULT_UI_SCALE
|
||||
}
|
||||
}
|
||||
|
||||
export const uiScale = ref(readStoredUiScale())
|
||||
export const uiScale = ref(DEFAULT_UI_SCALE)
|
||||
|
||||
export function setUiScale(value: number): void {
|
||||
uiScale.value = clampUiScale(value)
|
||||
@@ -38,23 +28,27 @@ export function resetUiScale(): void {
|
||||
|
||||
export { DEFAULT_UI_SCALE, MAX_UI_SCALE, MIN_UI_SCALE }
|
||||
|
||||
/** 把缩放应用到文档根节点,并在变化时持久化。 */
|
||||
export function installUiScale(): void {
|
||||
const apply = (value: number) => {
|
||||
const factor = String(value / 100)
|
||||
const root = document.documentElement
|
||||
root.style.setProperty('--ui-scale', factor)
|
||||
// 用 CSS zoom 整体缩放界面(含内嵌网页),在 WebView2/WebKit 上最可靠。
|
||||
root.style.setProperty('zoom', factor)
|
||||
/**
|
||||
* 把缩放值应用到原生 webview。
|
||||
* 同时保留 --ui-scale CSS 变量,供依赖它的样式/锚点使用。
|
||||
*/
|
||||
async function applyUiScale(value: number): Promise<void> {
|
||||
const factor = value / 100
|
||||
document.documentElement.style.setProperty('--ui-scale', String(factor))
|
||||
try {
|
||||
await invoke('plugin:settings|set_ui_scale', { factor })
|
||||
} catch (error) {
|
||||
console.warn('应用界面缩放失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
apply(uiScale.value)
|
||||
/**
|
||||
* 初始化缩放:应用当前值,并在变化时同步到原生 webview。
|
||||
* 初值由调用方(App 初始化流程)在读取后端设置后通过 setUiScale 覆盖。
|
||||
*/
|
||||
export function installUiScale(): void {
|
||||
void applyUiScale(uiScale.value)
|
||||
watch(uiScale, (value) => {
|
||||
apply(value)
|
||||
try {
|
||||
localStorage.setItem(UI_SCALE_STORAGE_KEY, String(value))
|
||||
} catch (error) {
|
||||
console.warn('保存界面缩放失败', error)
|
||||
}
|
||||
void applyUiScale(value)
|
||||
})
|
||||
}
|
||||
@@ -6061,6 +6061,36 @@
|
||||
"app.onboarding.welcome.title": {
|
||||
"message": "一切就绪"
|
||||
},
|
||||
"app.onboarding.home-intro.title": {
|
||||
"message": "欢迎回家"
|
||||
},
|
||||
"app.onboarding.home-intro.description": {
|
||||
"message": "这里是你的主页,展示你的实例、最近活动和快速开始游戏的入口。"
|
||||
},
|
||||
"app.onboarding.account-login.title": {
|
||||
"message": "登录 StarLight"
|
||||
},
|
||||
"app.onboarding.account-login.description": {
|
||||
"message": "在这里登录 StarLight 皮肤站,即可使用皮肤站玩家、安装托管整合包并同步你的账号。"
|
||||
},
|
||||
"app.onboarding.announcement.title": {
|
||||
"message": "最新公告"
|
||||
},
|
||||
"app.onboarding.announcement.description": {
|
||||
"message": "打开侧栏的公告入口,查看最新消息、服规与服务器通知。"
|
||||
},
|
||||
"app.onboarding.create-starlight.title": {
|
||||
"message": "创建 StarLight 实例"
|
||||
},
|
||||
"app.onboarding.create-starlight.description": {
|
||||
"message": "点击这里的“创建新实例”。StarLight 实例会安装服务器指定的整合包与版本,并在每次启动前保持更新。"
|
||||
},
|
||||
"app.onboarding.settings-updates.title": {
|
||||
"message": "保持最新"
|
||||
},
|
||||
"app.onboarding.settings-updates.description": {
|
||||
"message": "“更新”页用于设置更新通道、检查启动器新版本并查看更新日志。"
|
||||
},
|
||||
"app.project.curseforge.loading": {
|
||||
"message": "正在加载 CurseForge 项目…"
|
||||
},
|
||||
@@ -10428,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 皮肤站"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import ScreenshotsPage from '@/components/ui/screenshots-page/index.vue'
|
||||
|
||||
defineOptions({ name: 'ScreenshotsPage' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="box-border h-full p-6">
|
||||
<ScreenshotsPage show-heading />
|
||||
</div>
|
||||
</template>
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -14,6 +14,7 @@ export const DEFAULT_FEATURE_FLAGS = {
|
||||
skip_non_essential_warnings: false,
|
||||
skip_unknown_pack_warning: false,
|
||||
i18n_debug: false,
|
||||
multiplayer_in_app: false,
|
||||
show_instance_play_time: true,
|
||||
page_transitions: true,
|
||||
advanced_filters_collapsed: true,
|
||||
|
||||
@@ -56,13 +56,7 @@ zip = { workspace = true }
|
||||
|
||||
[dependencies.tauri]
|
||||
workspace = true
|
||||
features = [
|
||||
"devtools",
|
||||
"macos-private-api",
|
||||
"protocol-asset",
|
||||
"tray-icon",
|
||||
"unstable"
|
||||
]
|
||||
features = ["devtools", "macos-private-api", "protocol-asset", "tray-icon", "unstable"]
|
||||
|
||||
[build-dependencies]
|
||||
cc = { workspace = true }
|
||||
|
||||
@@ -365,6 +365,7 @@ fn main() {
|
||||
"install_existing_instance",
|
||||
"hosted_default",
|
||||
"hosted_set_session",
|
||||
"hosted_has_game_account",
|
||||
"hosted_create",
|
||||
"hosted_binding",
|
||||
"hosted_sync",
|
||||
@@ -508,6 +509,20 @@ fn main() {
|
||||
"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",
|
||||
])
|
||||
.default_permission(
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
@@ -524,6 +539,7 @@ fn main() {
|
||||
"proxy_get",
|
||||
"proxy_set",
|
||||
"proxy_test",
|
||||
"set_ui_scale",
|
||||
])
|
||||
.default_permission(
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
@@ -669,6 +685,7 @@ fn main() {
|
||||
"file_save_as",
|
||||
"file_read_dragged_file",
|
||||
"screenshot_thumbnail",
|
||||
"instance_icon_thumbnail",
|
||||
"studio_read_text",
|
||||
"studio_read_binary",
|
||||
"studio_write_binary",
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
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<chrono::Utc>,
|
||||
pub modified_at: i64,
|
||||
pub group_id: Option<String>,
|
||||
pub path: PathBuf,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
fn serialize_screenshot<R: tauri::Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
s: theseus::instance::InstanceScreenshot,
|
||||
) -> Result<InstanceScreenshot> {
|
||||
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<R: tauri::Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
items: Vec<theseus::instance::InstanceScreenshot>,
|
||||
) -> Result<Vec<InstanceScreenshot>> {
|
||||
items
|
||||
.into_iter()
|
||||
.map(|s| serialize_screenshot(app, s))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_list_screenshots<R: tauri::Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
instance_id: &str,
|
||||
) -> Result<Vec<InstanceScreenshot>> {
|
||||
serialize_screenshots(
|
||||
&app,
|
||||
theseus::instance::list_screenshots(instance_id).await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_list_all_screenshots<R: tauri::Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
) -> Result<Vec<InstanceScreenshot>> {
|
||||
serialize_screenshots(
|
||||
&app,
|
||||
theseus::instance::list_all_screenshots().await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_list_synced_screenshots<R: tauri::Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
) -> Result<Vec<InstanceScreenshot>> {
|
||||
serialize_screenshots(
|
||||
&app,
|
||||
theseus::instance::list_synced_screenshots().await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_save_edited_screenshot<R: tauri::Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
key: theseus::instance::ScreenshotKey,
|
||||
bytes: Vec<u8>,
|
||||
mode: theseus::instance::ScreenshotEditSaveMode,
|
||||
) -> Result<InstanceScreenshot> {
|
||||
serialize_screenshot(
|
||||
&app,
|
||||
theseus::instance::save_edited_screenshot(key, bytes, mode).await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_list_screenshot_groups()
|
||||
-> Result<Vec<theseus::instance::ScreenshotGroup>> {
|
||||
Ok(theseus::instance::list_screenshot_groups().await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_create_screenshot_group(
|
||||
name: String,
|
||||
ids: Vec<String>,
|
||||
) -> Result<theseus::instance::ScreenshotGroup> {
|
||||
Ok(theseus::instance::create_screenshot_group(name, ids).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_rename_screenshot_group(
|
||||
id: String,
|
||||
new_name: String,
|
||||
) -> Result<theseus::instance::ScreenshotGroup> {
|
||||
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<theseus::instance::ScreenshotGroupMembershipUpdate>,
|
||||
) -> Result<()> {
|
||||
Ok(theseus::instance::set_screenshot_group_memberships(updates).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_import_screenshot_groups(
|
||||
groups: Vec<theseus::instance::ScreenshotGroupImport>,
|
||||
) -> Result<()> {
|
||||
Ok(theseus::instance::import_screenshot_groups(groups).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_delete_screenshots(
|
||||
keys: Vec<theseus::instance::ScreenshotKey>,
|
||||
) -> Result<()> {
|
||||
Ok(theseus::instance::delete_screenshots(&keys).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_export_screenshots(
|
||||
keys: Vec<theseus::instance::ScreenshotKey>,
|
||||
path: PathBuf,
|
||||
) -> Result<()> {
|
||||
Ok(theseus::instance::export_screenshots(&keys, path).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_move_screenshots(
|
||||
keys: Vec<theseus::instance::ScreenshotKey>,
|
||||
target: &str,
|
||||
) -> Result<Vec<theseus::instance::ScreenshotKey>> {
|
||||
Ok(theseus::instance::move_screenshots(&keys, target).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn instance_open_screenshot<R: tauri::Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
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(())
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::api::Result;
|
||||
use tauri::{Emitter, Runtime};
|
||||
use tauri::{Emitter, Manager, Runtime};
|
||||
use theseus::prelude::*;
|
||||
use theseus::{ProxyConfig, ProxyTestResult};
|
||||
|
||||
@@ -12,7 +12,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
cancel_directory_change,
|
||||
proxy_get,
|
||||
proxy_set,
|
||||
proxy_test
|
||||
proxy_test,
|
||||
set_ui_scale
|
||||
])
|
||||
.build()
|
||||
}
|
||||
@@ -37,6 +38,17 @@ pub async fn settings_set(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_ui_scale(
|
||||
app: tauri::AppHandle<impl Runtime>,
|
||||
factor: f64,
|
||||
) -> Result<()> {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_zoom(factor);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_engine_set(
|
||||
engine: settings::DownloadEngine,
|
||||
|
||||
@@ -1,6 +1,77 @@
|
||||
{
|
||||
"updated_at": "2026-09-30T15:12:17.359Z",
|
||||
"updated_at": "2026-10-01T05:02:05.765Z",
|
||||
"announcements": [
|
||||
{
|
||||
"id": "launcher-1.0.1-beta6",
|
||||
"version": "1.0.1-beta6",
|
||||
"publishedAt": "2026-10-01",
|
||||
"title": {
|
||||
"en-US": "Starlight Launcher 1.0.1-beta6",
|
||||
"zh-CN": "Starlight Launcher 1.0.1-beta6"
|
||||
},
|
||||
"changes": {
|
||||
"fixed": [
|
||||
{
|
||||
"en-US": "Fixed the onboarding tour skipping steps automatically, getting stuck, or not navigating between steps; the tour bubble no longer covers the left navigation bar.",
|
||||
"zh-CN": "修复新手导览自动跳过、卡住或不跳转的问题;导览气泡不再遮挡左侧导航栏。"
|
||||
},
|
||||
{
|
||||
"en-US": "Fixed the onboarding tour target on the minimal home layout.",
|
||||
"zh-CN": "修复精简主页下导览目标找不到的问题。"
|
||||
}
|
||||
],
|
||||
"added": [
|
||||
{
|
||||
"en-US": "Feature flag names in developer settings are now shown in Chinese.",
|
||||
"zh-CN": "开发者设置中的功能开关名现在显示中文。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "launcher-1.0.1-beta5",
|
||||
"version": "1.0.1-beta5",
|
||||
"publishedAt": "2026-10-01",
|
||||
"title": {
|
||||
"en-US": "Starlight Launcher 1.0.1-beta5",
|
||||
"zh-CN": "Starlight Launcher 1.0.1-beta5"
|
||||
},
|
||||
"changes": {
|
||||
"added": [
|
||||
{
|
||||
"en-US": "Reworked the onboarding tour, covering the home page, StarLight login, announcements, instance creation, discovery, skins, library, lab, downloads, and update settings in order.",
|
||||
"zh-CN": "重做了新手导览,依次介绍主页、StarLight 登录、公告、创建实例、发现内容、皮肤、实例库、实验室、下载与更新设置。"
|
||||
}
|
||||
],
|
||||
"fixed": [
|
||||
{
|
||||
"en-US": "Fixed the developer feature flags page rendering blank.",
|
||||
"zh-CN": "修复开发者“功能开关”页面空白的问题。"
|
||||
},
|
||||
{
|
||||
"en-US": "Fixed a build error that prevented the launcher from compiling.",
|
||||
"zh-CN": "修复导致启动器无法编译的构建错误。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "launcher-1.0.1-beta4",
|
||||
"version": "1.0.1-beta4",
|
||||
"publishedAt": "2026-09-30",
|
||||
"title": {
|
||||
"en-US": "Starlight Launcher 1.0.1-beta4",
|
||||
"zh-CN": "Starlight Launcher 1.0.1-beta4"
|
||||
},
|
||||
"changes": {
|
||||
"fixed": [
|
||||
{
|
||||
"en-US": "Fixed a build error in 1.0.1-beta3 that prevented the launcher from compiling.",
|
||||
"zh-CN": "修复 1.0.1-beta3 中导致启动器无法编译的构建错误。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "launcher-1.0.1-beta3",
|
||||
"version": "1.0.1-beta3",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM screenshot_groups WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0a9977b44045c6d0e974770deabc5cb51b41bf5fa94b8bd4b9676f360bf2de7e"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM screenshots WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2ede94b5eab73255500fbd4b6f7eb85abf6c98d4bdfcd93a9a6054e9d5235099"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tUPDATE instances\n\t\tSET\n\t\t\tsubmitted_time_played = CASE\n\t\t\t\tWHEN submitted_time_played < 0 THEN ?\n\t\t\t\tWHEN submitted_time_played > ? THEN ?\n\t\t\t\tELSE submitted_time_played + ?\n\t\t\tEND,\n\t\t\trecent_time_played = 0,\n\t\t\tmodified = ?\n\t\tWHERE id = ?\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3af5ce6fdc84ce12da06b92950d0249bdc4023c0b3d0c683318079ce93c9dd56"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE screenshot_groups SET name = ? WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "607eac5be3e2a29bb81cce3d0ff14690b61e08f5cb3316c4349c78dc0e4d81f7"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM screenshot_group_memberships WHERE screenshot_id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6c58c5daf14ed5198c14799f4a2dd02ebf24d7d61bb74b22abdf46e53b4efdfa"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n auto_concurrent_downloads, minecraft_metadata_source,\n minecraft_file_source, modrinth_source, curseforge_source, mojang_auth_source,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n\t\t\t\tdiscord_rpc, developer_mode, telemetry, telemetry_consent_version, personalized_ads,\n onboarded, onboarding_version, onboarding_instance_tour_completed,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_memory_auto, mc_force_fullscreen, mc_maximize_window, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start, enter_lightweight_mode_on_game_launch,\n auto_set_java_high_performance_mode,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates, accent_color,\n custom_background_path, custom_background_blur, custom_background_opacity,\n transparent_background, transparent_background_opacity, transparent_background_blur,\n sidebar_instance_count, home_layout, minimal_home_instance_id,\n json(home_widgets) as \"home_widgets?: String\", auto_hide_downloads_button,\n json(terracotta_public_nodes) terracotta_public_nodes,\n version\n FROM settings\n ",
|
||||
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n auto_concurrent_downloads, minecraft_metadata_source,\n minecraft_file_source, modrinth_source, curseforge_source, mojang_auth_source,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n\t\t\t\tdiscord_rpc, developer_mode, telemetry, telemetry_consent_version, personalized_ads,\n onboarded, onboarding_version, onboarding_instance_tour_completed,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_memory_auto, mc_force_fullscreen, mc_maximize_window, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start, enter_lightweight_mode_on_game_launch,\n auto_set_java_high_performance_mode,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates, accent_color,\n custom_background_path, custom_background_blur, custom_background_opacity,\n transparent_background, transparent_background_opacity, transparent_background_blur,\n sidebar_instance_count, home_layout, minimal_home_instance_id,\n json(home_widgets) as \"home_widgets?: String\", auto_hide_downloads_button,\n json(terracotta_public_nodes) terracotta_public_nodes,\n ui_scale,\n version\n FROM settings\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -294,8 +294,13 @@
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"name": "ui_scale",
|
||||
"ordinal": 58,
|
||||
"type_info": "Float"
|
||||
},
|
||||
{
|
||||
"name": "version",
|
||||
"ordinal": 59,
|
||||
"type_info": "Integer"
|
||||
}
|
||||
],
|
||||
@@ -361,8 +366,9 @@
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4034abe449274d6b517215f0780ef9685227fb64b061b1a728aebd56cb267f76"
|
||||
"hash": "a00177609aec1a056ec47d46d4acd6d1a6244434991d3624b70c7734a2ab85c3"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -9,10 +9,7 @@ ariadne = { workspace = true }
|
||||
async-minecraft-ping = { workspace = true, features = ["srv"] }
|
||||
async-recursion = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
async-tungstenite = { workspace = true, features = [
|
||||
"tokio-runtime",
|
||||
"tokio-rustls-webpki-roots"
|
||||
] }
|
||||
async-tungstenite = { workspace = true, features = ["tokio-runtime", "tokio-rustls-webpki-roots"] }
|
||||
async-walkdir = { workspace = true }
|
||||
async_zip = { workspace = true, features = [
|
||||
"bzip2",
|
||||
@@ -119,13 +116,7 @@ tokio = { workspace = true, features = [
|
||||
"time",
|
||||
] }
|
||||
tokio-rustls = { workspace = true }
|
||||
tokio-util = { workspace = true, features = [
|
||||
"compat",
|
||||
"io",
|
||||
"io-util",
|
||||
"rt",
|
||||
"time"
|
||||
] }
|
||||
tokio-util = { workspace = true, features = ["compat", "io", "io-util", "rt", "time"] }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-error = { workspace = true }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE settings ADD COLUMN ui_scale REAL NOT NULL DEFAULT 100.0;
|
||||
@@ -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));
|
||||
@@ -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,
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ScreenshotGroupImport {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub screenshot_ids: Vec<String>,
|
||||
}
|
||||
|
||||
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<Vec<ScreenshotGroup>> {
|
||||
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<String>,
|
||||
) -> crate::Result<ScreenshotGroup> {
|
||||
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<ScreenshotGroup> {
|
||||
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<ScreenshotGroupMembershipUpdate>,
|
||||
) -> 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::<HashSet<_>>();
|
||||
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<ScreenshotGroupImport>,
|
||||
) -> 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<String>,
|
||||
) -> crate::Result<Vec<String>> {
|
||||
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<ScreenshotGroupMembershipUpdate>,
|
||||
) -> crate::Result<Vec<ScreenshotGroupMembershipUpdate>> {
|
||||
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<String>,
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<HashSet<String>> {
|
||||
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<String> {
|
||||
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<Item = String>,
|
||||
) {
|
||||
for instance_id in instance_ids {
|
||||
let _ = emit_instance(
|
||||
&instance_id,
|
||||
InstancePayloadType::ScreenshotsUpdated,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<Utc>,
|
||||
pub modified_at: i64,
|
||||
pub group_id: Option<String>,
|
||||
#[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<Vec<InstanceScreenshot>> {
|
||||
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<Vec<InstanceScreenshot>>
|
||||
{
|
||||
list_all_screenshots().await
|
||||
}
|
||||
|
||||
pub async fn list_all_screenshots() -> crate::Result<Vec<InstanceScreenshot>> {
|
||||
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<InstanceScreenshotSource>,
|
||||
) -> crate::Result<Vec<InstanceScreenshot>> {
|
||||
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::<Vec<Vec<InstanceScreenshot>>>()
|
||||
.await?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
sort_screenshots(&mut screenshots);
|
||||
Ok(screenshots)
|
||||
}
|
||||
|
||||
async fn lock_instance_screenshots<'a>(
|
||||
state: &State,
|
||||
instance_ids: impl IntoIterator<Item = &'a str>,
|
||||
) -> Vec<OwnedMutexGuard<()>> {
|
||||
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::<Vec<_>>();
|
||||
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::<String, String>::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<Vec<ScreenshotKey>> {
|
||||
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::<Vec<_>>();
|
||||
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<PathBuf> {
|
||||
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<u8>,
|
||||
mode: ScreenshotEditSaveMode,
|
||||
) -> crate::Result<InstanceScreenshot> {
|
||||
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<PathBuf> {
|
||||
// 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<PathBuf> {
|
||||
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::<String>();
|
||||
|
||||
if sanitized.is_empty() || sanitized == "." || sanitized == ".." {
|
||||
"Instance".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_archive_folder(
|
||||
base: &str,
|
||||
used_folders: &mut HashSet<String>,
|
||||
) -> 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!()
|
||||
}
|
||||
@@ -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<Utc>,
|
||||
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<ScreenshotRow>,
|
||||
) -> 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<Vec<InstanceScreenshot>> {
|
||||
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<Vec<ScannedScreenshot>> {
|
||||
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<Vec<ScannedScreenshot>> {
|
||||
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::<Utc>::from)
|
||||
.map_err(|error| IOError::with_path(error, &path))?;
|
||||
let modified_at = metadata
|
||||
.modified()
|
||||
.map(DateTime::<Utc>::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<ScannedScreenshot>,
|
||||
) -> crate::Result<Vec<InstanceScreenshot>> {
|
||||
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::<HashMap<_, _>>();
|
||||
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::<Vec<_>>()
|
||||
.await?;
|
||||
|
||||
let mut unmatched_by_hash =
|
||||
HashMap::<(String, i64), Vec<ScreenshotRow>>::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::<Vec<_>>();
|
||||
sort_screenshots(&mut screenshots);
|
||||
Ok(screenshots)
|
||||
}
|
||||
@@ -442,7 +442,8 @@ fn is_supported_game_account(
|
||||
|
||||
async fn has_game_account_in(state: &State) -> crate::Result<bool> {
|
||||
let accounts = Credentials::get_all_without_refresh(&state.pool).await?;
|
||||
Ok(accounts.values().any(|account| {
|
||||
Ok(accounts.iter().any(|account| {
|
||||
let account = account.value();
|
||||
is_supported_game_account(
|
||||
account.account_type,
|
||||
account
|
||||
|
||||
@@ -360,6 +360,7 @@ pub enum InstancePayloadType {
|
||||
revision: u64,
|
||||
},
|
||||
ServersUpdated,
|
||||
ScreenshotsUpdated,
|
||||
WorldUpdated {
|
||||
world: String,
|
||||
},
|
||||
|
||||
@@ -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<Option<InstanceScreenshotSource>> {
|
||||
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<Vec<InstanceScreenshotSource>> {
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<Vec<ScreenshotRow>> {
|
||||
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<Option<ScreenshotRow>> {
|
||||
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<Vec<ScreenshotGroupRow>> {
|
||||
Ok(sqlx::query_as!(
|
||||
ScreenshotGroupRow,
|
||||
"
|
||||
SELECT id, name
|
||||
FROM screenshot_groups
|
||||
ORDER BY display_order, name, id
|
||||
",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?)
|
||||
}
|
||||
@@ -120,6 +120,7 @@ pub struct State {
|
||||
pub(crate) install_job_cancellations: DashMap<Uuid, CancellationToken>,
|
||||
pub(crate) install_job_operation_locks:
|
||||
DashMap<Uuid, Arc<AsyncMutex<InstallJobOperationState>>>,
|
||||
pub(crate) screenshot_locks: DashMap<String, Arc<AsyncMutex<()>>>,
|
||||
|
||||
/// 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),
|
||||
|
||||
@@ -141,6 +141,8 @@ pub struct Settings {
|
||||
pub transparent_background_opacity: u32,
|
||||
pub transparent_background_blur: bool,
|
||||
pub sidebar_instance_count: u32,
|
||||
#[serde(default = "default_ui_scale")]
|
||||
pub ui_scale: f64,
|
||||
#[serde(default)]
|
||||
pub close_behavior: String,
|
||||
#[serde(default)]
|
||||
@@ -192,6 +194,10 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_ui_scale() -> f64 {
|
||||
100.0
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, Hash, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FeatureFlag {
|
||||
@@ -242,6 +248,7 @@ impl Settings {
|
||||
sidebar_instance_count, home_layout, minimal_home_instance_id,
|
||||
json(home_widgets) as \"home_widgets?: String\", auto_hide_downloads_button,
|
||||
json(terracotta_public_nodes) terracotta_public_nodes,
|
||||
ui_scale,
|
||||
version
|
||||
FROM settings
|
||||
"
|
||||
@@ -308,6 +315,7 @@ impl Settings {
|
||||
as u32,
|
||||
transparent_background_blur: res.transparent_background_blur == 1,
|
||||
sidebar_instance_count: res.sidebar_instance_count as u32,
|
||||
ui_scale: res.ui_scale,
|
||||
close_behavior,
|
||||
auto_hide_downloads_button: res.auto_hide_downloads_button == 1,
|
||||
home_layout: HomeLayout::from_string(&res.home_layout),
|
||||
@@ -603,7 +611,10 @@ impl Settings {
|
||||
.bind(self.force_unicode_font)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE settings SET ui_scale = ? WHERE id = 0")
|
||||
.bind(self.ui_scale)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-circle"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 323 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-crop"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 2v14a2 2 0 0 0 2 2h14" />
|
||||
<path d="M18 22V8a2 2 0 0 0-2-2H2" />
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 365 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-eraser"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21" />
|
||||
<path d="m5.082 11.09 8.828 8.828" />
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 462 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-highlighter"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m9 11-6 6v3h9l3-3" />
|
||||
<path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4" />
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 401 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-mouse-pointer-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z" />
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 444 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-move-up-right"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M13 5H19V11" />
|
||||
<path d="M19 5L5 19" />
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 347 B |
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-square"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 340 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-type"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 4v16" />
|
||||
<path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2" />
|
||||
<path d="M9 20h6" />
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 387 B |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"extends": "@modrinth/tooling-config/typescript/vue.json"
|
||||
"extends": "@modrinth/tooling-config/typescript/vue.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",
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
import { EditIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
placeholder?: string
|
||||
defaultValue?: string
|
||||
maxWidth?: string
|
||||
maxLength?: number
|
||||
editLabel?: string
|
||||
activationMode?: 'text' | 'icon' | 'manual'
|
||||
inputClass?: string
|
||||
buttonClass?: string
|
||||
iconTextClass?: string
|
||||
validate?: (value: string) => boolean
|
||||
onChange?: (value: string) => boolean | void | Promise<boolean | void>
|
||||
}>(),
|
||||
{
|
||||
placeholder: '',
|
||||
defaultValue: '',
|
||||
maxWidth: '100%',
|
||||
maxLength: undefined,
|
||||
editLabel: 'Edit',
|
||||
activationMode: 'text',
|
||||
validate: undefined,
|
||||
onChange: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const isEditing = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const isInvalid = ref(false)
|
||||
const input = ref<HTMLInputElement>()
|
||||
const isEditIconVisible = ref(false)
|
||||
const draft = ref(model.value)
|
||||
const originalValue = ref('')
|
||||
let editIconHideTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const displayValue = computed(() => model.value || props.defaultValue || props.placeholder)
|
||||
const sizingValue = computed(() => {
|
||||
if (isEditing.value) {
|
||||
return draft.value || props.placeholder || ' '
|
||||
}
|
||||
|
||||
return displayValue.value || ' '
|
||||
})
|
||||
|
||||
watch(model, (value) => {
|
||||
if (!isEditing.value) {
|
||||
draft.value = value
|
||||
}
|
||||
})
|
||||
|
||||
function clearEditIconHideTimeout() {
|
||||
if (editIconHideTimeout) {
|
||||
clearTimeout(editIconHideTimeout)
|
||||
editIconHideTimeout = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function showEditIcon() {
|
||||
isEditIconVisible.value = true
|
||||
}
|
||||
|
||||
function keepEditIconVisible() {
|
||||
clearEditIconHideTimeout()
|
||||
isEditIconVisible.value = true
|
||||
}
|
||||
|
||||
function hideEditIcon() {
|
||||
if (!editIconHideTimeout) {
|
||||
isEditIconVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditIconMouseleave() {
|
||||
clearEditIconHideTimeout()
|
||||
editIconHideTimeout = setTimeout(() => {
|
||||
isEditIconVisible.value = false
|
||||
editIconHideTimeout = undefined
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function startEditing() {
|
||||
if (isEditing.value) return
|
||||
|
||||
clearEditIconHideTimeout()
|
||||
originalValue.value = model.value
|
||||
draft.value = model.value
|
||||
isInvalid.value = false
|
||||
isEditing.value = true
|
||||
await nextTick()
|
||||
input.value?.focus()
|
||||
input.value?.select()
|
||||
}
|
||||
|
||||
async function applyValue() {
|
||||
if (!isEditing.value || isSaving.value) return
|
||||
|
||||
const nextValue = (draft.value || props.defaultValue).trim()
|
||||
|
||||
if (nextValue === originalValue.value) {
|
||||
model.value = nextValue
|
||||
isEditing.value = false
|
||||
isInvalid.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (props.validate && !props.validate(nextValue)) {
|
||||
isInvalid.value = true
|
||||
await nextTick()
|
||||
input.value?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
isInvalid.value = false
|
||||
|
||||
try {
|
||||
const accepted = await props.onChange?.(nextValue)
|
||||
if (accepted === false) {
|
||||
isInvalid.value = true
|
||||
isSaving.value = false
|
||||
await nextTick()
|
||||
input.value?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
model.value = nextValue
|
||||
isEditing.value = false
|
||||
} catch {
|
||||
isInvalid.value = true
|
||||
isSaving.value = false
|
||||
await nextTick()
|
||||
input.value?.focus()
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
if (isSaving.value) return
|
||||
|
||||
draft.value = originalValue.value
|
||||
isInvalid.value = false
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void applyValue()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
cancelEditing()
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(clearEditIconHideTimeout)
|
||||
|
||||
defineExpose({
|
||||
isEditing,
|
||||
startEditing,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:data-value="sizingValue"
|
||||
class="relative flex h-6 min-w-3 min-h-0 max-w-full flex-col justify-center border-b font-medium"
|
||||
:class="
|
||||
isEditing
|
||||
? [
|
||||
'after:invisible after:block after:w-full after:whitespace-pre after:content-[attr(data-value)]',
|
||||
isInvalid ? 'border-red' : 'border-[var(--color-text-primary)]',
|
||||
]
|
||||
: 'border-transparent'
|
||||
"
|
||||
:style="{ maxWidth }"
|
||||
>
|
||||
<input
|
||||
v-if="isEditing"
|
||||
ref="input"
|
||||
v-model="draft"
|
||||
type="text"
|
||||
:aria-invalid="isInvalid"
|
||||
:aria-label="editLabel"
|
||||
:disabled="isSaving"
|
||||
:maxlength="maxLength"
|
||||
:placeholder="placeholder"
|
||||
class="absolute inset-0 top-px w-full !h-full !min-h-0 min-w-0 truncate bg-transparent !p-0 text-inherit !border-b-2 border-0 !border-brand !border-solid !shadow-none [font:inherit] !outline-none"
|
||||
:class="inputClass"
|
||||
@blur="applyValue"
|
||||
@click.stop
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<div
|
||||
v-else-if="activationMode === 'icon'"
|
||||
class="group/edit-icon flex min-w-0 max-w-full items-center"
|
||||
@mouseenter="showEditIcon"
|
||||
@mouseleave="hideEditIcon"
|
||||
>
|
||||
<span class="min-w-0 truncate" :class="iconTextClass" :title="model || displayValue">
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
<span
|
||||
class="flex shrink-0 overflow-hidden transition-all duration-150"
|
||||
:class="
|
||||
isEditIconVisible
|
||||
? 'max-w-6 translate-x-0 opacity-100'
|
||||
: 'max-w-0 translate-x-1 opacity-0'
|
||||
"
|
||||
@mouseenter="keepEditIconVisible"
|
||||
@mouseleave="handleEditIconMouseleave"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 flex size-5 cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-[var(--color-text-tertiary)] transition-colors hover:text-brand focus-visible:text-[var(--color-text-primary)]"
|
||||
:aria-label="`${editLabel}: ${displayValue}`"
|
||||
@click.stop="startEditing"
|
||||
>
|
||||
<EditIcon class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="activationMode === 'text'"
|
||||
type="button"
|
||||
class="flex w-full max-w-full items-center gap-2 truncate border-0 bg-transparent p-0 text-left text-inherit transition-colors hover:text-brand focus-visible:text-[var(--color-text-primary)] [font:inherit]"
|
||||
:class="buttonClass"
|
||||
:aria-label="`${editLabel}: ${displayValue}`"
|
||||
:title="model || displayValue"
|
||||
@click="startEditing"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ displayValue }}</span>
|
||||
</button>
|
||||
<span v-else class="min-w-0 truncate" :title="model || displayValue">
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
RedoIcon,
|
||||
SaveIcon,
|
||||
TrashIcon,
|
||||
UndoIcon,
|
||||
XIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '#ui/components/base/buttons/Button.vue'
|
||||
import IconButton from '#ui/components/base/buttons/IconButton.vue'
|
||||
import Chips from '#ui/components/base/Chips.vue'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { imageViewerEditorMessages as messages } from './image-viewer-editor-messages'
|
||||
import type { ScreenshotEraserMode } from './image-viewer-editor-types'
|
||||
import type { useImageEditor } from './use-image-editor'
|
||||
|
||||
const props = defineProps<{
|
||||
editor: ReturnType<typeof useImageEditor>
|
||||
busy: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
save: [mode: 'create_copy' | 'replace_edit']
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const {
|
||||
color,
|
||||
strokeWidth,
|
||||
fontSize,
|
||||
eraserMode,
|
||||
zoom,
|
||||
isFit,
|
||||
canUndo,
|
||||
canRedo,
|
||||
canDelete,
|
||||
canZoomOut,
|
||||
canZoomIn,
|
||||
hasColorProperty,
|
||||
propertyValueKind,
|
||||
showEraserMode,
|
||||
showCropControls,
|
||||
cropWidth,
|
||||
cropHeight,
|
||||
canResetCrop,
|
||||
updateColor,
|
||||
updateStrokeWidth,
|
||||
updateFontSize,
|
||||
beginPropertyEdit,
|
||||
commitPropertyEdit,
|
||||
deleteSelection,
|
||||
resetCrop,
|
||||
undo,
|
||||
redo,
|
||||
setZoom,
|
||||
setFit,
|
||||
} = props.editor
|
||||
|
||||
const propertyValue = computed(() =>
|
||||
propertyValueKind.value === 'size' ? fontSize.value : strokeWidth.value,
|
||||
)
|
||||
const propertyMin = computed(() => (propertyValueKind.value === 'size' ? 12 : 1))
|
||||
const propertyMax = computed(() => (propertyValueKind.value === 'size' ? 120 : 40))
|
||||
const propertyProgress = computed(
|
||||
() => ((propertyValue.value - propertyMin.value) / (propertyMax.value - propertyMin.value)) * 100,
|
||||
)
|
||||
const hasPropertyControls = computed(
|
||||
() =>
|
||||
showCropControls.value ||
|
||||
showEraserMode.value ||
|
||||
hasColorProperty.value ||
|
||||
Boolean(propertyValueKind.value),
|
||||
)
|
||||
const eraserModes: ScreenshotEraserMode[] = ['element', 'area']
|
||||
|
||||
function updatePropertyValue(nextValue: number) {
|
||||
if (!Number.isFinite(nextValue)) return
|
||||
const normalizedValue = Math.min(
|
||||
propertyMax.value,
|
||||
Math.max(propertyMin.value, Math.round(nextValue)),
|
||||
)
|
||||
if (propertyValueKind.value === 'size') updateFontSize(normalizedValue)
|
||||
else updateStrokeWidth(normalizedValue)
|
||||
}
|
||||
|
||||
function handlePropertyInput(event: Event) {
|
||||
updatePropertyValue(Number((event.target as HTMLInputElement).value))
|
||||
}
|
||||
|
||||
function handleColorInput(event: Event) {
|
||||
updateColor((event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
function formatEraserMode(value: ScreenshotEraserMode) {
|
||||
return formatMessage(value === 'element' ? messages.element : messages.area)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="absolute bottom-6 left-1/2 z-10 flex w-max max-w-[calc(100%_-_3rem)] -translate-x-1/2 items-center gap-2 rounded-[20px] border border-solid border-white/10 bg-surface-3 p-2 shadow-[0_1rem_3rem_rgb(0_0_0_/_32%)] max-[900px]:flex-wrap max-[900px]:justify-center"
|
||||
@click.stop
|
||||
>
|
||||
<div v-if="hasPropertyControls" class="flex min-w-0 items-center gap-2">
|
||||
<div v-if="showCropControls" class="flex items-center gap-2">
|
||||
<span class="shrink-0 text-base font-semibold leading-5 tabular-nums text-white/60">
|
||||
{{ formatMessage(messages.cropDimensions, { width: cropWidth, height: cropHeight }) }}
|
||||
</span>
|
||||
<Button type="quiet" :disabled="!canResetCrop" @click="resetCrop">
|
||||
{{ formatMessage(messages.resetCrop) }}
|
||||
</Button>
|
||||
</div>
|
||||
<Chips
|
||||
v-if="showEraserMode"
|
||||
v-model="eraserMode"
|
||||
:items="eraserModes"
|
||||
:format-label="formatEraserMode"
|
||||
:aria-label="formatMessage(messages.eraserMode)"
|
||||
size="small"
|
||||
hide-checkmark-icon
|
||||
/>
|
||||
<input
|
||||
v-if="hasColorProperty"
|
||||
type="color"
|
||||
:value="color"
|
||||
:label="formatMessage(messages.colour)"
|
||||
:aria-label="formatMessage(messages.colour)"
|
||||
class="size-9 shrink-0 cursor-pointer rounded-xl border border-solid border-surface-5 bg-surface-4 p-1"
|
||||
@focus="beginPropertyEdit"
|
||||
@pointerdown="beginPropertyEdit"
|
||||
@input="handleColorInput"
|
||||
@change="commitPropertyEdit"
|
||||
/>
|
||||
<div
|
||||
v-if="propertyValueKind"
|
||||
class="flex w-52 min-w-0 items-center gap-2"
|
||||
@focusin="beginPropertyEdit"
|
||||
@pointerdown.capture="beginPropertyEdit"
|
||||
@change.capture="commitPropertyEdit"
|
||||
@focusout="commitPropertyEdit"
|
||||
>
|
||||
<span class="shrink-0 text-base font-semibold leading-5 text-white/60">
|
||||
{{ formatMessage(propertyValueKind === 'size' ? messages.size : messages.width) }}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
:value="propertyValue"
|
||||
:min="propertyMin"
|
||||
:max="propertyMax"
|
||||
step="1"
|
||||
:aria-label="formatMessage(propertyValueKind === 'size' ? messages.size : messages.width)"
|
||||
class="editor-property-range min-w-0 flex-1"
|
||||
:style="{ '--property-progress': `${propertyProgress}%` }"
|
||||
@input="handlePropertyInput"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
:value="propertyValue"
|
||||
:min="propertyMin"
|
||||
:max="propertyMax"
|
||||
step="1"
|
||||
:aria-label="formatMessage(propertyValueKind === 'size' ? messages.size : messages.width)"
|
||||
class="editor-property-value h-9 w-12 shrink-0 rounded-xl border border-solid border-surface-5 bg-surface-4 px-1 text-center text-base font-semibold leading-5 tabular-nums text-white outline-none focus:ring-4 focus:ring-brand-shadow"
|
||||
@input="handlePropertyInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasPropertyControls" class="h-6 w-px bg-white/10" />
|
||||
<div class="flex items-center gap-2">
|
||||
<IconButton
|
||||
v-if="canDelete"
|
||||
v-tooltip="formatMessage(messages.deleteSelection)"
|
||||
:label="formatMessage(messages.deleteSelection)"
|
||||
type="quiet"
|
||||
@click="deleteSelection"
|
||||
>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.undo)"
|
||||
:label="formatMessage(messages.undo)"
|
||||
type="quiet"
|
||||
:disabled="!canUndo"
|
||||
@click="undo"
|
||||
>
|
||||
<UndoIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.redo)"
|
||||
:label="formatMessage(messages.redo)"
|
||||
type="quiet"
|
||||
:disabled="!canRedo"
|
||||
@click="redo"
|
||||
>
|
||||
<RedoIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.zoomOut)"
|
||||
:label="formatMessage(messages.zoomOut)"
|
||||
type="quiet"
|
||||
:disabled="!canZoomOut"
|
||||
@click="setZoom(zoom - 0.1)"
|
||||
>
|
||||
<ZoomOutIcon />
|
||||
</IconButton>
|
||||
<Button
|
||||
v-tooltip="formatMessage(messages.fitToWorkspace)"
|
||||
type="quiet"
|
||||
class="w-16 px-2 tabular-nums text-white/60"
|
||||
@click="setFit"
|
||||
>
|
||||
{{ isFit ? formatMessage(messages.fit) : `${Math.round(zoom * 100)}%` }}
|
||||
</Button>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.zoomIn)"
|
||||
:label="formatMessage(messages.zoomIn)"
|
||||
type="quiet"
|
||||
:disabled="!canZoomIn"
|
||||
@click="setZoom(zoom + 0.1)"
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div class="h-6 w-px bg-white/10" />
|
||||
<Button type="quiet" :disabled="busy" @click="emit('cancel')">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</Button>
|
||||
<Button type="colored" color="brand" :disabled="busy" @click="emit('save', 'create_copy')">
|
||||
<SaveIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.saveAsCopy) }}
|
||||
</Button>
|
||||
<Button type="base" :disabled="busy" @click="emit('save', 'replace_edit')">
|
||||
<SaveIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.overwrite) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-property-range {
|
||||
height: 1rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
height: 0.25rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-brand) 0%,
|
||||
var(--color-brand) var(--property-progress),
|
||||
var(--surface-5) var(--property-progress),
|
||||
var(--surface-5) 100%
|
||||
);
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
margin-top: -0.3125rem;
|
||||
border: 2px solid var(--surface-1);
|
||||
border-radius: 999px;
|
||||
background: var(--color-brand);
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 35%);
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
&:focus-visible::-webkit-slider-thumb {
|
||||
box-shadow: 0 0 0 0.25rem var(--color-brand-shadow);
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
height: 0.25rem;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-5);
|
||||
}
|
||||
|
||||
&::-moz-range-progress {
|
||||
height: 0.25rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border: 2px solid var(--surface-1);
|
||||
border-radius: 999px;
|
||||
background: var(--color-brand);
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 35%);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-property-value {
|
||||
appearance: textfield;
|
||||
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,580 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
EditIcon,
|
||||
LeftArrowIcon,
|
||||
RightArrowIcon,
|
||||
XIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import Button from '#ui/components/base/buttons/Button.vue'
|
||||
import IconButton from '#ui/components/base/buttons/IconButton.vue'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { injectNotificationManager } from '#ui/providers'
|
||||
|
||||
import Controls from './controls.vue'
|
||||
import { imageViewerEditorMessages as messages } from './image-viewer-editor-messages'
|
||||
import type { ImageViewerEditorMode } from './image-viewer-editor-types'
|
||||
import Toolbar from './toolbar.vue'
|
||||
import type {
|
||||
ImageViewerEditorData,
|
||||
ImageViewerEditorItem,
|
||||
ImageViewerEditorMetadata,
|
||||
ImageViewerEditorSavePayload,
|
||||
} from './types'
|
||||
import { useImageEditor } from './use-image-editor'
|
||||
|
||||
const CLICK_ZOOM = 2
|
||||
const MAX_VIEW_ZOOM = 5
|
||||
const ZOOM_STEP = 1.25
|
||||
|
||||
const props = defineProps<{
|
||||
item: ImageViewerEditorItem
|
||||
mode: ImageViewerEditorMode
|
||||
index: number
|
||||
count: number
|
||||
canEdit: boolean
|
||||
saving: boolean
|
||||
loadData: (item: ImageViewerEditorItem) => Promise<ImageViewerEditorData>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
close: []
|
||||
edit: []
|
||||
next: []
|
||||
previous: []
|
||||
metadata: [metadata: ImageViewerEditorMetadata]
|
||||
save: [payload: ImageViewerEditorSavePayload]
|
||||
}>()
|
||||
|
||||
const canvasElement = ref<HTMLCanvasElement>()
|
||||
const viewport = ref<HTMLElement>()
|
||||
const fitBounds = ref<HTMLElement>()
|
||||
const exporting = ref(false)
|
||||
const discarding = ref(false)
|
||||
const loadingEditorData = ref(true)
|
||||
const nativeImageReady = ref(false)
|
||||
const spacePressed = ref(false)
|
||||
const panning = ref<{
|
||||
x: number
|
||||
y: number
|
||||
scrollLeft: number
|
||||
scrollTop: number
|
||||
moved: boolean
|
||||
}>()
|
||||
const brushPointer = ref({ x: 0, y: 0, visible: false })
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const editor = useImageEditor()
|
||||
const {
|
||||
loading,
|
||||
tool,
|
||||
color,
|
||||
strokeWidth,
|
||||
eraserMode,
|
||||
zoom,
|
||||
fitScale,
|
||||
isFit,
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
canZoomIn,
|
||||
initialize,
|
||||
dispose,
|
||||
setTool,
|
||||
setInteractionEnabled,
|
||||
discardChanges,
|
||||
fitToViewport,
|
||||
setZoom,
|
||||
setFit,
|
||||
waitForRender,
|
||||
exportPng,
|
||||
handleKeyboardShortcut,
|
||||
isTextEditing,
|
||||
resetHistory,
|
||||
} = editor
|
||||
|
||||
const busy = computed(
|
||||
() =>
|
||||
props.saving || exporting.value || discarding.value || loadingEditorData.value || loading.value,
|
||||
)
|
||||
const hasBrushPointer = computed(
|
||||
() =>
|
||||
props.mode === 'edit' &&
|
||||
(tool.value === 'pen' ||
|
||||
tool.value === 'highlight' ||
|
||||
(tool.value === 'eraser' && eraserMode.value === 'area')),
|
||||
)
|
||||
const brushPointerSize = computed(() => Math.max(2, strokeWidth.value * zoom.value))
|
||||
const brushPointerColor = computed(() => (tool.value === 'eraser' ? '#ffffff' : color.value))
|
||||
const viewZoom = computed(() => zoom.value / Math.max(fitScale.value, Number.EPSILON))
|
||||
const viewZoomPercent = computed(() => Math.round(viewZoom.value * 100))
|
||||
const viewZoomed = computed(() => viewZoom.value > 1.001)
|
||||
const canViewZoomIn = computed(() => canZoomIn.value && viewZoom.value < MAX_VIEW_ZOOM)
|
||||
const nativeImageView = computed(
|
||||
() =>
|
||||
props.mode === 'view' &&
|
||||
[props.item.src, props.item.editorSource?.path].some(isGifOrWebpSource),
|
||||
)
|
||||
const previewLoading = computed(
|
||||
() => loadingEditorData.value || (nativeImageView.value && !nativeImageReady.value),
|
||||
)
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
let initializationGeneration = 0
|
||||
let initializationChain = Promise.resolve()
|
||||
|
||||
function isGifOrWebpSource(source?: string) {
|
||||
if (!source) return false
|
||||
let pathname = source
|
||||
try {
|
||||
pathname = new URL(source, 'https://modrinth.invalid').pathname
|
||||
} catch {
|
||||
// ... ignore
|
||||
}
|
||||
try {
|
||||
pathname = decodeURIComponent(pathname)
|
||||
} catch {
|
||||
// ... ignore
|
||||
}
|
||||
return /\.(?:gif|webp)$/i.test(pathname)
|
||||
}
|
||||
|
||||
function queueInitialization() {
|
||||
const generation = ++initializationGeneration
|
||||
loadingEditorData.value = true
|
||||
nativeImageReady.value = false
|
||||
const editorDataPromise = props.loadData(props.item)
|
||||
initializationChain = initializationChain.then(async () => {
|
||||
if (generation !== initializationGeneration) return
|
||||
try {
|
||||
const editorData = await editorDataPromise
|
||||
if (generation !== initializationGeneration || !canvasElement.value) return
|
||||
await initialize(canvasElement.value, editorData, getViewportSize())
|
||||
if (generation !== initializationGeneration) return
|
||||
setInteractionEnabled(props.mode === 'edit')
|
||||
observeViewport()
|
||||
await waitForRender()
|
||||
if (generation !== initializationGeneration) return
|
||||
emit('metadata', {
|
||||
size: editorData.source.size,
|
||||
width: originalWidth.value,
|
||||
height: originalHeight.value,
|
||||
})
|
||||
} catch (error) {
|
||||
if (generation !== initializationGeneration) return
|
||||
handleError(error)
|
||||
emit('close')
|
||||
} finally {
|
||||
if (generation === initializationGeneration) {
|
||||
loadingEditorData.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function observeViewport() {
|
||||
resizeObserver?.disconnect()
|
||||
if (!viewport.value || !fitBounds.value) return
|
||||
resizeObserver = new ResizeObserver(fitEditorToViewport)
|
||||
resizeObserver.observe(viewport.value)
|
||||
resizeObserver.observe(fitBounds.value)
|
||||
fitEditorToViewport()
|
||||
}
|
||||
|
||||
function fitEditorToViewport() {
|
||||
const viewportSize = getViewportSize()
|
||||
if (!viewportSize) return
|
||||
fitToViewport(viewportSize.width, viewportSize.height)
|
||||
if (isFit.value) centerViewport()
|
||||
}
|
||||
|
||||
function getViewportSize() {
|
||||
if (!fitBounds.value) return undefined
|
||||
return {
|
||||
width: fitBounds.value.clientWidth,
|
||||
height: fitBounds.value.clientHeight,
|
||||
}
|
||||
}
|
||||
|
||||
function centerViewport() {
|
||||
requestAnimationFrame(() => {
|
||||
if (!viewport.value) return
|
||||
viewport.value.scrollLeft = Math.max(
|
||||
0,
|
||||
(viewport.value.scrollWidth - viewport.value.clientWidth) / 2,
|
||||
)
|
||||
viewport.value.scrollTop = Math.max(
|
||||
0,
|
||||
(viewport.value.scrollHeight - viewport.value.clientHeight) / 2,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
setFit()
|
||||
centerViewport()
|
||||
}
|
||||
|
||||
function setViewZoom(nextZoom: number) {
|
||||
if (nextZoom <= 1.001) {
|
||||
resetView()
|
||||
return
|
||||
}
|
||||
setZoom(fitScale.value * Math.min(MAX_VIEW_ZOOM, nextZoom))
|
||||
centerViewport()
|
||||
}
|
||||
|
||||
async function requestSave(mode: 'create_copy' | 'replace_edit') {
|
||||
if (busy.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
emit('save', {
|
||||
item: props.item,
|
||||
pngBytes: await exportPng(),
|
||||
mode,
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (busy.value) return
|
||||
discarding.value = true
|
||||
try {
|
||||
await discardChanges()
|
||||
emit('cancel')
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
discarding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (props.mode !== 'edit' || document.querySelector('.modal-root')) return
|
||||
if (handleKeyboardShortcut(event)) return
|
||||
if (event.code === 'Space' && !isTextEditing() && !isTypingTarget(event.target)) {
|
||||
spacePressed.value = true
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
void cancel()
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyup(event: KeyboardEvent) {
|
||||
if (event.code === 'Space') spacePressed.value = false
|
||||
}
|
||||
|
||||
function handleEditMenuUndo(event: Event) {
|
||||
if (props.mode !== 'edit' || isTextEditing() || isTypingTarget(document.activeElement)) return
|
||||
event.preventDefault()
|
||||
void editor.undo()
|
||||
}
|
||||
|
||||
function handleEditMenuRedo(event: Event) {
|
||||
if (props.mode !== 'edit' || isTextEditing() || isTypingTarget(document.activeElement)) return
|
||||
event.preventDefault()
|
||||
void editor.redo()
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!viewport.value) return
|
||||
const target = event.target
|
||||
if (props.mode === 'view' && event.button === 0 && target === event.currentTarget) {
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
const isCanvasTarget = target instanceof Element && Boolean(target.closest('.editor-canvas'))
|
||||
const isViewPan = props.mode === 'view' && event.button === 0 && isCanvasTarget
|
||||
const isEditPan =
|
||||
props.mode === 'edit' && (event.button === 1 || (event.button === 0 && spacePressed.value))
|
||||
if (!isViewPan && !isEditPan) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
panning.value = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
scrollLeft: viewport.value.scrollLeft,
|
||||
scrollTop: viewport.value.scrollTop,
|
||||
moved: false,
|
||||
}
|
||||
viewport.value.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function movePan(event: PointerEvent) {
|
||||
if (!viewport.value || !panning.value) return
|
||||
const deltaX = event.clientX - panning.value.x
|
||||
const deltaY = event.clientY - panning.value.y
|
||||
if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) panning.value.moved = true
|
||||
viewport.value.scrollLeft = panning.value.scrollLeft - deltaX
|
||||
viewport.value.scrollTop = panning.value.scrollTop - deltaY
|
||||
}
|
||||
|
||||
function stopPan() {
|
||||
const pan = panning.value
|
||||
panning.value = undefined
|
||||
if (!pan || props.mode !== 'view') return
|
||||
if (pan.moved) return
|
||||
if (viewZoomed.value) resetView()
|
||||
else setViewZoom(CLICK_ZOOM)
|
||||
}
|
||||
|
||||
function updateBrushPointer(event: PointerEvent) {
|
||||
if (!hasBrushPointer.value || panning.value) {
|
||||
brushPointer.value.visible = false
|
||||
return
|
||||
}
|
||||
const bounds = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
brushPointer.value = {
|
||||
x: event.clientX - bounds.left,
|
||||
y: event.clientY - bounds.top,
|
||||
visible: true,
|
||||
}
|
||||
}
|
||||
|
||||
function handleWheel(event: WheelEvent) {
|
||||
if (props.mode === 'view') {
|
||||
event.preventDefault()
|
||||
setViewZoom(viewZoom.value * (event.deltaY < 0 ? 1.1 : 1 / 1.1))
|
||||
return
|
||||
}
|
||||
if (!event.ctrlKey && !event.metaKey) return
|
||||
event.preventDefault()
|
||||
setZoom(zoom.value + (event.deltaY < 0 ? 0.1 : -0.1))
|
||||
}
|
||||
|
||||
function isTypingTarget(target: EventTarget | null) {
|
||||
return (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
(target instanceof HTMLElement && target.isContentEditable)
|
||||
)
|
||||
}
|
||||
|
||||
function markSaved() {
|
||||
resetHistory()
|
||||
}
|
||||
|
||||
watch(() => [props.item.id, props.item.src], queueInitialization)
|
||||
watch(
|
||||
() => props.mode,
|
||||
(mode) => {
|
||||
spacePressed.value = false
|
||||
panning.value = undefined
|
||||
brushPointer.value.visible = false
|
||||
if (mode === 'view' && nativeImageView.value) nativeImageReady.value = false
|
||||
setInteractionEnabled(mode === 'edit')
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
document.addEventListener('keyup', handleKeyup)
|
||||
document.addEventListener('edit-menu:undo', handleEditMenuUndo)
|
||||
document.addEventListener('edit-menu:redo', handleEditMenuRedo)
|
||||
await nextTick()
|
||||
queueInitialization()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
initializationGeneration++
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.removeEventListener('keyup', handleKeyup)
|
||||
document.removeEventListener('edit-menu:undo', handleEditMenuUndo)
|
||||
document.removeEventListener('edit-menu:redo', handleEditMenuRedo)
|
||||
resizeObserver?.disconnect()
|
||||
void dispose()
|
||||
})
|
||||
|
||||
defineExpose({ markSaved })
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
ref="viewport"
|
||||
class="editor-viewport absolute inset-0 flex min-h-0 min-w-0 select-none overflow-auto px-6 pb-[5.75rem] pt-[4.75rem] max-[900px]:px-4 max-[900px]:pb-[8.5rem] max-[900px]:pt-[4.5rem]"
|
||||
:class="{
|
||||
'is-view': mode === 'view',
|
||||
'is-view-zoomed': mode === 'view' && viewZoomed,
|
||||
'is-panning': panning,
|
||||
'is-pan-ready': mode === 'edit' && spacePressed && !panning,
|
||||
}"
|
||||
@pointerdown.capture="handlePointerDown"
|
||||
@pointermove="movePan"
|
||||
@pointerup="stopPan"
|
||||
@pointercancel="panning = undefined"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<div
|
||||
v-if="previewLoading"
|
||||
class="pointer-events-none relative z-[3] m-auto h-full w-full shrink-0 animate-pulse rounded-md bg-surface-2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
v-show="!previewLoading"
|
||||
class="editor-canvas relative z-[2] m-auto shrink-0"
|
||||
:class="{
|
||||
'h-full w-full': nativeImageView && previewLoading,
|
||||
'is-native-image-view': nativeImageView,
|
||||
}"
|
||||
@pointerenter="updateBrushPointer"
|
||||
@pointermove="updateBrushPointer"
|
||||
@pointerleave="brushPointer.visible = false"
|
||||
>
|
||||
<canvas ref="canvasElement" :aria-label="item.alt" role="img" />
|
||||
<img
|
||||
v-if="nativeImageView"
|
||||
:src="item.src"
|
||||
:alt="item.alt"
|
||||
class="pointer-events-none absolute inset-0 z-[2] h-full w-full object-contain"
|
||||
draggable="false"
|
||||
@load="nativeImageReady = true"
|
||||
/>
|
||||
<div
|
||||
v-if="hasBrushPointer && brushPointer.visible"
|
||||
class="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-1/2 rounded-full border border-solid shadow-[0_0_0_1px_rgb(0_0_0_/_80%),inset_0_0_0_1px_rgb(255_255_255_/_35%)]"
|
||||
:style="{
|
||||
left: `${brushPointer.x}px`,
|
||||
top: `${brushPointer.y}px`,
|
||||
width: `${brushPointerSize}px`,
|
||||
height: `${brushPointerSize}px`,
|
||||
borderColor: brushPointerColor,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="tool !== 'eraser'"
|
||||
class="absolute inset-0 rounded-full opacity-[0.15]"
|
||||
:style="{ backgroundColor: color }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="fitBounds"
|
||||
class="pointer-events-none absolute inset-x-6 bottom-[5.75rem] top-[4.75rem] z-[3] flex items-center justify-center max-[900px]:inset-x-4 max-[900px]:bottom-[8.5rem] max-[900px]:top-[4.5rem]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<template v-if="mode === 'edit'">
|
||||
<Toolbar :tool="tool" @select="setTool" />
|
||||
<Controls :editor="editor" :busy="busy" @cancel="cancel" @save="requestSave" />
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="absolute bottom-6 left-1/2 z-10 flex max-w-[calc(100%_-_3rem)] -translate-x-1/2 items-center gap-2 rounded-[20px] border border-solid border-white/10 bg-surface-3 p-2 shadow-[0_1rem_3rem_rgb(0_0_0_/_32%)]"
|
||||
@click.stop
|
||||
>
|
||||
<div v-if="count > 1" class="flex items-center gap-2">
|
||||
<IconButton :label="formatMessage(messages.previous)" type="quiet" @click="emit('previous')">
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
</IconButton>
|
||||
<span
|
||||
class="flex min-w-14 justify-center gap-1 text-base font-semibold leading-5 tabular-nums text-[var(--color-text-tertiary)]"
|
||||
aria-live="polite"
|
||||
>
|
||||
<strong class="font-semibold text-[var(--color-text-primary)]">{{ index + 1 }}</strong>
|
||||
<span>/ {{ count }}</span>
|
||||
</span>
|
||||
<IconButton :label="formatMessage(messages.next)" type="quiet" @click="emit('next')">
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div v-if="count > 1" class="h-6 w-px bg-white/10" />
|
||||
<div class="flex items-center gap-2">
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.zoomOut)"
|
||||
:label="formatMessage(messages.zoomOut)"
|
||||
type="quiet"
|
||||
:disabled="!viewZoomed"
|
||||
@click="setViewZoom(viewZoom / ZOOM_STEP)"
|
||||
>
|
||||
<ZoomOutIcon aria-hidden="true" />
|
||||
</IconButton>
|
||||
<Button
|
||||
v-tooltip="formatMessage(messages.fitToWorkspace)"
|
||||
type="quiet"
|
||||
class="w-16 px-2 tabular-nums text-[var(--color-text-tertiary)]"
|
||||
@click="resetView"
|
||||
>
|
||||
{{ viewZoomPercent }}%
|
||||
</Button>
|
||||
<IconButton
|
||||
v-tooltip="formatMessage(messages.zoomIn)"
|
||||
:label="formatMessage(messages.zoomIn)"
|
||||
type="quiet"
|
||||
:disabled="!canViewZoomIn"
|
||||
@click="setViewZoom(viewZoom * ZOOM_STEP)"
|
||||
>
|
||||
<ZoomInIcon aria-hidden="true" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div class="h-6 w-px bg-white/10" />
|
||||
<IconButton
|
||||
v-if="canEdit"
|
||||
v-tooltip="formatMessage(messages.edit)"
|
||||
:label="formatMessage(messages.edit)"
|
||||
type="quiet"
|
||||
@click="emit('edit')"
|
||||
>
|
||||
<EditIcon aria-hidden="true" />
|
||||
</IconButton>
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<div class="h-6 w-px bg-white/10" />
|
||||
<IconButton :label="formatMessage(messages.close)" type="quiet" @click="emit('close')">
|
||||
<XIcon aria-hidden="true" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-viewport {
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-canvas :deep(.canvas-container) {
|
||||
flex: none;
|
||||
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 24%);
|
||||
}
|
||||
|
||||
.editor-canvas.is-native-image-view :deep(.canvas-container) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.editor-viewport.is-view .editor-canvas.is-native-image-view {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.editor-viewport.is-view-zoomed .editor-canvas.is-native-image-view {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.editor-viewport.is-panning .editor-canvas.is-native-image-view {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.editor-viewport.is-view :deep(.upper-canvas) {
|
||||
cursor: zoom-in !important;
|
||||
}
|
||||
|
||||
.editor-viewport.is-view-zoomed :deep(.upper-canvas),
|
||||
.editor-viewport.is-pan-ready :deep(.upper-canvas) {
|
||||
cursor: grab !important;
|
||||
}
|
||||
|
||||
.editor-viewport.is-panning :deep(.upper-canvas) {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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<string, unknown> & {
|
||||
editorKind?: ScreenshotEditorObjectKind
|
||||
sourceRect?: ScreenshotEditorSourceRect
|
||||
censorColor?: string
|
||||
}
|
||||
|
||||
export type EditorHistoryEntry = {
|
||||
objects: ScreenshotEditorObjectState[]
|
||||
crop: ScreenshotEditorSourceRect
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useFormatBytes } from '#ui/composables/format-bytes'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { injectImageViewerEditor } from '#ui/providers/image-viewer-editor'
|
||||
|
||||
import Editor from './editor.vue'
|
||||
import { imageViewerEditorMessages as messages } from './image-viewer-editor-messages'
|
||||
import type { ImageViewerEditorMode } from './image-viewer-editor-types'
|
||||
import type {
|
||||
ImageViewerEditorData,
|
||||
ImageViewerEditorItem,
|
||||
ImageViewerEditorMetadata,
|
||||
ImageViewerEditorSavePayload,
|
||||
} from './types'
|
||||
|
||||
const MAX_CACHED_ITEMS = 5
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: ImageViewerEditorItem[]
|
||||
editor?: 'enabled' | 'disabled'
|
||||
saving?: boolean
|
||||
}>(),
|
||||
{
|
||||
editor: 'disabled',
|
||||
saving: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
show: [item: ImageViewerEditorItem, index: number]
|
||||
hide: []
|
||||
navigate: [item: ImageViewerEditorItem, index: number, direction: 'next' | 'previous']
|
||||
save: [payload: ImageViewerEditorSavePayload]
|
||||
}>()
|
||||
|
||||
const activeId = ref<string | null>(null)
|
||||
const mode = ref<ImageViewerEditorMode>('view')
|
||||
const closeAfterEditing = ref(false)
|
||||
const editorComponent = ref<InstanceType<typeof Editor>>()
|
||||
const activeMetadata = ref<ImageViewerEditorMetadata | null>(null)
|
||||
const context = injectImageViewerEditor(null)
|
||||
const itemDataCache = new Map<string, Promise<ImageViewerEditorData>>()
|
||||
const itemImageCache = new Map<string, HTMLImageElement>()
|
||||
const formatBytes = useFormatBytes()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const activeIndex = computed(() => props.items.findIndex((item) => item.id === activeId.value))
|
||||
const activeItem = computed(() => props.items[activeIndex.value] ?? null)
|
||||
const canEdit = computed(
|
||||
() =>
|
||||
props.editor === 'enabled' &&
|
||||
Boolean(context?.loadEditorData) &&
|
||||
Boolean(activeItem.value?.editorSource),
|
||||
)
|
||||
|
||||
watch(activeItem, (item, previousItem) => {
|
||||
if (item?.id !== previousItem?.id || item?.src !== previousItem?.src) activeMetadata.value = null
|
||||
if (!item && activeId.value !== null) hide()
|
||||
})
|
||||
|
||||
function getItemCacheKey(item: ImageViewerEditorItem) {
|
||||
return [item.id, item.src, item.editorSource?.id, item.editorSource?.path].join('\0')
|
||||
}
|
||||
|
||||
function loadItemData(item: ImageViewerEditorItem): Promise<ImageViewerEditorData> {
|
||||
const key = getItemCacheKey(item)
|
||||
const cached = itemDataCache.get(key)
|
||||
if (cached) {
|
||||
itemDataCache.delete(key)
|
||||
itemDataCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
if (item.editorSource && context) return await context.loadEditorData(item.editorSource)
|
||||
const response = await fetch(item.src)
|
||||
if (!response.ok) throw new Error(`Could not load image: ${response.statusText}`)
|
||||
return { source: await response.blob() }
|
||||
})()
|
||||
|
||||
itemDataCache.set(key, promise)
|
||||
while (itemDataCache.size > MAX_CACHED_ITEMS) {
|
||||
const oldestKey = itemDataCache.keys().next().value
|
||||
if (oldestKey === undefined) break
|
||||
itemDataCache.delete(oldestKey)
|
||||
}
|
||||
void promise.catch(() => {
|
||||
if (itemDataCache.get(key) === promise) itemDataCache.delete(key)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
function preloadItemImage(item: ImageViewerEditorItem) {
|
||||
const key = getItemCacheKey(item)
|
||||
const cached = itemImageCache.get(key)
|
||||
if (cached) {
|
||||
itemImageCache.delete(key)
|
||||
itemImageCache.set(key, cached)
|
||||
return
|
||||
}
|
||||
|
||||
const image = new Image()
|
||||
image.src = item.src
|
||||
itemImageCache.set(key, image)
|
||||
while (itemImageCache.size > MAX_CACHED_ITEMS) {
|
||||
const oldestKey = itemImageCache.keys().next().value
|
||||
if (oldestKey === undefined) break
|
||||
itemImageCache.delete(oldestKey)
|
||||
}
|
||||
void image.decode().catch(() => undefined)
|
||||
}
|
||||
|
||||
function preloadItemsAround(index: number) {
|
||||
if (!props.items.length) return
|
||||
const indexes = new Set([
|
||||
index,
|
||||
(index - 1 + props.items.length) % props.items.length,
|
||||
(index + 1) % props.items.length,
|
||||
])
|
||||
for (const itemIndex of indexes) {
|
||||
const item = props.items[itemIndex]
|
||||
if (item) {
|
||||
preloadItemImage(item)
|
||||
void loadItemData(item).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function show(index: number) {
|
||||
const item = props.items[index]
|
||||
if (!item) return
|
||||
preloadItemsAround(index)
|
||||
if (activeId.value === null) {
|
||||
context?.onShow?.()
|
||||
}
|
||||
activeId.value = item.id
|
||||
mode.value = 'view'
|
||||
closeAfterEditing.value = false
|
||||
emit('show', item, index)
|
||||
}
|
||||
|
||||
async function edit(index: number) {
|
||||
show(index)
|
||||
await beginEditing(true)
|
||||
}
|
||||
|
||||
async function beginEditing(closeOnCancel = false) {
|
||||
if (!canEdit.value || props.saving) return
|
||||
closeAfterEditing.value = closeOnCancel
|
||||
mode.value = 'edit'
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function finishEditing() {
|
||||
if (props.saving) return
|
||||
if (closeAfterEditing.value) {
|
||||
hide()
|
||||
} else {
|
||||
mode.value = 'view'
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (activeId.value === null || props.saving) return
|
||||
activeId.value = null
|
||||
mode.value = 'view'
|
||||
closeAfterEditing.value = false
|
||||
itemDataCache.clear()
|
||||
itemImageCache.clear()
|
||||
context?.onHide?.()
|
||||
emit('hide')
|
||||
}
|
||||
|
||||
function navigate(offset: number, direction: 'next' | 'previous') {
|
||||
if (mode.value !== 'view' || props.items.length < 2) return
|
||||
const index = (activeIndex.value + offset + props.items.length) % props.items.length
|
||||
preloadItemsAround(index)
|
||||
activeId.value = props.items[index].id
|
||||
emit('navigate', props.items[index], index, direction)
|
||||
}
|
||||
|
||||
function next() {
|
||||
navigate(1, 'next')
|
||||
}
|
||||
|
||||
function previous() {
|
||||
navigate(-1, 'previous')
|
||||
}
|
||||
|
||||
async function markSavedAndView(itemId?: string) {
|
||||
editorComponent.value?.markSaved()
|
||||
mode.value = 'view'
|
||||
closeAfterEditing.value = false
|
||||
await nextTick()
|
||||
if (itemId && props.items.some((item) => item.id === itemId)) activeId.value = itemId
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!activeItem.value || mode.value === 'edit' || document.querySelector('.modal-root')) return
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
hide()
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
previous()
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', handleKeydown))
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
itemDataCache.clear()
|
||||
itemImageCache.clear()
|
||||
if (activeId.value !== null) context?.onHide?.()
|
||||
})
|
||||
|
||||
defineExpose({ show, edit, hide, next, previous, markSavedAndView })
|
||||
</script>
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="image-viewer">
|
||||
<div
|
||||
v-if="activeItem"
|
||||
class="fixed inset-0 z-[110] overflow-hidden bg-black/95 text-white"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="activeItem.title || activeItem.alt"
|
||||
@click.self="mode === 'view' && hide()"
|
||||
>
|
||||
<header
|
||||
v-if="activeItem.title || activeItem.description || activeMetadata"
|
||||
class="absolute inset-x-6 top-[calc(var(--top-bar-height,3rem)_+_1.5rem)] z-10 min-w-0"
|
||||
@click.stop
|
||||
>
|
||||
<div class="viewer-heading-text w-fit min-w-0 max-w-full">
|
||||
<h2
|
||||
v-if="activeItem.title"
|
||||
class="m-0 max-w-[min(42rem,70vw)] truncate text-base font-semibold leading-snug text-[var(--color-text-primary)]"
|
||||
>
|
||||
{{ activeItem.title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="activeItem.description"
|
||||
class="mb-0 mt-1 max-w-[min(42rem,70vw)] truncate text-xs leading-snug text-white"
|
||||
>
|
||||
{{ activeItem.description }}
|
||||
</p>
|
||||
<p
|
||||
v-if="activeMetadata"
|
||||
class="mb-0 mt-1 max-w-[min(42rem,70vw)] truncate text-xs leading-snug text-white"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.metadata, {
|
||||
size: formatBytes(activeMetadata.size),
|
||||
width: activeMetadata.width,
|
||||
height: activeMetadata.height,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Editor
|
||||
ref="editorComponent"
|
||||
:item="activeItem"
|
||||
:mode="mode"
|
||||
:index="activeIndex"
|
||||
:count="items.length"
|
||||
:can-edit="canEdit"
|
||||
:saving="saving"
|
||||
:load-data="loadItemData"
|
||||
@close="hide"
|
||||
@edit="beginEditing"
|
||||
@next="next"
|
||||
@previous="previous"
|
||||
@metadata="activeMetadata = $event"
|
||||
@cancel="finishEditing"
|
||||
@save="emit('save', $event)"
|
||||
>
|
||||
<template #actions>
|
||||
<slot name="actions" :item="activeItem" :index="activeIndex" :hide="hide" />
|
||||
</template>
|
||||
</Editor>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.image-viewer-enter-active {
|
||||
transition: opacity 180ms ease-out;
|
||||
}
|
||||
|
||||
.image-viewer-leave-active {
|
||||
transition: opacity 120ms ease-in;
|
||||
}
|
||||
|
||||
.image-viewer-enter-from,
|
||||
.image-viewer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.viewer-heading-text {
|
||||
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 90%));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.image-viewer-enter-active,
|
||||
.image-viewer-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||