Merge remote-tracking branch 'gitlab/main' into nodenext

This commit is contained in:
Alex Gleason 2025-09-02 19:27:44 -05:00
commit 1d58b3939f
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
109 changed files with 4218 additions and 1 deletions

View File

@ -303,6 +303,70 @@ function usePosts() {
}
```
### Infinite Scroll for Feeds
For feed-like interfaces, implement infinite scroll using TanStack Query's `useInfiniteQuery` with Nostr's timestamp-based pagination:
```typescript
import { useNostr } from '@nostrify/react';
import { useInfiniteQuery } from '@tanstack/react-query';
export function useGlobalFeed() {
const { nostr } = useNostr();
return useInfiniteQuery({
queryKey: ['global-feed'],
queryFn: async ({ pageParam, signal }) => {
const filter = { kinds: [1], limit: 20 };
if (pageParam) filter.until = pageParam;
const events = await nostr.query([filter], {
signal: AbortSignal.any([signal, AbortSignal.timeout(1500)])
});
return events;
},
getNextPageParam: (lastPage) => {
if (lastPage.length === 0) return undefined;
return lastPage[lastPage.length - 1].created_at - 1; // Subtract 1 since 'until' is inclusive
},
initialPageParam: undefined,
});
}
```
Example usage with intersection observer for automatic loading:
```tsx
import { useInView } from 'react-intersection-observer';
function GlobalFeed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useGlobalFeed();
const { ref, inView } = useInView();
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, fetchNextPage]);
const posts = data?.pages.flat() || [];
return (
<div className="space-y-4">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
{hasNextPage && (
<div ref={ref} className="py-4">
{isFetchingNextPage && <Skeleton className="h-20 w-full" />}
</div>
)}
</div>
);
}
```
#### Efficient Query Design
**Critical**: Always minimize the number of separate queries to avoid rate limiting and improve performance. Combine related queries whenever possible.

16
package-lock.json generated
View File

@ -56,6 +56,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-intersection-observer": "^9.16.0",
"react-resizable-panels": "^2.1.3",
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
@ -6627,6 +6628,21 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-intersection-observer": {
"version": "9.16.0",
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz",
"integrity": "sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==",
"license": "MIT",
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",

View File

@ -59,6 +59,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-intersection-observer": "^9.16.0",
"react-resizable-panels": "^2.1.3",
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",

42
src/App.js Normal file
View File

@ -0,0 +1,42 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
// NOTE: This file should normally not be modified unless you are adding a new provider.
// To add new routes, edit the AppRouter.tsx file.
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createHead, UnheadProvider } from '@unhead/react/client';
import { InferSeoMetaPlugin } from '@unhead/addons';
import { Suspense } from 'react';
import NostrProvider from '@/components/NostrProvider.js';
import { Toaster } from "@/components/ui/toaster.js";
import { TooltipProvider } from "@/components/ui/tooltip.js";
import { NostrLoginProvider } from '@nostrify/react/login';
import { AppProvider } from '@/components/AppProvider.js';
import { NWCProvider } from '@/contexts/NWCContext.js';
import AppRouter from "./AppRouter.js";
const head = createHead({
plugins: [
InferSeoMetaPlugin(),
],
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 60000, // 1 minute
gcTime: Infinity,
},
},
});
const defaultConfig = {
theme: "light",
relayUrl: "wss://relay.primal.net",
};
const presetRelays = [
{ url: 'wss://ditto.pub/relay', name: 'Ditto' },
{ url: 'wss://relay.nostr.band', name: 'Nostr.Band' },
{ url: 'wss://relay.damus.io', name: 'Damus' },
{ url: 'wss://relay.primal.net', name: 'Primal' },
];
export function App() {
return (_jsx(UnheadProvider, { head: head, children: _jsx(AppProvider, { storageKey: "nostr:app-config", defaultConfig: defaultConfig, presetRelays: presetRelays, children: _jsx(QueryClientProvider, { client: queryClient, children: _jsx(NostrLoginProvider, { storageKey: 'nostr:login', children: _jsx(NostrProvider, { children: _jsx(NWCProvider, { children: _jsxs(TooltipProvider, { children: [_jsx(Toaster, {}), _jsx(Suspense, { children: _jsx(AppRouter, {}) })] }) }) }) }) }) }) }));
}
export default App;

7
src/App.test.js Normal file
View File

@ -0,0 +1,7 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { render } from '@testing-library/react';
import { test } from 'vitest';
import App from "./App.js";
test('App', () => {
render(_jsx(App, {}));
});

10
src/AppRouter.js Normal file
View File

@ -0,0 +1,10 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { ScrollToTop } from "./components/ScrollToTop.js";
import Index from "./pages/Index.js";
import { NIP19Page } from "./pages/NIP19Page.js";
import NotFound from "./pages/NotFound.js";
export function AppRouter() {
return (_jsxs(BrowserRouter, { children: [_jsx(ScrollToTop, {}), _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(Index, {}) }), _jsx(Route, { path: "/:nip19", element: _jsx(NIP19Page, {}) }), _jsx(Route, { path: "*", element: _jsx(NotFound, {}) })] })] }));
}
export default AppRouter;

View File

@ -0,0 +1,65 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { useEffect } from 'react';
import { z } from 'zod';
import { useLocalStorage } from '@/hooks/useLocalStorage.js';
import { AppContext } from '@/contexts/AppContext.js';
// Zod schema for AppConfig validation
const AppConfigSchema = z.object({
theme: z.enum(['dark', 'light', 'system']),
relayUrl: z.string().url(),
});
export function AppProvider(props) {
const { children, storageKey, defaultConfig, presetRelays, } = props;
// App configuration state with localStorage persistence
const [config, setConfig] = useLocalStorage(storageKey, defaultConfig, {
serialize: JSON.stringify,
deserialize: (value) => {
const parsed = JSON.parse(value);
return AppConfigSchema.parse(parsed);
}
});
// Generic config updater with callback pattern
const updateConfig = (updater) => {
setConfig(updater);
};
const appContextValue = {
config,
updateConfig,
presetRelays,
};
// Apply theme effects to document
useApplyTheme(config.theme);
return (_jsx(AppContext.Provider, { value: appContextValue, children: children }));
}
/**
* Hook to apply theme changes to the document root
*/
function useApplyTheme(theme) {
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
// Handle system theme changes when theme is set to "system"
useEffect(() => {
if (theme !== 'system')
return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
root.classList.add(systemTheme);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
}

View File

@ -0,0 +1,120 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCurrentUser } from '@/hooks/useCurrentUser.js';
import { useNostrPublish } from '@/hooks/useNostrPublish.js';
import { useToast } from '@/hooks/useToast.js';
import { Button } from '@/components/ui/button.js';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form.js';
import { Input } from '@/components/ui/input.js';
import { Textarea } from '@/components/ui/textarea.js';
import { Switch } from '@/components/ui/switch.js';
import { Loader2, Upload } from 'lucide-react';
import { NSchema as n } from '@nostrify/nostrify';
import { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile.js';
export const EditProfileForm = () => {
const queryClient = useQueryClient();
const { user, metadata } = useCurrentUser();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
// Initialize the form with default values
const form = useForm({
resolver: zodResolver(n.metadata()),
defaultValues: {
name: '',
about: '',
picture: '',
banner: '',
website: '',
nip05: '',
bot: false,
},
});
// Update form values when user data is loaded
useEffect(() => {
if (metadata) {
form.reset({
name: metadata.name || '',
about: metadata.about || '',
picture: metadata.picture || '',
banner: metadata.banner || '',
website: metadata.website || '',
nip05: metadata.nip05 || '',
bot: metadata.bot || false,
});
}
}, [metadata, form]);
// Handle file uploads for profile picture and banner
const uploadPicture = async (file, field) => {
try {
// The first tuple in the array contains the URL
const [[_, url]] = await uploadFile(file);
form.setValue(field, url);
toast({
title: 'Success',
description: `${field === 'picture' ? 'Profile picture' : 'Banner'} uploaded successfully`,
});
}
catch (error) {
console.error(`Failed to upload ${field}:`, error);
toast({
title: 'Error',
description: `Failed to upload ${field === 'picture' ? 'profile picture' : 'banner'}. Please try again.`,
variant: 'destructive',
});
}
};
const onSubmit = async (values) => {
if (!user) {
toast({
title: 'Error',
description: 'You must be logged in to update your profile',
variant: 'destructive',
});
return;
}
try {
// Combine existing metadata with new values
const data = { ...metadata, ...values };
// Clean up empty values
for (const key in data) {
if (data[key] === '') {
delete data[key];
}
}
// Publish the metadata event (kind 0)
await publishEvent({
kind: 0,
content: JSON.stringify(data),
});
// Invalidate queries to refresh the data
queryClient.invalidateQueries({ queryKey: ['logins'] });
queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
toast({
title: 'Success',
description: 'Your profile has been updated',
});
}
catch (error) {
console.error('Failed to update profile:', error);
toast({
title: 'Error',
description: 'Failed to update your profile. Please try again.',
variant: 'destructive',
});
}
};
return (_jsx(Form, { ...form, children: _jsxs("form", { onSubmit: form.handleSubmit(onSubmit), className: "space-y-6", children: [_jsx(FormField, { control: form.control, name: "name", render: ({ field }) => (_jsxs(FormItem, { children: [_jsx(FormLabel, { children: "Name" }), _jsx(FormControl, { children: _jsx(Input, { placeholder: "Your name", ...field }) }), _jsx(FormDescription, { children: "This is your display name that will be displayed to others." }), _jsx(FormMessage, {})] })) }), _jsx(FormField, { control: form.control, name: "about", render: ({ field }) => (_jsxs(FormItem, { children: [_jsx(FormLabel, { children: "Bio" }), _jsx(FormControl, { children: _jsx(Textarea, { placeholder: "Tell others about yourself", className: "resize-none", ...field }) }), _jsx(FormDescription, { children: "A short description about yourself." }), _jsx(FormMessage, {})] })) }), _jsxs("div", { className: "grid grid-cols-1 md:grid-cols-2 gap-6", children: [_jsx(FormField, { control: form.control, name: "picture", render: ({ field }) => (_jsx(ImageUploadField, { field: field, label: "Profile Picture", placeholder: "https://example.com/profile.jpg", description: "URL to your profile picture. You can upload an image or provide a URL.", previewType: "square", onUpload: (file) => uploadPicture(file, 'picture') })) }), _jsx(FormField, { control: form.control, name: "banner", render: ({ field }) => (_jsx(ImageUploadField, { field: field, label: "Banner Image", placeholder: "https://example.com/banner.jpg", description: "URL to a wide banner image for your profile. You can upload an image or provide a URL.", previewType: "wide", onUpload: (file) => uploadPicture(file, 'banner') })) })] }), _jsxs("div", { className: "grid grid-cols-1 md:grid-cols-2 gap-6", children: [_jsx(FormField, { control: form.control, name: "website", render: ({ field }) => (_jsxs(FormItem, { children: [_jsx(FormLabel, { children: "Website" }), _jsx(FormControl, { children: _jsx(Input, { placeholder: "https://yourwebsite.com", ...field }) }), _jsx(FormDescription, { children: "Your personal website or social media link." }), _jsx(FormMessage, {})] })) }), _jsx(FormField, { control: form.control, name: "nip05", render: ({ field }) => (_jsxs(FormItem, { children: [_jsx(FormLabel, { children: "NIP-05 Identifier" }), _jsx(FormControl, { children: _jsx(Input, { placeholder: "you@example.com", ...field }) }), _jsx(FormDescription, { children: "Your verified Nostr identifier." }), _jsx(FormMessage, {})] })) })] }), _jsx(FormField, { control: form.control, name: "bot", render: ({ field }) => (_jsxs(FormItem, { className: "flex flex-row items-center justify-between rounded-lg border p-4", children: [_jsxs("div", { className: "space-y-0.5", children: [_jsx(FormLabel, { className: "text-base", children: "Bot Account" }), _jsx(FormDescription, { children: "Mark this account as automated or a bot." })] }), _jsx(FormControl, { children: _jsx(Switch, { checked: field.value, onCheckedChange: field.onChange }) })] })) }), _jsxs(Button, { type: "submit", className: "w-full md:w-auto", disabled: isPending || isUploading, children: [(isPending || isUploading) && (_jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" })), "Save Profile"] })] }) }));
};
const ImageUploadField = ({ field, label, placeholder, description, previewType, onUpload, }) => {
const fileInputRef = useRef(null);
return (_jsxs(FormItem, { children: [_jsx(FormLabel, { children: label }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(FormControl, { children: _jsx(Input, { placeholder: placeholder, name: field.name, value: field.value ?? '', onChange: e => field.onChange(e.target.value), onBlur: field.onBlur }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "file", ref: fileInputRef, accept: "image/*", className: "hidden", onChange: (e) => {
const file = e.target.files?.[0];
if (file) {
onUpload(file);
}
} }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => fileInputRef.current?.click(), children: [_jsx(Upload, { className: "h-4 w-4 mr-2" }), "Upload Image"] }), field.value && (_jsx("div", { className: `h-10 ${previewType === 'square' ? 'w-10' : 'w-24'} rounded overflow-hidden`, children: _jsx("img", { src: field.value, alt: `${label} preview`, className: "h-full w-full object-cover" }) }))] })] }), _jsx(FormDescription, { children: description }), _jsx(FormMessage, {})] }));
};

View File

@ -0,0 +1,41 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { Component } from 'react';
export class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error) {
return {
hasError: true,
error,
};
}
componentDidCatch(error, errorInfo) {
console.error('Error caught by ErrorBoundary:', error, errorInfo);
this.setState({
error,
errorInfo,
});
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (_jsx("div", { className: "min-h-screen bg-background flex items-center justify-center p-4", children: _jsxs("div", { className: "max-w-md w-full space-y-4", children: [_jsxs("div", { className: "text-center", children: [_jsx("h2", { className: "text-2xl font-bold text-foreground mb-2", children: "Something went wrong" }), _jsx("p", { className: "text-muted-foreground", children: "An unexpected error occurred. The error has been reported." })] }), _jsx("div", { className: "bg-muted p-4 rounded-lg", children: _jsxs("details", { className: "text-sm", children: [_jsx("summary", { className: "cursor-pointer font-medium text-foreground", children: "Error details" }), _jsxs("div", { className: "mt-2 space-y-2", children: [_jsxs("div", { children: [_jsx("strong", { className: "text-foreground", children: "Message:" }), _jsx("p", { className: "text-muted-foreground mt-1", children: this.state.error?.message })] }), this.state.error?.stack && (_jsxs("div", { children: [_jsx("strong", { className: "text-foreground", children: "Stack trace:" }), _jsx("pre", { className: "text-xs text-muted-foreground mt-1 overflow-auto max-h-32", children: this.state.error.stack })] }))] })] }) }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { onClick: this.handleReset, className: "flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors", children: "Try again" }), _jsx("button", { onClick: () => window.location.reload(), className: "flex-1 px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90 transition-colors", children: "Reload page" })] })] }) }));
}
return this.props.children;
}
}

View File

@ -0,0 +1,45 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { useEffect, useRef } from 'react';
import { NPool, NRelay1 } from '@nostrify/nostrify';
import { NostrContext } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { useAppContext } from '@/hooks/useAppContext.js';
const NostrProvider = (props) => {
const { children } = props;
const { config, presetRelays } = useAppContext();
const queryClient = useQueryClient();
// Create NPool instance only once
const pool = useRef(undefined);
// Use refs so the pool always has the latest data
const relayUrl = useRef(config.relayUrl);
// Update refs when config changes
useEffect(() => {
relayUrl.current = config.relayUrl;
queryClient.resetQueries();
}, [config.relayUrl, queryClient]);
// Initialize NPool only once
if (!pool.current) {
pool.current = new NPool({
open(url) {
return new NRelay1(url);
},
reqRouter(filters) {
return new Map([[relayUrl.current, filters]]);
},
eventRouter(_event) {
// Publish to the selected relay
const allRelays = new Set([relayUrl.current]);
// Also publish to the preset relays, capped to 5
for (const { url } of (presetRelays ?? [])) {
allRelays.add(url);
if (allRelays.size >= 5) {
break;
}
}
return [...allRelays];
},
});
}
return (_jsx(NostrContext.Provider, { value: { nostr: pool.current }, children: children }));
};
export default NostrProvider;

View File

@ -0,0 +1,77 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useAuthor } from '@/hooks/useAuthor.js';
import { genUserName } from '@/lib/genUserName.js';
import { cn } from '@/lib/utils.js';
/** Parses content of text note events so that URLs and hashtags are linkified. */
export function NoteContent({ event, className, }) {
// Process the content to render mentions, links, etc.
const content = useMemo(() => {
const text = event.content;
// Regex to find URLs, Nostr references, and hashtags
const regex = /(https?:\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#\w+)/g;
const parts = [];
let lastIndex = 0;
let match;
let keyCounter = 0;
while ((match = regex.exec(text)) !== null) {
const [fullMatch, url, nostrPrefix, nostrData, hashtag] = match;
const index = match.index;
// Add text before this match
if (index > lastIndex) {
parts.push(text.substring(lastIndex, index));
}
if (url) {
// Handle URLs
parts.push(_jsx("a", { href: url, target: "_blank", rel: "noopener noreferrer", className: "text-blue-500 hover:underline", children: url }, `url-${keyCounter++}`));
}
else if (nostrPrefix && nostrData) {
// Handle Nostr references
try {
const nostrId = `${nostrPrefix}${nostrData}`;
const decoded = nip19.decode(nostrId);
if (decoded.type === 'npub') {
const pubkey = decoded.data;
parts.push(_jsx(NostrMention, { pubkey: pubkey }, `mention-${keyCounter++}`));
}
else {
// For other types, just show as a link
parts.push(_jsx(Link, { to: `/${nostrId}`, className: "text-blue-500 hover:underline", children: fullMatch }, `nostr-${keyCounter++}`));
}
}
catch {
// If decoding fails, just render as text
parts.push(fullMatch);
}
}
else if (hashtag) {
// Handle hashtags
const tag = hashtag.slice(1); // Remove the #
parts.push(_jsx(Link, { to: `/t/${tag}`, className: "text-blue-500 hover:underline", children: hashtag }, `hashtag-${keyCounter++}`));
}
lastIndex = index + fullMatch.length;
}
// Add any remaining text
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex));
}
// If no special content was found, just use the plain text
if (parts.length === 0) {
parts.push(text);
}
return parts;
}, [event]);
return (_jsx("div", { className: cn("whitespace-pre-wrap break-words", className), children: content.length > 0 ? content : event.content }));
}
// Helper component to display user mentions
function NostrMention({ pubkey }) {
const author = useAuthor(pubkey);
const npub = nip19.npubEncode(pubkey);
const hasRealName = !!author.data?.metadata?.name;
const displayName = author.data?.metadata?.name ?? genUserName(pubkey);
return (_jsxs(Link, { to: `/${npub}`, className: cn("font-medium hover:underline", hasRealName
? "text-blue-500"
: "text-gray-500 hover:text-gray-700"), children: ["@", displayName] }));
}

View File

@ -0,0 +1,98 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { TestApp } from '@/test/TestApp.js';
import { NoteContent } from "./NoteContent.js";
describe('NoteContent', () => {
it('linkifies URLs in kind 1 events', () => {
const event = {
id: 'test-id',
pubkey: 'test-pubkey',
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: 'Check out this link: https://example.com',
sig: 'test-sig',
};
render(_jsx(TestApp, { children: _jsx(NoteContent, { event: event }) }));
const link = screen.getByRole('link', { name: 'https://example.com' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://example.com');
expect(link).toHaveAttribute('target', '_blank');
});
it('linkifies URLs in kind 1111 events (comments)', () => {
const event = {
id: 'test-comment-id',
pubkey: 'test-pubkey',
created_at: Math.floor(Date.now() / 1000),
kind: 1111,
tags: [
['a', '30040:pubkey:identifier'],
['k', '30040'],
['p', 'pubkey'],
],
content: 'I think the log events should be different kind numbers instead of having a `log-type` tag. That way you can use normal Nostr filters to filter the log types. Also, the `note` type should just b a kind 1111: https://nostrbook.dev/kinds/1111',
sig: 'test-sig',
};
render(_jsx(TestApp, { children: _jsx(NoteContent, { event: event }) }));
const link = screen.getByRole('link', { name: 'https://nostrbook.dev/kinds/1111' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'https://nostrbook.dev/kinds/1111');
expect(link).toHaveAttribute('target', '_blank');
});
it('handles text without URLs correctly', () => {
const event = {
id: 'test-id',
pubkey: 'test-pubkey',
created_at: Math.floor(Date.now() / 1000),
kind: 1111,
tags: [],
content: 'This is just plain text without any links.',
sig: 'test-sig',
};
render(_jsx(TestApp, { children: _jsx(NoteContent, { event: event }) }));
expect(screen.getByText('This is just plain text without any links.')).toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('renders hashtags as links', () => {
const event = {
id: 'test-id',
pubkey: 'test-pubkey',
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: 'This is a post about #nostr and #bitcoin development.',
sig: 'test-sig',
};
render(_jsx(TestApp, { children: _jsx(NoteContent, { event: event }) }));
const nostrHashtag = screen.getByRole('link', { name: '#nostr' });
const bitcoinHashtag = screen.getByRole('link', { name: '#bitcoin' });
expect(nostrHashtag).toBeInTheDocument();
expect(bitcoinHashtag).toBeInTheDocument();
expect(nostrHashtag).toHaveAttribute('href', '/t/nostr');
expect(bitcoinHashtag).toHaveAttribute('href', '/t/bitcoin');
});
it('generates deterministic names for users without metadata and styles them differently', () => {
// Use a valid npub for testing
const event = {
id: 'test-id',
pubkey: 'test-pubkey',
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: `Mentioning nostr:npub1zg69v7ys40x77y352eufp27daufrg4ncjz4ummcjx3t83y9tehhsqepuh0`,
sig: 'test-sig',
};
render(_jsx(TestApp, { children: _jsx(NoteContent, { event: event }) }));
// The mention should be rendered with a deterministic name
const mention = screen.getByRole('link');
expect(mention).toBeInTheDocument();
// Should have muted styling for generated names (gray instead of blue)
expect(mention).toHaveClass('text-gray-500');
expect(mention).not.toHaveClass('text-blue-500');
// The text should start with @ and contain a generated name (not a truncated npub)
const linkText = mention.textContent;
expect(linkText).not.toMatch(/^@npub1/); // Should not be a truncated npub
expect(linkText).toEqual("@Swift Falcon");
});
});

View File

@ -0,0 +1,65 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { Check, ChevronsUpDown, Wifi, Plus } from "lucide-react";
import { cn } from "@/lib/utils.js";
import { Button } from "@/components/ui/button.js";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command.js";
import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover.js";
import { useState } from "react";
import { useAppContext } from "@/hooks/useAppContext.js";
export function RelaySelector(props) {
const { className } = props;
const { config, updateConfig, presetRelays = [] } = useAppContext();
const selectedRelay = config.relayUrl;
const setSelectedRelay = (relay) => {
updateConfig((current) => ({ ...current, relayUrl: relay }));
};
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const selectedOption = presetRelays.find((option) => option.url === selectedRelay);
// Function to normalize relay URL by adding wss:// if no protocol is present
const normalizeRelayUrl = (url) => {
const trimmed = url.trim();
if (!trimmed)
return trimmed;
// Check if it already has a protocol
if (trimmed.includes('://')) {
return trimmed;
}
// Add wss:// prefix
return `wss://${trimmed}`;
};
// Handle adding a custom relay
const handleAddCustomRelay = (url) => {
setSelectedRelay?.(normalizeRelayUrl(url));
setOpen(false);
setInputValue("");
};
// Check if input value looks like a valid relay URL
const isValidRelayInput = (value) => {
const trimmed = value.trim();
if (!trimmed)
return false;
// Basic validation - should contain at least a domain-like structure
const normalized = normalizeRelayUrl(trimmed);
try {
new URL(normalized);
return true;
}
catch {
return false;
}
};
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", role: "combobox", "aria-expanded": open, className: cn("justify-between", className), children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Wifi, { className: "h-4 w-4" }), _jsx("span", { className: "truncate", children: selectedOption
? selectedOption.name
: selectedRelay
? selectedRelay.replace(/^wss?:\/\//, '')
: "Select relay..." })] }), _jsx(ChevronsUpDown, { className: "ml-2 h-4 w-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "w-[300px] p-0", children: _jsxs(Command, { children: [_jsx(CommandInput, { placeholder: "Search relays or type URL...", value: inputValue, onValueChange: setInputValue }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: inputValue && isValidRelayInput(inputValue) ? (_jsxs(CommandItem, { onSelect: () => handleAddCustomRelay(inputValue), className: "cursor-pointer", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), _jsxs("div", { className: "flex flex-col", children: [_jsx("span", { className: "font-medium", children: "Add custom relay" }), _jsx("span", { className: "text-xs text-muted-foreground", children: normalizeRelayUrl(inputValue) })] })] })) : (_jsx("div", { className: "py-6 text-center text-sm text-muted-foreground", children: inputValue ? "Invalid relay URL" : "No relay found." })) }), _jsxs(CommandGroup, { children: [presetRelays
.filter((option) => !inputValue ||
option.name.toLowerCase().includes(inputValue.toLowerCase()) ||
option.url.toLowerCase().includes(inputValue.toLowerCase()))
.map((option) => (_jsxs(CommandItem, { value: option.url, onSelect: (currentValue) => {
setSelectedRelay(normalizeRelayUrl(currentValue));
setOpen(false);
setInputValue("");
}, children: [_jsx(Check, { className: cn("mr-2 h-4 w-4", selectedRelay === option.url ? "opacity-100" : "opacity-0") }), _jsxs("div", { className: "flex flex-col", children: [_jsx("span", { className: "font-medium", children: option.name }), _jsx("span", { className: "text-xs text-muted-foreground", children: option.url })] })] }, option.url))), inputValue && isValidRelayInput(inputValue) && (_jsxs(CommandItem, { onSelect: () => handleAddCustomRelay(inputValue), className: "cursor-pointer border-t", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), _jsxs("div", { className: "flex flex-col", children: [_jsx("span", { className: "font-medium", children: "Add custom relay" }), _jsx("span", { className: "text-xs text-muted-foreground", children: normalizeRelayUrl(inputValue) })] })] }))] })] })] }) })] }));
}

