Add web interface and tests
This commit is contained in:
5
packages/web/.gitignore
vendored
Normal file
5
packages/web/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
bundle
|
||||
*.local
|
33
packages/web/esbuild.js
Executable file
33
packages/web/esbuild.js
Executable file
@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
// @ts-check
|
||||
const { builtinModules } = require("module")
|
||||
const { build } = require("esbuild")
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
build({
|
||||
bundle: true,
|
||||
define: {},
|
||||
entryPoints: [
|
||||
"./src/electron/index.ts",
|
||||
// "./src/electron/preload.ts"
|
||||
],
|
||||
outdir: "./dist/main",
|
||||
external: builtinModules.concat("electron"),
|
||||
target: ["chrome90"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
sourcemap: "external",
|
||||
minify: process.env.NODE_ENV === "production",
|
||||
banner: {
|
||||
js: "/* eslint-disable */",
|
||||
},
|
||||
loader: {
|
||||
".png": "file",
|
||||
".eot": "file",
|
||||
".svg": "file",
|
||||
".woff": "file",
|
||||
".woff2": "file",
|
||||
".ttf": "file",
|
||||
},
|
||||
watch: args.includes("-w") || args.includes("--watch"),
|
||||
})
|
17
packages/web/index.html
Normal file
17
packages/web/index.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OPVault Viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
51
packages/web/package.json
Normal file
51
packages/web/package.json
Normal file
@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "opvault-web",
|
||||
"version": "0.0.0",
|
||||
"main": "dist/main/index.js",
|
||||
"author": "proteria",
|
||||
"license": "GPL-3.0-only",
|
||||
"description": "OnePassword local vault viewer",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"start": "NODE_ENV=development electron --enable-transparent-visuals --disable-gpu ./dist/main/index.js",
|
||||
"bundle": "./scripts/build.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@emotion/css": "^11.5.0",
|
||||
"@emotion/react": "^11.5.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@vitejs/plugin-react": "^1.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"electron": "^15.2.0",
|
||||
"electron-builder": "^22.13.1",
|
||||
"esbuild": "^0.13.6",
|
||||
"opvault.js": "*",
|
||||
"path-browserify": "^1.0.1",
|
||||
"react": "^17.0.0",
|
||||
"react-dom": "^17.0.0",
|
||||
"react-icons": "^4.3.1",
|
||||
"sass": "^1.43.4",
|
||||
"typescript": "^4.3.2",
|
||||
"vite": "^2.6.4"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.proteria.opvault",
|
||||
"productName": "OPVault Viewer",
|
||||
"files": [
|
||||
"**/*"
|
||||
],
|
||||
"directories": {
|
||||
"output": "bundle",
|
||||
"app": "dist"
|
||||
},
|
||||
"linux": {
|
||||
"executableName": "opvault",
|
||||
"icon": "1p.png",
|
||||
"category": "Utility"
|
||||
}
|
||||
}
|
||||
}
|
15
packages/web/scripts/build-package-json.js
Executable file
15
packages/web/scripts/build-package-json.js
Executable file
@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require("fs")
|
||||
const { resolve } = require("path")
|
||||
|
||||
const json = require("../package.json")
|
||||
json.name = "OPVault"
|
||||
json.main = "main/index.js"
|
||||
delete json.scripts
|
||||
delete json.devDependencies
|
||||
delete json.build
|
||||
|
||||
fs.writeFileSync(
|
||||
resolve(__dirname, "../dist/package.json"),
|
||||
JSON.stringify(json, null, 2)
|
||||
)
|
5
packages/web/scripts/build.sh
Executable file
5
packages/web/scripts/build.sh
Executable file
@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
yarn build
|
||||
NODE_ENV=production ./esbuild.js
|
||||
./scripts/build-package-json.js
|
||||
./node_modules/.bin/electron-builder build
|
45
packages/web/src/App.tsx
Normal file
45
packages/web/src/App.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
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 { VaultView } from "./pages/Vault"
|
||||
import { PickOPVault } from "./pages/PickOPVault"
|
||||
import { Unlock } from "./pages/Unlock"
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const [instance, setInstance] = useState<OnePassword>()
|
||||
const [vault, setVault] = useState<Vault>()
|
||||
|
||||
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 <PickOPVault setHandle={setHandle} />
|
||||
}
|
||||
if (!vault) {
|
||||
return <Unlock instance={instance} onUnlock={unlock} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<VaultView onLock={onLock} vault={vault} />
|
||||
</div>
|
||||
)
|
||||
}
|
90
packages/web/src/components/CategoryIcon.tsx
Normal file
90
packages/web/src/components/CategoryIcon.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { Category } from "opvault.js"
|
||||
import { cx, css } from "@emotion/css"
|
||||
import { BsBank2, BsPeopleFill } from "react-icons/bs"
|
||||
import { CgLogIn } from "react-icons/cg"
|
||||
import { HiMail, HiIdentification } from "react-icons/hi"
|
||||
import { RiGovernmentLine } from "react-icons/ri"
|
||||
import {
|
||||
FaArchive,
|
||||
FaDatabase,
|
||||
FaPassport,
|
||||
FaServer,
|
||||
FaFish,
|
||||
FaGift,
|
||||
FaCar,
|
||||
FaWifi,
|
||||
} from "react-icons/fa"
|
||||
import { GrLicense, GrNotes, GrCreditCard } from "react-icons/gr"
|
||||
import { MdPassword } from "react-icons/md"
|
||||
|
||||
function getComponent(category: Category) {
|
||||
switch (category) {
|
||||
case Category.BankAccount:
|
||||
return BsBank2
|
||||
case Category.CreditCard:
|
||||
return GrCreditCard
|
||||
case Category.Database:
|
||||
return FaDatabase
|
||||
case Category.DriverLicense:
|
||||
return FaCar
|
||||
case Category.Email:
|
||||
return HiMail
|
||||
case Category.Identity:
|
||||
return HiIdentification
|
||||
case Category.Login:
|
||||
return CgLogIn
|
||||
case Category.Membership:
|
||||
return BsPeopleFill
|
||||
case Category.OutdoorLicense:
|
||||
return FaFish
|
||||
case Category.Passport:
|
||||
return FaPassport
|
||||
case Category.Password:
|
||||
return MdPassword
|
||||
case Category.Rewards:
|
||||
return FaGift
|
||||
case Category.Router:
|
||||
return FaWifi
|
||||
case Category.SecureNote:
|
||||
return GrNotes
|
||||
case Category.Server:
|
||||
return FaServer
|
||||
case Category.SoftwareLicense:
|
||||
return GrLicense
|
||||
case Category.SSN:
|
||||
return RiGovernmentLine
|
||||
case Category.Tombstone:
|
||||
return FaArchive
|
||||
default:
|
||||
category
|
||||
}
|
||||
}
|
||||
|
||||
export const reactIconClass = css`
|
||||
fill: var(--color);
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path:not([fill="none"]),
|
||||
path[stroke] {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
interface CategoryIconProps {
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
fill?: string
|
||||
category: Category
|
||||
}
|
||||
|
||||
export const CategoryIcon: React.FC<CategoryIconProps> = ({
|
||||
className,
|
||||
category,
|
||||
style,
|
||||
fill,
|
||||
}) => {
|
||||
const Component = getComponent(category)
|
||||
return Component ? (
|
||||
<Component className={cx(reactIconClass, className)} fill={fill} style={style} />
|
||||
) : null
|
||||
}
|
51
packages/web/src/components/ErrorBoundary.tsx
Normal file
51
packages/web/src/components/ErrorBoundary.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import type { ErrorInfo } from "react"
|
||||
import React from "react"
|
||||
import styled from "@emotion/styled"
|
||||
|
||||
/**
|
||||
* @module ErrorBoundary
|
||||
* React HOC to restrict an Error from blowing up the entire application.
|
||||
*/
|
||||
|
||||
type State = { error?: Error; info?: ErrorInfo }
|
||||
|
||||
const Div = styled.div`
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
box-shadow: rgba(0, 0, 0, 0.05) 0px 1px 1px;
|
||||
margin: 20px;
|
||||
padding: 20px;
|
||||
`
|
||||
const Header = styled.h2`
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
`
|
||||
const Pre = styled.pre`
|
||||
font-size: 15px;
|
||||
line-height: 1.3em;
|
||||
`
|
||||
|
||||
export class ErrorBoundary extends React.Component<any, State> {
|
||||
state: State = {}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
this.setState({ error, info })
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error, info } = this.state
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
return (
|
||||
<Div>
|
||||
<Header>Error: {error.message}</Header>
|
||||
<Pre>{info?.componentStack?.replace(/^\n/, "")}</Pre>
|
||||
<Pre>{error.stack}</Pre>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{this.props.children}</>
|
||||
}
|
||||
}
|
142
packages/web/src/components/Item.tsx
Normal file
142
packages/web/src/components/Item.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import styled from "@emotion/styled"
|
||||
import type { Attachment, AttachmentMetadata, Item } from "opvault.js"
|
||||
import { useEffect, useState } from "react"
|
||||
import { CategoryIcon } from "./CategoryIcon"
|
||||
import { ItemDates } from "./ItemDates"
|
||||
import {
|
||||
ItemFieldView,
|
||||
FieldContainer,
|
||||
FieldTitle,
|
||||
ItemDetailsFieldView,
|
||||
} from "./ItemField"
|
||||
import { ItemWarning } from "./ItemWarning"
|
||||
|
||||
interface ItemViewProps {
|
||||
item: Item
|
||||
}
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`
|
||||
const Icon = styled(CategoryIcon)`
|
||||
font-size: 2em;
|
||||
margin-right: 5px;
|
||||
`
|
||||
const SectionTitle = styled.div`
|
||||
font-size: 85%;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin: 20px 0 10px;
|
||||
`
|
||||
const Tag = styled.div`
|
||||
display: inline-block;
|
||||
margin-top: 2px;
|
||||
margin-right: 5px;
|
||||
border-radius: 4px;
|
||||
padding: 3px 7px;
|
||||
background-color: var(--label-background);
|
||||
`
|
||||
const ExtraField = styled(FieldContainer)`
|
||||
margin-bottom: 20px;
|
||||
`
|
||||
|
||||
const ItemTitle = styled.h2``
|
||||
const Container = styled.div`
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 0 10px;
|
||||
`
|
||||
const Inner = styled.div`
|
||||
padding: 10px 0;
|
||||
`
|
||||
const AttachmentContainer = styled.div`
|
||||
display: flex;
|
||||
margin: 5px 0;
|
||||
`
|
||||
|
||||
export const ItemView: React.FC<ItemViewProps> = ({ item }) => (
|
||||
<Container>
|
||||
<Inner>
|
||||
<ItemWarning item={item} />
|
||||
<Header>
|
||||
{item.details.fields == null}
|
||||
<Icon category={item.category} />
|
||||
<ItemTitle>{item.overview.title}</ItemTitle>
|
||||
</Header>
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
<pre>
|
||||
{JSON.stringify({ overview: item.overview, details: item.details }, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
{item.details.sections
|
||||
?.filter(s => s.fields?.some(x => x.v != null))
|
||||
.map((section, i) => (
|
||||
<div key={i}>
|
||||
{section.title != null && <SectionTitle>{section.title}</SectionTitle>}
|
||||
{section.fields?.map((field, j) => (
|
||||
<ItemFieldView key={j} field={field} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!!item.details.fields?.length && (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
{item.details.fields!.map((field, i) => (
|
||||
<ItemDetailsFieldView key={i} field={field} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.details.notesPlain != null && (
|
||||
<ExtraField>
|
||||
<FieldTitle>notes</FieldTitle>
|
||||
<div>
|
||||
<p>{item.details.notesPlain}</p>
|
||||
</div>
|
||||
</ExtraField>
|
||||
)}
|
||||
|
||||
{!!item.overview.tags?.length && (
|
||||
<ExtraField>
|
||||
<FieldTitle>tags</FieldTitle>
|
||||
<div>
|
||||
{item.overview.tags!.map((tag, i) => (
|
||||
<Tag key={i}>{tag}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</ExtraField>
|
||||
)}
|
||||
|
||||
{item.attachments.length > 0 && (
|
||||
<ExtraField>
|
||||
<FieldTitle>attachments</FieldTitle>
|
||||
<div>
|
||||
{item.attachments.map((file, i) => (
|
||||
<AttachmentView key={i} file={file} />
|
||||
))}
|
||||
</div>
|
||||
</ExtraField>
|
||||
)}
|
||||
|
||||
<ExtraField>
|
||||
<ItemDates item={item} />
|
||||
</ExtraField>
|
||||
</Inner>
|
||||
</Container>
|
||||
)
|
||||
|
||||
function AttachmentView({ file }: { file: Attachment }) {
|
||||
const [metadata, setMetadata] = useState<AttachmentMetadata>()
|
||||
useEffect(() => {
|
||||
file.unlock().then(() => setMetadata(file.metadata))
|
||||
}, [file])
|
||||
|
||||
if (!metadata) return null
|
||||
|
||||
return <AttachmentContainer>{metadata.overview.filename}</AttachmentContainer>
|
||||
}
|
16
packages/web/src/components/ItemDates.tsx
Normal file
16
packages/web/src/components/ItemDates.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import styled from "@emotion/styled"
|
||||
import type { Item } from "opvault.js"
|
||||
|
||||
const Container = styled.div`
|
||||
text-align: center;
|
||||
font-size: 90%;
|
||||
line-height: 1.5em;
|
||||
opacity: 0.5;
|
||||
`
|
||||
|
||||
export const ItemDates: React.FC<{ item: Item }> = ({ item }) => (
|
||||
<Container>
|
||||
<div>Last Updated: {new Date(item.updatedAt).toLocaleString()}</div>
|
||||
<div>Created: {new Date(item.createdAt).toLocaleString()}</div>
|
||||
</Container>
|
||||
)
|
48
packages/web/src/components/ItemField.tsx
Normal file
48
packages/web/src/components/ItemField.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import styled from "@emotion/styled"
|
||||
import type { ItemField, ItemSection } from "opvault.js"
|
||||
import { ErrorBoundary } from "./ErrorBoundary"
|
||||
import { ItemFieldValue, ItemDetailsFieldValue } from "./ItemFieldValue"
|
||||
|
||||
export { Container as FieldContainer }
|
||||
const Container: React.FC = styled.div`
|
||||
padding: 5px 0;
|
||||
margin-bottom: 3px;
|
||||
`
|
||||
export const FieldTitle: React.FC = styled.div`
|
||||
font-size: 85%;
|
||||
margin-bottom: 3px;
|
||||
`
|
||||
|
||||
export const ItemFieldView: React.FC<{
|
||||
field: ItemSection.Any
|
||||
}> = ({ field }) => {
|
||||
if (field.v == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Container>
|
||||
<FieldTitle>{field.t}</FieldTitle>
|
||||
<ItemFieldValue field={field} />
|
||||
</Container>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export const ItemDetailsFieldView: React.FC<{
|
||||
field: ItemField
|
||||
}> = ({ field }) => {
|
||||
if (field.value == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Container>
|
||||
<FieldTitle>{field.name}</FieldTitle>
|
||||
<ItemDetailsFieldValue field={field} />
|
||||
</Container>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
100
packages/web/src/components/ItemFieldContextMenu.tsx
Normal file
100
packages/web/src/components/ItemFieldContextMenu.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import styled from "@emotion/styled"
|
||||
|
||||
const Container = styled.menu`
|
||||
background-color: #fff;
|
||||
border-radius: 3px;
|
||||
box-shadow: #0004 0px 1px 4px;
|
||||
left: 99%;
|
||||
margin-block-start: 0;
|
||||
min-width: 120px;
|
||||
padding-inline-start: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
user-select: none;
|
||||
z-index: 2;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: #3c3c3c;
|
||||
box-shadow: rgb(0 0 0) 0px 2px 4px;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
& & {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
const Separator = styled.div`
|
||||
border-bottom: 1px solid #777;
|
||||
margin-top: 0.4em;
|
||||
margin-bottom: 0.4em;
|
||||
margin-left: 0.6em;
|
||||
margin-right: 0.6em;
|
||||
`
|
||||
|
||||
const Item = styled.div`
|
||||
cursor: default;
|
||||
font-size: 13px;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
height: 2.5em;
|
||||
align-items: center;
|
||||
padding-left: 1em;
|
||||
position: relative;
|
||||
&:hover {
|
||||
background-color: #ddd;
|
||||
border-radius: 3px;
|
||||
.item-field-context-menu {
|
||||
display: block;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: #094771;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
function useContextMenu() {
|
||||
const [show, setShow] = useState(false)
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 })
|
||||
const onRightClick = useCallback((e: React.MouseEvent) => {
|
||||
setShow(true)
|
||||
e.preventDefault()
|
||||
setPos({ x: e.pageX, y: e.pageY })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fn = () => setShow(false)
|
||||
document.addEventListener("click", fn)
|
||||
return () => document.removeEventListener("click", fn)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
show,
|
||||
position: {
|
||||
top: pos.y,
|
||||
left: pos.x,
|
||||
},
|
||||
onRightClick,
|
||||
}
|
||||
}
|
||||
|
||||
export function useItemFieldContextMenu() {
|
||||
const { onRightClick, position, show } = useContextMenu()
|
||||
|
||||
const ContextMenuContainer: React.FC = useCallback(
|
||||
({ children }) => {
|
||||
if (!show) return null
|
||||
return (
|
||||
<Container style={position} className="item-field-context-menu">
|
||||
{children}
|
||||
</Container>
|
||||
)
|
||||
},
|
||||
[show, position]
|
||||
)
|
||||
|
||||
return {
|
||||
onRightClick,
|
||||
Item,
|
||||
ContextMenuContainer,
|
||||
}
|
||||
}
|
103
packages/web/src/components/ItemFieldValue.tsx
Normal file
103
packages/web/src/components/ItemFieldValue.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import styled from "@emotion/styled"
|
||||
import type { ItemSection, ItemField } from "opvault.js"
|
||||
import { FieldType } from "opvault.js"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { parseMonthYear } from "../utils"
|
||||
import { ErrorBoundary } from "./ErrorBoundary"
|
||||
import { useItemFieldContextMenu } from "./ItemFieldContextMenu"
|
||||
|
||||
const Container = styled.div``
|
||||
|
||||
const Password: React.FC<{
|
||||
field: ItemSection.Concealed
|
||||
}> = ({ field }) => {
|
||||
const [show, setShow] = useState(false)
|
||||
const { onRightClick, ContextMenuContainer, Item } = useItemFieldContextMenu()
|
||||
const onToggle = useCallback(() => setShow(x => !x), [])
|
||||
const onCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(field.v)
|
||||
}, [field.v])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
onContextMenu={onRightClick}
|
||||
onDoubleClick={() => setShow(x => !x)}
|
||||
style={{
|
||||
fontFamily: "var(--monospace)",
|
||||
...(!show && { userSelect: "none" }),
|
||||
}}
|
||||
>
|
||||
{show ? field.v : "·".repeat(10)}
|
||||
</Container>
|
||||
<ContextMenuContainer>
|
||||
<Item onClick={onCopy}>Copier</Item>
|
||||
<Item onClick={onToggle}>{show ? "Cacher" : "Afficher"}</Item>
|
||||
</ContextMenuContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const MonthYear: React.FC<{ field: ItemSection.MonthYear }> = ({ field }) => {
|
||||
const { year, month } = parseMonthYear(field.v)
|
||||
return (
|
||||
<Container>
|
||||
{month.toString().padStart(2, "0")}/{year.toString().padStart(4, "0")}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const DateView: React.FC<{ field: ItemSection.Date }> = ({ field }) => {
|
||||
const date = useMemo(() => new Date(field.v * 1000), [field.v])
|
||||
return <Container>{date.toLocaleDateString()}</Container>
|
||||
}
|
||||
|
||||
export const ItemFieldValue: React.FC<{
|
||||
field: ItemSection.Any
|
||||
}> = ({ field }) => {
|
||||
if (field.v == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (field.k) {
|
||||
case "concealed":
|
||||
return <Password field={field} />
|
||||
case "monthYear":
|
||||
return <MonthYear field={field} />
|
||||
case "date":
|
||||
return <DateView field={field} />
|
||||
case "address":
|
||||
return (
|
||||
<Container style={{ whiteSpace: "pre" }}>
|
||||
<div>{field.v.street}</div>
|
||||
<div>
|
||||
{field.v.city}, {field.v.state} ({field.v.zip})
|
||||
</div>
|
||||
<div>{field.v.country}</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Container>{field.v}</Container>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export const ItemDetailsFieldValue: React.FC<{
|
||||
field: ItemField
|
||||
}> = ({ field }) => {
|
||||
if (
|
||||
field.type === FieldType.Password ||
|
||||
(field.type === FieldType.Text && field.designation === "password")
|
||||
) {
|
||||
return <Password field={{ v: field.value } as any} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Container>{field.value}</Container>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
71
packages/web/src/components/ItemList.tsx
Normal file
71
packages/web/src/components/ItemList.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import styled from "@emotion/styled"
|
||||
import { cx } from "@emotion/css"
|
||||
import type { Item } from "opvault.js"
|
||||
import { CategoryIcon } from "./CategoryIcon"
|
||||
|
||||
interface ListProps {
|
||||
items: Item[]
|
||||
selected?: Item
|
||||
onSelect(item: Item): void
|
||||
}
|
||||
|
||||
const Container = styled.div``
|
||||
const List = styled.ol`
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
`
|
||||
const ItemView = styled.li`
|
||||
border-radius: 5px;
|
||||
display: grid;
|
||||
padding: 5px 15px;
|
||||
transition: background-color 0.1s;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
grid-template-columns: 35px 1fr;
|
||||
&:hover {
|
||||
background-color: var(--hover-background);
|
||||
}
|
||||
&.selected {
|
||||
background-color: var(--selected-background);
|
||||
}
|
||||
&.trashed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
`
|
||||
const ItemTitle = styled.div`
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
`
|
||||
const ItemDescription = styled.div`
|
||||
font-size: 95%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 230px;
|
||||
`
|
||||
const Icon = styled(CategoryIcon)`
|
||||
font-size: 1.5em;
|
||||
`
|
||||
|
||||
export const ItemList: React.FC<ListProps> = ({ items, onSelect, selected }) => (
|
||||
<Container>
|
||||
<List>
|
||||
{items.map(item => (
|
||||
<ItemView
|
||||
key={item.uuid}
|
||||
onClick={() => onSelect(item)}
|
||||
className={cx({
|
||||
selected: selected?.uuid === item.uuid,
|
||||
trashed: item.isDeleted,
|
||||
})}
|
||||
>
|
||||
<Icon fill="#FFF" category={item.category} />
|
||||
<div>
|
||||
<ItemTitle>{item.overview.title!}</ItemTitle>
|
||||
<ItemDescription>{item.overview.ainfo}</ItemDescription>
|
||||
</div>
|
||||
</ItemView>
|
||||
))}
|
||||
</List>
|
||||
</Container>
|
||||
)
|
41
packages/web/src/components/ItemWarning.tsx
Normal file
41
packages/web/src/components/ItemWarning.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import styled from "@emotion/styled"
|
||||
import type { Item } from "opvault.js"
|
||||
import { useMemo } from "react"
|
||||
import { parseMonthYear } from "../utils"
|
||||
|
||||
const Container = styled.div`
|
||||
background: #cdc7b2;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #575345;
|
||||
}
|
||||
`
|
||||
|
||||
export const ItemWarning: React.FC<{ item: Item }> = ({ item }) => {
|
||||
const isExpired = useMemo(() => {
|
||||
const fields = item.details.sections?.flatMap(x => x.fields ?? [])
|
||||
if (!fields?.length) return false
|
||||
|
||||
for (const field of fields) {
|
||||
if (field.k === "monthYear") {
|
||||
const { year, month } = parseMonthYear(field.v)
|
||||
const now = new Date()
|
||||
const currentYear = now.getFullYear()
|
||||
return currentYear > year || (currentYear === year && now.getMonth() + 1 > month)
|
||||
} else if (field.k === "date") {
|
||||
const now = Date.now()
|
||||
const fieldDate = new Date(field.v * 1000).valueOf()
|
||||
return now > fieldDate
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}, [item])
|
||||
|
||||
if (isExpired) {
|
||||
return <Container>Expired</Container>
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
23
packages/web/src/components/TitleBar.tsx
Normal file
23
packages/web/src/components/TitleBar.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import styled from "@emotion/styled"
|
||||
|
||||
const Container = styled.div`
|
||||
background: linear-gradient(to bottom, #292929, #202020);
|
||||
border-bottom: 1px solid #070707;
|
||||
border-radius: 5px 5px 0 0;
|
||||
height: var(--titlebar-height);
|
||||
-webkit-app-region: drag;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
`
|
||||
const Title = styled.div`
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
flex-grow: 1;
|
||||
`
|
||||
|
||||
export const TitleBar = () => (
|
||||
<Container>
|
||||
<Title>OPVault Viewer</Title>
|
||||
</Container>
|
||||
)
|
19
packages/web/src/components/VaultPicker.tsx
Normal file
19
packages/web/src/components/VaultPicker.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { useCallback } from "react"
|
||||
|
||||
export const VaultPicker: React.FC<{
|
||||
setHandle(handle: FileSystemDirectoryHandle): void
|
||||
}> = ({ setHandle }) => {
|
||||
const onClick = useCallback(async () => {
|
||||
try {
|
||||
const handle = await showDirectoryPicker()
|
||||
setHandle(handle)
|
||||
} catch (e) {
|
||||
if ((e as Error).name === "AbortError") {
|
||||
return
|
||||
}
|
||||
alert(e)
|
||||
}
|
||||
}, [setHandle])
|
||||
|
||||
return <button onClick={onClick}>Pick a vault here.</button>
|
||||
}
|
59
packages/web/src/electron/index.ts
Normal file
59
packages/web/src/electron/index.ts
Normal file
@ -0,0 +1,59 @@
|
||||
// @ts-check
|
||||
// Modules to control application life and create native browser window
|
||||
// import { join } from "path"
|
||||
import { app, BrowserWindow, Menu } from "electron"
|
||||
|
||||
function createWindow() {
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 650,
|
||||
// frame: false,
|
||||
// transparent: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
// preload: join(__dirname, "preload.js"),
|
||||
},
|
||||
})
|
||||
|
||||
mainWindow.webContents.session.enableNetworkEmulation({
|
||||
offline: true,
|
||||
})
|
||||
|
||||
Menu.setApplicationMenu(null)
|
||||
|
||||
// and load the index.html of the app.
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
mainWindow.loadURL("http://localhost:3000")
|
||||
mainWindow.webContents.openDevTools()
|
||||
} else {
|
||||
mainWindow.loadFile("./web/index.html")
|
||||
}
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
mainWindow.webContents.openDevTools()
|
||||
}
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(() => {
|
||||
createWindow()
|
||||
|
||||
app.on("activate", () => {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit()
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
4
packages/web/src/electron/preload.ts
Normal file
4
packages/web/src/electron/preload.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { contextBridge } from "electron"
|
||||
import { nodeAdapter } from "opvault.js/src/adapters/node"
|
||||
|
||||
contextBridge.exposeInMainWorld("nodeAdapter", nodeAdapter)
|
113
packages/web/src/index.scss
Normal file
113
packages/web/src/index.scss
Normal file
@ -0,0 +1,113 @@
|
||||
@mixin scheme($property, $light-value, $dark-value) {
|
||||
#{$property}: $light-value;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#{$property}: $dark-value;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: transparent;
|
||||
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;
|
||||
|
||||
--color: #000;
|
||||
--titlebar-height: 46px;
|
||||
--titlebar-height: 0px;
|
||||
--label-background: #ddd;
|
||||
--selected-background: #c9c9c9;
|
||||
--hover-background: #ddd;
|
||||
--monospace: D2Coding, source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
color: #fff;
|
||||
--color: #fff;
|
||||
--label-background: #353535;
|
||||
--selected-background: #353535;
|
||||
--selected-background: #15539e;
|
||||
--hover-background: #222;
|
||||
}
|
||||
#root {
|
||||
background-color: #292929;
|
||||
}
|
||||
}
|
||||
|
||||
pre,
|
||||
code {
|
||||
font-family: var(--monospace);
|
||||
}
|
||||
|
||||
input {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
input[type="search"],
|
||||
input[type="input"],
|
||||
input[type="password"] {
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
@include scheme(background-color, #f6f5f4, #333);
|
||||
border-radius: 4px;
|
||||
border: 1px solid;
|
||||
@include scheme(border-color, #cdc7c2, #1b1b1b);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
&:hover {
|
||||
@include scheme(background-color, #f9f9f8, #363636);
|
||||
}
|
||||
&:active {
|
||||
@include scheme(background-color, #d6d1cd, #292929);
|
||||
}
|
||||
}
|
||||
button {
|
||||
font-size: 16px;
|
||||
padding: 8px 15px;
|
||||
box-shadow: rgb(0 0 0 / 7%) 0px 1px 2px;
|
||||
transition: 0.1s;
|
||||
}
|
||||
button[type="submit"] {
|
||||
background-color: #15539e;
|
||||
color: #fff;
|
||||
}
|
||||
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);
|
||||
}
|
12
packages/web/src/main.tsx
Normal file
12
packages/web/src/main.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import React from "react"
|
||||
import { render } from "react-dom"
|
||||
import { App } from "./App"
|
||||
import "./index.scss"
|
||||
|
||||
render(
|
||||
<React.StrictMode>
|
||||
{/* <TitleBar /> */}
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById("root")
|
||||
)
|
20
packages/web/src/pages/PickOPVault.tsx
Normal file
20
packages/web/src/pages/PickOPVault.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import styled from "@emotion/styled"
|
||||
import { VaultPicker } from "../components/VaultPicker"
|
||||
|
||||
const Container = styled.div`
|
||||
width: 800px;
|
||||
padding: 100px;
|
||||
text-align: center;
|
||||
`
|
||||
const Info = styled.div`
|
||||
margin: 10px;
|
||||
`
|
||||
|
||||
export const PickOPVault: React.FC<{
|
||||
setHandle(handle: FileSystemDirectoryHandle): void
|
||||
}> = ({ setHandle }) => (
|
||||
<Container>
|
||||
<VaultPicker setHandle={setHandle} />
|
||||
<Info>No vault is picked.</Info>
|
||||
</Container>
|
||||
)
|
54
packages/web/src/pages/Unlock.tsx
Normal file
54
packages/web/src/pages/Unlock.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import type { OnePassword } from "opvault.js"
|
||||
import styled from "@emotion/styled"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
const Container = styled.div`
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
`
|
||||
|
||||
export const Unlock: React.FC<{
|
||||
instance: OnePassword
|
||||
onUnlock(profile: string, password: string): void
|
||||
}> = ({ onUnlock, instance }) => {
|
||||
const [profiles, setProfiles] = useState<string[]>(() => [])
|
||||
const [profile, setProfile] = useState<string>()
|
||||
const [password, setPassword] = useState("")
|
||||
|
||||
const unlock = useCallback(() => {
|
||||
if (!profile) return
|
||||
onUnlock(profile, password)
|
||||
setPassword("")
|
||||
}, [onUnlock, profile, password])
|
||||
|
||||
useEffect(() => {
|
||||
instance.getProfileNames().then(profiles => {
|
||||
setProfiles(profiles)
|
||||
setProfile(profiles[0])
|
||||
})
|
||||
}, [instance])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div>
|
||||
<select value={profile} onChange={e => setProfile(e.currentTarget.value)}>
|
||||
{profiles.map(p => (
|
||||
<option key={p} value={p}>
|
||||
Vault: {p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ margin: "10px 0" }}>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={!profile || !password} onClick={unlock}>
|
||||
Unlock
|
||||
</button>
|
||||
</Container>
|
||||
)
|
||||
}
|
142
packages/web/src/pages/Vault.tsx
Normal file
142
packages/web/src/pages/Vault.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import styled from "@emotion/styled"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import type { Vault, Item } from "opvault.js"
|
||||
import { Category } from "opvault.js"
|
||||
import { IoSearch } from "react-icons/io5"
|
||||
import { ItemList } from "../components/ItemList"
|
||||
import { ItemView } from "../components/Item"
|
||||
import { reactIconClass } from "../components/CategoryIcon"
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
height: calc(100vh - var(--titlebar-height));
|
||||
`
|
||||
const ListContainer = styled.div`
|
||||
width: 300px;
|
||||
margin-right: 10px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: #202020;
|
||||
}
|
||||
`
|
||||
const ItemContainer = styled.div`
|
||||
width: calc(100% - 300px);
|
||||
overflow: hidden;
|
||||
`
|
||||
const SearchContainer = styled.div`
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
position: relative;
|
||||
`
|
||||
const SortContainer = styled.div`
|
||||
margin: 10px 10px;
|
||||
`
|
||||
const SearchInput = styled.input`
|
||||
--margin: 10px;
|
||||
width: calc(100% - var(--margin) * 2);
|
||||
margin: 0 var(--margin);
|
||||
padding-left: 2em !important;
|
||||
`
|
||||
const SearchIcon = styled(IoSearch)`
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
left: 20px;
|
||||
`
|
||||
|
||||
const enum SortBy {
|
||||
Name,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
|
||||
vault,
|
||||
onLock,
|
||||
}) => {
|
||||
const [items, setItems] = useState<Item[]>(() => [])
|
||||
const [item, setItem] = useState<Item>()
|
||||
const [sortBy, setSortBy] = useState(SortBy.Name)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const compareFn = useMemo((): ((a: Item, b: Item) => number) => {
|
||||
switch (sortBy) {
|
||||
case SortBy.Name:
|
||||
return (a, b) => (a.overview.title ?? "").localeCompare(b?.overview.title ?? "")
|
||||
case SortBy.CreatedAt:
|
||||
return (a, b) => a.createdAt - b.createdAt
|
||||
case SortBy.UpdatedAt:
|
||||
return (a, b) => a.updatedAt - b.updatedAt
|
||||
}
|
||||
}, [sortBy])
|
||||
|
||||
const sortedItem = useMemo(() => items.slice().sort(compareFn), [items, compareFn])
|
||||
|
||||
useEffect(() => {
|
||||
setItem(undefined)
|
||||
arrayFrom(vault.values()).then(setItems)
|
||||
}, [vault])
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
sortedItem
|
||||
.filter(x => x.category !== Category.Tombstone)
|
||||
.filter(
|
||||
search
|
||||
? x =>
|
||||
stringCompare(search, x.overview.title) ||
|
||||
stringCompare(search, x.overview.ainfo)
|
||||
: () => true
|
||||
),
|
||||
[sortedItem, search]
|
||||
)
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ListContainer>
|
||||
<div
|
||||
style={{
|
||||
margin: "10px 10px",
|
||||
}}
|
||||
>
|
||||
<button onClick={onLock}>Lock</button>
|
||||
</div>
|
||||
<SearchContainer>
|
||||
<SearchInput
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<SearchIcon className={reactIconClass} />
|
||||
</SearchContainer>
|
||||
<SortContainer>
|
||||
<select
|
||||
style={{ width: "100%" }}
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(+e.currentTarget.value)}
|
||||
>
|
||||
<option value={SortBy.Name}>Sort by Name</option>
|
||||
<option value={SortBy.CreatedAt}>Sort by Created Time</option>
|
||||
<option value={SortBy.UpdatedAt}>Sort by Updated Time</option>
|
||||
</select>
|
||||
</SortContainer>
|
||||
<ItemList items={filtered} onSelect={setItem} selected={item} />
|
||||
</ListContainer>
|
||||
<ItemContainer>{item && <ItemView item={item} />}</ItemContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
async function arrayFrom<T>(generator: AsyncGenerator<T, void, unknown>) {
|
||||
const list: T[] = []
|
||||
for await (const value of generator) {
|
||||
list.push(value)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
function stringCompare(search: string, source?: string) {
|
||||
if (!search) return true
|
||||
if (!source) return false
|
||||
return source.toLocaleLowerCase().includes(search.toLocaleLowerCase())
|
||||
}
|
5
packages/web/src/utils/index.ts
Normal file
5
packages/web/src/utils/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export function parseMonthYear(v: number) {
|
||||
const year = Math.floor(v / 100)
|
||||
const month = v % 100
|
||||
return { year, month }
|
||||
}
|
1
packages/web/src/vite-env.d.ts
vendored
Normal file
1
packages/web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
3
packages/web/tsconfig.json
Normal file
3
packages/web/tsconfig.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
21
packages/web/vite.config.ts
Normal file
21
packages/web/vite.config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { defineConfig } from "vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react()],
|
||||
define: {
|
||||
global: "globalThis",
|
||||
"process.browser": "true",
|
||||
"process.env.NODE_DEBUG": "false",
|
||||
},
|
||||
build: {
|
||||
outDir: "dist/web",
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
path: require.resolve("path-browserify"),
|
||||
},
|
||||
},
|
||||
})
|
Reference in New Issue
Block a user