Add settings panel
This commit is contained in:
parent
298482f70e
commit
bf5bdd1f72
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -1,5 +1,6 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"cSpell.words": ["autolock"],
|
||||
"cSpell.ignorePaths": [
|
||||
"**/package-lock.json",
|
||||
"**/node_modules/**",
|
||||
@ -9,4 +10,4 @@
|
||||
".vscode-insiders",
|
||||
"i18n.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
18
package.json
18
package.json
@ -10,39 +10,39 @@
|
||||
"repl": "node -r ts-node/register/transpile-only src/repl.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.2.22",
|
||||
"@types/chai": "^4.3.0",
|
||||
"@types/chai-as-promised": "^7.1.4",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/mocha": "github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768",
|
||||
"@types/node": "^16.11.9",
|
||||
"@types/sinon": "^10.0.6",
|
||||
"@types/sinon-chai": "^3.2.5",
|
||||
"@types/sinon-chai": "^3.2.6",
|
||||
"@types/wicg-file-system-access": "^2020.9.4",
|
||||
"@typescript-eslint/eslint-plugin": "5.4.0",
|
||||
"@typescript-eslint/parser": "5.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.7.0",
|
||||
"@typescript-eslint/parser": "5.7.0",
|
||||
"chai": "^4.3.4",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"chalk": "^4.1.2",
|
||||
"eslint": "8.3.0",
|
||||
"eslint": "8.5.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-import-resolver-typescript": "2.5.0",
|
||||
"eslint-plugin-import": "2.25.3",
|
||||
"eslint-plugin-react": "7.27.1",
|
||||
"eslint-plugin-react-hooks": "4.3.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"marked": "^4.0.4",
|
||||
"marked": "^4.0.8",
|
||||
"mocha": "^9.1.3",
|
||||
"mochawesome": "^7.0.1",
|
||||
"prettier": "^2.4.1",
|
||||
"prettier": "^2.5.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"sass": "^1.43.4",
|
||||
"sass": "^1.45.0",
|
||||
"sinon": "^12.0.1",
|
||||
"sinon-chai": "^3.7.0",
|
||||
"tslib": "^2.3.1",
|
||||
"ts-node": "^10.4.0",
|
||||
"tsconfig-paths": "^3.12.0",
|
||||
"typescript": "^4.5.2"
|
||||
"typescript": "^4.5.4"
|
||||
},
|
||||
"prettier": {
|
||||
"arrowParens": "avoid",
|
||||
|
@ -15,9 +15,9 @@
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-json": "^4.1.0",
|
||||
"@rollup/plugin-replace": "^3.0.0",
|
||||
"prettier": "^2.4.1",
|
||||
"rollup": "^2.60.1",
|
||||
"prettier": "^2.5.1",
|
||||
"rollup": "^2.61.1",
|
||||
"rollup-plugin-ts": "^2.0.4",
|
||||
"typedoc": "^0.22.9"
|
||||
"typedoc": "^0.22.10"
|
||||
}
|
||||
}
|
||||
|
@ -23,12 +23,14 @@ export type TextField = {
|
||||
value: string
|
||||
designation: string
|
||||
name: string
|
||||
id?: undefined
|
||||
}
|
||||
export type BooleanField = {
|
||||
type: FieldType.Checkbox
|
||||
name: string
|
||||
value?: "✓" | string
|
||||
designation?: undefined
|
||||
id?: undefined
|
||||
}
|
||||
|
||||
export type ItemField =
|
||||
@ -38,12 +40,14 @@ export type ItemField =
|
||||
name: string
|
||||
designation: "username"
|
||||
value: string
|
||||
id?: undefined
|
||||
type?: undefined
|
||||
}
|
||||
| {
|
||||
name: string
|
||||
designation: "password"
|
||||
value: string
|
||||
id?: undefined
|
||||
type?: undefined
|
||||
}
|
||||
| {
|
||||
@ -51,6 +55,7 @@ export type ItemField =
|
||||
type: FieldType
|
||||
value: string
|
||||
designation?: string
|
||||
id?: undefined
|
||||
name: string
|
||||
}
|
||||
|
||||
|
@ -11,7 +11,7 @@ build({
|
||||
entryPoints: ["./src/electron/index.ts", "./src/electron/preload.ts"],
|
||||
outdir: "./dist/main",
|
||||
external: builtinModules.concat("electron"),
|
||||
target: ["chrome90"],
|
||||
target: ["chrome96"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
sourcemap: "external",
|
||||
minify: process.env.NODE_ENV === "production",
|
||||
|
@ -13,17 +13,17 @@
|
||||
"bundle": "./scripts/build.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@emotion/css": "^11.5.0",
|
||||
"@emotion/react": "^11.6.0",
|
||||
"@emotion/css": "^11.7.1",
|
||||
"@emotion/react": "^11.7.1",
|
||||
"@emotion/styled": "^11.6.0",
|
||||
"@rollup/plugin-yaml": "^3.1.0",
|
||||
"@types/react": "^17.0.36",
|
||||
"@types/react": "^17.0.37",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@vitejs/plugin-react": "^1.1.0",
|
||||
"@vitejs/plugin-react": "^1.1.3",
|
||||
"buffer": "^6.0.3",
|
||||
"electron": "^16.0.1",
|
||||
"electron": "^16.0.5",
|
||||
"electron-builder": "^22.14.5",
|
||||
"esbuild": "^0.13.15",
|
||||
"esbuild": "^0.14.5",
|
||||
"js-yaml": "^4.1.0",
|
||||
"opvault.js": "*",
|
||||
"path-browserify": "^1.0.1",
|
||||
@ -31,8 +31,8 @@
|
||||
"react-dom": "^17.0.2",
|
||||
"react-idle-timer": "4.6.4",
|
||||
"react-icons": "^4.3.1",
|
||||
"sass": "^1.43.4",
|
||||
"typescript": "^4.5.2",
|
||||
"vite": "^2.6.14"
|
||||
"sass": "^1.45.0",
|
||||
"typescript": "^4.5.4",
|
||||
"vite": "^2.7.3"
|
||||
}
|
||||
}
|
||||
|
@ -1,21 +1,32 @@
|
||||
/* eslint-disable import/no-unresolved */
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import type { Vault, OnePassword } from "opvault.js"
|
||||
import { useIdleTimer } from "react-idle-timer/modern"
|
||||
import { VaultView } from "./pages/Vault"
|
||||
import { VaultPicker } from "./pages/VaultPicker"
|
||||
import { Key, useStorage } from "./utils/localStorage"
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const [instance, setInstance] = useState<OnePassword>()
|
||||
const [vault, setVault] = useState<Vault>()
|
||||
|
||||
const [enableAutoLock] = useStorage(Key.ENABLE_AUTO_LOCK)
|
||||
const [autolockAfter] = useStorage(Key.AUTO_LOCK_AFTER)
|
||||
|
||||
const onLock = useCallback(() => {
|
||||
vault?.lock()
|
||||
setVault(undefined)
|
||||
}, [vault])
|
||||
|
||||
const onAutoLock = useCallback(() => {
|
||||
if (enableAutoLock) {
|
||||
onLock()
|
||||
}
|
||||
}, [onLock, enableAutoLock])
|
||||
|
||||
const { reset, pause } = useIdleTimer({
|
||||
timeout: 60_000,
|
||||
onIdle: onLock,
|
||||
timeout: autolockAfter * 1000,
|
||||
onIdle: onAutoLock,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -12,6 +12,7 @@ const Container: React.FC = styled.div`
|
||||
export const FieldTitle: React.FC = styled.div`
|
||||
font-size: 85%;
|
||||
margin-bottom: 3px;
|
||||
user-select: none;
|
||||
`
|
||||
|
||||
export const ItemFieldView = memo<{
|
||||
@ -31,10 +32,13 @@ export const ItemFieldView = memo<{
|
||||
)
|
||||
})
|
||||
|
||||
const hideIds = new Set(["use_desktop", "use_mobile", "use_html"])
|
||||
const hideNames = new Set(["remember"])
|
||||
|
||||
export const ItemDetailsFieldView = memo<{
|
||||
field: ItemField
|
||||
}>(({ field }) => {
|
||||
if (field.value == null) {
|
||||
if (field.value == null || hideIds.has(field.id!) || hideNames.has(field.name)) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
@ -6,12 +6,23 @@ import { parseMonthYear } from "../utils"
|
||||
import { BigTextView } from "./BigTextView"
|
||||
import { ErrorBoundary } from "./ErrorBoundary"
|
||||
import { useItemFieldContextMenu } from "./ItemFieldContextMenu"
|
||||
import { toast, ToastType } from "./Toast"
|
||||
|
||||
const Container = styled.div``
|
||||
const Container = styled.div`
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
color: #6fa9ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
`
|
||||
|
||||
function useCopy(text: string) {
|
||||
return useCallback(() => {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast({
|
||||
type: ToastType.Secondary,
|
||||
message: "Copied to clipboard.",
|
||||
})
|
||||
}, [text])
|
||||
}
|
||||
|
||||
@ -38,6 +49,7 @@ const Password: React.FC<{
|
||||
<Container
|
||||
onContextMenu={onRightClick}
|
||||
onDoubleClick={() => setShow(x => !x)}
|
||||
onClick={onCopy}
|
||||
style={{
|
||||
fontFamily: "var(--monospace)",
|
||||
...(!show && { userSelect: "none" }),
|
||||
@ -75,7 +87,9 @@ const TextView: React.FC<{ value: string }> = ({ value }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container onContextMenu={onRightClick}>{value}</Container>
|
||||
<Container onContextMenu={onRightClick} onClick={onCopy}>
|
||||
{value}
|
||||
</Container>
|
||||
<ContextMenuContainer>
|
||||
<Item onClick={onCopy}>Copier</Item>
|
||||
</ContextMenuContainer>
|
||||
|
71
packages/web/src/components/Modal.tsx
Normal file
71
packages/web/src/components/Modal.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import styled from "@emotion/styled"
|
||||
import { useCallback } from "react"
|
||||
|
||||
const ModalBackground = styled.div`
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(1px);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
`
|
||||
const ModalBackground2 = styled.div`
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
const ModalContainer = styled.div`
|
||||
background: var(--page-background);
|
||||
box-shadow: rgba(0, 0, 0, 0.25) 0px 14px 28px, rgba(0, 0, 0, 0.22) 0px 10px 10px;
|
||||
border-radius: 5px;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
`
|
||||
const ModalTitle = styled.div`
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 10px 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
`
|
||||
const ModalContent = styled.div`
|
||||
padding: 15px 20px;
|
||||
`
|
||||
|
||||
export const Modal: React.FC<{
|
||||
show: boolean
|
||||
title: string
|
||||
onClose(): void
|
||||
}> = ({ show, children, title, onClose }) => {
|
||||
const onBackgroundClick = useCallback(
|
||||
e => {
|
||||
if (e.currentTarget === e.target) {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
)
|
||||
|
||||
if (!show) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalBackground />
|
||||
<ModalBackground2 onClick={onBackgroundClick}>
|
||||
<ModalContainer>
|
||||
<ModalTitle>{title}</ModalTitle>
|
||||
<ModalContent>{children}</ModalContent>
|
||||
</ModalContainer>
|
||||
</ModalBackground2>
|
||||
</>
|
||||
)
|
||||
}
|
69
packages/web/src/components/Toast.tsx
Normal file
69
packages/web/src/components/Toast.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import styled from "@emotion/styled"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export enum ToastType {
|
||||
Regular = "regular",
|
||||
Primary = "primary",
|
||||
Secondary = "secondary",
|
||||
Success = "success",
|
||||
Danger = "danger",
|
||||
Warning = "warning",
|
||||
Info = "info",
|
||||
}
|
||||
|
||||
interface Message {
|
||||
message: string
|
||||
type: ToastType
|
||||
}
|
||||
interface InternalMessage extends Message {
|
||||
opacity: number
|
||||
id: number
|
||||
}
|
||||
|
||||
export let toast: (message: Message) => void
|
||||
|
||||
const Container = styled.div``
|
||||
const ToastContainer = styled.div`
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 20px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0.5rem 1rem rgb(0 0 0 / 15%);
|
||||
transition: opacity 1s ease-in-out, bottom 0.3s linear;
|
||||
`
|
||||
|
||||
let lastId = 0
|
||||
|
||||
export const Toast: React.FC = () => {
|
||||
const [list, setList] = useState<InternalMessage[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
toast = message => {
|
||||
const newId = ++lastId
|
||||
setList(list => list.concat({ ...message, id: newId, opacity: 1 }))
|
||||
setTimeout(() => {
|
||||
setList(list => list.map(x => (x.id === newId ? { ...x, opacity: 0 } : x)))
|
||||
}, 1000)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{list.map((message, i, { length }) => (
|
||||
<ToastContainer
|
||||
onTransitionEnd={e => {
|
||||
if (e.propertyName === "opacity") {
|
||||
setList(list => list.filter(x => x.id !== message.id))
|
||||
}
|
||||
}}
|
||||
key={message.id}
|
||||
style={{ opacity: message.opacity, bottom: 40 * (length - i) - 10 }}
|
||||
className={`color-${message.type}`}
|
||||
>
|
||||
{message.message}
|
||||
</ToastContainer>
|
||||
))}
|
||||
</Container>
|
||||
)
|
||||
}
|
@ -8,20 +8,18 @@ import {
|
||||
useState,
|
||||
} from "react"
|
||||
import texts from "./texts.yml"
|
||||
import { get, set, Key } from "../utils/localStorage"
|
||||
|
||||
const categories = Object.keys(texts)
|
||||
|
||||
const ALLOWED = new Set(["en", "fr"])
|
||||
const SKIP_ITALIC = new Set(["zh", "ko", "ja"])
|
||||
const LOCALSTORAGE_KEY = "preferred-locale"
|
||||
|
||||
function getLocaleFromStorage() {
|
||||
try {
|
||||
const key = localStorage.getItem(LOCALSTORAGE_KEY)
|
||||
if (key && ALLOWED.has(key)) {
|
||||
return key
|
||||
}
|
||||
} catch {}
|
||||
const key = get(Key.PREFERRED_LOCALE)
|
||||
if (key && ALLOWED.has(key)) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
function getNavigatorLocale() {
|
||||
@ -81,9 +79,8 @@ export function useTranslate() {
|
||||
export const LocaleContextProvider = memo(({ children }) => {
|
||||
const [locale, setLocale] = useState(getEnvLocale)
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(LOCALSTORAGE_KEY, locale)
|
||||
} catch {}
|
||||
set(Key.PREFERRED_LOCALE, locale)
|
||||
document.documentElement.lang = locale
|
||||
}, [locale])
|
||||
const value = useMemo(() => ({ locale, setLocale }), [locale])
|
||||
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
|
||||
|
@ -10,8 +10,8 @@ body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, system-ui, "Roboto", "Oxygen", "Ubuntu",
|
||||
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, system-ui, "Roboto", "Oxygen",
|
||||
"Cantarell", "Droid Sans", "Helvetica Neue", "Noto Sans CJK JP", sans-serif;
|
||||
}
|
||||
:root {
|
||||
--page-background: #fff;
|
||||
@ -66,22 +66,49 @@ input {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
input[type="search"],
|
||||
input[type="input"],
|
||||
input[type="password"] {
|
||||
@mixin input {
|
||||
@include scheme(background-color, #fff, #2d2d2d);
|
||||
border-radius: 6px;
|
||||
border: 1px solid;
|
||||
@include scheme(border-color, #cdc7c2, #1b1b1b);
|
||||
color: inherit;
|
||||
outline: none;
|
||||
padding: 7px 8px;
|
||||
transition: 0.1s;
|
||||
&:focus {
|
||||
@include scheme(border-color, #3584e480, #15539e);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="search"],
|
||||
input[type="input"],
|
||||
input[type="number"],
|
||||
input[type="password"] {
|
||||
@include input;
|
||||
border-radius: 6px;
|
||||
border: 1px solid;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
padding: 7px 8px;
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
input[type="checkbox" i] {
|
||||
@include input;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05),
|
||||
inset 0px -15px 10px -12px rgba(0, 0, 0, 0.05);
|
||||
padding: 9px;
|
||||
border-radius: 3px;
|
||||
appearance: none;
|
||||
position: relative;
|
||||
&:checked:after {
|
||||
content: "\2714";
|
||||
font-size: 15px;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 3px;
|
||||
color: var(--color);
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
@include scheme(background-color, #f6f5f4, #333);
|
||||
@ -110,3 +137,34 @@ button[type="submit"] {
|
||||
select {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
// #region color
|
||||
.color-primary,
|
||||
.color-secondary,
|
||||
.color-info,
|
||||
.color-danger {
|
||||
@include scheme(color, #fff, #fafafa);
|
||||
}
|
||||
.color-success,
|
||||
.color-warning {
|
||||
@include scheme(color, #000, #111);
|
||||
}
|
||||
.color-primary {
|
||||
@include scheme(background-color, #0b5ed7, #375a7f);
|
||||
}
|
||||
.color-secondary {
|
||||
@include scheme(background-color, #6c757d, #626262);
|
||||
}
|
||||
.color-success {
|
||||
@include scheme(background-color, #198754, #00bc8c);
|
||||
}
|
||||
.color-info {
|
||||
@include scheme(background-color, #0dcaf0, #17a2b8);
|
||||
}
|
||||
.color-warning {
|
||||
@include scheme(background-color, #ffc107, #f39c12);
|
||||
}
|
||||
.color-danger {
|
||||
@include scheme(background-color, #dc3545, #e74c3c);
|
||||
}
|
||||
// #endregion
|
||||
|
@ -3,6 +3,7 @@ import { render } from "react-dom"
|
||||
import { App } from "./App"
|
||||
import { LocaleContextProvider } from "./i18n"
|
||||
import { SideEffect } from "./SideEffect"
|
||||
import { Toast } from "./components/Toast"
|
||||
import "./index.scss"
|
||||
|
||||
if (navigator.platform === "MacIntel") {
|
||||
@ -15,6 +16,7 @@ const Root: React.FC = () => (
|
||||
<LocaleContextProvider>
|
||||
<SideEffect />
|
||||
<App />
|
||||
<Toast />
|
||||
</LocaleContextProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
@ -4,19 +4,54 @@ import type { Vault, Item } from "opvault.js"
|
||||
import { Category } from "opvault.js"
|
||||
import { FiLock } from "react-icons/fi"
|
||||
import { IoSearch } from "react-icons/io5"
|
||||
import { Si1Password } from "react-icons/si"
|
||||
import { BsGear } from "react-icons/bs"
|
||||
import { ItemList } from "../components/ItemList"
|
||||
import { ItemView } from "../components/Item"
|
||||
import { reactIconClass } from "../components/CategoryIcon"
|
||||
import { useTranslate } from "../i18n/index"
|
||||
import { scrollbar } from "../styles"
|
||||
import { Settings } from "../settings"
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
height: calc(100vh - var(--titlebar-height));
|
||||
`
|
||||
const TabContainer = styled.div`
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 55px;
|
||||
overflow: hidden;
|
||||
padding-bottom: 5px;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #222;
|
||||
border-right-color: transparent;
|
||||
}
|
||||
&&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
const TabButton = styled.button<{ active?: boolean }>`
|
||||
align-items: center;
|
||||
background: ${p => (p.active ? "var(--selected-background)" : "transparent")};
|
||||
border-radius: ${p => (p.active ? 0 : 3)}px;
|
||||
border: transparent;
|
||||
box-shadow: none;
|
||||
display: inline-flex;
|
||||
margin-bottom: 5px;
|
||||
font-size: 22px;
|
||||
padding: 10px 14px;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--selected-background: #1c1c1c;
|
||||
}
|
||||
`
|
||||
const TabContainerMain = styled.div`
|
||||
flex-grow: 1;
|
||||
`
|
||||
const ListContainer = styled.div`
|
||||
border-right: 1px solid var(--border-color);
|
||||
width: 300px;
|
||||
width: 350px;
|
||||
margin-right: 10px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
@ -33,15 +68,13 @@ const SearchContainer = styled.div`
|
||||
text-align: center;
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
margin: 10px 0;
|
||||
margin-right: 10px;
|
||||
`
|
||||
const SortContainer = styled.div`
|
||||
margin: 10px 10px;
|
||||
`
|
||||
const LockButton = styled.button`
|
||||
&& {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
`
|
||||
|
||||
const SearchInput = styled.input`
|
||||
--margin: 10px;
|
||||
width: calc(100% - var(--margin) * 2 + 9px);
|
||||
@ -64,6 +97,7 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
|
||||
vault,
|
||||
onLock,
|
||||
}) => {
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const t = useTranslate()
|
||||
const [items, setItems] = useState<Item[]>(() => [])
|
||||
const [item, setItem] = useState<Item>()
|
||||
@ -115,20 +149,29 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TabContainer>
|
||||
<TabContainerMain>
|
||||
<TabButton active>
|
||||
<Si1Password />
|
||||
</TabButton>
|
||||
</TabContainerMain>
|
||||
<TabButton onClick={onLock} title={t.action.lock}>
|
||||
<FiLock />
|
||||
</TabButton>
|
||||
<TabButton onClick={() => setShowSettings(true)}>
|
||||
<BsGear />
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
|
||||
<ListContainer className={scrollbar}>
|
||||
<div style={{ margin: "10px 10px", display: "flex" }}>
|
||||
<LockButton onClick={onLock} title={t.action.lock}>
|
||||
<FiLock />
|
||||
</LockButton>
|
||||
<SearchContainer>
|
||||
<SearchInput
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<SearchIcon className={reactIconClass} />
|
||||
</SearchContainer>
|
||||
</div>
|
||||
<SearchContainer>
|
||||
<SearchInput
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<SearchIcon className={reactIconClass} />
|
||||
</SearchContainer>
|
||||
|
||||
<SortContainer>
|
||||
<select
|
||||
@ -146,6 +189,8 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
|
||||
<ItemContainer>
|
||||
{item && <ItemView className={scrollbar} item={item} />}
|
||||
</ItemContainer>
|
||||
|
||||
<Settings show={showSettings} onHide={() => setShowSettings(false)} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
86
packages/web/src/settings/index.tsx
Normal file
86
packages/web/src/settings/index.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import styled from "@emotion/styled"
|
||||
import { Modal } from "../components/Modal"
|
||||
import { useLocaleContext, useTranslate } from "../i18n"
|
||||
import { Key, useStorage } from "../utils/localStorage"
|
||||
|
||||
const FormItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 13px;
|
||||
`
|
||||
const FormLabel = styled.div`
|
||||
width: 120px;
|
||||
`
|
||||
const FormValue = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
min-width: 200px;
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
`
|
||||
const Checkbox = styled.input`
|
||||
margin-left: 0;
|
||||
margin-right: 8px;
|
||||
`
|
||||
|
||||
const GhostLabel = styled.div`
|
||||
opacity: 0.5;
|
||||
position: absolute;
|
||||
left: 37px;
|
||||
pointer-events: none;
|
||||
`
|
||||
|
||||
export const Settings: React.FC<{
|
||||
show: boolean
|
||||
onHide(): void
|
||||
}> = ({ show = true, onHide }) => {
|
||||
const { locale, setLocale } = useLocaleContext()
|
||||
const t = useTranslate()
|
||||
|
||||
const [enableAutoLock, setEnableAutoLock] = useStorage(Key.ENABLE_AUTO_LOCK)
|
||||
const [autolockAfter, setAutolockAfter] = useStorage(Key.AUTO_LOCK_AFTER)
|
||||
|
||||
return (
|
||||
<Modal show={show} title={t.label.settings} onClose={onHide}>
|
||||
<FormItem>
|
||||
<FormLabel>{t.label.language}</FormLabel>
|
||||
<FormValue>
|
||||
<select
|
||||
title={t.label.language}
|
||||
value={locale}
|
||||
onChange={e => setLocale(e.currentTarget.value)}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
</select>
|
||||
</FormValue>
|
||||
</FormItem>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel title={t.tips.automatically_lock_after_inactivity}>
|
||||
{t.options.enable_autolock}
|
||||
</FormLabel>
|
||||
<FormValue>
|
||||
<Checkbox
|
||||
type="checkbox"
|
||||
checked={enableAutoLock}
|
||||
onChange={e => setEnableAutoLock(e.target.checked)}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={autolockAfter}
|
||||
onChange={e => setAutolockAfter(e.target.valueAsNumber)}
|
||||
disabled={!enableAutoLock}
|
||||
/>
|
||||
<GhostLabel>
|
||||
<span style={{ opacity: 0 }}>{autolockAfter} </span>
|
||||
{t.noun.seconds}
|
||||
</GhostLabel>
|
||||
</FormValue>
|
||||
</FormItem>
|
||||
</Modal>
|
||||
)
|
||||
}
|
@ -1,26 +1,67 @@
|
||||
export const enum Key {
|
||||
LAST_VAULT_PATH = "lastVaultPath",
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
export enum Key {
|
||||
LAST_VAULT_PATH = "app.state.last_vault_path",
|
||||
PREFERRED_LOCALE = "app.config.locale",
|
||||
ENABLE_AUTO_LOCK = "app.config.enable_auto_lock",
|
||||
AUTO_LOCK_AFTER = "app.config.auto_lock_after",
|
||||
}
|
||||
|
||||
interface StoredData {
|
||||
[Key.LAST_VAULT_PATH]: string
|
||||
[Key.PREFERRED_LOCALE]: string
|
||||
[Key.ENABLE_AUTO_LOCK]: boolean
|
||||
[Key.AUTO_LOCK_AFTER]: number
|
||||
}
|
||||
|
||||
export function get<K extends keyof StoredData>(key: K) {
|
||||
const events = new Map(Object.values(Key).map(key => [key, new Set()])) as {
|
||||
get<K extends Key>(key: K): Set<(value: StoredData[Key]) => void>
|
||||
}
|
||||
|
||||
export function useStorage<K extends Key>(key: K) {
|
||||
const [state, setState] = useState(get(key)!)
|
||||
useEffect(() => {
|
||||
events.get(key).add(setState as any)
|
||||
return () => {
|
||||
events.get(key).delete(setState as any)
|
||||
}
|
||||
}, [key])
|
||||
const setState2 = useCallback(
|
||||
(value: StoredData[K]) => {
|
||||
set(key, value)
|
||||
},
|
||||
[key]
|
||||
)
|
||||
|
||||
return [state, setState2] as const
|
||||
}
|
||||
|
||||
export function get<K extends Key>(key: K): StoredData[K] | undefined {
|
||||
try {
|
||||
const value = localStorage.getItem(key)
|
||||
return value == null ? undefined : (JSON.parse(value!) as StoredData[K])
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function set<K extends keyof StoredData>(key: K, value: StoredData[K]) {
|
||||
export function set<K extends Key>(key: K, value: StoredData[K]) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {}
|
||||
events.get(key).forEach(fn => fn(value))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
export function remove(key: keyof StoredData) {
|
||||
export function remove(key: Key) {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const defaults: typeof set = (key, value) => {
|
||||
if (!(key in localStorage)) {
|
||||
set(key, value)
|
||||
}
|
||||
}
|
||||
defaults(Key.ENABLE_AUTO_LOCK, true)
|
||||
defaults(Key.AUTO_LOCK_AFTER, 120)
|
||||
|
576
pnpm-lock.yaml
generated
576
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user