View File

@ -0,0 +1,9 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
export function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
}

View File

@ -0,0 +1,87 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useState, forwardRef } from 'react';
import { Wallet, Plus, Trash2, Zap, Globe, WalletMinimal, CheckCircle, X } from 'lucide-react';
import { Button } from '@/components/ui/button.js';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog.js';
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger, DrawerClose, } from '@/components/ui/drawer.js';
import { Input } from '@/components/ui/input.js';
import { Label } from '@/components/ui/label.js';
import { Textarea } from '@/components/ui/textarea.js';
import { Badge } from '@/components/ui/badge.js';
import { Separator } from '@/components/ui/separator.js';
import { useNWC } from '@/hooks/useNWCContext.js';
import { useWallet } from '@/hooks/useWallet.js';
import { useToast } from '@/hooks/useToast.js';
import { useIsMobile } from '@/hooks/useIsMobile.js';
// Extracted AddWalletContent to prevent re-renders
const AddWalletContent = forwardRef(({ alias, setAlias, connectionUri, setConnectionUri }, ref) => (_jsxs("div", { className: "space-y-4 px-4", ref: ref, children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "alias", children: "Wallet Name (optional)" }), _jsx(Input, { id: "alias", placeholder: "My Lightning Wallet", value: alias, onChange: (e) => setAlias(e.target.value) })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "connection-uri", children: "Connection URI" }), _jsx(Textarea, { id: "connection-uri", placeholder: "nostr+walletconnect://...", value: connectionUri, onChange: (e) => setConnectionUri(e.target.value), rows: 3 })] })] })));
AddWalletContent.displayName = 'AddWalletContent';
// Extracted WalletContent to prevent re-renders
const WalletContent = forwardRef(({ hasWebLN, isDetecting, hasNWC, connections, connectionInfo, activeConnection, handleSetActive, handleRemoveConnection, setAddDialogOpen }, ref) => (_jsxs("div", { className: "space-y-6 px-4 pb-4", ref: ref, children: [_jsxs("div", { className: "space-y-3", children: [_jsx("h3", { className: "font-medium", children: "Current Status" }), _jsxs("div", { className: "grid gap-3", children: [_jsxs("div", { className: "flex items-center justify-between p-3 border rounded-lg", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Globe, { className: "h-4 w-4 text-muted-foreground" }), _jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium", children: "WebLN" }), _jsx("p", { className: "text-xs text-muted-foreground", children: "Browser extension" })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [hasWebLN && _jsx(CheckCircle, { className: "h-4 w-4 text-green-600" }), _jsx(Badge, { variant: hasWebLN ? "default" : "secondary", className: "text-xs", children: isDetecting ? "..." : hasWebLN ? "Ready" : "Not Found" })] })] }), _jsxs("div", { className: "flex items-center justify-between p-3 border rounded-lg", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(WalletMinimal, { className: "h-4 w-4 text-muted-foreground" }), _jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium", children: "Nostr Wallet Connect" }), _jsx("p", { className: "text-xs text-muted-foreground", children: connections.length > 0
? `${connections.length} wallet${connections.length !== 1 ? 's' : ''} connected`
: "Remote wallet connection" })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [hasNWC && _jsx(CheckCircle, { className: "h-4 w-4 text-green-600" }), _jsx(Badge, { variant: hasNWC ? "default" : "secondary", className: "text-xs", children: hasNWC ? "Ready" : "None" })] })] })] })] }), _jsx(Separator, {}), _jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("h3", { className: "font-medium", children: "Nostr Wallet Connect" }), _jsxs(Button, { size: "sm", variant: "outline", onClick: () => setAddDialogOpen(true), children: [_jsx(Plus, { className: "h-4 w-4 mr-1" }), "Add"] })] }), connections.length === 0 ? (_jsx("div", { className: "text-center py-6 text-muted-foreground", children: _jsx("p", { className: "text-sm", children: "No wallets connected" }) })) : (_jsx("div", { className: "space-y-2", children: connections.map((connection) => {
const info = connectionInfo[connection.connectionString];
const isActive = activeConnection === connection.connectionString;
return (_jsxs("div", { className: `flex items-center justify-between p-3 border rounded-lg ${isActive ? 'ring-2 ring-primary' : ''}`, children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(WalletMinimal, { className: "h-4 w-4 text-muted-foreground" }), _jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium", children: connection.alias || info?.alias || 'Lightning Wallet' }), _jsx("p", { className: "text-xs text-muted-foreground", children: "NWC Connection" })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [isActive && _jsx(CheckCircle, { className: "h-4 w-4 text-green-600" }), !isActive && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => handleSetActive(connection.connectionString), children: _jsx(Zap, { className: "h-3 w-3" }) })), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => handleRemoveConnection(connection.connectionString), children: _jsx(Trash2, { className: "h-3 w-3" }) })] })] }, connection.connectionString));
}) }))] }), !hasWebLN && connections.length === 0 && (_jsxs(_Fragment, { children: [_jsx(Separator, {}), _jsx("div", { className: "text-center py-4 space-y-2", children: _jsx("p", { className: "text-sm text-muted-foreground", children: "Install a WebLN extension or connect a NWC wallet for zaps." }) })] }))] })));
WalletContent.displayName = 'WalletContent';
export function WalletModal({ children, className }) {
const [open, setOpen] = useState(false);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [connectionUri, setConnectionUri] = useState('');
const [alias, setAlias] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const isMobile = useIsMobile();
const { connections, activeConnection, connectionInfo, addConnection, removeConnection, setActiveConnection } = useNWC();
const { hasWebLN, isDetecting } = useWallet();
const hasNWC = connections.length > 0 && connections.some(c => c.isConnected);
const { toast } = useToast();
const handleAddConnection = async () => {
if (!connectionUri.trim()) {
toast({
title: 'Connection URI required',
description: 'Please enter a valid NWC connection URI.',
variant: 'destructive',
});
return;
}
setIsConnecting(true);
try {
const success = await addConnection(connectionUri.trim(), alias.trim() || undefined);
if (success) {
setConnectionUri('');
setAlias('');
setAddDialogOpen(false);
}
}
finally {
setIsConnecting(false);
}
};
const handleRemoveConnection = (connectionString) => {
removeConnection(connectionString);
};
const handleSetActive = (connectionString) => {
setActiveConnection(connectionString);
toast({
title: 'Active wallet changed',
description: 'The selected wallet is now active for zaps.',
});
};
const walletContentProps = {
hasWebLN,
isDetecting,
hasNWC,
connections,
connectionInfo,
activeConnection,
handleSetActive,
handleRemoveConnection,
setAddDialogOpen,
};
const addWalletDialog = (_jsx(Dialog, { open: addDialogOpen, onOpenChange: setAddDialogOpen, children: _jsxs(DialogContent, { className: "sm:max-w-[425px]", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "Connect NWC Wallet" }), _jsx(DialogDescription, { children: "Enter your connection string from a compatible wallet." })] }), _jsx(AddWalletContent, { alias: alias, setAlias: setAlias, connectionUri: connectionUri, setConnectionUri: setConnectionUri }), _jsx(DialogFooter, { className: "px-4", children: _jsx(Button, { onClick: handleAddConnection, disabled: isConnecting || !connectionUri.trim(), className: "w-full", children: isConnecting ? 'Connecting...' : 'Connect' }) })] }) }));
if (isMobile) {
return (_jsxs(_Fragment, { children: [_jsxs(Drawer, { open: open, onOpenChange: setOpen, children: [_jsx(DrawerTrigger, { asChild: true, children: children || (_jsxs(Button, { variant: "outline", size: "sm", className: className, children: [_jsx(Wallet, { className: "h-4 w-4 mr-2" }), "Wallet Settings"] })) }), _jsxs(DrawerContent, { className: "h-full", children: [_jsxs(DrawerHeader, { className: "text-center relative", children: [_jsx(DrawerClose, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "sm", className: "absolute right-4 top-4", children: [_jsx(X, { className: "h-4 w-4" }), _jsx("span", { className: "sr-only", children: "Close" })] }) }), _jsxs(DrawerTitle, { className: "flex items-center justify-center gap-2 pt-2", children: [_jsx(Wallet, { className: "h-5 w-5" }), "Lightning Wallet"] }), _jsx(DrawerDescription, { children: "Connect your lightning wallet to send zaps instantly." })] }), _jsx("div", { className: "overflow-y-auto", children: _jsx(WalletContent, { ...walletContentProps }) })] })] }), _jsx(Drawer, { open: addDialogOpen, onOpenChange: setAddDialogOpen, children: _jsxs(DrawerContent, { children: [_jsxs(DrawerHeader, { children: [_jsx(DrawerTitle, { children: "Connect NWC Wallet" }), _jsx(DrawerDescription, { children: "Enter your connection string from a compatible wallet." })] }), _jsx(AddWalletContent, { alias: alias, setAlias: setAlias, connectionUri: connectionUri, setConnectionUri: setConnectionUri }), _jsx("div", { className: "p-4", children: _jsx(Button, { onClick: handleAddConnection, disabled: isConnecting || !connectionUri.trim(), className: "w-full", children: isConnecting ? 'Connecting...' : 'Connect' }) })] }) })] }));
}
return (_jsxs(_Fragment, { children: [_jsxs(Dialog, { open: open, onOpenChange: setOpen, children: [_jsx(DialogTrigger, { asChild: true, children: children || (_jsxs(Button, { variant: "outline", size: "sm", className: className, children: [_jsx(Wallet, { className: "h-4 w-4 mr-2" }), "Wallet Settings"] })) }), _jsxs(DialogContent, { className: "sm:max-w-[500px] max-h-[80vh] overflow-y-auto", children: [_jsxs(DialogHeader, { children: [_jsxs(DialogTitle, { className: "flex items-center gap-2", children: [_jsx(Wallet, { className: "h-5 w-5" }), "Lightning Wallet"] }), _jsx(DialogDescription, { children: "Connect your lightning wallet to send zaps instantly." })] }), _jsx(WalletContent, { ...walletContentProps })] })] }), addWalletDialog] }));
}

View File

@ -0,0 +1,23 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { ZapDialog } from '@/components/ZapDialog.js';
import { useZaps } from '@/hooks/useZaps.js';
import { useWallet } from '@/hooks/useWallet.js';
import { useCurrentUser } from '@/hooks/useCurrentUser.js';
import { useAuthor } from '@/hooks/useAuthor.js';
import { Zap } from 'lucide-react';
export function ZapButton({ target, className = "text-xs ml-1", showCount = true, zapData: externalZapData }) {
const { user } = useCurrentUser();
const { data: author } = useAuthor(target?.pubkey || '');
const { webln, activeNWC } = useWallet();
// Only fetch data if not provided externally
const { totalSats: fetchedTotalSats, isLoading } = useZaps(externalZapData ? [] : target ?? [], // Empty array prevents fetching if external data provided
webln, activeNWC);
// Don't show zap button if user is not logged in, is the author, or author has no lightning address
if (!user || !target || user.pubkey === target.pubkey || (!author?.metadata?.lud16 && !author?.metadata?.lud06)) {
return null;
}
// Use external data if provided, otherwise use fetched data
const totalSats = externalZapData?.totalSats ?? fetchedTotalSats;
const showLoading = externalZapData?.isLoading || isLoading;
return (_jsx(ZapDialog, { target: target, children: _jsxs("div", { className: `flex items-center gap-1 ${className}`, children: [_jsx(Zap, { className: "h-4 w-4" }), _jsx("span", { className: "text-xs", children: showLoading ? ('...') : showCount && totalSats > 0 ? (`${totalSats.toLocaleString()}`) : ('Zap') })] }) }));
}

164
src/components/ZapDialog.js Normal file
View File

