mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-10 07:15:09 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af0b8ee132 |
@@ -28,6 +28,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"d3-force": "^3.0.0",
|
"d3-force": "^3.0.0",
|
||||||
|
"framer-motion": "^12.34.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"meshcore-hashtag-cracker": "^1.7.0",
|
"meshcore-hashtag-cracker": "^1.7.0",
|
||||||
|
|||||||
+56
-44
@@ -47,6 +47,7 @@ import {
|
|||||||
loadLocalStorageFavorites,
|
loadLocalStorageFavorites,
|
||||||
clearLocalStorageFavorites,
|
clearLocalStorageFavorites,
|
||||||
} from './utils/favorites';
|
} from './utils/favorites';
|
||||||
|
import { Star, Trash2, Route, Hash } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
@@ -878,27 +879,32 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const settingsSidebarContent = (
|
const settingsSidebarContent = (
|
||||||
<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">
|
||||||
<div className="flex justify-between items-center px-3 py-3 border-b border-border">
|
<div className="flex justify-between items-center px-4 py-3 border-b border-border/50">
|
||||||
<h2 className="text-xs uppercase text-muted-foreground font-medium">Settings</h2>
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Settings
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCloseSettingsView}
|
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"
|
title="Back to conversations"
|
||||||
aria-label="Back to conversations"
|
aria-label="Back to conversations"
|
||||||
>
|
>
|
||||||
←
|
←
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto py-1">
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
{SETTINGS_SECTION_ORDER.map((section) => (
|
{SETTINGS_SECTION_ORDER.map((section) => (
|
||||||
<button
|
<button
|
||||||
key={section}
|
key={section}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full px-3 py-2.5 text-left border-l-2 border-transparent hover:bg-accent',
|
'w-full px-4 py-2.5 text-left text-sm transition-all mx-1.5 rounded-lg',
|
||||||
settingsSection === section && 'bg-accent border-l-primary'
|
'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)}
|
onClick={() => setSettingsSection(section)}
|
||||||
>
|
>
|
||||||
@@ -912,7 +918,7 @@ export function App() {
|
|||||||
const activeSidebarContent = showSettings ? settingsSidebarContent : sidebarContent;
|
const activeSidebarContent = showSettings ? settingsSidebarContent : sidebarContent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full bg-background">
|
||||||
<StatusBar
|
<StatusBar
|
||||||
health={health}
|
health={health}
|
||||||
config={config}
|
config={config}
|
||||||
@@ -940,8 +946,9 @@ export function App() {
|
|||||||
{activeConversation ? (
|
{activeConversation ? (
|
||||||
activeConversation.type === 'map' ? (
|
activeConversation.type === 'map' ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
|
<div className="flex items-center gap-2 px-5 py-3 border-b border-border/50 bg-card/30">
|
||||||
Node Map
|
<Hash className="h-4 w-4 text-primary/60" />
|
||||||
|
<span className="font-semibold text-base">Node Map</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
|
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
|
||||||
@@ -956,8 +963,9 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
) : activeConversation.type === 'raw' ? (
|
) : activeConversation.type === 'raw' ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
|
<div className="flex items-center gap-2 px-5 py-3 border-b border-border/50 bg-card/30">
|
||||||
Raw Packet Feed
|
<Hash className="h-4 w-4 text-primary/60" />
|
||||||
|
<span className="font-semibold text-base">Raw Packet Feed</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<RawPacketList packets={rawPackets} />
|
<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 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.type === 'channel' &&
|
||||||
!activeConversation.name.startsWith('#') &&
|
!activeConversation.name.startsWith('#') &&
|
||||||
activeConversation.name !== 'Public'
|
activeConversation.name !== 'Public'
|
||||||
@@ -976,7 +985,7 @@ export function App() {
|
|||||||
{activeConversation.name}
|
{activeConversation.name}
|
||||||
</span>
|
</span>
|
||||||
<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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigator.clipboard.writeText(activeConversation.id);
|
navigator.clipboard.writeText(activeConversation.id);
|
||||||
@@ -1011,9 +1020,7 @@ export function App() {
|
|||||||
`${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`
|
`${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)) {
|
if (isValidLocation(contact.lat, contact.lon)) {
|
||||||
// Calculate distance from us if we have valid location
|
|
||||||
const distFromUs =
|
const distFromUs =
|
||||||
config && isValidLocation(config.lat, config.lon)
|
config && isValidLocation(config.lat, config.lon)
|
||||||
? calculateDistance(
|
? calculateDistance(
|
||||||
@@ -1026,7 +1033,7 @@ export function App() {
|
|||||||
parts.push(
|
parts.push(
|
||||||
<span key="coords">
|
<span key="coords">
|
||||||
<span
|
<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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const url =
|
const url =
|
||||||
@@ -1044,7 +1051,7 @@ export function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return parts.length > 0 ? (
|
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) => (
|
{parts.map((part, i) => (
|
||||||
<span key={i}>
|
<span key={i}>
|
||||||
@@ -1057,22 +1064,20 @@ export function App() {
|
|||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||||
{/* Direct trace button (contacts only) */}
|
|
||||||
{activeConversation.type === 'contact' && (
|
{activeConversation.type === 'contact' && (
|
||||||
<button
|
<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}
|
onClick={handleTrace}
|
||||||
title="Direct Trace"
|
title="Direct Trace"
|
||||||
>
|
>
|
||||||
🛎
|
<Route className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Favorite button */}
|
|
||||||
{(activeConversation.type === 'channel' ||
|
{(activeConversation.type === 'channel' ||
|
||||||
activeConversation.type === 'contact') && (
|
activeConversation.type === 'contact') && (
|
||||||
<button
|
<button
|
||||||
className="p-1.5 rounded hover:bg-accent text-xl leading-none"
|
className="p-2 rounded-lg hover:bg-secondary transition-all"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleToggleFavorite(
|
handleToggleFavorite(
|
||||||
activeConversation.type as 'channel' | 'contact',
|
activeConversation.type as 'channel' | 'contact',
|
||||||
@@ -1089,24 +1094,26 @@ export function App() {
|
|||||||
: 'Add to favorites'
|
: 'Add to favorites'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isFavorite(
|
<Star
|
||||||
favorites,
|
className={cn(
|
||||||
activeConversation.type as 'channel' | 'contact',
|
'h-4 w-4 transition-colors',
|
||||||
activeConversation.id
|
isFavorite(
|
||||||
) ? (
|
favorites,
|
||||||
<span className="text-yellow-500">★</span>
|
activeConversation.type as 'channel' | 'contact',
|
||||||
) : (
|
activeConversation.id
|
||||||
<span className="text-muted-foreground">☆</span>
|
)
|
||||||
)}
|
? 'text-amber-400 fill-amber-400'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Delete button */}
|
|
||||||
{!(
|
{!(
|
||||||
activeConversation.type === 'channel' &&
|
activeConversation.type === 'channel' &&
|
||||||
activeConversation.name === 'Public'
|
activeConversation.name === 'Public'
|
||||||
) && (
|
) && (
|
||||||
<button
|
<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={() => {
|
onClick={() => {
|
||||||
if (activeConversation.type === 'channel') {
|
if (activeConversation.type === 'channel') {
|
||||||
handleDeleteChannel(activeConversation.id);
|
handleDeleteChannel(activeConversation.id);
|
||||||
@@ -1116,7 +1123,7 @@ export function App() {
|
|||||||
}}
|
}}
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
🗑
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1161,17 +1168,22 @@ export function App() {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
Select a conversation or start a new one
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showSettings && (
|
{showSettings && (
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<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">
|
<div className="flex justify-between items-center px-5 py-3 border-b border-border/50 bg-card/30">
|
||||||
<span>Radio & Settings</span>
|
<span className="font-semibold text-base">Radio & Settings</span>
|
||||||
<span className="text-sm text-muted-foreground hidden md:inline">
|
<span className="text-xs text-muted-foreground/60 hidden md:inline">
|
||||||
{SETTINGS_SECTION_LABELS[settingsSection]}
|
{SETTINGS_SECTION_LABELS[settingsSection]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1199,10 +1211,10 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Global Cracker Panel - always rendered to maintain state */}
|
{/* Global Cracker Panel */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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'
|
showCracker ? 'h-[275px]' : 'h-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ export function ContactAvatar({ name, publicKey, size = 28, contactType }: Conta
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
backgroundColor: avatar.background,
|
backgroundColor: avatar.background,
|
||||||
color: avatar.textColor,
|
color: avatar.textColor,
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
fontSize: size * 0.45,
|
fontSize: size * 0.42,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{avatar.text}
|
{avatar.text}
|
||||||
|
|||||||
@@ -8,23 +8,19 @@ import {
|
|||||||
type FormEvent,
|
type FormEvent,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Input } from './ui/input';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Button } from './ui/button';
|
import { Send, Lock } from 'lucide-react';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// MeshCore message size limits (empirically determined from LoRa packet constraints)
|
// MeshCore message size limits (empirically determined from LoRa packet constraints)
|
||||||
// Direct delivery allows ~156 bytes; multi-hop requires buffer for path growth.
|
const DM_HARD_LIMIT = 156;
|
||||||
// Channels include "sender: " prefix in the encrypted payload.
|
const DM_WARNING_THRESHOLD = 140;
|
||||||
// All limits are in bytes (UTF-8), not characters, since LoRa packets are byte-constrained.
|
const CHANNEL_HARD_LIMIT = 156;
|
||||||
const DM_HARD_LIMIT = 156; // Max bytes for direct delivery
|
const CHANNEL_WARNING_THRESHOLD = 120;
|
||||||
const DM_WARNING_THRESHOLD = 140; // Conservative for multi-hop
|
const CHANNEL_DANGER_BUFFER = 8;
|
||||||
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 textEncoder = new TextEncoder();
|
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 {
|
function byteLen(s: string): number {
|
||||||
return textEncoder.encode(s).length;
|
return textEncoder.encode(s).length;
|
||||||
}
|
}
|
||||||
@@ -33,11 +29,8 @@ interface MessageInputProps {
|
|||||||
onSend: (text: string) => Promise<void>;
|
onSend: (text: string) => Promise<void>;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
/** When true, input becomes password field for repeater telemetry */
|
|
||||||
isRepeaterMode?: boolean;
|
isRepeaterMode?: boolean;
|
||||||
/** Conversation type for character limit calculation */
|
|
||||||
conversationType?: 'contact' | 'channel' | 'raw';
|
conversationType?: 'contact' | 'channel' | 'raw';
|
||||||
/** Sender name (radio name) for channel message limit calculation */
|
|
||||||
senderName?: string;
|
senderName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,21 +51,18 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
appendText: (appendedText: string) => {
|
appendText: (appendedText: string) => {
|
||||||
setText((prev) => prev + appendedText);
|
setText((prev) => prev + appendedText);
|
||||||
// Focus the input after appending
|
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Calculate character limits based on conversation type
|
|
||||||
const limits = useMemo(() => {
|
const limits = useMemo(() => {
|
||||||
if (conversationType === 'contact') {
|
if (conversationType === 'contact') {
|
||||||
return {
|
return {
|
||||||
warningAt: DM_WARNING_THRESHOLD,
|
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,
|
hardLimit: DM_HARD_LIMIT,
|
||||||
};
|
};
|
||||||
} else if (conversationType === 'channel') {
|
} else if (conversationType === 'channel') {
|
||||||
// Channel hard limit = 156 bytes - senderName bytes - 2 (for ": " separator)
|
|
||||||
const nameByteLen = senderName ? byteLen(senderName) : 10;
|
const nameByteLen = senderName ? byteLen(senderName) : 10;
|
||||||
const hardLimit = Math.max(1, CHANNEL_HARD_LIMIT - nameByteLen - 2);
|
const hardLimit = Math.max(1, CHANNEL_HARD_LIMIT - nameByteLen - 2);
|
||||||
return {
|
return {
|
||||||
@@ -81,13 +71,11 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
hardLimit,
|
hardLimit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null; // Raw/other - no limits
|
return null;
|
||||||
}, [conversationType, senderName]);
|
}, [conversationType, senderName]);
|
||||||
|
|
||||||
// UTF-8 byte length of the current text (LoRa packets are byte-constrained)
|
|
||||||
const textByteLen = useMemo(() => byteLen(text), [text]);
|
const textByteLen = useMemo(() => byteLen(text), [text]);
|
||||||
|
|
||||||
// Determine current limit state
|
|
||||||
const { limitState, warningMessage } = useMemo((): {
|
const { limitState, warningMessage } = useMemo((): {
|
||||||
limitState: LimitState;
|
limitState: LimitState;
|
||||||
warningMessage: string | null;
|
warningMessage: string | null;
|
||||||
@@ -95,13 +83,13 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
if (!limits) return { limitState: 'normal', warningMessage: null };
|
if (!limits) return { limitState: 'normal', warningMessage: null };
|
||||||
|
|
||||||
if (textByteLen >= limits.hardLimit) {
|
if (textByteLen >= limits.hardLimit) {
|
||||||
return { limitState: 'error', warningMessage: 'likely truncated by radio' };
|
return { limitState: 'error', warningMessage: 'likely truncated' };
|
||||||
}
|
}
|
||||||
if (textByteLen >= limits.dangerAt) {
|
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) {
|
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 };
|
return { limitState: 'normal', warningMessage: null };
|
||||||
}, [textByteLen, limits]);
|
}, [textByteLen, limits]);
|
||||||
@@ -113,7 +101,6 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
|
|
||||||
// For repeater mode, empty password means guest login
|
|
||||||
if (isRepeaterMode) {
|
if (isRepeaterMode) {
|
||||||
if (sending || disabled) return;
|
if (sending || disabled) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
@@ -129,7 +116,6 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
// Refocus after React re-enables the input (now in CLI command mode)
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 0);
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
} else {
|
} else {
|
||||||
if (!trimmed || sending || disabled) return;
|
if (!trimmed || sending || disabled) return;
|
||||||
@@ -146,7 +132,6 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
// Refocus after React re-enables the input
|
|
||||||
setTimeout(() => inputRef.current?.focus(), 0);
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -163,66 +148,111 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
[handleSubmit]
|
[handleSubmit]
|
||||||
);
|
);
|
||||||
|
|
||||||
// For repeater mode, always allow submit (empty = guest login)
|
|
||||||
const canSubmit = isRepeaterMode ? true : text.trim().length > 0;
|
const canSubmit = isRepeaterMode ? true : text.trim().length > 0;
|
||||||
|
const showCharCounter = !isRepeaterMode && limits !== null && textByteLen > 0;
|
||||||
// Show character counter for messages (not repeater mode or raw)
|
|
||||||
const showCharCounter = !isRepeaterMode && limits !== null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="px-4 py-3 border-t border-border flex flex-col gap-1" onSubmit={handleSubmit}>
|
<div className="px-4 py-3 border-t border-border/50">
|
||||||
<div className="flex gap-2">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-1.5">
|
||||||
<Input
|
<div className="flex items-center gap-2">
|
||||||
ref={inputRef}
|
{/* Input container with glow */}
|
||||||
type={isRepeaterMode ? 'password' : 'text'}
|
<div
|
||||||
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
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'tabular-nums',
|
'flex-1 relative rounded-xl border transition-all duration-200',
|
||||||
limitState === 'error' || limitState === 'danger'
|
disabled
|
||||||
? 'text-red-500 font-medium'
|
? 'bg-muted/30 border-border/30'
|
||||||
: limitState === 'warning'
|
: text.length > 0
|
||||||
? 'text-yellow-500'
|
? 'bg-secondary/40 border-primary/25 shadow-glow-amber-sm'
|
||||||
: 'text-muted-foreground'
|
: 'bg-secondary/30 border-border/50 focus-within:border-primary/30 focus-within:shadow-glow-amber-sm'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{textByteLen}/{limits!.hardLimit}b{remaining < 0 && ` (${remaining})`}
|
{isRepeaterMode && (
|
||||||
</span>
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/40" />
|
||||||
{warningMessage && (
|
)}
|
||||||
<span className={cn(limitState === 'error' ? 'text-red-500' : 'text-yellow-500')}>
|
<input
|
||||||
— {warningMessage}
|
ref={inputRef}
|
||||||
</span>
|
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>
|
</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,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
|
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
|
||||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||||
import { formatTime, parseSenderFromText } from '../utils/messageParser';
|
import { formatTime, parseSenderFromText } from '../utils/messageParser';
|
||||||
@@ -49,7 +51,7 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
|
|||||||
href={match[0]}
|
href={match[0]}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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]}
|
{match[0]}
|
||||||
</a>
|
</a>
|
||||||
@@ -73,7 +75,6 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
|||||||
let keyIndex = 0;
|
let keyIndex = 0;
|
||||||
|
|
||||||
while ((match = mentionPattern.exec(text)) !== null) {
|
while ((match = mentionPattern.exec(text)) !== null) {
|
||||||
// Add text before the match (with linkification)
|
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
parts.push(...linkifyText(text.slice(lastIndex, match.index), `pre-${keyIndex}`));
|
parts.push(...linkifyText(text.slice(lastIndex, match.index), `pre-${keyIndex}`));
|
||||||
}
|
}
|
||||||
@@ -85,18 +86,19 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
|
|||||||
<span
|
<span
|
||||||
key={`mention-${keyIndex++}`}
|
key={`mention-${keyIndex++}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded px-0.5',
|
'rounded-md px-1 py-0.5 text-[13px]',
|
||||||
isOwnMention ? 'bg-primary/30 text-primary font-medium' : 'bg-muted-foreground/20'
|
isOwnMention
|
||||||
|
? 'bg-primary/20 text-primary font-semibold ring-1 ring-primary/30'
|
||||||
|
: 'bg-accent/10 text-accent/80'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@[{mentionedName}]
|
@{mentionedName}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
lastIndex = match.index + match[0].length;
|
lastIndex = match.index + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add remaining text after last match (with linkification)
|
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < text.length) {
|
||||||
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`));
|
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;
|
return parts.length > 0 ? parts : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clickable hop count badge that opens the path modal
|
// Clickable hop count badge
|
||||||
interface HopCountBadgeProps {
|
interface HopCountBadgeProps {
|
||||||
paths: MessagePath[];
|
paths: MessagePath[];
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -113,16 +115,16 @@ interface HopCountBadgeProps {
|
|||||||
|
|
||||||
function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
||||||
const hopInfo = formatHopCounts(paths);
|
const hopInfo = formatHopCounts(paths);
|
||||||
const label = `(${hopInfo.display})`;
|
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';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick();
|
onClick();
|
||||||
@@ -154,45 +156,35 @@ export function MessageList({
|
|||||||
senderInfo: SenderInfo;
|
senderInfo: SenderInfo;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Capture scroll state in the scroll handler BEFORE any state updates
|
|
||||||
const scrollStateRef = useRef({
|
const scrollStateRef = useRef({
|
||||||
scrollTop: 0,
|
scrollTop: 0,
|
||||||
scrollHeight: 0,
|
scrollHeight: 0,
|
||||||
clientHeight: 0,
|
clientHeight: 0,
|
||||||
wasNearTop: false,
|
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);
|
const prevConvKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Handle scroll position AFTER render
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!listRef.current) return;
|
if (!listRef.current) return;
|
||||||
|
|
||||||
const list = listRef.current;
|
const list = listRef.current;
|
||||||
const messagesAdded = messages.length - prevMessagesLengthRef.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 convKey = messages.length > 0 ? messages[0].conversation_key : null;
|
||||||
const conversationChanged = convKey !== null && convKey !== prevConvKeyRef.current;
|
const conversationChanged = convKey !== null && convKey !== prevConvKeyRef.current;
|
||||||
if (convKey !== null) prevConvKeyRef.current = convKey;
|
if (convKey !== null) prevConvKeyRef.current = convKey;
|
||||||
|
|
||||||
if ((isInitialLoadRef.current || conversationChanged) && messages.length > 0) {
|
if ((isInitialLoadRef.current || conversationChanged) && messages.length > 0) {
|
||||||
// Initial load or conversation switch - scroll to bottom
|
|
||||||
list.scrollTop = list.scrollHeight;
|
list.scrollTop = list.scrollHeight;
|
||||||
isInitialLoadRef.current = false;
|
isInitialLoadRef.current = false;
|
||||||
} else if (messagesAdded > 0 && prevMessagesLengthRef.current > 0) {
|
} else if (messagesAdded > 0 && prevMessagesLengthRef.current > 0) {
|
||||||
// Messages were added - use scroll state captured before the update
|
|
||||||
const scrollHeightDiff = list.scrollHeight - scrollStateRef.current.scrollHeight;
|
const scrollHeightDiff = list.scrollHeight - scrollStateRef.current.scrollHeight;
|
||||||
|
|
||||||
if (scrollStateRef.current.wasNearTop && scrollHeightDiff > 0) {
|
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;
|
list.scrollTop = scrollStateRef.current.scrollTop + scrollHeightDiff;
|
||||||
} else if (scrollStateRef.current.wasNearBottom) {
|
} else if (scrollStateRef.current.wasNearBottom) {
|
||||||
// User was near bottom - scroll to bottom for new messages (including sent)
|
|
||||||
list.scrollTop = list.scrollHeight;
|
list.scrollTop = list.scrollHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +192,6 @@ export function MessageList({
|
|||||||
prevMessagesLengthRef.current = messages.length;
|
prevMessagesLengthRef.current = messages.length;
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
// Reset initial load flag when conversation changes (messages becomes empty then filled)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
isInitialLoadRef.current = true;
|
isInitialLoadRef.current = true;
|
||||||
@@ -216,14 +207,12 @@ export function MessageList({
|
|||||||
}
|
}
|
||||||
}, [messages.length]);
|
}, [messages.length]);
|
||||||
|
|
||||||
// Handle scroll - capture state and detect when user is near top/bottom
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
if (!listRef.current) return;
|
if (!listRef.current) return;
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
|
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
|
||||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||||
|
|
||||||
// Always capture current scroll state (needed for scroll preservation)
|
|
||||||
scrollStateRef.current = {
|
scrollStateRef.current = {
|
||||||
scrollTop,
|
scrollTop,
|
||||||
scrollHeight,
|
scrollHeight,
|
||||||
@@ -232,44 +221,35 @@ export function MessageList({
|
|||||||
wasNearBottom: distanceFromBottom < 100,
|
wasNearBottom: distanceFromBottom < 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show scroll-to-bottom button when not near the bottom (more than 100px away)
|
|
||||||
setShowScrollToBottom(distanceFromBottom > 100);
|
setShowScrollToBottom(distanceFromBottom > 100);
|
||||||
|
|
||||||
if (!onLoadOlder || loadingOlder || !hasOlderMessages) return;
|
if (!onLoadOlder || loadingOlder || !hasOlderMessages) return;
|
||||||
|
|
||||||
// Trigger load when within 100px of top
|
|
||||||
if (scrollTop < 100) {
|
if (scrollTop < 100) {
|
||||||
onLoadOlder();
|
onLoadOlder();
|
||||||
}
|
}
|
||||||
}, [onLoadOlder, loadingOlder, hasOlderMessages]);
|
}, [onLoadOlder, loadingOlder, hasOlderMessages]);
|
||||||
|
|
||||||
// Scroll to bottom handler
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
if (listRef.current) {
|
if (listRef.current) {
|
||||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
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(
|
const sortedMessages = useMemo(
|
||||||
() => [...messages].sort((a, b) => a.received_at - b.received_at),
|
() => [...messages].sort((a, b) => a.received_at - b.received_at),
|
||||||
[messages]
|
[messages]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Look up contact by public key
|
|
||||||
const getContact = (conversationKey: string | null): Contact | null => {
|
const getContact = (conversationKey: string | null): Contact | null => {
|
||||||
if (!conversationKey) return null;
|
if (!conversationKey) return null;
|
||||||
return contacts.find((c) => c.public_key === conversationKey) || 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 => {
|
const getContactByName = (name: string): Contact | null => {
|
||||||
return contacts.find((c) => c.name === name) || null;
|
return contacts.find((c) => c.name === name) || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build sender info for path modal
|
|
||||||
const getSenderInfo = (
|
const getSenderInfo = (
|
||||||
msg: Message,
|
msg: Message,
|
||||||
contact: Contact | null,
|
contact: Contact | null,
|
||||||
@@ -283,7 +263,6 @@ export function MessageList({
|
|||||||
lon: contact.lon,
|
lon: contact.lon,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// For channel messages, try to find contact by parsed sender name
|
|
||||||
if (parsedSender) {
|
if (parsedSender) {
|
||||||
const senderContact = getContactByName(parsedSender);
|
const senderContact = getContactByName(parsedSender);
|
||||||
if (senderContact) {
|
if (senderContact) {
|
||||||
@@ -295,7 +274,6 @@ export function MessageList({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: unknown sender
|
|
||||||
return {
|
return {
|
||||||
name: parsedSender || 'Unknown',
|
name: parsedSender || 'Unknown',
|
||||||
publicKeyOrPrefix: msg.conversation_key || '',
|
publicKeyOrPrefix: msg.conversation_key || '',
|
||||||
@@ -306,21 +284,26 @@ export function MessageList({
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
Loading messages...
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
No messages yet
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to get a unique sender key for grouping messages
|
|
||||||
const getSenderKey = (msg: Message, sender: string | null): string => {
|
const getSenderKey = (msg: Message, sender: string | null): string => {
|
||||||
if (msg.outgoing) return '__outgoing__';
|
if (msg.outgoing) return '__outgoing__';
|
||||||
if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key;
|
if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key;
|
||||||
@@ -330,26 +313,24 @@ export function MessageList({
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-hidden relative">
|
<div className="flex-1 overflow-hidden relative">
|
||||||
<div
|
<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}
|
ref={listRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
{loadingOlder && (
|
{loadingOlder && (
|
||||||
<div className="text-center py-2 text-muted-foreground text-sm">
|
<div className="flex justify-center py-3">
|
||||||
Loading older messages...
|
<div className="w-5 h-5 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!loadingOlder && hasOlderMessages && (
|
{!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
|
Scroll up for older messages
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sortedMessages.map((msg, index) => {
|
{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 contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
|
||||||
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
|
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
|
||||||
|
|
||||||
// Skip sender parsing for repeater messages (CLI responses often have colons)
|
|
||||||
const { sender, content } = isRepeater
|
const { sender, content } = isRepeater
|
||||||
? { sender: null, content: msg.text }
|
? { sender: null, content: msg.text }
|
||||||
: parseSenderFromText(msg.text);
|
: parseSenderFromText(msg.text);
|
||||||
@@ -359,7 +340,6 @@ export function MessageList({
|
|||||||
|
|
||||||
const canClickSender = !msg.outgoing && onSenderClick && displaySender !== 'Unknown';
|
const canClickSender = !msg.outgoing && onSenderClick && displaySender !== 'Unknown';
|
||||||
|
|
||||||
// Determine if we should show avatar (first message in a chunk from same sender)
|
|
||||||
const currentSenderKey = getSenderKey(msg, sender);
|
const currentSenderKey = getSenderKey(msg, sender);
|
||||||
const prevMsg = sortedMessages[index - 1];
|
const prevMsg = sortedMessages[index - 1];
|
||||||
const prevSenderKey = prevMsg
|
const prevSenderKey = prevMsg
|
||||||
@@ -368,16 +348,13 @@ export function MessageList({
|
|||||||
const showAvatar = !msg.outgoing && currentSenderKey !== prevSenderKey;
|
const showAvatar = !msg.outgoing && currentSenderKey !== prevSenderKey;
|
||||||
const isFirstMessage = index === 0;
|
const isFirstMessage = index === 0;
|
||||||
|
|
||||||
// Get avatar info for incoming messages
|
|
||||||
let avatarName: string | null = null;
|
let avatarName: string | null = null;
|
||||||
let avatarKey: string = '';
|
let avatarKey: string = '';
|
||||||
if (!msg.outgoing) {
|
if (!msg.outgoing) {
|
||||||
if (msg.type === 'PRIV' && msg.conversation_key) {
|
if (msg.type === 'PRIV' && msg.conversation_key) {
|
||||||
// DM: use conversation_key (sender's public key)
|
|
||||||
avatarName = contact?.name || null;
|
avatarName = contact?.name || null;
|
||||||
avatarKey = msg.conversation_key;
|
avatarKey = msg.conversation_key;
|
||||||
} else if (sender) {
|
} else if (sender) {
|
||||||
// Channel message: try to find contact by name, or use sender name as pseudo-key
|
|
||||||
const senderContact = getContactByName(sender);
|
const senderContact = getContactByName(sender);
|
||||||
avatarName = sender;
|
avatarName = sender;
|
||||||
avatarKey = senderContact?.public_key || `name:${sender}`;
|
avatarKey = senderContact?.public_key || `name:${sender}`;
|
||||||
@@ -390,36 +367,40 @@ export function MessageList({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-start max-w-[85%]',
|
'flex items-start max-w-[85%]',
|
||||||
msg.outgoing && 'flex-row-reverse self-end',
|
msg.outgoing && 'flex-row-reverse self-end',
|
||||||
showAvatar && !isFirstMessage && 'mt-3'
|
showAvatar && !isFirstMessage && 'mt-4'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!msg.outgoing && (
|
{!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 && (
|
{showAvatar && avatarKey && (
|
||||||
<ContactAvatar name={avatarName} publicKey={avatarKey} size={32} />
|
<ContactAvatar name={avatarName} publicKey={avatarKey} size={30} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'py-1.5 px-3 rounded-lg min-w-0',
|
'py-2 px-3 rounded-2xl min-w-0 relative',
|
||||||
msg.outgoing ? 'bg-[#1e3a29]' : 'bg-muted'
|
msg.outgoing
|
||||||
|
? 'bg-gradient-to-br from-primary/20 to-primary/10 border border-primary/15'
|
||||||
|
: 'bg-secondary/60 border border-border/30'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{showAvatar && (
|
{showAvatar && (
|
||||||
<div className="text-[13px] font-semibold text-muted-foreground mb-0.5">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
{canClickSender ? (
|
<span className="text-[13px] font-semibold">
|
||||||
<span
|
{canClickSender ? (
|
||||||
className="cursor-pointer hover:text-primary hover:underline"
|
<span
|
||||||
onClick={() => onSenderClick(displaySender)}
|
className="cursor-pointer hover:text-primary transition-colors"
|
||||||
title={`Mention ${displaySender}`}
|
onClick={() => onSenderClick(displaySender)}
|
||||||
>
|
title={`Mention ${displaySender}`}
|
||||||
{displaySender}
|
>
|
||||||
</span>
|
{displaySender}
|
||||||
) : (
|
</span>
|
||||||
displaySender
|
) : (
|
||||||
)}
|
<span className="text-muted-foreground">{displaySender}</span>
|
||||||
<span className="font-normal text-muted-foreground/70 ml-2 text-[11px]">
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground/40">
|
||||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||||
</span>
|
</span>
|
||||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||||
@@ -436,7 +417,7 @@ export function MessageList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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) => (
|
{content.split('\n').map((line, i, arr) => (
|
||||||
<span key={i}>
|
<span key={i}>
|
||||||
{renderTextWithMentions(line, radioName)}
|
{renderTextWithMentions(line, radioName)}
|
||||||
@@ -445,7 +426,7 @@ export function MessageList({
|
|||||||
))}
|
))}
|
||||||
{!showAvatar && (
|
{!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)}
|
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||||
</span>
|
</span>
|
||||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||||
@@ -466,7 +447,7 @@ export function MessageList({
|
|||||||
(msg.acked > 0 ? (
|
(msg.acked > 0 ? (
|
||||||
msg.paths && msg.paths.length > 0 ? (
|
msg.paths && msg.paths.length > 0 ? (
|
||||||
<span
|
<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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSelectedPath({
|
setSelectedPath({
|
||||||
@@ -480,12 +461,22 @@ export function MessageList({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
title="View echo paths"
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -495,28 +486,20 @@ export function MessageList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll to bottom button */}
|
{/* Scroll to bottom button */}
|
||||||
{showScrollToBottom && (
|
<AnimatePresence>
|
||||||
<button
|
{showScrollToBottom && (
|
||||||
onClick={scrollToBottom}
|
<motion.button
|
||||||
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"
|
initial={{ opacity: 0, y: 10, scale: 0.9 }}
|
||||||
title="Scroll to bottom"
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
>
|
exit={{ opacity: 0, y: 10, scale: 0.9 }}
|
||||||
<svg
|
onClick={scrollToBottom}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
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"
|
||||||
width="20"
|
title="Scroll to bottom"
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
>
|
||||||
<polyline points="6 9 12 15 18 9" />
|
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
||||||
</svg>
|
</motion.button>
|
||||||
</button>
|
)}
|
||||||
)}
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Path modal */}
|
{/* Path modal */}
|
||||||
{selectedPath && (
|
{selectedPath && (
|
||||||
|
|||||||
+258
-263
@@ -1,4 +1,17 @@
|
|||||||
import { useState } from 'react';
|
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 {
|
import {
|
||||||
CONTACT_TYPE_REPEATER,
|
CONTACT_TYPE_REPEATER,
|
||||||
type Contact,
|
type Contact,
|
||||||
@@ -10,8 +23,6 @@ import { getStateKey, type ConversationTimes } from '../utils/conversationState'
|
|||||||
import { getContactDisplayName } from '../utils/pubkey';
|
import { getContactDisplayName } from '../utils/pubkey';
|
||||||
import { ContactAvatar } from './ContactAvatar';
|
import { ContactAvatar } from './ContactAvatar';
|
||||||
import { isFavorite } from '../utils/favorites';
|
import { isFavorite } from '../utils/favorites';
|
||||||
import { Input } from './ui/input';
|
|
||||||
import { Button } from './ui/button';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type SortOrder = 'alpha' | 'recent';
|
type SortOrder = 'alpha' | 'recent';
|
||||||
@@ -24,16 +35,13 @@ interface SidebarProps {
|
|||||||
onNewMessage: () => void;
|
onNewMessage: () => void;
|
||||||
lastMessageTimes: ConversationTimes;
|
lastMessageTimes: ConversationTimes;
|
||||||
unreadCounts: Record<string, number>;
|
unreadCounts: Record<string, number>;
|
||||||
/** Tracks which conversations have unread messages that mention the user */
|
|
||||||
mentions: Record<string, boolean>;
|
mentions: Record<string, boolean>;
|
||||||
showCracker: boolean;
|
showCracker: boolean;
|
||||||
crackerRunning: boolean;
|
crackerRunning: boolean;
|
||||||
onToggleCracker: () => void;
|
onToggleCracker: () => void;
|
||||||
onMarkAllRead: () => void;
|
onMarkAllRead: () => void;
|
||||||
favorites: Favorite[];
|
favorites: Favorite[];
|
||||||
/** Sort order from server settings */
|
|
||||||
sortOrder?: SortOrder;
|
sortOrder?: SortOrder;
|
||||||
/** Callback when sort order changes */
|
|
||||||
onSortOrderChange?: (order: SortOrder) => void;
|
onSortOrderChange?: (order: SortOrder) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,13 +78,11 @@ export function Sidebar({
|
|||||||
const isActive = (type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer', id: string) =>
|
const isActive = (type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer', id: string) =>
|
||||||
activeConversation?.type === type && activeConversation?.id === id;
|
activeConversation?.type === type && activeConversation?.id === id;
|
||||||
|
|
||||||
// Get unread count for a conversation
|
|
||||||
const getUnreadCount = (type: 'channel' | 'contact', id: string): number => {
|
const getUnreadCount = (type: 'channel' | 'contact', id: string): number => {
|
||||||
const key = getStateKey(type, id);
|
const key = getStateKey(type, id);
|
||||||
return unreadCounts[key] || 0;
|
return unreadCounts[key] || 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if a conversation has a mention
|
|
||||||
const hasMention = (type: 'channel' | 'contact', id: string): boolean => {
|
const hasMention = (type: 'channel' | 'contact', id: string): boolean => {
|
||||||
const key = getStateKey(type, id);
|
const key = getStateKey(type, id);
|
||||||
return mentions[key] || false;
|
return mentions[key] || false;
|
||||||
@@ -87,7 +93,7 @@ export function Sidebar({
|
|||||||
return lastMessageTimes[key] || 0;
|
return lastMessageTimes[key] || 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Deduplicate channels by name, keeping the first (lowest index)
|
// Deduplicate channels by name
|
||||||
const uniqueChannels = channels.reduce<Channel[]>((acc, channel) => {
|
const uniqueChannels = channels.reduce<Channel[]>((acc, channel) => {
|
||||||
if (!acc.some((c) => c.name === channel.name)) {
|
if (!acc.some((c) => c.name === channel.name)) {
|
||||||
acc.push(channel);
|
acc.push(channel);
|
||||||
@@ -95,12 +101,10 @@ export function Sidebar({
|
|||||||
return acc;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Deduplicate contacts by public key, preferring ones with names
|
// Deduplicate contacts by public key
|
||||||
// Also filter out any contacts with empty public keys
|
|
||||||
const uniqueContacts = contacts
|
const uniqueContacts = contacts
|
||||||
.filter((c) => c.public_key && c.public_key.length > 0)
|
.filter((c) => c.public_key && c.public_key.length > 0)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// Sort contacts with names first
|
|
||||||
if (a.name && !b.name) return -1;
|
if (a.name && !b.name) return -1;
|
||||||
if (!a.name && b.name) return 1;
|
if (!a.name && b.name) return 1;
|
||||||
return (a.name || '').localeCompare(b.name || '');
|
return (a.name || '').localeCompare(b.name || '');
|
||||||
@@ -112,54 +116,40 @@ export function Sidebar({
|
|||||||
return acc;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Sort channels based on sort order, with Public always first
|
// Sort channels
|
||||||
const sortedChannels = [...uniqueChannels].sort((a, b) => {
|
const sortedChannels = [...uniqueChannels].sort((a, b) => {
|
||||||
// Public channel always sorts to the top
|
|
||||||
if (a.name === 'Public') return -1;
|
if (a.name === 'Public') return -1;
|
||||||
if (b.name === 'Public') return 1;
|
if (b.name === 'Public') return 1;
|
||||||
|
|
||||||
if (sortOrder === 'recent') {
|
if (sortOrder === 'recent') {
|
||||||
const timeA = getLastMessageTime('channel', a.key);
|
const timeA = getLastMessageTime('channel', a.key);
|
||||||
const timeB = getLastMessageTime('channel', b.key);
|
const timeB = getLastMessageTime('channel', b.key);
|
||||||
// If both have messages, sort by most recent first
|
|
||||||
if (timeA && timeB) return timeB - timeA;
|
if (timeA && timeB) return timeB - timeA;
|
||||||
// Items with messages come before items without
|
|
||||||
if (timeA && !timeB) return -1;
|
if (timeA && !timeB) return -1;
|
||||||
if (!timeA && timeB) return 1;
|
if (!timeA && timeB) return 1;
|
||||||
// Fall back to alpha for items without messages
|
|
||||||
}
|
}
|
||||||
return a.name.localeCompare(b.name);
|
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 sortedContacts = [...uniqueContacts].sort((a, b) => {
|
||||||
const aIsRepeater = a.type === CONTACT_TYPE_REPEATER;
|
const aIsRepeater = a.type === CONTACT_TYPE_REPEATER;
|
||||||
const bIsRepeater = b.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;
|
||||||
if (!aIsRepeater && bIsRepeater) return -1;
|
if (!aIsRepeater && bIsRepeater) return -1;
|
||||||
|
|
||||||
// Both repeaters: always sort alphabetically
|
|
||||||
if (aIsRepeater && bIsRepeater) {
|
if (aIsRepeater && bIsRepeater) {
|
||||||
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
|
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both non-repeaters: use selected sort order
|
|
||||||
if (sortOrder === 'recent') {
|
if (sortOrder === 'recent') {
|
||||||
const timeA = getLastMessageTime('contact', a.public_key);
|
const timeA = getLastMessageTime('contact', a.public_key);
|
||||||
const timeB = getLastMessageTime('contact', b.public_key);
|
const timeB = getLastMessageTime('contact', b.public_key);
|
||||||
// If both have messages, sort by most recent first
|
|
||||||
if (timeA && timeB) return timeB - timeA;
|
if (timeA && timeB) return timeB - timeA;
|
||||||
// Items with messages come before items without
|
|
||||||
if (timeA && !timeB) return -1;
|
if (timeA && !timeB) return -1;
|
||||||
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);
|
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 query = searchQuery.toLowerCase().trim();
|
||||||
const filteredChannels = query
|
const filteredChannels = query
|
||||||
? sortedChannels.filter(
|
? sortedChannels.filter(
|
||||||
@@ -172,7 +162,7 @@ export function Sidebar({
|
|||||||
)
|
)
|
||||||
: sortedContacts;
|
: sortedContacts;
|
||||||
|
|
||||||
// Separate favorites from regular items
|
// Separate favorites
|
||||||
const favoriteChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
const favoriteChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
||||||
const favoriteContacts = filteredContacts.filter((c) =>
|
const favoriteContacts = filteredContacts.filter((c) =>
|
||||||
isFavorite(favorites, 'contact', c.public_key)
|
isFavorite(favorites, 'contact', c.public_key)
|
||||||
@@ -184,7 +174,6 @@ export function Sidebar({
|
|||||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
(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 };
|
type FavoriteItem = { type: 'channel'; channel: Channel } | { type: 'contact'; contact: Contact };
|
||||||
|
|
||||||
const favoriteItems: FavoriteItem[] = [
|
const favoriteItems: FavoriteItem[] = [
|
||||||
@@ -199,168 +188,216 @@ export function Sidebar({
|
|||||||
b.type === 'channel'
|
b.type === 'channel'
|
||||||
? getLastMessageTime('channel', b.channel.key)
|
? getLastMessageTime('channel', b.channel.key)
|
||||||
: getLastMessageTime('contact', b.contact.public_key);
|
: getLastMessageTime('contact', b.contact.public_key);
|
||||||
// Sort by most recent first
|
|
||||||
if (timeA && timeB) return timeB - timeA;
|
if (timeA && timeB) return timeB - timeA;
|
||||||
if (timeA && !timeB) return -1;
|
if (timeA && !timeB) return -1;
|
||||||
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 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;
|
const nameB = b.type === 'channel' ? b.channel.name : b.contact.name || b.contact.public_key;
|
||||||
return nameA.localeCompare(nameB);
|
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 (
|
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 */}
|
{/* Header */}
|
||||||
<div className="flex justify-between items-center px-3 py-3 border-b border-border">
|
<div className="flex justify-between items-center px-4 py-3 border-b border-border/50">
|
||||||
<h2 className="text-xs uppercase text-muted-foreground font-medium">Conversations</h2>
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
<Button
|
Conversations
|
||||||
variant="ghost"
|
</span>
|
||||||
size="sm"
|
<button
|
||||||
onClick={onNewMessage}
|
onClick={onNewMessage}
|
||||||
title="New Message"
|
title="New Conversation"
|
||||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative px-3 py-2 border-b border-border">
|
<div className="px-3 py-2 border-b border-border/50">
|
||||||
<Input
|
<div className="relative">
|
||||||
type="text"
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50" />
|
||||||
placeholder="Search..."
|
<input
|
||||||
value={searchQuery}
|
type="text"
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
placeholder="Search..."
|
||||||
className="h-8 text-sm pr-8"
|
value={searchQuery}
|
||||||
/>
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
{searchQuery && (
|
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"
|
||||||
<button
|
/>
|
||||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none"
|
<AnimatePresence>
|
||||||
onClick={() => setSearchQuery('')}
|
{searchQuery && (
|
||||||
title="Clear search"
|
<motion.button
|
||||||
>
|
initial={{ opacity: 0, scale: 0.5 }}
|
||||||
×
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
{/* Quick nav - special views */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
{!query && (
|
||||||
{/* Raw Packet Feed */}
|
<div className="flex items-center gap-1 px-3 py-2 border-b border-border/50">
|
||||||
{!query && (
|
{[
|
||||||
<div
|
{
|
||||||
className={cn(
|
type: 'raw' as const,
|
||||||
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
|
id: 'raw',
|
||||||
isActive('raw', 'raw') && 'bg-accent border-l-primary'
|
name: 'Raw Packet Feed',
|
||||||
)}
|
icon: Radio,
|
||||||
onClick={() =>
|
title: 'Packet Feed',
|
||||||
handleSelectConversation({
|
},
|
||||||
type: 'raw',
|
{ type: 'map' as const, id: 'map', name: 'Node Map', icon: Map, title: 'Node Map' },
|
||||||
id: 'raw',
|
{
|
||||||
name: 'Raw Packet Feed',
|
type: 'visualizer' as const,
|
||||||
})
|
id: 'visualizer',
|
||||||
}
|
name: 'Mesh Visualizer',
|
||||||
>
|
icon: Sparkles,
|
||||||
<span className="text-muted-foreground text-xs">📡</span>
|
title: 'Visualizer',
|
||||||
<span className="flex-1 truncate">Packet Feed</span>
|
},
|
||||||
</div>
|
].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 */}
|
{/* Cracker + Mark all read */}
|
||||||
{!query && (
|
{!query && (
|
||||||
<div
|
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/50">
|
||||||
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
|
'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg text-xs transition-all',
|
||||||
isActive('map', 'map') && 'bg-accent border-l-primary'
|
showCracker
|
||||||
)}
|
? 'bg-primary/15 text-primary border border-primary/20'
|
||||||
onClick={() =>
|
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
|
||||||
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'
|
|
||||||
)}
|
)}
|
||||||
onClick={onToggleCracker}
|
onClick={onToggleCracker}
|
||||||
|
title="Room Finder"
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground text-xs">🔓</span>
|
<KeyRound className="h-3.5 w-3.5" />
|
||||||
<span className="flex-1 truncate">
|
<span className="truncate">
|
||||||
{showCracker ? 'Hide' : 'Show'} Room Finder
|
Finder
|
||||||
<span
|
{crackerRunning && (
|
||||||
className={cn(
|
<span className="ml-1 inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
'ml-1 text-xs',
|
)}
|
||||||
crackerRunning ? 'text-green-500' : 'text-muted-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
({crackerRunning ? 'running' : 'stopped'})
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
)}
|
{Object.keys(unreadCounts).length > 0 && (
|
||||||
|
<button
|
||||||
{/* Mark All Read */}
|
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"
|
||||||
{!query && Object.keys(unreadCounts).length > 0 && (
|
onClick={onMarkAllRead}
|
||||||
<div
|
title="Mark all as read"
|
||||||
className="px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent"
|
>
|
||||||
onClick={onMarkAllRead}
|
<CheckCheck className="h-3.5 w-3.5" />
|
||||||
>
|
<span className="truncate">Read all</span>
|
||||||
<span className="text-muted-foreground text-xs">✓</span>
|
</button>
|
||||||
<span className="flex-1 truncate text-muted-foreground">Mark all as read</span>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Conversation list */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
{/* Favorites */}
|
{/* Favorites */}
|
||||||
{favoriteItems.length > 0 && (
|
{favoriteItems.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-center px-3 py-2 pt-3">
|
<div className="flex items-center gap-1.5 px-4 py-2 pt-2.5">
|
||||||
<span className="text-[11px] uppercase text-muted-foreground">Favorites</span>
|
<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>
|
</div>
|
||||||
{favoriteItems.map((item) => {
|
{favoriteItems.map((item) => {
|
||||||
if (item.type === 'channel') {
|
if (item.type === 'channel') {
|
||||||
const channel = item.channel;
|
const channel = item.channel;
|
||||||
const unreadCount = getUnreadCount('channel', channel.key);
|
const count = getUnreadCount('channel', channel.key);
|
||||||
const isMention = hasMention('channel', channel.key);
|
const mention = hasMention('channel', channel.key);
|
||||||
return (
|
return (
|
||||||
<div
|
<ConversationItem
|
||||||
key={`fav-chan-${channel.key}`}
|
key={`fav-chan-${channel.key}`}
|
||||||
className={cn(
|
active={isActive('channel', channel.key)}
|
||||||
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
|
unreadCount={count}
|
||||||
isActive('channel', channel.key) && 'bg-accent border-l-primary',
|
isMentionItem={mention}
|
||||||
unreadCount > 0 && '[&_.name]:font-bold [&_.name]:text-foreground'
|
|
||||||
)}
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleSelectConversation({
|
handleSelectConversation({
|
||||||
type: 'channel',
|
type: 'channel',
|
||||||
@@ -368,34 +405,21 @@ export function Sidebar({
|
|||||||
name: channel.name,
|
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>
|
{channel.name}
|
||||||
{unreadCount > 0 && (
|
</ConversationItem>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const contact = item.contact;
|
const contact = item.contact;
|
||||||
const unreadCount = getUnreadCount('contact', contact.public_key);
|
const count = getUnreadCount('contact', contact.public_key);
|
||||||
const isMention = hasMention('contact', contact.public_key);
|
const mention = hasMention('contact', contact.public_key);
|
||||||
return (
|
return (
|
||||||
<div
|
<ConversationItem
|
||||||
key={`fav-contact-${contact.public_key}`}
|
key={`fav-contact-${contact.public_key}`}
|
||||||
className={cn(
|
active={isActive('contact', contact.public_key)}
|
||||||
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
|
unreadCount={count}
|
||||||
isActive('contact', contact.public_key) && 'bg-accent border-l-primary',
|
isMentionItem={mention}
|
||||||
unreadCount > 0 && '[&_.name]:font-bold [&_.name]:text-foreground'
|
|
||||||
)}
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleSelectConversation({
|
handleSelectConversation({
|
||||||
type: 'contact',
|
type: 'contact',
|
||||||
@@ -403,29 +427,17 @@ export function Sidebar({
|
|||||||
name: getContactDisplayName(contact.name, contact.public_key),
|
name: getContactDisplayName(contact.name, contact.public_key),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
icon={
|
||||||
|
<ContactAvatar
|
||||||
|
name={contact.name}
|
||||||
|
publicKey={contact.public_key}
|
||||||
|
size={22}
|
||||||
|
contactType={contact.type}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ContactAvatar
|
{getContactDisplayName(contact.name, contact.public_key)}
|
||||||
name={contact.name}
|
</ConversationItem>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
@@ -435,27 +447,30 @@ export function Sidebar({
|
|||||||
{/* Channels */}
|
{/* Channels */}
|
||||||
{nonFavoriteChannels.length > 0 && (
|
{nonFavoriteChannels.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-center px-3 py-2 pt-3">
|
<div className="flex justify-between items-center px-4 py-2 pt-3">
|
||||||
<span className="text-[11px] uppercase text-muted-foreground">Channels</span>
|
<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
|
<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}
|
onClick={handleSortToggle}
|
||||||
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
|
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
|
||||||
>
|
>
|
||||||
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
|
{sortOrder === 'alpha' ? 'A-Z' : 'Recent'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{nonFavoriteChannels.map((channel) => {
|
{nonFavoriteChannels.map((channel) => {
|
||||||
const unreadCount = getUnreadCount('channel', channel.key);
|
const count = getUnreadCount('channel', channel.key);
|
||||||
const isMention = hasMention('channel', channel.key);
|
const mention = hasMention('channel', channel.key);
|
||||||
return (
|
return (
|
||||||
<div
|
<ConversationItem
|
||||||
key={`chan-${channel.key}`}
|
key={`chan-${channel.key}`}
|
||||||
className={cn(
|
active={isActive('channel', channel.key)}
|
||||||
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
|
unreadCount={count}
|
||||||
isActive('channel', channel.key) && 'bg-accent border-l-primary',
|
isMentionItem={mention}
|
||||||
unreadCount > 0 && '[&_.name]:font-bold [&_.name]:text-foreground'
|
|
||||||
)}
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleSelectConversation({
|
handleSelectConversation({
|
||||||
type: 'channel',
|
type: 'channel',
|
||||||
@@ -463,21 +478,10 @@ export function Sidebar({
|
|||||||
name: channel.name,
|
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>
|
{channel.name}
|
||||||
{unreadCount > 0 && (
|
</ConversationItem>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
@@ -486,29 +490,32 @@ export function Sidebar({
|
|||||||
{/* Contacts */}
|
{/* Contacts */}
|
||||||
{nonFavoriteContacts.length > 0 && (
|
{nonFavoriteContacts.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-center px-3 py-2 pt-3">
|
<div className="flex justify-between items-center px-4 py-2 pt-3">
|
||||||
<span className="text-[11px] uppercase text-muted-foreground">Contacts</span>
|
<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 && (
|
{nonFavoriteChannels.length === 0 && (
|
||||||
<button
|
<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}
|
onClick={handleSortToggle}
|
||||||
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
|
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
|
||||||
>
|
>
|
||||||
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
|
{sortOrder === 'alpha' ? 'A-Z' : 'Recent'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{nonFavoriteContacts.map((contact) => {
|
{nonFavoriteContacts.map((contact) => {
|
||||||
const unreadCount = getUnreadCount('contact', contact.public_key);
|
const count = getUnreadCount('contact', contact.public_key);
|
||||||
const isMention = hasMention('contact', contact.public_key);
|
const mention = hasMention('contact', contact.public_key);
|
||||||
return (
|
return (
|
||||||
<div
|
<ConversationItem
|
||||||
key={contact.public_key}
|
key={contact.public_key}
|
||||||
className={cn(
|
active={isActive('contact', contact.public_key)}
|
||||||
'px-3 py-2.5 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent',
|
unreadCount={count}
|
||||||
isActive('contact', contact.public_key) && 'bg-accent border-l-primary',
|
isMentionItem={mention}
|
||||||
unreadCount > 0 && '[&_.name]:font-bold [&_.name]:text-foreground'
|
|
||||||
)}
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleSelectConversation({
|
handleSelectConversation({
|
||||||
type: 'contact',
|
type: 'contact',
|
||||||
@@ -516,29 +523,17 @@ export function Sidebar({
|
|||||||
name: getContactDisplayName(contact.name, contact.public_key),
|
name: getContactDisplayName(contact.name, contact.public_key),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
icon={
|
||||||
|
<ContactAvatar
|
||||||
|
name={contact.name}
|
||||||
|
publicKey={contact.public_key}
|
||||||
|
size={22}
|
||||||
|
contactType={contact.type}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ContactAvatar
|
{getContactDisplayName(contact.name, contact.public_key)}
|
||||||
name={contact.name}
|
</ConversationItem>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
@@ -548,7 +543,7 @@ export function Sidebar({
|
|||||||
{nonFavoriteContacts.length === 0 &&
|
{nonFavoriteContacts.length === 0 &&
|
||||||
nonFavoriteChannels.length === 0 &&
|
nonFavoriteChannels.length === 0 &&
|
||||||
favoriteItems.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'}
|
{query ? 'No matches found' : 'No conversations yet'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
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 type { HealthStatus, RadioConfig } from '../types';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import { toast } from './ui/sonner';
|
import { toast } from './ui/sonner';
|
||||||
@@ -39,61 +40,111 @@ export function StatusBar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 px-4 py-2 bg-[#252525] border-b border-[#333] text-xs">
|
<div className="relative flex items-center gap-3 px-4 py-2.5 border-b border-border/50 bg-card/80 backdrop-blur-sm">
|
||||||
{/* Mobile menu button - only visible on small screens */}
|
{/* 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 && (
|
{onMenuClick && (
|
||||||
<button
|
<button
|
||||||
onClick={onMenuClick}
|
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"
|
aria-label="Open menu"
|
||||||
>
|
>
|
||||||
<Menu className="h-5 w-5" />
|
<Menu className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h1 className="text-base font-semibold mr-auto">RemoteTerm</h1>
|
{/* Logo / Title */}
|
||||||
|
<div className="flex items-center gap-2 mr-auto">
|
||||||
<div className="flex items-center gap-1 text-[#888]">
|
<div className="relative">
|
||||||
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-[#4caf50]' : 'bg-[#666]'}`} />
|
<Radio className="h-5 w-5 text-primary" />
|
||||||
<span className="hidden lg:inline text-[#e0e0e0]">
|
{connected && (
|
||||||
{connected ? 'Connected' : 'Disconnected'}
|
<motion.div
|
||||||
</span>
|
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>
|
</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 && (
|
{config && (
|
||||||
<div className="hidden lg:flex items-center gap-2 text-[#888]">
|
<div className="hidden lg:flex items-center gap-2">
|
||||||
<span className="text-[#e0e0e0]">{config.name || 'Unnamed'}</span>
|
<span className="text-xs font-medium text-foreground/80">{config.name || 'Unnamed'}</span>
|
||||||
<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={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(config.public_key);
|
navigator.clipboard.writeText(config.public_key);
|
||||||
toast.success('Public key copied!');
|
toast.success('Public key copied!');
|
||||||
}}
|
}}
|
||||||
title="Click to copy public key"
|
title="Click to copy public key"
|
||||||
>
|
>
|
||||||
{config.public_key.toLowerCase()}
|
{config.public_key.toLowerCase().slice(0, 16)}...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={handleReconnect}
|
onClick={onSettingsClick}
|
||||||
disabled={reconnecting}
|
className={`p-2 rounded-lg transition-all ${
|
||||||
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"
|
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>
|
||||||
)}
|
</div>
|
||||||
<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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
default:
|
||||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
'bg-primary text-primary-foreground hover:bg-primary/90 shadow-glow-amber-sm hover:shadow-glow-amber rounded-lg font-semibold',
|
||||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
destructive:
|
||||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded-lg',
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
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',
|
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: {
|
size: {
|
||||||
default: 'h-10 px-4 py-2',
|
default: 'h-10 px-5 py-2',
|
||||||
sm: 'h-9 rounded-md px-3',
|
sm: 'h-8 rounded-lg px-3 text-xs',
|
||||||
lg: 'h-11 rounded-md px-8',
|
lg: 'h-12 rounded-xl px-8 text-base',
|
||||||
icon: 'h-10 w-10',
|
icon: 'h-10 w-10',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SheetPrimitive.Overlay
|
<SheetPrimitive.Overlay
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
|
|||||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const sheetVariants = cva(
|
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: {
|
variants: {
|
||||||
side: {
|
side: {
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
toastOptions={{
|
toastOptions={{
|
||||||
classNames: {
|
classNames: {
|
||||||
toast:
|
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',
|
description: 'group-[.toast]:text-muted-foreground',
|
||||||
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||||
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||||
// Muted error style - dark red-tinted background with readable text
|
|
||||||
error:
|
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}
|
{...props}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
|
|||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
|||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
+149
-20
@@ -4,26 +4,32 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 10%;
|
/* Midnight Amber - a retro-futuristic radio terminal palette */
|
||||||
--foreground: 0 0% 88%;
|
--background: 222 47% 6%;
|
||||||
--card: 0 0% 14%;
|
--foreground: 40 20% 88%;
|
||||||
--card-foreground: 0 0% 88%;
|
--card: 222 40% 10%;
|
||||||
--popover: 0 0% 14%;
|
--card-foreground: 40 20% 88%;
|
||||||
--popover-foreground: 0 0% 88%;
|
--popover: 222 44% 9%;
|
||||||
--primary: 122 39% 49%;
|
--popover-foreground: 40 20% 88%;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary: 38 92% 50%;
|
||||||
--secondary: 0 0% 20%;
|
--primary-foreground: 222 47% 6%;
|
||||||
--secondary-foreground: 0 0% 88%;
|
--secondary: 222 30% 16%;
|
||||||
--muted: 0 0% 20%;
|
--secondary-foreground: 40 20% 85%;
|
||||||
--muted-foreground: 0 0% 53%;
|
--muted: 222 30% 14%;
|
||||||
--accent: 0 0% 20%;
|
--muted-foreground: 215 15% 50%;
|
||||||
--accent-foreground: 0 0% 88%;
|
--accent: 187 80% 42%;
|
||||||
--destructive: 0 62% 50%;
|
--accent-foreground: 222 47% 6%;
|
||||||
|
--destructive: 0 72% 51%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: 0 0% 20%;
|
--border: 222 30% 18%;
|
||||||
--input: 0 0% 20%;
|
--input: 222 30% 16%;
|
||||||
--ring: 122 39% 49%;
|
--ring: 38 92% 50%;
|
||||||
--radius: 0.5rem;
|
--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 {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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;
|
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 */
|
/* Constrain CodeMirror editor width */
|
||||||
.cm-editor {
|
.cm-editor {
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
|
|||||||
@@ -32,7 +32,13 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 */
|
/* Prevent overscroll/bounce on mobile */
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
padding: var(--safe-area-top) var(--safe-area-right) var(--safe-area-bottom-capped)
|
padding: var(--safe-area-top) var(--safe-area-right) var(--safe-area-bottom-capped)
|
||||||
|
|||||||
@@ -41,11 +41,33 @@ export default {
|
|||||||
DEFAULT: "hsl(var(--card))",
|
DEFAULT: "hsl(var(--card))",
|
||||||
foreground: "hsl(var(--card-foreground))",
|
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: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
md: "calc(var(--radius) - 2px)",
|
md: "calc(var(--radius) - 2px)",
|
||||||
sm: "calc(var(--radius) - 4px)",
|
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