mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 02:53:00 +02:00
Add command palette
This commit is contained in:
21
frontend/package-lock.json
generated
21
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.6.3",
|
||||
"version": "3.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.6.3",
|
||||
"version": "3.8.0",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
@@ -20,6 +20,7 @@
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-force-3d": "^3.0.6",
|
||||
"leaflet": "^1.9.4",
|
||||
@@ -3687,6 +3688,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-force-3d": "^3.0.6",
|
||||
"leaflet": "^1.9.4",
|
||||
|
||||
@@ -88,6 +88,7 @@ export function App() {
|
||||
useState<NewMessagePrefillRequest | null>(null);
|
||||
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
|
||||
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
|
||||
const [repeaterAutoLoginKey, setRepeaterAutoLoginKey] = useState<string | null>(null);
|
||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
||||
const {
|
||||
@@ -457,6 +458,18 @@ export function App() {
|
||||
[fetchUndecryptedCount, setChannels]
|
||||
);
|
||||
|
||||
const handleRepeaterAutoLogin = useCallback(
|
||||
(publicKey: string, displayName: string) => {
|
||||
handleSelectConversationWithTargetReset({
|
||||
type: 'contact',
|
||||
id: publicKey,
|
||||
name: displayName,
|
||||
});
|
||||
setRepeaterAutoLoginKey(publicKey);
|
||||
},
|
||||
[handleSelectConversationWithTargetReset]
|
||||
);
|
||||
|
||||
const handleOpenNewMessage = useCallback(
|
||||
(event?: MouseEvent<HTMLButtonElement>) => {
|
||||
setNewMessagePrefillRequest(null);
|
||||
@@ -587,6 +600,8 @@ export function App() {
|
||||
},
|
||||
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||
repeaterAutoLoginKey,
|
||||
onClearRepeaterAutoLogin: () => setRepeaterAutoLoginKey(null),
|
||||
};
|
||||
const searchProps = {
|
||||
contacts,
|
||||
@@ -720,6 +735,7 @@ export function App() {
|
||||
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
|
||||
contactInfoPaneProps={contactInfoPaneProps}
|
||||
channelInfoPaneProps={channelInfoPaneProps}
|
||||
onRepeaterAutoLogin={handleRepeaterAutoLogin}
|
||||
/>
|
||||
</DistanceUnitProvider>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useRef, type ComponentProps } from 'react';
|
||||
import { lazy, Suspense, useCallback, useRef, type ComponentProps } from 'react';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
|
||||
import { StatusBar } from './StatusBar';
|
||||
@@ -8,6 +8,7 @@ import { NewMessageModal } from './NewMessageModal';
|
||||
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
|
||||
import { ContactInfoPane } from './ContactInfoPane';
|
||||
import { ChannelInfoPane } from './ChannelInfoPane';
|
||||
import { CommandPalette } from './CommandPalette';
|
||||
import { SecurityWarningModal } from './SecurityWarningModal';
|
||||
import { Toaster } from './ui/sonner';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
@@ -71,6 +72,7 @@ interface AppShellProps {
|
||||
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
|
||||
contactInfoPaneProps: ContactInfoPaneProps;
|
||||
channelInfoPaneProps: ChannelInfoPaneProps;
|
||||
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
@@ -100,6 +102,7 @@ export function AppShell({
|
||||
bulkAddChannelResultModalProps,
|
||||
contactInfoPaneProps,
|
||||
channelInfoPaneProps,
|
||||
onRepeaterAutoLogin,
|
||||
}: AppShellProps) {
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedRight: ({ initial }) => {
|
||||
@@ -119,6 +122,14 @@ export function AppShell({
|
||||
preventScrollOnSwipe: false,
|
||||
});
|
||||
|
||||
const handleOpenSettings = useCallback(
|
||||
(section: SettingsSection) => {
|
||||
onSettingsSectionChange(section);
|
||||
if (!showSettings) onToggleSettingsView();
|
||||
},
|
||||
[onSettingsSectionChange, onToggleSettingsView, showSettings]
|
||||
);
|
||||
|
||||
const searchMounted = useRef(false);
|
||||
if (conversationPaneProps.activeConversation?.type === 'search') {
|
||||
searchMounted.current = true;
|
||||
@@ -323,6 +334,13 @@ export function AppShell({
|
||||
onClose={onCloseBulkAddResults}
|
||||
/>
|
||||
|
||||
<CommandPalette
|
||||
contacts={sidebarProps.contacts}
|
||||
channels={sidebarProps.channels}
|
||||
onSelectConversation={sidebarProps.onSelectConversation}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onRepeaterAutoLogin={onRepeaterAutoLogin}
|
||||
/>
|
||||
<SecurityWarningModal health={statusProps.health} />
|
||||
<ContactInfoPane {...contactInfoPaneProps} />
|
||||
<ChannelInfoPane {...channelInfoPaneProps} />
|
||||
|
||||
439
frontend/src/components/CommandPalette.tsx
Normal file
439
frontend/src/components/CommandPalette.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Hash,
|
||||
Map,
|
||||
MessageSquare,
|
||||
Network,
|
||||
Radio,
|
||||
Route,
|
||||
Search,
|
||||
Sparkles,
|
||||
User,
|
||||
Waypoints,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from './ui/command';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import {
|
||||
SETTINGS_SECTION_LABELS,
|
||||
SETTINGS_SECTION_ORDER,
|
||||
SETTINGS_SECTION_ICONS,
|
||||
type SettingsSection,
|
||||
} from './settings/settingsConstants';
|
||||
import type { Channel, Contact, Conversation } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
|
||||
const MAX_PER_GROUP = 8;
|
||||
|
||||
interface CommandPaletteProps {
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
onSelectConversation: (conv: Conversation) => void;
|
||||
onOpenSettings: (section: SettingsSection) => void;
|
||||
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
|
||||
}
|
||||
|
||||
interface Searchable {
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
interface SearchableContact extends Searchable {
|
||||
contact: Contact;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface SearchableChannel extends Searchable {
|
||||
channel: Channel;
|
||||
}
|
||||
|
||||
interface ToolItem extends Searchable {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
type: 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
|
||||
}
|
||||
|
||||
interface SettingItem extends Searchable {
|
||||
section: SettingsSection;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const TOOL_ITEMS: ToolItem[] = [
|
||||
{ id: 'raw', name: 'Raw Packet Feed', icon: Radio, type: 'raw', searchText: 'raw packet feed' },
|
||||
{ id: 'map', name: 'Map View', icon: Map, type: 'map', searchText: 'map view' },
|
||||
{
|
||||
id: 'visualizer',
|
||||
name: 'Network Visualizer',
|
||||
icon: Network,
|
||||
type: 'visualizer',
|
||||
searchText: 'network visualizer',
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
name: 'Message Search',
|
||||
icon: Search,
|
||||
type: 'search',
|
||||
searchText: 'message search',
|
||||
},
|
||||
{ id: 'trace', name: 'Route Trace', icon: Route, type: 'trace', searchText: 'route trace' },
|
||||
];
|
||||
|
||||
const SETTING_ITEMS: SettingItem[] = SETTINGS_SECTION_ORDER.map((section) => ({
|
||||
section,
|
||||
label: SETTINGS_SECTION_LABELS[section],
|
||||
icon: SETTINGS_SECTION_ICONS[section],
|
||||
searchText: `settings ${SETTINGS_SECTION_LABELS[section]}`.toLowerCase(),
|
||||
}));
|
||||
|
||||
function fuzzyMatch(text: string, query: string): boolean {
|
||||
let qi = 0;
|
||||
for (let ti = 0; ti < text.length && qi < query.length; ti++) {
|
||||
if (text[ti] === query[qi]) qi++;
|
||||
}
|
||||
return qi === query.length;
|
||||
}
|
||||
|
||||
function filterList<T extends Searchable>(items: T[], query: string): T[] {
|
||||
if (!query) return items.slice(0, MAX_PER_GROUP);
|
||||
const results: T[] = [];
|
||||
for (const item of items) {
|
||||
if (fuzzyMatch(item.searchText, query)) {
|
||||
results.push(item);
|
||||
if (results.length >= MAX_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
contacts,
|
||||
channels,
|
||||
onSelectConversation,
|
||||
onOpenSettings,
|
||||
onRepeaterAutoLogin,
|
||||
}: CommandPaletteProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
const select = useCallback((action: () => void) => {
|
||||
setOpen(false);
|
||||
action();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
favContacts,
|
||||
favRepeaters,
|
||||
regularContacts,
|
||||
repeaters,
|
||||
rooms,
|
||||
favChannels,
|
||||
regularChannels,
|
||||
} = useMemo(() => {
|
||||
const fc: SearchableContact[] = [];
|
||||
const fr: SearchableContact[] = [];
|
||||
const rc: SearchableContact[] = [];
|
||||
const rp: SearchableContact[] = [];
|
||||
const rm: SearchableContact[] = [];
|
||||
for (const c of contacts) {
|
||||
const displayName = getContactDisplayName(c.name, c.public_key, c.last_advert);
|
||||
const entry: SearchableContact = {
|
||||
contact: c,
|
||||
displayName,
|
||||
searchText: `${displayName} ${c.public_key}`.toLowerCase(),
|
||||
};
|
||||
if (c.type === CONTACT_TYPE_REPEATER) {
|
||||
(c.favorite ? fr : rp).push(entry);
|
||||
} else if (c.type === CONTACT_TYPE_ROOM) {
|
||||
rm.push(entry);
|
||||
} else {
|
||||
(c.favorite ? fc : rc).push(entry);
|
||||
}
|
||||
}
|
||||
const fch: SearchableChannel[] = [];
|
||||
const rch: SearchableChannel[] = [];
|
||||
for (const ch of channels) {
|
||||
const entry: SearchableChannel = {
|
||||
channel: ch,
|
||||
searchText: `${ch.name} ${ch.key}`.toLowerCase(),
|
||||
};
|
||||
(ch.favorite ? fch : rch).push(entry);
|
||||
}
|
||||
return {
|
||||
favContacts: fc,
|
||||
favRepeaters: fr,
|
||||
regularContacts: rc,
|
||||
repeaters: rp,
|
||||
rooms: rm,
|
||||
favChannels: fch,
|
||||
regularChannels: rch,
|
||||
};
|
||||
}, [contacts, channels]);
|
||||
|
||||
const lq = query.toLowerCase();
|
||||
const fTools = filterList(TOOL_ITEMS, lq);
|
||||
const fSettings = filterList(SETTING_ITEMS, lq);
|
||||
const fFavContacts = filterList(favContacts, lq);
|
||||
const fFavRepeaters = filterList(favRepeaters, lq);
|
||||
const fFavChannels = filterList(favChannels, lq);
|
||||
const fContacts = filterList(regularContacts, lq);
|
||||
const fRepeaters = filterList(repeaters, lq);
|
||||
const fRooms = filterList(rooms, lq);
|
||||
const fChannels = filterList(regularChannels, lq);
|
||||
|
||||
const totalResults =
|
||||
fTools.length +
|
||||
fSettings.length +
|
||||
fFavContacts.length +
|
||||
fFavRepeaters.length +
|
||||
fFavChannels.length +
|
||||
fContacts.length +
|
||||
fRepeaters.length +
|
||||
fRooms.length +
|
||||
fChannels.length;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setQuery('');
|
||||
}}
|
||||
>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg" hideCloseButton>
|
||||
<DialogTitle className="sr-only">Command palette</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Search for conversations, settings, and tools
|
||||
</DialogDescription>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput placeholder="Jump to..." value={query} onValueChange={setQuery} />
|
||||
<CommandList>
|
||||
{totalResults === 0 && <CommandEmpty>No results found.</CommandEmpty>}
|
||||
|
||||
{fTools.length > 0 && (
|
||||
<CommandGroup heading="Tools">
|
||||
{fTools.map((tool) => (
|
||||
<CommandItem
|
||||
key={tool.id}
|
||||
onSelect={() =>
|
||||
select(() =>
|
||||
onSelectConversation({ type: tool.type, id: tool.id, name: tool.name })
|
||||
)
|
||||
}
|
||||
>
|
||||
<tool.icon className="text-muted-foreground" />
|
||||
<span>{tool.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{fSettings.length > 0 && (
|
||||
<CommandGroup heading="Settings">
|
||||
{fSettings.map((item) => (
|
||||
<CommandItem
|
||||
key={item.section}
|
||||
onSelect={() => select(() => onOpenSettings(item.section))}
|
||||
>
|
||||
<item.icon className="text-muted-foreground" />
|
||||
<span>{item.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{fFavContacts.length > 0 && (
|
||||
<ContactGroup
|
||||
heading="Favorite Contacts"
|
||||
items={fFavContacts}
|
||||
icon={User}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
showStar
|
||||
/>
|
||||
)}
|
||||
|
||||
{fFavRepeaters.length > 0 && (
|
||||
<RepeaterGroup
|
||||
heading="Favorite Repeaters"
|
||||
items={fFavRepeaters}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onRepeaterAutoLogin={onRepeaterAutoLogin}
|
||||
showStar
|
||||
/>
|
||||
)}
|
||||
|
||||
{fFavChannels.length > 0 && (
|
||||
<CommandGroup heading="Favorite Channels">
|
||||
{fFavChannels.map(({ channel: ch }) => (
|
||||
<CommandItem
|
||||
key={ch.key}
|
||||
onSelect={() =>
|
||||
select(() =>
|
||||
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Hash className="text-muted-foreground" />
|
||||
<span>{ch.name}</span>
|
||||
<Sparkles className="ml-auto h-3 w-3 text-yellow-500" />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{fContacts.length > 0 && (
|
||||
<ContactGroup
|
||||
heading="Contacts"
|
||||
items={fContacts}
|
||||
icon={User}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fRepeaters.length > 0 && (
|
||||
<RepeaterGroup
|
||||
heading="Repeaters"
|
||||
items={fRepeaters}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onRepeaterAutoLogin={onRepeaterAutoLogin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fRooms.length > 0 && (
|
||||
<ContactGroup
|
||||
heading="Rooms"
|
||||
items={fRooms}
|
||||
icon={MessageSquare}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fChannels.length > 0 && (
|
||||
<CommandGroup heading="Channels">
|
||||
{fChannels.map(({ channel: ch }) => (
|
||||
<CommandItem
|
||||
key={ch.key}
|
||||
onSelect={() =>
|
||||
select(() =>
|
||||
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Hash className="text-muted-foreground" />
|
||||
<span>{ch.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactGroup({
|
||||
heading,
|
||||
items,
|
||||
icon: Icon,
|
||||
showStar,
|
||||
onSelect,
|
||||
onSelectConversation,
|
||||
}: {
|
||||
heading: string;
|
||||
items: SearchableContact[];
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
showStar?: boolean;
|
||||
onSelect: (action: () => void) => void;
|
||||
onSelectConversation: (conv: Conversation) => void;
|
||||
}) {
|
||||
return (
|
||||
<CommandGroup heading={heading}>
|
||||
{items.map(({ contact: c, displayName }) => (
|
||||
<CommandItem
|
||||
key={c.public_key}
|
||||
onSelect={() =>
|
||||
onSelect(() =>
|
||||
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="text-muted-foreground" />
|
||||
<span>{displayName}</span>
|
||||
{showStar && <Sparkles className="ml-auto h-3 w-3 text-yellow-500" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function RepeaterGroup({
|
||||
heading,
|
||||
items,
|
||||
showStar,
|
||||
onSelect,
|
||||
onSelectConversation,
|
||||
onRepeaterAutoLogin,
|
||||
}: {
|
||||
heading: string;
|
||||
items: SearchableContact[];
|
||||
showStar?: boolean;
|
||||
onSelect: (action: () => void) => void;
|
||||
onSelectConversation: (conv: Conversation) => void;
|
||||
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<CommandGroup heading={heading}>
|
||||
{items.flatMap(({ contact: c, displayName }) => [
|
||||
<CommandItem
|
||||
key={c.public_key}
|
||||
onSelect={() =>
|
||||
onSelect(() =>
|
||||
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Waypoints className="text-muted-foreground" />
|
||||
<span>{displayName}</span>
|
||||
{showStar && <Sparkles className="ml-auto h-3 w-3 text-yellow-500" />}
|
||||
</CommandItem>,
|
||||
<CommandItem
|
||||
key={`${c.public_key}-acl`}
|
||||
onSelect={() =>
|
||||
onSelect(() => onRepeaterAutoLogin(c.public_key, displayName))
|
||||
}
|
||||
>
|
||||
<Waypoints className="text-muted-foreground" />
|
||||
<span>
|
||||
{displayName}{' '}
|
||||
<span className="text-muted-foreground">(ACL login + load all)</span>
|
||||
</span>
|
||||
</CommandItem>,
|
||||
])}
|
||||
</CommandGroup>
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,8 @@ interface ConversationPaneProps {
|
||||
onToggleNotifications: () => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
repeaterAutoLoginKey: string | null;
|
||||
onClearRepeaterAutoLogin: () => void;
|
||||
}
|
||||
|
||||
function LoadingPane({ label }: { label: string }) {
|
||||
@@ -149,6 +151,8 @@ export function ConversationPane({
|
||||
onToggleNotifications,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
repeaterAutoLoginKey,
|
||||
onClearRepeaterAutoLogin,
|
||||
}: ConversationPaneProps) {
|
||||
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
@@ -248,6 +252,8 @@ export function ConversationPane({
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
autoLoginAndLoadAll={repeaterAutoLoginKey === activeConversation.id}
|
||||
onAutoLoginConsumed={onClearRepeaterAutoLogin}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -48,6 +48,8 @@ interface RepeaterDashboardProps {
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
autoLoginAndLoadAll?: boolean;
|
||||
onAutoLoginConsumed?: () => void;
|
||||
}
|
||||
|
||||
export function RepeaterDashboard({
|
||||
@@ -67,6 +69,8 @@ export function RepeaterDashboard({
|
||||
onOpenContactInfo,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
autoLoginAndLoadAll,
|
||||
onAutoLoginConsumed,
|
||||
}: RepeaterDashboardProps) {
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
||||
@@ -125,6 +129,15 @@ export function RepeaterDashboard({
|
||||
setTelemetryHistory(liveHistory);
|
||||
}, [paneData.status?.telemetry_history]);
|
||||
|
||||
// Command palette "ACL login + load all" auto-action
|
||||
const autoLoginConsumedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!autoLoginAndLoadAll || autoLoginConsumedRef.current) return;
|
||||
autoLoginConsumedRef.current = true;
|
||||
onAutoLoginConsumed?.();
|
||||
void loginAsGuest().then(() => loadAll());
|
||||
}, [autoLoginAndLoadAll, onAutoLoginConsumed, loginAsGuest, loadAll]);
|
||||
|
||||
const isFav = contact?.favorite ?? false;
|
||||
|
||||
const handleRepeaterLogin = async (nextPassword: string) => {
|
||||
|
||||
144
frontend/src/components/ui/command.tsx
Normal file
144
frontend/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<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;
|
||||
|
||||
function CommandDialog({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog>) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg" hideCloseButton>
|
||||
<DialogTitle className="sr-only">Command palette</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Search for conversations, settings, and tools
|
||||
</DialogDescription>
|
||||
<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">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<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<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||
));
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<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-[0.625rem] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
function CommandShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<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,
|
||||
};
|
||||
@@ -158,6 +158,8 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
onToggleNotifications: vi.fn(),
|
||||
trackedTelemetryRepeaters: [],
|
||||
onToggleTrackedTelemetry: vi.fn(async () => {}),
|
||||
repeaterAutoLoginKey: null,
|
||||
onClearRepeaterAutoLogin: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user