feat: screenshots tab!

This commit is contained in:
neoapps-dev
2026-04-12 18:31:57 +03:00
parent 55acb96468
commit c49f62e539
12 changed files with 561 additions and 62 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

171
src-tauri/Cargo.lock generated
View File

@@ -284,6 +284,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bit_field"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -512,6 +518,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "combine"
version = "4.6.7"
@@ -624,12 +636,37 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -911,6 +948,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embed-resource"
version = "3.0.8"
@@ -937,7 +980,9 @@ version = "1.0.1"
dependencies = [
"base64 0.21.7",
"futures-util",
"image 0.24.9",
"libc",
"percent-encoding",
"reqwest 0.11.27",
"rfd",
"serde",
@@ -1055,6 +1100,21 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "exr"
version = "1.74.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
dependencies = [
"bit_field",
"half",
"lebe",
"miniz_oxide",
"rayon-core",
"smallvec",
"zune-inflate",
]
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -1424,6 +1484,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "gif"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gilrs"
version = "0.11.1"
@@ -1627,6 +1697,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1990,6 +2071,24 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.24.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
dependencies = [
"bytemuck",
"byteorder",
"color_quant",
"exr",
"gif",
"jpeg-decoder",
"num-traits",
"png 0.17.16",
"qoi",
"tiff",
]
[[package]]
name = "image"
version = "0.25.10"
@@ -2163,6 +2262,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "jpeg-decoder"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
dependencies = [
"rayon",
]
[[package]]
name = "js-sys"
version = "0.3.94"
@@ -2226,6 +2334,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lebe"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]]
name = "libappindicator"
version = "0.9.0"
@@ -3212,6 +3326,15 @@ version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]]
name = "qoi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
dependencies = [
"bytemuck",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -3367,6 +3490,26 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -4279,7 +4422,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http 1.4.0",
"image",
"image 0.25.10",
"jni",
"libc",
"log",
@@ -4622,6 +4765,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tiff"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e"
dependencies = [
"flate2",
"jpeg-decoder",
"weezl",
]
[[package]]
name = "time"
version = "0.3.47"
@@ -5440,6 +5594,12 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "winapi"
version = "0.3.9"
@@ -6361,6 +6521,15 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-inflate"
version = "0.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
dependencies = [
"simd-adler32",
]
[[package]]
name = "zvariant"
version = "5.10.0"

View File

@@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri = { version = "2", features = [ "tray-icon", "image-png"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -26,3 +26,5 @@ tokio-util = { version = "0.7.18", features = ["rt"] }
base64 = "0.21"
rfd = "0.15"
libc = "0.2"
image = "0.24"
percent-encoding = "2.3"

View File

@@ -1,26 +1,30 @@
{
"identifier": "default",
"description": "Default permissions",
"windows": [
"main"
],
"permissions": [
"core:default",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-set-title",
"core:window:allow-start-dragging",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-unminimize",
"core:window:allow-set-focus",
"opener:default",
"gamepad:default",
"drpc:allow-is-running",
"drpc:default",
"core:event:allow-listen",
"core:app:default"
]
{
"identifier": "default",
"description": "Default permissions",
"windows": [
"main"
],
"permissions": [
"core:default",
"core:app:default",
"core:event:default",
"core:image:default",
"core:menu:default",
"core:path:default",
"core:resources:default",
"core:tray:default",
"core:webview:default",
"core:window:default",
"opener:default",
"gamepad:default",
"drpc:default",
{
"identifier": "opener:allow-open-path",
"allow": [{ "path": "**" }]
},
{
"identifier": "opener:allow-reveal-item-in-dir",
"allow": [{ "path": "**" }]
}
]
}

View File

@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default permissions","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-toggle-maximize","core:window:allow-close","core:window:allow-set-title","core:window:allow-start-dragging","core:window:allow-show","core:window:allow-hide","core:window:allow-unminimize","core:window:allow-set-focus","opener:default","gamepad:default","drpc:allow-is-running","drpc:default","core:event:allow-listen","core:app:default"]}}
{"default":{"identifier":"default","description":"Default permissions","local":true,"windows":["main"],"permissions":["core:default","core:app:default","core:event:default","core:image:default","core:menu:default","core:path:default","core:resources:default","core:tray:default","core:webview:default","core:window:default","opener:default","gamepad:default","drpc:default",{"identifier":"opener:allow-open-path","allow":[{"path":"**"}]},{"identifier":"opener:allow-reveal-item-in-dir","allow":[{"path":"**"}]}]}}

View File

@@ -73,6 +73,15 @@ pub struct Runner {
pub r#type: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ScreenshotInfo {
pub path: String,
pub instance_id: String,
pub name: String,
pub date: u64,
}
pub struct DownloadState { pub token: Arc<Mutex<Option<CancellationToken>>> }
pub struct GameState { pub child: Arc<Mutex<Option<tokio::process::Child>>> }
@@ -1295,6 +1304,46 @@ async fn fetch_skin(username: String) -> Result<(String, String), String> {
Ok((image_b64.to_string(), name_exact))
}
#[tauri::command]
fn get_screenshots(app: AppHandle) -> Vec<ScreenshotInfo> {
let mut screenshots = Vec::new();
let instances_dir = get_app_dir(&app).join("instances");
let _ = fs::create_dir_all(&instances_dir);
if let Ok(entries) = fs::read_dir(instances_dir) {
for entry in entries.flatten() {
if entry.path().is_dir() {
let instance_id = entry.file_name().to_string_lossy().to_string();
let screenshots_dir = entry.path().join("screenshots");
if let Ok(files) = fs::read_dir(screenshots_dir) {
for file in files.flatten() {
let path = file.path();
if path.extension().and_then(|s| s.to_str()) == Some("png") {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let date = path.metadata().and_then(|m| m.modified()).ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
screenshots.push(ScreenshotInfo {
path: path.to_string_lossy().to_string(),
instance_id: instance_id.clone(),
name,
date,
});
}
}
}
}
}
}
screenshots.sort_by(|a, b| b.date.cmp(&a.date));
screenshots
}
#[tauri::command]
fn delete_screenshot(path: String) -> Result<(), String> {
fs::remove_file(path).map_err(|e| e.to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -1303,7 +1352,28 @@ pub fn run() {
.plugin(tauri_plugin_gamepad::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_drpc::init())
.invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install])
.register_uri_scheme_protocol("screenshots", |_app, request| {
let uri = request.uri().path();
let decoded_path = percent_encoding::percent_decode_str(uri).decode_utf8_lossy();
let path = std::path::Path::new(&*decoded_path);
match std::fs::read(path) {
Ok(data) => {
tauri::http::Response::builder()
.header("Content-Type", "image/png")
.header("Access-Control-Allow-Origin", "*")
.body(data)
.unwrap()
}
Err(_) => {
tauri::http::Response::builder()
.status(404)
.body(Vec::new())
.unwrap()
}
}
})
.invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install, get_screenshots, delete_screenshot])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -26,7 +26,7 @@
}
],
"security": {
"csp": null
"csp": "default-src 'self'; img-src 'self' screenshots: asset: https://asset.localhost http://asset.localhost; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self' https://* http://* screenshots: asset:; script-src 'self' 'unsafe-inline' 'unsafe-eval';"
}
},
"bundle": {

View File

@@ -211,16 +211,16 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
if (e.key === 'ArrowRight') {
if (legacyMode) onNavigateRight();
else if (focusIndex === 3) onNavigateRight();
else if (focusIndex === 1 || focusIndex === 2) setFocusIndex(prev => prev + 1);
else if (focusIndex === 4) onNavigateRight();
else if (focusIndex >= 1 && focusIndex < 4) setFocusIndex(prev => prev + 1);
} else if (e.key === 'ArrowLeft') {
if (legacyMode) return;
if (focusIndex === 2 || focusIndex === 3) setFocusIndex(prev => prev - 1);
if (focusIndex > 1 && focusIndex <= 4) setFocusIndex(prev => prev - 1);
} else if (e.key === 'ArrowDown') {
if (legacyMode) {
return;
setFocusIndex(prev => (prev < 4 ? prev + 1 : prev));
} else {
setFocusIndex(prev => (prev < 3 ? prev + 1 : prev));
setFocusIndex(prev => (prev < 4 ? prev + 1 : prev));
}
} else if (e.key === 'ArrowUp') {
if (legacyMode) {
@@ -240,6 +240,9 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
} else if (focusIndex === 3) {
playPressSound();
setSkinUrl('/images/Default.png');
} else if (focusIndex === 4) {
playPressSound();
setActiveView('screenshots');
}
}
};
@@ -320,6 +323,16 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
<img src="/images/Trash_Bin_Icon.png" alt="Delete" className="w-8 h-8 object-contain brightness-200" style={{ imageRendering: 'pixelated' }} />
</button>
)}
<button
data-focus="4" tabIndex={0}
onMouseEnter={() => isFocusedSection && setFocusIndex(4)}
onClick={() => { playPressSound(); setActiveView('screenshots'); }}
className={`mc-sq-btn w-12 h-12 flex items-center justify-center outline-none border-none transition-all ${isFocusedSection && focusIndex === 4 ? 'scale-110' : ''}`}
style={isFocusedSection && focusIndex === 4 ? { backgroundImage: "url('/images/Button_Square_Highlighted.png')" } : {}}
title="Screenshots"
>
<img src="/images/Screenshots_Icon.png" alt="Screenshots" className="w-8 h-8 object-contain" style={{ imageRendering: 'pixelated' }} />
</button>
</div>
</motion.div>
);