@ -0,0 +1,164 @@
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
import { useState, useEffect, useRef, forwardRef } from 'react';
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, ArrowLeft, X } from 'lucide-react';
import { Button } from '@/components/ui/button.js';
import { cn } from '@/lib/utils.js';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog.js';
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger, DrawerClose, } from '@/components/ui/drawer.js';
import { Input } from '@/components/ui/input.js';
import { Label } from '@/components/ui/label.js';
import { Textarea } from '@/components/ui/textarea.js';
import { Card, CardContent } from '@/components/ui/card.js';
import { Separator } from '@/components/ui/separator.js';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group.js';
import { useCurrentUser } from '@/hooks/useCurrentUser.js';
import { useAuthor } from '@/hooks/useAuthor.js';
import { useToast } from '@/hooks/useToast.js';
import { useZaps } from '@/hooks/useZaps.js';
import { useWallet } from '@/hooks/useWallet.js';
import { useIsMobile } from '@/hooks/useIsMobile.js';
import QRCode from 'qrcode';
const presetAmounts = [
{ amount: 1, icon: Sparkle },
{ amount: 50, icon: Sparkles },
{ amount: 100, icon: Zap },
{ amount: 250, icon: Star },
{ amount: 1000, icon: Rocket },
];
// Moved ZapContent outside of ZapDialog to prevent re-renders causing focus loss
const ZapContent = forwardRef(({ invoice, amount, comment, isZapping, qrCodeUrl, copied, hasWebLN, handleZap, handleCopy, openInWallet, setAmount, setComment, inputRef, zap, }, ref) => (_jsx("div", { ref: ref, children: invoice ? (_jsxs("div", { className: "flex flex-col h-full min-h-0", children: [_jsx("div", { className: "text-center pt-4", children: _jsxs("div", { className: "text-2xl font-bold", children: [amount, " sats"] }) }), _jsx(Separator, { className: "my-4" }), _jsxs("div", { className: "flex flex-col justify-center min-h-0 flex-1 px-2", children: [_jsx("div", { className: "flex justify-center", children: _jsx(Card, { className: "p-3 [@media(max-height:680px)]:max-w-[65vw] max-w-[95vw] mx-auto", children: _jsx(CardContent, { className: "p-0 flex justify-center", children: qrCodeUrl ? (_jsx("img", { src: qrCodeUrl, alt: "Lightning Invoice QR Code", className: "w-full h-auto aspect-square max-w-full object-contain" })) : (_jsx("div", { className: "w-full aspect-square bg-muted animate-pulse rounded" })) }) }) }), _jsxs("div", { className: "space-y-2 mt-4", children: [_jsx(Label, { htmlFor: "invoice", children: "Lightning Invoice" }), _jsxs("div", { className: "flex gap-2 min-w-0", children: [_jsx(Input, { id: "invoice", value: invoice, readOnly: true, className: "font-mono text-xs min-w-0 flex-1 overflow-hidden text-ellipsis", onClick: (e) => e.currentTarget.select() }), _jsx(Button, { variant: "outline", size: "icon", onClick: handleCopy, className: "shrink-0", children: copied ? (_jsx(Check, { className: "h-4 w-4 text-green-600" })) : (_jsx(Copy, { className: "h-4 w-4" })) })] })] }), _jsxs("div", { className: "space-y-3 mt-4", children: [hasWebLN && (_jsxs(Button, { onClick: () => {
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
zap(finalAmount, comment);
}, disabled: isZapping, className: "w-full", size: "lg", children: [_jsx(Zap, { className: "h-4 w-4 mr-2" }), isZapping ? "Processing..." : "Pay with WebLN"] })), _jsxs(Button, { variant: "outline", onClick: openInWallet, className: "w-full", size: "lg", children: [_jsx(ExternalLink, { className: "h-4 w-4 mr-2" }), "Open in Lightning Wallet"] }), _jsx("div", { className: "text-xs sm:text-[.65rem] text-muted-foreground text-center", children: "Scan the QR code or copy the invoice to pay with any Lightning wallet." })] })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid gap-3 px-4 py-4 w-full overflow-hidden", children: [_jsx(ToggleGroup, { type: "single", value: String(amount), onValueChange: (value) => {
if (value) {
setAmount(parseInt(value, 10));
}
}, className: "grid grid-cols-5 gap-1 w-full", children: presetAmounts.map(({ amount: presetAmount, icon: Icon }) => (_jsxs(ToggleGroupItem, { value: String(presetAmount), className: "flex flex-col h-auto min-w-0 text-xs px-1 py-2", children: [_jsx(Icon, { className: "h-4 w-4 mb-1" }), _jsx("span", { className: "truncate", children: presetAmount })] }, presetAmount))) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "h-px flex-1 bg-muted" }), _jsx("span", { className: "text-xs text-muted-foreground", children: "OR" }), _jsx("div", { className: "h-px flex-1 bg-muted" })] }), _jsx(Input, { ref: inputRef, id: "custom-amount", type: "number", placeholder: "Custom amount", value: amount, onChange: (e) => setAmount(e.target.value), className: "w-full text-sm" }), _jsx(Textarea, { id: "custom-comment", placeholder: "Add a comment (optional)", value: comment, onChange: (e) => setComment(e.target.value), className: "w-full resize-none text-sm", rows: 2 })] }), _jsx("div", { className: "px-4 pb-4", children: _jsx(Button, { onClick: handleZap, className: "w-full", disabled: isZapping, size: "default", children: isZapping ? ('Creating invoice...') : (_jsxs(_Fragment, { children: [_jsx(Zap, { className: "h-4 w-4 mr-2" }), "Zap ", amount, " sats"] })) }) })] })) })));
ZapContent.displayName = 'ZapContent';
export function ZapDialog({ target, children, className }) {
const [open, setOpen] = useState(false);
const { user } = useCurrentUser();
const { data: author } = useAuthor(target.pubkey);
const { toast } = useToast();
const { webln, activeNWC, hasWebLN, detectWebLN } = useWallet();
const { zap, isZapping, invoice, setInvoice } = useZaps(target, webln, activeNWC, () => setOpen(false));
const [amount, setAmount] = useState(100);
const [comment, setComment] = useState('');
const [copied, setCopied] = useState(false);
const [qrCodeUrl, setQrCodeUrl] = useState('');
const inputRef = useRef(null);
const isMobile = useIsMobile();
useEffect(() => {
if (target) {
setComment('Zapped with MKStack!');
}
}, [target]);
// Detect WebLN when dialog opens
useEffect(() => {
if (open && !hasWebLN) {
detectWebLN();
}
}, [open, hasWebLN, detectWebLN]);
// Generate QR code
useEffect(() => {
let isCancelled = false;
const generateQR = async () => {
if (!invoice) {
setQrCodeUrl('');
return;
}
try {
const url = await QRCode.toDataURL(invoice.toUpperCase(), {
width: 512,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF',
},
});
if (!isCancelled) {
setQrCodeUrl(url);
}
}
catch (err) {
if (!isCancelled) {
console.error('Failed to generate QR code:', err);
}
}
};
generateQR();
return () => {
isCancelled = true;
};
}, [invoice]);
const handleCopy = async () => {
if (invoice) {
await navigator.clipboard.writeText(invoice);
setCopied(true);
toast({
title: 'Invoice copied',
description: 'Lightning invoice copied to clipboard',
});
setTimeout(() => setCopied(false), 2000);
}
};
const openInWallet = () => {
if (invoice) {
const lightningUrl = `lightning:${invoice}`;
window.open(lightningUrl, '_blank');
}
};
useEffect(() => {
if (open) {
setAmount(100);
setInvoice(null);
setCopied(false);
setQrCodeUrl('');
}
else {
// Clean up state when dialog closes
setAmount(100);
setInvoice(null);
setCopied(false);
setQrCodeUrl('');
}
}, [open, setInvoice]);
const handleZap = () => {
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
zap(finalAmount, comment);
};
const contentProps = {
invoice,
amount,
comment,
isZapping,
qrCodeUrl,
copied,
hasWebLN,
handleZap,
handleCopy,
openInWallet,
setAmount,
setComment,
inputRef,
zap,
};
if (!user || user.pubkey === target.pubkey || !author?.metadata?.lud06 && !author?.metadata?.lud16) {
return null;
}
if (isMobile) {
// Use drawer for entire mobile flow, make it full-screen when showing invoice
return (_jsxs(Drawer, { open: open, onOpenChange: (newOpen) => {
// Reset invoice when closing
if (!newOpen) {
setInvoice(null);
setQrCodeUrl('');
}
setOpen(newOpen);
}, dismissible: true, snapPoints: invoice ? [0.5, 0.75, 0.98] : [0.98], activeSnapPoint: invoice ? 0.98 : 0.98, modal: true, shouldScaleBackground: false, fadeFromIndex: 0, children: [_jsx(DrawerTrigger, { asChild: true, children: _jsx("div", { className: `cursor-pointer ${className || ''}`, children: children }) }), _jsxs(DrawerContent, { className: cn("transition-all duration-300", invoice ? "h-full max-h-screen" : "max-h-[98vh]"), "data-testid": "zap-modal", children: [_jsxs(DrawerHeader, { className: "text-center relative", children: [invoice && (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => {
setInvoice(null);
setQrCodeUrl('');
}, className: "absolute left-4 top-4 flex items-center gap-2", children: _jsx(ArrowLeft, { className: "h-4 w-4" }) })), _jsx(DrawerClose, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "sm", className: "absolute right-4 top-4", children: [_jsx(X, { className: "h-4 w-4" }), _jsx("span", { className: "sr-only", children: "Close" })] }) }), _jsx(DrawerTitle, { className: "text-lg break-words pt-2", children: invoice ? 'Lightning Payment' : 'Send a Zap' }), _jsx(DrawerDescription, { className: "text-sm break-words text-center", children: invoice ? ('Pay with Bitcoin Lightning Network') : ('Zaps are small Bitcoin payments that support the creator of this item. If you enjoyed this, consider sending a zap!') })] }), _jsx("div", { className: "flex-1 overflow-y-auto px-4 pb-4", children: _jsx(ZapContent, { ...contentProps }) })] }, invoice ? 'payment' : 'form')] }));
}
return (_jsxs(Dialog, { open: open, onOpenChange: setOpen, children: [_jsx(DialogTrigger, { asChild: true, children: _jsx("div", { className: `cursor-pointer ${className || ''}`, children: children }) }), _jsxs(DialogContent, { className: "sm:max-w-[425px] max-h-[95vh] overflow-hidden", "data-testid": "zap-modal", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { className: "text-lg break-words", children: invoice ? 'Lightning Payment' : 'Send a Zap' }), _jsx(DialogDescription, { className: "text-sm text-center break-words", children: invoice ? ('Pay with Bitcoin Lightning Network') : (_jsx(_Fragment, { children: "Zaps are small Bitcoin payments that support the creator of this item. If you enjoyed this, consider sending a zap!" })) })] }), _jsx("div", { className: "overflow-y-auto", children: _jsx(ZapContent, { ...contentProps }) })] })] }));
}

View File

@ -0,0 +1,19 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
// NOTE: This file is stable and usually should not be modified.
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
import { ChevronDown, LogOut, UserIcon, UserPlus, Wallet } from 'lucide-react';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu.js';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.js';
import { RelaySelector } from '@/components/RelaySelector.js';
import { WalletModal } from '@/components/WalletModal.js';
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts.js';
import { genUserName } from '@/lib/genUserName.js';
export function AccountSwitcher({ onAddAccountClick }) {
const { currentUser, otherUsers, setLogin, removeLogin } = useLoggedInAccounts();
if (!currentUser)
return null;
const getDisplayName = (account) => {
return account.metadata.name ?? genUserName(account.pubkey);
};
return (_jsxs(DropdownMenu, { modal: false, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { className: 'flex items-center gap-3 p-3 rounded-full hover:bg-accent transition-all w-full text-foreground', children: [_jsxs(Avatar, { className: 'w-10 h-10', children: [_jsx(AvatarImage, { src: currentUser.metadata.picture, alt: getDisplayName(currentUser) }), _jsx(AvatarFallback, { children: getDisplayName(currentUser).charAt(0) })] }), _jsx("div", { className: 'flex-1 text-left hidden md:block truncate', children: _jsx("p", { className: 'font-medium text-sm truncate', children: getDisplayName(currentUser) }) }), _jsx(ChevronDown, { className: 'w-4 h-4 text-muted-foreground' })] }) }), _jsxs(DropdownMenuContent, { className: 'w-56 p-2 animate-scale-in', children: [_jsx("div", { className: 'font-medium text-sm px-2 py-1.5', children: "Switch Relay" }), _jsx(RelaySelector, { className: "w-full" }), _jsx(DropdownMenuSeparator, {}), _jsx("div", { className: 'font-medium text-sm px-2 py-1.5', children: "Switch Account" }), otherUsers.map((user) => (_jsxs(DropdownMenuItem, { onClick: () => setLogin(user.id), className: 'flex items-center gap-2 cursor-pointer p-2 rounded-md', children: [_jsxs(Avatar, { className: 'w-8 h-8', children: [_jsx(AvatarImage, { src: user.metadata.picture, alt: getDisplayName(user) }), _jsx(AvatarFallback, { children: getDisplayName(user)?.charAt(0) || _jsx(UserIcon, {}) })] }), _jsx("div", { className: 'flex-1 truncate', children: _jsx("p", { className: 'text-sm font-medium', children: getDisplayName(user) }) }), user.id === currentUser.id && _jsx("div", { className: 'w-2 h-2 rounded-full bg-primary' })] }, user.id))), _jsx(DropdownMenuSeparator, {}), _jsx(WalletModal, { children: _jsxs(DropdownMenuItem, { className: 'flex items-center gap-2 cursor-pointer p-2 rounded-md', onSelect: (e) => e.preventDefault(), children: [_jsx(Wallet, { className: 'w-4 h-4' }), _jsx("span", { children: "Wallet Settings" })] }) }), _jsxs(DropdownMenuItem, { onClick: onAddAccountClick, className: 'flex items-center gap-2 cursor-pointer p-2 rounded-md', children: [_jsx(UserPlus, { className: 'w-4 h-4' }), _jsx("span", { children: "Add another account" })] }), _jsxs(DropdownMenuItem, { onClick: () => removeLogin(currentUser.id), className: 'flex items-center gap-2 cursor-pointer p-2 rounded-md text-red-500', children: [_jsx(LogOut, { className: 'w-4 h-4' }), _jsx("span", { children: "Log out" })] })] })] }));
}

View File

@ -0,0 +1,21 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
// NOTE: This file is stable and usually should not be modified.
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
import { useState } from 'react';
import { User, UserPlus } from 'lucide-react';
import { Button } from '@/components/ui/button.js';
import LoginDialog from "./LoginDialog.js";
import SignupDialog from "./SignupDialog.js";
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts.js';
import { AccountSwitcher } from "./AccountSwitcher.js";
import { cn } from '@/lib/utils.js';
export function LoginArea({ className }) {
const { currentUser } = useLoggedInAccounts();
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [signupDialogOpen, setSignupDialogOpen] = useState(false);
const handleLogin = () => {
setLoginDialogOpen(false);
setSignupDialogOpen(false);
};
return (_jsxs("div", { className: cn("inline-flex items-center justify-center", className), children: [currentUser ? (_jsx(AccountSwitcher, { onAddAccountClick: () => setLoginDialogOpen(true) })) : (_jsxs("div", { className: "flex gap-3 justify-center", children: [_jsxs(Button, { onClick: () => setLoginDialogOpen(true), className: 'flex items-center gap-2 px-4 py-2 rounded-full bg-primary text-primary-foreground w-full font-medium transition-all hover:bg-primary/90 animate-scale-in', children: [_jsx(User, { className: 'w-4 h-4' }), _jsx("span", { className: 'truncate', children: "Log in" })] }), _jsxs(Button, { onClick: () => setSignupDialogOpen(true), variant: "outline", className: "flex items-center gap-2 px-4 py-2 rounded-full font-medium transition-all", children: [_jsx(UserPlus, { className: "w-4 h-4" }), _jsx("span", { children: "Sign Up" })] })] })), _jsx(LoginDialog, { isOpen: loginDialogOpen, onClose: () => setLoginDialogOpen(false), onLogin: handleLogin, onSignup: () => setSignupDialogOpen(true) }), _jsx(SignupDialog, { isOpen: signupDialogOpen, onClose: () => setSignupDialogOpen(false) })] }));
}

View File

