feat: add zustand for state management

This commit is contained in:
kastov 2025-12-30 22:00:37 +03:00
parent d3c1d4f5d7
commit c9a6b76a8b
No known key found for this signature in database
GPG key ID: 1B27BE29057F4C90
11 changed files with 312 additions and 30 deletions

32
package-lock.json generated
View file

@ -23,7 +23,8 @@
"react-error-boundary": "^6.0.1",
"react-icons": "^5.5.0",
"react-router-dom": "6.27.0",
"uqr": "^0.1.2"
"uqr": "^0.1.2",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/compat": "^1.3.2",
@ -12358,6 +12359,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View file

@ -42,7 +42,8 @@
"react-error-boundary": "^6.0.1",
"react-icons": "^5.5.0",
"react-router-dom": "6.27.0",
"uqr": "^0.1.2"
"uqr": "^0.1.2",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/compat": "^1.3.2",

View file

@ -1,2 +1,2 @@
export * from './model'
export { DecryptCard } from './ui/decrypt-card'

View file

@ -0,0 +1,2 @@
export * from './interfaces'
export * from './use-decrypt-keys-store'

View file

@ -0,0 +1,10 @@
import type { IDecryptKey } from './state.interface'
export interface IActions {
actions: {
addKey: (key: Omit<IDecryptKey, 'id'>) => void
deleteKey: (id: string) => void
selectKey: (id: null | string) => void
updateKey: (id: string, key: Partial<Omit<IDecryptKey, 'id'>>) => void
}
}

View file

@ -0,0 +1,2 @@
export type { IActions } from './action.interface'
export type { IDecryptKey, IState } from './state.interface'

View file

@ -0,0 +1,10 @@
export interface IDecryptKey {
content: string
id: string
name: string
}
export interface IState {
keys: IDecryptKey[]
selectedKeyId: null | string
}

View file

@ -0,0 +1,59 @@
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
import { create } from 'zustand'
import type { IActions, IState } from './interfaces'
const generateId = () => {
const salt = Math.random().toString(36).substring(2, 8)
return `${Date.now()}-${salt}`
}
export const useDecryptKeysStore = create<IActions & IState>()(
persist(
devtools(
(set) => ({
keys: [],
selectedKeyId: null,
actions: {
addKey: (key) => {
const newKey = { ...key, id: generateId() }
set((state) => ({
keys: [...state.keys, newKey],
selectedKeyId: newKey.id
}))
},
deleteKey: (id) => {
set((state) => ({
keys: state.keys.filter((k) => k.id !== id),
selectedKeyId: state.selectedKeyId === id ? null : state.selectedKeyId
}))
},
selectKey: (id) => {
set({ selectedKeyId: id })
},
updateKey: (id, updates) => {
set((state) => ({
keys: state.keys.map((k) => (k.id === id ? { ...k, ...updates } : k))
}))
}
}
}),
{ anonymousActionType: 'decryptKeysStore', name: 'decryptKeysStore' }
),
{
name: 'decryptKeysStore',
partialize: (state) => ({
keys: state.keys,
selectedKeyId: state.selectedKeyId
}),
storage: createJSONStorage(() => localStorage),
version: 1
}
)
)
export const useDecryptKeys = () => useDecryptKeysStore((state) => state.keys)
export const useSelectedKeyId = () => useDecryptKeysStore((state) => state.selectedKeyId)
export const useSelectedKey = () =>
useDecryptKeysStore((state) => state.keys.find((k) => k.id === state.selectedKeyId) ?? null)
export const useDecryptKeysActions = () => useDecryptKeysStore((state) => state.actions)

View file

@ -0,0 +1,102 @@
import { Button, Group, Modal, Stack, Textarea, TextInput } from '@mantine/core'
import { IconKey, IconPlus } from '@tabler/icons-react'
import { useState } from 'react'
import { BaseOverlayHeader } from '@shared/ui'
import { useDecryptKeysActions } from '../model'
import styles from './decrypt-card.module.css'
interface AddKeyModalProps {
onClose: () => void
opened: boolean
}
const PLACEHOLDER_KEY = `-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA...
-----END RSA PRIVATE KEY-----`
export function AddKeyModal({ opened, onClose }: AddKeyModalProps) {
const [name, setName] = useState('')
const [content, setContent] = useState('')
const { addKey } = useDecryptKeysActions()
const handleSubmit = () => {
if (!name.trim() || !content.trim()) return
addKey({ content: content.trim(), name: name.trim() })
setName('')
setContent('')
onClose()
}
const handleClose = () => {
setName('')
setContent('')
onClose()
}
return (
<Modal
centered
classNames={{
content: styles.modalContent,
header: styles.modalHeader,
body: styles.modalBody
}}
onClose={handleClose}
opened={opened}
size="lg"
title={
<BaseOverlayHeader
IconComponent={IconKey}
iconSize={20}
iconVariant="gradient-cyan"
title="Add Decrypt Key"
/>
}
>
<Stack gap="md">
<TextInput
data-autofocus
description="A friendly name to identify this key"
label="Key Name"
onChange={(e) => setName(e.currentTarget.value)}
placeholder="e.g. TestKey"
value={name}
/>
<Textarea
autosize
description="RSA private key in PEM format"
label="Private Key"
maxRows={8}
minRows={8}
onChange={(e) => setContent(e.currentTarget.value)}
placeholder={PLACEHOLDER_KEY}
styles={{
input: {
fontFamily: 'Fira Mono, monospace',
fontSize: '0.75rem'
}
}}
value={content}
/>
<Group justify="flex-end" mt="md">
<Button onClick={handleClose} variant="subtle">
Cancel
</Button>
<Button
disabled={!name.trim() || !content.trim()}
leftSection={<IconPlus size={16} />}
onClick={handleSubmit}
variant="gradient-cyan"
>
Add Key
</Button>
</Group>
</Stack>
</Modal>
)
}

