mirror of
https://github.com/remnawave/utils.git
synced 2026-05-13 12:26:35 +00:00
feat: add zustand for state management
This commit is contained in:
parent
d3c1d4f5d7
commit
c9a6b76a8b
11 changed files with 312 additions and 30 deletions
32
package-lock.json
generated
32
package-lock.json
generated
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export * from './model'
|
||||
export { DecryptCard } from './ui/decrypt-card'
|
||||
|
||||
|
|
|
|||
2
src/features/cryptohapp/decrypt/model/index.ts
Normal file
2
src/features/cryptohapp/decrypt/model/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './interfaces'
|
||||
export * from './use-decrypt-keys-store'
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export type { IActions } from './action.interface'
|
||||
export type { IDecryptKey, IState } from './state.interface'
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export interface IDecryptKey {
|
||||
content: string
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface IState {
|
||||
keys: IDecryptKey[]
|
||||
selectedKeyId: null | string
|
||||
}
|
||||
|
|
@ -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)
|
||||
102
src/features/cryptohapp/decrypt/ui/add-key-modal.tsx
Normal file
102
src/features/cryptohapp/decrypt/ui/add-key-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue