chore: monorepo

This commit is contained in:
2023-12-25 14:28:39 +07:00
parent a6da07cd3f
commit 227c2ddefa
374 changed files with 19966 additions and 12758 deletions

32
packages/ui/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@lume/ui",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"dependencies": {
"@lume/ark": "workspace:^",
"@lume/icons": "workspace:^",
"@lume/utils": "workspace:^",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@tanstack/react-query": "^5.14.2",
"@tauri-apps/api": "2.0.0-alpha.11",
"@tauri-apps/plugin-http": "2.0.0-alpha.3",
"@tauri-apps/plugin-os": "2.0.0-alpha.4",
"minidenticons": "^4.2.0",
"react": "^18.2.0",
"react-router-dom": "^6.21.0",
"sonner": "^1.2.4"
},
"devDependencies": {
"@lume/tailwindcss": "workspace:^",
"@lume/tsconfig": "workspace:^",
"@lume/types": "workspace:^",
"@types/react": "^18.2.45",
"tailwind-merge": "^1.14.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,49 @@
import { useProfile, useStorage } from "@lume/ark";
import { useNetworkStatus } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar";
import { minidenticon } from "minidenticons";
import { Link } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { AccountMoreActions } from "./more";
export function ActiveAccount() {
const storage = useStorage();
const isOnline = useNetworkStatus();
const { user } = useProfile(storage.account.pubkey);
const svgURI = `data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(storage.account.pubkey, 90, 50),
)}`;
return (
<div className="flex flex-col gap-1 rounded-xl bg-black/10 p-1 ring-1 ring-transparent hover:bg-black/20 hover:ring-blue-500 dark:bg-white/10 dark:hover:bg-white/20">
<Link to="/settings/" className="relative inline-block">
<Avatar.Root>
<Avatar.Image
src={user?.picture || user?.image}
alt={storage.account.pubkey}
loading="lazy"
decoding="async"
style={{ contentVisibility: "auto" }}
className="aspect-square h-auto w-full rounded-lg object-cover"
/>
<Avatar.Fallback delayMs={150}>
<img
src={svgURI}
alt={storage.account.pubkey}
className="aspect-square h-auto w-full rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<span
className={twMerge(
"absolute bottom-0 right-0 block h-2 w-2 rounded-full ring-2 ring-neutral-100 dark:ring-neutral-900",
isOnline ? "bg-teal-500" : "bg-red-500",
)}
/>
</Link>
<AccountMoreActions />
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { useStorage } from "@lume/ark";
import * as AlertDialog from "@radix-ui/react-alert-dialog";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
export function Logout() {
const storage = useStorage();
const navigate = useNavigate();
const queryClient = useQueryClient();
const logout = async () => {
try {
// logout
await storage.logout();
// clear cache
queryClient.clear();
// redirect to welcome screen
navigate("/auth/welcome");
} catch (e) {
toast.error(e);
}
};
return (
<AlertDialog.Root>
<AlertDialog.Trigger asChild>
<button
type="button"
className="inline-flex h-10 items-center rounded-lg px-2 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none"
>
Logout
</button>
</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
<AlertDialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-md rounded-xl bg-neutral-100 dark:bg-neutral-900">
<div className="flex flex-col gap-1 border-b border-white/5 px-5 py-4">
<AlertDialog.Title className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Are you sure!
</AlertDialog.Title>
<AlertDialog.Description className="text-sm leading-tight text-neutral-600 dark:text-neutral-400">
You can always log back in at any time. If you just want to
switch accounts, you can do that by adding an existing account.
</AlertDialog.Description>
</div>
<div className="flex justify-end gap-2 px-5 py-3">
<AlertDialog.Cancel asChild>
<button
type="button"
className="inline-flex h-9 items-center justify-center rounded-lg px-4 text-sm font-medium text-neutral-900 outline-none hover:bg-neutral-200 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
Cancel
</button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<button
type="button"
onClick={() => logout()}
className="inline-flex h-9 items-center justify-center rounded-lg bg-red-500 px-4 text-sm font-medium text-white outline-none hover:bg-red-600"
>
Logout
</button>
</AlertDialog.Action>
</div>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
);
}

View File

@@ -0,0 +1,32 @@
import { HorizontalDotsIcon } from "@lume/icons";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { Link } from "react-router-dom";
import { Logout } from "./logout";
export function AccountMoreActions() {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="inline-flex items-center justify-center rounded-md"
>
<HorizontalDotsIcon className="h-4 w-4" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="ml-2 flex w-[200px] flex-col overflow-hidden rounded-xl bg-blue-500 p-2 focus:outline-none">
<DropdownMenu.Item asChild>
<Link
to="/settings/"
className="inline-flex h-10 items-center rounded-lg px-2 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none"
>
Settings
</Link>
</DropdownMenu.Item>
<Logout />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

12
packages/ui/src/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export * from "./account/active";
export * from "./account/logout";
export * from "./account/more";
export * from "./navigation";
export * from "./nip05";
export * from "./user";
export * from "./titlebar";
export * from "./layouts/app";
export * from "./layouts/auth";
export * from "./layouts/composer";
export * from "./layouts/home";
export * from "./layouts/settings";

View File

@@ -0,0 +1,28 @@
import { type Platform } from "@tauri-apps/plugin-os";
import { Outlet } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { Navigation } from "../navigation";
import { WindowTitleBar } from "../titlebar";
export function AppLayout({ platform }: { platform: Platform }) {
return (
<div
className={twMerge(
"flex h-screen w-screen flex-col",
platform !== "macos" ? "bg-blue-50 dark:bg-blue-950" : "",
)}
>
{platform !== "macos" ? (
<WindowTitleBar platform={platform} />
) : (
<div data-tauri-drag-region className="h-9 shrink-0" />
)}
<div className="flex h-full min-h-0 w-full">
<Navigation />
<div className="h-full flex-1 px-1 pb-1">
<Outlet />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { type Platform } from "@tauri-apps/plugin-os";
import { Outlet, ScrollRestoration } from "react-router-dom";
import { WindowTitleBar } from "../titlebar";
export function AuthLayout({ platform }: { platform: Platform }) {
return (
<div className="flex h-screen w-screen flex-col bg-neutral-50 dark:bg-neutral-950">
{platform !== "macos" ? (
<WindowTitleBar platform={platform} />
) : (
<div data-tauri-drag-region className="h-9 shrink-0" />
)}
<div className="h-full w-full">
<Outlet />
<ScrollRestoration />
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
export function ComposerLayout() {
const location = useLocation();
return (
<div className="container mx-auto h-full px-8 pt-8">
<div className="mb-8 flex h-10 shrink-0 items-center gap-3">
{location.pathname !== '/new/privkey' ? (
<div className="flex h-10 items-center gap-2 rounded-lg bg-neutral-100 px-0.5 dark:bg-neutral-800">
<NavLink
to="/new/"
end
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
Post
</NavLink>
<NavLink
to="/new/article"
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
Article
</NavLink>
<NavLink
to="/new/file"
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-28 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
File Sharing
</NavLink>
</div>
) : null}
</div>
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { Outlet } from 'react-router-dom';
export function HomeLayout() {
return (
<div className="h-full w-full rounded-xl overflow-hidden bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-[inset_0_0_0.5px_1px_hsla(0,0%,100%,0.075),0_0_0_1px_hsla(0,0%,0%,0.05),0_0.3px_0.4px_hsla(0,0%,0%,0.02),0_0.9px_1.5px_hsla(0,0%,0%,0.045),0_3.5px_6px_hsla(0,0%,0%,0.09)]">
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,92 @@
import {
AdvancedSettingsIcon,
InfoIcon,
SecureIcon,
SettingsIcon,
UserIcon,
} from "@lume/icons";
import { NavLink, Outlet } from "react-router-dom";
import { twMerge } from "tailwind-merge";
export function SettingsLayout() {
return (
<div className="flex h-full min-h-0 w-full flex-col gap-8 overflow-y-auto">
<div className="flex h-24 w-full items-center justify-center border-b border-neutral-200 px-2 dark:border-neutral-900">
<div className="flex items-center gap-0.5">
<NavLink
to="/settings/"
end
className={({ isActive }) =>
twMerge(
"flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900",
isActive
? "bg-neutral-100 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-900"
: "",
)
}
>
<UserIcon className="h-6 w-6" />
<p className="text-sm font-medium">User</p>
</NavLink>
<NavLink
to="/settings/general"
className={({ isActive }) =>
twMerge(
"flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900",
isActive
? "bg-neutral-100 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-900"
: "",
)
}
>
<SettingsIcon className="h-6 w-6" />
<p className="text-sm font-medium">General</p>
</NavLink>
<NavLink
to="/settings/backup"
className={({ isActive }) =>
twMerge(
"flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900",
isActive
? "bg-neutral-100 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-900"
: "",
)
}
>
<SecureIcon className="h-6 w-6" />
<p className="text-sm font-medium">Backup</p>
</NavLink>
<NavLink
to="/settings/advanced"
className={({ isActive }) =>
twMerge(
"flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900",
isActive
? "bg-neutral-100 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-900"
: "",
)
}
>
<AdvancedSettingsIcon className="h-6 w-6" />
<p className="text-sm font-medium">Advanced</p>
</NavLink>
<NavLink
to="/settings/about"
className={({ isActive }) =>
twMerge(
"flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900",
isActive
? "bg-neutral-100 text-blue-500 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-900"
: "",
)
}
>
<InfoIcon className="h-6 w-6" />
<p className="text-sm font-medium">About</p>
</NavLink>
</div>
</div>
<Outlet />
</div>
);
}

View File

@@ -0,0 +1,155 @@
import {
DepotIcon,
HomeIcon,
NwcIcon,
PlusIcon,
RelayIcon,
SearchIcon,
} from "@lume/icons";
import { Link, NavLink } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { ActiveAccount } from "./account/active";
export function Navigation() {
return (
<div className="flex h-full w-20 shrink-0 flex-col justify-between px-4 py-3">
<div className="flex flex-1 flex-col gap-5">
<NavLink
to="/"
preventScrollReset={true}
className="inline-flex flex-col items-center justify-center"
>
{({ isActive }) => (
<>
<div
className={twMerge(
"inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl",
isActive
? "bg-black/10 text-black dark:bg-white/10 dark:text-white"
: "text-black/50 dark:text-neutral-400",
)}
>
<HomeIcon className="h-6 w-6" />
</div>
<div
className={twMerge(
"text-sm",
isActive
? "font-semibold text-black dark:text-white"
: "font-medium text-black/50 dark:text-white/50",
)}
>
Home
</div>
</>
)}
</NavLink>
<NavLink
to="/relays"
preventScrollReset={true}
className="inline-flex flex-col items-center justify-center"
>
{({ isActive }) => (
<>
<div
className={twMerge(
"inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl",
isActive
? "bg-black/10 text-black dark:bg-white/10 dark:text-white"
: "text-black/50 dark:text-neutral-400",
)}
>
<RelayIcon className="h-6 w-6" />
</div>
<div
className={twMerge(
"text-sm",
isActive
? "font-semibold text-black dark:text-white"
: "font-medium text-black/50 dark:text-white/50",
)}
>
Relays
</div>
</>
)}
</NavLink>
<NavLink
to="/depot"
preventScrollReset={true}
className="inline-flex flex-col items-center justify-center"
>
{({ isActive }) => (
<>
<div
className={twMerge(
"inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl",
isActive
? "bg-black/10 text-black dark:bg-white/10 dark:text-white"
: "text-black/50 dark:text-neutral-400",
)}
>
<DepotIcon className="h-6 w-6" />
</div>
<div
className={twMerge(
"text-sm",
isActive
? "font-semibold text-black dark:text-white"
: "font-medium text-black/50 dark:text-white/50",
)}
>
Depot
</div>
</>
)}
</NavLink>
<NavLink
to="/nwc"
preventScrollReset={true}
className="inline-flex flex-col items-center justify-center"
>
{({ isActive }) => (
<>
<div
className={twMerge(
"inline-flex aspect-square h-auto w-full items-center justify-center rounded-xl",
isActive
? "bg-black/10 text-black dark:bg-white/10 dark:text-white"
: "text-black/50 dark:text-neutral-400",
)}
>
<NwcIcon className="h-6 w-6" />
</div>
<div
className={twMerge(
"text-sm",
isActive
? "font-semibold text-black dark:text-white"
: "font-medium text-black/50 dark:text-white/50",
)}
>
Wallet
</div>
</>
)}
</NavLink>
</div>
<div className="flex shrink-0 flex-col gap-3 p-1">
<Link
to="/new/"
className="flex aspect-square h-auto w-full items-center justify-center rounded-xl bg-black/10 text-black hover:bg-blue-500 hover:text-white dark:bg-white/10 dark:text-white dark:hover:bg-blue-500"
>
<PlusIcon className="h-5 w-5" />
</Link>
<Link
to="/nwc"
className="flex aspect-square h-auto w-full items-center justify-center rounded-xl bg-black/10 hover:bg-blue-500 hover:text-white dark:bg-white/10 dark:hover:bg-blue-500"
>
<SearchIcon className="h-5 w-5" />
</Link>
<ActiveAccount />
</div>
</div>
);
}

74
packages/ui/src/nip05.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { UnverifiedIcon, VerifiedIcon } from "@lume/icons";
import { useQuery } from "@tanstack/react-query";
import { fetch } from "@tauri-apps/plugin-http";
import { memo } from "react";
import { twMerge } from "tailwind-merge";
interface NIP05 {
names: {
[key: string]: string;
};
}
export const NIP05 = memo(function NIP05({
pubkey,
nip05,
className,
}: {
pubkey: string;
nip05: string;
className?: string;
}) {
const { status, data } = useQuery({
queryKey: ["nip05", nip05],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
try {
const localPath = nip05.split("@")[0];
const service = nip05.split("@")[1];
const verifyURL = `https://${service}/.well-known/nostr.json?name=${localPath}`;
const res = await fetch(verifyURL, {
method: "GET",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
signal,
});
if (!res.ok)
throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
const data: NIP05 = await res.json();
if (data.names) {
if (data.names[localPath.toLowerCase()] === pubkey) return true;
if (data.names[localPath] === pubkey) return true;
return false;
}
return false;
} catch (e) {
throw new Error(`Failed to verify NIP-05, error: ${e}`);
}
},
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
});
if (status === "pending") {
<div className="h-4 w-4 animate-pulse rounded-full bg-neutral-100 dark:bg-neutral-900" />;
}
return (
<div className="inline-flex items-center gap-1">
<p className={twMerge("text-sm font-medium", className)}>
{nip05.startsWith("_@") ? nip05.replace("_@", "") : nip05}
</p>
{data === true ? (
<VerifiedIcon className="h-4 w-4 text-teal-500" />
) : (
<UnverifiedIcon className="h-4 w-4 text-red-500" />
)}
</div>
);
});

View File

@@ -0,0 +1,21 @@
import type { ButtonHTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
export function WindowButton({
className,
children,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type="button"
className={twMerge(
"inline-flex cursor-default items-center justify-center",
className,
)}
{...props}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,140 @@
import type { SVGProps } from 'react';
export const WindowIcons = {
minimizeWin: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
width="10"
height="1"
viewBox="0 0 10 1"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M0.498047 1.00098C0.429688 1.00098 0.364583 0.987956 0.302734 0.961914C0.244141 0.935872 0.192057 0.900065 0.146484 0.854492C0.100911 0.808919 0.0651042 0.756836 0.0390625 0.698242C0.0130208 0.636393 0 0.571289 0 0.50293C0 0.43457 0.0130208 0.371094 0.0390625 0.3125C0.0651042 0.250651 0.100911 0.19694 0.146484 0.151367C0.192057 0.102539 0.244141 0.0651042 0.302734 0.0390625C0.364583 0.0130208 0.429688 0 0.498047 0H9.50195C9.57031 0 9.63379 0.0130208 9.69238 0.0390625C9.75423 0.0651042 9.80794 0.102539 9.85352 0.151367C9.89909 0.19694 9.9349 0.250651 9.96094 0.3125C9.98698 0.371094 10 0.43457 10 0.50293C10 0.571289 9.98698 0.636393 9.96094 0.698242C9.9349 0.756836 9.89909 0.808919 9.85352 0.854492C9.80794 0.900065 9.75423 0.935872 9.69238 0.961914C9.63379 0.987956 9.57031 1.00098 9.50195 1.00098H0.498047Z"
fill="currentColor"
fillOpacity="0.8956"
/>
</svg>
),
maximizeWin: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M1.47461 10.001C1.2793 10.001 1.09212 9.96191 0.913086 9.88379C0.734049 9.80241 0.576172 9.69499 0.439453 9.56152C0.30599 9.4248 0.198568 9.26693 0.117188 9.08789C0.0390625 8.90885 0 8.72168 0 8.52637V1.47559C0 1.28027 0.0390625 1.0931 0.117188 0.914062C0.198568 0.735026 0.30599 0.578776 0.439453 0.445312C0.576172 0.308594 0.734049 0.201172 0.913086 0.123047C1.09212 0.0416667 1.2793 0.000976562 1.47461 0.000976562H8.52539C8.7207 0.000976562 8.90788 0.0416667 9.08691 0.123047C9.26595 0.201172 9.4222 0.308594 9.55566 0.445312C9.69238 0.578776 9.7998 0.735026 9.87793 0.914062C9.95931 1.0931 10 1.28027 10 1.47559V8.52637C10 8.72168 9.95931 8.90885 9.87793 9.08789C9.7998 9.26693 9.69238 9.4248 9.55566 9.56152C9.4222 9.69499 9.26595 9.80241 9.08691 9.88379C8.90788 9.96191 8.7207 10.001 8.52539 10.001H1.47461ZM8.50098 9C8.56934 9 8.63281 8.98698 8.69141 8.96094C8.75326 8.9349 8.80697 8.89909 8.85254 8.85352C8.89811 8.80794 8.93392 8.75586 8.95996 8.69727C8.986 8.63542 8.99902 8.57031 8.99902 8.50195V1.5C8.99902 1.43164 8.986 1.36816 8.95996 1.30957C8.93392 1.24772 8.89811 1.19401 8.85254 1.14844C8.80697 1.10286 8.75326 1.06706 8.69141 1.04102C8.63281 1.01497 8.56934 1.00195 8.50098 1.00195H1.49902C1.43066 1.00195 1.36556 1.01497 1.30371 1.04102C1.24512 1.06706 1.19303 1.10286 1.14746 1.14844C1.10189 1.19401 1.06608 1.24772 1.04004 1.30957C1.014 1.36816 1.00098 1.43164 1.00098 1.5V8.50195C1.00098 8.57031 1.014 8.63542 1.04004 8.69727C1.06608 8.75586 1.10189 8.80794 1.14746 8.85352C1.19303 8.89909 1.24512 8.9349 1.30371 8.96094C1.36556 8.98698 1.43066 9 1.49902 9H8.50098Z"
fill="currentColor"
fillOpacity="0.8956"
/>
</svg>
),
maximizeRestoreWin: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
width="10"
height="11"
viewBox="0 0 10 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M8.99902 2.98096C8.99902 2.71077 8.94531 2.45687 8.83789 2.21924C8.73047 1.97835 8.58398 1.77002 8.39844 1.59424C8.21615 1.4152 8.00293 1.27523 7.75879 1.17432C7.5179 1.07015 7.264 1.01807 6.99707 1.01807H2.08496C2.13704 0.868327 2.21029 0.731608 2.30469 0.60791C2.39909 0.484212 2.50814 0.378418 2.63184 0.290527C2.75553 0.202637 2.89062 0.135905 3.03711 0.090332C3.18685 0.0415039 3.34147 0.0170898 3.50098 0.0170898H6.99707C7.41048 0.0170898 7.79948 0.0968424 8.16406 0.256348C8.52865 0.412598 8.84603 0.625814 9.11621 0.895996C9.38965 1.16618 9.60449 1.48356 9.76074 1.84814C9.92025 2.21273 10 2.60173 10 3.01514V6.51611C10 6.67562 9.97559 6.83024 9.92676 6.97998C9.88118 7.12646 9.81445 7.26156 9.72656 7.38525C9.63867 7.50895 9.53288 7.618 9.40918 7.7124C9.28548 7.8068 9.14876 7.88005 8.99902 7.93213V2.98096ZM1.47461 10.0171C1.2793 10.0171 1.09212 9.97803 0.913086 9.8999C0.734049 9.81852 0.576172 9.7111 0.439453 9.57764C0.30599 9.44092 0.198568 9.28304 0.117188 9.104C0.0390625 8.92497 0 8.73779 0 8.54248V3.49365C0 3.29508 0.0390625 3.10791 0.117188 2.93213C0.198568 2.75309 0.30599 2.59684 0.439453 2.46338C0.576172 2.32666 0.732422 2.21924 0.908203 2.14111C1.08724 2.05973 1.27604 2.01904 1.47461 2.01904H6.52344C6.72201 2.01904 6.91081 2.05973 7.08984 2.14111C7.26888 2.21924 7.42513 2.32503 7.55859 2.4585C7.69206 2.59196 7.79785 2.74821 7.87598 2.92725C7.95736 3.10628 7.99805 3.29508 7.99805 3.49365V8.54248C7.99805 8.74105 7.95736 8.92985 7.87598 9.10889C7.79785 9.28467 7.69043 9.44092 7.55371 9.57764C7.42025 9.7111 7.264 9.81852 7.08496 9.8999C6.90918 9.97803 6.72201 10.0171 6.52344 10.0171H1.47461ZM6.49902 9.01611C6.56738 9.01611 6.63086 9.00309 6.68945 8.97705C6.7513 8.95101 6.80501 8.9152 6.85059 8.86963C6.89941 8.82406 6.93685 8.77197 6.96289 8.71338C6.98893 8.65153 7.00195 8.58643 7.00195 8.51807V3.51807C7.00195 3.44971 6.98893 3.3846 6.96289 3.32275C6.93685 3.2609 6.90104 3.20719 6.85547 3.16162C6.8099 3.11605 6.75618 3.08024 6.69434 3.0542C6.63249 3.02816 6.56738 3.01514 6.49902 3.01514H1.49902C1.43066 3.01514 1.36556 3.02816 1.30371 3.0542C1.24512 3.08024 1.19303 3.11768 1.14746 3.1665C1.10189 3.21208 1.06608 3.26579 1.04004 3.32764C1.014 3.38623 1.00098 3.44971 1.00098 3.51807V8.51807C1.00098 8.58643 1.014 8.65153 1.04004 8.71338C1.06608 8.77197 1.10189 8.82406 1.14746 8.86963C1.19303 8.9152 1.24512 8.95101 1.30371 8.97705C1.36556 9.00309 1.43066 9.01611 1.49902 9.01611H6.49902Z"
fill="currentColor"
fillOpacity="0.8956"
/>
</svg>
),
closeWin: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M5 5.70898L0.854492 9.85449C0.756836 9.95215 0.639648 10.001 0.50293 10.001C0.359701 10.001 0.239258 9.95378 0.141602 9.85938C0.0472005 9.76172 0 9.64128 0 9.49805C0 9.36133 0.0488281 9.24414 0.146484 9.14648L4.29199 5.00098L0.146484 0.855469C0.0488281 0.757812 0 0.638997 0 0.499023C0 0.430664 0.0130208 0.36556 0.0390625 0.303711C0.0651042 0.241862 0.100911 0.189779 0.146484 0.147461C0.192057 0.101888 0.245768 0.0660807 0.307617 0.0400391C0.369466 0.0139974 0.43457 0.000976562 0.50293 0.000976562C0.639648 0.000976562 0.756836 0.0498047 0.854492 0.147461L5 4.29297L9.14551 0.147461C9.24316 0.0498047 9.36198 0.000976562 9.50195 0.000976562C9.57031 0.000976562 9.63379 0.0139974 9.69238 0.0400391C9.75423 0.0660807 9.80794 0.101888 9.85352 0.147461C9.89909 0.193034 9.9349 0.246745 9.96094 0.308594C9.98698 0.367188 10 0.430664 10 0.499023C10 0.638997 9.95117 0.757812 9.85352 0.855469L5.70801 5.00098L9.85352 9.14648C9.95117 9.24414 10 9.36133 10 9.49805C10 9.56641 9.98698 9.63151 9.96094 9.69336C9.9349 9.75521 9.89909 9.80892 9.85352 9.85449C9.8112 9.90007 9.75911 9.93587 9.69727 9.96191C9.63542 9.98796 9.57031 10.001 9.50195 10.001C9.36198 10.001 9.24316 9.95215 9.14551 9.85449L5 5.70898Z"
fill="currentColor"
fillOpacity="0.8956"
/>
</svg>
),
closeMac: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
width="6"
height="6"
viewBox="0 0 16 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M15.7522 4.44381L11.1543 9.04165L15.7494 13.6368C16.0898 13.9771 16.078 14.5407 15.724 14.8947L13.8907 16.728C13.5358 17.0829 12.9731 17.0938 12.6328 16.7534L8.03766 12.1583L3.44437 16.7507C3.10402 17.091 2.54132 17.0801 2.18645 16.7253L0.273257 14.8121C-0.0807018 14.4572 -0.0925004 13.8945 0.247845 13.5542L4.84024 8.96087L0.32499 4.44653C-0.0153555 4.10619 -0.00355681 3.54258 0.350402 3.18862L2.18373 1.35529C2.53859 1.00042 3.1013 0.989533 3.44164 1.32988L7.95689 5.84422L12.5556 1.24638C12.8951 0.906035 13.4587 0.917833 13.8126 1.27179L15.7267 3.18589C16.0807 3.53985 16.0925 4.10346 15.7522 4.44381Z"
fill="currentColor"
/>
</svg>
),
minMac: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
width="8"
height="8"
viewBox="0 0 17 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_20_2051)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1.47211 1.18042H15.4197C15.8052 1.18042 16.1179 1.50551 16.1179 1.90769V3.73242C16.1179 4.13387 15.8052 4.80006 15.4197 4.80006H1.47211C1.08665 4.80006 0.773926 4.47497 0.773926 4.07278V1.90769C0.773926 1.50551 1.08665 1.18042 1.47211 1.18042Z"
fill="currentColor"
/>
</g>
</svg>
),
fullMac: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
width="6"
height="6"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_20_2057)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.53068 0.433838L15.0933 12.0409C15.0933 12.0409 15.0658 5.35028 15.0658 4.01784C15.0658 1.32095 14.1813 0.433838 11.5378 0.433838C10.6462 0.433838 3.53068 0.433838 3.53068 0.433838ZM12.4409 15.5378L0.87735 3.93073C0.87735 3.93073 0.905794 10.6214 0.905794 11.9538C0.905794 14.6507 1.79024 15.5378 4.43291 15.5378C5.32535 15.5378 12.4409 15.5378 12.4409 15.5378Z"
fill="currentColor"
/>
</g>
</svg>
),
plusMac: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
width="8"
height="8"
viewBox="0 0 17 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_20_2053)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.5308 9.80147H10.3199V15.0095C10.3199 15.3949 9.9941 15.7076 9.59265 15.7076H7.51555C7.11337 15.7076 6.78828 15.3949 6.78828 15.0095V9.80147H1.58319C1.19774 9.80147 0.88501 9.47638 0.88501 9.07419V6.90619C0.88501 6.50401 1.19774 6.17892 1.58319 6.17892H6.78828V1.06183C6.78828 0.676375 7.11337 0.363647 7.51555 0.363647H9.59265C9.9941 0.363647 10.3199 0.676375 10.3199 1.06183V6.17892H15.5308C15.9163 6.17892 16.229 6.50401 16.229 6.90619V9.07419C16.229 9.47638 15.9163 9.80147 15.5308 9.80147Z"
fill="currentColor"
/>
</g>
</svg>
),
};

View File

@@ -0,0 +1,115 @@
import { type Window, getCurrent } from "@tauri-apps/api/window";
import { type } from "@tauri-apps/plugin-os";
import React, { createContext, useCallback, useEffect, useState } from "react";
interface AppWindowContextType {
appWindow: Window | null;
isWindowMaximized: boolean;
minimizeWindow: () => Promise<void>;
maximizeWindow: () => Promise<void>;
fullscreenWindow: () => Promise<void>;
closeWindow: () => Promise<void>;
}
export const AppWindowContext = createContext<AppWindowContextType>({
appWindow: null,
isWindowMaximized: false,
minimizeWindow: () => Promise.resolve(),
maximizeWindow: () => Promise.resolve(),
fullscreenWindow: () => Promise.resolve(),
closeWindow: () => Promise.resolve(),
});
interface AppWindowProviderProps {
children: React.ReactNode;
}
export const AppWindowProvider: React.FC<AppWindowProviderProps> = ({
children,
}) => {
const [appWindow, setAppWindow] = useState<Window | null>(null);
const [isWindowMaximized, setIsWindowMaximized] = useState(false);
useEffect(() => {
const window = getCurrent();
setAppWindow(window);
}, []);
const updateIsWindowMaximized = useCallback(async () => {
if (appWindow) {
const _isWindowMaximized = await appWindow.isMaximized();
setIsWindowMaximized(_isWindowMaximized);
}
}, [appWindow]);
useEffect(() => {
let unlisten: () => void = () => {};
async function getOsType() {
const osname = await type();
if (osname !== "macos") {
updateIsWindowMaximized();
const listen = async () => {
if (appWindow) {
unlisten = await appWindow.onResized(() => {
updateIsWindowMaximized();
});
}
};
listen();
}
}
getOsType();
// Cleanup the listener when the component unmounts
return () => unlisten?.();
}, [appWindow, updateIsWindowMaximized]);
const minimizeWindow = async () => {
if (appWindow) {
await appWindow.minimize();
}
};
const maximizeWindow = async () => {
if (appWindow) {
await appWindow.toggleMaximize();
}
};
const fullscreenWindow = async () => {
if (appWindow) {
const fullscreen = await appWindow.isFullscreen();
if (fullscreen) {
await appWindow.setFullscreen(false);
} else {
await appWindow.setFullscreen(true);
}
}
};
const closeWindow = async () => {
if (appWindow) {
await appWindow.close();
}
};
return (
<AppWindowContext.Provider
value={{
appWindow,
isWindowMaximized,
minimizeWindow,
maximizeWindow,
fullscreenWindow,
closeWindow,
}}
>
{children}
</AppWindowContext.Provider>
);
};

View File

@@ -0,0 +1,43 @@
import { HTMLProps, useContext } from "react";
import { twMerge } from "tailwind-merge";
import { WindowButton } from "../components/button";
import { WindowIcons } from "../components/icons";
import { AppWindowContext } from "../context";
export function Gnome({ className, ...props }: HTMLProps<HTMLDivElement>) {
const { isWindowMaximized, minimizeWindow, maximizeWindow, closeWindow } =
useContext(AppWindowContext);
return (
<div
className={twMerge(
"mr-[10px] h-auto items-center space-x-[13px]",
className,
)}
{...props}
>
<WindowButton
onClick={minimizeWindow}
className="m-0 aspect-square h-6 w-6 cursor-default rounded-full bg-[#dadada] p-0 text-[#3d3d3d] hover:bg-[#d1d1d1] active:bg-[#bfbfbf] dark:bg-[#373737] dark:text-white dark:hover:bg-[#424242] dark:active:bg-[#565656]"
>
<WindowIcons.minimizeWin className="h-[9px] w-[9px]" />
</WindowButton>
<WindowButton
onClick={maximizeWindow}
className="m-0 aspect-square h-6 w-6 cursor-default rounded-full bg-[#dadada] p-0 text-[#3d3d3d] hover:bg-[#d1d1d1] active:bg-[#bfbfbf] dark:bg-[#373737] dark:text-white dark:hover:bg-[#424242] dark:active:bg-[#565656]"
>
{!isWindowMaximized ? (
<WindowIcons.maximizeWin className="h-2 w-2" />
) : (
<WindowIcons.maximizeRestoreWin className="h-[9px] w-[9px]" />
)}
</WindowButton>
<WindowButton
onClick={closeWindow}
className="m-0 aspect-square h-6 w-6 cursor-default rounded-full bg-[#dadada] p-0 text-[#3d3d3d] hover:bg-[#d1d1d1] active:bg-[#bfbfbf] dark:bg-[#373737] dark:text-white dark:hover:bg-[#424242] dark:active:bg-[#565656]"
>
<WindowIcons.closeWin className="h-2 w-2" />
</WindowButton>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { HTMLProps, useContext, useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import { WindowButton } from "../components/button";
import { WindowIcons } from "../components/icons";
import { AppWindowContext } from "../context";
export function MacOS({ className, ...props }: HTMLProps<HTMLDivElement>) {
const { minimizeWindow, maximizeWindow, fullscreenWindow, closeWindow } =
useContext(AppWindowContext);
const [isAltKeyPressed, setIsAltKeyPressed] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const last = isAltKeyPressed ? (
<WindowIcons.plusMac />
) : (
<WindowIcons.fullMac />
);
const key = "Alt";
const handleMouseEnter = () => {
setIsHovering(true);
};
const handleMouseLeave = () => {
setIsHovering(false);
};
const handleAltKeyDown = (e: KeyboardEvent) => {
if (e.key === key) {
setIsAltKeyPressed(true);
}
};
const handleAltKeyUp = (e: KeyboardEvent) => {
if (e.key === key) {
setIsAltKeyPressed(false);
}
};
useEffect(() => {
// Attach event listeners when the component mounts
window.addEventListener("keydown", handleAltKeyDown);
window.addEventListener("keyup", handleAltKeyUp);
}, []);
return (
<div
className={twMerge(
"space-x-2 px-3 text-black active:text-black dark:text-black",
className,
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<WindowButton
onClick={closeWindow}
className="aspect-square h-3 w-3 cursor-default content-center items-center justify-center self-center rounded-full border border-black/[.12] bg-[#ff544d] text-center text-black/60 hover:bg-[#ff544d] active:bg-[#bf403a] active:text-black/60 dark:border-none"
>
{isHovering && <WindowIcons.closeMac />}
</WindowButton>
<WindowButton
onClick={minimizeWindow}
className="aspect-square h-3 w-3 cursor-default content-center items-center justify-center self-center rounded-full border border-black/[.12] bg-[#ffbd2e] text-center text-black/60 hover:bg-[#ffbd2e] active:bg-[#bf9122] active:text-black/60 dark:border-none"
>
{isHovering && <WindowIcons.minMac />}
</WindowButton>
<WindowButton
// onKeyDown={handleAltKeyDown}
// onKeyUp={handleAltKeyUp}
onClick={isAltKeyPressed ? maximizeWindow : fullscreenWindow}
className="aspect-square h-3 w-3 cursor-default content-center items-center justify-center self-center rounded-full border border-black/[.12] bg-[#28c93f] text-center text-black/60 hover:bg-[#28c93f] active:bg-[#1e9930] active:text-black/60 dark:border-none"
>
{isHovering && last}
</WindowButton>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { HTMLProps, useContext } from "react";
import { twMerge } from "tailwind-merge";
import { WindowButton } from "../components/button";
import { WindowIcons } from "../components/icons";
import { AppWindowContext } from "../context";
export function Windows({ className, ...props }: HTMLProps<HTMLDivElement>) {
const { isWindowMaximized, minimizeWindow, maximizeWindow, closeWindow } =
useContext(AppWindowContext);
return (
<div className={twMerge("h-8", className)} {...props}>
<WindowButton
onClick={minimizeWindow}
className="max-h-8 w-[46px] cursor-default rounded-none bg-transparent text-black/90 hover:bg-black/[.05] active:bg-black/[.03] dark:text-white dark:hover:bg-white/[.06] dark:active:bg-white/[.04]"
>
<WindowIcons.minimizeWin />
</WindowButton>
<WindowButton
onClick={maximizeWindow}
className={twMerge(
"max-h-8 w-[46px] cursor-default rounded-none bg-transparent",
"text-black/90 hover:bg-black/[.05] active:bg-black/[.03] dark:text-white dark:hover:bg-white/[.06] dark:active:bg-white/[.04]",
// !isMaximizable && "text-white/[.36]",
)}
>
{!isWindowMaximized ? (
<WindowIcons.maximizeWin />
) : (
<WindowIcons.maximizeRestoreWin />
)}
</WindowButton>
<WindowButton
onClick={closeWindow}
className="max-h-8 w-[46px] cursor-default rounded-none bg-transparent text-black/90 hover:bg-[#c42b1c] hover:text-white active:bg-[#c42b1c]/90 dark:text-white"
>
<WindowIcons.closeWin />
</WindowButton>
</div>
);
}

View File

@@ -0,0 +1,7 @@
export * from './context';
export * from './components/button';
export * from './components/icons';
export * from './controls/gnome';
export * from './controls/windows';
export * from './controls/macos';
export * from './titleBar';

View File

@@ -0,0 +1,31 @@
import { Platform } from "@tauri-apps/plugin-os";
import { AppWindowProvider } from "./context";
import { Gnome } from "./controls/gnome";
import { MacOS } from "./controls/macos";
import { Windows } from "./controls/windows";
export function WindowTitleBar({ platform }: { platform: Platform }) {
const ControlsComponent = () => {
switch (platform) {
case "windows":
return <Windows className="ml-auto flex" />;
case "macos":
return <MacOS className="ml-0 flex" />;
case "linux":
return <Gnome className="ml-auto flex" />;
default:
return <Windows className="ml-auto flex" />;
}
};
return (
<AppWindowProvider>
<div
data-tauri-drag-region
className="bg-background flex select-none flex-row overflow-hidden"
>
<ControlsComponent />
</div>
</AppWindowProvider>
);
}

619
packages/ui/src/user.tsx Normal file
View File

@@ -0,0 +1,619 @@
import { useProfile } from "@lume/ark";
import { RepostIcon } from "@lume/icons";
import { displayNpub, formatCreatedAt } from "@lume/utils";
import * as Avatar from "@radix-ui/react-avatar";
import * as HoverCard from "@radix-ui/react-hover-card";
import { minidenticon } from "minidenticons";
import { memo, useMemo } from "react";
import { Link } from "react-router-dom";
import { NIP05 } from "./nip05";
export const User = memo(function User({
pubkey,
time,
variant = "default",
subtext,
}: {
pubkey: string;
time?: number;
variant?:
| "default"
| "simple"
| "mention"
| "notify"
| "repost"
| "chat"
| "large"
| "thread"
| "miniavatar"
| "avatar"
| "stacked"
| "ministacked"
| "childnote";
subtext?: string;
}) {
const { isLoading, user } = useProfile(pubkey);
const createdAt = useMemo(
() => formatCreatedAt(time, variant === "chat"),
[time, variant],
);
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
const fallbackAvatar = useMemo(
() =>
`data:image/svg+xml;utf8,${encodeURIComponent(
minidenticon(pubkey, 90, 50),
)}`,
[pubkey],
);
if (variant === "mention") {
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white"
/>
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">
{createdAt}
</span>
</div>
</div>
);
}
return (
<div className="flex h-6 items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-6 w-6 rounded-md"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name ||
user?.display_name ||
user?.displayName ||
fallbackName}
</h5>
<span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-600 dark:text-neutral-400">
{createdAt}
</span>
</div>
</div>
);
}
if (variant === "notify") {
if (isLoading) {
return (
<div className="flex items-center gap-2">
<Avatar.Root className="h-8 w-8 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Root>
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{fallbackName}
</h5>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Avatar.Root className="h-8 w-8 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-8 w-8 rounded-md"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-8 w-8 rounded-md bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name ||
user?.display_name ||
user?.displayName ||
fallbackName}
</h5>
</div>
);
}
if (variant === "large") {
if (isLoading) {
return (
<div className="flex items-center gap-2.5">
<div className="h-14 w-14 shrink-0 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
<div className="flex flex-col gap-1.5">
<div className="h-3.5 w-36 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
return (
<div className="flex h-full w-full flex-col gap-2.5">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-11 w-11 rounded-lg object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-11 w-11 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-col items-start text-start">
<p className="max-w-[15rem] truncate text-lg font-semibold">
{user?.name || user?.display_name || user?.displayName}
</p>
<p className="break-p prose prose-neutral max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert">
{user?.about || user?.bio || "No bio"}
</p>
</div>
</div>
);
}
if (variant === "simple") {
if (isLoading) {
return (
<div className="flex items-center gap-2.5">
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
<div className="flex w-full flex-col items-start gap-1">
<div className="h-4 w-36 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
return (
<div className="flex items-center gap-2.5">
<Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex w-full flex-col items-start">
<h3 className="max-w-[15rem] truncate text-base font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName}
</h3>
<p className="max-w-[10rem] truncate text-sm text-neutral-900 dark:text-neutral-100/70">
{user?.nip05 || user?.username || fallbackName}
</p>
</div>
</div>
);
}
if (variant === "avatar") {
if (isLoading) {
return (
<div className="h-12 w-12 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
);
}
return (
<Avatar.Root>
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-12 w-12 rounded-lg"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-12 w-12 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
);
}
if (variant === "miniavatar") {
if (isLoading) {
return (
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
);
}
return (
<Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
);
}
if (variant === "childnote") {
if (isLoading) {
return (
<>
<Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black object-cover dark:bg-white"
/>
</Avatar.Root>
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="w-full max-w-[10rem] truncate">{fallbackName} </div>
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{subtext}:
</div>
</div>
</>
);
}
return (
<>
<Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 font-semibold leading-tight">
<div className="w-full max-w-[10rem] truncate">
{user?.display_name ||
user?.name ||
user?.displayName ||
fallbackName}{" "}
</div>
<div className="font-normal text-neutral-700 dark:text-neutral-300">
{subtext}:
</div>
</div>
</>
);
}
if (variant === "stacked") {
if (isLoading) {
return (
<div className="inline-block h-8 w-8 animate-pulse rounded-full bg-neutral-300 ring-1 ring-neutral-200 dark:bg-neutral-700 dark:ring-neutral-800" />
);
}
return (
<Avatar.Root>
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="inline-block h-8 w-8 rounded-full ring-1 ring-neutral-200 dark:ring-neutral-800"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="inline-block h-8 w-8 rounded-full bg-black ring-1 ring-neutral-200 dark:bg-white dark:ring-neutral-800"
/>
</Avatar.Fallback>
</Avatar.Root>
);
}
if (variant === "ministacked") {
if (isLoading) {
return (
<div className="inline-block h-6 w-6 animate-pulse rounded-full bg-neutral-300 ring-1 ring-white dark:ring-black" />
);
}
return (
<Avatar.Root>
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="inline-block h-6 w-6 rounded-full ring-1 ring-white dark:ring-black"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="inline-block h-6 w-6 rounded-full bg-black ring-1 ring-white dark:bg-white dark:ring-black"
/>
</Avatar.Fallback>
</Avatar.Root>
);
}
if (variant === "repost") {
if (isLoading) {
return (
<div className="flex gap-3">
<div className="inline-flex h-10 w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<div className="h-6 w-6 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
return (
<div className="flex gap-2 px-3">
<div className="inline-flex w-10 items-center justify-center">
<RepostIcon className="h-5 w-5 text-blue-500" />
</div>
<div className="inline-flex items-center gap-2">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-6 w-6 rounded object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-6 w-6 rounded bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[10rem] truncate font-medium text-neutral-900 dark:text-neutral-100/80">
{user?.name ||
user?.display_name ||
user?.displayName ||
fallbackName}
</h5>
<span className="text-blue-500">reposted</span>
</div>
</div>
</div>
);
}
if (variant === "thread") {
if (isLoading) {
return (
<div className="flex h-16 items-center gap-3 px-3">
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
<div className="flex flex-1 flex-col gap-1">
<div className="h-4 w-36 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
<div className="h-3 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div>
);
}
return (
<div className="flex h-16 items-center gap-3 px-3">
<Avatar.Root className="h-10 w-10 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-1 flex-col">
<h5 className="max-w-[15rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name || user?.display_name || user?.displayName || "Anon"}
</h5>
<div className="inline-flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
<span>{createdAt}</span>
<span>·</span>
<span>{fallbackName}</span>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center gap-3 px-3">
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={fallbackAvatar}
alt={pubkey}
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Root>
<div className="h-6 flex-1">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{fallbackName}
</div>
</div>
</div>
);
}
return (
<HoverCard.Root>
<div className="flex items-center gap-3 px-3">
<HoverCard.Trigger asChild>
<Avatar.Root className="h-9 w-9 shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-9 w-9 rounded-lg bg-white object-cover ring-1 ring-neutral-200/50 dark:ring-neutral-800/50"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-9 w-9 rounded-lg bg-black ring-1 ring-neutral-200/50 dark:bg-white dark:ring-neutral-800/50"
/>
</Avatar.Fallback>
</Avatar.Root>
</HoverCard.Trigger>
<div className="flex h-6 flex-1 items-start gap-2">
<div className="max-w-[15rem] truncate font-semibold text-neutral-950 dark:text-neutral-50">
{user?.name ||
user?.display_name ||
user?.displayName ||
fallbackName}
</div>
<div className="ml-auto inline-flex items-center gap-3">
<div className="text-neutral-500 dark:text-neutral-400">
{createdAt}
</div>
</div>
</div>
</div>
<HoverCard.Portal>
<HoverCard.Content
className="ml-4 w-[300px] overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100 shadow-lg data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade data-[side=right]:animate-slideLeftAndFade data-[side=top]:animate-slideDownAndFade data-[state=open]:transition-all focus:outline-none dark:border-neutral-800 dark:bg-neutral-900"
sideOffset={5}
>
<div className="flex gap-2.5 border-b border-neutral-200 px-3 py-3 dark:border-neutral-800">
<Avatar.Root className="shrink-0">
<Avatar.Image
src={user?.picture || user?.image}
alt={pubkey}
loading="lazy"
decoding="async"
className="h-10 w-10 rounded-lg object-cover"
/>
<Avatar.Fallback delayMs={300}>
<img
src={fallbackAvatar}
alt={pubkey}
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
/>
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-1 flex-col gap-2">
<div className="inline-flex flex-col">
<h5 className="text-sm font-semibold">
{user?.name ||
user?.display_name ||
user?.displayName ||
user?.username}
</h5>
{user?.nip05 ? (
<NIP05
pubkey={pubkey}
nip05={user.nip05}
className="max-w-[15rem] truncate text-sm text-neutral-500 dark:text-neutral-300"
/>
) : (
<span className="max-w-[15rem] truncate text-sm text-neutral-500 dark:text-neutral-300">
{fallbackName}
</span>
)}
</div>
<div>
<p className="line-clamp-3 break-all text-sm leading-tight text-neutral-900 dark:text-neutral-100">
{user?.about}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 px-3 py-3">
<Link
to={`/users/${pubkey}`}
className="inline-flex h-9 flex-1 items-center justify-center rounded-lg bg-neutral-200 text-sm font-semibold hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
View profile
</Link>
<Link
to={`/chats/${pubkey}`}
className="inline-flex h-9 flex-1 items-center justify-center rounded-lg bg-neutral-200 text-sm font-semibold hover:bg-blue-500 hover:text-white dark:bg-neutral-800"
>
Message
</Link>
</div>
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
);
});

View File

@@ -0,0 +1,8 @@
import sharedConfig from "@lume/tailwindcss";
const config = {
content: ["./src/**/*.{js,ts,jsx,tsx}"],
presets: [sharedConfig],
};
export default config;

View File

@@ -0,0 +1,8 @@
{
"extends": "@lume/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}