feat: add login screen

This commit is contained in:
reya
2024-07-23 14:18:40 +07:00
parent 462837565e
commit 7cd5f06122
22 changed files with 837 additions and 27 deletions

View File

@@ -1,3 +1,92 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
font-size: 14px;
}
a {
@apply cursor-default no-underline !important;
}
button {
@apply cursor-default focus:outline-none;
}
input::-ms-reveal,
input::-ms-clear {
display: none;
}
::-webkit-input-placeholder {
line-height: normal;
}
.spinner-leaf {
position: absolute;
top: 0;
left: calc(50% - 12.5% / 2);
width: 12.5%;
height: 100%;
animation: spinner-leaf-fade 800ms linear infinite;
&::before {
content: "";
display: block;
width: 100%;
height: 30%;
background-color: currentColor;
@apply rounded;
}
&:where(:nth-child(1)) {
transform: rotate(0deg);
animation-delay: -800ms;
}
&:where(:nth-child(2)) {
transform: rotate(45deg);
animation-delay: -700ms;
}
&:where(:nth-child(3)) {
transform: rotate(90deg);
animation-delay: -600ms;
}
&:where(:nth-child(4)) {
transform: rotate(135deg);
animation-delay: -500ms;
}
&:where(:nth-child(5)) {
transform: rotate(180deg);
animation-delay: -400ms;
}
&:where(:nth-child(6)) {
transform: rotate(225deg);
animation-delay: -300ms;
}
&:where(:nth-child(7)) {
transform: rotate(270deg);
animation-delay: -200ms;
}
&:where(:nth-child(8)) {
transform: rotate(315deg);
animation-delay: -100ms;
}
}
@keyframes spinner-leaf-fade {
from {
opacity: 1;
}
to {
opacity: 0.25;
}
}

23
src/commons.ts Normal file
View File

@@ -0,0 +1,23 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function npub(pubkey: string, len: number) {
if (pubkey.length <= len) return pubkey;
const separator = " ... ";
const sepLen = separator.length;
const charsToShow = len - sepLen;
const frontChars = Math.ceil(charsToShow / 2);
const backChars = Math.floor(charsToShow / 2);
return (
pubkey.substring(0, frontChars) +
separator +
pubkey.substring(pubkey.length - backChars)
);
}

View File

