Add settings panel

This commit is contained in:
aet 2021-12-19 01:57:38 -05:00
parent 298482f70e
commit bf5bdd1f72
18 changed files with 847 additions and 275 deletions

View File

@ -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"
]
}
}

View File

@ -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",

View File

@ -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"
}
}

View File

@ -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
}

View File

@ -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",

View File

@ -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"
}
}

View File

@ -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(() => {

View File

@ -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
}

View File

@ -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>

View 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>
</>
)
}

View 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>
)
}

View File

@ -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>

View File

@ -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

View File

@ -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>
)

View File

@ -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>
)
}

View 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>
)
}

View File

@ -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

File diff suppressed because it is too large Load Diff