@ -0,0 +1,168 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
// NOTE: This file is stable and usually should not be modified.
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
import { useRef, useState, useEffect } from 'react';
import { Shield, Upload, AlertTriangle, UserPlus, KeyRound, Sparkles, Cloud } from 'lucide-react';
import { Button } from '@/components/ui/button.js';
import { Input } from '@/components/ui/input.js';
import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog.js";
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs.js';
import { Alert, AlertDescription } from '@/components/ui/alert.js';
import { useLoginActions } from '@/hooks/useLoginActions.js';
import { cn } from '@/lib/utils.js';
const validateNsec = (nsec) => {
return /^nsec1[a-zA-Z0-9]{58}$/.test(nsec);
};
const validateBunkerUri = (uri) => {
return uri.startsWith('bunker://');
};
const LoginDialog = ({ isOpen, onClose, onLogin, onSignup }) => {
const [isLoading, setIsLoading] = useState(false);
const [isFileLoading, setIsFileLoading] = useState(false);
const [nsec, setNsec] = useState('');
const [bunkerUri, setBunkerUri] = useState('');
const [errors, setErrors] = useState({});
const fileInputRef = useRef(null);
const login = useLoginActions();
// Reset all state when dialog opens/closes
useEffect(() => {
if (isOpen) {
// Reset state when dialog opens
setIsLoading(false);
setIsFileLoading(false);
setNsec('');
setBunkerUri('');
setErrors({});
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
}, [isOpen]);
const handleExtensionLogin = async () => {
setIsLoading(true);
setErrors(prev => ({ ...prev, extension: undefined }));
try {
if (!('nostr' in window)) {
throw new Error('Nostr extension not found. Please install a NIP-07 extension.');
}
await login.extension();
onLogin();
onClose();
}
catch (e) {
const error = e;
console.error('Bunker login failed:', error);
console.error('Nsec login failed:', error);
console.error('Extension login failed:', error);
setErrors(prev => ({
...prev,
extension: error instanceof Error ? error.message : 'Extension login failed'
}));
}
finally {
setIsLoading(false);
}
};
const executeLogin = (key) => {
setIsLoading(true);
setErrors({});
// Use a timeout to allow the UI to update before the synchronous login call
setTimeout(() => {
try {
login.nsec(key);
onLogin();
onClose();
}
catch {
setErrors({ nsec: "Failed to login with this key. Please check that it's correct." });
setIsLoading(false);
}
}, 50);
};
const handleKeyLogin = () => {
if (!nsec.trim()) {
setErrors(prev => ({ ...prev, nsec: 'Please enter your secret key' }));
return;
}
if (!validateNsec(nsec)) {
setErrors(prev => ({ ...prev, nsec: 'Invalid secret key format. Must be a valid nsec starting with nsec1.' }));
return;
}
executeLogin(nsec);
};
const handleBunkerLogin = async () => {
if (!bunkerUri.trim()) {
setErrors(prev => ({ ...prev, bunker: 'Please enter a bunker URI' }));
return;
}
if (!validateBunkerUri(bunkerUri)) {
setErrors(prev => ({ ...prev, bunker: 'Invalid bunker URI format. Must start with bunker://' }));
return;
}
setIsLoading(true);
setErrors(prev => ({ ...prev, bunker: undefined }));
try {
await login.bunker(bunkerUri);
onLogin();
onClose();
// Clear the URI from memory
setBunkerUri('');
}
catch {
setErrors(prev => ({
...prev,
bunker: 'Failed to connect to bunker. Please check the URI.'
}));
}
finally {
setIsLoading(false);
}
};
const handleFileUpload = (e) => {
const file = e.target.files?.[0];
if (!file)
return;
setIsFileLoading(true);
setErrors({});
const reader = new FileReader();
reader.onload = (event) => {
setIsFileLoading(false);
const content = event.target?.result;
if (content) {
const trimmedContent = content.trim();
if (validateNsec(trimmedContent)) {
executeLogin(trimmedContent);
}
else {
setErrors({ file: 'File does not contain a valid secret key.' });
}
}
else {
setErrors({ file: 'Could not read file content.' });
}
};
reader.onerror = () => {
setIsFileLoading(false);
setErrors({ file: 'Failed to read file.' });
};
reader.readAsText(file);
};
const handleSignupClick = () => {
onClose();
if (onSignup) {
onSignup();
}
};
const defaultTab = 'nostr' in window ? 'extension' : 'key';
return (_jsx(Dialog, { open: isOpen, onOpenChange: onClose, children: _jsxs(DialogContent, { className: cn("max-w-[95vw] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl overflow-y-scroll"), children: [_jsx(DialogHeader, { className: cn('px-6 pt-6 pb-1 relative'), children: _jsx(DialogDescription, { className: "text-center", children: "Sign up or log in to continue" }) }), _jsxs("div", { className: 'px-6 pt-2 pb-4 space-y-4 overflow-y-auto flex-1', children: [_jsx("div", { className: 'relative p-4 rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-950/50 dark:to-indigo-950/50 border border-blue-200 dark:border-blue-800 overflow-hidden', children: _jsxs("div", { className: 'relative z-10 text-center space-y-3', children: [_jsxs("div", { className: 'flex justify-center items-center gap-2 mb-2', children: [_jsx(Sparkles, { className: 'w-5 h-5 text-blue-600' }), _jsx("span", { className: 'font-semibold text-blue-800 dark:text-blue-200', children: "New to Nostr?" })] }), _jsx("p", { className: 'text-sm text-blue-700 dark:text-blue-300', children: "Create a new account to get started. It's free and open." }), _jsxs(Button, { onClick: handleSignupClick, className: 'w-full rounded-full py-3 text-base font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 transform transition-all duration-200 hover:scale-105 shadow-lg border-0', children: [_jsx(UserPlus, { className: 'w-4 h-4 mr-2' }), _jsx("span", { children: "Sign Up" })] })] }) }), _jsxs("div", { className: 'relative', children: [_jsx("div", { className: 'absolute inset-0 flex items-center', children: _jsx("div", { className: 'w-full border-t border-gray-300 dark:border-gray-600' }) }), _jsx("div", { className: 'relative flex justify-center text-sm', children: _jsx("span", { className: 'px-3 bg-background text-muted-foreground', children: _jsx("span", { children: "Or log in" }) }) })] }), _jsxs(Tabs, { defaultValue: defaultTab, className: "w-full", children: [_jsxs(TabsList, { className: "grid w-full grid-cols-3 bg-muted/80 rounded-lg mb-4", children: [_jsxs(TabsTrigger, { value: "extension", className: "flex items-center gap-2", children: [_jsx(Shield, { className: "w-4 h-4" }), _jsx("span", { children: "Extension" })] }), _jsxs(TabsTrigger, { value: "key", className: "flex items-center gap-2", children: [_jsx(KeyRound, { className: "w-4 h-4" }), _jsx("span", { children: "Key" })] }), _jsxs(TabsTrigger, { value: "bunker", className: "flex items-center gap-2", children: [_jsx(Cloud, { className: "w-4 h-4" }), _jsx("span", { children: "Bunker" })] })] }), _jsxs(TabsContent, { value: 'extension', className: 'space-y-3 bg-muted', children: [errors.extension && (_jsxs(Alert, { variant: "destructive", children: [_jsx(AlertTriangle, { className: "h-4 w-4" }), _jsx(AlertDescription, { children: errors.extension })] })), _jsxs("div", { className: 'text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800', children: [_jsx(Shield, { className: 'w-12 h-12 mx-auto mb-3 text-primary' }), _jsx("p", { className: 'text-sm text-gray-600 dark:text-gray-300 mb-4', children: "Login with one click using the browser extension" }), _jsx("div", { className: "flex justify-center", children: _jsx(Button, { className: 'w-full rounded-full py-4', onClick: handleExtensionLogin, disabled: isLoading, children: isLoading ? 'Logging in...' : 'Login with Extension' }) })] })] }), _jsx(TabsContent, { value: 'key', className: 'space-y-4', children: _jsxs("div", { className: 'space-y-4', children: [_jsxs("div", { className: 'space-y-2', children: [_jsx("label", { htmlFor: 'nsec', className: 'text-sm font-medium', children: "Secret Key (nsec)" }), _jsx(Input, { id: 'nsec', type: "password", value: nsec, onChange: (e) => {
setNsec(e.target.value);
if (errors.nsec)
setErrors(prev => ({ ...prev, nsec: undefined }));
}, className: `rounded-lg ${errors.nsec ? 'border-red-500 focus-visible:ring-red-500' : ''}`, placeholder: 'nsec1...', autoComplete: "off" }), errors.nsec && (_jsx("p", { className: "text-sm text-red-500", children: errors.nsec }))] }), _jsx(Button, { className: 'w-full rounded-full py-3', onClick: handleKeyLogin, disabled: isLoading || !nsec.trim(), children: isLoading ? 'Verifying...' : 'Log In' }), _jsxs("div", { className: 'relative', children: [_jsx("div", { className: 'absolute inset-0 flex items-center', children: _jsx("div", { className: 'w-full border-t border-muted' }) }), _jsx("div", { className: 'relative flex justify-center text-xs', children: _jsx("span", { className: 'px-2 bg-background text-muted-foreground', children: "or" }) })] }), _jsxs("div", { className: 'text-center', children: [_jsx("input", { type: 'file', accept: '.txt', className: 'hidden', ref: fileInputRef, onChange: handleFileUpload }), _jsxs(Button, { variant: 'outline', className: 'w-full', onClick: () => fileInputRef.current?.click(), disabled: isLoading || isFileLoading, children: [_jsx(Upload, { className: 'w-4 h-4 mr-2' }), isFileLoading ? 'Reading File...' : 'Upload Your Key File'] }), errors.file && (_jsx("p", { className: "text-sm text-red-500 mt-2", children: errors.file }))] })] }) }), _jsxs(TabsContent, { value: 'bunker', className: 'space-y-3 bg-muted', children: [_jsxs("div", { className: 'space-y-2', children: [_jsx("label", { htmlFor: 'bunkerUri', className: 'text-sm font-medium text-gray-700 dark:text-gray-400', children: "Bunker URI" }), _jsx(Input, { id: 'bunkerUri', value: bunkerUri, onChange: (e) => {
setBunkerUri(e.target.value);
if (errors.bunker)
setErrors(prev => ({ ...prev, bunker: undefined }));
}, className: `rounded-lg border-gray-300 dark:border-gray-700 focus-visible:ring-primary ${errors.bunker ? 'border-red-500' : ''}`, placeholder: 'bunker://', autoComplete: "off" }), errors.bunker && (_jsx("p", { className: "text-sm text-red-500", children: errors.bunker }))] }), _jsx("div", { className: "flex justify-center", children: _jsx(Button, { className: 'w-full rounded-full py-4', onClick: handleBunkerLogin, disabled: isLoading || !bunkerUri.trim(), children: isLoading ? 'Connecting...' : 'Login with Bunker' }) })] })] })] })] }) }));
};
export default LoginDialog;

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,29 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useAuthor } from '@/hooks/useAuthor.js';
import { useComments } from '@/hooks/useComments.js';
import { CommentForm } from "./CommentForm.js";
import { NoteContent } from '@/components/NoteContent.js';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.js';
import { Button } from '@/components/ui/button.js';
import { Card, CardContent } from '@/components/ui/card.js';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible.js';
import { DropdownMenu, DropdownMenuTrigger } from '@/components/ui/dropdown-menu.js';
import { MessageSquare, ChevronDown, ChevronRight, MoreHorizontal } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { genUserName } from '@/lib/genUserName.js';
export function Comment({ root, comment, depth = 0, maxDepth = 3, limit }) {
const [showReplyForm, setShowReplyForm] = useState(false);
const [showReplies, setShowReplies] = useState(depth < 2); // Auto-expand first 2 levels
const author = useAuthor(comment.pubkey);
const { data: commentsData } = useComments(root, limit);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(comment.pubkey);
const timeAgo = formatDistanceToNow(new Date(comment.created_at * 1000), { addSuffix: true });
// Get direct replies to this comment
const replies = commentsData?.getDirectReplies(comment.id) || [];
const hasReplies = replies.length > 0;
return (_jsxs("div", { className: `space-y-3 ${depth > 0 ? 'ml-6 border-l-2 border-muted pl-4' : ''}`, children: [_jsx(Card, { className: "bg-card/50", children: _jsx(CardContent, { className: "p-4", children: _jsxs("div", { className: "space-y-3", children: [_jsx("div", { className: "flex items-start justify-between", children: _jsxs("div", { className: "flex items-center space-x-3", children: [_jsx(Link, { to: `/${nip19.npubEncode(comment.pubkey)}`, children: _jsxs(Avatar, { className: "h-8 w-8 hover:ring-2 hover:ring-primary/30 transition-all cursor-pointer", children: [_jsx(AvatarImage, { src: metadata?.picture }), _jsx(AvatarFallback, { className: "text-xs", children: displayName.charAt(0) })] }) }), _jsxs("div", { children: [_jsx(Link, { to: `/${nip19.npubEncode(comment.pubkey)}`, className: "font-medium text-sm hover:text-primary transition-colors", children: displayName }), _jsx("p", { className: "text-xs text-muted-foreground", children: timeAgo })] })] }) }), _jsx("div", { className: "text-sm", children: _jsx(NoteContent, { event: comment, className: "text-sm" }) }), _jsxs("div", { className: "flex items-center justify-between pt-2", children: [_jsxs("div", { className: "flex items-center space-x-2", children: [_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => setShowReplyForm(!showReplyForm), className: "h-8 px-2 text-xs", children: [_jsx(MessageSquare, { className: "h-3 w-3 mr-1" }), "Reply"] }), hasReplies && (_jsx(Collapsible, { open: showReplies, onOpenChange: setShowReplies, children: _jsx(CollapsibleTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", size: "sm", className: "h-8 px-2 text-xs", children: [showReplies ? (_jsx(ChevronDown, { className: "h-3 w-3 mr-1" })) : (_jsx(ChevronRight, { className: "h-3 w-3 mr-1" })), replies.length, " ", replies.length === 1 ? 'reply' : 'replies'] }) }) }))] }), _jsx(DropdownMenu, { children: _jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "sm", className: "h-8 px-2 text-xs", "aria-label": "Comment options", children: _jsx(MoreHorizontal, { className: "h-3 w-3" }) }) }) })] })] }) }) }), showReplyForm && (_jsx("div", { className: "ml-6", children: _jsx(CommentForm, { root: root, reply: comment, onSuccess: () => setShowReplyForm(false), placeholder: "Write a reply...", compact: true }) })), hasReplies && (_jsx(Collapsible, { open: showReplies, onOpenChange: setShowReplies, children: _jsx(CollapsibleContent, { className: "space-y-3", children: replies.map((reply) => (_jsx(Comment, { root: root, comment: reply, depth: depth + 1, maxDepth: maxDepth, limit: limit }, reply.id))) }) }))] }));
}

View File

@ -0,0 +1,29 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { Button } from '@/components/ui/button.js';
import { Textarea } from '@/components/ui/textarea.js';
import { Card, CardContent } from '@/components/ui/card.js';
import { useCurrentUser } from '@/hooks/useCurrentUser.js';
import { usePostComment } from '@/hooks/usePostComment.js';
import { LoginArea } from '@/components/auth/LoginArea.js';
import { MessageSquare, Send } from 'lucide-react';
export function CommentForm({ root, reply, onSuccess, placeholder = "Write a comment...", compact = false }) {
const [content, setContent] = useState('');
const { user } = useCurrentUser();
const { mutate: postComment, isPending } = usePostComment();
const handleSubmit = (e) => {
e.preventDefault();
if (!content.trim() || !user)
return;
postComment({ content: content.trim(), root, reply }, {
onSuccess: () => {
setContent('');
onSuccess?.();
},
});
};
if (!user) {
return (_jsx(Card, { className: compact ? "border-dashed" : "", children: _jsx(CardContent, { className: compact ? "p-4" : "p-6", children: _jsxs("div", { className: "text-center space-y-4", children: [_jsxs("div", { className: "flex items-center justify-center space-x-2 text-muted-foreground", children: [_jsx(MessageSquare, { className: "h-5 w-5" }), _jsxs("span", { children: ["Sign in to ", reply ? 'reply' : 'comment'] })] }), _jsx(LoginArea, {})] }) }) }));
}
return (_jsx(Card, { className: compact ? "border-dashed" : "", children: _jsx(CardContent, { className: compact ? "p-4" : "p-6", children: _jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [_jsx(Textarea, { value: content, onChange: (e) => setContent(e.target.value), placeholder: placeholder, className: compact ? "min-h-[80px]" : "min-h-[100px]", disabled: isPending }), _jsxs("div", { className: "flex justify-between items-center", children: [_jsx("span", { className: "text-sm text-muted-foreground", children: reply ? 'Replying to comment' : 'Adding to the discussion' }), _jsxs(Button, { type: "submit", disabled: !content.trim() || isPending, size: compact ? "sm" : "default", children: [_jsx(Send, { className: "h-4 w-4 mr-2" }), isPending ? 'Posting...' : (reply ? 'Reply' : 'Comment')] })] })] }) }) }));
}

View File

@ -0,0 +1,16 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useComments } from '@/hooks/useComments.js';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.js';
import { Skeleton } from '@/components/ui/skeleton.js';
import { MessageSquare } from 'lucide-react';
import { cn } from '@/lib/utils.js';
import { CommentForm } from "./CommentForm.js";
import { Comment } from "./Comment.js";
export function CommentsSection({ root, title = "Comments", emptyStateMessage = "No comments yet", emptyStateSubtitle = "Be the first to share your thoughts!", className, limit = 500, }) {
const { data: commentsData, isLoading, error } = useComments(root, limit);
const comments = commentsData?.topLevelComments || [];
if (error) {
return (_jsx(Card, { className: "rounded-none sm:rounded-lg mx-0 sm:mx-0", children: _jsx(CardContent, { className: "px-2 py-6 sm:p-6", children: _jsxs("div", { className: "text-center text-muted-foreground", children: [_jsx(MessageSquare, { className: "h-8 w-8 mx-auto mb-2 opacity-50" }), _jsx("p", { children: "Failed to load comments" })] }) }) }));
}
return (_jsxs(Card, { className: cn("rounded-none sm:rounded-lg mx-0 sm:mx-0", className), children: [_jsx(CardHeader, { className: "px-2 pt-6 pb-4 sm:p-6", children: _jsxs(CardTitle, { className: "flex items-center space-x-2", children: [_jsx(MessageSquare, { className: "h-5 w-5" }), _jsx("span", { children: title }), !isLoading && (_jsxs("span", { className: "text-sm font-normal text-muted-foreground", children: ["(", comments.length, ")"] }))] }) }), _jsxs(CardContent, { className: "px-2 pb-6 pt-4 sm:p-6 sm:pt-0 space-y-6", children: [_jsx(CommentForm, { root: root }), isLoading ? (_jsx("div", { className: "space-y-4", children: [...Array(3)].map((_, i) => (_jsx(Card, { className: "bg-card/50", children: _jsx(CardContent, { className: "p-4", children: _jsxs("div", { className: "space-y-3", children: [_jsxs("div", { className: "flex items-center space-x-3", children: [_jsx(Skeleton, { className: "h-8 w-8 rounded-full" }), _jsxs("div", { className: "space-y-1", children: [_jsx(Skeleton, { className: "h-4 w-24" }), _jsx(Skeleton, { className: "h-3 w-16" })] })] }), _jsx(Skeleton, { className: "h-16 w-full" })] }) }) }, i))) })) : comments.length === 0 ? (_jsxs("div", { className: "text-center py-8 text-muted-foreground", children: [_jsx(MessageSquare, { className: "h-12 w-12 mx-auto mb-4 opacity-30" }), _jsx("p", { className: "text-lg font-medium mb-2", children: emptyStateMessage }), _jsx("p", { className: "text-sm", children: emptyStateSubtitle })] })) : (_jsx("div", { className: "space-y-4", children: comments.map((comment) => (_jsx(Comment, { root: root, comment: comment }, comment.id))) }))] })] }));
}

View File

@ -0,0 +1,13 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils.js";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef(({ className, ...props }, ref) => (_jsx(AccordionPrimitive.Item, { ref: ref, className: cn("border-b", className), ...props })));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef(({ className, children, ...props }, ref) => (_jsx(AccordionPrimitive.Header, { className: "flex", children: _jsxs(AccordionPrimitive.Trigger, { ref: ref, className: cn("flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180", className), ...props, children: [children, _jsx(ChevronDown, { className: "h-4 w-4 shrink-0 transition-transform duration-200" })] }) })));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef(({ className, children, ...props }, ref) => (_jsx(AccordionPrimitive.Content, { ref: ref, className: "overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down", ...props, children: _jsx("div", { className: cn("pb-4 pt-0", className), children: children }) })));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@ -0,0 +1,26 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils.js";
import { buttonVariants } from "@/components/ui/button-variants.js";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => (_jsx(AlertDialogPrimitive.Overlay, { className: cn("fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", className), ...props, ref: ref })));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (_jsxs(AlertDialogPortal, { children: [_jsx(AlertDialogOverlay, {}), _jsx(AlertDialogPrimitive.Content, { ref: ref, className: cn("fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", className), ...props })] })));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }) => (_jsx("div", { className: cn("flex flex-col space-y-2 text-center sm:text-left", className), ...props }));
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }) => (_jsx("div", { className: cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className), ...props }));
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (_jsx(AlertDialogPrimitive.Title, { ref: ref, className: cn("text-lg font-semibold", className), ...props })));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => (_jsx(AlertDialogPrimitive.Description, { ref: ref, className: cn("text-sm text-muted-foreground", className), ...props })));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (_jsx(AlertDialogPrimitive.Action, { ref: ref, className: cn(buttonVariants(), className), ...props })));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => (_jsx(AlertDialogPrimitive.Cancel, { ref: ref, className: cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className), ...props })));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export { AlertDialog, AlertDialogPortal, AlertDialogOverlay, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel, };

View File

@ -0,0 +1,22 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils.js";
const alertVariants = cva("relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", {
variants: {
variant: {
default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
});
const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (_jsx("div", { ref: ref, role: "alert", className: cn(alertVariants({ variant }), className), ...props })));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef(({ className, ...props }, ref) => (_jsx("h5", { ref: ref, className: cn("mb-1 font-medium leading-none tracking-tight", className), ...props })));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { ref: ref, className: cn("text-sm [&_p]:leading-relaxed", className), ...props })));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View File

@ -0,0 +1,3 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };

View File

@ -0,0 +1,11 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils.js";
const Avatar = React.forwardRef(({ className, ...props }, ref) => (_jsx(AvatarPrimitive.Root, { ref: ref, className: cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className), ...props })));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef(({ className, ...props }, ref) => (_jsx(AvatarPrimitive.Image, { ref: ref, className: cn("aspect-square h-full w-full object-cover", className), ...props })));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => (_jsx(AvatarPrimitive.Fallback, { ref: ref, className: cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className), ...props })));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@ -0,0 +1,14 @@
import { cva } from "class-variance-authority";
export const badgeVariants = cva("inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", {
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
});

View File

