Add Button component

This commit is contained in:
Daniel Pupius
2025-04-25 17:30:02 -07:00
parent 81892a5793
commit ca231c81bd
5 changed files with 169 additions and 74 deletions

View File

@@ -2,10 +2,17 @@ import React, { useState, useCallback } from "react";
import { useAppSelector, useAppDispatch } from "../hooks";
import { PacketRenderer } from "./packets/PacketRenderer";
import { StreamControl } from "./StreamControl";
import { Trash2, RefreshCw, Archive } from "lucide-react";
import {
Trash2,
RefreshCw,
Archive,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { clearPackets, toggleStreamPause } from "../store/slices/packetSlice";
import { Packet } from "../lib/types";
import { Separator } from "./Separator";
import { Button } from "./ui/Button";
// Number of packets to show per page
const PACKETS_PER_PAGE = 100;
@@ -122,13 +129,9 @@ export const PacketList: React.FC = () => {
/>
{/* Clear button */}
<button
onClick={handleClearPackets}
className="flex items-center space-x-2 px-3 py-1.5 effect-outset border border-neutral-950/90 rounded-md text-neutral-400 hover:bg-neutral-700/50"
>
<Trash2 className="h-4 w-4 mr-1.5" />
<span className="text-sm font-medium">Clear</span>
</button>
<Button onClick={handleClearPackets} icon={Trash2} size="md">
Clear
</Button>
</div>
<div className="text-sm text-neutral-400 px-2">
{packets.length} packets received
@@ -183,12 +186,14 @@ export const PacketList: React.FC = () => {
messages.
</span>
</div>
<button
<Button
onClick={handleToggleStream}
className="inline-flex items-center px-3 py-1.5 mt-2 text-sm effect-outset border border-neutral-950/90 rounded-md text-neutral-300 hover:bg-neutral-700/50 transition-colors"
variant="primary"
size="md"
className="mt-2"
>
Resume to view
</button>
</Button>
</div>
)}
@@ -202,35 +207,31 @@ export const PacketList: React.FC = () => {
</div>
<div className="flex items-center space-x-3">
<button
<Button
onClick={() =>
setCurrentPage(currentPage > 1 ? currentPage - 1 : 1)
}
disabled={currentPage === 1}
className={`flex items-center px-3 py-1.5 effect-outset border border-neutral-950/90 rounded-md ${
currentPage === 1
? "text-neutral-500 cursor-not-allowed opacity-50"
: "text-neutral-400 hover:bg-neutral-700/50"
}`}
icon={ChevronLeft}
variant="secondary"
size="md"
>
<span className="text-sm font-medium">Previous</span>
</button>
Previous
</Button>
<button
<Button
onClick={() =>
setCurrentPage(
currentPage < totalPages ? currentPage + 1 : totalPages
)
}
disabled={currentPage === totalPages}
className={`flex items-center px-3 py-1.5 effect-outset border border-neutral-950/90 rounded-md ${
currentPage === totalPages
? "text-neutral-500 cursor-not-allowed opacity-50"
: "text-neutral-400 hover:bg-neutral-700/50"
}`}
icon={ChevronRight}
variant="secondary"
size="md"
>
<span className="text-sm font-medium">Next</span>
</button>
Next
</Button>
</div>
</div>
</>

View File

@@ -0,0 +1,72 @@
import React from "react";
import { LucideIcon } from "lucide-react";
import { cn } from "../../lib/cn";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Button content */
children: React.ReactNode;
/** Optional icon to display before the text */
icon?: LucideIcon;
/** Button variant */
variant?: "primary" | "secondary" | "danger" | "ghost";
/** Button size */
size?: "sm" | "md" | "lg";
/** Full width button */
fullWidth?: boolean;
/** Optional class name to extend styles */
className?: string;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
icon: Icon,
variant = "secondary",
size = "md",
fullWidth = false,
className,
disabled,
...props
},
ref
) => {
const variantClasses = {
primary: "bg-blue-600 text-white hover:bg-blue-700 border-blue-700/50",
secondary: "text-neutral-400 hover:bg-neutral-700/50 border-neutral-950/90",
danger: "bg-red-700/20 text-red-400 hover:bg-red-700/30 border-red-900/60",
ghost: "text-neutral-400 hover:bg-neutral-700/30 border-transparent",
};
const sizeClasses = {
sm: "px-2 py-1 text-xs",
md: "px-3 py-1.5 text-sm",
lg: "px-4 py-2 text-base",
};
const disabledClasses = disabled
? "opacity-50 cursor-not-allowed"
: "transition-colors";
return (
<button
ref={ref}
disabled={disabled}
className={cn(
"flex items-center justify-center font-medium effect-outset rounded-md border",
variantClasses[variant],
sizeClasses[size],
fullWidth ? "w-full" : "",
disabledClasses,
className
)}
{...props}
>
{Icon && <Icon className={cn("w-4 h-4", children ? "mr-1.5" : "")} />}
{children}
</button>
);
}
);
Button.displayName = "Button";