View File

@@ -47,7 +47,7 @@ const HomeView = memo(function HomeView() {
: isGameRunning
? stopGame
: isDownloading
? () => {}
? () => { }
: isInstalled
? handleLaunch
: () => toggleInstall(profile),
@@ -105,35 +105,37 @@ const HomeView = memo(function HomeView() {
animate={{ opacity: isFocusedSection ? 1 : 0.5, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }}
className="w-full max-w-[540px] flex flex-col space-y-3 outline-none"
className="relative w-full max-w-[540px] flex flex-col space-y-3 outline-none"
>
{buttons.map((btn: any, i: number) => (
<button
key={i}
onMouseEnter={() => isFocusedSection && !btn.disabled && setMenuFocus(i)}
onMouseLeave={() => setMenuFocus(null)}
onClick={() => {
if (isFocusedSection && !btn.disabled) {
playPressSound();
btn.action();
}
}}
disabled={btn.disabled}
className={`w-full h-12 flex items-center justify-center text-2xl mc-text-shadow transition-colors outline-none border-none ${btn.disabled ? "text-gray-400 cursor-not-allowed" : menuFocus === i ? (btn.isDanger ? "text-red-400" : "text-[#FFFF55]") : btn.isDanger ? "text-red-500" : "text-white"}`}
style={{
backgroundImage: btn.disabled
? "url('/images/Button_Background.png')"
: menuFocus === i
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
opacity: btn.disabled ? 0.5 : 1,
}}
>
{btn.label}
</button>
<div key={i} className="relative w-full group">
<button
onMouseEnter={() => isFocusedSection && !btn.disabled && setMenuFocus(i)}
onMouseLeave={() => setMenuFocus(null)}
onClick={() => {
if (isFocusedSection && !btn.disabled) {
playPressSound();
btn.action();
}
}}
disabled={btn.disabled}
className={`w-full h-12 flex items-center justify-between px-6 text-2xl mc-text-shadow transition-colors outline-none border-none ${btn.disabled ? "text-gray-400 cursor-not-allowed" : menuFocus === i ? (btn.isDanger ? "text-red-400" : "text-[#FFFF55]") : btn.isDanger ? "text-red-500" : "text-white"}`}
style={{
backgroundImage: btn.disabled
? "url('/images/Button_Background.png')"
: menuFocus === i
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
opacity: btn.disabled ? 0.5 : 1,
}}
>
<span className="w-full text-center">{btn.label}</span>
</button>
</div>
))}
{!legacyMode && (
<div className="pt-4 flex flex-col items-center w-full gap-3">
<div className="flex gap-8">

View File

@@ -0,0 +1,218 @@
import { useState, useEffect, memo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useUI, useAudio, useGame, useConfig } from "../../context/LauncherContext";
import { ScreenshotService, ScreenshotInfo } from "../../services/ScreenshotService";
const ScreenshotsView = memo(function ScreenshotsView() {
const { setActiveView } = useUI();
const { playPressSound, playBackSound } = useAudio();
const { editions } = useGame();
const { animationsEnabled } = useConfig();
const [screenshots, setScreenshots] = useState<ScreenshotInfo[]>([]);
const [selectedScreenshot, setSelectedScreenshot] = useState<ScreenshotInfo | null>(null);
const [loading, setLoading] = useState(true);
const [gridFocusIndex, setGridFocusIndex] = useState(0);
const [modalFocusIndex, setModalFocusIndex] = useState(0);
useEffect(() => {
ScreenshotService.getScreenshots().then((data) => {
setScreenshots(data);
setLoading(false);
});
}, []);
const handleBack = () => {
playBackSound();
setActiveView("main");
};
const handleDelete = async (screenshot: ScreenshotInfo) => {
if (confirm("Are you sure you want to delete this screenshot?")) {
playPressSound();
await ScreenshotService.deleteScreenshot(screenshot.path);
setScreenshots((prev) => prev.filter((s) => s.path !== screenshot.path));
setSelectedScreenshot(null);
}
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (loading) return;
if (selectedScreenshot) {
if (e.key === "Escape") {
playBackSound();
setSelectedScreenshot(null);
} else if (e.key === "ArrowLeft") {
setModalFocusIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (e.key === "ArrowRight") {
setModalFocusIndex((prev) => (prev < 2 ? prev + 1 : prev));
} else if (e.key === "Enter") {
if (modalFocusIndex === 0) handleDelete(selectedScreenshot);
else if (modalFocusIndex === 1) {
playBackSound();
setSelectedScreenshot(null);
}
}
return;
}
const cols = window.innerWidth >= 1024 ? 4 : window.innerWidth >= 768 ? 3 : 2;
if (e.key === "Escape") {
handleBack();
} else if (e.key === "ArrowLeft") {
setGridFocusIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (e.key === "ArrowRight") {
setGridFocusIndex((prev) => (prev < screenshots.length - 1 ? prev + 1 : prev));
} else if (e.key === "ArrowUp") {
setGridFocusIndex((prev) => (prev >= cols ? prev - cols : prev));
} else if (e.key === "ArrowDown") {
setGridFocusIndex((prev) => (prev <= screenshots.length - 1 - cols ? prev + cols : prev));
} else if (e.key === "Enter") {
if (screenshots[gridFocusIndex]) {
playPressSound();
setModalFocusIndex(0);
setSelectedScreenshot(screenshots[gridFocusIndex]);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [loading, selectedScreenshot, gridFocusIndex, modalFocusIndex, screenshots]);
useEffect(() => {
if (!selectedScreenshot) {
const element = document.getElementById(`ss-${gridFocusIndex}`);
element?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [gridFocusIndex, selectedScreenshot]);
const getEditionLogo = (instanceId: string) => {
const edition = editions.find((e: any) => e.id === instanceId);
return edition?.logo || edition?.titleImage;
};
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full h-full max-w-5xl"
>
<div className="flex items-center justify-between w-full mb-4 border-b-2 border-[#373737] pb-2">
<h2 className="text-2xl text-white mc-text-shadow tracking-widest uppercase opacity-80 font-bold px-4">
Screenshots
</h2>
<button
onClick={handleBack}
className="mc-button px-6 py-2 text-white text-xl mc-text-shadow"
style={{ width: "120px", height: "40px" }}
>
Back
</button>
</div>
<div className="w-full flex-1 overflow-y-auto custom-scrollbar p-4">
{loading ? (
<div className="flex items-center justify-center h-full">
<span className="text-white text-xl mc-text-shadow">Scanning for screenshots...</span>
</div>
) : screenshots.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full space-y-4">
<span className="text-gray-400 text-xl mc-text-shadow italic">No screenshots found.</span>
<span className="text-gray-500 text-sm mc-text-shadow">Take some in-game with F2!</span>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{screenshots.map((ss, index) => (
<motion.div
key={ss.path}
id={`ss-${index}`}
whileHover={{ scale: 1.05 }}
onClick={() => {
setGridFocusIndex(index);
setSelectedScreenshot(ss);
}}
className={`relative aspect-video bg-black/40 border-2 transition-all cursor-pointer overflow-hidden group shadow-lg ${gridFocusIndex === index ? "border-[#FFFF55] scale-105" : "border-transparent"}`}
>
<img
src={`screenshots://localhost/${ss.path}`}
className="w-full h-full object-cover"
loading="lazy"
alt={ss.name}
onError={(e) => {
(e.target as HTMLImageElement).src = "/images/Folder_Icon.png";
}}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
{getEditionLogo(ss.instanceId) && (
<img
src={getEditionLogo(ss.instanceId)}
className="absolute bottom-2 left-2 w-8 h-8 object-contain drop-shadow-[0_2px_2px_rgba(0,0,0,0.8)]"
style={{ imageRendering: "pixelated" }}
/>
)}
<div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-black/60 px-2 py-1 text-[10px] text-white pointer-events-none">
{new Date(ss.date * 1000).toLocaleDateString()}
</div>
</motion.div>
))}
</div>
)}
</div>
<AnimatePresence>
{selectedScreenshot && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-black/90 flex flex-col items-center justify-center p-8 backdrop-blur-sm"
onClick={() => setSelectedScreenshot(null)}
>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
className="relative max-w-full max-h-[80vh] flex flex-col items-center"
onClick={(e) => e.stopPropagation()}
>
<img
src={`screenshots://localhost/${selectedScreenshot.path}`}
className="max-w-full max-h-full object-contain border-4 border-[#373737] shadow-2xl"
onError={(e) => {
(e.target as HTMLImageElement).src = "/images/Pack_Icon.png";
}}
/>
<div className="flex gap-4 mt-8 w-full justify-center">
<button
onClick={() => handleDelete(selectedScreenshot)}
className={`mc-button px-6 py-2 text-red-500 mc-text-shadow flex items-center justify-center transition-all ${modalFocusIndex === 1 ? "scale-110 brightness-125" : ""}`}
style={{ minWidth: "180px", height: "48px", backgroundImage: modalFocusIndex === 1 ? "url('/images/button_highlighted.png')" : "" }}
>
Delete
</button>
<button
onClick={() => setSelectedScreenshot(null)}
className={`mc-button px-6 py-2 text-white mc-text-shadow flex items-center justify-center transition-all ${modalFocusIndex === 2 ? "scale-110 brightness-125" : ""}`}
style={{ minWidth: "120px", height: "48px", backgroundImage: modalFocusIndex === 2 ? "url('/images/button_highlighted.png')" : "" }}
>
Close
</button>
</div>
<div className="mt-4 text-gray-400 text-sm mc-text-shadow">
{selectedScreenshot.name} - {new Date(selectedScreenshot.date * 1000).toLocaleString()}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
});
export default ScreenshotsView;

View File

@@ -9,6 +9,7 @@ import WorkshopView from "../components/views/WorkshopView";
import SetupView from "../components/views/SetupView";
import PckEditorView from "../components/views/PckEditorView";
import { ArcEditorView } from "../components/views/ArcEditorView";
import ScreenshotsView from "../components/views/ScreenshotsView";
import SkinViewer from "../components/common/SkinViewer";
import TeamModal from "../components/modals/TeamModal";
import PanoramaBackground from "../components/common/PanoramaBackground";
@@ -348,6 +349,9 @@ export default function App() {
<ArcEditorView key="arc-editor-view" />
)}
{activeView === "skins" && <SkinsView key="skins-view" />}
{activeView === "screenshots" && (
<ScreenshotsView key="screenshots-view" />
)}
</AnimatePresence>
</div>
</div>

View File

@@ -0,0 +1,17 @@
import { invoke } from "@tauri-apps/api/core";
export interface ScreenshotInfo {
path: string;
instanceId: string;
name: string;
date: number;
}
export class ScreenshotService {
static async getScreenshots(): Promise<ScreenshotInfo[]> {
return invoke("get_screenshots");
}
static async deleteScreenshot(path: string): Promise<void> {
return invoke("delete_screenshot", { path });
}
}