@ -0,0 +1,7 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { cn } from "@/lib/utils.js";
import { badgeVariants } from "./badge-variants.js";
function Badge({ className, variant, ...props }) {
return (_jsx("div", { className: cn(badgeVariants({ variant }), className), ...props }));
}
export { Badge };

View File

@ -0,0 +1,23 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils.js";
const Breadcrumb = React.forwardRef(({ ...props }, ref) => _jsx("nav", { ref: ref, "aria-label": "breadcrumb", ...props }));
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => (_jsx("ol", { ref: ref, className: cn("flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", className), ...props })));
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => (_jsx("li", { ref: ref, className: cn("inline-flex items-center gap-1.5", className), ...props })));
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (_jsx(Comp, { ref: ref, className: cn("transition-colors hover:text-foreground", className), ...props }));
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => (_jsx("span", { ref: ref, role: "link", "aria-disabled": "true", "aria-current": "page", className: cn("font-normal text-foreground", className), ...props })));
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }) => (_jsx("li", { role: "presentation", "aria-hidden": "true", className: cn("[&>svg]:size-3.5", className), ...props, children: children ?? _jsx(ChevronRight, {}) }));
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({ className, ...props }) => (_jsxs("span", { role: "presentation", "aria-hidden": "true", className: cn("flex h-9 w-9 items-center justify-center", className), ...props, children: [_jsx(MoreHorizontal, { className: "h-4 w-4" }), _jsx("span", { className: "sr-only", children: "More" })] }));
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbEllipsis, };

View File

@ -0,0 +1,23 @@
import { cva } from "class-variance-authority";
export const buttonVariants = cva("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", {
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});

View File

@ -0,0 +1,11 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils.js";
import { buttonVariants } from "./button-variants.js";
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (_jsx(Comp, { className: cn(buttonVariants({ variant, size, className })), ref: ref, ...props }));
});
Button.displayName = "Button";
export { Button };

View File

@ -0,0 +1,36 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils.js";
import { buttonVariants } from "@/components/ui/button-variants.js";
function Calendar({ className, classNames, showOutsideDays = true, ...props }) {
return (_jsx(DayPicker, { showOutsideDays: showOutsideDays, className: cn("p-3", className), classNames: {
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(buttonVariants({ variant: "outline" }), "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_range_end: "day-range-end",
day_selected: "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside: "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}, components: {
IconLeft: ({ ..._props }) => _jsx(ChevronLeft, { className: "h-4 w-4" }),
IconRight: ({ ..._props }) => _jsx(ChevronRight, { className: "h-4 w-4" }),
}, ...props }));
}
Calendar.displayName = "Calendar";
export { Calendar };

16
src/components/ui/card.js Normal file
View File

@ -0,0 +1,16 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import { cn } from "@/lib/utils.js";
const Card = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { ref: ref, className: cn("rounded-lg border bg-card text-card-foreground shadow-sm", className), ...props })));
Card.displayName = "Card";
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { ref: ref, className: cn("flex flex-col space-y-1.5 p-6", className), ...props })));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (_jsx("h3", { ref: ref, className: cn("text-2xl font-semibold leading-none tracking-tight", className), ...props })));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (_jsx("p", { ref: ref, className: cn("text-sm text-muted-foreground", className), ...props })));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { ref: ref, className: cn("p-6 pt-0", className), ...props })));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { ref: ref, className: cn("flex items-center p-6 pt-0", className), ...props })));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@ -0,0 +1,98 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import useEmblaCarousel from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils.js";
import { Button } from "@/components/ui/button.js";
const CarouselContext = React.createContext(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef(({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel({
...opts,
axis: orientation === "horizontal" ? "x" : "y",
}, plugins);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback((event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
}
else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
}, [scrollPrev, scrollNext]);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (_jsx(CarouselContext.Provider, { value: {
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}, children: _jsx("div", { ref: ref, onKeyDownCapture: handleKeyDown, className: cn("relative", className), role: "region", "aria-roledescription": "carousel", ...props, children: children }) }));
});
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (_jsx("div", { ref: carouselRef, className: "overflow-hidden", children: _jsx("div", { ref: ref, className: cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className), ...props }) }));
});
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (_jsx("div", { ref: ref, role: "group", "aria-roledescription": "slide", className: cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className), ...props }));
});
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (_jsxs(Button, { ref: ref, variant: variant, size: size, className: cn("absolute h-8 w-8 rounded-full", orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90", className), disabled: !canScrollPrev, onClick: scrollPrev, ...props, children: [_jsx(ArrowLeft, { className: "h-4 w-4" }), _jsx("span", { className: "sr-only", children: "Previous slide" })] }));
});
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (_jsxs(Button, { ref: ref, variant: variant, size: size, className: cn("absolute h-8 w-8 rounded-full", orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", className), disabled: !canScrollNext, onClick: scrollNext, ...props, children: [_jsx(ArrowRight, { className: "h-4 w-4" }), _jsx("span", { className: "sr-only", children: "Next slide" })] }));
});
CarouselNext.displayName = "CarouselNext";
export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, };

130
src/components/ui/chart.js Normal file
View File

@ -0,0 +1,130 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils.js";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" };
const ChartContext = React.createContext(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (_jsx(ChartContext.Provider, { value: { config }, children: _jsxs("div", { "data-chart": chartId, ref: ref, className: cn("flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", className), ...props, children: [_jsx(ChartStyle, { id: chartId, config: config }), _jsx(RechartsPrimitive.ResponsiveContainer, { children: children })] }) }));
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (_jsx("style", { dangerouslySetInnerHTML: {
__html: Object.entries(THEMES)
.map(([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`)
.join("\n"),
} }));
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef(({ active, payload, className, indicator = "dot", hideLabel = false, hideIndicator = false, label, labelFormatter, labelClassName, formatter, color, nameKey, labelKey, }, ref) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value = !labelKey && typeof label === "string"
? config[label]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (_jsx("div", { className: cn("font-medium", labelClassName), children: labelFormatter(value, payload) }));
}
if (!value) {
return null;
}
return _jsx("div", { className: cn("font-medium", labelClassName), children: value });
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (_jsxs("div", { ref: ref, className: cn("grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", className), children: [!nestLabel ? tooltipLabel : null, _jsx("div", { className: "grid gap-1.5", children: payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (_jsx("div", { className: cn("flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", indicator === "dot" && "items-center"), children: formatter && item?.value !== undefined && item.name ? (formatter(item.value, item.name, item, index, item.payload)) : (_jsxs(_Fragment, { children: [itemConfig?.icon ? (_jsx(itemConfig.icon, {})) : (!hideIndicator && (_jsx("div", { className: cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}), style: {
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} }))), _jsxs("div", { className: cn("flex flex-1 justify-between leading-none", nestLabel ? "items-end" : "items-center"), children: [_jsxs("div", { className: "grid gap-1.5", children: [nestLabel ? tooltipLabel : null, _jsx("span", { className: "text-muted-foreground", children: itemConfig?.label || item.name })] }), item.value && (_jsx("span", { className: "font-mono font-medium tabular-nums text-foreground", children: item.value.toLocaleString() }))] })] })) }, item.dataKey));
}) })] }));
});
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (_jsx("div", { ref: ref, className: cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className), children: payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (_jsxs("div", { className: cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"), children: [itemConfig?.icon && !hideIcon ? (_jsx(itemConfig.icon, {})) : (_jsx("div", { className: "h-2 w-2 shrink-0 rounded-[2px]", style: {
backgroundColor: item.color,
} })), itemConfig?.label] }, item.value));
}) }));
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config, payload, key) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload = "payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey = key;
if (key in payload &&
typeof payload[key] === "string") {
configLabelKey = payload[key];
}
else if (payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key] === "string") {
configLabelKey = payloadPayload[key];
}
return configLabelKey in config
? config[configLabelKey]
: config[key];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle, };

View File

@ -0,0 +1,8 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils.js";
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (_jsx(CheckboxPrimitive.Root, { ref: ref, className: cn("peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", className), ...props, children: _jsx(CheckboxPrimitive.Indicator, { className: cn("flex items-center justify-center text-current"), children: _jsx(Check, { className: "h-4 w-4" }) }) })));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@ -0,0 +1,5 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@ -0,0 +1,28 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils.js";
import { Dialog, DialogContent } from "@/components/ui/dialog.js";
const Command = React.forwardRef(({ className, ...props }, ref) => (_jsx(CommandPrimitive, { ref: ref, className: cn("flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", className), ...props })));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }) => {
return (_jsx(Dialog, { ...props, children: _jsx(DialogContent, { className: "overflow-hidden p-0 shadow-lg", children: _jsx(Command, { className: "[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5", children: children }) }) }));
};
const CommandInput = React.forwardRef(({ className, ...props }, ref) => (_jsxs("div", { className: "flex items-center border-b px-3", "cmdk-input-wrapper": "", children: [_jsx(Search, { className: "mr-2 h-4 w-4 shrink-0 opacity-50" }), _jsx(CommandPrimitive.Input, { ref: ref, className: cn("flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", className), ...props })] })));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef(({ className, ...props }, ref) => (_jsx(CommandPrimitive.List, { ref: ref, className: cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className), ...props })));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef((props, ref) => (_jsx(CommandPrimitive.Empty, { ref: ref, className: "py-6 text-center text-sm", ...props })));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef(({ className, ...props }, ref) => (_jsx(CommandPrimitive.Group, { ref: ref, className: cn("overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", className), ...props })));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef(({ className, ...props }, ref) => (_jsx(CommandPrimitive.Separator, { ref: ref, className: cn("-mx-1 h-px bg-border", className), ...props })));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef(({ className, ...props }, ref) => (_jsx(CommandPrimitive.Item, { ref: ref, className: cn("relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50", className), ...props })));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }) => {
return (_jsx("span", { className: cn("ml-auto text-xs tracking-widest text-muted-foreground", className), ...props }));
};
CommandShortcut.displayName = "CommandShortcut";
export { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator, };

View File

@ -0,0 +1,33 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils.js";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (_jsxs(ContextMenuPrimitive.SubTrigger, { ref: ref, className: cn("flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", inset && "pl-8", className), ...props, children: [children, _jsx(ChevronRight, { className: "ml-auto h-4 w-4" })] })));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (_jsx(ContextMenuPrimitive.SubContent, { ref: ref, className: cn("z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className), ...props })));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef(({ className, ...props }, ref) => (_jsx(ContextMenuPrimitive.Portal, { children: _jsx(ContextMenuPrimitive.Content, { ref: ref, className: cn("z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className), ...props }) })));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (_jsx(ContextMenuPrimitive.Item, { ref: ref, className: cn("relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", inset && "pl-8", className), ...props })));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (_jsxs(ContextMenuPrimitive.CheckboxItem, { ref: ref, className: cn("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className), checked: checked, ...props, children: [_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(ContextMenuPrimitive.ItemIndicator, { children: _jsx(Check, { className: "h-4 w-4" }) }) }), children] })));
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs(ContextMenuPrimitive.RadioItem, { ref: ref, className: cn("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className), ...props, children: [_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(ContextMenuPrimitive.ItemIndicator, { children: _jsx(Circle, { className: "h-2 w-2 fill-current" }) }) }), children] })));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (_jsx(ContextMenuPrimitive.Label, { ref: ref, className: cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className), ...props })));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (_jsx(ContextMenuPrimitive.Separator, { ref: ref, className: cn("-mx-1 my-1 h-px bg-border", className), ...props })));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }) => {
return (_jsx("span", { className: cn("ml-auto text-xs tracking-widest text-muted-foreground", className), ...props }));
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuGroup, ContextMenuPortal, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, };

View File

@ -0,0 +1,22 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils.js";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (_jsx(DialogPrimitive.Overlay, { ref: ref, className: cn("fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", className), ...props })));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs(DialogPortal, { children: [_jsx(DialogOverlay, {}), _jsxs(DialogPrimitive.Content, { ref: ref, className: cn("fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", className), ...props, children: [children, _jsxs(DialogPrimitive.Close, { className: "absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground", children: [_jsx(X, { className: "h-4 w-4" }), _jsx("span", { className: "sr-only", children: "Close" })] })] })] })));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }) => (_jsx("div", { className: cn("flex flex-col space-y-1.5 text-center sm:text-left", className), ...props }));
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }) => (_jsx("div", { className: cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className), ...props }));
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (_jsx(DialogPrimitive.Title, { ref: ref, className: cn("text-lg font-semibold leading-none tracking-tight", className), ...props })));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (_jsx(DialogPrimitive.Description, { ref: ref, className: cn("text-sm text-muted-foreground", className), ...props })));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, };

View File

@ -0,0 +1,22 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils.js";
const Drawer = ({ shouldScaleBackground = true, ...props }) => (_jsx(DrawerPrimitive.Root, { shouldScaleBackground: shouldScaleBackground, ...props }));
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef(({ className, ...props }, ref) => (_jsx(DrawerPrimitive.Overlay, { ref: ref, className: cn("fixed inset-0 z-50 bg-black/80", className), ...props })));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs(DrawerPortal, { children: [_jsx(DrawerOverlay, {}), _jsxs(DrawerPrimitive.Content, { ref: ref, className: cn("fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background", className), ...props, children: [_jsx("div", { className: "mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" }), children] })] })));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({ className, ...props }) => (_jsx("div", { className: cn("grid gap-1.5 p-4 text-center sm:text-left", className), ...props }));
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({ className, ...props }) => (_jsx("div", { className: cn("mt-auto flex flex-col gap-2 p-4", className), ...props }));
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef(({ className, ...props }, ref) => (_jsx(DrawerPrimitive.Title, { ref: ref, className: cn("text-lg font-semibold leading-none tracking-tight", className), ...props })));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef(({ className, ...props }, ref) => (_jsx(DrawerPrimitive.Description, { ref: ref, className: cn("text-sm text-muted-foreground", className), ...props })));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export { Drawer, DrawerPortal, DrawerOverlay, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, };

View File

@ -0,0 +1,35 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils.js";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (_jsxs(DropdownMenuPrimitive.SubTrigger, { ref: ref, className: cn("flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent", inset && "pl-8", className), ...props, children: [children, _jsx(ChevronRight, { className: "ml-auto h-4 w-4" })] })));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (_jsx(DropdownMenuPrimitive.SubContent, { ref: ref, className: cn("z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className), ...props })));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (_jsx(DropdownMenuPrimitive.Portal, { children: _jsx(DropdownMenuPrimitive.Content, { ref: ref, sideOffset: sideOffset, className: cn("z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className), ...props }) })));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (_jsx(DropdownMenuPrimitive.Item, { ref: ref, className: cn("relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", inset && "pl-8", className), ...props })));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (_jsxs(DropdownMenuPrimitive.CheckboxItem, { ref: ref, className: cn("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className), checked: checked, ...props, children: [_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(DropdownMenuPrimitive.ItemIndicator, { children: _jsx(Check, { className: "h-4 w-4" }) }) }), children] })));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs(DropdownMenuPrimitive.RadioItem, { ref: ref, className: cn("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className), ...props, children: [_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(DropdownMenuPrimitive.ItemIndicator, { children: _jsx(Circle, { className: "h-2 w-2 fill-current" }) }) }), children] })));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (_jsx(DropdownMenuPrimitive.Label, { ref: ref, className: cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className), ...props })));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (_jsx(DropdownMenuPrimitive.Separator, { ref: ref, className: cn("-mx-1 my-1 h-px bg-muted", className), ...props })));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }) => {
return (_jsx("span", { className: cn("ml-auto text-xs tracking-widest opacity-60", className), ...props }));
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuGroup, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup, };

View File

@ -0,0 +1,22 @@
import * as React from "react";
import { useFormContext, } from "react-hook-form";
export const FormFieldContext = React.createContext({});
export const FormItemContext = React.createContext({});
export const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};

43
src/components/ui/form.js Normal file
View File

@ -0,0 +1,43 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { Controller, FormProvider, } from "react-hook-form";
import { cn } from "@/lib/utils.js";
import { Label } from "@/components/ui/label.js";
import { FormFieldContext, FormItemContext, useFormField } from "./form-utils.js";
const Form = FormProvider;
const FormField = ({ ...props }) => {
return (_jsx(FormFieldContext.Provider, { value: { name: props.name }, children: _jsx(Controller, { ...props }) }));
};
const FormItem = React.forwardRef(({ className, ...props }, ref) => {
const id = React.useId();
return (_jsx(FormItemContext.Provider, { value: { id }, children: _jsx("div", { ref: ref, className: cn("space-y-2", className), ...props }) }));
});
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (_jsx(Label, { ref: ref, className: cn(error && "text-destructive", className), htmlFor: formItemId, ...props }));
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (_jsx(Slot, { ref: ref, id: formItemId, "aria-describedby": !error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`, "aria-invalid": !!error, ...props }));
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (_jsx("p", { ref: ref, id: formDescriptionId, className: cn("text-sm text-muted-foreground", className), ...props }));
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (_jsx("p", { ref: ref, id: formMessageId, className: cn("text-sm font-medium text-destructive", className), ...props, children: body }));
});
FormMessage.displayName = "FormMessage";
export { Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField, };

View File

@ -0,0 +1,9 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils.js";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (_jsx(HoverCardPrimitive.Content, { ref: ref, align: align, sideOffset: sideOffset, className: cn("z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className), ...props })));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@ -0,0 +1,18 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { Dot } from "lucide-react";
import { cn } from "@/lib/utils.js";
const InputOTP = React.forwardRef(({ className, containerClassName, ...props }, ref) => (_jsx(OTPInput, { ref: ref, containerClassName: cn("flex items-center gap-2 has-[:disabled]:opacity-50", containerClassName), className: cn("disabled:cursor-not-allowed", className), ...props })));
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { ref: ref, className: cn("flex items-center", className), ...props })));
InputOTPGroup.displayName = "InputOTPGroup";
const InputOTPSlot = React.forwardRef(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (_jsxs("div", { ref: ref, className: cn("relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md", isActive && "z-10 ring-2 ring-ring ring-offset-background", className), ...props, children: [char, hasFakeCaret && (_jsx("div", { className: "pointer-events-none absolute inset-0 flex items-center justify-center", children: _jsx("div", { className: "h-4 w-px animate-caret-blink bg-foreground duration-1000" }) }))] }));
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef(({ ...props }, ref) => (_jsx("div", { ref: ref, role: "separator", ...props, children: _jsx(Dot, {}) })));
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@ -0,0 +1,8 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import { cn } from "@/lib/utils.js";
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (_jsx("input", { type: type, className: cn("flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", className), ref: ref, ...props }));
});
Input.displayName = "Input";
export { Input };

View File

