223 lines
6.1 KiB
TypeScript
223 lines
6.1 KiB
TypeScript
import styled from "@emotion/styled"
|
|
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 { 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: 350px;
|
|
margin-right: 10px;
|
|
overflow-y: scroll;
|
|
overflow-x: hidden;
|
|
@media (prefers-color-scheme: dark) {
|
|
background: #202020;
|
|
border-right-color: transparent;
|
|
}
|
|
`
|
|
const ItemContainer = styled.div`
|
|
width: calc(100% - 300px);
|
|
overflow: hidden;
|
|
`
|
|
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 SearchInput = styled.input`
|
|
--margin: 10px;
|
|
width: calc(100% - var(--margin) * 2 + 9px);
|
|
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 [showSettings, setShowSettings] = useState(false)
|
|
const t = useTranslate()
|
|
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) => b.createdAt - a.createdAt
|
|
case SortBy.UpdatedAt:
|
|
return (a, b) => b.updatedAt - a.updatedAt
|
|
}
|
|
}, [sortBy])
|
|
|
|
const sortedItem = useMemo(() => items.slice().sort(compareFn), [items, compareFn])
|
|
|
|
useEffect(() => {
|
|
setItem(undefined)
|
|
arrayFrom(vault.values()).then(setItems)
|
|
}, [vault])
|
|
|
|
const filtered = useMemo(() => {
|
|
const items = sortedItem.filter(x => x.category !== Category.Tombstone)
|
|
let res: Item[] = items
|
|
if (search) {
|
|
res = []
|
|
for (const x of items) {
|
|
const compare = Math.max(
|
|
stringCompare(search, x.overview.title),
|
|
stringCompare(search, x.overview.ainfo)
|
|
) as CompareResult
|
|
switch (compare) {
|
|
case CompareResult.NoMatch:
|
|
continue
|
|
case CompareResult.Includes:
|
|
res.push(x)
|
|
break
|
|
case CompareResult.Equals:
|
|
res.unshift(x)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return res
|
|
}, [sortedItem, search])
|
|
|
|
return (
|
|
<Container>
|
|
<TabContainer>
|
|
<TabContainerMain>
|
|
<TabButton active>
|
|
<Si1Password />
|
|
</TabButton>
|
|
</TabContainerMain>
|
|
<TabButton onClick={onLock} title={t.action.lock}>
|
|
<FiLock />
|
|
</TabButton>
|
|
<TabButton onClick={() => setShowSettings(true)} title={t.label.settings}>
|
|
<BsGear />
|
|
</TabButton>
|
|
</TabContainer>
|
|
|
|
<ListContainer className={scrollbar}>
|
|
<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}>{t.options.sort_by_name}</option>
|
|
<option value={SortBy.CreatedAt}>{t.options.sort_by_created_at}</option>
|
|
<option value={SortBy.UpdatedAt}>{t.options.sort_by_updated_at}</option>
|
|
</select>
|
|
</SortContainer>
|
|
<ItemList items={filtered} onSelect={setItem} selected={item} />
|
|
</ListContainer>
|
|
<ItemContainer>
|
|
{item && <ItemView className={scrollbar} item={item} />}
|
|
</ItemContainer>
|
|
|
|
<Settings show={showSettings} onHide={() => setShowSettings(false)} />
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
async function arrayFrom<T>(generator: AsyncGenerator<T, void, unknown>) {
|
|
const list: T[] = []
|
|
for await (const value of generator) {
|
|
list.push(value)
|
|
}
|
|
return list
|
|
}
|
|
|
|
enum CompareResult {
|
|
NoMatch,
|
|
Includes,
|
|
Equals,
|
|
}
|
|
|
|
function stringCompare(search: string, source?: string) {
|
|
if (!search) return CompareResult.Includes
|
|
if (!source) return CompareResult.NoMatch
|
|
source = source.toLocaleLowerCase()
|
|
search = search.toLocaleUpperCase()
|
|
const includes = source.includes(search.toLocaleLowerCase())
|
|
if (includes) {
|
|
return source.length === search.length ? CompareResult.Equals : CompareResult.Includes
|
|
}
|
|
return CompareResult.NoMatch
|
|
}
|