mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
1 Commits
settings-s
...
actually-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af0b8ee132 |
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
🛎
|
||||
<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">★</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">☆</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"
|
||||
>
|
||||
🗑
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
🔧
|
||||
</span>{' '}
|
||||
{settingsMode ? 'Back to Chat' : 'Radio & Config'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user