View file

@ -32,3 +32,23 @@
.card:hover .icon {
box-shadow: 0 0 30px rgba(251, 146, 60, 0.3);
}
.modalContent {
border: 1px solid rgba(255, 255, 255, 0.08);
}
.modalHeader {
background: rgba(22, 27, 35, 0.95);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-lg);
}
.modalRoot[data-full-screen] .modalHeader {
@supports (padding: env(safe-area-inset-top)) {
padding-top: max(var(--mantine-spacing-sm), env(safe-area-inset-top, 0px));
}
}
.modalBody {
padding: var(--mantine-spacing-lg);
}

View file

@ -1,29 +1,47 @@
import { Box, Button, Card, Group, Stack, Text, Textarea, ThemeIcon } from '@mantine/core'
import { IconLockOpen } from '@tabler/icons-react'
import {
ActionIcon,
Box,
Button,
Card,
Group,
Select,
Stack,
Text,
Textarea,
ThemeIcon,
Tooltip
} from '@mantine/core'
import { IconLockOpen, IconPlus, IconTrash } from '@tabler/icons-react'
import { useDisclosure } from '@mantine/hooks'
import { motion } from 'motion/react'
import JSEncrypt from 'jsencrypt'
import { useState } from 'react'
import { CopyableCodeBlock } from '@shared/ui/copyable-code-block'
import { useDecryptKeys, useDecryptKeysActions, useSelectedKey, useSelectedKeyId } from '../model'
import styles from './decrypt-card.module.css'
const EXAMPLE_PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY-----
Paste your private key here...
-----END RSA PRIVATE KEY-----`
import { AddKeyModal } from './add-key-modal'
export function DecryptCard() {
const [privateKey, setPrivateKey] = useState('')
const [encryptedContent, setEncryptedContent] = useState('')
const [decryptedContent, setDecryptedContent] = useState<null | string>(null)
const [error, setError] = useState<null | string>(null)
const [modalOpened, { open: openModal, close: closeModal }] = useDisclosure(false)
const keys = useDecryptKeys()
const selectedKeyId = useSelectedKeyId()
const selectedKey = useSelectedKey()
const { deleteKey, selectKey } = useDecryptKeysActions()
const keyOptions = keys.map((k) => ({ label: k.name, value: k.id }))
const handleDecrypt = () => {
setError(null)
setDecryptedContent(null)
if (!privateKey.trim()) {
setError('Please enter a valid private key')
if (!selectedKey) {
setError('Please select a decrypt key')
return
}
@ -34,7 +52,7 @@ export function DecryptCard() {
try {
const decrypt = new JSEncrypt()
decrypt.setPrivateKey(privateKey)
decrypt.setPrivateKey(selectedKey.content)
let contentToDecrypt = encryptedContent.trim()
const prefixes = ['happ://crypt2/', 'happ://crypt3/', 'happ://crypt4/']
@ -59,6 +77,12 @@ export function DecryptCard() {
}
}
const handleDeleteKey = () => {
if (selectedKeyId) {
deleteKey(selectedKeyId)
}
}
return (
<motion.div transition={{ type: 'spring', stiffness: 300, damping: 20 }}>
<Card className={styles.card} padding="xl" radius="lg">
@ -82,23 +106,45 @@ export function DecryptCard() {
</Box>
</Group>
<Textarea
autosize
description="Your RSA private key (PEM format)"
label="Private Key"
maxRows={8}
minRows={8}
onChange={(e) => setPrivateKey(e.currentTarget.value)}
placeholder={EXAMPLE_PRIVATE_KEY}
size="md"
styles={{
input: {
fontFamily: 'Fira Mono, monospace',
fontSize: '0.70rem'
}
}}
value={privateKey}
/>
<Stack gap="xs">
<Group align="flex-end" gap="xs">
<Select
allowDeselect={false}
data={keyOptions}
description="🔒 All keys are stored locally in your browser"
label="Decrypt Key"
onChange={(value) => selectKey(value)}
placeholder={
keys.length === 0 ? 'No keys saved' : 'Select a key...'
}
style={{ flex: 1 }}
value={selectedKeyId}
/>
<Tooltip label="Add new key">
<ActionIcon
onClick={openModal}
size="input-sm"
variant="gradient-cyan"
>
<IconPlus size={18} />
</ActionIcon>
</Tooltip>
{selectedKeyId && (
<Tooltip label="Delete selected key">
<ActionIcon
color="red"
onClick={handleDeleteKey}
size="input-sm"
variant="gradient-red"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Stack>
<AddKeyModal onClose={closeModal} opened={modalOpened} />
<Textarea
autosize