@ -0,0 +1,9 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils.js";
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
const Label = React.forwardRef(({ className, ...props }, ref) => (_jsx(LabelPrimitive.Root, { ref: ref, className: cn(labelVariants(), className), ...props })));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@ -0,0 +1,35 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils.js";
const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group;
const MenubarPortal = MenubarPrimitive.Portal;
const MenubarSub = MenubarPrimitive.Sub;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const Menubar = React.forwardRef(({ className, ...props }, ref) => (_jsx(MenubarPrimitive.Root, { ref: ref, className: cn("flex h-10 items-center space-x-1 rounded-md border bg-background p-1", className), ...props })));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef(({ className, ...props }, ref) => (_jsx(MenubarPrimitive.Trigger, { ref: ref, className: cn("flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", className), ...props })));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (_jsxs(MenubarPrimitive.SubTrigger, { ref: ref, className: cn("flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", inset && "pl-8", className), ...props, children: [children, _jsx(ChevronRight, { className: "ml-auto h-4 w-4" })] })));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef(({ className, ...props }, ref) => (_jsx(MenubarPrimitive.SubContent, { ref: ref, className: cn("z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className), ...props })));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (_jsx(MenubarPrimitive.Portal, { children: _jsx(MenubarPrimitive.Content, { ref: ref, align: align, alignOffset: alignOffset, sideOffset: sideOffset, className: cn("z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className), ...props }) })));
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef(({ className, inset, ...props }, ref) => (_jsx(MenubarPrimitive.Item, { ref: ref, className: cn("relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", inset && "pl-8", className), ...props })));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (_jsxs(MenubarPrimitive.CheckboxItem, { ref: ref, className: cn("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className), checked: checked, ...props, children: [_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(MenubarPrimitive.ItemIndicator, { children: _jsx(Check, { className: "h-4 w-4" }) }) }), children] })));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs(MenubarPrimitive.RadioItem, { ref: ref, className: cn("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className), ...props, children: [_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(MenubarPrimitive.ItemIndicator, { children: _jsx(Circle, { className: "h-2 w-2 fill-current" }) }) }), children] })));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef(({ className, inset, ...props }, ref) => (_jsx(MenubarPrimitive.Label, { ref: ref, className: cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className), ...props })));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef(({ className, ...props }, ref) => (_jsx(MenubarPrimitive.Separator, { ref: ref, className: cn("-mx-1 my-1 h-px bg-muted", className), ...props })));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({ className, ...props }) => {
return (_jsx("span", { className: cn("ml-auto text-xs tracking-widest text-muted-foreground", className), ...props }));
};
MenubarShortcut.displayname = "MenubarShortcut";
export { Menubar, MenubarMenu, MenubarTrigger, MenubarContent, MenubarItem, MenubarSeparator, MenubarLabel, MenubarCheckboxItem, MenubarRadioGroup, MenubarRadioItem, MenubarPortal, MenubarSubContent, MenubarSubTrigger, MenubarGroup, MenubarSub, MenubarShortcut, };

View File

@ -0,0 +1,2 @@
import { cva } from "class-variance-authority";
export const navigationMenuTriggerStyle = cva("group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50");

View File

@ -0,0 +1,23 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils.js";
import { navigationMenuTriggerStyle } from "./navigation-menu-variants.js";
const NavigationMenu = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs(NavigationMenuPrimitive.Root, { ref: ref, className: cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className), ...props, children: [children, _jsx(NavigationMenuViewport, {})] })));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef(({ className, ...props }, ref) => (_jsx(NavigationMenuPrimitive.List, { ref: ref, className: cn("group flex flex-1 list-none items-center justify-center space-x-1", className), ...props })));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const NavigationMenuTrigger = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs(NavigationMenuPrimitive.Trigger, { ref: ref, className: cn(navigationMenuTriggerStyle(), "group", className), ...props, children: [children, " ", _jsx(ChevronDown, { className: "relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180", "aria-hidden": "true" })] })));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef(({ className, ...props }, ref) => (_jsx(NavigationMenuPrimitive.Content, { ref: ref, className: cn("left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ", className), ...props })));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { className: cn("absolute left-0 top-full flex justify-center"), children: _jsx(NavigationMenuPrimitive.Viewport, { className: cn("origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]", className), ref: ref, ...props }) })));
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef(({ className, ...props }, ref) => (_jsx(NavigationMenuPrimitive.Indicator, { ref: ref, className: cn("top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in", className), ...props, children: _jsx("div", { className: "relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" }) })));
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName;
export { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuContent, NavigationMenuTrigger, NavigationMenuLink, NavigationMenuIndicator, NavigationMenuViewport, };

View File

@ -0,0 +1,23 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils.js";
import { buttonVariants } from "@/components/ui/button-variants.js";
const Pagination = ({ className, ...props }) => (_jsx("nav", { role: "navigation", "aria-label": "pagination", className: cn("mx-auto flex w-full justify-center", className), ...props }));
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef(({ className, ...props }, ref) => (_jsx("ul", { ref: ref, className: cn("flex flex-row items-center gap-1", className), ...props })));
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef(({ className, ...props }, ref) => (_jsx("li", { ref: ref, className: cn("", className), ...props })));
PaginationItem.displayName = "PaginationItem";
const PaginationLink = ({ className, isActive, size = "icon", ...props }) => (_jsx("a", { "aria-current": isActive ? "page" : undefined, className: cn(buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}), className), ...props }));
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({ className, size = "default", ...props }) => (_jsxs(PaginationLink, { "aria-label": "Go to previous page", size: size, className: cn("gap-1 pl-2.5", className), ...props, children: [_jsx(ChevronLeft, { className: "h-4 w-4" }), _jsx("span", { children: "Previous" })] }));
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({ className, size = "default", ...props }) => (_jsxs(PaginationLink, { "aria-label": "Go to next page", size: size, className: cn("gap-1 pr-2.5", className), ...props, children: [_jsx("span", { children: "Next" }), _jsx(ChevronRight, { className: "h-4 w-4" })] }));
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({ className, ...props }) => (_jsxs("span", { "aria-hidden": true, className: cn("flex h-9 w-9 items-center justify-center", className), ...props, children: [_jsx(MoreHorizontal, { className: "h-4 w-4" }), _jsx("span", { className: "sr-only", children: "More pages" })] }));
PaginationEllipsis.displayName = "PaginationEllipsis";
export { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, };

View File

@ -0,0 +1,9 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils.js";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (_jsx(PopoverPrimitive.Portal, { children: _jsx(PopoverPrimitive.Content, { ref: ref, align: align, sideOffset: sideOffset, className: cn("z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className), ...props }) })));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@ -0,0 +1,7 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils.js";
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (_jsx(ProgressPrimitive.Root, { ref: ref, className: cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className), ...props, children: _jsx(ProgressPrimitive.Indicator, { className: "h-full w-full flex-1 bg-primary transition-all", style: { transform: `translateX(-${100 - (value || 0)}%)` } }) })));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@ -0,0 +1,14 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils.js";
const RadioGroup = React.forwardRef(({ className, ...props }, ref) => {
return (_jsx(RadioGroupPrimitive.Root, { className: cn("grid gap-2", className), ...props, ref: ref }));
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => {
return (_jsx(RadioGroupPrimitive.Item, { ref: ref, className: cn("aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", className), ...props, children: _jsx(RadioGroupPrimitive.Indicator, { className: "flex items-center justify-center", children: _jsx(Circle, { className: "h-2.5 w-2.5 fill-current text-current" }) }) }));
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@ -0,0 +1,8 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils.js";
const ResizablePanelGroup = ({ className, ...props }) => (_jsx(ResizablePrimitive.PanelGroup, { className: cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className), ...props }));
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({ withHandle, className, ...props }) => (_jsx(ResizablePrimitive.PanelResizeHandle, { className: cn("relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90", className), ...props, children: withHandle && (_jsx("div", { className: "z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border", children: _jsx(GripVertical, { className: "h-2.5 w-2.5" }) })) }));
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@ -0,0 +1,11 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils.js";
const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs(ScrollAreaPrimitive.Root, { ref: ref, className: cn("relative overflow-hidden", className), ...props, children: [_jsx(ScrollAreaPrimitive.Viewport, { className: "h-full w-full rounded-[inherit]", children: children }), _jsx(ScrollBar, {}), _jsx(ScrollAreaPrimitive.Corner, {})] })));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef(({ className, orientation = "vertical", ...props }, ref) => (_jsx(ScrollAreaPrimitive.ScrollAreaScrollbar, { ref: ref, orientation: orientation, className: cn("flex touch-none select-none transition-colors", orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]", orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]", className), ...props, children: _jsx(ScrollAreaPrimitive.ScrollAreaThumb, { className: "relative flex-1 rounded-full bg-border" }) })));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@ -0,0 +1,26 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils.js";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs(SelectPrimitive.Trigger, { ref: ref, className: cn("flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", className), ...props, children: [children, _jsx(SelectPrimitive.Icon, { asChild: true, children: _jsx(ChevronDown, { className: "h-4 w-4 opacity-50" }) })] })));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (_jsx(SelectPrimitive.ScrollUpButton, { ref: ref, className: cn("flex cursor-default items-center justify-center py-1", className), ...props, children: _jsx(ChevronUp, { className: "h-4 w-4" }) })));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (_jsx(SelectPrimitive.ScrollDownButton, { ref: ref, className: cn("flex cursor-default items-center justify-center py-1", className), ...props, children: _jsx(ChevronDown, { className: "h-4 w-4" }) })));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (_jsx(SelectPrimitive.Portal, { children: _jsxs(SelectPrimitive.Content, { ref: ref, className: cn("relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className), position: position, ...props, children: [_jsx(SelectScrollUpButton, {}), _jsx(SelectPrimitive.Viewport, { className: cn("p-1", position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"), children: children }), _jsx(SelectScrollDownButton, {})] }) })));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (_jsx(SelectPrimitive.Label, { ref: ref, className: cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className), ...props })));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs(SelectPrimitive.Item, { ref: ref, className: cn("relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className), ...props, children: [_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(SelectPrimitive.ItemIndicator, { children: _jsx(Check, { className: "h-4 w-4" }) }) }), _jsx(SelectPrimitive.ItemText, { children: children })] })));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (_jsx(SelectPrimitive.Separator, { ref: ref, className: cn("-mx-1 my-1 h-px bg-muted", className), ...props })));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton, };

View File

@ -0,0 +1,7 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils.js";
const Separator = React.forwardRef(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (_jsx(SeparatorPrimitive.Root, { ref: ref, decorative: decorative, orientation: orientation, className: cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className), ...props })));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@ -0,0 +1,36 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils.js";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (_jsx(SheetPrimitive.Overlay, { className: cn("fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", className), ...props, ref: ref })));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva("fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", {
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom: "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right: "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
});
const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => (_jsxs(SheetPortal, { children: [_jsx(SheetOverlay, {}), _jsxs(SheetPrimitive.Content, { ref: ref, className: cn(sheetVariants({ side }), className), ...props, children: [children, _jsxs(SheetPrimitive.Close, { className: "absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary", children: [_jsx(X, { className: "h-4 w-4" }), _jsx("span", { className: "sr-only", children: "Close" })] })] })] })));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }) => (_jsx("div", { className: cn("flex flex-col space-y-2 text-center sm:text-left", className), ...props }));
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ className, ...props }) => (_jsx("div", { className: cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className), ...props }));
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (_jsx(SheetPrimitive.Title, { ref: ref, className: cn("text-lg font-semibold text-foreground", className), ...props })));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (_jsx(SheetPrimitive.Description, { ref: ref, className: cn("text-sm text-muted-foreground", className), ...props })));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger };

View File

@ -0,0 +1,33 @@
import * as React from "react";
import { cva } from "class-variance-authority";
export const SIDEBAR_COOKIE_NAME = "sidebar:state";
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = "16rem";
export const SIDEBAR_WIDTH_MOBILE = "18rem";
export const SIDEBAR_WIDTH_ICON = "3rem";
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
export const SidebarContext = React.createContext(null);
export function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
export const sidebarMenuButtonVariants = cva("peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", {
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline: "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});

View File

@ -0,0 +1,193 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { PanelLeft } from "lucide-react";
import { useIsMobile } from "@/hooks/useIsMobile.js";
import { cn } from "@/lib/utils.js";
import { Button } from "@/components/ui/button.js";
import { Input } from "@/components/ui/input.js";
import { Separator } from "@/components/ui/separator.js";
import { Sheet, SheetContent } from "@/components/ui/sheet.js";
import { Skeleton } from "@/components/ui/skeleton.js";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip.js";
import { SIDEBAR_COOKIE_NAME, SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_WIDTH, SIDEBAR_WIDTH_MOBILE, SIDEBAR_WIDTH_ICON, SIDEBAR_KEYBOARD_SHORTCUT, SidebarContext, useSidebar, sidebarMenuButtonVariants, } from "./sidebar-utils.js";
const SidebarProvider = React.forwardRef(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback((value) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
}
else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}, [setOpenProp, open]);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo(() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]);
return (_jsx(SidebarContext.Provider, { value: contextValue, children: _jsx(TooltipProvider, { delayDuration: 0, children: _jsx("div", { style: {
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
}, className: cn("group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", className), ref: ref, ...props, children: children }) }) }));
});
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (_jsx("div", { className: cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className), ref: ref, ...props, children: children }));
}
if (isMobile) {
return (_jsx(Sheet, { open: openMobile, onOpenChange: setOpenMobile, ...props, children: _jsx(SheetContent, { "data-sidebar": "sidebar", "data-mobile": "true", className: "w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden", style: {
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
}, side: side, children: _jsx("div", { className: "flex h-full w-full flex-col", children: children }) }) }));
}
return (_jsxs("div", { ref: ref, className: "group peer hidden md:block text-sidebar-foreground", "data-state": state, "data-collapsible": state === "collapsed" ? collapsible : "", "data-variant": variant, "data-side": side, children: [_jsx("div", { className: cn("duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear", "group-data-[collapsible=offcanvas]:w-0", "group-data-[side=right]:rotate-180", variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]") }), _jsx("div", { className: cn("duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex", side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l", className), ...props, children: _jsx("div", { "data-sidebar": "sidebar", className: "flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow", children: children }) })] }));
});
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (_jsxs(Button, { ref: ref, "data-sidebar": "trigger", variant: "ghost", size: "icon", className: cn("h-7 w-7", className), onClick: (event) => {
onClick?.(event);
toggleSidebar();
}, ...props, children: [_jsx(PanelLeft, {}), _jsx("span", { className: "sr-only", children: "Toggle Sidebar" })] }));
});
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (_jsx("button", { ref: ref, "data-sidebar": "rail", "aria-label": "Toggle Sidebar", tabIndex: -1, onClick: toggleSidebar, title: "Toggle Sidebar", className: cn("absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex", "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize", "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar", "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", className), ...props }));
});
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef(({ className, ...props }, ref) => {
return (_jsx("main", { ref: ref, className: cn("relative flex min-h-svh flex-1 flex-col bg-background", "peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow", className), ...props }));
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef(({ className, ...props }, ref) => {
return (_jsx(Input, { ref: ref, "data-sidebar": "input", className: cn("h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring", className), ...props }));
});
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef(({ className, ...props }, ref) => {
return (_jsx("div", { ref: ref, "data-sidebar": "header", className: cn("flex flex-col gap-2 p-2", className), ...props }));
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef(({ className, ...props }, ref) => {
return (_jsx("div", { ref: ref, "data-sidebar": "footer", className: cn("flex flex-col gap-2 p-2", className), ...props }));
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef(({ className, ...props }, ref) => {
return (_jsx(Separator, { ref: ref, "data-sidebar": "separator", className: cn("mx-2 w-auto bg-sidebar-border", className), ...props }));
});
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef(({ className, ...props }, ref) => {
return (_jsx("div", { ref: ref, "data-sidebar": "content", className: cn("flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", className), ...props }));
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef(({ className, ...props }, ref) => {
return (_jsx("div", { ref: ref, "data-sidebar": "group", className: cn("relative flex w-full min-w-0 flex-col p-2", className), ...props }));
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (_jsx(Comp, { ref: ref, "data-sidebar": "group-label", className: cn("duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", className), ...props }));
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (_jsx(Comp, { ref: ref, "data-sidebar": "group-action", className: cn("absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden", "group-data-[collapsible=icon]:hidden", className), ...props }));
});
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { ref: ref, "data-sidebar": "group-content", className: cn("w-full text-sm", className), ...props })));
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef(({ className, ...props }, ref) => (_jsx("ul", { ref: ref, "data-sidebar": "menu", className: cn("flex w-full min-w-0 flex-col gap-1", className), ...props })));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef(({ className, ...props }, ref) => (_jsx("li", { ref: ref, "data-sidebar": "menu-item", className: cn("group/menu-item relative", className), ...props })));
SidebarMenuItem.displayName = "SidebarMenuItem";
const SidebarMenuButton = React.forwardRef(({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (_jsx(Comp, { ref: ref, "data-sidebar": "menu-button", "data-size": size, "data-active": isActive, className: cn(sidebarMenuButtonVariants({ variant, size }), className), ...props }));
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: button }), _jsx(TooltipContent, { side: "right", align: "center", hidden: state !== "collapsed" || isMobile, ...tooltip })] }));
});
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (_jsx(Comp, { ref: ref, "data-sidebar": "menu-action", className: cn("absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden", "peer-data-[size=sm]/menu-button:top-1", "peer-data-[size=default]/menu-button:top-1.5", "peer-data-[size=lg]/menu-button:top-2.5", "group-data-[collapsible=icon]:hidden", showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", className), ...props }));
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { ref: ref, "data-sidebar": "menu-badge", className: cn("absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none", "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", "peer-data-[size=sm]/menu-button:top-1", "peer-data-[size=default]/menu-button:top-1.5", "peer-data-[size=lg]/menu-button:top-2.5", "group-data-[collapsible=icon]:hidden", className), ...props })));
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (_jsxs("div", { ref: ref, "data-sidebar": "menu-skeleton", className: cn("rounded-md h-8 flex gap-2 px-2 items-center", className), ...props, children: [showIcon && (_jsx(Skeleton, { className: "size-4 rounded-md", "data-sidebar": "menu-skeleton-icon" })), _jsx(Skeleton, { className: "h-4 flex-1 max-w-[--skeleton-width]", "data-sidebar": "menu-skeleton-text", style: {
"--skeleton-width": width,
} })] }));
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef(({ className, ...props }, ref) => (_jsx("ul", { ref: ref, "data-sidebar": "menu-sub", className: cn("mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5", "group-data-[collapsible=icon]:hidden", className), ...props })));
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef(({ ...props }, ref) => _jsx("li", { ref: ref, ...props }));
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (_jsx(Comp, { ref: ref, "data-sidebar": "menu-sub-button", "data-size": size, "data-active": isActive, className: cn("flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", size === "sm" && "text-xs", size === "md" && "text-sm", "group-data-[collapsible=icon]:hidden", className), ...props }));
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInput, SidebarInset, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarSeparator, SidebarTrigger, };

View File

@ -0,0 +1,6 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { cn } from "@/lib/utils.js";
function Skeleton({ className, ...props }) {
return (_jsx("div", { className: cn("animate-pulse rounded-md bg-muted", className), ...props }));
}
export { Skeleton };

View File

