Compare commits

...

1 Commits

Author SHA1 Message Date
Jack Kingsman af0b8ee132 Actually cool ui concepts 2026-02-12 12:44:08 -08:00
16 changed files with 793 additions and 559 deletions
+1
View File
@@ -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
View File
@@ -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"
> >
&#x1F6CE; <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">&#9733;</span> activeConversation.type as 'channel' | 'contact',
) : ( activeConversation.id
<span className="text-muted-foreground">&#9734;</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"
> >
&#128465; <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'
)} )}
> >
+2 -2
View File
@@ -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}
+111 -81
View File
@@ -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>
); );
}); });
+82 -99
View File
@@ -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
View File
@@ -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>
)} )}
+81 -30
View File
@@ -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">
&#128295;
</span>{' '}
{settingsMode ? 'Back to Chat' : 'Radio & Config'}
</button>
</div> </div>
); );
} }
+13 -9
View File
@@ -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',
}, },
}, },
+2 -2
View File
@@ -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}
+1 -1
View File
@@ -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}
+2 -2
View File
@@ -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: {
+4 -3
View File
@@ -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}
+2 -2
View File
@@ -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
View File
@@ -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;
+7 -1
View File
@@ -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)
+22
View File
@@ -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)',
}, },
}, },
}, },