From eb27e81d685b50b74f724e1135e3ed75647760c3 Mon Sep 17 00:00:00 2001 From: proteriax <8125011+proteriax@users.noreply.github.com> Date: Mon, 8 Nov 2021 02:59:58 -0500 Subject: [PATCH] Migrate to native Node.js file access and improve UI --- packages/web/esbuild.js | 2 +- packages/web/index.html | 2 +- packages/web/src/App.tsx | 34 ++----- packages/web/src/components/Item.tsx | 51 ++++++---- packages/web/src/components/ItemList.tsx | 2 +- packages/web/src/components/VaultPicker.tsx | 22 ---- packages/web/src/electron/index.ts | 3 +- packages/web/src/electron/ipc-types.d.ts | 9 ++ packages/web/src/electron/ipc.ts | 53 ++++++++++ packages/web/src/electron/preload.ts | 7 +- packages/web/src/i18n/texts.yml | 16 ++- packages/web/src/index.scss | 43 ++++---- packages/web/src/main.tsx | 6 +- packages/web/src/pages/PickOPVault.tsx | 23 ----- packages/web/src/pages/Unlock.tsx | 106 +++++++++++++++++--- packages/web/src/pages/Vault.tsx | 52 ++++++---- packages/web/src/pages/VaultPicker.tsx | 94 +++++++++++++++++ packages/web/src/styles.ts | 28 ++++++ packages/web/src/utils/ipc-adapter.ts | 31 ++++++ packages/web/src/utils/localStorage.ts | 26 +++++ packages/web/src/utils/memoize.ts | 18 ++++ 21 files changed, 472 insertions(+), 156 deletions(-) delete mode 100644 packages/web/src/components/VaultPicker.tsx create mode 100644 packages/web/src/electron/ipc-types.d.ts create mode 100644 packages/web/src/electron/ipc.ts delete mode 100644 packages/web/src/pages/PickOPVault.tsx create mode 100644 packages/web/src/pages/VaultPicker.tsx create mode 100644 packages/web/src/styles.ts create mode 100644 packages/web/src/utils/ipc-adapter.ts create mode 100644 packages/web/src/utils/localStorage.ts create mode 100644 packages/web/src/utils/memoize.ts diff --git a/packages/web/esbuild.js b/packages/web/esbuild.js index e6535d5..11a2455 100755 --- a/packages/web/esbuild.js +++ b/packages/web/esbuild.js @@ -8,7 +8,7 @@ const args = process.argv.slice(2) build({ bundle: true, define: {}, - entryPoints: ["./src/electron/index.ts"], + entryPoints: ["./src/electron/index.ts", "./src/electron/preload.ts"], outdir: "./dist/main", external: builtinModules.concat("electron"), target: ["chrome90"], diff --git a/packages/web/index.html b/packages/web/index.html index b58135d..0ea92dc 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -11,7 +11,7 @@ OPVault Viewer -
+
diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 4987dec..b2902ec 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,40 +1,26 @@ import { useCallback, useState } from "react" -import type { Vault } from "opvault.js" -import { OnePassword } from "opvault.js" -import { getBrowserAdapter } from "opvault.js/src/adapters/browser" +import type { Vault, OnePassword } from "opvault.js" import { VaultView } from "./pages/Vault" -import { PickOPVault } from "./pages/PickOPVault" -import { Unlock } from "./pages/Unlock" +import { VaultPicker } from "./pages/VaultPicker" export const App: React.FC = () => { const [instance, setInstance] = useState() const [vault, setVault] = useState() - const unlock = useCallback( - async (profile: string, password: string) => { - const vault = await instance!.getProfile(profile!) - await vault.unlock(password) - setVault(vault) - }, - [instance] - ) - - const setHandle = useCallback(async (handle: FileSystemDirectoryHandle) => { - const adapter = getBrowserAdapter(handle) - const instance = new OnePassword({ path: "/", adapter }) - setInstance(instance) - }, []) - const onLock = useCallback(() => { vault?.lock() setVault(undefined) }, [vault]) - if (!instance) { - return - } if (!vault) { - return + return ( + + ) } return ( diff --git a/packages/web/src/components/Item.tsx b/packages/web/src/components/Item.tsx index 8e162a3..b60a93a 100644 --- a/packages/web/src/components/Item.tsx +++ b/packages/web/src/components/Item.tsx @@ -1,6 +1,7 @@ import styled from "@emotion/styled" -import type { Attachment, AttachmentMetadata, Item } from "opvault.js" +import type { Attachment, AttachmentMetadata, Item, ItemField } from "opvault.js" import { useEffect, useState } from "react" +import { useTranslate } from "../i18n" import { CategoryIcon } from "./CategoryIcon" import { ItemDates } from "./ItemDates" import { @@ -13,6 +14,7 @@ import { ItemWarning } from "./ItemWarning" interface ItemViewProps { item: Item + className?: string } const Header = styled.div` @@ -55,8 +57,32 @@ const AttachmentContainer = styled.div` margin: 5px 0; ` -export const ItemView: React.FC = ({ item }) => ( - +const FieldsView: React.FC<{ fields?: ItemField[] }> = ({ fields }) => + fields?.length ? ( +
+ {fields.map((field, i) => ( + + ))} +
+ ) : null + +const TagsView: React.FC<{ tags?: string[] }> = ({ tags }) => { + const t = useTranslate() + if (!tags?.length) return null + return ( + + {t.noun_tags} +
+ {tags.map((tag, i) => ( + {tag} + ))} +
+
+ ) +} + +export const ItemView: React.FC = ({ className, item }) => ( +
@@ -84,13 +110,7 @@ export const ItemView: React.FC = ({ item }) => ( ))} - {!!item.details.fields?.length && ( -
- {item.details.fields!.map((field, i) => ( - - ))} -
- )} + {item.details.notesPlain != null && ( @@ -101,16 +121,7 @@ export const ItemView: React.FC = ({ item }) => ( )} - {!!item.overview.tags?.length && ( - - tags -
- {item.overview.tags!.map((tag, i) => ( - {tag} - ))} -
-
- )} + {item.attachments.length > 0 && ( diff --git a/packages/web/src/components/ItemList.tsx b/packages/web/src/components/ItemList.tsx index f6c57df..959fef8 100644 --- a/packages/web/src/components/ItemList.tsx +++ b/packages/web/src/components/ItemList.tsx @@ -62,7 +62,7 @@ export const ItemList: React.FC = ({ items, onSelect, selected }) =>
{item.overview.title!} - {item.overview.ainfo} + {item.overview.ainfo || " "}
))} diff --git a/packages/web/src/components/VaultPicker.tsx b/packages/web/src/components/VaultPicker.tsx deleted file mode 100644 index 76cb1e0..0000000 --- a/packages/web/src/components/VaultPicker.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useCallback } from "react" -import { useTranslate } from "../i18n" - -export const VaultPicker: React.FC<{ - setHandle(handle: FileSystemDirectoryHandle): void -}> = ({ setHandle }) => { - const t = useTranslate() - - const onClick = useCallback(async () => { - try { - const handle = await showDirectoryPicker() - setHandle(handle) - } catch (e) { - if ((e as Error).name === "AbortError") { - return - } - alert(e) - } - }, [setHandle]) - - return -} diff --git a/packages/web/src/electron/index.ts b/packages/web/src/electron/index.ts index 763404d..29c923b 100644 --- a/packages/web/src/electron/index.ts +++ b/packages/web/src/electron/index.ts @@ -2,6 +2,7 @@ // Modules to control application life and create native browser window import { join } from "path" import { app, BrowserWindow, Menu } from "electron" +import "./ipc" function createWindow() { // Create the browser window. @@ -13,7 +14,7 @@ function createWindow() { icon: join(__dirname, "../512x512.png"), webPreferences: { contextIsolation: true, - // preload: join(__dirname, "preload.js"), + preload: join(__dirname, "preload.js"), }, }) diff --git a/packages/web/src/electron/ipc-types.d.ts b/packages/web/src/electron/ipc-types.d.ts new file mode 100644 index 0000000..fd583e3 --- /dev/null +++ b/packages/web/src/electron/ipc-types.d.ts @@ -0,0 +1,9 @@ +export interface IPC { + showDirectoryPicker(): Promise + pathExists(path: string): Promise + readdir(path: string): Promise + readBuffer(path: string): Promise + readFile(path: string): Promise + writeFile(path: string, data: string): Promise + isDirectory(path: string): Promise +} diff --git a/packages/web/src/electron/ipc.ts b/packages/web/src/electron/ipc.ts new file mode 100644 index 0000000..0f94320 --- /dev/null +++ b/packages/web/src/electron/ipc.ts @@ -0,0 +1,53 @@ +import fs, { promises } from "fs" +import { ipcMain, dialog } from "electron" +import type { IPC } from "./ipc-types" + +registerService({ + async showDirectoryPicker() { + const result = await dialog.showOpenDialog({ + properties: ["openDirectory", "treatPackageAsDirectory"], + }) + if (result.canceled || !result.filePaths.length) return + return result.filePaths[0] + }, + + async pathExists(_, path) { + return fs.existsSync(path) + }, + + async readBuffer(_, path) { + return promises.readFile(path) + }, + + async readFile(_, path) { + return promises.readFile(path, "utf-8") + }, + + async writeFile(_, path, content) { + await promises.writeFile(path, content) + }, + + async readdir(_, path) { + return promises.readdir(path) + }, + + async isDirectory(_, path) { + const stats = await promises.stat(path) + return stats.isDirectory() + }, +}) + +/** + * Listens to `channel`, when a new message arrives `listener` would be called + * with `listener(event, ...args)` + */ +function registerService(listeners: { + [K in keyof IPC]: ( + event: Electron.IpcMainEvent, + ...args: Parameters + ) => ReturnType +}) { + for (const [key, value] of Object.entries(listeners)) { + ipcMain.handle(`service-${key}`, value as any) + } +} diff --git a/packages/web/src/electron/preload.ts b/packages/web/src/electron/preload.ts index 0a12555..052d4d2 100644 --- a/packages/web/src/electron/preload.ts +++ b/packages/web/src/electron/preload.ts @@ -1,4 +1,5 @@ -import { contextBridge } from "electron" -import { nodeAdapter } from "opvault.js/src/adapters/node" +import { contextBridge, ipcRenderer } from "electron" -contextBridge.exposeInMainWorld("nodeAdapter", nodeAdapter) +contextBridge.exposeInMainWorld("ipcRenderer", { + invoke: ipcRenderer.invoke, +}) diff --git a/packages/web/src/i18n/texts.yml b/packages/web/src/i18n/texts.yml index 0cef55f..c5735a9 100644 --- a/packages/web/src/i18n/texts.yml +++ b/packages/web/src/i18n/texts.yml @@ -1,7 +1,7 @@ # /* spellchecker: disable */ label_choose_a_vault: - en: Pick a vault here. - fr: Choisir un coffre ici. + en: Pick a vault + fr: Choisir un coffre label_no_vault_selected: en: No vault is selected. @@ -15,10 +15,18 @@ label_created_at: en: Created At fr: Créé +label_password_placeholder: + en: Master Password + fr: Mot de passe principal + noun_vault: en: vault fr: coffre +noun_tags: + en: tags + fr: mots-clés + action_lock: en: Lock fr: Vérouiller @@ -26,3 +34,7 @@ action_lock: action_unlock: en: Unlock fr: Déverouiller + +action_go_back: + en: Back + fr: Revenir diff --git a/packages/web/src/index.scss b/packages/web/src/index.scss index 3837737..c7d17ed 100644 --- a/packages/web/src/index.scss +++ b/packages/web/src/index.scss @@ -12,34 +12,48 @@ body { font-size: 15px; font-family: -apple-system, BlinkMacSystemFont, system-ui, "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; - +} +:root { + --page-background: #fff; --color: #000; --titlebar-height: 46px; --titlebar-height: 0px; --label-background: #ddd; --selected-background: #c9c9c9; --hover-background: #ddd; + --border-color: #ddd; --monospace: D2Coding, source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; } +@media (prefers-color-scheme: light) { + :root.mac { + --page-background: #f7f7f7; + } +} + html, body, -#root { +#app { height: 100%; } +#app { + background-color: var(--page-background); + position: relative; +} + @media (prefers-color-scheme: dark) { - body { - color: #fff; + :root { --color: #fff; --label-background: #353535; --selected-background: #353535; - --selected-background: #15539e; + --border-color: #333; --hover-background: #222; + --page-background: #292929; } - #root { - background-color: #292929; + body { + color: #fff; } } @@ -96,18 +110,3 @@ button[type="submit"] { select { padding: 5px 10px; } - -::-webkit-scrollbar { - width: 7px; -} -::-webkit-scrollbar-track { - background: transparent; -} -::-webkit-scrollbar-thumb { - @include scheme(background, #8883, #6663); - border-radius: 6px; -} -::-webkit-scrollbar-thumb:hover { - transition: 0.1s; - @include scheme(background, #ddd, #555); -} diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 518a412..3cf8fa2 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -4,6 +4,10 @@ import { App } from "./App" import { LocaleContextProvider } from "./i18n" import "./index.scss" +if (navigator.platform === "MacIntel") { + document.documentElement.classList.add("mac") +} + render( {/* */} @@ -11,5 +15,5 @@ render( , - document.getElementById("root") + document.getElementById("app") ) diff --git a/packages/web/src/pages/PickOPVault.tsx b/packages/web/src/pages/PickOPVault.tsx deleted file mode 100644 index a2ea524..0000000 --- a/packages/web/src/pages/PickOPVault.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import styled from "@emotion/styled" -import { VaultPicker } from "../components/VaultPicker" -import { useTranslate } from "../i18n" - -const Container = styled.div` - padding: 100px; - text-align: center; -` -const Info = styled.div` - margin: 10px; -` - -export const PickOPVault: React.FC<{ - setHandle(handle: FileSystemDirectoryHandle): void -}> = ({ setHandle }) => { - const t = useTranslate() - return ( - - - {t.label_no_vault_selected} - - ) -} diff --git a/packages/web/src/pages/Unlock.tsx b/packages/web/src/pages/Unlock.tsx index 1030ca9..2811459 100644 --- a/packages/web/src/pages/Unlock.tsx +++ b/packages/web/src/pages/Unlock.tsx @@ -1,25 +1,80 @@ import type { OnePassword } from "opvault.js" import styled from "@emotion/styled" -import { useCallback, useEffect, useState } from "react" +import React, { useCallback, useEffect, useState } from "react" +import { IoMdArrowRoundBack } from "react-icons/io" +import { FaUnlock } from "react-icons/fa" import { useTranslate } from "../i18n" -const Container = styled.form` +const Container = styled.div` padding: 20px; + transform: translate(-50%, -50%); + position: absolute; + top: 50%; + left: 50%; + width: 500px; +` +const BackButton = styled.button` + && { + background: transparent; + } + border: none; + box-shadow: none; + font-size: 2em; + cursor: pointer; + padding: 0; + &:hover { + svg path { + fill: var(--selected-background); + transition: 0.2s; + } + } +` +const Input = styled.input` + box-shadow: inset 0 2px 2px rgb(0 0 0 / 8%); + font-size: 1.5em; + width: calc(95.5% - 60px); + && { + border-radius: 10px; + border-width: 1px; + padding: 15px 20px; + padding-right: 60px; + } +` +const Select = styled.select` + float: right; +` +const Submit = styled.button` + font-size: 1.8em; + && { + background: transparent; + border: none; + box-shadow: none; + } + svg path { + fill: var(--color); + } + &:hover { + transition: 0.2s; + } text-align: center; + position: absolute; + top: 8px; + right: 5px; ` export const Unlock: React.FC<{ instance: OnePassword onUnlock(profile: string, password: string): void -}> = ({ onUnlock, instance }) => { + onReturn(): void +}> = ({ onUnlock, onReturn, instance }) => { const t = useTranslate() const [profiles, setProfiles] = useState(() => []) const [profile, setProfile] = useState() const [password, setPassword] = useState("") const unlock = useCallback( - (e: React.FormEvent) => { - e.preventDefault() + (e?: React.FormEvent) => { + e?.preventDefault() if (!profile) return onUnlock(profile, password) @@ -28,6 +83,15 @@ export const Unlock: React.FC<{ [onUnlock, profile, password] ) + const onKeyUp = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + unlock() + } + }, + [unlock] + ) + useEffect(() => { instance.getProfileNames().then(profiles => { setProfiles(profiles) @@ -36,26 +100,40 @@ export const Unlock: React.FC<{ }, [instance]) return ( - -
- setProfile(e.currentTarget.value)} + > {profiles.map(p => ( ))} - +
-
- + setPassword(e.currentTarget.value)} + placeholder={t.label_password_placeholder} + onKeyUp={onKeyUp} /> + + +
-
) } diff --git a/packages/web/src/pages/Vault.tsx b/packages/web/src/pages/Vault.tsx index e7e624e..3e1dd6c 100644 --- a/packages/web/src/pages/Vault.tsx +++ b/packages/web/src/pages/Vault.tsx @@ -1,24 +1,28 @@ import styled from "@emotion/styled" -import { useCallback, useEffect, useMemo, useState } from "react" +import { useEffect, useMemo, useState } from "react" 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 { ItemList } from "../components/ItemList" import { ItemView } from "../components/Item" import { reactIconClass } from "../components/CategoryIcon" import { useTranslate } from "../i18n/index" +import { scrollbar } from "../styles" const Container = styled.div` display: flex; height: calc(100vh - var(--titlebar-height)); ` const ListContainer = styled.div` + border-right: 1px solid var(--border-color); width: 300px; margin-right: 10px; overflow-y: scroll; overflow-x: hidden; @media (prefers-color-scheme: dark) { background: #202020; + border-right-color: transparent; } ` const ItemContainer = styled.div` @@ -27,15 +31,20 @@ const ItemContainer = styled.div` ` const SearchContainer = styled.div` text-align: center; - margin: 10px 0; position: relative; + flex-grow: 1; ` 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); + width: calc(100% - var(--margin) * 2 + 9px); margin: 0 var(--margin); padding-left: 2em !important; ` @@ -66,9 +75,9 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({ case SortBy.Name: return (a, b) => (a.overview.title ?? "").localeCompare(b?.overview.title ?? "") case SortBy.CreatedAt: - return (a, b) => a.createdAt - b.createdAt + return (a, b) => b.createdAt - a.createdAt case SortBy.UpdatedAt: - return (a, b) => a.updatedAt - b.updatedAt + return (a, b) => b.updatedAt - a.updatedAt } }, [sortBy]) @@ -95,22 +104,21 @@ export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({ return ( - -
- + +
+ + + + + setSearch(e.currentTarget.value)} + /> + +
- - setSearch(e.currentTarget.value)} - /> - - +