@@ -0,0 +1,47 @@
import { cn } from "@/commons";
import type { ReactNode } from "react";
export function Spinner({
children,
className,
}: {
children?: ReactNode;
className?: string;
}) {
const spinner = (
<span className={cn("block relative opacity-65 size-4", className)}>
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
<span className="spinner-leaf" />
</span>
);
if (children === undefined) return spinner;
return (
<div className="relative flex items-center justify-center">
<span>
{/**
* `display: contents` removes the content from the accessibility tree in some browsers,
* so we force remove it with `aria-hidden`
*/}
<span
aria-hidden
style={{ display: "contents", visibility: "hidden" }}
// biome-ignore lint/correctness/noConstantCondition: Workaround to use `inert` until https://github.com/facebook/react/pull/24730 is merged.
{...{ inert: true ? "" : undefined }}
>
{children}
</span>
<div className="absolute flex items-center justify-center">
<span>{spinner}</span>
</div>
</span>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { cn } from "@/commons";
import { useUserContext } from "./provider";
export function UserAbout({ className }: { className?: string }) {
const user = useUserContext();
return (
<div className={cn("content-break select-text", className)}>
{user.profile?.about?.trim() || "No bio"}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { cn } from "@/commons";
import * as Avatar from "@radix-ui/react-avatar";
import { minidenticon } from "minidenticons";
import { useMemo } from "react";
import { useUserContext } from "./provider";
export function UserAvatar({ className }: { className?: string }) {
const user = useUserContext();
const fallback = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(user.pubkey, 60, 50),
)}`,
[user.pubkey],
);
return (
<Avatar.Root
className={cn(
"shrink-0 block overflow-hidden bg-neutral-200 dark:bg-neutral-800",
className,
)}
>
<Avatar.Image
src={user.profile?.picture}
alt={user.pubkey}
loading="lazy"
decoding="async"
className="w-full aspect-square object-cover outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/>
<Avatar.Fallback>
<img
src={fallback}
alt={user.pubkey}
className="size-full bg-black dark:bg-white outline-[.5px] outline-black/5 content-visibility-auto contain-intrinsic-size-[auto]"
/>
</Avatar.Fallback>
</Avatar.Root>
);
}

View File

@@ -0,0 +1,36 @@
import { cn } from "@/commons";
import { useUserContext } from "./provider";
export function UserCover({ className }: { className?: string }) {
const user = useUserContext();
if (!user) {
return (
<div
className={cn(
"animate-pulse bg-neutral-300 dark:bg-neutral-700",
className,
)}
/>
);
}
if (user && !user.profile?.banner) {
return (
<div
className={cn("bg-gradient-to-b from-blue-400 to-teal-200", className)}
/>
);
}
return (
<img
src={user?.profile?.banner}
alt="banner"
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className={cn("object-cover", className)}
/>
);
}

View File

@@ -0,0 +1,15 @@
import { UserAbout } from "./about";
import { UserAvatar } from "./avatar";
import { UserCover } from "./cover";
import { UserName } from "./name";
import { UserProvider } from "./provider";
import { UserRoot } from "./root";
export const User = {
Provider: UserProvider,
Root: UserRoot,
Avatar: UserAvatar,
Cover: UserCover,
Name: UserName,
About: UserAbout,
};

View File

@@ -0,0 +1,21 @@
import { cn, npub } from "@/commons";
import { useUserContext } from "./provider";
export function UserName({
className,
prefix,
}: {
className?: string;
prefix?: string;
}) {
const user = useUserContext();
return (
<div className={cn("max-w-[12rem] truncate", className)}>
{prefix}
{user.profile?.display_name ||
user.profile?.name ||
npub(user.pubkey, 16)}
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { useQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { type ReactNode, createContext, useContext } from "react";
type Metadata = {
name?: string;
display_name?: string;
about?: string;
website?: string;
picture?: string;
banner?: string;
nip05?: string;
lud06?: string;
lud16?: string;
};
type UserContext = {
pubkey: string;
isLoading: boolean;
isError: boolean;
profile: Metadata | undefined;
};
const UserContext = createContext<UserContext>(null);
export function UserProvider({
pubkey,
children,
}: {
pubkey: string;
children: ReactNode;
}) {
const {
isLoading,
isError,
data: profile,
} = useQuery({
queryKey: ["profile", pubkey],
queryFn: async () => {
try {
const normalizePubkey = pubkey
.replace("nostr:", "")
.replace(/[^\w\s]/gi, "");
const query: string = await invoke("get_profile", {
id: normalizePubkey,
});
return JSON.parse(query) as Metadata;
} catch (e) {
throw new Error(String(e));
}
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
});
return (
<UserContext.Provider value={{ pubkey, profile, isError, isLoading }}>
{children}
</UserContext.Provider>
);
}
export function useUserContext() {
const context = useContext(UserContext);
return context;
}

View File

@@ -0,0 +1,12 @@
import { cn } from "@/commons";
import type { ReactNode } from "react";
export function UserRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={cn(className)}>{children}</div>;
}

View File

@@ -3,11 +3,18 @@ import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import "./app.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Import the generated route tree
import { routeTree } from "./routes.gen";
// Create a new router instance
const router = createRouter({ routeTree });
const queryClient = new QueryClient();
const router = createRouter({
routeTree,
context: {
queryClient,
},
});
// Register the router instance for type safety
declare module "@tanstack/react-router" {
@@ -22,7 +29,9 @@ if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<RouterProvider router={router} />
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);
}

View File

@@ -13,17 +13,29 @@ import { createFileRoute } from '@tanstack/react-router'
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as IndexImport } from './routes/index'
import { Route as AccountChatsImport } from './routes/$account.chats'
// Create Virtual Routes
const IndexLazyImport = createFileRoute('/')()
const NewLazyImport = createFileRoute('/new')()
// Create/Update Routes
const IndexLazyRoute = IndexLazyImport.update({
const NewLazyRoute = NewLazyImport.update({
path: '/new',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/new.lazy').then((d) => d.Route))
const IndexRoute = IndexImport.update({
path: '/',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
} as any)
const AccountChatsRoute = AccountChatsImport.update({
path: '/$account/chats',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
@@ -33,7 +45,21 @@ declare module '@tanstack/react-router' {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexLazyImport
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/new': {
id: '/new'
path: '/new'
fullPath: '/new'
preLoaderRoute: typeof NewLazyImport
parentRoute: typeof rootRoute
}
'/$account/chats': {
id: '/$account/chats'
path: '/$account/chats'
fullPath: '/$account/chats'
preLoaderRoute: typeof AccountChatsImport
parentRoute: typeof rootRoute
}
}
@@ -41,7 +67,11 @@ declare module '@tanstack/react-router' {
// Create and export the route tree
export const routeTree = rootRoute.addChildren({ IndexLazyRoute })
export const routeTree = rootRoute.addChildren({
IndexRoute,
NewLazyRoute,
AccountChatsRoute,
})
/* prettier-ignore-end */
@@ -51,11 +81,19 @@ export const routeTree = rootRoute.addChildren({ IndexLazyRoute })
"__root__": {
"filePath": "__root.tsx",
"children": [
"/"
"/",
"/new",
"/$account/chats"
]
},
"/": {
"filePath": "index.lazy.tsx"
"filePath": "index.tsx"
},
"/new": {
"filePath": "new.lazy.tsx"
},
"/$account/chats": {
"filePath": "$account.chats.tsx"
}
}
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/$account/chats')({
component: () => <div>Hello /$account/chats!</div>
})

View File

@@ -1,5 +1,10 @@
import { Outlet, createRootRoute } from "@tanstack/react-router";
import type { QueryClient } from "@tanstack/react-query";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
export const Route = createRootRoute({
interface RouterContext {
queryClient: QueryClient;
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />,
});

View File

@@ -1,5 +0,0 @@
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/')({
component: () => <div>Hello /!</div>
})

111
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,111 @@
import { npub } from "@/commons";
import { Spinner } from "@/components/spinner";
import { User } from "@/components/user";
import { Plus } from "@phosphor-icons/react";
import { Link, createFileRoute, redirect } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { useMemo, useState } from "react";
export const Route = createFileRoute("/")({
beforeLoad: async () => {
const accounts: string[] = await invoke("get_accounts");
if (!accounts.length) {
throw redirect({
to: "/new",
replace: true,
});
}
return { accounts };
},
component: Screen,
});
function Screen() {
const context = Route.useRouteContext();
const navigate = Route.useNavigate();
const currentDate = useMemo(
() =>
new Date().toLocaleString("default", {
weekday: "long",
month: "long",
day: "numeric",
}),
[],
);
const [loading, setLoading] = useState({ npub: "", status: false });
const login = async (npub: string) => {
try {
setLoading({ npub, status: true });
const status = await invoke("login", { id: npub });
if (status) {
return navigate({
to: "/$account/chats",
params: { account: npub },
replace: true,
});
}
} catch (e) {
setLoading({ npub: "", status: false });
}
};
return (
<div className="size-full flex items-center justify-center">
<div className="w-[320px] flex flex-col gap-8">
<div className="flex flex-col gap-1 text-center">
<h3 className="leading-tight text-neutral-700 dark:text-neutral-300">
{currentDate}
</h3>
<h1 className="leading-tight text-xl font-semibold">Welcome back!</h1>
</div>
<div className="flex flex-col w-full bg-white divide-y divide-neutral-100 dark:divide-white/5 rounded-xl shadow-lg shadow-neutral-500/10 dark:shadow-none dark:bg-white/10 dark:ring-1 dark:ring-white/5">
{context.accounts.map((account) => (
<div
key={account}
onClick={() => login(account)}
onKeyDown={() => login(account)}
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
>
<User.Provider pubkey={account}>
<User.Root className="flex items-center gap-2.5 p-3">
<User.Avatar className="rounded-full size-10" />
<div className="inline-flex flex-col items-start">
<User.Name className="max-w-[6rem] truncate font-medium leading-tight" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{npub(account, 16)}
</span>
</div>
</User.Root>
</User.Provider>
<div className="inline-flex items-center justify-center size-10">
{loading.npub === account && loading.status ? (
<Spinner />
) : null}
</div>
</div>
))}
<Link
to="/new"
className="flex items-center justify-between hover:bg-black/5 dark:hover:bg-white/5"
>
<div className="flex items-center gap-2.5 p-3">
<div className="inline-flex items-center justify-center rounded-full size-10 bg-neutral-200 dark:bg-white/10">
<Plus className="size-5" />
</div>
<span className="max-w-[6rem] truncate text-sm font-medium leading-tight">
Add an account
</span>
</div>
</Link>
</div>
</div>
</div>
);
}

5
src/routes/new.lazy.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/new')({
component: () => <div>Hello /new!</div>
})