feat: improve account management
This commit is contained in:
177
src/routes/$account/backup.tsx
Normal file
177
src/routes/$account/backup.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { displayNsec } from "@/commons";
|
||||
import { Spinner } from "@/components";
|
||||
import { CheckIcon } from "@/components";
|
||||
import * as Checkbox from "@radix-ui/react-checkbox";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/$account/backup")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { account } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [key, setKey] = useState(null);
|
||||
const [passphase, setPassphase] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [confirm, setConfirm] = useState({ c1: false, c2: false });
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
if (key) {
|
||||
if (!confirm.c1 || !confirm.c2) {
|
||||
return await message("You need to confirm before continue", {
|
||||
title: "Backup",
|
||||
kind: "info",
|
||||
});
|
||||
}
|
||||
|
||||
navigate({ to: "/", replace: true });
|
||||
}
|
||||
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
invoke("get_encrypted_key", {
|
||||
npub: account,
|
||||
password: passphase,
|
||||
}).then((encrypted: string) => {
|
||||
// update state
|
||||
setKey(encrypted);
|
||||
setLoading(false);
|
||||
});
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
await message(String(e), {
|
||||
title: "Backup",
|
||||
kind: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const copyKey = async () => {
|
||||
try {
|
||||
await writeText(key);
|
||||
setCopied(true);
|
||||
} catch (e) {
|
||||
await message(String(e), {
|
||||
title: "Backup",
|
||||
kind: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-6 px-5 mx-auto xl:max-w-xl">
|
||||
<div className="flex flex-col text-center">
|
||||
<h3 className="text-xl font-semibold">Backup your sign in keys</h3>
|
||||
<p className="text-neutral-700 dark:text-neutral-300">
|
||||
It's use for login to Lume or other Nostr clients. You will lost
|
||||
access to your account if you lose this key.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col w-full gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="passphase" className="font-medium">
|
||||
Set a passphase to secure your key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
name="passphase"
|
||||
type="password"
|
||||
value={passphase}
|
||||
onChange={(e) => setPassphase(e.target.value)}
|
||||
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{key ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="nsec" className="font-medium">
|
||||
Copy this key and keep it in safe place
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
name="nsec"
|
||||
type="text"
|
||||
value={displayNsec(key, 36)}
|
||||
readOnly
|
||||
className="w-full px-3 border-transparent rounded-lg h-11 bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring-0 dark:bg-white/10 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyKey()}
|
||||
className="inline-flex items-center justify-center w-24 rounded-lg h-11 bg-neutral-200 hover:bg-neutral-300 dark:bg-white/20 dark:hover:bg-white/30"
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="font-medium">Before you continue:</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox.Root
|
||||
checked={confirm.c1}
|
||||
onCheckedChange={() =>
|
||||
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
||||
}
|
||||
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
id="confirm1"
|
||||
>
|
||||
<Checkbox.Indicator className="text-blue-500">
|
||||
<CheckIcon className="size-4" />
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox.Root>
|
||||
<label
|
||||
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||
htmlFor="confirm1"
|
||||
>
|
||||
I will make sure keep it safe and not sharing with anyone.
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox.Root
|
||||
checked={confirm.c2}
|
||||
onCheckedChange={() =>
|
||||
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
||||
}
|
||||
className="flex items-center justify-center rounded-md outline-none appearance-none size-6 bg-neutral-100 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
id="confirm2"
|
||||
>
|
||||
<Checkbox.Indicator className="text-blue-500">
|
||||
<CheckIcon className="size-4" />
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox.Root>
|
||||
<label
|
||||
className="text-sm leading-none text-neutral-800 dark:text-neutral-200"
|
||||
htmlFor="confirm2"
|
||||
>
|
||||
I understand I cannot recover private key.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center justify-center w-full font-semibold text-white bg-blue-500 rounded-lg h-11 shrink-0 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Spinner /> : "Continue"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
import { cn } from "@/commons";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ComposeFilledIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
} from "@/components";
|
||||
import { User } from "@/components/user";
|
||||
import { LumeWindow, NostrAccount } from "@/system";
|
||||
import { Outlet, createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { memo, useCallback, useState } from "react";
|
||||
|
||||
export const Route = createLazyFileRoute("/$account/")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const { settings, platform } = Route.useRouteContext();
|
||||
|
||||
const openLumeStore = async () => {
|
||||
await getCurrentWindow().emit("columns", {
|
||||
type: "add",
|
||||
column: {
|
||||
label: "store",
|
||||
name: "Column Gallery",
|
||||
content: "/store",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-screen h-screen">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-11 shrink-0 items-center justify-between px-3"
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={cn(
|
||||
"flex-1 flex items-center gap-2",
|
||||
platform === "macos" ? "pl-[64px]" : "",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLumeStore()}
|
||||
className="inline-flex items-center justify-center gap-0.5 rounded-full text-sm font-medium h-8 w-max pl-1.5 pr-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
>
|
||||
<PlusIcon className="size-5" />
|
||||
Column
|
||||
</button>
|
||||
<div id="toolbar" />
|
||||
</div>
|
||||
<div data-tauri-drag-region className="hidden md:flex md:flex-1">
|
||||
<Search />
|
||||
</div>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex-1 flex items-center justify-end gap-3"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => LumeWindow.openEditor()}
|
||||
className="inline-flex items-center justify-center h-8 gap-1 px-3 text-sm font-medium bg-black/5 dark:bg-white/5 rounded-full w-max hover:bg-blue-500 hover:text-white"
|
||||
>
|
||||
<ComposeFilledIcon className="size-4" />
|
||||
New Post
|
||||
</button>
|
||||
<Accounts />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1",
|
||||
settings.vibrancy
|
||||
? ""
|
||||
: "bg-white dark:bg-black border-t border-black/20 dark:border-white/20",
|
||||
)}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Accounts = memo(function Accounts() {
|
||||
const { otherAccounts } = Route.useRouteContext();
|
||||
const { account } = Route.useParams();
|
||||
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
const showContextMenu = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const menuItems = await Promise.all([
|
||||
MenuItem.new({
|
||||
text: "New Post",
|
||||
action: () => LumeWindow.openEditor(),
|
||||
}),
|
||||
PredefinedMenuItem.new({ item: "Separator" }),
|
||||
MenuItem.new({
|
||||
text: "View Profile",
|
||||
action: () => LumeWindow.openProfile(account),
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Open Settings",
|
||||
action: () => LumeWindow.openSettings(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const menu = await Menu.new({
|
||||
items: menuItems,
|
||||
});
|
||||
|
||||
await menu.popup().catch((e) => console.error(e));
|
||||
},
|
||||
[account],
|
||||
);
|
||||
|
||||
const changeAccount = useCallback(
|
||||
async (npub: string) => {
|
||||
// Change current account and update signer
|
||||
const select = await NostrAccount.loadAccount(npub);
|
||||
|
||||
if (select) {
|
||||
// Reset current columns
|
||||
await getCurrentWindow().emit("columns", { type: "reset" });
|
||||
|
||||
// Redirect to new account
|
||||
return navigate({
|
||||
to: "/$account/home",
|
||||
params: { account: npub },
|
||||
resetScroll: true,
|
||||
replace: true,
|
||||
});
|
||||
} else {
|
||||
await message("Something wrong.", { title: "Accounts", kind: "error" });
|
||||
}
|
||||
},
|
||||
[otherAccounts],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-tauri-drag-region className="hidden md:flex items-center gap-3">
|
||||
{otherAccounts.map((npub) => (
|
||||
<button key={npub} type="button" onClick={(e) => changeAccount(npub)}>
|
||||
<User.Provider pubkey={npub}>
|
||||
<User.Root className="shrink-0 rounded-full transition-all ease-in-out duration-150 will-change-auto hover:ring-1 hover:ring-blue-500">
|
||||
<User.Avatar className="rounded-full size-8" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<User.Provider pubkey={account}>
|
||||
<User.Root className="shrink-0 rounded-full">
|
||||
<User.Avatar className="rounded-full size-8" />
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const Search = memo(function Search() {
|
||||
const [searchType, setSearchType] = useState<"notes" | "users">("notes");
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const showContextMenu = useCallback(async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const menuItems = await Promise.all([
|
||||
MenuItem.new({
|
||||
text: "Notes",
|
||||
action: () => setSearchType("notes"),
|
||||
}),
|
||||
MenuItem.new({
|
||||
text: "Users",
|
||||
action: () => setSearchType("users"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const menu = await Menu.new({
|
||||
items: menuItems,
|
||||
});
|
||||
|
||||
await menu.popup().catch((e) => console.error(e));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-8 w-full px-3 text-sm rounded-full inline-flex items-center bg-black/5 dark:bg-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => showContextMenu(e)}
|
||||
className="inline-flex items-center gap-1 capitalize text-sm font-medium pr-2 border-r border-black/10 dark:border-white/10 text-black/50 dark:text-white/50"
|
||||
>
|
||||
{searchType}
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder="Search..."
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
LumeWindow.openSearch(searchType, query);
|
||||
}
|
||||
}}
|
||||
className="h-full w-full px-3 text-sm rounded-full border-none ring-0 focus:ring-0 focus:outline-none bg-transparent placeholder:text-black/50 dark:placeholder:text-white/50"
|
||||
/>
|
||||
<SearchIcon className="size-5" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { NostrAccount, NostrQuery } from "@/system";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/$account/")({
|
||||
beforeLoad: async ({ params }) => {
|
||||
const settings = await NostrQuery.getUserSettings();
|
||||
const accounts = await NostrAccount.getAccounts();
|
||||
const otherAccounts = accounts.filter(
|
||||
(account) => account !== params.account,
|
||||
);
|
||||
|
||||
return { otherAccounts, settings };
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user