mirror of
https://github.com/dpup/meshstream.git
synced 2026-03-28 17:42:37 +01:00
Add Button component
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
|
||||
72
web/src/components/ui/Button.tsx
Normal file
72
web/src/components/ui/Button.tsx
Normal 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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user