@ -0,0 +1,7 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils.js";
const Slider = React.forwardRef(({ className, ...props }, ref) => (_jsxs(SliderPrimitive.Root, { ref: ref, className: cn("relative flex w-full touch-none select-none items-center", className), ...props, children: [_jsx(SliderPrimitive.Track, { className: "relative h-2 w-full grow overflow-hidden rounded-full bg-secondary", children: _jsx(SliderPrimitive.Range, { className: "absolute h-full bg-primary" }) }), _jsx(SliderPrimitive.Thumb, { className: "block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" })] })));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@ -0,0 +1,7 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils.js";
const Switch = React.forwardRef(({ className, ...props }, ref) => (_jsx(SwitchPrimitives.Root, { className: cn("peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", className), ...props, ref: ref, children: _jsx(SwitchPrimitives.Thumb, { className: cn("pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0") }) })));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@ -0,0 +1,20 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import { cn } from "@/lib/utils.js";
const Table = React.forwardRef(({ className, ...props }, ref) => (_jsx("div", { className: "relative w-full overflow-auto", children: _jsx("table", { ref: ref, className: cn("w-full caption-bottom text-sm", className), ...props }) })));
Table.displayName = "Table";
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (_jsx("thead", { ref: ref, className: cn("[&_tr]:border-b", className), ...props })));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef(({ className, ...props }, ref) => (_jsx("tbody", { ref: ref, className: cn("[&_tr:last-child]:border-0", className), ...props })));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (_jsx("tfoot", { ref: ref, className: cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className), ...props })));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef(({ className, ...props }, ref) => (_jsx("tr", { ref: ref, className: cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className), ...props })));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef(({ className, ...props }, ref) => (_jsx("th", { ref: ref, className: cn("h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0", className), ...props })));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef(({ className, ...props }, ref) => (_jsx("td", { ref: ref, className: cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className), ...props })));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (_jsx("caption", { ref: ref, className: cn("mt-4 text-sm text-muted-foreground", className), ...props })));
TableCaption.displayName = "TableCaption";
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption, };

12
src/components/ui/tabs.js Normal file
View File

@ -0,0 +1,12 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils.js";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef(({ className, ...props }, ref) => (_jsx(TabsPrimitive.List, { ref: ref, className: cn("inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground", className), ...props })));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (_jsx(TabsPrimitive.Trigger, { ref: ref, className: cn("inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm", className), ...props })));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (_jsx(TabsPrimitive.Content, { ref: ref, className: cn("mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", className), ...props })));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,8 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import { cn } from "@/lib/utils.js";
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
return (_jsx("textarea", { className: cn("flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", className), ref: ref, ...props }));
});
Textarea.displayName = "Textarea";
export { Textarea };

View File

@ -0,0 +1,33 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils.js";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef(({ className, ...props }, ref) => (_jsx(ToastPrimitives.Viewport, { ref: ref, className: cn("fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", className), ...props })));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva("group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", {
variants: {
variant: {
default: "border bg-background text-foreground",
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
return (_jsx(ToastPrimitives.Root, { ref: ref, className: cn(toastVariants({ variant }), className), ...props }));
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (_jsx(ToastPrimitives.Action, { ref: ref, className: cn("inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", className), ...props })));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (_jsx(ToastPrimitives.Close, { ref: ref, className: cn("absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", className), "toast-close": "", ...props, children: _jsx(X, { className: "h-4 w-4" }) })));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (_jsx(ToastPrimitives.Title, { ref: ref, className: cn("text-sm font-semibold", className), ...props })));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (_jsx(ToastPrimitives.Description, { ref: ref, className: cn("text-sm opacity-90", className), ...props })));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
export { ToastProvider, ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, ToastAction, };

View File

@ -0,0 +1,9 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useToast } from "@/hooks/useToast.js";
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport, } from "@/components/ui/toast.js";
export function Toaster() {
const { toasts } = useToast();
return (_jsxs(ToastProvider, { children: [toasts.map(function ({ id, title, description, action, ...props }) {
return (_jsxs(Toast, { ...props, children: [_jsxs("div", { className: "grid gap-1", children: [title && _jsx(ToastTitle, { children: title }), description && (_jsx(ToastDescription, { children: description }))] }), action, _jsx(ToastClose, {})] }, id));
}), _jsx(ToastViewport, {})] }));
}

View File

@ -0,0 +1,20 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { cn } from "@/lib/utils.js";
import { toggleVariants } from "./toggle-variants.js";
const ToggleGroupContext = React.createContext({
size: "default",
variant: "default",
});
const ToggleGroup = React.forwardRef(({ className, variant, size, children, ...props }, ref) => (_jsx(ToggleGroupPrimitive.Root, { ref: ref, className: cn("flex items-center justify-center gap-1", className), ...props, children: _jsx(ToggleGroupContext.Provider, { value: { variant, size }, children: children }) })));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef(({ className, children, variant, size, value, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (_jsx(ToggleGroupPrimitive.Item, { ref: ref, className: cn(toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}), className), value: value, ...props, children: children }));
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

@ -0,0 +1,18 @@
import { cva } from "class-variance-authority";
export const toggleVariants = cva("inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground", {
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});

View File

@ -0,0 +1,8 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cn } from "@/lib/utils.js";
import { toggleVariants } from "./toggle-variants.js";
const Toggle = React.forwardRef(({ className, variant, size, ...props }, ref) => (_jsx(TogglePrimitive.Root, { ref: ref, className: cn(toggleVariants({ variant, size, className })), ...props })));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle };

View File

@ -0,0 +1,10 @@
import { jsx as _jsx } from "react/jsx-runtime";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils.js";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (_jsx(TooltipPrimitive.Content, { ref: ref, sideOffset: sideOffset, className: cn("z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className), ...props })));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@ -0,0 +1,2 @@
import { createContext } from "react";
export const AppContext = createContext(undefined);

View File

@ -0,0 +1,7 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { useNWCInternal as useNWCHook } from '@/hooks/useNWC.js';
import { NWCContext } from '@/hooks/useNWCContext.js';
export function NWCProvider({ children }) {
const nwc = useNWCHook();
return _jsx(NWCContext.Provider, { value: nwc, children: children });
}

View File

@ -0,0 +1,13 @@
import { useContext } from "react";
import { AppContext } from "@/contexts/AppContext.js";
/**
* Hook to access and update application configuration
* @returns Application context with config and update methods
*/
export function useAppContext() {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error('useAppContext must be used within an AppProvider');
}
return context;
}

26
src/hooks/useAuthor.js Normal file
View File

@ -0,0 +1,26 @@
import { NSchema as n } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
export function useAuthor(pubkey) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['author', pubkey ?? ''],
queryFn: async ({ signal }) => {
if (!pubkey) {
return {};
}
const [event] = await nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }], { signal: AbortSignal.any([signal, AbortSignal.timeout(1500)]) });
if (!event) {
throw new Error('No event found');
}
try {
const metadata = n.json().pipe(n.metadata()).parse(event.content);
return { metadata, event };
}
catch {
return { event };
}
},
retry: 3,
});
}

90
src/hooks/useComments.js Normal file
View File

@ -0,0 +1,90 @@
import { NKinds } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
export function useComments(root, limit) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['comments', root instanceof URL ? root.toString() : root.id, limit],
queryFn: async (c) => {
const filter = { kinds: [1111] };
if (root instanceof URL) {
filter['#I'] = [root.toString()];
}
else if (NKinds.addressable(root.kind)) {
const d = root.tags.find(([name]) => name === 'd')?.[1] ?? '';
filter['#A'] = [`${root.kind}:${root.pubkey}:${d}`];
}
else if (NKinds.replaceable(root.kind)) {
filter['#A'] = [`${root.kind}:${root.pubkey}:`];
}
else {
filter['#E'] = [root.id];
}
if (typeof limit === 'number') {
filter.limit = limit;
}
// Query for all kind 1111 comments that reference this addressable event regardless of depth
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
const events = await nostr.query([filter], { signal });
// Helper function to get tag value
const getTagValue = (event, tagName) => {
const tag = event.tags.find(([name]) => name === tagName);
return tag?.[1];
};
// Filter top-level comments (those with lowercase tag matching the root)
const topLevelComments = events.filter(comment => {
if (root instanceof URL) {
return getTagValue(comment, 'i') === root.toString();
}
else if (NKinds.addressable(root.kind)) {
const d = getTagValue(root, 'd') ?? '';
return getTagValue(comment, 'a') === `${root.kind}:${root.pubkey}:${d}`;
}
else if (NKinds.replaceable(root.kind)) {
return getTagValue(comment, 'a') === `${root.kind}:${root.pubkey}:`;
}
else {
return getTagValue(comment, 'e') === root.id;
}
});
// Helper function to get all descendants of a comment
const getDescendants = (parentId) => {
const directReplies = events.filter(comment => {
const eTag = getTagValue(comment, 'e');
return eTag === parentId;
});
const allDescendants = [...directReplies];
// Recursively get descendants of each direct reply
for (const reply of directReplies) {
allDescendants.push(...getDescendants(reply.id));
}
return allDescendants;
};
// Create a map of comment ID to its descendants
const commentDescendants = new Map();
for (const comment of events) {
commentDescendants.set(comment.id, getDescendants(comment.id));
}
// Sort top-level comments by creation time (newest first)
const sortedTopLevel = topLevelComments.sort((a, b) => b.created_at - a.created_at);
return {
allComments: events,
topLevelComments: sortedTopLevel,
getDescendants: (commentId) => {
const descendants = commentDescendants.get(commentId) || [];
// Sort descendants by creation time (oldest first for threaded display)
return descendants.sort((a, b) => a.created_at - b.created_at);
},
getDirectReplies: (commentId) => {
const directReplies = events.filter(comment => {
const eTag = getTagValue(comment, 'e');
return eTag === commentId;
});
// Sort direct replies by creation time (oldest first for threaded display)
return directReplies.sort((a, b) => a.created_at - b.created_at);
}
};
},
enabled: !!root,
});
}

View File

@ -0,0 +1,41 @@
import { NUser, useNostrLogin } from '@nostrify/react/login';
import { useNostr } from '@nostrify/react';
import { useCallback, useMemo } from 'react';
import { useAuthor } from "./useAuthor.js";
export function useCurrentUser() {
const { nostr } = useNostr();
const { logins } = useNostrLogin();
const loginToUser = useCallback((login) => {
switch (login.type) {
case 'nsec': // Nostr login with secret key
return NUser.fromNsecLogin(login);
case 'bunker': // Nostr login with NIP-46 "bunker://" URI
return NUser.fromBunkerLogin(login, nostr);
case 'extension': // Nostr login with NIP-07 browser extension
return NUser.fromExtensionLogin(login);
// Other login types can be defined here
default:
throw new Error(`Unsupported login type: ${login.type}`);
}
}, [nostr]);
const users = useMemo(() => {
const users = [];
for (const login of logins) {
try {
const user = loginToUser(login);
users.push(user);
}
catch (error) {
console.warn('Skipped invalid login', login.id, error);
}
}
return users;
}, [logins, loginToUser]);
const user = users[0];
const author = useAuthor(user?.pubkey);
return {
user,
users,
...author.data,
};
}

15
src/hooks/useIsMobile.js Normal file
View File

@ -0,0 +1,15 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

View File

