mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 17:23:05 +02:00
Initial commit
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { AppSettings, AppSettingsUpdate, RadioConfig, RadioConfigUpdate } from '../types';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Button } from './ui/button';
|
||||
import { Separator } from './ui/separator';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
|
||||
interface ConfigModalProps {
|
||||
open: boolean;
|
||||
config: RadioConfig | null;
|
||||
appSettings: AppSettings | null;
|
||||
onClose: () => void;
|
||||
onSave: (update: RadioConfigUpdate) => Promise<void>;
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
onSetPrivateKey: (key: string) => Promise<void>;
|
||||
onReboot: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ConfigModal({
|
||||
open,
|
||||
config,
|
||||
appSettings,
|
||||
onClose,
|
||||
onSave,
|
||||
onSaveAppSettings,
|
||||
onSetPrivateKey,
|
||||
onReboot,
|
||||
}: ConfigModalProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [lat, setLat] = useState('');
|
||||
const [lon, setLon] = useState('');
|
||||
const [txPower, setTxPower] = useState('');
|
||||
const [freq, setFreq] = useState('');
|
||||
const [bw, setBw] = useState('');
|
||||
const [sf, setSf] = useState('');
|
||||
const [cr, setCr] = useState('');
|
||||
const [privateKey, setPrivateKey] = useState('');
|
||||
const [maxRadioContacts, setMaxRadioContacts] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rebooting, setRebooting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setName(config.name);
|
||||
setLat(String(config.lat));
|
||||
setLon(String(config.lon));
|
||||
setTxPower(String(config.tx_power));
|
||||
setFreq(String(config.radio.freq));
|
||||
setBw(String(config.radio.bw));
|
||||
setSf(String(config.radio.sf));
|
||||
setCr(String(config.radio.cr));
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appSettings) {
|
||||
setMaxRadioContacts(String(appSettings.max_radio_contacts));
|
||||
}
|
||||
}, [appSettings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const update: RadioConfigUpdate = {
|
||||
name,
|
||||
lat: parseFloat(lat),
|
||||
lon: parseFloat(lon),
|
||||
tx_power: parseInt(txPower, 10),
|
||||
radio: {
|
||||
freq: parseFloat(freq),
|
||||
bw: parseFloat(bw),
|
||||
sf: parseInt(sf, 10),
|
||||
cr: parseInt(cr, 10),
|
||||
},
|
||||
};
|
||||
await onSave(update);
|
||||
|
||||
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
|
||||
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings?.max_radio_contacts) {
|
||||
await onSaveAppSettings({ max_radio_contacts: newMaxRadioContacts });
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPrivateKey = async () => {
|
||||
if (!privateKey.trim()) {
|
||||
setError('Private key is required');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await onSetPrivateKey(privateKey.trim());
|
||||
setPrivateKey('');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to set private key');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReboot = async () => {
|
||||
if (!confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.')) {
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setRebooting(true);
|
||||
|
||||
try {
|
||||
await onReboot();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to reboot radio');
|
||||
} finally {
|
||||
setRebooting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Radio Configuration</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{!config ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
Loading configuration...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="public-key">Public Key</Label>
|
||||
<Input id="public-key" value={config.public_key} disabled />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lat">Latitude</Label>
|
||||
<Input
|
||||
id="lat"
|
||||
type="number"
|
||||
step="any"
|
||||
value={lat}
|
||||
onChange={(e) => setLat(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lon">Longitude</Label>
|
||||
<Input
|
||||
id="lon"
|
||||
type="number"
|
||||
step="any"
|
||||
value={lon}
|
||||
onChange={(e) => setLon(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="freq">Frequency (MHz)</Label>
|
||||
<Input
|
||||
id="freq"
|
||||
type="number"
|
||||
step="any"
|
||||
value={freq}
|
||||
onChange={(e) => setFreq(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bw">Bandwidth (kHz)</Label>
|
||||
<Input
|
||||
id="bw"
|
||||
type="number"
|
||||
step="any"
|
||||
value={bw}
|
||||
onChange={(e) => setBw(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sf">Spreading Factor</Label>
|
||||
<Input
|
||||
id="sf"
|
||||
type="number"
|
||||
min="7"
|
||||
max="12"
|
||||
value={sf}
|
||||
onChange={(e) => setSf(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cr">Coding Rate</Label>
|
||||
<Input
|
||||
id="cr"
|
||||
type="number"
|
||||
min="1"
|
||||
max="4"
|
||||
value={cr}
|
||||
onChange={(e) => setCr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tx-power">TX Power (dBm)</Label>
|
||||
<Input
|
||||
id="tx-power"
|
||||
type="number"
|
||||
value={txPower}
|
||||
onChange={(e) => setTxPower(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-tx">Max TX Power</Label>
|
||||
<Input id="max-tx" type="number" value={config.max_tx_power} disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-contacts">Max Contacts on Radio</Label>
|
||||
<Input
|
||||
id="max-contacts"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
value={maxRadioContacts}
|
||||
onChange={(e) => setMaxRadioContacts(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recent non-repeater contacts loaded to radio for DM auto-ACK (1-1000)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="private-key"
|
||||
type="password"
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
placeholder="64-character hex private key"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSetPrivateKey}
|
||||
disabled={loading || !privateKey.trim()}
|
||||
>
|
||||
Set
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Reboot Radio</Label>
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
Some configuration changes (like name) require a radio reboot to take effect.
|
||||
The connection will temporarily drop and automatically reconnect.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReboot}
|
||||
disabled={rebooting || loading}
|
||||
className="border-yellow-500/50 text-yellow-200 hover:bg-yellow-500/10"
|
||||
>
|
||||
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={loading || !config}>
|
||||
{loading ? 'Saving...' : 'Save Config'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getContactAvatar } from '../utils/contactAvatar';
|
||||
|
||||
interface ContactAvatarProps {
|
||||
name: string | null;
|
||||
publicKey: string;
|
||||
size?: number;
|
||||
contactType?: number;
|
||||
}
|
||||
|
||||
export function ContactAvatar({ name, publicKey, size = 28, contactType }: ContactAvatarProps) {
|
||||
const avatar = getContactAvatar(name, publicKey, contactType);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-full font-semibold flex-shrink-0 select-none"
|
||||
style={{
|
||||
backgroundColor: avatar.background,
|
||||
color: avatar.textColor,
|
||||
width: size,
|
||||
height: size,
|
||||
fontSize: size * 0.45,
|
||||
}}
|
||||
>
|
||||
{avatar.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { GroupTextCracker, type ProgressReport } from 'meshcore-cracker';
|
||||
import type { RawPacket, Channel } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CrackedRoom {
|
||||
roomName: string;
|
||||
key: string;
|
||||
packetId: number;
|
||||
message: string;
|
||||
crackedAt: number;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
packet: RawPacket;
|
||||
attempts: number;
|
||||
lastAttemptLength: number;
|
||||
status: 'pending' | 'cracking' | 'cracked' | 'failed';
|
||||
}
|
||||
|
||||
interface CrackerPanelProps {
|
||||
packets: RawPacket[];
|
||||
channels: Channel[];
|
||||
onChannelCreate: (name: string, key: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function CrackerPanel({ packets, channels, onChannelCreate }: CrackerPanelProps) {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [maxLength, setMaxLength] = useState(6);
|
||||
const [retryFailedAtNextLength, setRetryFailedAtNextLength] = useState(false);
|
||||
const [progress, setProgress] = useState<ProgressReport | null>(null);
|
||||
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
|
||||
const [crackedRooms, setCrackedRooms] = useState<CrackedRoom[]>([]);
|
||||
const [wordlistLoaded, setWordlistLoaded] = useState(false);
|
||||
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
|
||||
|
||||
const crackerRef = useRef<GroupTextCracker | null>(null);
|
||||
const isRunningRef = useRef(false);
|
||||
const abortedRef = useRef(false);
|
||||
const isProcessingRef = useRef(false);
|
||||
const queueRef = useRef<Map<number, QueueItem>>(new Map());
|
||||
const retryFailedRef = useRef(false);
|
||||
const maxLengthRef = useRef(6);
|
||||
|
||||
// Initialize cracker
|
||||
useEffect(() => {
|
||||
const cracker = new GroupTextCracker();
|
||||
crackerRef.current = cracker;
|
||||
setGpuAvailable(cracker.isGpuAvailable());
|
||||
|
||||
// Load wordlist
|
||||
cracker.loadWordlist('/words_alpha.txt')
|
||||
.then(() => setWordlistLoaded(true))
|
||||
.catch((err) => console.error('Failed to load wordlist:', err));
|
||||
|
||||
return () => {
|
||||
cracker.destroy();
|
||||
crackerRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get existing channel keys for filtering
|
||||
const existingChannelKeys = new Set(channels.map(c => c.key.toUpperCase()));
|
||||
|
||||
// Filter packets to only undecrypted GROUP_TEXT
|
||||
const undecryptedGroupText = packets.filter(
|
||||
p => p.payload_type === 'GROUP_TEXT' && !p.decrypted
|
||||
);
|
||||
|
||||
// Update queue when packets change
|
||||
useEffect(() => {
|
||||
setQueue(prev => {
|
||||
const newQueue = new Map(prev);
|
||||
let changed = false;
|
||||
|
||||
for (const packet of undecryptedGroupText) {
|
||||
if (!newQueue.has(packet.id)) {
|
||||
newQueue.set(packet.id, {
|
||||
packet,
|
||||
attempts: 0,
|
||||
lastAttemptLength: 0,
|
||||
status: 'pending',
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
queueRef.current = newQueue;
|
||||
return newQueue;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [undecryptedGroupText.length]);
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
queueRef.current = queue;
|
||||
}, [queue]);
|
||||
|
||||
useEffect(() => {
|
||||
retryFailedRef.current = retryFailedAtNextLength;
|
||||
}, [retryFailedAtNextLength]);
|
||||
|
||||
useEffect(() => {
|
||||
maxLengthRef.current = maxLength;
|
||||
}, [maxLength]);
|
||||
|
||||
// Stats (cracking count is implicit - if progress is shown, we're cracking one)
|
||||
const pendingCount = Array.from(queue.values()).filter(q => q.status === 'pending').length;
|
||||
const crackedCount = Array.from(queue.values()).filter(q => q.status === 'cracked').length;
|
||||
const failedCount = Array.from(queue.values()).filter(q => q.status === 'failed').length;
|
||||
|
||||
// Process next packet in queue
|
||||
const processNext = useCallback(async () => {
|
||||
// Prevent concurrent processing
|
||||
if (isProcessingRef.current) return;
|
||||
if (!crackerRef.current || !isRunningRef.current) return;
|
||||
|
||||
const currentQueue = queueRef.current;
|
||||
|
||||
// Find next pending packet
|
||||
let nextItem: QueueItem | null = null;
|
||||
let nextId: number | null = null;
|
||||
|
||||
for (const [id, item] of currentQueue.entries()) {
|
||||
if (item.status === 'pending') {
|
||||
nextItem = item;
|
||||
nextId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no pending and retry option is enabled, pick the failed one with lowest lastAttemptLength
|
||||
if (!nextItem && retryFailedRef.current) {
|
||||
const failedItems = Array.from(currentQueue.entries()).filter(
|
||||
([, item]) => item.status === 'failed' && item.lastAttemptLength < 10 // Hard cap at length 10
|
||||
);
|
||||
if (failedItems.length > 0) {
|
||||
// Sort by lastAttemptLength ascending and pick the first (lowest)
|
||||
failedItems.sort((a, b) => a[1].lastAttemptLength - b[1].lastAttemptLength);
|
||||
[nextId, nextItem] = failedItems[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextItem || nextId === null) {
|
||||
// Nothing to process right now, but keep running and check again later
|
||||
if (isRunningRef.current) {
|
||||
setTimeout(() => processNext(), 1000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Lock processing
|
||||
isProcessingRef.current = true;
|
||||
|
||||
const currentMaxLength = maxLengthRef.current;
|
||||
const targetLength = nextItem.lastAttemptLength > 0
|
||||
? nextItem.lastAttemptLength + 1
|
||||
: currentMaxLength;
|
||||
|
||||
try {
|
||||
const result = await crackerRef.current.crack(
|
||||
nextItem.packet.data,
|
||||
{
|
||||
maxLength: targetLength,
|
||||
useTimestampFilter: true,
|
||||
useUtf8Filter: true,
|
||||
},
|
||||
(prog) => {
|
||||
setProgress(prog);
|
||||
}
|
||||
);
|
||||
|
||||
if (abortedRef.current) {
|
||||
abortedRef.current = false;
|
||||
isProcessingRef.current = false;
|
||||
setProgress(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.found && result.roomName && result.key) {
|
||||
// Success!
|
||||
setQueue(prev => {
|
||||
const updated = new Map(prev);
|
||||
const item = updated.get(nextId!);
|
||||
if (item) {
|
||||
updated.set(nextId!, {
|
||||
...item,
|
||||
status: 'cracked',
|
||||
attempts: item.attempts + 1,
|
||||
lastAttemptLength: targetLength,
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
const newRoom: CrackedRoom = {
|
||||
roomName: result.roomName,
|
||||
key: result.key,
|
||||
packetId: nextId!,
|
||||
message: result.decryptedMessage || '',
|
||||
crackedAt: Date.now(),
|
||||
};
|
||||
setCrackedRooms(prev => [...prev, newRoom]);
|
||||
|
||||
// Auto-add channel if not already exists
|
||||
const keyUpper = result.key.toUpperCase();
|
||||
if (!existingChannelKeys.has(keyUpper)) {
|
||||
try {
|
||||
await onChannelCreate('#' + result.roomName, result.key);
|
||||
} catch (err) {
|
||||
console.error('Failed to create channel:', err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Failed
|
||||
setQueue(prev => {
|
||||
const updated = new Map(prev);
|
||||
const item = updated.get(nextId!);
|
||||
if (item) {
|
||||
updated.set(nextId!, {
|
||||
...item,
|
||||
status: 'failed',
|
||||
attempts: item.attempts + 1,
|
||||
lastAttemptLength: targetLength,
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Cracking error:', err);
|
||||
setQueue(prev => {
|
||||
const updated = new Map(prev);
|
||||
const item = updated.get(nextId!);
|
||||
if (item) {
|
||||
updated.set(nextId!, {
|
||||
...item,
|
||||
status: 'failed',
|
||||
attempts: item.attempts + 1,
|
||||
lastAttemptLength: targetLength,
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
// Unlock processing
|
||||
isProcessingRef.current = false;
|
||||
setProgress(null);
|
||||
|
||||
// Continue processing if still running
|
||||
if (isRunningRef.current) {
|
||||
setTimeout(() => processNext(), 100);
|
||||
}
|
||||
}, [existingChannelKeys, onChannelCreate]);
|
||||
|
||||
// Start/stop handlers
|
||||
const handleStart = () => {
|
||||
if (!gpuAvailable) {
|
||||
alert('WebGPU is not available in your browser. Please use Chrome 113+ or Edge 113+.');
|
||||
return;
|
||||
}
|
||||
setIsRunning(true);
|
||||
isRunningRef.current = true;
|
||||
abortedRef.current = false;
|
||||
processNext();
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
setIsRunning(false);
|
||||
isRunningRef.current = false;
|
||||
abortedRef.current = true;
|
||||
crackerRef.current?.abort();
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-3 gap-3 bg-background border-t border-border">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onClick={isRunning ? handleStop : handleStart}
|
||||
disabled={!wordlistLoaded || gpuAvailable === false}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded text-sm font-medium",
|
||||
isRunning
|
||||
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isRunning ? 'Stop' : 'Start Cracking'}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-muted-foreground">Max Length:</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxLength}
|
||||
onChange={(e) => setMaxLength(Math.min(10, Math.max(1, parseInt(e.target.value) || 6)))}
|
||||
className="w-14 px-2 py-1 text-sm bg-muted border border-border rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={retryFailedAtNextLength}
|
||||
onChange={(e) => setRetryFailedAtNextLength(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Retry failed at n+1
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Pending: <span className="text-foreground font-medium">{pendingCount}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Cracked: <span className="text-green-500 font-medium">{crackedCount}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Failed: <span className="text-destructive font-medium">{failedCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{progress && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{progress.phase === 'wordlist' ? 'Dictionary' : progress.phase === 'bruteforce' ? 'Bruteforce' : 'Public Key'}
|
||||
{progress.phase === 'bruteforce' && ` - Length ${progress.currentLength}`}
|
||||
: {progress.currentPosition}
|
||||
</span>
|
||||
<span>
|
||||
{progress.rateKeysPerSec >= 1e9
|
||||
? `${(progress.rateKeysPerSec / 1e9).toFixed(2)} Gkeys/s`
|
||||
: `${(progress.rateKeysPerSec / 1e6).toFixed(1)} Mkeys/s`}
|
||||
{' '}• ETA: {progress.etaSeconds < 60 ? `${Math.round(progress.etaSeconds)}s` : `${Math.round(progress.etaSeconds / 60)}m`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-200"
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GPU status */}
|
||||
{gpuAvailable === false && (
|
||||
<div className="text-sm text-destructive">
|
||||
WebGPU not available. Cracking requires Chrome 113+ or Edge 113+.
|
||||
</div>
|
||||
)}
|
||||
{!wordlistLoaded && gpuAvailable !== false && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading wordlist...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cracked rooms list */}
|
||||
{crackedRooms.length > 0 && (
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="text-xs text-muted-foreground mb-1">Cracked Rooms:</div>
|
||||
<div className="space-y-1">
|
||||
{crackedRooms.map((room, i) => (
|
||||
<div key={i} className="text-sm bg-green-950/30 border border-green-900/50 rounded px-2 py-1">
|
||||
<span className="text-green-400 font-medium">#{room.roomName}</span>
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
"{room.message.slice(0, 50)}{room.message.length > 50 ? '...' : ''}"
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, useCallback, useImperativeHandle, forwardRef, useRef, type FormEvent, type KeyboardEvent } from 'react';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface MessageInputProps {
|
||||
onSend: (text: string) => Promise<void>;
|
||||
disabled: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface MessageInputHandle {
|
||||
appendText: (text: string) => void;
|
||||
}
|
||||
|
||||
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
|
||||
function MessageInput({ onSend, disabled, placeholder }, ref) {
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
appendText: (appendedText: string) => {
|
||||
setText((prev) => prev + appendedText);
|
||||
// Focus the input after appending
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || sending || disabled) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
await onSend(trimmed);
|
||||
setText('');
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[text, sending, disabled, onSend]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e as unknown as FormEvent);
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
return (
|
||||
<form className="px-4 py-3 border-t border-border flex gap-2" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder || 'Type a message...'}
|
||||
disabled={disabled || sending}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit" disabled={disabled || sending || !text.trim()}>
|
||||
{sending ? 'Sending...' : 'Send'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import type { Contact, Message } from '../types';
|
||||
import { formatTime, parseSenderFromText } from '../utils/messageParser';
|
||||
import { pubkeysMatch } from '../utils/pubkey';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
contacts: Contact[];
|
||||
loading: boolean;
|
||||
loadingOlder?: boolean;
|
||||
hasOlderMessages?: boolean;
|
||||
onSenderClick?: (sender: string) => void;
|
||||
onLoadOlder?: () => void;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
contacts,
|
||||
loading,
|
||||
loadingOlder = false,
|
||||
hasOlderMessages = false,
|
||||
onSenderClick,
|
||||
onLoadOlder,
|
||||
}: MessageListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const prevMessagesLengthRef = useRef<number>(0);
|
||||
const isInitialLoadRef = useRef<boolean>(true);
|
||||
|
||||
// Capture scroll state in the scroll handler BEFORE any state updates
|
||||
const scrollStateRef = useRef({
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
wasNearTop: false,
|
||||
});
|
||||
|
||||
// Handle scroll position AFTER render
|
||||
useLayoutEffect(() => {
|
||||
if (!listRef.current) return;
|
||||
|
||||
const list = listRef.current;
|
||||
const messagesAdded = messages.length - prevMessagesLengthRef.current;
|
||||
|
||||
if (isInitialLoadRef.current && messages.length > 0) {
|
||||
// Initial load - scroll to bottom
|
||||
list.scrollTop = list.scrollHeight;
|
||||
isInitialLoadRef.current = false;
|
||||
} else if (messagesAdded > 0 && prevMessagesLengthRef.current > 0) {
|
||||
// Messages were added - use scroll state captured before the update
|
||||
const scrollHeightDiff = list.scrollHeight - scrollStateRef.current.scrollHeight;
|
||||
|
||||
if (scrollStateRef.current.wasNearTop && scrollHeightDiff > 0) {
|
||||
// User was near top (loading older) - preserve position by adding the height diff
|
||||
list.scrollTop = scrollStateRef.current.scrollTop + scrollHeightDiff;
|
||||
} else if (!scrollStateRef.current.wasNearTop) {
|
||||
// User was at bottom - scroll to bottom for new messages
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
prevMessagesLengthRef.current = messages.length;
|
||||
}, [messages]);
|
||||
|
||||
// Reset initial load flag when conversation changes (messages becomes empty then filled)
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
isInitialLoadRef.current = true;
|
||||
prevMessagesLengthRef.current = 0;
|
||||
scrollStateRef.current = { scrollTop: 0, scrollHeight: 0, wasNearTop: false };
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
// Handle scroll - capture state and detect when user is near top
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!listRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight } = listRef.current;
|
||||
|
||||
// Always capture current scroll state (needed for scroll preservation)
|
||||
scrollStateRef.current = {
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
wasNearTop: scrollTop < 150,
|
||||
};
|
||||
|
||||
if (!onLoadOlder || loadingOlder || !hasOlderMessages) return;
|
||||
|
||||
// Trigger load when within 100px of top
|
||||
if (scrollTop < 100) {
|
||||
onLoadOlder();
|
||||
}
|
||||
}, [onLoadOlder, loadingOlder, hasOlderMessages]);
|
||||
|
||||
// Look up contact by public key or prefix
|
||||
const getContact = (conversationKey: string | null): Contact | null => {
|
||||
if (!conversationKey) return null;
|
||||
return contacts.find(c => pubkeysMatch(c.public_key, conversationKey)) || null;
|
||||
};
|
||||
|
||||
// Look up contact by name (for channel messages where we parse sender from text)
|
||||
const getContactByName = (name: string): Contact | null => {
|
||||
return contacts.find(c => c.name === name) || null;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">Loading messages...</div>;
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return <div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">No messages yet</div>;
|
||||
}
|
||||
|
||||
// Deduplicate messages by content + timestamp (same message via different paths)
|
||||
const deduplicatedMessages = messages.reduce<Message[]>((acc, msg) => {
|
||||
const key = `${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`;
|
||||
const existing = acc.find(m =>
|
||||
`${m.type}-${m.conversation_key}-${m.text}-${m.sender_timestamp}` === key
|
||||
);
|
||||
if (!existing) {
|
||||
acc.push(msg);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// Sort messages by received_at ascending (oldest first)
|
||||
const sortedMessages = [...deduplicatedMessages].sort(
|
||||
(a, b) => a.received_at - b.received_at
|
||||
);
|
||||
|
||||
// Helper to get a unique sender key for grouping messages
|
||||
const getSenderKey = (msg: Message, sender: string | null): string => {
|
||||
if (msg.outgoing) return '__outgoing__';
|
||||
if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key;
|
||||
return sender || '__unknown__';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-0.5" ref={listRef} onScroll={handleScroll}>
|
||||
{loadingOlder && (
|
||||
<div className="text-center py-2 text-muted-foreground text-sm">
|
||||
Loading older messages...
|
||||
</div>
|
||||
)}
|
||||
{!loadingOlder && hasOlderMessages && (
|
||||
<div className="text-center py-2 text-muted-foreground text-xs">
|
||||
Scroll up for older messages
|
||||
</div>
|
||||
)}
|
||||
{sortedMessages.map((msg, index) => {
|
||||
const { sender, content } = parseSenderFromText(msg.text);
|
||||
// For DMs, look up contact; for channel messages, use parsed sender
|
||||
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
|
||||
const displaySender = msg.outgoing
|
||||
? 'You'
|
||||
: contact?.name || sender || msg.conversation_key?.slice(0, 8) || 'Unknown';
|
||||
|
||||
const canClickSender = !msg.outgoing && onSenderClick && displaySender !== 'Unknown';
|
||||
|
||||
// Determine if we should show avatar (first message in a chunk from same sender)
|
||||
const currentSenderKey = getSenderKey(msg, sender);
|
||||
const prevMsg = sortedMessages[index - 1];
|
||||
const prevSenderKey = prevMsg ? getSenderKey(prevMsg, parseSenderFromText(prevMsg.text).sender) : null;
|
||||
const showAvatar = !msg.outgoing && currentSenderKey !== prevSenderKey;
|
||||
const isFirstMessage = index === 0;
|
||||
|
||||
// Get avatar info for incoming messages
|
||||
let avatarName: string | null = null;
|
||||
let avatarKey: string = '';
|
||||
if (!msg.outgoing) {
|
||||
if (msg.type === 'PRIV' && msg.conversation_key) {
|
||||
// DM: use conversation_key (sender's public key)
|
||||
avatarName = contact?.name || null;
|
||||
avatarKey = msg.conversation_key;
|
||||
} else if (sender) {
|
||||
// Channel message: try to find contact by name, or use sender name as pseudo-key
|
||||
const senderContact = getContactByName(sender);
|
||||
avatarName = sender;
|
||||
avatarKey = senderContact?.public_key || `name:${sender}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
"flex items-start max-w-[85%]",
|
||||
msg.outgoing && "flex-row-reverse self-end",
|
||||
showAvatar && !isFirstMessage && "mt-3"
|
||||
)}
|
||||
>
|
||||
{!msg.outgoing && (
|
||||
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
|
||||
{showAvatar && avatarKey && (
|
||||
<ContactAvatar name={avatarName} publicKey={avatarKey} size={32} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(
|
||||
"py-1.5 px-3 rounded-lg min-w-0",
|
||||
msg.outgoing ? "bg-[#1e3a29]" : "bg-muted"
|
||||
)}>
|
||||
{showAvatar && (
|
||||
<div className="text-[13px] font-semibold text-muted-foreground mb-0.5">
|
||||
{canClickSender ? (
|
||||
<span
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
onClick={() => onSenderClick(displaySender)}
|
||||
title={`Mention ${displaySender}`}
|
||||
>
|
||||
{displaySender}
|
||||
</span>
|
||||
) : (
|
||||
displaySender
|
||||
)}
|
||||
<span className="font-normal text-muted-foreground/70 ml-2 text-[11px]">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="break-words whitespace-pre-wrap">
|
||||
{content.split('\n').map((line, i, arr) => (
|
||||
<span key={i}>
|
||||
{line}
|
||||
{i < arr.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
{!showAvatar && (
|
||||
<span className="text-[10px] text-muted-foreground/50 ml-2">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
</span>
|
||||
)}
|
||||
{msg.outgoing && (msg.acked ? ' ✓' : ' ?')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import type { Contact, Conversation } from '../types';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from './ui/dialog';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type Tab = 'existing' | 'new-contact' | 'new-room' | 'hashtag';
|
||||
|
||||
interface NewMessageModalProps {
|
||||
open: boolean;
|
||||
contacts: Contact[];
|
||||
undecryptedCount: number;
|
||||
onClose: () => void;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export function NewMessageModal({
|
||||
open,
|
||||
contacts,
|
||||
undecryptedCount,
|
||||
onClose,
|
||||
onSelectConversation,
|
||||
onCreateContact,
|
||||
onCreateChannel,
|
||||
onCreateHashtagChannel,
|
||||
}: NewMessageModalProps) {
|
||||
const [tab, setTab] = useState<Tab>('existing');
|
||||
const [name, setName] = useState('');
|
||||
const [key, setKey] = useState('');
|
||||
const [tryHistorical, setTryHistorical] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const hashtagInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (tab === 'new-contact') {
|
||||
if (!name.trim() || !key.trim()) {
|
||||
setError('Name and public key are required');
|
||||
return;
|
||||
}
|
||||
await onCreateContact(name.trim(), key.trim(), tryHistorical);
|
||||
onSelectConversation({
|
||||
type: 'contact',
|
||||
id: key.trim(),
|
||||
name: name.trim(),
|
||||
});
|
||||
} else if (tab === 'new-room') {
|
||||
if (!name.trim() || !key.trim()) {
|
||||
setError('Room name and key are required');
|
||||
return;
|
||||
}
|
||||
await onCreateChannel(name.trim(), key.trim(), tryHistorical);
|
||||
} else if (tab === 'hashtag') {
|
||||
const channelName = name.trim();
|
||||
const validationError = validateHashtagName(channelName);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
await onCreateHashtagChannel(`#${channelName}`, tryHistorical);
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateHashtagName = (channelName: string): string | null => {
|
||||
if (!channelName) {
|
||||
return 'Channel name is required';
|
||||
}
|
||||
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
|
||||
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleCreateAndAddAnother = async () => {
|
||||
setError('');
|
||||
const channelName = name.trim();
|
||||
const validationError = validateHashtagName(channelName);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await onCreateHashtagChannel(`#${channelName}`, tryHistorical);
|
||||
setName('');
|
||||
hashtagInputRef.current?.focus();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showHistoricalOption = tab !== 'existing' && undecryptedCount > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Conversation</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="existing">Existing</TabsTrigger>
|
||||
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
||||
<TabsTrigger value="new-room">Room</TabsTrigger>
|
||||
<TabsTrigger value="hashtag">Hashtag</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="existing" className="mt-4">
|
||||
<div className="max-h-[300px] overflow-y-auto rounded-md border">
|
||||
{contacts.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
No contacts available
|
||||
</div>
|
||||
) : (
|
||||
contacts.map((contact) => (
|
||||
<div
|
||||
key={contact.public_key}
|
||||
className="cursor-pointer px-4 py-2 hover:bg-accent"
|
||||
onClick={() => {
|
||||
onSelectConversation({
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(contact.name, contact.public_key),
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{getContactDisplayName(contact.name, contact.public_key)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-name">Name</Label>
|
||||
<Input
|
||||
id="contact-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Contact name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-key">Public Key</Label>
|
||||
<Input
|
||||
id="contact-key"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder="64-character hex public key"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new-room" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="room-name">Room Name</Label>
|
||||
<Input
|
||||
id="room-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Room name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="room-key">Room Key</Label>
|
||||
<Input
|
||||
id="room-key"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder="Pre-shared key (hex)"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="hashtag" className="mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hashtag-name">Hashtag Channel</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm text-muted-foreground">#</span>
|
||||
<Input
|
||||
ref={hashtagInputRef}
|
||||
id="hashtag-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="channel-name"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{showHistoricalOption && (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Label
|
||||
htmlFor="try-historical"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
Try decrypting {undecryptedCount.toLocaleString()} stored packet{undecryptedCount !== 1 ? 's' : ''}
|
||||
</Label>
|
||||
<Checkbox
|
||||
id="try-historical"
|
||||
checked={tryHistorical}
|
||||
onCheckedChange={(checked) => setTryHistorical(checked === true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{tab === 'hashtag' && (
|
||||
<Button variant="secondary" onClick={handleCreateAndAddAnother} disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create & Add Another'}
|
||||
</Button>
|
||||
)}
|
||||
{tab !== 'existing' && (
|
||||
<Button onClick={handleCreate} disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { RawPacket } from '../types';
|
||||
|
||||
interface RawPacketListProps {
|
||||
packets: RawPacket[];
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function formatPayloadType(type: string): string {
|
||||
// Convert SNAKE_CASE to Title Case
|
||||
return type
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function getDecryptedLabel(packet: RawPacket): string {
|
||||
if (!packet.decrypted || !packet.decrypted_info) {
|
||||
return formatPayloadType(packet.payload_type);
|
||||
}
|
||||
|
||||
const info = packet.decrypted_info;
|
||||
if (packet.payload_type === 'GROUP_TEXT' && info.channel_name) {
|
||||
return `GroupText to ${info.channel_name}`;
|
||||
}
|
||||
if (packet.payload_type === 'TEXT_MESSAGE' && info.sender) {
|
||||
return `TextMessage from ${info.sender}`;
|
||||
}
|
||||
|
||||
return formatPayloadType(packet.payload_type);
|
||||
}
|
||||
|
||||
function formatSignalInfo(packet: RawPacket): string {
|
||||
const parts: string[] = [];
|
||||
if (packet.snr !== null && packet.snr !== undefined) {
|
||||
parts.push(`SNR: ${packet.snr.toFixed(1)} dB`);
|
||||
}
|
||||
if (packet.rssi !== null && packet.rssi !== undefined) {
|
||||
parts.push(`RSSI: ${packet.rssi} dBm`);
|
||||
}
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
export function RawPacketList({ packets }: RawPacketListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [packets]);
|
||||
|
||||
if (packets.length === 0) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-5 text-center text-muted-foreground">
|
||||
No packets received yet. Packets will appear here in real-time.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Sort packets by timestamp ascending (oldest first)
|
||||
const sortedPackets = [...packets].sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 flex flex-col gap-3" ref={listRef}>
|
||||
{sortedPackets.map((packet) => (
|
||||
<div key={packet.id} className="py-2 px-3 bg-muted rounded">
|
||||
<div className={packet.decrypted ? 'text-primary' : 'text-destructive'}>
|
||||
{!packet.decrypted && <span className="mr-1">🔒</span>}
|
||||
{getDecryptedLabel(packet)}
|
||||
{' • '}
|
||||
{formatTime(packet.timestamp)}
|
||||
</div>
|
||||
{(packet.snr !== null || packet.rssi !== null) && (
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||
{formatSignalInfo(packet)}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-mono text-[11px] break-all text-muted-foreground/70 mt-1">
|
||||
{packet.data.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useState } from 'react';
|
||||
import type { Contact, Channel, Conversation } from '../types';
|
||||
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
|
||||
import { getPubkeyPrefix, getContactDisplayName } from '../utils/pubkey';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { CONTACT_TYPE_REPEATER } from '../utils/contactAvatar';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type SortOrder = 'alpha' | 'recent';
|
||||
|
||||
interface SidebarProps {
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
activeConversation: Conversation | null;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onNewMessage: () => void;
|
||||
lastMessageTimes: ConversationTimes;
|
||||
unreadCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
// Load sort preference from localStorage
|
||||
function loadSortOrder(): SortOrder {
|
||||
try {
|
||||
const stored = localStorage.getItem('remoteterm-sortOrder');
|
||||
return stored === 'recent' ? 'recent' : 'alpha';
|
||||
} catch {
|
||||
return 'alpha';
|
||||
}
|
||||
}
|
||||
|
||||
// Save sort preference to localStorage
|
||||
function saveSortOrder(order: SortOrder): void {
|
||||
try {
|
||||
localStorage.setItem('remoteterm-sortOrder', order);
|
||||
} catch {
|
||||
// localStorage might be full or disabled
|
||||
}
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
contacts,
|
||||
channels,
|
||||
activeConversation,
|
||||
onSelectConversation,
|
||||
onNewMessage,
|
||||
lastMessageTimes,
|
||||
unreadCounts,
|
||||
}: SidebarProps) {
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>(loadSortOrder);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const handleSortToggle = () => {
|
||||
const newOrder = sortOrder === 'alpha' ? 'recent' : 'alpha';
|
||||
setSortOrder(newOrder);
|
||||
saveSortOrder(newOrder);
|
||||
};
|
||||
|
||||
const handleSelectConversation = (conversation: Conversation) => {
|
||||
setSearchQuery('');
|
||||
onSelectConversation(conversation);
|
||||
};
|
||||
|
||||
const isActive = (type: 'contact' | 'channel' | 'raw', id: string) =>
|
||||
activeConversation?.type === type && activeConversation?.id === id;
|
||||
|
||||
// Get unread count for a conversation
|
||||
const getUnreadCount = (type: 'channel' | 'contact', id: string): number => {
|
||||
const key = getStateKey(type, id);
|
||||
return unreadCounts[key] || 0;
|
||||
};
|
||||
|
||||
const getLastMessageTime = (type: 'channel' | 'contact', id: string) => {
|
||||
const key = getStateKey(type, id);
|
||||
return lastMessageTimes[key] || 0;
|
||||
};
|
||||
|
||||
// Deduplicate channels by name, keeping the first (lowest index)
|
||||
const uniqueChannels = channels.reduce<Channel[]>((acc, channel) => {
|
||||
if (!acc.some((c) => c.name === channel.name)) {
|
||||
acc.push(channel);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// Deduplicate contacts by 12-char prefix, preferring ones with names
|
||||
// Also filter out any contacts with empty public keys
|
||||
const uniqueContacts = contacts
|
||||
.filter((c) => c.public_key && c.public_key.length > 0)
|
||||
.sort((a, b) => {
|
||||
// Sort contacts with names first
|
||||
if (a.name && !b.name) return -1;
|
||||
if (!a.name && b.name) return 1;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
})
|
||||
.reduce<Contact[]>((acc, contact) => {
|
||||
const prefix = getPubkeyPrefix(contact.public_key);
|
||||
if (!acc.some((c) => getPubkeyPrefix(c.public_key) === prefix)) {
|
||||
acc.push(contact);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
// Sort channels based on sort order, with Public always first
|
||||
const sortedChannels = [...uniqueChannels].sort((a, b) => {
|
||||
// Public channel always sorts to the top
|
||||
if (a.name === 'Public') return -1;
|
||||
if (b.name === 'Public') return 1;
|
||||
|
||||
if (sortOrder === 'recent') {
|
||||
const timeA = getLastMessageTime('channel', a.key);
|
||||
const timeB = getLastMessageTime('channel', b.key);
|
||||
// If both have messages, sort by most recent first
|
||||
if (timeA && timeB) return timeB - timeA;
|
||||
// Items with messages come before items without
|
||||
if (timeA && !timeB) return -1;
|
||||
if (!timeA && timeB) return 1;
|
||||
// Fall back to alpha for items without messages
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Sort contacts: non-repeaters first (by recent or alpha), then repeaters (always alpha)
|
||||
const sortedContacts = [...uniqueContacts].sort((a, b) => {
|
||||
const aIsRepeater = a.type === CONTACT_TYPE_REPEATER;
|
||||
const bIsRepeater = b.type === CONTACT_TYPE_REPEATER;
|
||||
|
||||
// Repeaters always go to the bottom
|
||||
if (aIsRepeater && !bIsRepeater) return 1;
|
||||
if (!aIsRepeater && bIsRepeater) return -1;
|
||||
|
||||
// Both repeaters: always sort alphabetically
|
||||
if (aIsRepeater && bIsRepeater) {
|
||||
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
|
||||
}
|
||||
|
||||
// Both non-repeaters: use selected sort order
|
||||
if (sortOrder === 'recent') {
|
||||
const timeA = getLastMessageTime('contact', a.public_key);
|
||||
const timeB = getLastMessageTime('contact', b.public_key);
|
||||
// If both have messages, sort by most recent first
|
||||
if (timeA && timeB) return timeB - timeA;
|
||||
// Items with messages come before items without
|
||||
if (timeA && !timeB) return -1;
|
||||
if (!timeA && timeB) return 1;
|
||||
// Fall back to alpha for items without messages
|
||||
}
|
||||
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
|
||||
});
|
||||
|
||||
// Filter by search query
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
const filteredChannels = query
|
||||
? sortedChannels.filter((c) => c.name.toLowerCase().includes(query))
|
||||
: sortedChannels;
|
||||
const filteredContacts = query
|
||||
? sortedContacts.filter((c) =>
|
||||
(c.name?.toLowerCase().includes(query)) ||
|
||||
c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedContacts;
|
||||
|
||||
return (
|
||||
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center px-3 py-3 border-b border-border">
|
||||
<h2 className="text-xs uppercase text-muted-foreground font-medium">Conversations</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onNewMessage}
|
||||
title="New Message"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative px-3 py-2 border-b border-border">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 text-sm pr-8"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title="Clear search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Raw Packet Feed */}
|
||||
{!query && (
|
||||
<div
|
||||
className={cn(
|
||||
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
|
||||
isActive('raw', 'raw') && "bg-accent border-l-primary"
|
||||
)}
|
||||
onClick={() =>
|
||||
handleSelectConversation({
|
||||
type: 'raw',
|
||||
id: 'raw',
|
||||
name: 'Raw Packet Feed',
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">📡</span>
|
||||
<span className="flex-1 truncate">Packet Feed</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channels */}
|
||||
{filteredChannels.length > 0 && (
|
||||
<>
|
||||
<div className="flex justify-between items-center px-3 py-2 pt-3">
|
||||
<span className="text-[11px] uppercase text-muted-foreground">Channels</span>
|
||||
<button
|
||||
className="bg-transparent border border-border text-muted-foreground px-1.5 py-0.5 text-[10px] rounded hover:bg-accent hover:text-foreground"
|
||||
onClick={handleSortToggle}
|
||||
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
|
||||
>
|
||||
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
|
||||
</button>
|
||||
</div>
|
||||
{filteredChannels.map((channel) => {
|
||||
const unreadCount = getUnreadCount('channel', channel.key);
|
||||
return (
|
||||
<div
|
||||
key={`chan-${channel.key}`}
|
||||
className={cn(
|
||||
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
|
||||
isActive('channel', channel.key) && "bg-accent border-l-primary",
|
||||
unreadCount > 0 && "[&_.name]:font-bold [&_.name]:text-foreground"
|
||||
)}
|
||||
onClick={() =>
|
||||
handleSelectConversation({
|
||||
type: 'channel',
|
||||
id: channel.key,
|
||||
name: channel.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">#</span>
|
||||
<span className="name flex-1 truncate">{channel.name}</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="bg-primary text-primary-foreground text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Contacts */}
|
||||
{filteredContacts.length > 0 && (
|
||||
<>
|
||||
<div className="flex justify-between items-center px-3 py-2 pt-3">
|
||||
<span className="text-[11px] uppercase text-muted-foreground">Contacts</span>
|
||||
{filteredChannels.length === 0 && (
|
||||
<button
|
||||
className="bg-transparent border border-border text-muted-foreground px-1.5 py-0.5 text-[10px] rounded hover:bg-accent hover:text-foreground"
|
||||
onClick={handleSortToggle}
|
||||
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
|
||||
>
|
||||
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filteredContacts.map((contact) => {
|
||||
const unreadCount = getUnreadCount('contact', contact.public_key);
|
||||
return (
|
||||
<div
|
||||
key={contact.public_key}
|
||||
className={cn(
|
||||
"px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent",
|
||||
isActive('contact', contact.public_key) && "bg-accent border-l-primary",
|
||||
unreadCount > 0 && "[&_.name]:font-bold [&_.name]:text-foreground"
|
||||
)}
|
||||
onClick={() =>
|
||||
handleSelectConversation({
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(contact.name, contact.public_key),
|
||||
})
|
||||
}
|
||||
>
|
||||
<ContactAvatar name={contact.name} publicKey={contact.public_key} size={24} contactType={contact.type} />
|
||||
<span className="name flex-1 truncate">
|
||||
{getContactDisplayName(contact.name, contact.public_key)}
|
||||
</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="bg-primary text-primary-foreground text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{filteredContacts.length === 0 && filteredChannels.length === 0 && (
|
||||
<div className="p-5 text-center text-muted-foreground">
|
||||
{query ? 'No matches found' : 'No conversations yet'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import { Menu } from 'lucide-react';
|
||||
import type { HealthStatus, RadioConfig } from '../types';
|
||||
import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
|
||||
interface StatusBarProps {
|
||||
health: HealthStatus | null;
|
||||
config: RadioConfig | null;
|
||||
onConfigClick: () => void;
|
||||
onAdvertise: () => void;
|
||||
onMenuClick?: () => void;
|
||||
}
|
||||
|
||||
export function StatusBar({ health, config, onConfigClick, onAdvertise, onMenuClick }: StatusBarProps) {
|
||||
const connected = health?.radio_connected ?? false;
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
|
||||
const handleReconnect = async () => {
|
||||
setReconnecting(true);
|
||||
try {
|
||||
const result = await api.reconnectRadio();
|
||||
if (result.connected) {
|
||||
toast.success('Reconnected', { description: result.message });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Reconnection failed', {
|
||||
description: err instanceof Error ? err.message : 'Check radio connection and power',
|
||||
});
|
||||
} finally {
|
||||
setReconnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-4 py-2 bg-[#252525] border-b border-[#333] text-xs">
|
||||
{/* Mobile menu button - only visible on small screens */}
|
||||
{onMenuClick && (
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="md:hidden p-1 bg-transparent border-none text-[#e0e0e0] cursor-pointer"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<h1 className="hidden lg:block text-base font-semibold mr-auto">RemoteTerm</h1>
|
||||
|
||||
<div className="flex items-center gap-1 text-[#888]">
|
||||
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-[#4caf50]' : 'bg-[#666]'}`} />
|
||||
<span className="hidden lg:inline text-[#e0e0e0]">{connected ? 'Connected' : 'Disconnected'}</span>
|
||||
</div>
|
||||
|
||||
{health?.serial_port && (
|
||||
<div className="hidden xl:flex items-center gap-1 text-[#888]">
|
||||
Port: <span className="text-[#e0e0e0]">{health.serial_port}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config && (
|
||||
<>
|
||||
<div className="hidden lg:flex items-center gap-1 text-[#888]">
|
||||
Name: <span className="text-[#e0e0e0]">{config.name || 'Unnamed'}</span>
|
||||
</div>
|
||||
<div className="hidden xl:flex items-center gap-1 text-[#888]">
|
||||
Freq: <span className="text-[#e0e0e0]">{config.radio.freq} MHz</span>
|
||||
</div>
|
||||
<div className="hidden xl:flex items-center gap-1 text-[#888]">
|
||||
SF{config.radio.sf}/CR{config.radio.cr}
|
||||
</div>
|
||||
<div className="hidden xl:flex items-center gap-1 text-[#888]">
|
||||
TX: <span className="text-[#e0e0e0]">{config.tx_power} dBm</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spacer to push buttons right on mobile */}
|
||||
<div className="flex-1 lg:hidden" />
|
||||
|
||||
{!connected && (
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
disabled={reconnecting}
|
||||
className="px-3 py-1 bg-[#4a3000] border border-[#6b4500] text-[#ffa500] rounded text-xs cursor-pointer hover:bg-[#5a3a00] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{reconnecting ? 'Reconnecting...' : 'Reconnect'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onAdvertise}
|
||||
disabled={!connected}
|
||||
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444] disabled:bg-[#333] disabled:text-[#666] disabled:cursor-not-allowed"
|
||||
>
|
||||
Advertise
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfigClick}
|
||||
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444]"
|
||||
>
|
||||
Config
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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",
|
||||
warning:
|
||||
"border-yellow-500/50 bg-yellow-500/10 text-yellow-200 [&>svg]:text-yellow-500",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center 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",
|
||||
{
|
||||
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",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<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}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<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<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<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}
|
||||
<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">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<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<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<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<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<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 }
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<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 }
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<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=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
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",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {
|
||||
hideCloseButton?: boolean
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, hideCloseButton = false, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{!hideCloseButton && (
|
||||
<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">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<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<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Toaster as Sonner, toast } from "sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
theme="dark"
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
// Muted error style - dark red-tinted background with readable text
|
||||
error: "group-[.toaster]:bg-[#2a1a1a] group-[.toaster]:text-[#e8a0a0] group-[.toaster]:border-[#4a2a2a] [&_[data-description]]:text-[#b08080]",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster, toast }
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<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<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<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<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<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 }
|
||||
Reference in New Issue
Block a user