mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 18:51:21 +02:00
feat: add client tag support to all event creation (#191)
* feat: add client tag support to all event creation
Implements a global settings system to control whether the Grimoire client tag
should be included in all published events. This allows users to opt-in or
opt-out of identifying their client in published events.
Changes:
- Created global settings service (src/services/settings.ts) with reactive
BehaviorSubject for app-wide configuration
- Created useSettings hook (src/hooks/useSettings.ts) for React components
- Migrated PostViewer from local settings to global settings system
- Added client tag support to:
- Post publishing (PostViewer.tsx)
- Spell publishing (publish-spell.ts)
- Event deletion (delete-event.ts)
- NIP-29 chat messages, reactions, join/leave, and group bookmarks
(nip-29-adapter.ts)
- Zap requests (create-zap-request.ts)
The client tag setting defaults to enabled (true) for backward compatibility.
Users can toggle this in the post composer settings dropdown.
All event creation locations now check settingsManager.getSetting("includeClientTag")
before adding the GRIMOIRE_CLIENT_TAG to event tags.
* refactor: exclude client tags from NIP-29 and zap requests
Remove client tag support from NIP-29 adapter events and zap requests
as these events may be rejected by servers with large tags or have
specific size constraints.
Changes:
- Removed client tag from NIP-29 chat messages (kind 9)
- Removed client tag from NIP-29 reactions (kind 7)
- Removed client tag from NIP-29 join/leave requests (kind 9021, 9022)
- Removed client tag from NIP-29 group bookmarks (kind 10009)
- Removed client tag from zap requests (kind 9734)
Client tags remain enabled for:
- Regular posts (kind 1)
- Spell publishing (kind 777)
- Event deletion (kind 5)
This ensures maximum compatibility with relay servers and LNURL endpoints
while still providing client identification for standard events.
* feat: implement comprehensive namespaced settings system
Redesigned the settings system with proper namespacing, type safety, validation,
and migration support. This provides a solid foundation for all app configuration.
Settings Structure:
- post: Post composition settings (client tag, relay selection)
- appearance: UI/theme settings (theme, compact mode, font size, animations)
- relay: Relay configuration (fallback, discovery, outbox, timeouts)
- privacy: Privacy settings (read receipts, content warnings, link warnings)
- database: Caching settings (max events, cleanup, IndexedDB options)
- notifications: Browser notifications preferences
- developer: Debug and experimental features
Key Features:
- Fully typed with TypeScript interfaces for each namespace
- Automatic validation with fallback to defaults for invalid data
- Migration system from old flat structure to namespaced structure
- Backwards compatible with old "grimoire-settings" localStorage key
- Import/export functionality for settings backup/restore
- Reactive updates via RxJS BehaviorSubject
- Section-level and individual setting updates
- Reset individual sections or all settings
Changes:
- Created comprehensive AppSettings interface with 7 namespaced sections
- Implemented SettingsManager class with reactive updates and persistence
- Updated useSettings hook to support namespaced API
- Updated PostViewer, publish-spell, and delete-event to use new API
(settingsManager.getSetting("post", "includeClientTag"))
- Added extensive inline documentation for all settings
Migration:
- Automatically migrates old includeClientTag setting to post.includeClientTag
- Moves data from "grimoire-settings" to "grimoire-settings-v2" key
- Validates all loaded settings and fills in defaults for missing values
This foundation will support future settings UI with tabbed interface.
* feat: add comprehensive settings UI with Post and Appearance sections
Created a minimal MVP settings system accessible via command palette and user menu.
Settings are organized in a clean tabbed interface with two initial sections.
UI Features:
- SettingsViewer component with sidebar navigation
- Post section: Toggle to include Grimoire client tag in published events
- Appearance section:
- Theme selector (light/dark/system)
- Toggle to show/hide client tags in event UI ("via Grimoire" etc)
Integration:
- Added "settings" command to command palette
- Added "Settings" option to user menu (before Support Grimoire)
- Registered "settings" as new AppId in window system
Display Logic:
- BaseEventRenderer now honors settings.appearance.showClientTags
- When disabled, "via Grimoire" and other client tags are hidden from events
- Setting applies instantly across all event renderers
Technical Details:
- SettingsViewer uses existing UI components (Checkbox, Button, Label)
- Leverages useSettings hook for reactive updates
- Settings persist to localStorage via settingsManager
- Simple button group for theme selection instead of dropdown
- Clean two-column layout with icons for each section
This provides a solid foundation for adding more settings sections later
(relay config, privacy, database, notifications, developer options).
* refactor: redesign settings UI with shadcn Tabs and cleaner layout
Simplified the settings UI to be more minimal and follow Grimoire patterns:
Changes:
- Use shadcn Tabs component instead of custom sidebar navigation
- Remove non-working theme selector (no theme system implemented yet)
- Use regular <label> elements with htmlFor instead of custom Label component
- Shorter, cleaner copy throughout
- Properly remove unused theme-related imports from user menu
- Follow Grimoire UI patterns (similar to SettingsDialog)
Settings sections:
- Post: Include client tag setting
- Appearance: Show client tags setting
The UI is now cleaner, uses proper form semantics, and matches the rest
of Grimoire's design system.
* feat: add working theme selector and improve settings UI
Added fully functional theme selector and improved the settings UI with
better components and cleaner copy.
Changes:
- Created Switch component (shadcn/radix-ui) for boolean settings
- Added working theme selector that integrates with existing theme system
- Uses useTheme hook to display available themes (Dark, Light, Plan9)
- Reordered sections: Appearance first, Post second
- Reordered settings: Theme first in Appearance section
- Replaced Checkbox with Switch for better UX on boolean toggles
- Simplified copy: "Add Grimoire tag to published events" instead of listing kinds
- Simplified copy: "Display client identifiers in events" instead of "via Grimoire" mention
- Better layout: Label/description on left, Switch on right
Settings now use proper form components:
- Switch for boolean toggles (include client tag, show client tags)
- Button group for theme selection
- Clean justify-between layout for settings rows
The theme selector works immediately - clicking Dark/Light/Plan9 applies
the theme instantly via the existing ThemeProvider context.
* refactor: improve settings UI with Select, icons, and better spacing
Enhanced the settings UI with shadcn Select component, tab icons, and
improved typography and spacing.
Changes:
- Created Select component (shadcn/radix-ui) for dropdowns
- Added icons to tabs: Palette for Appearance, FileEdit for Post
- Replaced button group theme selector with Select dropdown (w-48)
- Added gap-4 between labels and switches for better spacing
- Increased setting names from text-sm to text-base (bigger)
- Reduced help text from text-sm to text-xs (smaller)
- Added gap-3 between "Theme" label and Select
- Added font-medium to "Theme" label for consistency
Layout improvements:
- Theme selector now uses Select with 192px width
- All setting rows have gap-4 between content and controls
- Consistent text hierarchy: font-medium for labels, text-xs for descriptions
- Tab triggers have gap-2 between icon and text
The Select component is properly integrated with the theme system and
displays Dark, Light, and Plan9 options with checkmark indicators.
* refactor: use horizontal layout for theme selector
Aligned theme selector with other settings by using horizontal layout:
- Label and description on left, Select on right
- Added gap-4 for consistent spacing
- Changed label from text-sm to text-base font-medium
- Added helpful description: 'Choose your color scheme'
- Added id='theme' for accessibility
Now all settings follow the same visual pattern.
* refactor: reduce theme selector width for compact display
Changed theme selector from w-48 (192px) to w-32 (128px) since
theme names are short (Dark, Light, Plan9). More compact and
better proportioned for the content.
* fix: pass 'settings' command string when opening from menu
Changed the command string from 'Settings' to 'settings' when opening
the settings window from the user menu. This ensures clicking edit on
the settings window shows the correct command: 'settings'
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import { useProfileSearch } from "@/hooks/useProfileSearch";
|
||||
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
|
||||
import { useBlossomUpload } from "@/hooks/useBlossomUpload";
|
||||
import { useRelayState } from "@/hooks/useRelayState";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { RichEditor, type RichEditorHandle } from "./editor/RichEditor";
|
||||
import type { BlobAttachment, EmojiTag } from "./editor/MentionEditor";
|
||||
import { RelayLink } from "./nostr/RelayLink";
|
||||
@@ -53,15 +54,6 @@ interface RelayPublishState {
|
||||
|
||||
// Storage keys
|
||||
const DRAFT_STORAGE_KEY = "grimoire-post-draft";
|
||||
const SETTINGS_STORAGE_KEY = "grimoire-post-settings";
|
||||
|
||||
interface PostSettings {
|
||||
includeClientTag: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: PostSettings = {
|
||||
includeClientTag: true,
|
||||
};
|
||||
|
||||
interface PostViewerProps {
|
||||
windowId?: string;
|
||||
@@ -73,6 +65,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
|
||||
const { searchEmojis } = useEmojiSearch();
|
||||
const { state } = useGrimoire();
|
||||
const { getRelay } = useRelayState();
|
||||
const { settings, updateSetting } = useSettings();
|
||||
|
||||
// Editor ref for programmatic control
|
||||
const editorRef = useRef<RichEditorHandle>(null);
|
||||
@@ -86,36 +79,9 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
|
||||
const [showPublishedPreview, setShowPublishedPreview] = useState(false);
|
||||
const [newRelayInput, setNewRelayInput] = useState("");
|
||||
|
||||
// Load settings from localStorage
|
||||
const [settings, setSettings] = useState<PostSettings>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : DEFAULT_SETTINGS;
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
});
|
||||
|
||||
// Get relay pool state for connection status
|
||||
const relayPoolMap = use$(pool.relays$);
|
||||
|
||||
// Persist settings to localStorage when they change
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (err) {
|
||||
console.error("Failed to save post settings:", err);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Update a single setting
|
||||
const updateSetting = useCallback(
|
||||
<K extends keyof PostSettings>(key: K, value: PostSettings[K]) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Get active account's write relays from Grimoire state, fallback to aggregators
|
||||
const writeRelays = useMemo(() => {
|
||||
if (!state.activeAccount?.relays) return AGGREGATOR_RELAYS;
|
||||
@@ -403,7 +369,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
|
||||
}
|
||||
|
||||
// Add client tag (if enabled)
|
||||
if (settings.includeClientTag) {
|
||||
if (settings?.post?.includeClientTag) {
|
||||
additionalTags.push(GRIMOIRE_CLIENT_TAG);
|
||||
}
|
||||
|
||||
@@ -692,9 +658,9 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={settings.includeClientTag}
|
||||
checked={settings?.post?.includeClientTag ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSetting("includeClientTag", checked)
|
||||
updateSetting("post", "includeClientTag", checked)
|
||||
}
|
||||
>
|
||||
Include client tag
|
||||
|
||||
128
src/components/SettingsViewer.tsx
Normal file
128
src/components/SettingsViewer.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./ui/select";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useTheme } from "@/lib/themes";
|
||||
import { Palette, FileEdit } from "lucide-react";
|
||||
|
||||
export function SettingsViewer() {
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const { themeId, setTheme, availableThemes } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs defaultValue="appearance" className="flex-1 flex flex-col">
|
||||
<div className="border-b px-6 py-3">
|
||||
<TabsList>
|
||||
<TabsTrigger value="appearance" className="gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
Appearance
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="post" className="gap-2">
|
||||
<FileEdit className="h-4 w-4" />
|
||||
Post
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<TabsContent value="appearance" className="m-0 p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-1">Appearance</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize display preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor="theme"
|
||||
className="text-base font-medium cursor-pointer"
|
||||
>
|
||||
Theme
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose your color scheme
|
||||
</p>
|
||||
</div>
|
||||
<Select value={themeId} onValueChange={setTheme}>
|
||||
<SelectTrigger id="theme" className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableThemes.map((theme) => (
|
||||
<SelectItem key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor="show-client-tags"
|
||||
className="text-base font-medium cursor-pointer"
|
||||
>
|
||||
Show client tags
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Display client identifiers in events
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="show-client-tags"
|
||||
checked={settings?.appearance?.showClientTags ?? true}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
updateSetting("appearance", "showClientTags", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="post" className="m-0 p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-1">Post Settings</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure event publishing
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor="include-client-tag"
|
||||
className="text-base font-medium cursor-pointer"
|
||||
>
|
||||
Include client tag
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add Grimoire tag to published events
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="include-client-tag"
|
||||
checked={settings?.post?.includeClientTag ?? true}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
updateSetting("post", "includeClientTag", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,9 @@ const CountViewer = lazy(() => import("./CountViewer"));
|
||||
const PostViewer = lazy(() =>
|
||||
import("./PostViewer").then((m) => ({ default: m.PostViewer })),
|
||||
);
|
||||
const SettingsViewer = lazy(() =>
|
||||
import("./SettingsViewer").then((m) => ({ default: m.SettingsViewer })),
|
||||
);
|
||||
|
||||
// Loading fallback component
|
||||
function ViewerLoading() {
|
||||
@@ -247,6 +250,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
case "post":
|
||||
content = <PostViewer windowId={window.id} />;
|
||||
break;
|
||||
case "settings":
|
||||
content = <SettingsViewer />;
|
||||
break;
|
||||
default:
|
||||
content = (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { useAccount } from "@/hooks/useAccount";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { JsonViewer } from "@/components/JsonViewer";
|
||||
import { EmojiPickerDialog } from "@/components/chat/EmojiPickerDialog";
|
||||
import { formatTimestamp } from "@/hooks/useLocale";
|
||||
@@ -531,6 +532,7 @@ export function BaseEventContainer({
|
||||
}) {
|
||||
const { locale, addWindow } = useGrimoire();
|
||||
const { canSign, signer, pubkey } = useAccount();
|
||||
const { settings } = useSettings();
|
||||
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
|
||||
|
||||
const handleReactClick = () => {
|
||||
@@ -612,7 +614,7 @@ export function BaseEventContainer({
|
||||
>
|
||||
{relativeTime}
|
||||
</span>
|
||||
{clientName && (
|
||||
{settings?.appearance?.showClientTags && clientName && (
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
via{" "}
|
||||
{clientAppPointer ? (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
User,
|
||||
Palette,
|
||||
Wallet,
|
||||
X,
|
||||
RefreshCw,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
Zap,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import accounts from "@/services/accounts";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
@@ -26,9 +26,6 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -44,7 +41,6 @@ import SettingsDialog from "@/components/SettingsDialog";
|
||||
import LoginDialog from "./LoginDialog";
|
||||
import ConnectWalletDialog from "@/components/ConnectWalletDialog";
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "@/lib/themes";
|
||||
import { toast } from "sonner";
|
||||
import { useWallet } from "@/hooks/useWallet";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
@@ -94,7 +90,6 @@ export default function UserMenu() {
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [showConnectWallet, setShowConnectWallet] = useState(false);
|
||||
const [showWalletInfo, setShowWalletInfo] = useState(false);
|
||||
const { themeId, setTheme, availableThemes } = useTheme();
|
||||
|
||||
// Calculate monthly donations reactively from DB (last 30 days)
|
||||
const monthlyDonations =
|
||||
@@ -488,32 +483,15 @@ export default function UserMenu() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* App Preferences - Theme */}
|
||||
{/* Settings */}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="cursor-crosshair">
|
||||
<Palette className="size-4 text-muted-foreground mr-2" />
|
||||
Theme
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{availableThemes.map((theme) => (
|
||||
<DropdownMenuItem
|
||||
key={theme.id}
|
||||
className="cursor-crosshair"
|
||||
onClick={() => setTheme(theme.id)}
|
||||
>
|
||||
<span
|
||||
className={`size-2 rounded-full mr-2 ${
|
||||
themeId === theme.id
|
||||
? "bg-primary"
|
||||
: "bg-muted-foreground/30"
|
||||
}`}
|
||||
/>
|
||||
{theme.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem
|
||||
className="cursor-crosshair"
|
||||
onClick={() => addWindow("settings", {}, "settings")}
|
||||
>
|
||||
<Settings className="size-4 text-muted-foreground mr-2" />
|
||||
<span className="text-sm">Settings</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Support Grimoire */}
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
157
src/components/ui/select.tsx
Normal file
157
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
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";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<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}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<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,
|
||||
};
|
||||
27
src/components/ui/switch.tsx
Normal file
27
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm 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}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
Reference in New Issue
Block a user