@ -0,0 +1,44 @@
import { useState, useEffect } from 'react';
/**
* Generic hook for managing localStorage state
*/
export function useLocalStorage(key, defaultValue, serializer) {
const serialize = serializer?.serialize || JSON.stringify;
const deserialize = serializer?.deserialize || JSON.parse;
const [state, setState] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? deserialize(item) : defaultValue;
}
catch (error) {
console.warn(`Failed to load ${key} from localStorage:`, error);
return defaultValue;
}
});
const setValue = (value) => {
try {
const valueToStore = value instanceof Function ? value(state) : value;
setState(valueToStore);
localStorage.setItem(key, serialize(valueToStore));
}
catch (error) {
console.warn(`Failed to save ${key} to localStorage:`, error);
}
};
// Sync with localStorage changes from other tabs
useEffect(() => {
const handleStorageChange = (e) => {
if (e.key === key && e.newValue !== null) {
try {
setState(deserialize(e.newValue));
}
catch (error) {
console.warn(`Failed to sync ${key} from localStorage:`, error);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key, deserialize]);
return [state, setValue];
}

View File

@ -0,0 +1,42 @@
import { useNostr } from '@nostrify/react';
import { useNostrLogin } from '@nostrify/react/login';
import { useQuery } from '@tanstack/react-query';
import { NSchema as n } from '@nostrify/nostrify';
export function useLoggedInAccounts() {
const { nostr } = useNostr();
const { logins, setLogin, removeLogin } = useNostrLogin();
const { data: authors = [] } = useQuery({
queryKey: ['logins', logins.map((l) => l.id).join(';')],
queryFn: async ({ signal }) => {
const events = await nostr.query([{ kinds: [0], authors: logins.map((l) => l.pubkey) }], { signal: AbortSignal.any([signal, AbortSignal.timeout(1500)]) });
return logins.map(({ id, pubkey }) => {
const event = events.find((e) => e.pubkey === pubkey);
try {
const metadata = n.json().pipe(n.metadata()).parse(event?.content);
return { id, pubkey, metadata, event };
}
catch {
return { id, pubkey, metadata: {}, event };
}
});
},
retry: 3,
});
// Current user is the first login
const currentUser = (() => {
const login = logins[0];
if (!login)
return undefined;
const author = authors.find((a) => a.id === login.id);
return { metadata: {}, ...author, id: login.id, pubkey: login.pubkey };
})();
// Other users are all logins except the current one
const otherUsers = (authors || []).slice(1);
return {
authors,
currentUser,
otherUsers,
setLogin,
removeLogin,
};
}

View File

@ -0,0 +1,31 @@
import { useNostr } from '@nostrify/react';
import { NLogin, useNostrLogin } from '@nostrify/react/login';
// NOTE: This file should not be edited except for adding new login methods.
export function useLoginActions() {
const { nostr } = useNostr();
const { logins, addLogin, removeLogin } = useNostrLogin();
return {
// Login with a Nostr secret key
nsec(nsec) {
const login = NLogin.fromNsec(nsec);
addLogin(login);
},
// Login with a NIP-46 "bunker://" URI
async bunker(uri) {
const login = await NLogin.fromBunker(uri, nostr);
addLogin(login);
},
// Login with a NIP-07 browser extension
async extension() {
const login = await NLogin.fromExtension();
addLogin(login);
},
// Log out the current user
async logout() {
const login = logins[0];
if (login) {
removeLogin(login.id);
}
}
};
}

189
src/hooks/useNWC.js Normal file
View File

@ -0,0 +1,189 @@
import { useState, useCallback } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage.js';
import { useToast } from '@/hooks/useToast.js';
import { LN } from '@getalby/sdk';
export function useNWCInternal() {
const { toast } = useToast();
const [connections, setConnections] = useLocalStorage('nwc-connections', []);
const [activeConnection, setActiveConnection] = useLocalStorage('nwc-active-connection', null);
const [connectionInfo, setConnectionInfo] = useState({});
// Add new connection
const addConnection = async (uri, alias) => {
const parseNWCUri = (uri) => {
try {
if (!uri.startsWith('nostr+walletconnect://') && !uri.startsWith('nostrwalletconnect://')) {
console.error('Invalid NWC URI protocol:', { protocol: uri.split('://')[0] });
return null;
}
return { connectionString: uri };
}
catch (error) {
console.error('Failed to parse NWC URI:', error);
return null;
}
};
const parsed = parseNWCUri(uri);
if (!parsed) {
toast({
title: 'Invalid NWC URI',
description: 'Please check the connection string and try again.',
variant: 'destructive',
});
return false;
}
const existingConnection = connections.find(c => c.connectionString === parsed.connectionString);
if (existingConnection) {
toast({
title: 'Connection already exists',
description: 'This wallet is already connected.',
variant: 'destructive',
});
return false;
}
try {
let timeoutId;
const testPromise = new Promise((resolve, reject) => {
try {
const client = new LN(parsed.connectionString);
resolve(client);
}
catch (error) {
reject(error);
}
});
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('Connection test timeout')), 10000);
});
try {
await Promise.race([testPromise, timeoutPromise]);
if (timeoutId)
clearTimeout(timeoutId);
}
catch (error) {
if (timeoutId)
clearTimeout(timeoutId);
throw error;
}
const connection = {
connectionString: parsed.connectionString,
alias: alias || 'NWC Wallet',
isConnected: true,
};
setConnectionInfo(prev => ({
...prev,
[parsed.connectionString]: {
alias: connection.alias,
methods: ['pay_invoice'],
},
}));
const newConnections = [...connections, connection];
setConnections(newConnections);
if (connections.length === 0 || !activeConnection)
setActiveConnection(parsed.connectionString);
toast({
title: 'Wallet connected',
description: `Successfully connected to ${connection.alias}.`,
});
return true;
}
catch (error) {
console.error('NWC connection failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
toast({
title: 'Connection failed',
description: `Could not connect to the wallet: ${errorMessage}`,
variant: 'destructive',
});
return false;
}
};
// Remove connection
const removeConnection = (connectionString) => {
const filtered = connections.filter(c => c.connectionString !== connectionString);
setConnections(filtered);
if (activeConnection === connectionString) {
const newActive = filtered.length > 0 ? filtered[0].connectionString : null;
setActiveConnection(newActive);
}
setConnectionInfo(prev => {
const newInfo = { ...prev };
delete newInfo[connectionString];
return newInfo;
});
toast({
title: 'Wallet disconnected',
description: 'The wallet connection has been removed.',
});
};
// Get active connection
const getActiveConnection = useCallback(() => {
if (!activeConnection && connections.length > 0) {
setActiveConnection(connections[0].connectionString);
return connections[0];
}
if (!activeConnection)
return null;
const found = connections.find(c => c.connectionString === activeConnection);
return found || null;
}, [activeConnection, connections, setActiveConnection]);
// Send payment using the SDK
const sendPayment = useCallback(async (connection, invoice) => {
if (!connection.connectionString) {
throw new Error('Invalid connection: missing connection string');
}
let client;
try {
client = new LN(connection.connectionString);
}
catch (error) {
console.error('Failed to create NWC client:', error);
throw new Error(`Failed to create NWC client: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
try {
let timeoutId;
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('Payment timeout after 15 seconds')), 15000);
});
const paymentPromise = client.pay(invoice);
try {
const response = await Promise.race([paymentPromise, timeoutPromise]);
if (timeoutId)
clearTimeout(timeoutId);
return response;
}
catch (error) {
if (timeoutId)
clearTimeout(timeoutId);
throw error;
}
}
catch (error) {
console.error('NWC payment failed:', error);
if (error instanceof Error) {
if (error.message.includes('timeout')) {
throw new Error('Payment timed out. Please try again.');
}
else if (error.message.includes('insufficient')) {
throw new Error('Insufficient balance in connected wallet.');
}
else if (error.message.includes('invalid')) {
throw new Error('Invalid invoice or connection. Please check your wallet.');
}
else {
throw new Error(`Payment failed: ${error.message}`);
}
}
throw new Error('Payment failed with unknown error');
}
}, []);
return {
connections,
activeConnection,
connectionInfo,
addConnection,
removeConnection,
setActiveConnection,
getActiveConnection,
sendPayment,
};
}

View File

@ -0,0 +1,10 @@
import { useContext } from 'react';
import { createContext } from 'react';
export const NWCContext = createContext(null);
export function useNWC() {
const context = useContext(NWCContext);
if (!context) {
throw new Error('useNWC must be used within a NWCProvider');
}
return context;
}

4
src/hooks/useNostr.js Normal file
View File

@ -0,0 +1,4 @@
// This file exists because LLMs get confused and try to create this file if it doesn't exist.
// The `useNostr` hook should be imported directly from `@nostrify/react`, not from this file.
// This file SHOULD NOT be edited or removed.
export { useNostr } from "@nostrify/react";

View File

@ -0,0 +1,35 @@
import { useNostr } from "@nostrify/react";
import { useMutation } from "@tanstack/react-query";
import { useCurrentUser } from "./useCurrentUser.js";
export function useNostrPublish() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
return useMutation({
mutationFn: async (t) => {
if (user) {
const tags = t.tags ?? [];
// Add the client tag if it doesn't exist
if (location.protocol === "https:" && !tags.some(([name]) => name === "client")) {
tags.push(["client", location.hostname]);
}
const event = await user.signer.signEvent({
kind: t.kind,
content: t.content ?? "",
tags,
created_at: t.created_at ?? Math.floor(Date.now() / 1000),
});
await nostr.event(event, { signal: AbortSignal.timeout(5000) });
return event;
}
else {
throw new Error("User is not logged in");
}
},
onError: (error) => {
console.error("Failed to publish event:", error);
},
onSuccess: (data) => {
console.log("Event published successfully:", data);
},
});
}

View File

@ -0,0 +1,92 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostrPublish } from '@/hooks/useNostrPublish.js';
import { NKinds } from '@nostrify/nostrify';
/** Post a NIP-22 (kind 1111) comment on an event. */
export function usePostComment() {
const { mutateAsync: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ root, reply, content }) => {
const tags = [];
// d-tag identifiers
const dRoot = root instanceof URL ? '' : root.tags.find(([name]) => name === 'd')?.[1] ?? '';
const dReply = reply instanceof URL ? '' : reply?.tags.find(([name]) => name === 'd')?.[1] ?? '';
// Root event tags
if (root instanceof URL) {
tags.push(['I', root.toString()]);
}
else if (NKinds.addressable(root.kind)) {
tags.push(['A', `${root.kind}:${root.pubkey}:${dRoot}`]);
}
else if (NKinds.replaceable(root.kind)) {
tags.push(['A', `${root.kind}:${root.pubkey}:`]);
}
else {
tags.push(['E', root.id]);
}
if (root instanceof URL) {
tags.push(['K', root.hostname]);
}
else {
tags.push(['K', root.kind.toString()]);
tags.push(['P', root.pubkey]);
}
// Reply event tags
if (reply) {
if (reply instanceof URL) {
tags.push(['i', reply.toString()]);
}
else if (NKinds.addressable(reply.kind)) {
tags.push(['a', `${reply.kind}:${reply.pubkey}:${dReply}`]);
}
else if (NKinds.replaceable(reply.kind)) {
tags.push(['a', `${reply.kind}:${reply.pubkey}:`]);
}
else {
tags.push(['e', reply.id]);
}
if (reply instanceof URL) {
tags.push(['k', reply.hostname]);
}
else {
tags.push(['k', reply.kind.toString()]);
tags.push(['p', reply.pubkey]);
}
}
else {
// If this is a top-level comment, use the root event's tags
if (root instanceof URL) {
tags.push(['i', root.toString()]);
}
else if (NKinds.addressable(root.kind)) {
tags.push(['a', `${root.kind}:${root.pubkey}:${dRoot}`]);
}
else if (NKinds.replaceable(root.kind)) {
tags.push(['a', `${root.kind}:${root.pubkey}:`]);
}
else {
tags.push(['e', root.id]);
}
if (root instanceof URL) {
tags.push(['k', root.hostname]);
}
else {
tags.push(['k', root.kind.toString()]);
tags.push(['p', root.pubkey]);
}
}
const event = await publishEvent({
kind: 1111,
content,
tags,
});
return event;
},
onSuccess: (_, { root }) => {
// Invalidate and refetch comments
queryClient.invalidateQueries({
queryKey: ['comments', root instanceof URL ? root.toString() : root.id]
});
},
});
}

17
src/hooks/useTheme.js Normal file
View File

@ -0,0 +1,17 @@
import { useAppContext } from "@/hooks/useAppContext.js";
/**
* Hook to get and set the active theme
* @returns Theme context with theme and setTheme
*/
export function useTheme() {
const { config, updateConfig } = useAppContext();
return {
theme: config.theme,
setTheme: (theme) => {
updateConfig((currentConfig) => ({
...currentConfig,
theme,
}));
}
};
}

120
src/hooks/useToast.js Normal file
View File

@ -0,0 +1,120 @@
import * as React from "react";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
const toastTimeouts = new Map();
const addToRemoveQueue = (toastId) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state, action) => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) => t.id === action.toast.id ? { ...t, ...action.toast } : t),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
}
else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) => t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners = [];
let memoryState = { toasts: [] };
function dispatch(action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
function toast({ ...props }) {
const id = genId();
const update = (props) => dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open)
dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

View File

@ -0,0 +1,21 @@
import { useMutation } from "@tanstack/react-query";
import { BlossomUploader } from '@nostrify/nostrify/uploaders';
import { useCurrentUser } from "./useCurrentUser.js";
export function useUploadFile() {
const { user } = useCurrentUser();
return useMutation({
mutationFn: async (file) => {
if (!user) {
throw new Error('Must be logged in to upload files');
}
const uploader = new BlossomUploader({
servers: [
'https://blossom.primal.net/',
],
signer: user.signer,
});
const tags = await uploader.upload(file);
return tags;
},
});
}

78
src/hooks/useWallet.js Normal file
View File

@ -0,0 +1,78 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNWC } from '@/hooks/useNWCContext.js';
import { requestProvider } from 'webln';
export function useWallet() {
const [webln, setWebln] = useState(null);
const [isDetecting, setIsDetecting] = useState(false);
const [hasAttemptedDetection, setHasAttemptedDetection] = useState(false);
const { connections, getActiveConnection } = useNWC();
// Get the active connection directly - no memoization to avoid stale state
const activeNWC = getActiveConnection();
// Detect WebLN
const detectWebLN = useCallback(async () => {
if (webln || isDetecting)
return webln;
setIsDetecting(true);
try {
const provider = await requestProvider();
setWebln(provider);
setHasAttemptedDetection(true);
return provider;
}
catch (error) {
// Only log the error if it's not the common "no provider" error
if (error instanceof Error && !error.message.includes('no WebLN provider')) {
console.warn('WebLN detection error:', error);
}
setWebln(null);
setHasAttemptedDetection(true);
return null;
}
finally {
setIsDetecting(false);
}
}, [webln, isDetecting]);
// Only auto-detect once on mount
useEffect(() => {
if (!hasAttemptedDetection) {
detectWebLN();
}
}, [detectWebLN, hasAttemptedDetection]);
// Test WebLN connection
const testWebLN = useCallback(async () => {
if (!webln)
return false;
try {
await webln.enable();
return true;
}
catch (error) {
console.error('WebLN test failed:', error);
return false;
}
}, [webln]);
// Calculate status values reactively
const hasNWC = useMemo(() => {
return connections.length > 0 && connections.some(c => c.isConnected);
}, [connections]);
// Determine preferred payment method
const preferredMethod = activeNWC
? 'nwc'
: webln
? 'webln'
: 'manual';
const status = {
hasWebLN: !!webln,
hasNWC,
webln,
activeNWC,
isDetecting,
preferredMethod,
};
return {
...status,
hasAttemptedDetection,
detectWebLN,
testWebLN,
};
}

291
src/hooks/useZaps.js Normal file
View File

@ -0,0 +1,291 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser.js';
import { useAuthor } from '@/hooks/useAuthor.js';
import { useAppContext } from '@/hooks/useAppContext.js';
import { useToast } from '@/hooks/useToast.js';
import { useNWC } from '@/hooks/useNWCContext.js';
import { nip57 } from 'nostr-tools';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
export function useZaps(target, webln, _nwcConnection, onZapSuccess) {
const { nostr } = useNostr();
const { toast } = useToast();
const { user } = useCurrentUser();
const { config } = useAppContext();
const queryClient = useQueryClient();
// Handle the case where an empty array is passed (from ZapButton when external data is provided)
const actualTarget = Array.isArray(target) ? (target.length > 0 ? target[0] : null) : target;
const author = useAuthor(actualTarget?.pubkey);
const { sendPayment, getActiveConnection } = useNWC();
const [isZapping, setIsZapping] = useState(false);
const [invoice, setInvoice] = useState(null);
// Cleanup state when component unmounts
useEffect(() => {
return () => {
setIsZapping(false);
setInvoice(null);
};
}, []);
const { data: zapEvents, ...query } = useQuery({
queryKey: ['zaps', actualTarget?.id],
staleTime: 30000, // 30 seconds
refetchInterval: (query) => {
// Only refetch if the query is currently being observed (component is mounted)
return query.getObserversCount() > 0 ? 60000 : false;
},
queryFn: async (c) => {
if (!actualTarget)
return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
// Query for zap receipts for this specific event
if (actualTarget.kind >= 30000 && actualTarget.kind < 40000) {
// Addressable event
const identifier = actualTarget.tags.find((t) => t[0] === 'd')?.[1] || '';
const events = await nostr.query([{
kinds: [9735],
'#a': [`${actualTarget.kind}:${actualTarget.pubkey}:${identifier}`],
}], { signal });
return events;
}
else {
// Regular event
const events = await nostr.query([{
kinds: [9735],
'#e': [actualTarget.id],
}], { signal });
return events;
}
},
enabled: !!actualTarget?.id,
});
// Process zap events into simple counts and totals
const { zapCount, totalSats, zaps } = useMemo(() => {
if (!zapEvents || !Array.isArray(zapEvents) || !actualTarget) {
return { zapCount: 0, totalSats: 0, zaps: [] };
}
let count = 0;
let sats = 0;
zapEvents.forEach(zap => {
count++;
// Try multiple methods to extract the amount:
// Method 1: amount tag (from zap request, sometimes copied to receipt)
const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1];
if (amountTag) {
const millisats = parseInt(amountTag);
sats += Math.floor(millisats / 1000);
return;
}
// Method 2: Extract from bolt11 invoice
const bolt11Tag = zap.tags.find(([name]) => name === 'bolt11')?.[1];
if (bolt11Tag) {
try {
const invoiceSats = nip57.getSatoshisAmountFromBolt11(bolt11Tag);
sats += invoiceSats;
return;
}
catch (error) {
console.warn('Failed to parse bolt11 amount:', error);
}
}
// Method 3: Parse from description (zap request JSON)
const descriptionTag = zap.tags.find(([name]) => name === 'description')?.[1];
if (descriptionTag) {
try {
const zapRequest = JSON.parse(descriptionTag);
const requestAmountTag = zapRequest.tags?.find(([name]) => name === 'amount')?.[1];
if (requestAmountTag) {
const millisats = parseInt(requestAmountTag);
sats += Math.floor(millisats / 1000);
return;
}
}
catch (error) {
console.warn('Failed to parse description JSON:', error);
}
}
console.warn('Could not extract amount from zap receipt:', zap.id);
});
return { zapCount: count, totalSats: sats, zaps: zapEvents };
}, [zapEvents, actualTarget]);
const zap = async (amount, comment) => {
if (amount <= 0) {
return;
}
setIsZapping(true);
setInvoice(null); // Clear any previous invoice at the start
if (!user) {
toast({
title: 'Login required',
description: 'You must be logged in to send a zap.',
variant: 'destructive',
});
setIsZapping(false);
return;
}
if (!actualTarget) {
toast({
title: 'Event not found',
description: 'Could not find the event to zap.',
variant: 'destructive',
});
setIsZapping(false);
return;
}
try {
if (!author.data || !author.data?.metadata || !author.data?.event) {
toast({
title: 'Author not found',
description: 'Could not find the author of this item.',
variant: 'destructive',
});
setIsZapping(false);
return;
}
const { lud06, lud16 } = author.data.metadata;
if (!lud06 && !lud16) {
toast({
title: 'Lightning address not found',
description: 'The author does not have a lightning address configured.',
variant: 'destructive',
});
setIsZapping(false);
return;
}
// Get zap endpoint using the old reliable method
const zapEndpoint = await nip57.getZapEndpoint(author.data.event);
if (!zapEndpoint) {
toast({
title: 'Zap endpoint not found',
description: 'Could not find a zap endpoint for the author.',
variant: 'destructive',
});
setIsZapping(false);
return;
}
// Create zap request - use appropriate event format based on kind
// For addressable events (30000-39999), pass the object to get 'a' tag
// For all other events, pass the ID string to get 'e' tag
const event = (actualTarget.kind >= 30000 && actualTarget.kind < 40000)
? actualTarget
: actualTarget.id;
const zapAmount = amount * 1000; // convert to millisats
const zapRequest = nip57.makeZapRequest({
profile: actualTarget.pubkey,
event: event,
amount: zapAmount,
relays: [config.relayUrl],
comment
});
// Sign the zap request (but don't publish to relays - only send to LNURL endpoint)
if (!user.signer) {
throw new Error('No signer available');
}
const signedZapRequest = await user.signer.signEvent(zapRequest);
try {
const res = await fetch(`${zapEndpoint}?amount=${zapAmount}&nostr=${encodeURI(JSON.stringify(signedZapRequest))}`);
const responseData = await res.json();
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${responseData.reason || 'Unknown error'}`);
}
const newInvoice = responseData.pr;
if (!newInvoice || typeof newInvoice !== 'string') {
throw new Error('Lightning service did not return a valid invoice');
}
// Get the current active NWC connection dynamically
const currentNWCConnection = getActiveConnection();
// Try NWC first if available and properly connected
if (currentNWCConnection && currentNWCConnection.connectionString && currentNWCConnection.isConnected) {
try {
await sendPayment(currentNWCConnection, newInvoice);
// Clear states immediately on success
setIsZapping(false);
setInvoice(null);
toast({
title: 'Zap successful!',
description: `You sent ${amount} sats via NWC to the author.`,
});
// Invalidate zap queries to refresh counts
queryClient.invalidateQueries({ queryKey: ['zaps'] });
// Close dialog last to ensure clean state
onZapSuccess?.();
return;
}
catch (nwcError) {
console.error('NWC payment failed, falling back:', nwcError);
// Show specific NWC error to user for debugging
const errorMessage = nwcError instanceof Error ? nwcError.message : 'Unknown NWC error';
toast({
title: 'NWC payment failed',
description: `${errorMessage}. Falling back to other payment methods...`,
variant: 'destructive',
});
}
}
if (webln) { // Try WebLN next
try {
await webln.sendPayment(newInvoice);
// Clear states immediately on success
setIsZapping(false);
setInvoice(null);
toast({
title: 'Zap successful!',
description: `You sent ${amount} sats to the author.`,
});
// Invalidate zap queries to refresh counts
queryClient.invalidateQueries({ queryKey: ['zaps'] });
// Close dialog last to ensure clean state
onZapSuccess?.();
}
catch (weblnError) {
console.error('webln payment failed, falling back:', weblnError);
// Show specific WebLN error to user for debugging
const errorMessage = weblnError instanceof Error ? weblnError.message : 'Unknown WebLN error';
toast({
title: 'WebLN payment failed',
description: `${errorMessage}. Falling back to other payment methods...`,
variant: 'destructive',
});
setInvoice(newInvoice);
setIsZapping(false);
}
}
else { // Default - show QR code and manual Lightning URI
setInvoice(newInvoice);
setIsZapping(false);
}
}
catch (err) {
console.error('Zap error:', err);
toast({
title: 'Zap failed',
description: err.message,
variant: 'destructive',
});
setIsZapping(false);
}
}
catch (err) {
console.error('Zap error:', err);
toast({
title: 'Zap failed',
description: err.message,
variant: 'destructive',
});
setIsZapping(false);
}
};
const resetInvoice = useCallback(() => {
setInvoice(null);
}, []);
return {
zaps,
zapCount,
totalSats,
...query,
zap,
isZapping,
invoice,
setInvoice,
resetInvoice,
};
}

25
src/lib/genUserName.js Normal file
View File

@ -0,0 +1,25 @@
/** Generate a deterministic user display name based on a string seed. */
export function genUserName(seed) {
// Use a simple hash of the pubkey to generate consistent adjective + noun combinations
const adjectives = [
'Swift', 'Bright', 'Calm', 'Bold', 'Wise', 'Kind', 'Quick', 'Brave',
'Cool', 'Sharp', 'Clear', 'Strong', 'Smart', 'Fast', 'Keen', 'Pure',
'Noble', 'Gentle', 'Fierce', 'Steady', 'Clever', 'Proud', 'Silent', 'Wild'
];
const nouns = [
'Fox', 'Eagle', 'Wolf', 'Bear', 'Lion', 'Tiger', 'Hawk', 'Owl',
'Deer', 'Raven', 'Falcon', 'Lynx', 'Otter', 'Whale', 'Shark', 'Dolphin',
'Phoenix', 'Dragon', 'Panther', 'Jaguar', 'Cheetah', 'Leopard', 'Puma', 'Cobra'
];
// Create a simple hash from the pubkey
let hash = 0;
for (let i = 0; i < seed.length; i++) {
const char = seed.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
// Use absolute value to ensure positive index
const adjIndex = Math.abs(hash) % adjectives.length;
const nounIndex = Math.abs(hash >> 8) % nouns.length;
return [adjectives[adjIndex], nouns[nounIndex]].join(' ');
}

View File

@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { genUserName } from "./genUserName.js";
describe('genUserName', () => {
it('generates a deterministic name from a seed', () => {
const seed = 'test-seed-123';
const name1 = genUserName(seed);
const name2 = genUserName(seed);
expect(name1).toEqual('Brave Whale');
expect(name1).toEqual(name2);
});
it('generates different names for different seeds', () => {
const name1 = genUserName('seed1');
const name2 = genUserName('seed2');
const name3 = genUserName('seed3');
// While it's theoretically possible for different seeds to generate the same name,
// it's very unlikely with our word lists
expect(name1).not.toBe(name2);
expect(name2).not.toBe(name3);
expect(name1).not.toBe(name3);
});
it('handles typical Nostr pubkey format', () => {
// Typical hex pubkey (64 characters)
const pubkey = 'e4690a13290739da123aa17d553851dec4cdd0e9d89aa18de3741c446caf8761';
const name = genUserName(pubkey);
expect(name).toEqual('Gentle Hawk');
});
});

67
src/lib/polyfills.js Normal file
View File

@ -0,0 +1,67 @@
/**
* Polyfill for AbortSignal.any()
*
* AbortSignal.any() creates an AbortSignal that will be aborted when any of the
* provided signals are aborted. This is useful for combining multiple abort signals.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static
*/
// Check if AbortSignal.any is already available
if (!AbortSignal.any) {
AbortSignal.any = function (signals) {
// If no signals provided, return a signal that never aborts
if (signals.length === 0) {
return new AbortController().signal;
}
// If only one signal, return it directly for efficiency
if (signals.length === 1) {
return signals[0];
}
// Check if any signal is already aborted
for (const signal of signals) {
if (signal.aborted) {
// Create an already-aborted signal with the same reason
const controller = new AbortController();
controller.abort(signal.reason);
return controller.signal;
}
}
// Create a new controller for the combined signal
const controller = new AbortController();
// Function to abort the combined signal
const onAbort = (event) => {
const target = event.target;
controller.abort(target.reason);
};
// Listen for abort events on all input signals
for (const signal of signals) {
signal.addEventListener('abort', onAbort, { once: true });
}
// Clean up listeners when the combined signal is aborted
controller.signal.addEventListener('abort', () => {
for (const signal of signals) {
signal.removeEventListener('abort', onAbort);
}
}, { once: true });
return controller.signal;
};
}
/**
* Polyfill for AbortSignal.timeout()
*
* AbortSignal.timeout() creates an AbortSignal that will be aborted after a
* specified number of milliseconds.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static
*/
// Check if AbortSignal.timeout is already available
if (!AbortSignal.timeout) {
AbortSignal.timeout = function (milliseconds) {
const controller = new AbortController();
setTimeout(() => {
controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'));
}, milliseconds);
return controller.signal;
};
}
export {};

Some files were not shown because too many files have changed in this diff Show More