mirror of
https://github.com/lumehq/lume.git
synced 2025-03-28 02:31:49 +01:00
feat: add create account flow
This commit is contained in:
parent
d3fa59d2b1
commit
cbbf5eaf50
@ -18,6 +18,7 @@
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@tanstack/query-sync-storage-persister": "^5.28.4",
|
||||
"@tanstack/react-query": "^5.28.4",
|
||||
"@tanstack/react-query-persist-client": "^5.28.4",
|
||||
|
@ -1,17 +1,19 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@lume/utils";
|
||||
import { Dispatch, ReactNode, SetStateAction, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function AvatarUploader({
|
||||
setPicture,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
setPicture: Dispatch<SetStateAction<string>>;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const ark = useArk();
|
||||
|
||||
const [t] = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const uploadAvatar = async () => {
|
||||
@ -32,15 +34,9 @@ export function AvatarUploader({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadAvatar()}
|
||||
className="inline-flex w-32 items-center justify-center rounded-lg border border-blue-200 bg-blue-100 px-2 py-1.5 text-sm font-medium text-blue-500 hover:border-blue-300 hover:bg-blue-200 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-500 dark:hover:border-blue-800 dark:hover:bg-blue-800"
|
||||
className={cn("", className)}
|
||||
>
|
||||
{loading ? (
|
||||
<button type="button" className="size-4" disabled>
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
</button>
|
||||
) : (
|
||||
t("user.avatarButton")
|
||||
)}
|
||||
{loading ? <LoaderIcon className="size-4 animate-spin" /> : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
@ -1,25 +0,0 @@
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/auth/create")({
|
||||
component: Screen,
|
||||
loader: ({ context }) => {
|
||||
return context.ark.create_keys();
|
||||
},
|
||||
pendingComponent: Pending,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
return <div className="px-5"></div>;
|
||||
}
|
||||
|
||||
function Pending() {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center gap-2">
|
||||
<button type="button" className="size-5" disabled>
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
</button>
|
||||
<p>Creating account</p>
|
||||
</div>
|
||||
);
|
||||
}
|
186
apps/desktop2/src/routes/auth/new/backup.tsx
Normal file
186
apps/desktop2/src/routes/auth/new/backup.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import { displayNsec } from "@lume/utils";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import * as Checkbox from "@radix-ui/react-checkbox";
|
||||
import { CheckIcon } from "@lume/icons";
|
||||
|
||||
export const Route = createFileRoute("/auth/new/backup")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
// @ts-ignore, magic!!!
|
||||
const { account } = Route.useSearch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [key, setKey] = useState(null);
|
||||
const [passphase, setPassphase] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
if (key) {
|
||||
if (!confirm.c1 || !confirm.c2 || !confirm.c3) {
|
||||
return toast.warning("You need to confirm before continue");
|
||||
} else {
|
||||
return navigate({
|
||||
to: "/auth/settings",
|
||||
search: { account, new: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const encrypted: string = await invoke("get_encrypted_key", {
|
||||
npub: account,
|
||||
password: passphase,
|
||||
});
|
||||
|
||||
setKey(encrypted);
|
||||
} catch (e) {
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const copyKey = async () => {
|
||||
try {
|
||||
await writeText(key);
|
||||
setCopied(true);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 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 w-full flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="nsec" 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="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
||||
/>
|
||||
</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="h-11 w-full resize-none rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyKey}
|
||||
className="inline-flex h-11 w-24 items-center justify-center rounded-lg bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-900 dark:hover:bg-neutral-700"
|
||||
>
|
||||
{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 size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
|
||||
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"
|
||||
>
|
||||
{t("backup.confirm1")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox.Root
|
||||
checked={confirm.c2}
|
||||
onCheckedChange={() =>
|
||||
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
||||
}
|
||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
|
||||
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"
|
||||
>
|
||||
{t("backup.confirm2")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox.Root
|
||||
checked={confirm.c3}
|
||||
onCheckedChange={() =>
|
||||
setConfirm((state) => ({ ...state, c3: !state.c3 }))
|
||||
}
|
||||
className="flex size-6 appearance-none items-center justify-center rounded-md bg-neutral-100 outline-none dark:bg-neutral-900"
|
||||
id="confirm3"
|
||||
>
|
||||
<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="confirm3"
|
||||
>
|
||||
{t("backup.confirm3")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{t("global.continue")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
147
apps/desktop2/src/routes/auth/new/profile.tsx
Normal file
147
apps/desktop2/src/routes/auth/new/profile.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { AvatarUploader } from "@/components/avatarUploader";
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon, PlusIcon } from "@lume/icons";
|
||||
import { Metadata } from "@lume/types";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/auth/new/profile")({
|
||||
component: Screen,
|
||||
loader: ({ context }) => {
|
||||
return context.ark.create_keys();
|
||||
},
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const ark = useArk();
|
||||
const keys = Route.useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { register, handleSubmit } = useForm();
|
||||
|
||||
const [picture, setPicture] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onSubmit = async (data: {
|
||||
name: string;
|
||||
about: string;
|
||||
website: string;
|
||||
}) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Save account keys
|
||||
const save = await ark.save_account(keys.nsec);
|
||||
|
||||
// Then create profile
|
||||
if (save) {
|
||||
const profile: Metadata = { ...data, picture };
|
||||
const eventId = await ark.create_profile(profile);
|
||||
|
||||
if (eventId) {
|
||||
navigate({
|
||||
to: "/auth/new/backup",
|
||||
search: { account: keys.npub },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold">Let's set up your profile.</h3>
|
||||
</div>
|
||||
<div>
|
||||
<div className="relative size-24 rounded-full bg-gradient-to-tr from-orange-100 via-red-50 to-blue-200">
|
||||
{picture ? (
|
||||
<img
|
||||
src={picture}
|
||||
alt="avatar"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 z-10 h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<AvatarUploader
|
||||
setPicture={setPicture}
|
||||
className="absolute inset-0 z-20 flex h-full w-full items-center justify-center rounded-full bg-black/10 text-white hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
<PlusIcon className="size-8" />
|
||||
</AvatarUploader>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex w-full flex-col gap-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="display_name" className="font-medium">
|
||||
{t("user.displayName")} *
|
||||
</label>
|
||||
<input
|
||||
type={"text"}
|
||||
{...register("display_name", { required: true, minLength: 1 })}
|
||||
placeholder="e.g. Alice in Nostrland"
|
||||
spellCheck={false}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="name" className="font-medium">
|
||||
{t("user.name")}
|
||||
</label>
|
||||
<input
|
||||
type={"text"}
|
||||
{...register("name")}
|
||||
placeholder="e.g. alice"
|
||||
spellCheck={false}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="about" className="font-medium">
|
||||
{t("user.bio")}
|
||||
</label>
|
||||
<textarea
|
||||
{...register("about")}
|
||||
placeholder="e.g. Artist, anime-lover, and k-pop fan"
|
||||
spellCheck={false}
|
||||
className="relative h-24 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="website" className="font-medium">
|
||||
{t("user.website")}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
{...register("website")}
|
||||
placeholder="e.g. https://alice.me"
|
||||
spellCheck={false}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
t("global.continue")
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@ -32,9 +31,8 @@ function Screen() {
|
||||
try {
|
||||
const npub = await ark.save_account(key, password);
|
||||
navigate({
|
||||
to: "/$account/home",
|
||||
params: { account: npub },
|
||||
search: { onboarding: true },
|
||||
to: "/auth/settings",
|
||||
search: { account: npub, new: false },
|
||||
replace: true,
|
||||
});
|
||||
} catch (e) {
|
||||
@ -45,57 +43,50 @@ function Screen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto flex w-full max-w-md flex-col gap-8">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h1 className="text-2xl font-semibold">{t("login.title")}</h1>
|
||||
<p className="text-lg leading-snug text-neutral-600 dark:text-neutral-500">
|
||||
{t("login.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="key"
|
||||
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
Private Key
|
||||
</label>
|
||||
<input
|
||||
name="key"
|
||||
type="text"
|
||||
placeholder="nsec or ncryptsec..."
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
Password (Optional)
|
||||
</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-100 dark:bg-neutral-900 dark:focus:ring-blue-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={loading}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 text-lg font-medium text-white hover:bg-blue-600"
|
||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold">Continue with Private Key</h3>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="key"
|
||||
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
{loading ? <LoaderIcon className="size-5 animate-spin" /> : "Login"}
|
||||
</button>
|
||||
Private Key
|
||||
</label>
|
||||
<input
|
||||
name="key"
|
||||
type="text"
|
||||
placeholder="nsec or ncryptsec..."
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="font-medium text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
Password (Optional)
|
||||
</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="h-11 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-950 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={loading}
|
||||
className="mt-3 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <LoaderIcon className="size-4 animate-spin" /> : "Login"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
164
apps/desktop2/src/routes/auth/settings.lazy.tsx
Normal file
164
apps/desktop2/src/routes/auth/settings.lazy.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import { CheckIcon } from "@lume/icons";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Settings } from "@lume/types";
|
||||
import { useArk } from "@lume/ark";
|
||||
import {
|
||||
isPermissionGranted,
|
||||
requestPermission,
|
||||
} from "@tauri-apps/plugin-notification";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createLazyFileRoute("/auth/settings")({
|
||||
component: Screen,
|
||||
});
|
||||
|
||||
function Screen() {
|
||||
const ark = useArk();
|
||||
|
||||
// @ts-ignore, magic!!!
|
||||
const { account } = Route.useSearch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
notification: false,
|
||||
enhancedPrivacy: false,
|
||||
autoUpdate: false,
|
||||
});
|
||||
|
||||
const toggleNofitication = async () => {
|
||||
await requestPermission();
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
notification: !settings.notification,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleAutoUpdate = () => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
autoUpdate: !settings.autoUpdate,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleEnhancedPrivacy = () => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
enhancedPrivacy: !settings.enhancedPrivacy,
|
||||
}));
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
const eventId = await ark.set_settings(settings);
|
||||
if (eventId) toast.success("Settings have been updated successfully.");
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
const permissionGranted = await isPermissionGranted(); // get notification permission
|
||||
const settings = await ark.get_settings(account);
|
||||
|
||||
setSettings({ ...settings, notification: permissionGranted });
|
||||
}
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-center gap-6 px-5 xl:max-w-xl">
|
||||
<div className="flex flex-col items-center gap-5 text-center">
|
||||
<div className="flex size-20 items-center justify-center rounded-full bg-teal-100 text-teal-500">
|
||||
<CheckIcon className="size-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t("onboardingSettings.title")}
|
||||
</h1>
|
||||
<p className="leading-snug text-neutral-600 dark:text-neutral-400">
|
||||
{t("onboardingSettings.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
|
||||
<Switch.Root
|
||||
checked={settings.notification}
|
||||
onClick={() => toggleNofitication()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Push Notification</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Enabling push notifications will allow you to receive
|
||||
notifications from Lume.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
|
||||
<Switch.Root
|
||||
checked={settings.enhancedPrivacy}
|
||||
onClick={() => toggleEnhancedPrivacy()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Enhanced Privacy</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Lume will display external resources like image, video or link
|
||||
preview as plain text.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
|
||||
<Switch.Root
|
||||
checked={settings.autoUpdate}
|
||||
onClick={() => toggleAutoUpdate()}
|
||||
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
||||
>
|
||||
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||
</Switch.Root>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">Auto Update</h3>
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
Automatically download and install new version.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-50 px-5 py-4 dark:bg-neutral-950">
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
There are many more settings you can configure from the 'Settings'
|
||||
Screen. Be sure to visit it later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveSettings}
|
||||
className="inline-flex h-11 flex-1 items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 disabled:opacity-50 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Save settings
|
||||
</button>
|
||||
<Link
|
||||
to="/$account/home"
|
||||
params={{ account }}
|
||||
className="inline-flex h-11 flex-1 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{t("global.continue")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -32,7 +32,7 @@ function Screen() {
|
||||
</div>
|
||||
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
|
||||
<Link
|
||||
to="/auth/create"
|
||||
to="/auth/new/profile"
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white font-medium text-blue-500 backdrop-blur-lg hover:bg-white/90"
|
||||
>
|
||||
{t("welcome.signup")}
|
||||
|
@ -17,14 +17,8 @@ export function Screen() {
|
||||
// @ts-ignore, just work!!!
|
||||
const { id, name, account } = Route.useSearch();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useEvents("local", account);
|
||||
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
|
||||
useEvents("local", account);
|
||||
|
||||
const renderItem = (event: Event) => {
|
||||
if (!event) return;
|
||||
@ -46,13 +40,17 @@ export function Screen() {
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
</button>
|
||||
</div>
|
||||
) : !data ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
|
||||
) : !data.length ? (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex items-center gap-2 rounded-xl bg-neutral-100 p-5 dark:bg-neutral-900">
|
||||
<InfoIcon className="size-6" />
|
||||
<div>
|
||||
<p className="leading-tight">{t("emptyFeedTitle")}</p>
|
||||
<p className="leading-tight">{t("emptyFeedSubtitle")}</p>
|
||||
<p className="font-medium leading-tight">
|
||||
{t("global.emptyFeedTitle")}
|
||||
</p>
|
||||
<p className="leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
{t("global.emptyFeedSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Suggest />
|
||||
@ -62,8 +60,9 @@ export function Screen() {
|
||||
{data.map((item) => renderItem(item))}
|
||||
</Virtualizer>
|
||||
)}
|
||||
<div className="flex h-20 items-center justify-center">
|
||||
{data?.length && hasNextPage ? (
|
||||
|
||||
{data?.length && hasNextPage ? (
|
||||
<div className="flex h-20 items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchNextPage()}
|
||||
@ -79,8 +78,8 @@ export function Screen() {
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Column.Content>
|
||||
</Column.Root>
|
||||
);
|
||||
|
@ -6,6 +6,7 @@ import type {
|
||||
EventWithReplies,
|
||||
Keys,
|
||||
Metadata,
|
||||
Settings,
|
||||
} from "@lume/types";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
@ -338,6 +339,25 @@ export class Ark {
|
||||
}
|
||||
}
|
||||
|
||||
public async create_profile(profile: Metadata) {
|
||||
try {
|
||||
const event: string = await invoke("create_profile", {
|
||||
name: profile.name || "",
|
||||
display_name: profile.display_name || "",
|
||||
displayName: profile.display_name || "",
|
||||
about: profile.about || "",
|
||||
picture: profile.picture || "",
|
||||
banner: profile.banner || "",
|
||||
nip05: profile.nip05 || "",
|
||||
lud16: profile.lud16 || "",
|
||||
website: profile.website || "",
|
||||
});
|
||||
return event;
|
||||
} catch (e) {
|
||||
throw new Error(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
public async get_contact_list() {
|
||||
try {
|
||||
const cmd: string[] = await invoke("get_contact_list");
|
||||
@ -499,6 +519,30 @@ export class Ark {
|
||||
}
|
||||
}
|
||||
|
||||
public async get_settings(id: string) {
|
||||
try {
|
||||
const cmd: string = await invoke("get_settings", { id });
|
||||
if (!cmd) return null;
|
||||
if (!cmd.length) return null;
|
||||
|
||||
const settings: Settings = JSON.parse(cmd);
|
||||
return settings;
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
}
|
||||
|
||||
public async set_settings(settings: Settings) {
|
||||
try {
|
||||
const cmd: string = await invoke("set_settings", {
|
||||
content: JSON.stringify(settings),
|
||||
});
|
||||
return cmd;
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
}
|
||||
|
||||
public open_thread(id: string) {
|
||||
return new WebviewWindow(`event-${id}`, {
|
||||
title: "Thread",
|
||||
|
@ -1,24 +1,16 @@
|
||||
import { SVGProps } from "react";
|
||||
|
||||
export function CheckIcon(
|
||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
|
||||
) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M5 12.713l5.017 5.012.4-.701a28.598 28.598 0 018.7-9.42L20 7"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.5"
|
||||
d="M4.75 12.777 10 19.25l9.25-14.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
12
packages/types/index.d.ts
vendored
12
packages/types/index.d.ts
vendored
@ -1,13 +1,7 @@
|
||||
export interface Settings {
|
||||
autoupdate: boolean;
|
||||
nsecbunker: boolean;
|
||||
media: boolean;
|
||||
hashtag: boolean;
|
||||
lowPower: boolean;
|
||||
translation: boolean;
|
||||
translateApiKey: string;
|
||||
instantZap: boolean;
|
||||
defaultZapAmount: number;
|
||||
notification: boolean;
|
||||
enhancedPrivacy: boolean;
|
||||
autoUpdate: boolean;
|
||||
}
|
||||
|
||||
export interface Keys {
|
||||
|
@ -1,13 +1,16 @@
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function Container({
|
||||
children,
|
||||
withDrag = false,
|
||||
withNavigate = true,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
withDrag?: boolean;
|
||||
withNavigate?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
@ -18,7 +21,29 @@ export function Container({
|
||||
)}
|
||||
>
|
||||
{withDrag ? (
|
||||
<div data-tauri-drag-region className="h-11 w-full shrink-0" />
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-11 w-full shrink-0 items-center justify-end pr-2"
|
||||
>
|
||||
{withNavigate ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.history.back()}
|
||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-neutral-200 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.history.forward()}
|
||||
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-neutral-200 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<ArrowRightIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
|
@ -45,6 +45,21 @@ export function formatCreatedAt(time: number, message = false) {
|
||||
return formated;
|
||||
}
|
||||
|
||||
export function displayNsec(key: string, len: number) {
|
||||
if (key.length <= len) return key;
|
||||
|
||||
const separator = " ... ";
|
||||
|
||||
const sepLen = separator.length;
|
||||
const charsToShow = len - sepLen;
|
||||
const frontChars = Math.ceil(charsToShow / 2);
|
||||
const backChars = Math.floor(charsToShow / 2);
|
||||
|
||||
return (
|
||||
key.substr(0, frontChars) + separator + key.substr(key.length - backChars)
|
||||
);
|
||||
}
|
||||
|
||||
export function displayNpub(pubkey: string, len: number) {
|
||||
const npub = pubkey.startsWith("npub1")
|
||||
? pubkey
|
||||
|
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
@ -87,6 +87,9 @@ importers:
|
||||
'@radix-ui/react-popover':
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-switch':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@tanstack/query-sync-storage-persister':
|
||||
specifier: ^5.28.4
|
||||
version: 5.28.4
|
||||
@ -1998,6 +2001,33 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@babel/runtime': 7.24.0
|
||||
'@radix-ui/primitive': 1.0.1
|
||||
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.66)(react@18.2.0)
|
||||
'@radix-ui/react-context': 1.0.1(@types/react@18.2.66)(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.66)(react@18.2.0)
|
||||
'@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.66)(react@18.2.0)
|
||||
'@radix-ui/react-use-size': 1.0.1(@types/react@18.2.66)(react@18.2.0)
|
||||
'@types/react': 18.2.66
|
||||
'@types/react-dom': 18.2.22
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-tooltip@1.0.7(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==}
|
||||
peerDependencies:
|
||||
|
@ -41,10 +41,6 @@ fn main() {
|
||||
|
||||
// Add some bootstrap relays
|
||||
// #TODO: Pull bootstrap relays from user's settings
|
||||
client
|
||||
.add_relay("wss://nostr.mutinywallet.com")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
client
|
||||
.add_relay("wss://relay.nostr.band")
|
||||
.await
|
||||
|
@ -141,9 +141,6 @@ pub async fn load_selected_account(npub: &str, state: State<'_, Nostr>) -> Resul
|
||||
}
|
||||
}
|
||||
|
||||
// #TODO
|
||||
// Subscribe new event for activity and local newsfeed
|
||||
|
||||
Ok(true)
|
||||
} else {
|
||||
Err("nsec not found".into())
|
||||
|
@ -3,6 +3,7 @@ use keyring::Entry;
|
||||
use nostr_sdk::prelude::*;
|
||||
use std::{str::FromStr, time::Duration};
|
||||
use tauri::State;
|
||||
use url::Url;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct CacheContact {
|
||||
@ -99,22 +100,31 @@ pub async fn create_profile(
|
||||
lud16: &str,
|
||||
website: &str,
|
||||
state: State<'_, Nostr>,
|
||||
) -> Result<EventId, ()> {
|
||||
) -> Result<EventId, String> {
|
||||
let client = &state.client;
|
||||
let metadata = Metadata::new()
|
||||
let mut metadata = Metadata::new()
|
||||
.name(name)
|
||||
.display_name(display_name)
|
||||
.about(about)
|
||||
.nip05(nip05)
|
||||
.lud16(lud16)
|
||||
.picture(Url::parse(picture).unwrap())
|
||||
.banner(Url::parse(banner).unwrap())
|
||||
.website(Url::parse(website).unwrap());
|
||||
.lud16(lud16);
|
||||
|
||||
if let Ok(url) = Url::parse(picture) {
|
||||
metadata = metadata.picture(url)
|
||||
}
|
||||
|
||||
if let Ok(url) = Url::parse(banner) {
|
||||
metadata = metadata.banner(url)
|
||||
}
|
||||
|
||||
if let Ok(url) = Url::parse(website) {
|
||||
metadata = metadata.website(url)
|
||||
}
|
||||
|
||||
if let Ok(event_id) = client.set_metadata(&metadata).await {
|
||||
Ok(event_id)
|
||||
} else {
|
||||
Err(())
|
||||
Err("Create profile failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
@ -230,7 +240,7 @@ pub async fn set_settings(content: &str, state: State<'_, Nostr>) -> Result<Even
|
||||
if let Ok(event_id) = client.send_event_builder(builder).await {
|
||||
Ok(event_id)
|
||||
} else {
|
||||
Err("Set interest failed".into())
|
||||
Err("Set settings failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user