diff --git a/packages/opvault.js/src/types.ts b/packages/opvault.js/src/types.ts index 67b9446..c65651a 100644 --- a/packages/opvault.js/src/types.ts +++ b/packages/opvault.js/src/types.ts @@ -119,7 +119,7 @@ export namespace ItemSection { } export type Concealed = { k: "concealed" - n: "password" + n: "password" | `TOTP_${string}` v: string a?: { generate: "off" diff --git a/packages/web/src/about/LicenseViewer.tsx b/packages/web/src/about/LicenseViewer.tsx index b404351..9fbcace 100644 --- a/packages/web/src/about/LicenseViewer.tsx +++ b/packages/web/src/about/LicenseViewer.tsx @@ -1,6 +1,6 @@ import styled from "@emotion/styled" import { useEffect, useMemo, useState } from "react" -import { ClickableContainer } from "../components/ItemFieldValue" +import { Container as ClickableContainer } from "../components/ItemFieldValue/Container" import { scrollbar } from "../styles" const Container = styled.div` diff --git a/packages/web/src/components/FilteredVaultView.tsx b/packages/web/src/components/FilteredVaultView.tsx index 544438a..be217aa 100644 --- a/packages/web/src/components/FilteredVaultView.tsx +++ b/packages/web/src/components/FilteredVaultView.tsx @@ -158,16 +158,22 @@ export const FilteredVaultView: React.FC<{ items: Item[] }> = ({ items }) => { onChange={e => setCategory((e.currentTarget.value as Category) || undefined)} > {categoryMap.map(([value, name]) => ( - ))} setSortBy(+e.currentTarget.value)}> - - - + + + diff --git a/packages/web/src/components/Item.tsx b/packages/web/src/components/Item.tsx index c320c84..3a6ceb3 100644 --- a/packages/web/src/components/Item.tsx +++ b/packages/web/src/components/Item.tsx @@ -12,7 +12,7 @@ import { FieldTitle, ItemDetailsFieldView, } from "./ItemField" -import { PasswordFieldView } from "./ItemFieldValue" +import { Password } from "./ItemFieldValue/Password" import { ItemWarning } from "./ItemWarning" interface ItemViewProps { @@ -146,7 +146,7 @@ export const ItemView = memo(({ className, item }) => { {item.details.password != null && ( {t.label.password} - + )} diff --git a/packages/web/src/components/ItemField.tsx b/packages/web/src/components/ItemField.tsx index ff2a971..45cd2b1 100644 --- a/packages/web/src/components/ItemField.tsx +++ b/packages/web/src/components/ItemField.tsx @@ -1,4 +1,4 @@ -import { memo } from "react" +import { memo, useMemo } from "react" import styled from "@emotion/styled" import type { ItemField, ItemSection } from "opvault.js" import { ErrorBoundary } from "./ErrorBoundary" @@ -18,6 +18,11 @@ export const FieldTitle: React.FC = styled.div` export const ItemFieldView = memo<{ field: ItemSection.Any }>(({ field }) => { + const title = useMemo( + () => ((field as ItemSection.Concealed).n?.startsWith("TOTP_") ? "TOTP" : field.t), + [field] + ) + if (field.v == null) { return null } @@ -25,7 +30,7 @@ export const ItemFieldView = memo<{ return ( - {field.t} + {title} diff --git a/packages/web/src/components/ItemFieldValue.tsx b/packages/web/src/components/ItemFieldValue.tsx deleted file mode 100644 index b75d32e..0000000 --- a/packages/web/src/components/ItemFieldValue.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import styled from "@emotion/styled" -import type { ItemSection, ItemField } from "opvault.js" -import { FieldType } from "opvault.js" -import { useCallback, useMemo, useState } from "react" -import { useTranslate } from "../i18n" -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` - cursor: pointer; - &:hover { - color: #6fa9ff; - text-decoration: underline; - } -` - -export { Container as ClickableContainer } - -function useCopy(text: string) { - const t = useTranslate() - return useCallback(() => { - navigator.clipboard.writeText(text) - toast({ - type: ToastType.Secondary, - message: t.tips.copied_to_clipboard, - }) - }, [text, t]) -} - -export { Password as PasswordFieldView } - -const Password: React.FC<{ - field: Pick -}> = ({ field }) => { - const t = useTranslate() - const [show, setShow] = useState(false) - const [bigText, showBigText] = useState(false) - - const { onRightClick, ContextMenuContainer, Item } = useItemFieldContextMenu() - const onToggle = useCallback(() => setShow(x => !x), []) - const onCopy = useCopy(field.v) - const onOpenBigText = useCallback(() => { - showBigText(true) - }, []) - const onCloseBigText = useCallback(() => { - showBigText(false) - }, []) - - return ( - <> - setShow(x => !x)} - onClick={onCopy} - style={{ - fontFamily: "var(--monospace)", - ...(!show && { userSelect: "none" }), - }} - > - {show ? field.v : "·".repeat(10)} - - {bigText && {field.v}} - - {t.action.copy} - {show ? t.action.hide : t.action.show} - {!bigText && ( - {t.action.show_in_big_characters} - )} - - - ) -} - -const MonthYear: React.FC<{ field: ItemSection.MonthYear }> = ({ field }) => { - const { year, month } = parseMonthYear(field.v) - return ( - - {month.toString().padStart(2, "0")}/{year.toString().padStart(4, "0")} - - ) -} - -const DateView: React.FC<{ field: ItemSection.Date }> = ({ field }) => { - const date = useMemo(() => new Date(field.v * 1000), [field.v]) - return {date.toLocaleDateString()} -} - -const TextView: React.FC<{ value: string }> = ({ value }) => { - const { onRightClick, ContextMenuContainer, Item } = useItemFieldContextMenu() - const onCopy = useCopy(value) - - return ( - <> - - {value} - - - Copier - - - ) -} - -export const ItemFieldValue: React.FC<{ - field: ItemSection.Any -}> = ({ field }) => { - if (field.v == null) { - return null - } - - switch (field.k) { - case "concealed": - return - case "monthYear": - return - case "date": - return - case "address": - return ( - -
{field.v.street}
-
- {field.v.city}, {field.v.state} ({field.v.zip}) -
-
{field.v.country}
-
- ) - } - - return ( - - - - ) -} - -export const ItemDetailsFieldValue: React.FC<{ - field: ItemField -}> = ({ field }) => { - if (field.type === FieldType.Password || field.designation === "password") { - return - } - - return ( - - - - ) -} diff --git a/packages/web/src/components/ItemFieldValue/Address.tsx b/packages/web/src/components/ItemFieldValue/Address.tsx new file mode 100644 index 0000000..4b41f6a --- /dev/null +++ b/packages/web/src/components/ItemFieldValue/Address.tsx @@ -0,0 +1,12 @@ +import type { ItemSection } from "opvault.js" +import { Container } from "./Container" + +export const Address: React.FC<{ field: ItemSection.Address }> = ({ field }) => ( + +
{field.v.street}
+
+ {field.v.city}, {field.v.state} ({field.v.zip}) +
+
{field.v.country}
+
+) diff --git a/packages/web/src/components/ItemFieldValue/Container.ts b/packages/web/src/components/ItemFieldValue/Container.ts new file mode 100644 index 0000000..d783d06 --- /dev/null +++ b/packages/web/src/components/ItemFieldValue/Container.ts @@ -0,0 +1,9 @@ +import styled from "@emotion/styled" + +export const Container = styled.div` + cursor: pointer; + &:hover { + color: #6fa9ff; + text-decoration: underline; + } +` diff --git a/packages/web/src/components/ItemFieldValue/DateView.tsx b/packages/web/src/components/ItemFieldValue/DateView.tsx new file mode 100644 index 0000000..01c28e9 --- /dev/null +++ b/packages/web/src/components/ItemFieldValue/DateView.tsx @@ -0,0 +1,8 @@ +import type { ItemSection } from "opvault.js" +import { useMemo } from "react" +import { Container } from "./Container" + +export const DateView: React.FC<{ field: ItemSection.Date }> = ({ field }) => { + const date = useMemo(() => new Date(field.v * 1000), [field.v]) + return {date.toLocaleDateString()} +} diff --git a/packages/web/src/components/ItemFieldValue/MonthYear.tsx b/packages/web/src/components/ItemFieldValue/MonthYear.tsx new file mode 100644 index 0000000..408d707 --- /dev/null +++ b/packages/web/src/components/ItemFieldValue/MonthYear.tsx @@ -0,0 +1,12 @@ +import type { ItemSection } from "opvault.js" +import { parseMonthYear } from "../../utils" +import { Container } from "./Container" + +export const MonthYear: React.FC<{ field: ItemSection.MonthYear }> = ({ field }) => { + const { year, month } = parseMonthYear(field.v) + return ( + + {month.toString().padStart(2, "0")}/{year.toString().padStart(4, "0")} + + ) +} diff --git a/packages/web/src/components/ItemFieldValue/OTP.tsx b/packages/web/src/components/ItemFieldValue/OTP.tsx new file mode 100644 index 0000000..82047bb --- /dev/null +++ b/packages/web/src/components/ItemFieldValue/OTP.tsx @@ -0,0 +1,64 @@ +import styled from "@emotion/styled" +import type { ItemSection } from "opvault.js" +import { useCallback, useState } from "react" +import { useTranslate } from "../../i18n" +import { useItemFieldContextMenu } from "../ItemFieldContextMenu" +import { Container } from "./Container" +import { useCopy } from "./hooks" + +const OTPItemContainer = styled(Container)` + margin: 5px 0; +` + +const OTPItem = ({ children }: { children: string }) => { + const { onRightClick } = useItemFieldContextMenu() + const onCopy = useCopy(children) + + return ( + + {children} + + ) +} + +const ItemCount = styled(Container)` + opacity: 0.5; + user-select: none; +` + +export const OTP: React.FC<{ + field: Pick +}> = ({ field }) => { + const t = useTranslate() + const [show, setShow] = useState(false) + + const { onRightClick, ContextMenuContainer, Item } = useItemFieldContextMenu() + const onToggle = useCallback(() => setShow(x => !x), []) + const fields = field.v.split(" ") + + return ( + <> + {show ? ( +
setShow(x => !x)} + style={{ + fontFamily: "var(--monospace)", + paddingTop: 5, + }} + > + {fields.map((item, i) => ( + {item} + ))} +
+ ) : ( + + {fields.length} {fields.length === 1 ? t.noun.item : t.noun.items} + + )} + + {show ? t.action.hide : t.action.show} + + + ) +} diff --git a/packages/web/src/components/ItemFieldValue/Password.tsx b/packages/web/src/components/ItemFieldValue/Password.tsx new file mode 100644 index 0000000..11dddd1 --- /dev/null +++ b/packages/web/src/components/ItemFieldValue/Password.tsx @@ -0,0 +1,49 @@ +import type { ItemSection } from "opvault.js" +import { useCallback, useState } from "react" +import { useTranslate } from "../../i18n" +import { BigTextView } from "../BigTextView" +import { useItemFieldContextMenu } from "../ItemFieldContextMenu" +import { Container } from "./Container" +import { useCopy } from "./hooks" + +export const Password: React.FC<{ + field: Pick +}> = ({ field }) => { + const t = useTranslate() + const [show, setShow] = useState(false) + const [bigText, showBigText] = useState(false) + + const { onRightClick, ContextMenuContainer, Item } = useItemFieldContextMenu() + const onToggle = useCallback(() => setShow(x => !x), []) + const onCopy = useCopy(field.v) + const onOpenBigText = useCallback(() => { + showBigText(true) + }, []) + const onCloseBigText = useCallback(() => { + showBigText(false) + }, []) + + return ( + <> + setShow(x => !x)} + onClick={onCopy} + style={{ + fontFamily: "var(--monospace)", + ...(!show && { userSelect: "none" }), + }} + > + {show ? field.v : "·".repeat(10)} + + {bigText && {field.v}} + + {t.action.copy} + {show ? t.action.hide : t.action.show} + {!bigText && ( + {t.action.show_in_big_characters} + )} + + + ) +} diff --git a/packages/web/src/components/ItemFieldValue/Text.tsx b/packages/web/src/components/ItemFieldValue/Text.tsx new file mode 100644 index 0000000..44583b3 --- /dev/null +++ b/packages/web/src/components/ItemFieldValue/Text.tsx @@ -0,0 +1,19 @@ +import { useItemFieldContextMenu } from "../ItemFieldContextMenu" +import { Container } from "./Container" +import { useCopy } from "./hooks" + +export const TextView: React.FC<{ value: string }> = ({ value }) => { + const { onRightClick, ContextMenuContainer, Item } = useItemFieldContextMenu() + const onCopy = useCopy(value) + + return ( + <> + + {value} + + + Copier + + + ) +} diff --git a/packages/web/src/components/ItemFieldValue/hooks.ts b/packages/web/src/components/ItemFieldValue/hooks.ts new file mode 100644 index 0000000..6a08862 --- /dev/null +++ b/packages/web/src/components/ItemFieldValue/hooks.ts @@ -0,0 +1,14 @@ +import { useCallback } from "react" +import { useTranslate } from "../../i18n" +import { toast, ToastType } from "../Toast" + +export function useCopy(text: string) { + const t = useTranslate() + return useCallback(() => { + navigator.clipboard.writeText(text) + toast({ + type: ToastType.Secondary, + message: t.tips.copied_to_clipboard, + }) + }, [text, t]) +} diff --git a/packages/web/src/components/ItemFieldValue/index.tsx b/packages/web/src/components/ItemFieldValue/index.tsx new file mode 100644 index 0000000..62c3fa2 --- /dev/null +++ b/packages/web/src/components/ItemFieldValue/index.tsx @@ -0,0 +1,52 @@ +import type { ItemSection, ItemField } from "opvault.js" +import { FieldType } from "opvault.js" +import { ErrorBoundary } from "../ErrorBoundary" +import { Password } from "./Password" +import { OTP } from "./OTP" +import { MonthYear } from "./MonthYear" +import { DateView } from "./DateView" +import { TextView } from "./Text" +import { Address } from "./Address" + +export const ItemFieldValue: React.FC<{ + field: ItemSection.Any +}> = ({ field }) => { + if (field.v == null) { + return null + } + + switch (field.k) { + case "concealed": + return field.n.startsWith("TOTP_") ? ( + + ) : ( + + ) + case "monthYear": + return + case "date": + return + case "address": + return
+ } + + return ( + + + + ) +} + +export const ItemDetailsFieldValue: React.FC<{ + field: ItemField +}> = ({ field }) => { + if (field.type === FieldType.Password || field.designation === "password") { + return + } + + return ( + + + + ) +} diff --git a/packages/web/src/i18n/texts.yml b/packages/web/src/i18n/texts.yml index 6aaf808..dc840d5 100644 --- a/packages/web/src/i18n/texts.yml +++ b/packages/web/src/i18n/texts.yml @@ -192,6 +192,16 @@ noun: fr: secondes ja: 秒 + item: + en: item + fr: élément + ja: アイテム + + items: + en: items + fr: éléments + ja: アイテム + action: lock: en: Lock diff --git a/packages/web/src/settings/index.tsx b/packages/web/src/settings/index.tsx index dfbcc78..dd34cc2 100644 --- a/packages/web/src/settings/index.tsx +++ b/packages/web/src/settings/index.tsx @@ -75,6 +75,7 @@ export const Settings: React.FC<{ value={autolockAfter} onChange={e => setAutolockAfter(e.target.valueAsNumber)} disabled={!enableAutoLock} + min={5} /> {autolockAfter}