Actually cool ui concepts

This commit is contained in:
Jack Kingsman
2026-02-12 12:44:08 -08:00
parent e796f44b38
commit af0b8ee132
16 changed files with 793 additions and 559 deletions

View File

@@ -28,6 +28,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"framer-motion": "^12.34.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.562.0",
"meshcore-hashtag-cracker": "^1.7.0",

View File

@@ -47,6 +47,7 @@ import {
loadLocalStorageFavorites,
clearLocalStorageFavorites,
} from './utils/favorites';
import { Star, Trash2, Route, Hash } from 'lucide-react';
import { cn } from '@/lib/utils';
import type {
AppSettings,
@@ -878,27 +879,32 @@ export function App() {
);
const settingsSidebarContent = (
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
<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">Settings</h2>
<div className="sidebar w-64 h-full min-h-0 bg-card/50 backdrop-blur-sm border-r border-border/50 flex flex-col">
<div className="flex justify-between items-center px-4 py-3 border-b border-border/50">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Settings
</h2>
<button
type="button"
onClick={handleCloseSettingsView}
className="h-6 w-6 rounded text-muted-foreground hover:text-foreground hover:bg-accent"
className="h-7 w-7 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-all text-sm"
title="Back to conversations"
aria-label="Back to conversations"
>
</button>
</div>
<div className="flex-1 overflow-y-auto py-1">
<div className="flex-1 overflow-y-auto py-2">
{SETTINGS_SECTION_ORDER.map((section) => (
<button
key={section}
type="button"
className={cn(
'w-full px-3 py-2.5 text-left border-l-2 border-transparent hover:bg-accent',
settingsSection === section && 'bg-accent border-l-primary'
'w-full px-4 py-2.5 text-left text-sm transition-all mx-1.5 rounded-lg',
'w-[calc(100%-12px)]',
settingsSection === section
? 'bg-primary/10 text-foreground border border-primary/20 font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50 border border-transparent'
)}
onClick={() => setSettingsSection(section)}
>
@@ -912,7 +918,7 @@ export function App() {
const activeSidebarContent = showSettings ? settingsSidebarContent : sidebarContent;
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full bg-background">
<StatusBar
health={health}
config={config}
@@ -940,8 +946,9 @@ export function App() {
{activeConversation ? (
activeConversation.type === 'map' ? (
<>
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
Node Map
<div className="flex items-center gap-2 px-5 py-3 border-b border-border/50 bg-card/30">
<Hash className="h-4 w-4 text-primary/60" />
<span className="font-semibold text-base">Node Map</span>
</div>
<div className="flex-1 overflow-hidden">
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
@@ -956,8 +963,9 @@ export function App() {
/>
) : activeConversation.type === 'raw' ? (
<>
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
Raw Packet Feed
<div className="flex items-center gap-2 px-5 py-3 border-b border-border/50 bg-card/30">
<Hash className="h-4 w-4 text-primary/60" />
<span className="font-semibold text-base">Raw Packet Feed</span>
</div>
<div className="flex-1 overflow-hidden">
<RawPacketList packets={rawPackets} />
@@ -965,9 +973,10 @@ export function App() {
</>
) : (
<>
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg gap-2">
{/* Conversation header */}
<div className="flex justify-between items-center px-5 py-3 border-b border-border/50 bg-card/30 gap-3">
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
<span className="flex-shrink-0">
<span className="flex-shrink-0 font-semibold text-base">
{activeConversation.type === 'channel' &&
!activeConversation.name.startsWith('#') &&
activeConversation.name !== 'Public'
@@ -976,7 +985,7 @@ export function App() {
{activeConversation.name}
</span>
<span
className="font-normal text-sm text-muted-foreground font-mono truncate cursor-pointer hover:text-primary"
className="font-normal text-xs text-muted-foreground/50 font-mono truncate cursor-pointer hover:text-primary transition-colors"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(activeConversation.id);
@@ -1011,9 +1020,7 @@ export function App() {
`${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`
);
}
// Add coordinate link if contact has valid location
if (isValidLocation(contact.lat, contact.lon)) {
// Calculate distance from us if we have valid location
const distFromUs =
config && isValidLocation(config.lat, config.lon)
? calculateDistance(
@@ -1026,7 +1033,7 @@ export function App() {
parts.push(
<span key="coords">
<span
className="font-mono cursor-pointer hover:text-primary hover:underline"
className="font-mono cursor-pointer hover:text-primary hover:underline transition-colors"
onClick={(e) => {
e.stopPropagation();
const url =
@@ -1044,7 +1051,7 @@ export function App() {
);
}
return parts.length > 0 ? (
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
<span className="font-normal text-xs text-muted-foreground/60 flex-shrink-0">
(
{parts.map((part, i) => (
<span key={i}>
@@ -1057,22 +1064,20 @@ export function App() {
) : null;
})()}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Direct trace button (contacts only) */}
<div className="flex items-center gap-0.5 flex-shrink-0">
{activeConversation.type === 'contact' && (
<button
className="p-1.5 rounded hover:bg-accent text-xl leading-none"
className="p-2 rounded-lg text-muted-foreground hover:text-accent hover:bg-accent/10 transition-all"
onClick={handleTrace}
title="Direct Trace"
>
&#x1F6CE;
<Route className="h-4 w-4" />
</button>
)}
{/* Favorite button */}
{(activeConversation.type === 'channel' ||
activeConversation.type === 'contact') && (
<button
className="p-1.5 rounded hover:bg-accent text-xl leading-none"
className="p-2 rounded-lg hover:bg-secondary transition-all"
onClick={() =>
handleToggleFavorite(
activeConversation.type as 'channel' | 'contact',
@@ -1089,24 +1094,26 @@ export function App() {
: 'Add to favorites'
}
>
{isFavorite(
favorites,
activeConversation.type as 'channel' | 'contact',
activeConversation.id
) ? (
<span className="text-yellow-500">&#9733;</span>
) : (
<span className="text-muted-foreground">&#9734;</span>
)}
<Star
className={cn(
'h-4 w-4 transition-colors',
isFavorite(
favorites,
activeConversation.type as 'channel' | 'contact',
activeConversation.id
)
? 'text-amber-400 fill-amber-400'
: 'text-muted-foreground'
)}
/>
</button>
)}
{/* Delete button */}
{!(
activeConversation.type === 'channel' &&
activeConversation.name === 'Public'
) && (
<button
className="p-1.5 rounded hover:bg-destructive/20 text-destructive text-xl leading-none"
className="p-2 rounded-lg text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 transition-all"
onClick={() => {
if (activeConversation.type === 'channel') {
handleDeleteChannel(activeConversation.id);
@@ -1116,7 +1123,7 @@ export function App() {
}}
title="Delete"
>
&#128465;
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
@@ -1161,17 +1168,22 @@ export function App() {
</>
)
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Select a conversation or start a new one
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="text-4xl mb-3 opacity-10">📡</div>
<p className="text-muted-foreground/40 text-sm">
Select a conversation or start a new one
</p>
</div>
</div>
)}
</div>
{showSettings && (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
<span>Radio & Settings</span>
<span className="text-sm text-muted-foreground hidden md:inline">
<div className="flex justify-between items-center px-5 py-3 border-b border-border/50 bg-card/30">
<span className="font-semibold text-base">Radio & Settings</span>
<span className="text-xs text-muted-foreground/60 hidden md:inline">
{SETTINGS_SECTION_LABELS[settingsSection]}
</span>
</div>
@@ -1199,10 +1211,10 @@ export function App() {
</div>
</div>
{/* Global Cracker Panel - always rendered to maintain state */}
{/* Global Cracker Panel */}
<div
className={cn(
'border-t border-border bg-background transition-all duration-200 overflow-hidden',
'border-t border-border/50 bg-card/30 transition-all duration-300 overflow-hidden',
showCracker ? 'h-[275px]' : 'h-0'
)}
>

View File

@@ -12,13 +12,13 @@ export function ContactAvatar({ name, publicKey, size = 28, contactType }: Conta
return (
<div
className="flex items-center justify-center rounded-full font-semibold flex-shrink-0 select-none"
className="flex items-center justify-center rounded-full font-semibold flex-shrink-0 select-none ring-1 ring-white/5"
style={{
backgroundColor: avatar.background,
color: avatar.textColor,
width: size,
height: size,
fontSize: size * 0.45,
fontSize: size * 0.42,
}}
>
{avatar.text}

View File

@@ -8,23 +8,19 @@ import {
type FormEvent,
type KeyboardEvent,
} from 'react';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { motion, AnimatePresence } from 'framer-motion';
import { Send, Lock } from 'lucide-react';
import { toast } from './ui/sonner';
import { cn } from '@/lib/utils';
// MeshCore message size limits (empirically determined from LoRa packet constraints)
// Direct delivery allows ~156 bytes; multi-hop requires buffer for path growth.
// Channels include "sender: " prefix in the encrypted payload.
// All limits are in bytes (UTF-8), not characters, since LoRa packets are byte-constrained.
const DM_HARD_LIMIT = 156; // Max bytes for direct delivery
const DM_WARNING_THRESHOLD = 140; // Conservative for multi-hop
const CHANNEL_HARD_LIMIT = 156; // Base byte limit before sender overhead
const CHANNEL_WARNING_THRESHOLD = 120; // Conservative for multi-hop
const CHANNEL_DANGER_BUFFER = 8; // Red zone starts this many bytes before hard limit
const DM_HARD_LIMIT = 156;
const DM_WARNING_THRESHOLD = 140;
const CHANNEL_HARD_LIMIT = 156;
const CHANNEL_WARNING_THRESHOLD = 120;
const CHANNEL_DANGER_BUFFER = 8;
const textEncoder = new TextEncoder();
/** Get UTF-8 byte length of a string (LoRa packets are byte-constrained, not character-constrained). */
function byteLen(s: string): number {
return textEncoder.encode(s).length;
}
@@ -33,11 +29,8 @@ interface MessageInputProps {
onSend: (text: string) => Promise<void>;
disabled: boolean;
placeholder?: string;
/** When true, input becomes password field for repeater telemetry */
isRepeaterMode?: boolean;
/** Conversation type for character limit calculation */
conversationType?: 'contact' | 'channel' | 'raw';
/** Sender name (radio name) for channel message limit calculation */
senderName?: string;
}
@@ -58,21 +51,18 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
useImperativeHandle(ref, () => ({
appendText: (appendedText: string) => {
setText((prev) => prev + appendedText);
// Focus the input after appending
inputRef.current?.focus();
},
}));
// Calculate character limits based on conversation type
const limits = useMemo(() => {
if (conversationType === 'contact') {
return {
warningAt: DM_WARNING_THRESHOLD,
dangerAt: DM_HARD_LIMIT, // Same as hard limit for DMs (no intermediate red zone)
dangerAt: DM_HARD_LIMIT,
hardLimit: DM_HARD_LIMIT,
};
} else if (conversationType === 'channel') {
// Channel hard limit = 156 bytes - senderName bytes - 2 (for ": " separator)
const nameByteLen = senderName ? byteLen(senderName) : 10;
const hardLimit = Math.max(1, CHANNEL_HARD_LIMIT - nameByteLen - 2);
return {
@@ -81,13 +71,11 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
hardLimit,
};
}
return null; // Raw/other - no limits
return null;
}, [conversationType, senderName]);
// UTF-8 byte length of the current text (LoRa packets are byte-constrained)
const textByteLen = useMemo(() => byteLen(text), [text]);
// Determine current limit state
const { limitState, warningMessage } = useMemo((): {
limitState: LimitState;
warningMessage: string | null;
@@ -95,13 +83,13 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
if (!limits) return { limitState: 'normal', warningMessage: null };
if (textByteLen >= limits.hardLimit) {
return { limitState: 'error', warningMessage: 'likely truncated by radio' };
return { limitState: 'error', warningMessage: 'likely truncated' };
}
if (textByteLen >= limits.dangerAt) {
return { limitState: 'danger', warningMessage: 'may impact multi-repeater hop delivery' };
return { limitState: 'danger', warningMessage: 'multi-hop risk' };
}
if (textByteLen >= limits.warningAt) {
return { limitState: 'warning', warningMessage: 'may impact multi-repeater hop delivery' };
return { limitState: 'warning', warningMessage: 'multi-hop risk' };
}
return { limitState: 'normal', warningMessage: null };
}, [textByteLen, limits]);
@@ -113,7 +101,6 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
e.preventDefault();
const trimmed = text.trim();
// For repeater mode, empty password means guest login
if (isRepeaterMode) {
if (sending || disabled) return;
setSending(true);
@@ -129,7 +116,6 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
} finally {
setSending(false);
}
// Refocus after React re-enables the input (now in CLI command mode)
setTimeout(() => inputRef.current?.focus(), 0);
} else {
if (!trimmed || sending || disabled) return;
@@ -146,7 +132,6 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
} finally {
setSending(false);
}
// Refocus after React re-enables the input
setTimeout(() => inputRef.current?.focus(), 0);
}
},
@@ -163,66 +148,111 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
[handleSubmit]
);
// For repeater mode, always allow submit (empty = guest login)
const canSubmit = isRepeaterMode ? true : text.trim().length > 0;
// Show character counter for messages (not repeater mode or raw)
const showCharCounter = !isRepeaterMode && limits !== null;
const showCharCounter = !isRepeaterMode && limits !== null && textByteLen > 0;
return (
<form className="px-4 py-3 border-t border-border flex flex-col gap-1" onSubmit={handleSubmit}>
<div className="flex gap-2">
<Input
ref={inputRef}
type={isRepeaterMode ? 'password' : 'text'}
autoComplete={isRepeaterMode ? 'off' : undefined}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
placeholder ||
(isRepeaterMode ? 'Enter password for admin login...' : 'Type a message...')
}
disabled={disabled || sending}
className="flex-1 min-w-0"
/>
<Button
type="submit"
disabled={disabled || sending || !canSubmit}
className="flex-shrink-0"
>
{sending
? isRepeaterMode
? 'Logging in...'
: 'Sending...'
: isRepeaterMode
? text.trim()
? 'Log in with password'
: 'Log in as guest/use repeater ACLs'
: 'Send'}
</Button>
</div>
{showCharCounter && (
<div className="flex items-center justify-end gap-2 text-xs">
<span
<div className="px-4 py-3 border-t border-border/50">
<form onSubmit={handleSubmit} className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
{/* Input container with glow */}
<div
className={cn(
'tabular-nums',
limitState === 'error' || limitState === 'danger'
? 'text-red-500 font-medium'
: limitState === 'warning'
? 'text-yellow-500'
: 'text-muted-foreground'
'flex-1 relative rounded-xl border transition-all duration-200',
disabled
? 'bg-muted/30 border-border/30'
: text.length > 0
? 'bg-secondary/40 border-primary/25 shadow-glow-amber-sm'
: 'bg-secondary/30 border-border/50 focus-within:border-primary/30 focus-within:shadow-glow-amber-sm'
)}
>
{textByteLen}/{limits!.hardLimit}b{remaining < 0 && ` (${remaining})`}
</span>
{warningMessage && (
<span className={cn(limitState === 'error' ? 'text-red-500' : 'text-yellow-500')}>
{warningMessage}
</span>
)}
{isRepeaterMode && (
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/40" />
)}
<input
ref={inputRef}
type={isRepeaterMode ? 'password' : 'text'}
autoComplete={isRepeaterMode ? 'off' : undefined}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
placeholder ||
(isRepeaterMode ? 'Enter password for admin login...' : 'Type a message...')
}
disabled={disabled || sending}
className={cn(
'w-full h-10 bg-transparent rounded-xl text-sm placeholder:text-muted-foreground/40 focus:outline-none disabled:cursor-not-allowed disabled:opacity-40',
isRepeaterMode ? 'pl-9 pr-3' : 'px-4'
)}
/>
</div>
{/* Send button */}
<motion.button
type="submit"
disabled={disabled || sending || !canSubmit}
whileTap={{ scale: 0.92 }}
className={cn(
'h-10 rounded-xl flex items-center justify-center gap-2 font-medium text-sm transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed flex-shrink-0',
isRepeaterMode
? 'px-4 bg-accent/15 text-accent border border-accent/25 hover:bg-accent/25'
: canSubmit && !disabled
? 'px-4 bg-primary text-primary-foreground shadow-glow-amber-sm hover:shadow-glow-amber active:shadow-none'
: 'px-4 bg-secondary text-muted-foreground'
)}
>
{sending ? (
<div className="w-4 h-4 border-2 border-current/30 border-t-current rounded-full animate-spin" />
) : isRepeaterMode ? (
<>
<Lock className="h-4 w-4" />
<span className="hidden sm:inline">{text.trim() ? 'Login' : 'Guest'}</span>
</>
) : (
<>
<Send className="h-4 w-4" />
<span className="hidden sm:inline">Send</span>
</>
)}
</motion.button>
</div>
)}
</form>
{/* Character counter */}
<AnimatePresence>
{showCharCounter && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="flex items-center justify-end gap-2 text-xs overflow-hidden"
>
<span
className={cn(
'tabular-nums font-mono text-[11px]',
limitState === 'error' || limitState === 'danger'
? 'text-red-400 font-medium'
: limitState === 'warning'
? 'text-amber-400'
: 'text-muted-foreground/50'
)}
>
{textByteLen}/{limits!.hardLimit}b{remaining < 0 && ` (${remaining})`}
</span>
{warningMessage && (
<span
className={cn(
'text-[11px]',
limitState === 'error' ? 'text-red-400' : 'text-amber-400/70'
)}
>
{warningMessage}
</span>
)}
</motion.div>
)}
</AnimatePresence>
</form>
</div>
);
});

View File

@@ -7,6 +7,8 @@ import {
useState,
type ReactNode,
} from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown } from 'lucide-react';
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
@@ -49,7 +51,7 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
href={match[0]}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline hover:text-primary/80"
className="text-accent underline decoration-accent/40 hover:decoration-accent hover:text-accent/80 transition-colors"
>
{match[0]}
</a>
@@ -73,7 +75,6 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
let keyIndex = 0;
while ((match = mentionPattern.exec(text)) !== null) {
// Add text before the match (with linkification)
if (match.index > lastIndex) {
parts.push(...linkifyText(text.slice(lastIndex, match.index), `pre-${keyIndex}`));
}
@@ -85,18 +86,19 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
<span
key={`mention-${keyIndex++}`}
className={cn(
'rounded px-0.5',
isOwnMention ? 'bg-primary/30 text-primary font-medium' : 'bg-muted-foreground/20'
'rounded-md px-1 py-0.5 text-[13px]',
isOwnMention
? 'bg-primary/20 text-primary font-semibold ring-1 ring-primary/30'
: 'bg-accent/10 text-accent/80'
)}
>
@[{mentionedName}]
@{mentionedName}
</span>
);
lastIndex = match.index + match[0].length;
}
// Add remaining text after last match (with linkification)
if (lastIndex < text.length) {
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`));
}
@@ -104,7 +106,7 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
return parts.length > 0 ? parts : text;
}
// Clickable hop count badge that opens the path modal
// Clickable hop count badge
interface HopCountBadgeProps {
paths: MessagePath[];
onClick: () => void;
@@ -113,16 +115,16 @@ interface HopCountBadgeProps {
function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
const hopInfo = formatHopCounts(paths);
const label = `(${hopInfo.display})`;
const className =
variant === 'header'
? 'font-normal text-muted-foreground/70 ml-1 text-[11px] cursor-pointer hover:text-primary hover:underline'
: 'text-[10px] text-muted-foreground/50 ml-1 cursor-pointer hover:text-primary hover:underline';
const label = hopInfo.display;
return (
<span
className={className}
className={cn(
'cursor-pointer transition-colors',
variant === 'header'
? 'ml-1.5 text-[10px] px-1.5 py-0.5 rounded-full bg-accent/10 text-accent/60 hover:text-accent hover:bg-accent/15'
: 'ml-1.5 text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground/50 hover:text-accent hover:bg-accent/10'
)}
onClick={(e) => {
e.stopPropagation();
onClick();
@@ -154,45 +156,35 @@ export function MessageList({
senderInfo: SenderInfo;
} | null>(null);
// Capture scroll state in the scroll handler BEFORE any state updates
const scrollStateRef = useRef({
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
wasNearTop: false,
wasNearBottom: true, // Default to true so initial messages scroll to bottom
wasNearBottom: true,
});
// Track conversation key to detect when entire message set changes
const prevConvKeyRef = useRef<string | null>(null);
// Handle scroll position AFTER render
useLayoutEffect(() => {
if (!listRef.current) return;
const list = listRef.current;
const messagesAdded = messages.length - prevMessagesLengthRef.current;
// Detect if messages are from a different conversation (handles the case where
// the key prop remount consumes isInitialLoadRef on stale data from the previous
// conversation before the cache restore effect sets the correct messages)
const convKey = messages.length > 0 ? messages[0].conversation_key : null;
const conversationChanged = convKey !== null && convKey !== prevConvKeyRef.current;
if (convKey !== null) prevConvKeyRef.current = convKey;
if ((isInitialLoadRef.current || conversationChanged) && messages.length > 0) {
// Initial load or conversation switch - 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.wasNearBottom) {
// User was near bottom - scroll to bottom for new messages (including sent)
list.scrollTop = list.scrollHeight;
}
}
@@ -200,7 +192,6 @@ export function MessageList({
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;
@@ -216,14 +207,12 @@ export function MessageList({
}
}, [messages.length]);
// Handle scroll - capture state and detect when user is near top/bottom
const handleScroll = useCallback(() => {
if (!listRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
// Always capture current scroll state (needed for scroll preservation)
scrollStateRef.current = {
scrollTop,
scrollHeight,
@@ -232,44 +221,35 @@ export function MessageList({
wasNearBottom: distanceFromBottom < 100,
};
// Show scroll-to-bottom button when not near the bottom (more than 100px away)
setShowScrollToBottom(distanceFromBottom > 100);
if (!onLoadOlder || loadingOlder || !hasOlderMessages) return;
// Trigger load when within 100px of top
if (scrollTop < 100) {
onLoadOlder();
}
}, [onLoadOlder, loadingOlder, hasOlderMessages]);
// Scroll to bottom handler
const scrollToBottom = useCallback(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, []);
// Sort messages by received_at ascending (oldest first)
// Note: Deduplication is handled by useConversationMessages.addMessageIfNew()
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
const sortedMessages = useMemo(
() => [...messages].sort((a, b) => a.received_at - b.received_at),
[messages]
);
// Look up contact by public key
const getContact = (conversationKey: string | null): Contact | null => {
if (!conversationKey) return null;
return contacts.find((c) => 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;
};
// Build sender info for path modal
const getSenderInfo = (
msg: Message,
contact: Contact | null,
@@ -283,7 +263,6 @@ export function MessageList({
lon: contact.lon,
};
}
// For channel messages, try to find contact by parsed sender name
if (parsedSender) {
const senderContact = getContactByName(parsedSender);
if (senderContact) {
@@ -295,7 +274,6 @@ export function MessageList({
};
}
}
// Fallback: unknown sender
return {
name: parsedSender || 'Unknown',
publicKeyOrPrefix: msg.conversation_key || '',
@@ -306,21 +284,26 @@ export function MessageList({
if (loading) {
return (
<div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">
Loading messages...
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
<span className="text-sm text-muted-foreground">Loading messages...</span>
</div>
</div>
);
}
if (messages.length === 0) {
return (
<div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">
No messages yet
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="text-3xl mb-2 opacity-20">💬</div>
<span className="text-sm text-muted-foreground/60">No messages yet</span>
</div>
</div>
);
}
// 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;
@@ -330,26 +313,24 @@ export function MessageList({
return (
<div className="flex-1 overflow-hidden relative">
<div
className="h-full overflow-y-auto p-4 flex flex-col gap-0.5"
className="h-full overflow-y-auto px-4 py-3 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 className="flex justify-center py-3">
<div className="w-5 h-5 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
)}
{!loadingOlder && hasOlderMessages && (
<div className="text-center py-2 text-muted-foreground text-xs">
<div className="text-center py-2 text-muted-foreground/40 text-xs">
Scroll up for older messages
</div>
)}
{sortedMessages.map((msg, index) => {
// For DMs, look up contact; for channel messages, use parsed sender
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
// Skip sender parsing for repeater messages (CLI responses often have colons)
const { sender, content } = isRepeater
? { sender: null, content: msg.text }
: parseSenderFromText(msg.text);
@@ -359,7 +340,6 @@ export function MessageList({
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
@@ -368,16 +348,13 @@ export function MessageList({
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}`;
@@ -390,36 +367,40 @@ export function MessageList({
className={cn(
'flex items-start max-w-[85%]',
msg.outgoing && 'flex-row-reverse self-end',
showAvatar && !isFirstMessage && 'mt-3'
showAvatar && !isFirstMessage && 'mt-4'
)}
>
{!msg.outgoing && (
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
<div className="w-9 flex-shrink-0 flex items-start pt-0.5">
{showAvatar && avatarKey && (
<ContactAvatar name={avatarName} publicKey={avatarKey} size={32} />
<ContactAvatar name={avatarName} publicKey={avatarKey} size={30} />
)}
</div>
)}
<div
className={cn(
'py-1.5 px-3 rounded-lg min-w-0',
msg.outgoing ? 'bg-[#1e3a29]' : 'bg-muted'
'py-2 px-3 rounded-2xl min-w-0 relative',
msg.outgoing
? 'bg-gradient-to-br from-primary/20 to-primary/10 border border-primary/15'
: 'bg-secondary/60 border border-border/30'
)}
>
{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]">
<div className="flex items-center gap-1.5 mb-1">
<span className="text-[13px] font-semibold">
{canClickSender ? (
<span
className="cursor-pointer hover:text-primary transition-colors"
onClick={() => onSenderClick(displaySender)}
title={`Mention ${displaySender}`}
>
{displaySender}
</span>
) : (
<span className="text-muted-foreground">{displaySender}</span>
)}
</span>
<span className="text-[10px] text-muted-foreground/40">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
@@ -436,7 +417,7 @@ export function MessageList({
)}
</div>
)}
<div className="break-words whitespace-pre-wrap">
<div className="break-words whitespace-pre-wrap text-[14px] leading-relaxed">
{content.split('\n').map((line, i, arr) => (
<span key={i}>
{renderTextWithMentions(line, radioName)}
@@ -445,7 +426,7 @@ export function MessageList({
))}
{!showAvatar && (
<>
<span className="text-[10px] text-muted-foreground/50 ml-2">
<span className="text-[10px] text-muted-foreground/30 ml-2">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
@@ -466,7 +447,7 @@ export function MessageList({
(msg.acked > 0 ? (
msg.paths && msg.paths.length > 0 ? (
<span
className="cursor-pointer hover:text-primary"
className="ml-1.5 text-[11px] text-emerald-400/70 cursor-pointer hover:text-emerald-400 transition-colors"
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
@@ -480,12 +461,22 @@ export function MessageList({
});
}}
title="View echo paths"
>{`${msg.acked > 1 ? msg.acked : ''}`}</span>
>
{`${msg.acked > 1 ? msg.acked : ''}`}
</span>
) : (
`${msg.acked > 1 ? msg.acked : ''}`
<span className="ml-1.5 text-[11px] text-emerald-400/70">
{`${msg.acked > 1 ? msg.acked : ''}`}
</span>
)
) : (
<span title="No repeats heard yet"> ?</span>
<span
className="ml-1.5 text-[11px] text-muted-foreground/30"
title="No repeats heard yet"
>
{' '}
?
</span>
))}
</div>
</div>
@@ -495,28 +486,20 @@ export function MessageList({
</div>
{/* Scroll to bottom button */}
{showScrollToBottom && (
<button
onClick={scrollToBottom}
className="absolute bottom-4 right-4 w-10 h-10 rounded-full bg-muted hover:bg-accent border border-border flex items-center justify-center shadow-lg transition-opacity"
title="Scroll to bottom"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
<AnimatePresence>
{showScrollToBottom && (
<motion.button
initial={{ opacity: 0, y: 10, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.9 }}
onClick={scrollToBottom}
className="absolute bottom-4 right-4 w-10 h-10 rounded-full bg-card border border-border/50 flex items-center justify-center shadow-lg hover:bg-secondary transition-all hover:border-primary/30"
title="Scroll to bottom"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
)}
<ChevronDown className="h-5 w-5 text-muted-foreground" />
</motion.button>
)}
</AnimatePresence>
{/* Path modal */}
{selectedPath && (

View File

@@ -1,4 +1,17 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Radio,
Map,
Sparkles,
KeyRound,
CheckCheck,
Search,
X,
Star,
Hash,
User,
} from 'lucide-react';
import {
CONTACT_TYPE_REPEATER,
type Contact,
@@ -10,8 +23,6 @@ import { getStateKey, type ConversationTimes } from '../utils/conversationState'
import { getContactDisplayName } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar';
import { isFavorite } from '../utils/favorites';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { cn } from '@/lib/utils';
type SortOrder = 'alpha' | 'recent';
@@ -24,16 +35,13 @@ interface SidebarProps {
onNewMessage: () => void;
lastMessageTimes: ConversationTimes;
unreadCounts: Record<string, number>;
/** Tracks which conversations have unread messages that mention the user */
mentions: Record<string, boolean>;
showCracker: boolean;
crackerRunning: boolean;
onToggleCracker: () => void;
onMarkAllRead: () => void;
favorites: Favorite[];
/** Sort order from server settings */
sortOrder?: SortOrder;
/** Callback when sort order changes */
onSortOrderChange?: (order: SortOrder) => void;
}
@@ -70,13 +78,11 @@ export function Sidebar({
const isActive = (type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer', 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;
};
// Check if a conversation has a mention
const hasMention = (type: 'channel' | 'contact', id: string): boolean => {
const key = getStateKey(type, id);
return mentions[key] || false;
@@ -87,7 +93,7 @@ export function Sidebar({
return lastMessageTimes[key] || 0;
};
// Deduplicate channels by name, keeping the first (lowest index)
// Deduplicate channels by name
const uniqueChannels = channels.reduce<Channel[]>((acc, channel) => {
if (!acc.some((c) => c.name === channel.name)) {
acc.push(channel);
@@ -95,12 +101,10 @@ export function Sidebar({
return acc;
}, []);
// Deduplicate contacts by public key, preferring ones with names
// Also filter out any contacts with empty public keys
// Deduplicate contacts by public key
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 || '');
@@ -112,54 +116,40 @@ export function Sidebar({
return acc;
}, []);
// Sort channels based on sort order, with Public always first
// Sort channels
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)
// Sort contacts
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
// Filter by search
const query = searchQuery.toLowerCase().trim();
const filteredChannels = query
? sortedChannels.filter(
@@ -172,7 +162,7 @@ export function Sidebar({
)
: sortedContacts;
// Separate favorites from regular items
// Separate favorites
const favoriteChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
const favoriteContacts = filteredContacts.filter((c) =>
isFavorite(favorites, 'contact', c.public_key)
@@ -184,7 +174,6 @@ export function Sidebar({
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
// Combine and sort favorites by most recent message (always recent order)
type FavoriteItem = { type: 'channel'; channel: Channel } | { type: 'contact'; contact: Contact };
const favoriteItems: FavoriteItem[] = [
@@ -199,168 +188,216 @@ export function Sidebar({
b.type === 'channel'
? getLastMessageTime('channel', b.channel.key)
: getLastMessageTime('contact', b.contact.public_key);
// Sort by most recent first
if (timeA && timeB) return timeB - timeA;
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
// Fall back to name comparison
const nameA = a.type === 'channel' ? a.channel.name : a.contact.name || a.contact.public_key;
const nameB = b.type === 'channel' ? b.channel.name : b.contact.name || b.contact.public_key;
return nameA.localeCompare(nameB);
});
// Unread badge component
const UnreadBadge = ({ count, isMentionBadge }: { count: number; isMentionBadge: boolean }) => (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className={cn(
'text-[10px] font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center leading-none',
isMentionBadge
? 'bg-destructive text-destructive-foreground shadow-[0_0_8px_hsl(0_72%_51%/0.4)]'
: 'bg-primary text-primary-foreground shadow-glow-amber-sm'
)}
>
{count}
</motion.span>
);
// Conversation item component
const ConversationItem = ({
active,
unreadCount,
isMentionItem,
onClick,
icon,
children,
}: {
active: boolean;
unreadCount?: number;
isMentionItem?: boolean;
onClick: () => void;
icon?: React.ReactNode;
children: React.ReactNode;
}) => (
<div
className={cn(
'group px-3 py-2 cursor-pointer flex items-center gap-2.5 rounded-lg mx-1.5 my-0.5 transition-all duration-150',
active
? 'bg-primary/10 border border-primary/20 shadow-glow-amber-sm'
: 'border border-transparent hover:bg-secondary/60',
(unreadCount ?? 0) > 0 && !active && 'bg-secondary/30'
)}
onClick={onClick}
>
{icon}
<span
className={cn(
'flex-1 truncate text-sm transition-colors',
active ? 'text-foreground font-medium' : 'text-foreground/70 group-hover:text-foreground',
(unreadCount ?? 0) > 0 && 'text-foreground font-semibold'
)}
>
{children}
</span>
{(unreadCount ?? 0) > 0 && (
<UnreadBadge count={unreadCount!} isMentionBadge={isMentionItem ?? false} />
)}
</div>
);
return (
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
<div className="sidebar w-64 h-full min-h-0 bg-card/50 backdrop-blur-sm border-r border-border/50 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"
<div className="flex justify-between items-center px-4 py-3 border-b border-border/50">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Conversations
</span>
<button
onClick={onNewMessage}
title="New Message"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
title="New Conversation"
className="h-7 w-7 flex items-center justify-center rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-all text-lg font-light"
>
+
</Button>
</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 className="px-3 py-2 border-b border-border/50">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50" />
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-8 pl-8 pr-8 text-sm bg-secondary/40 border border-border/50 rounded-lg placeholder:text-muted-foreground/40 focus:outline-none focus:border-primary/30 focus:bg-secondary/60 transition-all"
/>
<AnimatePresence>
{searchQuery && (
<motion.button
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery('')}
title="Clear search"
>
<X className="h-3.5 w-3.5" />
</motion.button>
)}
</AnimatePresence>
</div>
</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>
)}
{/* Quick nav - special views */}
{!query && (
<div className="flex items-center gap-1 px-3 py-2 border-b border-border/50">
{[
{
type: 'raw' as const,
id: 'raw',
name: 'Raw Packet Feed',
icon: Radio,
title: 'Packet Feed',
},
{ type: 'map' as const, id: 'map', name: 'Node Map', icon: Map, title: 'Node Map' },
{
type: 'visualizer' as const,
id: 'visualizer',
name: 'Mesh Visualizer',
icon: Sparkles,
title: 'Visualizer',
},
].map((view) => (
<button
key={view.id}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg text-xs transition-all',
isActive(view.type, view.id)
? 'bg-primary/15 text-primary border border-primary/20'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
)}
onClick={() =>
handleSelectConversation({
type: view.type,
id: view.id,
name: view.name,
})
}
title={view.title}
>
<view.icon className="h-3.5 w-3.5" />
<span className="hidden xl:inline">{view.title.split(' ')[0]}</span>
</button>
))}
</div>
)}
{/* Node Map */}
{!query && (
<div
{/* Cracker + Mark all read */}
{!query && (
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/50">
<button
className={cn(
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
isActive('map', 'map') && 'bg-accent border-l-primary'
)}
onClick={() =>
handleSelectConversation({
type: 'map',
id: 'map',
name: 'Node Map',
})
}
>
<span className="text-muted-foreground text-xs">🗺</span>
<span className="flex-1 truncate">Node Map</span>
</div>
)}
{/* Mesh Visualizer */}
{!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('visualizer', 'visualizer') && 'bg-accent border-l-primary'
)}
onClick={() =>
handleSelectConversation({
type: 'visualizer',
id: 'visualizer',
name: 'Mesh Visualizer',
})
}
>
<span className="text-muted-foreground text-xs"></span>
<span className="flex-1 truncate">Mesh Visualizer</span>
</div>
)}
{/* Cracker Toggle */}
{!query && (
<div
className={cn(
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
showCracker && 'bg-accent border-l-primary'
'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg text-xs transition-all',
showCracker
? 'bg-primary/15 text-primary border border-primary/20'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
)}
onClick={onToggleCracker}
title="Room Finder"
>
<span className="text-muted-foreground text-xs">🔓</span>
<span className="flex-1 truncate">
{showCracker ? 'Hide' : 'Show'} Room Finder
<span
className={cn(
'ml-1 text-xs',
crackerRunning ? 'text-green-500' : 'text-muted-foreground'
)}
>
({crackerRunning ? 'running' : 'stopped'})
</span>
<KeyRound className="h-3.5 w-3.5" />
<span className="truncate">
Finder
{crackerRunning && (
<span className="ml-1 inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
)}
</span>
</div>
)}
{/* Mark All Read */}
{!query && Object.keys(unreadCounts).length > 0 && (
<div
className="px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent"
onClick={onMarkAllRead}
>
<span className="text-muted-foreground text-xs"></span>
<span className="flex-1 truncate text-muted-foreground">Mark all as read</span>
</div>
)}
</button>
{Object.keys(unreadCounts).length > 0 && (
<button
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg text-xs text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-all"
onClick={onMarkAllRead}
title="Mark all as read"
>
<CheckCheck className="h-3.5 w-3.5" />
<span className="truncate">Read all</span>
</button>
)}
</div>
)}
{/* Conversation list */}
<div className="flex-1 overflow-y-auto py-1">
{/* Favorites */}
{favoriteItems.length > 0 && (
<>
<div className="flex justify-between items-center px-3 py-2 pt-3">
<span className="text-[11px] uppercase text-muted-foreground">Favorites</span>
<div className="flex items-center gap-1.5 px-4 py-2 pt-2.5">
<Star className="h-3 w-3 text-amber-400/60" />
<span className="text-[11px] uppercase tracking-wider font-medium text-amber-400/60">
Favorites
</span>
</div>
{favoriteItems.map((item) => {
if (item.type === 'channel') {
const channel = item.channel;
const unreadCount = getUnreadCount('channel', channel.key);
const isMention = hasMention('channel', channel.key);
const count = getUnreadCount('channel', channel.key);
const mention = hasMention('channel', channel.key);
return (
<div
<ConversationItem
key={`fav-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'
)}
active={isActive('channel', channel.key)}
unreadCount={count}
isMentionItem={mention}
onClick={() =>
handleSelectConversation({
type: 'channel',
@@ -368,34 +405,21 @@ export function Sidebar({
name: channel.name,
})
}
icon={<Hash className="h-4 w-4 text-muted-foreground/50 flex-shrink-0" />}
>
<span className="name flex-1 truncate">{channel.name}</span>
{unreadCount > 0 && (
<span
className={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
isMention
? 'bg-destructive text-destructive-foreground'
: 'bg-primary text-primary-foreground'
)}
>
{unreadCount}
</span>
)}
</div>
{channel.name}
</ConversationItem>
);
} else {
const contact = item.contact;
const unreadCount = getUnreadCount('contact', contact.public_key);
const isMention = hasMention('contact', contact.public_key);
const count = getUnreadCount('contact', contact.public_key);
const mention = hasMention('contact', contact.public_key);
return (
<div
<ConversationItem
key={`fav-contact-${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'
)}
active={isActive('contact', contact.public_key)}
unreadCount={count}
isMentionItem={mention}
onClick={() =>
handleSelectConversation({
type: 'contact',
@@ -403,29 +427,17 @@ export function Sidebar({
name: getContactDisplayName(contact.name, contact.public_key),
})
}
icon={
<ContactAvatar
name={contact.name}
publicKey={contact.public_key}
size={22}
contactType={contact.type}
/>
}
>
<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={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
isMention
? 'bg-destructive text-destructive-foreground'
: 'bg-primary text-primary-foreground'
)}
>
{unreadCount}
</span>
)}
</div>
{getContactDisplayName(contact.name, contact.public_key)}
</ConversationItem>
);
}
})}
@@ -435,27 +447,30 @@ export function Sidebar({
{/* Channels */}
{nonFavoriteChannels.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>
<div className="flex justify-between items-center px-4 py-2 pt-3">
<div className="flex items-center gap-1.5">
<Hash className="h-3 w-3 text-muted-foreground/40" />
<span className="text-[11px] uppercase tracking-wider font-medium text-muted-foreground/60">
Channels
</span>
</div>
<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"
className="text-[10px] px-1.5 py-0.5 rounded bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary transition-all"
onClick={handleSortToggle}
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
>
{sortOrder === 'alpha' ? 'A-Z' : ''}
{sortOrder === 'alpha' ? 'A-Z' : 'Recent'}
</button>
</div>
{nonFavoriteChannels.map((channel) => {
const unreadCount = getUnreadCount('channel', channel.key);
const isMention = hasMention('channel', channel.key);
const count = getUnreadCount('channel', channel.key);
const mention = hasMention('channel', channel.key);
return (
<div
<ConversationItem
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'
)}
active={isActive('channel', channel.key)}
unreadCount={count}
isMentionItem={mention}
onClick={() =>
handleSelectConversation({
type: 'channel',
@@ -463,21 +478,10 @@ export function Sidebar({
name: channel.name,
})
}
icon={<Hash className="h-4 w-4 text-muted-foreground/50 flex-shrink-0" />}
>
<span className="name flex-1 truncate">{channel.name}</span>
{unreadCount > 0 && (
<span
className={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
isMention
? 'bg-destructive text-destructive-foreground'
: 'bg-primary text-primary-foreground'
)}
>
{unreadCount}
</span>
)}
</div>
{channel.name}
</ConversationItem>
);
})}
</>
@@ -486,29 +490,32 @@ export function Sidebar({
{/* Contacts */}
{nonFavoriteContacts.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>
<div className="flex justify-between items-center px-4 py-2 pt-3">
<div className="flex items-center gap-1.5">
<User className="h-3 w-3 text-muted-foreground/40" />
<span className="text-[11px] uppercase tracking-wider font-medium text-muted-foreground/60">
Contacts
</span>
</div>
{nonFavoriteChannels.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"
className="text-[10px] px-1.5 py-0.5 rounded bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary transition-all"
onClick={handleSortToggle}
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
>
{sortOrder === 'alpha' ? 'A-Z' : ''}
{sortOrder === 'alpha' ? 'A-Z' : 'Recent'}
</button>
)}
</div>
{nonFavoriteContacts.map((contact) => {
const unreadCount = getUnreadCount('contact', contact.public_key);
const isMention = hasMention('contact', contact.public_key);
const count = getUnreadCount('contact', contact.public_key);
const mention = hasMention('contact', contact.public_key);
return (
<div
<ConversationItem
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'
)}
active={isActive('contact', contact.public_key)}
unreadCount={count}
isMentionItem={mention}
onClick={() =>
handleSelectConversation({
type: 'contact',
@@ -516,29 +523,17 @@ export function Sidebar({
name: getContactDisplayName(contact.name, contact.public_key),
})
}
icon={
<ContactAvatar
name={contact.name}
publicKey={contact.public_key}
size={22}
contactType={contact.type}
/>
}
>
<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={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
isMention
? 'bg-destructive text-destructive-foreground'
: 'bg-primary text-primary-foreground'
)}
>
{unreadCount}
</span>
)}
</div>
{getContactDisplayName(contact.name, contact.public_key)}
</ConversationItem>
);
})}
</>
@@ -548,7 +543,7 @@ export function Sidebar({
{nonFavoriteContacts.length === 0 &&
nonFavoriteChannels.length === 0 &&
favoriteItems.length === 0 && (
<div className="p-5 text-center text-muted-foreground">
<div className="p-6 text-center text-muted-foreground/50 text-sm">
{query ? 'No matches found' : 'No conversations yet'}
</div>
)}

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Menu } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Menu, Radio, Settings, MessageSquare, Wifi, WifiOff } from 'lucide-react';
import type { HealthStatus, RadioConfig } from '../types';
import { api } from '../api';
import { toast } from './ui/sonner';
@@ -39,61 +40,111 @@ export function StatusBar({
};
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 */}
<div className="relative flex items-center gap-3 px-4 py-2.5 border-b border-border/50 bg-card/80 backdrop-blur-sm">
{/* Subtle gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-r from-primary/[0.03] via-transparent to-accent/[0.03] pointer-events-none" />
{/* Mobile menu button */}
{onMenuClick && (
<button
onClick={onMenuClick}
className="md:hidden p-1 bg-transparent border-none text-[#e0e0e0] cursor-pointer"
className="md:hidden p-1.5 rounded-lg bg-transparent text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
aria-label="Open menu"
>
<Menu className="h-5 w-5" />
</button>
)}
<h1 className="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>
{/* Logo / Title */}
<div className="flex items-center gap-2 mr-auto">
<div className="relative">
<Radio className="h-5 w-5 text-primary" />
{connected && (
<motion.div
className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-emerald-400"
animate={{ scale: [1, 1.3, 1] }}
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
/>
)}
</div>
<h1 className="text-base font-bold tracking-tight text-gradient-amber">RemoteTerm</h1>
</div>
{/* Connection status */}
<div className="flex items-center gap-2">
<AnimatePresence mode="wait">
{connected ? (
<motion.div
key="connected"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20"
>
<Wifi className="h-3.5 w-3.5 text-emerald-400" />
<span className="hidden lg:inline text-xs font-medium text-emerald-400">
Connected
</span>
</motion.div>
) : (
<motion.div
key="disconnected"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-destructive/10 border border-destructive/20"
>
<WifiOff className="h-3.5 w-3.5 text-destructive/70" />
<span className="hidden lg:inline text-xs font-medium text-destructive/70">
Offline
</span>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Radio info */}
{config && (
<div className="hidden lg:flex items-center gap-2 text-[#888]">
<span className="text-[#e0e0e0]">{config.name || 'Unnamed'}</span>
<div className="hidden lg:flex items-center gap-2">
<span className="text-xs font-medium text-foreground/80">{config.name || 'Unnamed'}</span>
<span
className="font-mono text-[#888] cursor-pointer hover:text-[#4a9eff]"
className="font-mono text-[11px] text-muted-foreground cursor-pointer hover:text-primary transition-colors"
onClick={() => {
navigator.clipboard.writeText(config.public_key);
toast.success('Public key copied!');
}}
title="Click to copy public key"
>
{config.public_key.toLowerCase()}
{config.public_key.toLowerCase().slice(0, 16)}...
</span>
</div>
)}
{!connected && (
{/* Action buttons */}
<div className="flex items-center gap-1.5">
{!connected && (
<motion.button
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
onClick={handleReconnect}
disabled={reconnecting}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-400 hover:bg-amber-500/20 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{reconnecting ? 'Reconnecting...' : 'Reconnect'}
</motion.button>
)}
<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"
onClick={onSettingsClick}
className={`p-2 rounded-lg transition-all ${
settingsMode
? 'bg-primary/15 text-primary border border-primary/30'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
title={settingsMode ? 'Back to Chat' : 'Settings'}
>
{reconnecting ? 'Reconnecting...' : 'Reconnect'}
{settingsMode ? <MessageSquare className="h-4 w-4" /> : <Settings className="h-4 w-4" />}
</button>
)}
<button
onClick={onSettingsClick}
className="px-3 py-1 bg-[#333] border border-[#444] text-[#e0e0e0] rounded text-xs cursor-pointer hover:bg-[#444]"
>
<span role="img" aria-label="Settings">
&#128295;
</span>{' '}
{settingsMode ? 'Back to Chat' : 'Radio & Config'}
</button>
</div>
</div>
);
}

View File

@@ -5,21 +5,25 @@ 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',
'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-40 active:scale-[0.97]',
{
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',
default:
'bg-primary text-primary-foreground hover:bg-primary/90 shadow-glow-amber-sm hover:shadow-glow-amber rounded-lg font-semibold',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded-lg',
outline:
'border border-border bg-transparent hover:bg-secondary hover:border-primary/30 rounded-lg',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-lg',
ghost: 'hover:bg-secondary hover:text-foreground rounded-lg',
link: 'text-primary underline-offset-4 hover:underline',
glow: 'bg-gradient-to-r from-amber-500 to-amber-600 text-primary-foreground hover:from-amber-400 hover:to-amber-500 shadow-glow-amber hover:shadow-glow-amber rounded-xl font-semibold',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
default: 'h-10 px-5 py-2',
sm: 'h-8 rounded-lg px-3 text-xs',
lg: 'h-12 rounded-xl px-8 text-base',
icon: 'h-10 w-10',
},
},

View File

@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<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',
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<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',
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border/50 bg-card/95 backdrop-blur-xl p-6 shadow-2xl 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-xl',
className
)}
{...props}

View File

@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
<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',
'flex h-10 w-full rounded-lg border border-border bg-secondary/50 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/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-40 md:text-sm',
className
)}
ref={ref}

View File

@@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
>(({ 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',
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
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',
'fixed z-50 gap-4 bg-card/95 backdrop-blur-xl p-6 shadow-2xl 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: {

View File

@@ -10,13 +10,14 @@ const Toaster = ({ ...props }: ToasterProps) => {
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
'group toast group-[.toaster]:bg-card/95 group-[.toaster]:backdrop-blur-xl group-[.toaster]:text-foreground group-[.toaster]:border-border/50 group-[.toaster]:shadow-2xl group-[.toaster]:rounded-xl',
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]',
'group-[.toaster]:bg-[#1a0e10]/95 group-[.toaster]:text-red-300 group-[.toaster]:border-red-500/20 [&_[data-description]]:text-red-400/70',
success:
'group-[.toaster]:bg-[#0e1a12]/95 group-[.toaster]:text-emerald-300 group-[.toaster]:border-emerald-500/20 [&_[data-description]]:text-emerald-400/70',
},
}}
{...props}

View File

@@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
'inline-flex h-10 items-center justify-center rounded-lg bg-secondary/50 p-1 text-muted-foreground',
className
)}
{...props}
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
<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',
'inline-flex items-center justify-center whitespace-nowrap rounded-md 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-primary/15 data-[state=active]:text-primary data-[state=active]:shadow-sm data-[state=active]:border data-[state=active]:border-primary/20',
className
)}
{...props}

View File

@@ -4,26 +4,32 @@
@layer base {
:root {
--background: 0 0% 10%;
--foreground: 0 0% 88%;
--card: 0 0% 14%;
--card-foreground: 0 0% 88%;
--popover: 0 0% 14%;
--popover-foreground: 0 0% 88%;
--primary: 122 39% 49%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 20%;
--secondary-foreground: 0 0% 88%;
--muted: 0 0% 20%;
--muted-foreground: 0 0% 53%;
--accent: 0 0% 20%;
--accent-foreground: 0 0% 88%;
--destructive: 0 62% 50%;
/* Midnight Amber - a retro-futuristic radio terminal palette */
--background: 222 47% 6%;
--foreground: 40 20% 88%;
--card: 222 40% 10%;
--card-foreground: 40 20% 88%;
--popover: 222 44% 9%;
--popover-foreground: 40 20% 88%;
--primary: 38 92% 50%;
--primary-foreground: 222 47% 6%;
--secondary: 222 30% 16%;
--secondary-foreground: 40 20% 85%;
--muted: 222 30% 14%;
--muted-foreground: 215 15% 50%;
--accent: 187 80% 42%;
--accent-foreground: 222 47% 6%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 20%;
--input: 0 0% 20%;
--ring: 122 39% 49%;
--radius: 0.5rem;
--border: 222 30% 18%;
--input: 222 30% 16%;
--ring: 38 92% 50%;
--radius: 0.75rem;
/* Extended palette for gradients and glow effects */
--amber-glow: 38 92% 50%;
--cyan-accent: 187 80% 42%;
--surface-glass: 222 40% 12%;
}
}
@@ -33,11 +39,134 @@
}
body {
@apply bg-background text-foreground;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* Glow and animation utilities */
@layer utilities {
.glow-amber {
box-shadow:
0 0 15px -3px hsl(38 92% 50% / 0.3),
0 0 6px -4px hsl(38 92% 50% / 0.2);
}
.glow-amber-sm {
box-shadow: 0 0 8px -2px hsl(38 92% 50% / 0.25);
}
.glow-cyan {
box-shadow:
0 0 15px -3px hsl(187 80% 42% / 0.3),
0 0 6px -4px hsl(187 80% 42% / 0.2);
}
.glow-connected {
box-shadow: 0 0 8px 2px hsl(142 70% 45% / 0.4);
}
.glass {
background: hsl(222 40% 12% / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.glass-strong {
background: hsl(222 40% 10% / 0.9);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.text-gradient-amber {
background: linear-gradient(135deg, hsl(38 92% 50%), hsl(28 92% 55%));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.border-glow {
border-color: hsl(38 92% 50% / 0.3);
}
.border-glow-cyan {
border-color: hsl(187 80% 42% / 0.3);
}
}
/* Keyframe animations */
@keyframes pulse-glow {
0%,
100% {
box-shadow: 0 0 4px 1px hsl(142 70% 45% / 0.4);
}
50% {
box-shadow: 0 0 10px 3px hsl(142 70% 45% / 0.6);
}
}
@keyframes pulse-amber {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
@keyframes signal-wave {
0% {
transform: scale(1);
opacity: 0.8;
}
100% {
transform: scale(2.5);
opacity: 0;
}
}
@keyframes slide-up-fade {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
.animate-pulse-amber {
animation: pulse-amber 2s ease-in-out infinite;
}
.animate-slide-up-fade {
animation: slide-up-fade 0.2s ease-out;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(222 30% 22%);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(222 30% 30%);
}
/* Constrain CodeMirror editor width */
.cm-editor {
max-width: 100% !important;

View File

@@ -32,7 +32,13 @@ body,
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
/* Prevent overscroll/bounce on mobile */
overscroll-behavior: none;
padding: var(--safe-area-top) var(--safe-area-right) var(--safe-area-bottom-capped)

View File

@@ -41,11 +41,33 @@ export default {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
amber: {
400: "#fbbf24",
500: "#f59e0b",
600: "#d97706",
},
cyan: {
400: "#22d3ee",
500: "#06b6d4",
600: "#0891b2",
},
navy: {
800: "#141a2e",
900: "#0f1420",
950: "#0a0e1a",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
xl: "calc(var(--radius) + 4px)",
},
boxShadow: {
'glow-amber': '0 0 15px -3px hsl(38 92% 50% / 0.3)',
'glow-amber-sm': '0 0 8px -2px hsl(38 92% 50% / 0.25)',
'glow-cyan': '0 0 15px -3px hsl(187 80% 42% / 0.3)',
'inner-glow': 'inset 0 1px 0 0 hsl(40 20% 88% / 0.05)',
},
},
},