View File

@@ -4,45 +4,59 @@ import { PageWrapper, MessageBubble } from "../components";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { ChevronLeft } from "lucide-react";
export const Route = createFileRoute('/channel/$channelId')({
export const Route = createFileRoute("/channel/$channelId")({
component: ChannelPage,
});
// Generate a deterministic color based on channel ID
const getChannelColor = (channelId: string) => {
const colors = [
'bg-green-500', 'bg-blue-500', 'bg-amber-500', 'bg-purple-500',
'bg-pink-500', 'bg-indigo-500', 'bg-red-500', 'bg-teal-500'
"bg-green-500",
"bg-blue-500",
"bg-amber-500",
"bg-purple-500",
"bg-pink-500",
"bg-indigo-500",
"bg-red-500",
"bg-teal-500",
];
// Use a hash of the channel ID to pick a consistent color
const hash = channelId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
const hash = channelId
.split("")
.reduce((acc, char) => acc + char.charCodeAt(0), 0);
return colors[hash % colors.length];
};
function ChannelPage() {
const { channelId } = Route.useParams();
const navigate = useNavigate();
const { channels, messages, nodes } = useAppSelector(state => state.aggregator);
const { channels, messages, nodes } = useAppSelector(
(state) => state.aggregator
);
const channel = channels[channelId];
// Create the channel key in the same format used by the aggregator
const channelKey = `channel_${channelId}`;
// Get channel messages and sort by timestamp (oldest to newest)
// This creates a shallow copy of the message array to sort it without modifying the original
const channelMessages = messages[channelKey] ?
[...messages[channelKey]].sort((a, b) => a.timestamp - b.timestamp) :
[];
const channelMessages = messages[channelKey]
? [...messages[channelKey]].sort((a, b) => a.timestamp - b.timestamp)
: [];
useEffect(() => {
if (!channel) {
console.log(`[Channel] Channel ${channelId} not found`);
// Navigate back to channels page if this channel doesn't exist
setTimeout(() => navigate({ to: '/channels' }), 500);
setTimeout(() => navigate({ to: "/channels" }), 500);
} else {
console.log(`[Channel] Displaying ${channelMessages.length} messages for channel ${channelId}`);
console.log(`[Channel] Message count from channel data: ${channel.messageCount}`);
console.log(
`[Channel] Displaying ${channelMessages.length} messages for channel ${channelId}`
);
console.log(
`[Channel] Message count from channel data: ${channel.messageCount}`
);
console.log(`[Channel] Available messages:`, messages);
console.log(`[Channel] Looking for key:`, channelKey);
}
@@ -63,28 +77,33 @@ function ChannelPage() {
return (
<PageWrapper>
<div className="max-w-4xl mx-auto h-full flex flex-col">
<div className="max-w-4xl h-full flex flex-col">
{/* Header */}
<div className="flex items-center mb-4">
<Link
to="/channels"
<Link
to="/channels"
className="mr-3 text-neutral-400 hover:text-neutral-200 transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</Link>
<div className="flex items-center">
<div className={`w-10 h-10 rounded-full ${channelColor} flex items-center justify-center text-white text-sm font-bold`}>
<div
className={`w-10 h-10 rounded-full ${channelColor} flex items-center justify-center text-white text-sm font-bold`}
>
{channelId.substring(0, 2).toUpperCase()}
</div>
<div className="ml-3">
<h1 className="text-xl font-semibold text-neutral-100">{channelId}</h1>
<h1 className="text-xl font-semibold text-neutral-100">
{channelId}
</h1>
<p className="text-sm text-neutral-400">
{channel.nodes.length} nodes · {channel.textMessageCount} messages
{channel.nodes.length} nodes · {channel.textMessageCount}{" "}
messages
</p>
</div>
</div>
</div>
{/* Message List */}
<div className="flex-1 overflow-y-auto pb-4">
{channelMessages.length === 0 ? (
@@ -94,10 +113,12 @@ function ChannelPage() {
) : (
<div className="space-y-4">
{channelMessages.map((message) => {
const nodeName = nodes[message.from]?.shortName || nodes[message.from]?.longName;
const nodeName =
nodes[message.from]?.shortName ||
nodes[message.from]?.longName;
return (
<MessageBubble
<MessageBubble
key={`${message.from}-${message.id}`}
message={message}
nodeName={nodeName}
@@ -110,4 +131,4 @@ function ChannelPage() {
</div>
</PageWrapper>
);
}
}

View File

@@ -3,22 +3,29 @@ import { useAppSelector } from "../hooks";
import { PageWrapper, ChannelCard } from "../components";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute('/channels')({
export const Route = createFileRoute("/channels")({
component: ChannelsPage,
});
function ChannelsPage() {
const { channels, messages } = useAppSelector(state => state.aggregator);
const { channels, messages } = useAppSelector((state) => state.aggregator);
useEffect(() => {
console.log(`[Channels] Displaying ${Object.keys(channels).length} channels`);
console.log(
`[Channels] Displaying ${Object.keys(channels).length} channels`
);
console.log(`[Channels] Messages in store:`, messages);
// Check if any channels have text messages but none are displayed
Object.entries(channels).forEach(([channelId, channel]) => {
const channelKey = `channel_${channelId}`;
if (channel.textMessageCount > 0 && (!messages[channelKey] || messages[channelKey].length === 0)) {
console.log(`[Channels] Mismatch: Channel ${channelId} reports ${channel.textMessageCount} text messages but has ${messages[channelKey]?.length || 0} in store`);
if (
channel.textMessageCount > 0 &&
(!messages[channelKey] || messages[channelKey].length === 0)
) {
console.log(
`[Channels] Mismatch: Channel ${channelId} reports ${channel.textMessageCount} text messages but has ${messages[channelKey]?.length || 0} in store`
);
}
});
}, [channels, messages]);
@@ -32,20 +39,21 @@ function ChannelsPage() {
// If one has activity and other doesn't, prioritize the active one
if (a.lastMessage) return -1;
if (b.lastMessage) return 1;
// Fall back to text message count if no last message time
return (b.textMessageCount || 0) - (a.textMessageCount || 0);
});
return (
<PageWrapper>
<div className="max-w-6xl mx-auto">
<h1 className="text-2xl font-semibold text-neutral-100 mb-6">Channels</h1>
<div className="max-w-4xl">
{sortedChannels.length === 0 ? (
<div className="bg-neutral-800 rounded-lg p-6 text-center text-neutral-400">
<p>No channels discovered yet.</p>
<p className="text-sm mt-2">Channels will appear here as they are discovered on the Meshtastic network.</p>
<p className="text-sm mt-2">
Channels will appear here as they are discovered on the Meshtastic
network.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -57,4 +65,4 @@ function ChannelsPage() {
</div>
</PageWrapper>
);
}
}

View File

@@ -1,25 +1,18 @@
import { PageWrapper, NodeList, GatewayList } from "../components";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute('/home')({
export const Route = createFileRoute("/home")({
component: HomePage,
});
function HomePage() {
return (
<PageWrapper>
<div className="mb-4">
<h1 className="text-xl font-bold mb-2 text-neutral-200">Mesh Overview</h1>
<p className="text-sm text-neutral-400">
Real-time view of your Meshtastic mesh network traffic
</p>
</div>
<div className="grid grid-cols-1 gap-4">
<div className="bg-neutral-900/50 p-3 rounded-lg border border-neutral-800">
<GatewayList />
</div>
<div className="bg-neutral-900/50 p-3 rounded-lg border border-neutral-800">
<NodeList />
</div>