Add settings panel
This commit is contained in:
parent
298482f70e
commit
bf5bdd1f72
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
|
"cSpell.words": ["autolock"],
|
||||||
"cSpell.ignorePaths": [
|
"cSpell.ignorePaths": [
|
||||||
"**/package-lock.json",
|
"**/package-lock.json",
|
||||||
"**/node_modules/**",
|
"**/node_modules/**",
|
||||||
|
18
package.json
18
package.json
@ -10,39 +10,39 @@
|
|||||||
"repl": "node -r ts-node/register/transpile-only src/repl.ts"
|
"repl": "node -r ts-node/register/transpile-only src/repl.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chai": "^4.2.22",
|
"@types/chai": "^4.3.0",
|
||||||
"@types/chai-as-promised": "^7.1.4",
|
"@types/chai-as-promised": "^7.1.4",
|
||||||
"@types/fs-extra": "^9.0.13",
|
"@types/fs-extra": "^9.0.13",
|
||||||
"@types/mocha": "github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768",
|
"@types/mocha": "github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768",
|
||||||
"@types/node": "^16.11.9",
|
"@types/node": "^16.11.9",
|
||||||
"@types/sinon": "^10.0.6",
|
"@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",
|
"@types/wicg-file-system-access": "^2020.9.4",
|
||||||
"@typescript-eslint/eslint-plugin": "5.4.0",
|
"@typescript-eslint/eslint-plugin": "5.7.0",
|
||||||
"@typescript-eslint/parser": "5.4.0",
|
"@typescript-eslint/parser": "5.7.0",
|
||||||
"chai": "^4.3.4",
|
"chai": "^4.3.4",
|
||||||
"chai-as-promised": "^7.1.1",
|
"chai-as-promised": "^7.1.1",
|
||||||
"chalk": "^4.1.2",
|
"chalk": "^4.1.2",
|
||||||
"eslint": "8.3.0",
|
"eslint": "8.5.0",
|
||||||
"eslint-config-prettier": "8.3.0",
|
"eslint-config-prettier": "8.3.0",
|
||||||
"eslint-import-resolver-typescript": "2.5.0",
|
"eslint-import-resolver-typescript": "2.5.0",
|
||||||
"eslint-plugin-import": "2.25.3",
|
"eslint-plugin-import": "2.25.3",
|
||||||
"eslint-plugin-react": "7.27.1",
|
"eslint-plugin-react": "7.27.1",
|
||||||
"eslint-plugin-react-hooks": "4.3.0",
|
"eslint-plugin-react-hooks": "4.3.0",
|
||||||
"fs-extra": "^10.0.0",
|
"fs-extra": "^10.0.0",
|
||||||
"marked": "^4.0.4",
|
"marked": "^4.0.8",
|
||||||
"mocha": "^9.1.3",
|
"mocha": "^9.1.3",
|
||||||
"mochawesome": "^7.0.1",
|
"mochawesome": "^7.0.1",
|
||||||
"prettier": "^2.4.1",
|
"prettier": "^2.5.1",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"sass": "^1.43.4",
|
"sass": "^1.45.0",
|
||||||
"sinon": "^12.0.1",
|
"sinon": "^12.0.1",
|
||||||
"sinon-chai": "^3.7.0",
|
"sinon-chai": "^3.7.0",
|
||||||
"tslib": "^2.3.1",
|
"tslib": "^2.3.1",
|
||||||
"ts-node": "^10.4.0",
|
"ts-node": "^10.4.0",
|
||||||
"tsconfig-paths": "^3.12.0",
|
"tsconfig-paths": "^3.12.0",
|
||||||
"typescript": "^4.5.2"
|
"typescript": "^4.5.4"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"arrowParens": "avoid",
|
"arrowParens": "avoid",
|
||||||
|
@ -15,9 +15,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-json": "^4.1.0",
|
"@rollup/plugin-json": "^4.1.0",
|
||||||
"@rollup/plugin-replace": "^3.0.0",
|
"@rollup/plugin-replace": "^3.0.0",
|
||||||
"prettier": "^2.4.1",
|
"prettier": "^2.5.1",
|
||||||
"rollup": "^2.60.1",
|
"rollup": "^2.61.1",
|
||||||
"rollup-plugin-ts": "^2.0.4",
|
"rollup-plugin-ts": "^2.0.4",
|
||||||
"typedoc": "^0.22.9"
|
"typedoc": "^0.22.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,12 +23,14 @@ export type TextField = {
|
|||||||
value: string
|
value: string
|
||||||
designation: string
|
designation: string
|
||||||
name: string
|
name: string
|
||||||
|
id?: undefined
|
||||||
}
|
}
|
||||||
export type BooleanField = {
|
export type BooleanField = {
|
||||||
type: FieldType.Checkbox
|
type: FieldType.Checkbox
|
||||||
name: string
|
name: string
|
||||||
value?: "✓" | string
|
value?: "✓" | string
|
||||||
designation?: undefined
|
designation?: undefined
|
||||||
|
id?: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ItemField =
|
export type ItemField =
|
||||||
@ -38,12 +40,14 @@ export type ItemField =
|
|||||||
name: string
|
name: string
|
||||||
designation: "username"
|
designation: "username"
|
||||||
value: string
|
value: string
|
||||||
|
id?: undefined
|
||||||
type?: undefined
|
type?: undefined
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
name: string
|
name: string
|
||||||
designation: "password"
|
designation: "password"
|
||||||
value: string
|
value: string
|
||||||
|
id?: undefined
|
||||||
type?: undefined
|
type?: undefined
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@ -51,6 +55,7 @@ export type ItemField =
|
|||||||
type: FieldType
|
type: FieldType
|
||||||
value: string
|
value: string
|
||||||
designation?: string
|
designation?: string
|
||||||
|
id?: undefined
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ build({
|
|||||||
entryPoints: ["./src/electron/index.ts", "./src/electron/preload.ts"],
|
entryPoints: ["./src/electron/index.ts", "./src/electron/preload.ts"],
|
||||||
outdir: "./dist/main",
|
outdir: "./dist/main",
|
||||||
external: builtinModules.concat("electron"),
|
external: builtinModules.concat("electron"),
|
||||||
target: ["chrome90"],
|
target: ["chrome96"],
|
||||||
tsconfig: "./tsconfig.json",
|
tsconfig: "./tsconfig.json",
|
||||||
sourcemap: "external",
|
sourcemap: "external",
|
||||||
minify: process.env.NODE_ENV === "production",
|
minify: process.env.NODE_ENV === "production",
|
||||||
|
@ -13,17 +13,17 @@
|
|||||||
"bundle": "./scripts/build.sh"
|
"bundle": "./scripts/build.sh"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@emotion/css": "^11.5.0",
|
"@emotion/css": "^11.7.1",
|
||||||
"@emotion/react": "^11.6.0",
|
"@emotion/react": "^11.7.1",
|
||||||
"@emotion/styled": "^11.6.0",
|
"@emotion/styled": "^11.6.0",
|
||||||
"@rollup/plugin-yaml": "^3.1.0",
|
"@rollup/plugin-yaml": "^3.1.0",
|
||||||
"@types/react": "^17.0.36",
|
"@types/react": "^17.0.37",
|
||||||
"@types/react-dom": "^17.0.11",
|
"@types/react-dom": "^17.0.11",
|
||||||
"@vitejs/plugin-react": "^1.1.0",
|
"@vitejs/plugin-react": "^1.1.3",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"electron": "^16.0.1",
|
"electron": "^16.0.5",
|
||||||
"electron-builder": "^22.14.5",
|
"electron-builder": "^22.14.5",
|
||||||
"esbuild": "^0.13.15",
|
"esbuild": "^0.14.5",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"opvault.js": "*",
|
"opvault.js": "*",
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
@ -31,8 +31,8 @@
|
|||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-idle-timer": "4.6.4",
|
"react-idle-timer": "4.6.4",
|
||||||
"react-icons": "^4.3.1",
|
"react-icons": "^4.3.1",
|
||||||
"sass": "^1.43.4",
|
"sass": "^1.45.0",
|
||||||
"typescript": "^4.5.2",
|
"typescript": "^4.5.4",
|
||||||
"vite": "^2.6.14"
|
"vite": "^2.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,21 +1,32 @@
|
|||||||
|
/* eslint-disable import/no-unresolved */
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import type { Vault, OnePassword } from "opvault.js"
|
import type { Vault, OnePassword } from "opvault.js"
|
||||||
import { useIdleTimer } from "react-idle-timer/modern"
|
import { useIdleTimer } from "react-idle-timer/modern"
|
||||||
import { VaultView } from "./pages/Vault"
|
import { VaultView } from "./pages/Vault"
|
||||||
import { VaultPicker } from "./pages/VaultPicker"
|
import { VaultPicker } from "./pages/VaultPicker"
|
||||||
|
import { Key, useStorage } from "./utils/localStorage"
|
||||||
|
|
||||||
export const App: React.FC = () => {
|
export const App: React.FC = () => {
|
||||||
const [instance, setInstance] = useState<OnePassword>()
|
const [instance, setInstance] = useState<OnePassword>()
|
||||||
const [vault, setVault] = useState<Vault>()
|
const [vault, setVault] = useState<Vault>()
|
||||||
|
|
||||||
|
const [enableAutoLock] = useStorage(Key.ENABLE_AUTO_LOCK)
|
||||||
|
const [autolockAfter] = useStorage(Key.AUTO_LOCK_AFTER)
|
||||||
|
|
||||||
const onLock = useCallback(() => {
|
const onLock = useCallback(() => {
|
||||||
vault?.lock()
|
vault?.lock()
|
||||||
setVault(undefined)
|
setVault(undefined)
|
||||||
}, [vault])
|
}, [vault])
|
||||||
|
|
||||||
|
const onAutoLock = useCallback(() => {
|
||||||
|
if (enableAutoLock) {
|
||||||
|
onLock()
|
||||||
|
}
|
||||||
|
}, [onLock, enableAutoLock])
|
||||||
|
|
||||||
const { reset, pause } = useIdleTimer({
|
const { reset, pause } = useIdleTimer({
|
||||||
timeout: 60_000,
|
timeout: autolockAfter * 1000,
|
||||||
onIdle: onLock,
|
onIdle: onAutoLock,
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -12,6 +12,7 @@ const Container: React.FC = styled.div`
|
|||||||
export const FieldTitle: React.FC = styled.div`
|
export const FieldTitle: React.FC = styled.div`
|
||||||
font-size: 85%;
|
font-size: 85%;
|
||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
|
user-select: none;
|
||||||
`
|
`
|
||||||
|
|
||||||
export const ItemFieldView = memo<{
|
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<{
|
export const ItemDetailsFieldView = memo<{
|
||||||
field: ItemField
|
field: ItemField
|
||||||
}>(({ field }) => {
|
}>(({ field }) => {
|
||||||
if (field.value == null) {
|
if (field.value == null || hideIds.has(field.id!) || hideNames.has(field.name)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,12 +6,23 @@ import { parseMonthYear } from "../utils"
|
|||||||
import { BigTextView } from "./BigTextView"
|
import { BigTextView } from "./BigTextView"
|
||||||
import { ErrorBoundary } from "./ErrorBoundary"
|
import { ErrorBoundary } from "./ErrorBoundary"
|
||||||
import { useItemFieldContextMenu } from "./ItemFieldContextMenu"
|
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) {
|
function useCopy(text: string) {
|
||||||
return useCallback(() => {
|
return useCallback(() => {
|
||||||
navigator.clipboard.writeText(text)
|
navigator.clipboard.writeText(text)
|
||||||
|
toast({
|
||||||
|
type: ToastType.Secondary,
|
||||||
|
message: "Copied to clipboard.",
|
||||||
|
})
|
||||||
}, [text])
|
}, [text])
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,6 +49,7 @@ const Password: React.FC<{
|
|||||||
<Container
|
<Container
|
||||||
onContextMenu={onRightClick}
|
onContextMenu={onRightClick}
|
||||||
onDoubleClick={() => setShow(x => !x)}
|
onDoubleClick={() => setShow(x => !x)}
|
||||||
|
onClick={onCopy}
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "var(--monospace)",
|
fontFamily: "var(--monospace)",
|
||||||
...(!show && { userSelect: "none" }),
|
...(!show && { userSelect: "none" }),
|
||||||
@ -75,7 +87,9 @@ const TextView: React.FC<{ value: string }> = ({ value }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Container onContextMenu={onRightClick}>{value}</Container>
|
<Container onContextMenu={onRightClick} onClick={onCopy}>
|
||||||
|
{value}
|
||||||
|
</Container>
|
||||||
<ContextMenuContainer>
|
<ContextMenuContainer>
|
||||||
<Item onClick={onCopy}>Copier</Item>
|
<Item onClick={onCopy}>Copier</Item>
|
||||||
</ContextMenuContainer>
|
</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,
|
useState,
|
||||||
} from "react"
|
} from "react"
|
||||||
import texts from "./texts.yml"
|
import texts from "./texts.yml"
|
||||||
|
import { get, set, Key } from "../utils/localStorage"
|
||||||
|
|
||||||
const categories = Object.keys(texts)
|
const categories = Object.keys(texts)
|
||||||
|
|
||||||
const ALLOWED = new Set(["en", "fr"])
|
const ALLOWED = new Set(["en", "fr"])
|
||||||
const SKIP_ITALIC = new Set(["zh", "ko", "ja"])
|
const SKIP_ITALIC = new Set(["zh", "ko", "ja"])
|
||||||
const LOCALSTORAGE_KEY = "preferred-locale"
|
|
||||||
|
|
||||||
function getLocaleFromStorage() {
|
function getLocaleFromStorage() {
|
||||||
try {
|
const key = get(Key.PREFERRED_LOCALE)
|
||||||
const key = localStorage.getItem(LOCALSTORAGE_KEY)
|
if (key && ALLOWED.has(key)) {
|
||||||
if (key && ALLOWED.has(key)) {
|
return key
|
||||||
return key
|
}
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNavigatorLocale() {
|
function getNavigatorLocale() {
|
||||||
@ -81,9 +79,8 @@ export function useTranslate() {
|
|||||||
export const LocaleContextProvider = memo(({ children }) => {
|
export const LocaleContextProvider = memo(({ children }) => {
|
||||||
const [locale, setLocale] = useState(getEnvLocale)
|
const [locale, setLocale] = useState(getEnvLocale)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
set(Key.PREFERRED_LOCALE, locale)
|
||||||
localStorage.setItem(LOCALSTORAGE_KEY, locale)
|
document.documentElement.lang = locale
|
||||||
} catch {}
|
|
||||||
}, [locale])
|
}, [locale])
|
||||||
const value = useMemo(() => ({ locale, setLocale }), [locale])
|
const value = useMemo(() => ({ locale, setLocale }), [locale])
|
||||||
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
|
return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
|
||||||
|
@ -10,8 +10,8 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, system-ui, "Roboto", "Oxygen", "Ubuntu",
|
font-family: -apple-system, BlinkMacSystemFont, system-ui, "Roboto", "Oxygen",
|
||||||
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
"Cantarell", "Droid Sans", "Helvetica Neue", "Noto Sans CJK JP", sans-serif;
|
||||||
}
|
}
|
||||||
:root {
|
:root {
|
||||||
--page-background: #fff;
|
--page-background: #fff;
|
||||||
@ -66,22 +66,49 @@ input {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
input[type="search"],
|
@mixin input {
|
||||||
input[type="input"],
|
|
||||||
input[type="password"] {
|
|
||||||
@include scheme(background-color, #fff, #2d2d2d);
|
@include scheme(background-color, #fff, #2d2d2d);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
@include scheme(border-color, #cdc7c2, #1b1b1b);
|
@include scheme(border-color, #cdc7c2, #1b1b1b);
|
||||||
color: inherit;
|
|
||||||
outline: none;
|
|
||||||
padding: 7px 8px;
|
|
||||||
transition: 0.1s;
|
transition: 0.1s;
|
||||||
&:focus {
|
&:focus {
|
||||||
@include scheme(border-color, #3584e480, #15539e);
|
@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,
|
button,
|
||||||
select {
|
select {
|
||||||
@include scheme(background-color, #f6f5f4, #333);
|
@include scheme(background-color, #f6f5f4, #333);
|
||||||
@ -110,3 +137,34 @@ button[type="submit"] {
|
|||||||
select {
|
select {
|
||||||
padding: 5px 10px;
|
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 { App } from "./App"
|
||||||
import { LocaleContextProvider } from "./i18n"
|
import { LocaleContextProvider } from "./i18n"
|
||||||
import { SideEffect } from "./SideEffect"
|
import { SideEffect } from "./SideEffect"
|
||||||
|
import { Toast } from "./components/Toast"
|
||||||
import "./index.scss"
|
import "./index.scss"
|
||||||
|
|
||||||
if (navigator.platform === "MacIntel") {
|
if (navigator.platform === "MacIntel") {
|
||||||
@ -15,6 +16,7 @@ const Root: React.FC = () => (
|
|||||||
<LocaleContextProvider>
|
<LocaleContextProvider>
|
||||||
<SideEffect />
|
<SideEffect />
|
||||||
<App />
|
<App />
|
||||||
|
<Toast />
|
||||||
</LocaleContextProvider>
|
</LocaleContextProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
@ -4,19 +4,54 @@ import type { Vault, Item } from "opvault.js"
|
|||||||
import { Category } from "opvault.js"
|
import { Category } from "opvault.js"
|
||||||
import { FiLock } from "react-icons/fi"
|
import { FiLock } from "react-icons/fi"
|
||||||
import { IoSearch } from "react-icons/io5"
|
import { IoSearch } from "react-icons/io5"
|
||||||
|
import { Si1Password } from "react-icons/si"
|
||||||
|
import { BsGear } from "react-icons/bs"
|
||||||
import { ItemList } from "../components/ItemList"
|
import { ItemList } from "../components/ItemList"
|
||||||
import { ItemView } from "../components/Item"
|
import { ItemView } from "../components/Item"
|
||||||
import { reactIconClass } from "../components/CategoryIcon"
|
import { reactIconClass } from "../components/CategoryIcon"
|
||||||
import { useTranslate } from "../i18n/index"
|
import { useTranslate } from "../i18n/index"
|
||||||
import { scrollbar } from "../styles"
|
import { scrollbar } from "../styles"
|
||||||
|
import { Settings } from "../settings"
|
||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
height: calc(100vh - var(--titlebar-height));
|
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`
|
const ListContainer = styled.div`
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
width: 300px;
|
width: 350px;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@ -33,15 +68,13 @@ const SearchContainer = styled.div`
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
margin: 10px 0;
|
||||||
|
margin-right: 10px;
|
||||||
`
|
`
|
||||||
const SortContainer = styled.div`
|
const SortContainer = styled.div`
|
||||||
margin: 10px 10px;
|
margin: 10px 10px;
|
||||||
`
|
`
|
||||||
const LockButton = styled.button`
|
|
||||||
&& {
|
|
||||||
padding: 5px 10px;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
const SearchInput = styled.input`
|
const SearchInput = styled.input`
|
||||||
--margin: 10px;
|
--margin: 10px;
|
||||||
width: calc(100% - var(--margin) * 2 + 9px);
|
width: calc(100% - var(--margin) * 2 + 9px);
|
||||||
@ -64,6 +97,7 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
|
|||||||
vault,
|
vault,
|
||||||
onLock,
|
onLock,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
const t = useTranslate()
|
const t = useTranslate()
|
||||||
const [items, setItems] = useState<Item[]>(() => [])
|
const [items, setItems] = useState<Item[]>(() => [])
|
||||||
const [item, setItem] = useState<Item>()
|
const [item, setItem] = useState<Item>()
|
||||||
@ -115,20 +149,29 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<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}>
|
<ListContainer className={scrollbar}>
|
||||||
<div style={{ margin: "10px 10px", display: "flex" }}>
|
<SearchContainer>
|
||||||
<LockButton onClick={onLock} title={t.action.lock}>
|
<SearchInput
|
||||||
<FiLock />
|
type="search"
|
||||||
</LockButton>
|
value={search}
|
||||||
<SearchContainer>
|
onChange={e => setSearch(e.currentTarget.value)}
|
||||||
<SearchInput
|
/>
|
||||||
type="search"
|
<SearchIcon className={reactIconClass} />
|
||||||
value={search}
|
</SearchContainer>
|
||||||
onChange={e => setSearch(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<SearchIcon className={reactIconClass} />
|
|
||||||
</SearchContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SortContainer>
|
<SortContainer>
|
||||||
<select
|
<select
|
||||||
@ -146,6 +189,8 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
|
|||||||
<ItemContainer>
|
<ItemContainer>
|
||||||
{item && <ItemView className={scrollbar} item={item} />}
|
{item && <ItemView className={scrollbar} item={item} />}
|
||||||
</ItemContainer>
|
</ItemContainer>
|
||||||
|
|
||||||
|
<Settings show={showSettings} onHide={() => setShowSettings(false)} />
|
||||||
</Container>
|
</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 {
|
import { useCallback, useEffect, useState } from "react"
|
||||||
LAST_VAULT_PATH = "lastVaultPath",
|
|
||||||
|
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 {
|
interface StoredData {
|
||||||
[Key.LAST_VAULT_PATH]: string
|
[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 {
|
try {
|
||||||
const value = localStorage.getItem(key)
|
const value = localStorage.getItem(key)
|
||||||
return value == null ? undefined : (JSON.parse(value!) as StoredData[K])
|
return value == null ? undefined : (JSON.parse(value!) as StoredData[K])
|
||||||
} catch {}
|
} 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 {
|
try {
|
||||||
localStorage.setItem(key, JSON.stringify(value))
|
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 {
|
try {
|
||||||
localStorage.removeItem(key)
|
localStorage.removeItem(key)
|
||||||
} catch {}
|
} 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