mirror of
https://gitlab.com/soapbox-pub/mkstack.git
synced 2025-09-24 10:16:06 +00:00
Merge remote-tracking branch 'gitlab/main' into nodenext
This commit is contained in:
commit
1d58b3939f
64
AGENTS.md
64
AGENTS.md
@ -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
16
package-lock.json
generated
@ -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",
|
||||
|
@ -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
42
src/App.js
Normal 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
7
src/App.test.js
Normal 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
10
src/AppRouter.js
Normal 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;
|
65
src/components/AppProvider.js
Normal file
65
src/components/AppProvider.js
Normal 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]);
|
||||
}
|
120
src/components/EditProfileForm.js
Normal file
120
src/components/EditProfileForm.js
Normal 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, {})] }));
|
||||
};
|
41
src/components/ErrorBoundary.js
Normal file
41
src/components/ErrorBoundary.js
Normal 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;
|
||||
}
|
||||
}
|
45
src/components/NostrProvider.js
Normal file
45
src/components/NostrProvider.js
Normal 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;
|
77
src/components/NoteContent.js
Normal file
77
src/components/NoteContent.js
Normal 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] }));
|
||||
}
|
98
src/components/NoteContent.test.js
Normal file
98
src/components/NoteContent.test.js
Normal 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");
|
||||
});
|
||||
});
|
65
src/components/RelaySelector.js
Normal file
65
src/components/RelaySelector.js
Normal 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) })] })] }))] })] })] }) })] }));
|
||||
}
|
9
src/components/ScrollToTop.js
Normal file
9
src/components/ScrollToTop.js
Normal 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;
|
||||
}
|
87
src/components/WalletModal.js
Normal file
87
src/components/WalletModal.js
Normal 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] }));
|
||||
}
|
23
src/components/ZapButton.js
Normal file
23
src/components/ZapButton.js
Normal 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
164
src/components/ZapDialog.js
Normal 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 }) })] })] }));
|
||||
}
|
19
src/components/auth/AccountSwitcher.js
Normal file
19
src/components/auth/AccountSwitcher.js
Normal 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" })] })] })] }));
|
||||
}
|
21
src/components/auth/LoginArea.js
Normal file
21
src/components/auth/LoginArea.js
Normal 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) })] }));
|
||||
}
|
168
src/components/auth/LoginDialog.js
Normal file
168
src/components/auth/LoginDialog.js
Normal 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;
|
266
src/components/auth/SignupDialog.js
Normal file
266
src/components/auth/SignupDialog.js
Normal file
File diff suppressed because one or more lines are too long
29
src/components/comments/Comment.js
Normal file
29
src/components/comments/Comment.js
Normal 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))) }) }))] }));
|
||||
}
|
29
src/components/comments/CommentForm.js
Normal file
29
src/components/comments/CommentForm.js
Normal 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')] })] })] }) }) }));
|
||||
}
|
16
src/components/comments/CommentsSection.js
Normal file
16
src/components/comments/CommentsSection.js
Normal 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))) }))] })] }));
|
||||
}
|
13
src/components/ui/accordion.js
Normal file
13
src/components/ui/accordion.js
Normal 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 };
|
26
src/components/ui/alert-dialog.js
Normal file
26
src/components/ui/alert-dialog.js
Normal 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, };
|
22
src/components/ui/alert.js
Normal file
22
src/components/ui/alert.js
Normal 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 };
|
3
src/components/ui/aspect-ratio.js
Normal file
3
src/components/ui/aspect-ratio.js
Normal file
@ -0,0 +1,3 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
||||
const AspectRatio = AspectRatioPrimitive.Root;
|
||||
export { AspectRatio };
|
11
src/components/ui/avatar.js
Normal file
11
src/components/ui/avatar.js
Normal 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 };
|
14
src/components/ui/badge-variants.js
Normal file
14
src/components/ui/badge-variants.js
Normal 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",
|
||||
},
|
||||
});
|
7
src/components/ui/badge.js
Normal file
7
src/components/ui/badge.js
Normal 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 };
|
23
src/components/ui/breadcrumb.js
Normal file
23
src/components/ui/breadcrumb.js
Normal 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, };
|
23
src/components/ui/button-variants.js
Normal file
23
src/components/ui/button-variants.js
Normal 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",
|
||||
},
|
||||
});
|
11
src/components/ui/button.js
Normal file
11
src/components/ui/button.js
Normal 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 };
|
36
src/components/ui/calendar.js
Normal file
36
src/components/ui/calendar.js
Normal 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
16
src/components/ui/card.js
Normal 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 };
|
98
src/components/ui/carousel.js
Normal file
98
src/components/ui/carousel.js
Normal 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
130
src/components/ui/chart.js
Normal 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, };
|
8
src/components/ui/checkbox.js
Normal file
8
src/components/ui/checkbox.js
Normal 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 };
|
5
src/components/ui/collapsible.js
Normal file
5
src/components/ui/collapsible.js
Normal 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 };
|
28
src/components/ui/command.js
Normal file
28
src/components/ui/command.js
Normal 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, };
|
33
src/components/ui/context-menu.js
Normal file
33
src/components/ui/context-menu.js
Normal 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, };
|
22
src/components/ui/dialog.js
Normal file
22
src/components/ui/dialog.js
Normal 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, };
|
22
src/components/ui/drawer.js
Normal file
22
src/components/ui/drawer.js
Normal 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, };
|
35
src/components/ui/dropdown-menu.js
Normal file
35
src/components/ui/dropdown-menu.js
Normal 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, };
|
22
src/components/ui/form-utils.js
Normal file
22
src/components/ui/form-utils.js
Normal 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
43
src/components/ui/form.js
Normal 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, };
|
9
src/components/ui/hover-card.js
Normal file
9
src/components/ui/hover-card.js
Normal 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 };
|
18
src/components/ui/input-otp.js
Normal file
18
src/components/ui/input-otp.js
Normal 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 };
|
8
src/components/ui/input.js
Normal file
8
src/components/ui/input.js
Normal 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 };
|
9
src/components/ui/label.js
Normal file
9
src/components/ui/label.js
Normal 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 };
|
35
src/components/ui/menubar.js
Normal file
35
src/components/ui/menubar.js
Normal 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, };
|
2
src/components/ui/navigation-menu-variants.js
Normal file
2
src/components/ui/navigation-menu-variants.js
Normal 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");
|
23
src/components/ui/navigation-menu.js
Normal file
23
src/components/ui/navigation-menu.js
Normal 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, };
|
23
src/components/ui/pagination.js
Normal file
23
src/components/ui/pagination.js
Normal 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, };
|
9
src/components/ui/popover.js
Normal file
9
src/components/ui/popover.js
Normal 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 };
|
7
src/components/ui/progress.js
Normal file
7
src/components/ui/progress.js
Normal 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 };
|
14
src/components/ui/radio-group.js
Normal file
14
src/components/ui/radio-group.js
Normal 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 };
|
8
src/components/ui/resizable.js
Normal file
8
src/components/ui/resizable.js
Normal 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 };
|
11
src/components/ui/scroll-area.js
Normal file
11
src/components/ui/scroll-area.js
Normal 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 };
|
26
src/components/ui/select.js
Normal file
26
src/components/ui/select.js
Normal 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, };
|
7
src/components/ui/separator.js
Normal file
7
src/components/ui/separator.js
Normal 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 };
|
36
src/components/ui/sheet.js
Normal file
36
src/components/ui/sheet.js
Normal 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 };
|
33
src/components/ui/sidebar-utils.js
Normal file
33
src/components/ui/sidebar-utils.js
Normal 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",
|
||||
},
|
||||
});
|
193
src/components/ui/sidebar.js
Normal file
193
src/components/ui/sidebar.js
Normal 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, };
|
6
src/components/ui/skeleton.js
Normal file
6
src/components/ui/skeleton.js
Normal 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 };
|
7
src/components/ui/slider.js
Normal file
7
src/components/ui/slider.js
Normal 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 };
|
7
src/components/ui/switch.js
Normal file
7
src/components/ui/switch.js
Normal 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 };
|
20
src/components/ui/table.js
Normal file
20
src/components/ui/table.js
Normal 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
12
src/components/ui/tabs.js
Normal 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 };
|
8
src/components/ui/textarea.js
Normal file
8
src/components/ui/textarea.js
Normal 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 };
|
33
src/components/ui/toast.js
Normal file
33
src/components/ui/toast.js
Normal 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, };
|
9
src/components/ui/toaster.js
Normal file
9
src/components/ui/toaster.js
Normal 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, {})] }));
|
||||
}
|
20
src/components/ui/toggle-group.js
Normal file
20
src/components/ui/toggle-group.js
Normal 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 };
|
18
src/components/ui/toggle-variants.js
Normal file
18
src/components/ui/toggle-variants.js
Normal 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",
|
||||
},
|
||||
});
|
8
src/components/ui/toggle.js
Normal file
8
src/components/ui/toggle.js
Normal 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 };
|
10
src/components/ui/tooltip.js
Normal file
10
src/components/ui/tooltip.js
Normal 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 };
|
2
src/contexts/AppContext.js
Normal file
2
src/contexts/AppContext.js
Normal file
@ -0,0 +1,2 @@
|
||||
import { createContext } from "react";
|
||||
export const AppContext = createContext(undefined);
|
7
src/contexts/NWCContext.js
Normal file
7
src/contexts/NWCContext.js
Normal 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 });
|
||||
}
|
13
src/hooks/useAppContext.js
Normal file
13
src/hooks/useAppContext.js
Normal 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
26
src/hooks/useAuthor.js
Normal 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
90
src/hooks/useComments.js
Normal 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,
|
||||
});
|
||||
}
|
41
src/hooks/useCurrentUser.js
Normal file
41
src/hooks/useCurrentUser.js
Normal 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
15
src/hooks/useIsMobile.js
Normal 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;
|
||||
}
|
44
src/hooks/useLocalStorage.js
Normal file
44
src/hooks/useLocalStorage.js
Normal 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];
|
||||
}
|
42
src/hooks/useLoggedInAccounts.js
Normal file
42
src/hooks/useLoggedInAccounts.js
Normal 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,
|
||||
};
|
||||
}
|
31
src/hooks/useLoginActions.js
Normal file
31
src/hooks/useLoginActions.js
Normal 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
189
src/hooks/useNWC.js
Normal 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,
|
||||
};
|
||||
}
|
10
src/hooks/useNWCContext.js
Normal file
10
src/hooks/useNWCContext.js
Normal 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
4
src/hooks/useNostr.js
Normal 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";
|
35
src/hooks/useNostrPublish.js
Normal file
35
src/hooks/useNostrPublish.js
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
92
src/hooks/usePostComment.js
Normal file
92
src/hooks/usePostComment.js
Normal 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
17
src/hooks/useTheme.js
Normal 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
120
src/hooks/useToast.js
Normal 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 };
|
21
src/hooks/useUploadFile.js
Normal file
21
src/hooks/useUploadFile.js
Normal 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
78
src/hooks/useWallet.js
Normal 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
291
src/hooks/useZaps.js
Normal 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
25
src/lib/genUserName.js
Normal 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(' ');
|
||||
}
|
27
src/lib/genUserName.test.js
Normal file
27
src/lib/genUserName.test.js
Normal 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
67
src/lib/polyfills.js
Normal 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
Loading…
x
Reference in New Issue
Block a user