diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 704d6798..723bcc26 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3355,7 +3355,7 @@ dependencies = [ "async-compression", "async-trait", "async_zip", - "base64 0.21.7", + "base64 0.22.1", "byteorder", "chrono", "craftping", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 21f45418..3521df3a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ directories = "5.0" once_cell = "1.18" futures = "0.3" tauri = { version = "2", features = ["protocol-asset", "image-png"] } +base64 = "0.22" tauri-plugin-opener = "2" sha1 = "0.10.6" zip = "2.6.1" @@ -45,7 +46,6 @@ dashmap = "6.1.0" rand = "0.8.5" sha2 = "0.10.8" p256 = "0.13.2" -base64 = "0.21.7" jsonwebtoken = "9.3.0" machineid-rs = "1.2.4" byteorder = { version = "1.4" } diff --git a/src-tauri/src/commands/image_command.rs b/src-tauri/src/commands/image_command.rs new file mode 100644 index 00000000..93c7c15d --- /dev/null +++ b/src-tauri/src/commands/image_command.rs @@ -0,0 +1,14 @@ +use tauri::command; +use std::fs; +use base64::{Engine as _, engine::general_purpose}; + +#[command] +pub async fn load_image_as_base64(image_path: String) -> Result { + match fs::read(image_path) { + Ok(image_data) => { + let base64_string = general_purpose::STANDARD.encode(&image_data); + Ok(format!("data:image/png;base64,{}", base64_string)) + } + Err(e) => Err(format!("Failed to read image: {}", e)) + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 22f171bb..458e57ea 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod image_command; pub mod cape_command; pub mod config_commands; pub mod content_command; diff --git a/src-tauri/src/commands/profile_command.rs b/src-tauri/src/commands/profile_command.rs index 7ed32207..988964c4 100644 --- a/src-tauri/src/commands/profile_command.rs +++ b/src-tauri/src/commands/profile_command.rs @@ -164,6 +164,7 @@ pub async fn launch_profile( quick_play_multiplayer: Option, migration_info: Option, ) -> Result<(), CommandError> { + log::info!("=== NEW VERSION WITH ANALYTICS ==="); log::info!( "[Command] launch_profile called for ID: {}. QuickPlay Single: {:?}, QuickPlay Multi: {:?}, Migration: {:?}", id, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ee19f5e9..d5c0c3ed 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -360,6 +360,7 @@ async fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + commands::image_command::load_image_as_base64, create_profile, get_profile, update_profile, diff --git a/src/components/effects/CustomImageBackground.tsx b/src/components/effects/CustomImageBackground.tsx new file mode 100644 index 00000000..2831ea26 --- /dev/null +++ b/src/components/effects/CustomImageBackground.tsx @@ -0,0 +1,123 @@ +import React, { useState, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { Icon } from "@iconify/react"; + +interface CustomImageBackgroundProps { + imagePath: string | null; + opacity: number; + blur: number; + scale: number; +} + +const CustomImageBackground: React.FC = ({ + imagePath, + opacity, + blur, + scale, +}) => { + const [imageDataUrl, setImageDataUrl] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadImage = async () => { + if (!imagePath) { + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + + const base64Data = await invoke("load_image_as_base64", { + imagePath: imagePath, + }); + + setImageDataUrl(base64Data); + console.log("Custom background image loaded successfully as base64"); + } catch (err) { + console.error("Failed to load custom background image:", err); + setError(err as string); + } finally { + setLoading(false); + } + }; + + loadImage(); + }, [imagePath]); + + if (!imagePath) { + return ( +
+
+
+ + + +
+

No custom image selected

+

Go to Settings → Background to select an image

+
+
+ ); + } + + if (loading) { + return ( +
+
+ +

Loading background image...

+
+
+ ); + } + + if (error) { + return ( +
+
+
+ + + +
+

Failed to load custom image

+

Check file path and format

+
+
+ ); + } + + if (!imageDataUrl) { + return ( +
+
+
+ + + +
+

No image data

+
+
+ ); + } + + return ( +
+ ); +}; + +export default CustomImageBackground; diff --git a/src/components/launcher/MainLaunchButton.tsx b/src/components/launcher/MainLaunchButton.tsx index 5c7ec17c..6f1d595b 100644 --- a/src/components/launcher/MainLaunchButton.tsx +++ b/src/components/launcher/MainLaunchButton.tsx @@ -51,6 +51,7 @@ export function MainLaunchButton({ // Use the profile launch hook for launch logic const { handleLaunch: hookHandleLaunch, isLaunching, statusMessage, launchState } = useProfileLaunch({ profileId: selectedVersion, + profileName: selectedVersionLabel, onLaunchSuccess: () => { setTransientSuccessActive(true); setTimeout(() => { diff --git a/src/components/launcher/PlayerActionsDisplay.tsx b/src/components/launcher/PlayerActionsDisplay.tsx index 1b7c5346..06e770cc 100644 --- a/src/components/launcher/PlayerActionsDisplay.tsx +++ b/src/components/launcher/PlayerActionsDisplay.tsx @@ -5,9 +5,11 @@ import { cn } from '../../lib/utils'; import { SkinViewer } from './SkinViewer'; import { MainLaunchButton } from './MainLaunchButton'; import { useThemeStore } from '../../store/useThemeStore'; +import { useSkinVisibilityStore } from '../../store/useSkinVisibilityStore'; import { MinecraftSkinService } from '../../services/minecraft-skin-service'; import type { GetStarlightSkinRenderPayload } from '../../types/localSkin'; import { convertFileSrc } from '@tauri-apps/api/core'; +import { Icon } from '@iconify/react'; const DEFAULT_FALLBACK_SKIN_URL = "/skins/default_steve_full.png"; // Defined constant for fallback URL @@ -35,6 +37,7 @@ export function PlayerActionsDisplay({ displayMode = 'playerName', }: PlayerActionsDisplayProps) { const accentColor = useThemeStore((state) => state.accentColor); + const { isSkinVisible, toggleSkinVisibility } = useSkinVisibilityStore(); const [resolvedSkinUrl, setResolvedSkinUrl] = useState(DEFAULT_FALLBACK_SKIN_URL); useEffect(() => { @@ -99,23 +102,36 @@ export function PlayerActionsDisplay({ }} /> ) : ( -

- {playerName || "no account"} -

+ isSkinVisible && ( +

+ {playerName || "no account"} +

+ ) )}
- + + {isSkinVisible ? ( + + ) : ( +
+ )}
diff --git a/src/components/launcher/ProfileSelectionModalContent.tsx b/src/components/launcher/ProfileSelectionModalContent.tsx index 20103949..d9460514 100644 --- a/src/components/launcher/ProfileSelectionModalContent.tsx +++ b/src/components/launcher/ProfileSelectionModalContent.tsx @@ -2,6 +2,7 @@ import { Icon } from "@iconify/react"; import { useVersionSelectionStore } from "../../store/version-selection-store"; import { useProfileStore } from "../../store/profile-store"; +import { useHiddenProfilesStore } from "../../store/useHiddenProfilesStore"; import type { Profile } from "../../types/profile"; import { ProfileCard } from "../profiles/ProfileCard"; import { VirtuosoGrid } from "react-virtuoso"; @@ -23,6 +24,7 @@ export function ProfileSelectionModalContent({ }: ProfileSelectionModalContentProps) { const { setSelectedVersion } = useVersionSelectionStore(); const { profiles, loading: profilesLoading, error: profilesError, fetchProfiles, deleteProfile } = useProfileStore(); + const { isProfileHidden } = useHiddenProfilesStore(); // Export modal state const [isExportModalOpen, setIsExportModalOpen] = useState(false); @@ -113,17 +115,24 @@ export function ProfileSelectionModalContent({
no profiles available
- ) : ( - { - const profile = profiles[index]; + ) : (() => { + const visibleProfiles = profiles.filter(profile => !isProfileHidden(profile.id)); + + return visibleProfiles.length === 0 ? ( +
+ no profiles available +
+ ) : ( + { + const profile = visibleProfiles[index]; return ( - )} + ); + })()} {/* Export Profile Modal */} {profileToExport && ( diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 75331805..ba0d701d 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -24,6 +24,7 @@ import { NebulaLightning } from ".././effects/NebulaLightning"; import { NebulaLiquidChrome } from ".././effects/NebulaLiquidChrome"; import { RetroGridEffect } from "../effects/RetroGridEffect"; import PlainBackground from "../effects/PlainBackground"; +import CustomImageBackground from "../effects/CustomImageBackground"; import * as ConfigService from "../../services/launcher-config-service"; import { SocialsModal } from "../modals/SocialsModal"; import { checkUpdateAvailable, downloadAndInstallUpdate } from "../../services/nrc-service"; @@ -64,7 +65,7 @@ export function AppLayout({ const minimizeRef = useRef(null); const maximizeRef = useRef(null); const closeRef = useRef(null); - const { currentEffect } = useBackgroundEffectStore(); + const { currentEffect, customBackgroundImage, backgroundImageOpacity, backgroundImageBlur, backgroundImageScale } = useBackgroundEffectStore(); const { qualityLevel } = useQualitySettingsStore(); const { isBackgroundAnimationEnabled, accentColor: themeAccentColor, accentColor } = useThemeStore(); @@ -253,6 +254,15 @@ export function AppLayout({ ); case BACKGROUND_EFFECTS.PLAIN_BACKGROUND: return ; + case BACKGROUND_EFFECTS.CUSTOM_IMAGE: + return ( + + ); default: return (
@@ -267,10 +277,10 @@ export function AppLayout({ ref={launcherRef} className="h-screen w-full bg-black/50 backdrop-blur-lg border-2 overflow-hidden relative flex shadow-[0_0_25px_rgba(0,0,0,0.4)]" style={{ - backgroundColor: backgroundColor, + backgroundColor: currentEffect === BACKGROUND_EFFECTS.CUSTOM_IMAGE ? 'transparent' : backgroundColor, backgroundSize: "cover", backgroundPosition: "center", - backgroundImage: `linear-gradient(to bottom right, ${backgroundColor}, rgba(0,0,0,0.9))`, + backgroundImage: currentEffect === BACKGROUND_EFFECTS.CUSTOM_IMAGE ? 'none' : `linear-gradient(to bottom right, ${backgroundColor}, rgba(0,0,0,0.9))`, borderColor: `${themeAccentColor.value}30`, boxShadow: `0 0 15px ${themeAccentColor.value}30, inset 0 0 10px ${themeAccentColor.value}20`, }} @@ -295,7 +305,7 @@ export function AppLayout({
{renderBackgroundEffect()} -
+
{children}
diff --git a/src/components/modals/CreateFolderModal.tsx b/src/components/modals/CreateFolderModal.tsx new file mode 100644 index 00000000..e8037915 --- /dev/null +++ b/src/components/modals/CreateFolderModal.tsx @@ -0,0 +1,119 @@ +"use client"; + +import type React from "react"; +import { useState } from "react"; +import { Button } from "../ui/buttons/Button"; +import { Icon } from "@iconify/react"; +import { toast } from "react-hot-toast"; +import { Modal } from "../ui/Modal"; + +interface CreateFolderModalProps { + isOpen: boolean; + onClose: () => void; + onCreateFolder: (folderName: string) => void; +} + +export function CreateFolderModal({ + isOpen, + onClose, + onCreateFolder, +}: CreateFolderModalProps) { + const [folderName, setFolderName] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!folderName.trim()) { + toast.error("Please enter a folder name"); + return; + } + + if (folderName.trim().length < 2) { + toast.error("Folder name must be at least 2 characters"); + return; + } + + setIsLoading(true); + + try { + await onCreateFolder(folderName.trim()); + setFolderName(""); + } catch (error) { + console.error("Error creating folder:", error); + toast.error("Failed to create folder"); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + setFolderName(""); + onClose(); + }; + + return ( + + + + +
+ } + > +
+
+ + setFolderName(e.target.value)} + placeholder="Enter folder name..." + className="w-full px-3 py-2 bg-black/30 border border-white/20 rounded-lg text-white placeholder-white/50 font-minecraft-ten focus:outline-none transition-colors" + maxLength={50} + autoFocus + /> +
+ {folderName.length}/50 characters +
+
+
+ + ); +} diff --git a/src/components/modals/MoveToFolderModal.tsx b/src/components/modals/MoveToFolderModal.tsx new file mode 100644 index 00000000..fa2809ce --- /dev/null +++ b/src/components/modals/MoveToFolderModal.tsx @@ -0,0 +1,135 @@ +"use client"; + +import type React from "react"; +import { useState } from "react"; +import { Button } from "../ui/buttons/Button"; +import { Icon } from "@iconify/react"; +import type { MinecraftSkin } from "../../types/localSkin"; +import type { SkinFolder } from "../../store/useSkinFoldersStore"; +import { useThemeStore } from "../../store/useThemeStore"; +import { Modal } from "../ui/Modal"; + +interface MoveToFolderModalProps { + isOpen: boolean; + skin: MinecraftSkin; + folders: SkinFolder[]; + onClose: () => void; + onMoveToFolder: (folderId: string) => void; +} + +export function MoveToFolderModal({ + isOpen, + skin, + folders, + onClose, + onMoveToFolder, +}: MoveToFolderModalProps) { + const [selectedFolderId, setSelectedFolderId] = useState(""); + const accentColor = useThemeStore((state) => state.accentColor); + + const handleSubmit = () => { + if (!selectedFolderId) { + return; + } + + onMoveToFolder(selectedFolderId); + }; + + if (!isOpen) return null; + + return ( + + + + +
+ } + > +
+ {/* Skin Info */} +
+
+

+ {skin.name} +

+

+ {skin.variant === "slim" ? "Slim" : "Classic"} Skin +

+
+
+ + {/* Folder Selection */} +
+ +
+ {folders.map((folder) => ( + + ))} + + {folders.length === 0 && ( +
+ No folders available. Create a folder first. +
+ )} +
+
+
+ + ); +} diff --git a/src/components/modrinth/v2/ModrinthProjectCardV2.tsx b/src/components/modrinth/v2/ModrinthProjectCardV2.tsx index d81f3ade..5750e2b8 100644 --- a/src/components/modrinth/v2/ModrinthProjectCardV2.tsx +++ b/src/components/modrinth/v2/ModrinthProjectCardV2.tsx @@ -19,6 +19,7 @@ import { openExternalUrl } from "../../../services/tauri-service"; import { toast } from "react-hot-toast"; import { preloadIcons } from "../../../lib/icon-utils"; import { ThemedSurface } from "../../ui/ThemedSurface"; +import { useThemeStore } from "../../../store/useThemeStore"; type Profile = any; @@ -191,14 +192,16 @@ export const ModrinthProjectCardV2 = React.memo( onDeleteVersionClick, onToggleEnableClick, itemIndex, - }) => { - useEffect(() => { - preloadIcons([ - "solar:download-minimalistic-bold", - "solar:alt-arrow-up-bold", - "solar:alt-arrow-down-bold", - ]); - }, []); + }) => { + const borderRadius = useThemeStore((state) => state.borderRadius); + + useEffect(() => { + preloadIcons([ + "solar:download-minimalistic-bold", + "solar:alt-arrow-up-bold", + "solar:alt-arrow-down-bold", + ]); + }, []); return (
@@ -234,6 +237,7 @@ export const ModrinthProjectCardV2 = React.memo(
+ {/* Project Icon */}
state.accentColor); + const borderRadius = useThemeStore((state) => state.borderRadius); const { openContextMenuId, setOpenContextMenuId } = useThemeStore(); // Settings context menu state @@ -73,6 +76,7 @@ export function ProfileCardV2({ // Modpack versions state for conditional rendering const [modpackVersions, setModpackVersions] = useState(null); const [isLoadingVersions, setIsLoadingVersions] = useState(false); + const [hasNewVersion, setHasNewVersion] = useState(false); // Profile settings store const { openModal } = useProfileSettingsStore(); @@ -166,6 +170,22 @@ export function ProfileCardV2({ } }, }] : []), + // Hide/Show option for standard profiles + ...(profile.is_standard_version ? [{ + id: "hide_show", + label: isProfileHidden(profile.id) ? "Show Profile" : "Hide Profile", + icon: isProfileHidden(profile.id) ? "solar:eye-bold" : "solar:eye-closed-bold", + separator: true, + onClick: (profile) => { + if (isProfileHidden(profile.id)) { + showProfile(profile.id); + toast.success(`👁️ Profile "${profile.name}" shown`); + } else { + hideProfile(profile.id); + toast.success(`👁️ Profile "${profile.name}" hidden`); + } + }, + }] : []), { id: "delete", label: "Delete", @@ -209,16 +229,28 @@ export function ProfileCardV2({ if (profile.modpack_info?.source) { setIsLoadingVersions(true); UnifiedService.getModpackVersions(profile.modpack_info.source) - .then(setModpackVersions) + .then(versions => { + setModpackVersions(versions); + + // Use the backend's updates_available logic instead of manual comparison + if (versions && typeof versions.updates_available === 'boolean') { + setHasNewVersion(versions.updates_available); + console.log(`Profile ${profile.name}: Backend says updates_available=${versions.updates_available}`); + } else { + setHasNewVersion(false); + } + }) .catch(err => { console.error("Failed to load modpack versions:", err); setModpackVersions(null); + setHasNewVersion(false); }) .finally(() => setIsLoadingVersions(false)); } else { setModpackVersions(null); + setHasNewVersion(false); } - }, [profile.modpack_info?.source]); + }, [profile.modpack_info?.source, profile.modpack_info?.version_number]); @@ -409,6 +441,8 @@ export function ProfileCardV2({
)} + + {/* Action buttons - top right */}
{/* Settings button */} @@ -467,6 +501,55 @@ export function ProfileCardV2({
+ {/* NEW UPDATE Text - outside avatar, top right corner */} + {profile.modpack_info?.source && !profile.is_standard_version && hasNewVersion && ( +
{ + e.preventDefault(); + e.stopPropagation(); + + // Open modpack versions modal + if (profile.modpack_info?.source && modpackVersions) { + import("../modals/ModpackVersionsModal").then(({ ModpackVersionsModal }) => { + showModal(`modpack-versions-${profile.id}`, ( + hideModal(`modpack-versions-${profile.id}`)} + versions={modpackVersions} + modpackName={profile.name} + profileId={profile.id} + onSwitchComplete={async () => { + console.log("Modpack version switched successfully for:", profile.name); + // Refresh profiles to ensure the profile prop is updated + try { + const { fetchProfiles } = useProfileStore.getState(); + await fetchProfiles(); + } catch (err) { + console.error("Failed to refresh profiles after modpack switch:", err); + } + }} + /> + )); + }); + } else { + toast.success(`🆕 Checking for updates for ${profile.name}!`); + console.log("Checking for updates for profile:", profile.name); + } + }} + className={`absolute ${isCompact ? 'top-1 left-2' : 'top-1 left-3'} cursor-pointer transition-all duration-200 hover:scale-105 z-20`} + style={{ + animation: 'pulse-scale 1.5s ease-in-out infinite', + }} + title="Check for Updates" + > + + NEW UPDATE + +
+ )} + {/* Profile content */}
+ {/* Settings Context Menu */} void; + onDelete?: (folderId: string, folderName: string) => void; + onRename?: (folderId: string, newName: string) => void; + onDrop?: (folderId: string, skinId: string) => void; +} + +export function FolderCard({ + folder, + onClick, + onDelete, + onRename, + onDrop, +}: FolderCardProps) { + const [isHovered, setIsHovered] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(folder.name); + const [isDragOver, setIsDragOver] = useState(false); + const accentColor = useThemeStore((state) => state.accentColor); + const { deleteFolder, renameFolder } = useSkinFoldersStore(); + + const handleRename = (e: React.FormEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!editName.trim() || editName.trim() === folder.name) { + setIsEditing(false); + setEditName(folder.name); + return; + } + + if (editName.trim().length < 2) { + toast.error("Folder name must be at least 2 characters"); + return; + } + + renameFolder(folder.id, editName.trim()); + setIsEditing(false); + toast.success(`📁 Folder renamed to "${editName.trim()}"`); + }; + + const handleDelete = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (onDelete) { + onDelete(folder.id, folder.name); + } else { + deleteFolder(folder.id); + toast.success(`📁 Folder "${folder.name}" deleted`); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setIsEditing(false); + setEditName(folder.name); + } else if (e.key === 'Enter') { + handleRename(e); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "move"; + setIsDragOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + const skinId = e.dataTransfer.getData("text/plain"); + if (skinId && onDrop) { + onDrop(folder.id, skinId); + } + }; + + return ( +
!isEditing && onClick()} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onDragOver={handleDragOver} + onDragEnter={(e) => { + e.preventDefault(); + setIsDragOver(true); + }} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + > + {/* Action buttons - top right */} +
+ {/* Rename button */} + + + {/* Delete button */} + +
+ + {/* Folder content */} +
+ {/* Folder Icon */} +
+
+ +
+ + {/* Drop indicator */} + {isDragOver && ( +
+
+ +
Drop Skin Here
+
+
+ )} +
+ + {/* Folder Info */} +
+ {/* Folder Name */} + {isEditing ? ( +
+ setEditName(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { + setIsEditing(false); + setEditName(folder.name); + }} + className="w-full px-2 py-1 bg-black/30 border border-blue-400 rounded text-white text-center font-minecraft-ten text-base focus:outline-none" + autoFocus + maxLength={50} + /> +
+ ) : ( +

+ {folder.name} +

+ )} + + {/* Folder Stats */} +
+
+ Folder + + {folder.skin_ids.length} Skin{folder.skin_ids.length !== 1 ? 's' : ''} +
+
+
+
+
+ ); +} diff --git a/src/components/tabs/PlayTab.tsx b/src/components/tabs/PlayTab.tsx index e7743969..c361a14c 100644 --- a/src/components/tabs/PlayTab.tsx +++ b/src/components/tabs/PlayTab.tsx @@ -12,6 +12,7 @@ import { BACKGROUND_EFFECTS, useBackgroundEffectStore, } from "../../store/background-effect-store"; +import { useSkinVisibilityStore } from "../../store/useSkinVisibilityStore"; export function PlayTab() { const { @@ -37,6 +38,7 @@ export function PlayTab() { setSelectedProfile(profileToSelect); }; + const currentDisplayProfile = storeSelectedProfile || (profiles.length > 0 ? profiles[0] : null); @@ -83,6 +85,7 @@ export function PlayTab() { launchButtonVersions={versions} className="" /> +
diff --git a/src/components/tabs/ProfilesTab.tsx b/src/components/tabs/ProfilesTab.tsx index 9dd30645..25ea5843 100644 --- a/src/components/tabs/ProfilesTab.tsx +++ b/src/components/tabs/ProfilesTab.tsx @@ -8,6 +8,7 @@ import { LoadingState } from "../ui/LoadingState"; import { EmptyState } from "../ui/EmptyState"; import { Icon } from "@iconify/react"; import { useThemeStore } from "../../store/useThemeStore"; +import { useHiddenProfilesStore } from "../../store/useHiddenProfilesStore"; import { gsap } from "gsap"; import { ProfileImport } from "../profiles/ProfileImport"; import { useProfileSettingsStore } from "../../store/profile-settings-store"; @@ -43,6 +44,7 @@ export function ProfilesTab() { ); const collapsedProfileGroups = useThemeStore((state) => state.collapsedProfileGroups); const toggleCollapsedProfileGroup = useThemeStore((state) => state.toggleCollapsedProfileGroup); + const { isProfileHidden } = useHiddenProfilesStore(); const tabRef = useRef(null); const contentRef = useRef(null); @@ -181,6 +183,9 @@ export function ProfilesTab() { const allProfiles = profiles; const initiallyFilteredProfiles = allProfiles.filter((profile) => { + // Hide filter - exclude hidden profiles + if (isProfileHidden(profile.id)) return false; + if ( searchQuery && !profile.name.toLowerCase().includes(searchQuery.toLowerCase()) diff --git a/src/components/tabs/ProfilesTabV2.tsx b/src/components/tabs/ProfilesTabV2.tsx index 7022f022..cc39fe20 100644 --- a/src/components/tabs/ProfilesTabV2.tsx +++ b/src/components/tabs/ProfilesTabV2.tsx @@ -20,6 +20,8 @@ import { useThemeStore } from "../../store/useThemeStore"; import { useGlobalModal } from "../../hooks/useGlobalModal"; import { ExportProfileModal } from "../profiles/ExportProfileModal"; import { Icon } from "@iconify/react"; +import { useRecentProfilesStore } from "../../store/useRecentProfilesStore"; +import { useHiddenProfilesStore } from "../../store/useHiddenProfilesStore"; export function ProfilesTabV2() { const { @@ -31,6 +33,7 @@ export function ProfilesTabV2() { const navigate = useNavigate(); const { confirm, confirmDialog } = useConfirmDialog(); const { openModal: openWizard } = useProfileWizardStore(); + const { isProfileHidden, showHiddenProfiles, toggleShowHiddenProfiles } = useHiddenProfilesStore(); // Global modal system const { showModal, hideModal } = useGlobalModal(); @@ -49,6 +52,10 @@ export function ProfilesTabV2() { // Local non-persistent state const [searchQuery, setSearchQuery] = useState(""); + const [showRecentOnly, setShowRecentOnly] = useState(false); + + // Recent profiles + const { getRecentProfiles } = useRecentProfilesStore(); // Use persistent values instead of local state const activeGroup = profilesTabActiveGroup; @@ -256,8 +263,13 @@ export function ProfilesTabV2() { ); } - // Filter profiles based on search query, active group, and version filter + // Filter profiles based on search query, active group, version filter, recent filter, and hidden status const filteredProfiles = profiles.filter((profile) => { + // Hidden filter - show only hidden profiles when showHiddenProfiles is true + const isHidden = isProfileHidden(profile.id); + if (showHiddenProfiles && !isHidden) return false; + if (!showHiddenProfiles && isHidden) return false; + // Search filter const matchesSearch = searchQuery === "" || profile.name.toLowerCase().includes(searchQuery.toLowerCase()) || @@ -274,7 +286,10 @@ export function ProfilesTabV2() { const matchesVersion = versionFilter === "all" || profile.game_version?.includes(versionFilter); - return matchesSearch && matchesGroup && matchesVersion; + // Recent filter + const matchesRecent = !showRecentOnly || getRecentProfiles().some(recent => recent.id === profile.id); + + return matchesSearch && matchesGroup && matchesVersion && matchesRecent; }); // Sort filtered profiles @@ -347,6 +362,34 @@ export function ProfilesTabV2() { onFilterChange={setProfilesTabVersionFilter} /> + {/* Recent Filter Button */} + + + {/* Show Hidden Profiles Button */} + + {/* Layout Toggle Button - Right next to SearchWithFilters */}
); @@ -518,6 +574,135 @@ export function SettingsTab() {
+ {/* Custom Image Settings */} + {currentEffect === BACKGROUND_EFFECTS.CUSTOM_IMAGE && ( +
+
+
+ +

+ Custom Background Image +

+
+

+ Choose a custom image for your launcher background +

+
+ +
+ {/* Image Selection */} +
+ +
+ + {customBackgroundImage && ( + + )} + +
+
+ + + {/* Image Settings */} +
+
+ + setBackgroundImageOpacity(parseFloat(e.target.value))} + className="w-full h-2 bg-white/20 rounded-lg appearance-none cursor-pointer slider accent-white hover:accent-white/80 transition-colors" + disabled={saving} + /> +
+ +
+ + setBackgroundImageBlur(parseInt(e.target.value))} + className="w-full h-2 bg-white/20 rounded-lg appearance-none cursor-pointer slider accent-white hover:accent-white/80 transition-colors" + disabled={saving} + /> +
+ +
+ + setBackgroundImageScale(parseFloat(e.target.value))} + className="w-full h-2 bg-white/20 rounded-lg appearance-none cursor-pointer slider accent-white hover:accent-white/80 transition-colors" + disabled={saving} + /> +
+
+
+
+ )} +
); diff --git a/src/components/tabs/SkinsTab.tsx b/src/components/tabs/SkinsTab.tsx index 615eb45a..2d276e52 100644 --- a/src/components/tabs/SkinsTab.tsx +++ b/src/components/tabs/SkinsTab.tsx @@ -18,11 +18,16 @@ import { SkinViewer } from "../launcher/SkinViewer"; import { useDebounce } from "../../hooks/useDebounce"; import { useThemeStore } from "../../store/useThemeStore"; import { useSkinStore } from "../../store/useSkinStore"; +import { useSkinFavoritesStore } from "../../store/useSkinFavoritesStore"; +import { useSkinFoldersStore } from "../../store/useSkinFoldersStore"; import { toast } from "react-hot-toast"; import { convertFileSrc } from "@tauri-apps/api/core"; import { SearchWithFilters } from "../ui/SearchWithFilters"; import { useGlobalModal } from "../../hooks/useGlobalModal"; import { AddSkinModal } from "../modals/AddSkinModal"; +import { CreateFolderModal } from "../modals/CreateFolderModal"; +import { MoveToFolderModal } from "../modals/MoveToFolderModal"; +import { FolderCard } from "../skins/FolderCard"; import { cn } from "../../lib/utils"; const SkinPreview = memo( @@ -36,6 +41,10 @@ const SkinPreview = memo( onClick, onEditSkin, onDeleteSkin, + onMoveToFolder, + onDragStart, + draggable, + hideMoveButton, }: { skin: MinecraftSkin; index: number; @@ -53,14 +62,20 @@ const SkinPreview = memo( skinName: string, event: React.MouseEvent, ) => void; + onMoveToFolder?: (skin: MinecraftSkin) => void; + onDragStart?: (event: React.DragEvent) => void; + draggable?: boolean; + hideMoveButton?: boolean; }) => { const [isHovered, setIsHovered] = useState(false); const accentColor = useThemeStore((state) => state.accentColor); const isBackgroundAnimationEnabled = useThemeStore( (state) => state.isBackgroundAnimationEnabled, ); + const { isFavorite, toggleFavorite } = useSkinFavoritesStore(); const isSelected = selectedLocalSkin?.id === skin.id; const isDisabled = loading && isSelected; + const isSkinFavorite = isFavorite(skin.id); const [starlightRenderUrl, setStarlightRenderUrl] = useState( null, @@ -167,14 +182,36 @@ const SkinPreview = memo( animationClasses, isDisabled ? "opacity-60 pointer-events-none" : "" )} - onClick={() => - !isDisabled && !isApplied && !isSelected && onClick(skin) - } + onClick={(e) => { + // Don't trigger click if we just finished dragging + if (e.defaultPrevented) return; + !isDisabled && !isApplied && !isSelected && onClick(skin); + }} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} + onDragStart={onDragStart} + draggable={draggable} > {/* Action buttons - top right */}
+ {/* Favorite button */} + + {onEditSkin && ( )} + {onMoveToFolder && !hideMoveButton && ( + + )} + {onDeleteSkin && (
- {/* Action Button */} + {/* Action Buttons */}
+ {/* Create Folder Button */} + + + {/* Filter Tabs */} +
+ + + +
+ {activeAccount && addSkinButton}
+ {/* Breadcrumb Navigation */} + {currentFolder && ( +
+
+ + +
+ +
+ + {folders.find(f => f.id === currentFolder)?.name} +
+
+
+ )} + {/* Content */}
{accountLoading ? ( @@ -701,15 +899,42 @@ export function SkinsTab() {

) : (
- startEditSkin(null, undefined)} - /> + {/* Add Skin Card - only show when not in a folder */} + {!currentFolder && ( + startEditSkin(null, undefined)} + /> + )} + + {/* Folders - only show when not in a folder and not showing favorites */} + {!currentFolder && !showFavoritesOnly && folders.map((folder, index) => ( + { + setCurrentFolder(folder.id); + setSearch(""); + setShowFavoritesOnly(false); + }} + onDelete={(folderId, folderName) => { + deleteFolder(folderId); + toast.success(`📁 Folder "${folderName}" deleted`); + }} + onDrop={(folderId, skinId) => { + addSkinToFolder(folderId, skinId); + const folderName = folders.find(f => f.id === folderId)?.name; + toast.success(`📁 Skin moved to folder "${folderName}"`); + }} + /> + ))} + + {/* Skins */} {filteredSkins.map((skin, index) => ( { + e.dataTransfer.setData("text/plain", skin.id); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.dropEffect = "move"; + }} + draggable={true} // Always draggable /> ))}
diff --git a/src/components/ui/CustomImagePreview.tsx b/src/components/ui/CustomImagePreview.tsx new file mode 100644 index 00000000..7ca5e9df --- /dev/null +++ b/src/components/ui/CustomImagePreview.tsx @@ -0,0 +1,96 @@ +import React, { useState, useEffect } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { Icon } from "@iconify/react"; + +interface CustomImagePreviewProps { + imagePath: string; + opacity: number; + blur: number; + scale: number; +} + +const CustomImagePreview: React.FC = ({ + imagePath, + opacity, + blur, + scale, +}) => { + const [imageDataUrl, setImageDataUrl] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadImage = async () => { + try { + setLoading(true); + setError(null); + + const base64Data = await invoke("load_image_as_base64", { + imagePath: imagePath, + }); + + setImageDataUrl(base64Data); + console.log("Image loaded successfully as base64"); + } catch (err) { + console.error("Failed to load image:", err); + setError(err as string); + } finally { + setLoading(false); + } + }; + + if (imagePath) { + loadImage(); + } + }, [imagePath]); + + if (loading) { + return ( +
+
+ +

Loading image...

+
+
+ ); + } + + if (error) { + return ( +
+
+ +

Failed to load image

+

Check file path and format

+
+
+ ); + } + + if (!imageDataUrl) { + return ( +
+
+ +

No image data

+
+
+ ); + } + + return ( +
+ ); +}; + +export default CustomImagePreview; diff --git a/src/components/ui/SearchWithFilters.tsx b/src/components/ui/SearchWithFilters.tsx index c9826a1d..84ef2f6c 100644 --- a/src/components/ui/SearchWithFilters.tsx +++ b/src/components/ui/SearchWithFilters.tsx @@ -65,15 +65,15 @@ export function SearchWithFilters({ return (
{/* Search with integrated filters */} -
- +
+ {/* Sort Filter */} diff --git a/src/hooks/useProfileLaunch.tsx b/src/hooks/useProfileLaunch.tsx index b346dc25..77a9479b 100644 --- a/src/hooks/useProfileLaunch.tsx +++ b/src/hooks/useProfileLaunch.tsx @@ -12,9 +12,11 @@ import { useGlobalModal } from "./useGlobalModal"; import { GroupMigrationModal } from "../components/modals/GroupMigrationModal"; import { checkForGroupMigration } from "../services/profile-service"; import { MigrationInfo } from "../types/profile"; +import { useRecentProfilesStore } from "../store/useRecentProfilesStore"; interface UseProfileLaunchOptions { profileId: string; + profileName?: string; quickPlaySingleplayer?: string; quickPlayMultiplayer?: string; onLaunchSuccess?: () => void; @@ -22,10 +24,11 @@ interface UseProfileLaunchOptions { } export function useProfileLaunch(options: UseProfileLaunchOptions) { - const { profileId, quickPlaySingleplayer, quickPlayMultiplayer, onLaunchSuccess, onLaunchError } = options; + const { profileId, profileName, quickPlaySingleplayer, quickPlayMultiplayer, onLaunchSuccess, onLaunchError } = options; const pollingIntervalRef = useRef(null); const { showModal, hideModal } = useGlobalModal(); + const { addRecentProfile } = useRecentProfilesStore(); const { @@ -61,6 +64,11 @@ export function useProfileLaunch(options: UseProfileLaunchOptions) { console.log(`[useProfileLaunch] LaunchSuccessful event for ${profileId}`); finalizeButtonLaunch(profileId); setButtonStatusMessage(profileId, "STARTING!"); + + if (profileName) { + addRecentProfile(profileId, profileName); + } + setTimeout(() => { setButtonStatusMessage(profileId, null); }, 3000); diff --git a/src/services/process-service.ts b/src/services/process-service.ts index 59355e66..581079d8 100644 --- a/src/services/process-service.ts +++ b/src/services/process-service.ts @@ -49,9 +49,9 @@ export async function launch( return invoke("launch_profile", { id, - quickPlaySingleplayer, - quickPlayMultiplayer, - migrationInfo + quick_play_singleplayer: quickPlaySingleplayer, + quick_play_multiplayer: quickPlayMultiplayer, + migration_info: migrationInfo }); } diff --git a/src/services/profile-service.ts b/src/services/profile-service.ts index 74c3b5d2..69549d15 100644 --- a/src/services/profile-service.ts +++ b/src/services/profile-service.ts @@ -66,8 +66,9 @@ export async function launchProfile( ): Promise { return invoke("launch_profile", { id, - quickPlaySingleplayer, - quickPlayMultiplayer + quick_play_singleplayer: quickPlaySingleplayer, + quick_play_multiplayer: quickPlayMultiplayer, + migration_info: null }); } diff --git a/src/store/background-effect-store.ts b/src/store/background-effect-store.ts index 07651e8c..e4083dd2 100644 --- a/src/store/background-effect-store.ts +++ b/src/store/background-effect-store.ts @@ -13,11 +13,20 @@ export enum BACKGROUND_EFFECTS { NEBULA_LIQUID_CHROME = "nebula-liquid-chrome", RETRO_GRID = "retro-grid", PLAIN_BACKGROUND = "plain-background", + CUSTOM_IMAGE = "custom-image", } interface BackgroundEffectState { currentEffect: string; setCurrentEffect: (effect: string) => void; + customBackgroundImage: string | null; + setCustomBackgroundImage: (imagePath: string | null) => void; + backgroundImageOpacity: number; + setBackgroundImageOpacity: (opacity: number) => void; + backgroundImageBlur: number; + setBackgroundImageBlur: (blur: number) => void; + backgroundImageScale: number; + setBackgroundImageScale: (scale: number) => void; } export const useBackgroundEffectStore = create()( @@ -25,6 +34,14 @@ export const useBackgroundEffectStore = create()( (set) => ({ currentEffect: BACKGROUND_EFFECTS.RETRO_GRID, setCurrentEffect: (effect) => set({ currentEffect: effect }), + customBackgroundImage: null, + setCustomBackgroundImage: (imagePath) => set({ customBackgroundImage: imagePath }), + backgroundImageOpacity: 0.8, + setBackgroundImageOpacity: (opacity) => set({ backgroundImageOpacity: opacity }), + backgroundImageBlur: 0, + setBackgroundImageBlur: (blur) => set({ backgroundImageBlur: blur }), + backgroundImageScale: 1.0, + setBackgroundImageScale: (scale) => set({ backgroundImageScale: scale }), }), { name: "norisk-background-effect-storage", diff --git a/src/store/useHiddenProfilesStore.ts b/src/store/useHiddenProfilesStore.ts new file mode 100644 index 00000000..cd6158d4 --- /dev/null +++ b/src/store/useHiddenProfilesStore.ts @@ -0,0 +1,51 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface HiddenProfilesState { + hiddenProfileIds: string[]; + showHiddenProfiles: boolean; + hideProfile: (profileId: string) => void; + showProfile: (profileId: string) => void; + isProfileHidden: (profileId: string) => boolean; + clearHiddenProfiles: () => void; + toggleShowHiddenProfiles: () => void; +} + +export const useHiddenProfilesStore = create()( + persist( + (set, get) => ({ + hiddenProfileIds: [], + showHiddenProfiles: false, + + hideProfile: (profileId: string) => { + set((state) => ({ + hiddenProfileIds: [...state.hiddenProfileIds, profileId], + })); + }, + + showProfile: (profileId: string) => { + set((state) => ({ + hiddenProfileIds: state.hiddenProfileIds.filter(id => id !== profileId), + })); + }, + + isProfileHidden: (profileId: string) => { + const { hiddenProfileIds } = get(); + return hiddenProfileIds.includes(profileId); + }, + + clearHiddenProfiles: () => { + set({ hiddenProfileIds: [] }); + }, + + toggleShowHiddenProfiles: () => { + set((state) => ({ + showHiddenProfiles: !state.showHiddenProfiles, + })); + }, + }), + { + name: 'hidden-profiles-storage', + }, + ), +); diff --git a/src/store/useRecentProfilesStore.ts b/src/store/useRecentProfilesStore.ts new file mode 100644 index 00000000..33d81c57 --- /dev/null +++ b/src/store/useRecentProfilesStore.ts @@ -0,0 +1,83 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface RecentProfile { + id: string; + name: string; + lastLaunched: number; + launchCount: number; +} + +interface RecentProfilesState { + recentProfiles: RecentProfile[]; + addRecentProfile: (profileId: string, profileName: string) => void; + getRecentProfiles: (limit?: number) => RecentProfile[]; + clearRecentProfiles: () => void; + removeRecentProfile: (profileId: string) => void; +} + +const STORAGE_KEY = "norisk-recent-profiles"; + +export const useRecentProfilesStore = create()( + persist( + (set, get) => ({ + recentProfiles: [], + + addRecentProfile: (profileId: string, profileName: string) => { + set((state) => { + const existingIndex = state.recentProfiles.findIndex(p => p.id === profileId); + const now = Date.now(); + + if (existingIndex >= 0) { + // Update existing profile + const updated = [...state.recentProfiles]; + updated[existingIndex] = { + ...updated[existingIndex], + lastLaunched: now, + launchCount: updated[existingIndex].launchCount + 1 + }; + return { recentProfiles: updated }; + } else { + // Add new profile + const newProfile: RecentProfile = { + id: profileId, + name: profileName, + lastLaunched: now, + launchCount: 1 + }; + return { + recentProfiles: [newProfile, ...state.recentProfiles].slice(0, 10) // Keep max 10 + }; + } + }); + }, + + getRecentProfiles: (limit = 5) => { + const { recentProfiles } = get(); + return recentProfiles + .sort((a, b) => b.lastLaunched - a.lastLaunched) + .slice(0, limit); + }, + + clearRecentProfiles: () => { + set({ recentProfiles: [] }); + }, + + removeRecentProfile: (profileId: string) => { + set((state) => ({ + recentProfiles: state.recentProfiles.filter(p => p.id !== profileId) + })); + }, + }), + { + name: STORAGE_KEY, + onRehydrateStorage: () => (state) => { + if (state) { + if (!Array.isArray(state.recentProfiles)) { + state.recentProfiles = []; + } + } + }, + }, + ), +); diff --git a/src/store/useSkinFavoritesStore.ts b/src/store/useSkinFavoritesStore.ts new file mode 100644 index 00000000..050aed8a --- /dev/null +++ b/src/store/useSkinFavoritesStore.ts @@ -0,0 +1,65 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { toast } from "react-hot-toast"; + +interface SkinFavoritesState { + favoriteSkinIds: string[]; + isFavorite: (skinId: string) => boolean; + addFavorite: (skinId: string) => void; + removeFavorite: (skinId: string) => void; + toggleFavorite: (skinId: string) => void; + clearFavorites: () => void; +} + +const STORAGE_KEY = "norisk-skin-favorites"; + +export const useSkinFavoritesStore = create()( + persist( + (set, get) => ({ + favoriteSkinIds: [], + + isFavorite: (skinId: string) => { + return get().favoriteSkinIds.includes(skinId); + }, + + addFavorite: (skinId: string) => { + set((state) => { + if (state.favoriteSkinIds.includes(skinId)) return state; + return { favoriteSkinIds: [skinId, ...state.favoriteSkinIds] }; + }); + toast.success("Skin added to favorites!"); + }, + + removeFavorite: (skinId: string) => { + set((state) => ({ + favoriteSkinIds: state.favoriteSkinIds.filter((id) => id !== skinId), + })); + toast.success("Skin removed from favorites!"); + }, + + toggleFavorite: (skinId: string) => { + const { isFavorite, addFavorite, removeFavorite } = get(); + if (isFavorite(skinId)) { + removeFavorite(skinId); + } else { + addFavorite(skinId); + } + }, + + clearFavorites: () => { + set({ favoriteSkinIds: [] }); + toast.success("All favorites cleared!"); + }, + }), + { + name: STORAGE_KEY, + onRehydrateStorage: () => (state) => { + if (state) { + if (!Array.isArray(state.favoriteSkinIds)) { + state.favoriteSkinIds = []; + } + } + }, + }, + ), +); diff --git a/src/store/useSkinFoldersStore.ts b/src/store/useSkinFoldersStore.ts new file mode 100644 index 00000000..8e098827 --- /dev/null +++ b/src/store/useSkinFoldersStore.ts @@ -0,0 +1,86 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export interface SkinFolder { + id: string; + name: string; + created_at: string; + skin_ids: string[]; +} + +interface SkinFoldersState { + folders: SkinFolder[]; + addFolder: (name: string) => void; + deleteFolder: (folderId: string) => void; + renameFolder: (folderId: string, newName: string) => void; + addSkinToFolder: (folderId: string, skinId: string) => void; + removeSkinFromFolder: (folderId: string, skinId: string) => void; +} + +export const useSkinFoldersStore = create()( + persist( + (set, get) => ({ + folders: [], + + addFolder: (name: string) => { + const newFolder: SkinFolder = { + id: `folder_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + name: name.trim(), + created_at: new Date().toISOString(), + skin_ids: [], + }; + + set((state) => ({ + folders: [...state.folders, newFolder], + })); + }, + + deleteFolder: (folderId: string) => { + set((state) => ({ + folders: state.folders.filter(folder => folder.id !== folderId), + })); + }, + + renameFolder: (folderId: string, newName: string) => { + set((state) => ({ + folders: state.folders.map(folder => + folder.id === folderId + ? { ...folder, name: newName.trim() } + : folder + ), + })); + }, + + addSkinToFolder: (folderId: string, skinId: string) => { + set((state) => ({ + folders: state.folders.map(folder => + folder.id === folderId + ? { + ...folder, + skin_ids: folder.skin_ids.includes(skinId) + ? folder.skin_ids + : [...folder.skin_ids, skinId] + } + : folder + ), + })); + }, + + removeSkinFromFolder: (folderId: string, skinId: string) => { + set((state) => ({ + folders: state.folders.map(folder => + folder.id === folderId + ? { + ...folder, + skin_ids: folder.skin_ids.filter(id => id !== skinId) + } + : folder + ), + })); + }, + }), + { + name: 'skin-folders-storage', + } + ) +); diff --git a/src/store/useSkinVisibilityStore.ts b/src/store/useSkinVisibilityStore.ts new file mode 100644 index 00000000..b177d3d5 --- /dev/null +++ b/src/store/useSkinVisibilityStore.ts @@ -0,0 +1,21 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface SkinVisibilityState { + isSkinVisible: boolean; + toggleSkinVisibility: () => void; + setSkinVisibility: (visible: boolean) => void; +} + +export const useSkinVisibilityStore = create()( + persist( + (set) => ({ + isSkinVisible: true, + toggleSkinVisibility: () => set((state) => ({ isSkinVisible: !state.isSkinVisible })), + setSkinVisibility: (visible) => set({ isSkinVisible: visible }), + }), + { + name: "norisk-skin-visibility-storage", + }, + ), +); diff --git a/src/styles/globals.css b/src/styles/globals.css index a5aaa726..ddfbe0e1 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -203,6 +203,15 @@ } } +@keyframes pulse-scale { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } +} + .animate-skeleton-pulse { animation: skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }