feat: add login screen
This commit is contained in:
89
src/App.css
89
src/App.css
@@ -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
23
src/commons.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
47
src/components/spinner.tsx
Normal file
47
src/components/spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/components/user/about.tsx
Normal file
12
src/components/user/about.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/user/avatar.tsx
Normal file
40
src/components/user/avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/user/cover.tsx
Normal file
36
src/components/user/cover.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
src/components/user/index.ts
Normal file
15
src/components/user/index.ts
Normal 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,
|
||||
};
|
||||
21
src/components/user/name.tsx
Normal file
21
src/components/user/name.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
src/components/user/provider.tsx
Normal file
71
src/components/user/provider.tsx
Normal 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;
|
||||
}
|
||||
12
src/components/user/root.tsx
Normal file
12
src/components/user/root.tsx
Normal 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>;
|
||||
}
|
||||
13
src/main.tsx
13
src/main.tsx
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
src/routes/$account.chats.tsx
Normal file
5
src/routes/$account.chats.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/$account/chats')({
|
||||
component: () => <div>Hello /$account/chats!</div>
|
||||
})
|
||||
@@ -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 />,
|
||||
});
|
||||
|
||||
@@ -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
111
src/routes/index.tsx
Normal 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
5
src/routes/new.lazy.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createLazyFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createLazyFileRoute('/new')({
|
||||
component: () => <div>Hello /new!</div>
|
||||
})
|
||||
Reference in New Issue
Block a user