Feat/UI overhaul (#126)

This commit is contained in:
Danish Arora 2025-04-04 17:26:44 +05:30 committed by GitHub
parent b3b261fc26
commit 57d8c27b25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 2848 additions and 709 deletions

View File

@ -1,6 +1,6 @@
# Waku Keystore Management # Waku Keystore Management
A simple Next.js application to manage Waku RLN keystores. Application to manage Waku RLN keystores.
## Overview ## Overview
@ -8,29 +8,14 @@ This application provides an interface for managing keystores for Waku's rate-li
## Features ## Features
- Connect to MetaMask wallet - Connect to MetaMask wallet with dropdown menu for account details
- Terminal-inspired UI with cyberpunk styling
- View wallet information including address, network, and balance - View wallet information including address, network, and balance
- Support for Linea Sepolia testnet only - Support for Linea Sepolia testnet only
- Keystore management functionality - Keystore management with copy, view, export, and remove functionality
- Token approval for RLN membership registration - Token approval for RLN membership registration
- Light/standard RLN implementation toggle
## Getting Started
1. First, install the dependencies:
```bash
npm install
```
2. Run the development server:
```bash
npm run dev
```
3. Open [http://localhost:3000](http://localhost:3000) with your browser.
4. Connect your MetaMask wallet (Linea Sepolia testnet is required).
## Linea Sepolia Network ## Linea Sepolia Network
@ -53,3 +38,6 @@ When registering for RLN membership, you'll need to complete two transactions:
If you encounter an "ERC20: insufficient allowance" error, it means the token approval transaction was not completed successfully. Please try again and make sure to approve the token spending in your wallet. If you encounter an "ERC20: insufficient allowance" error, it means the token approval transaction was not completed successfully. Please try again and make sure to approve the token spending in your wallet.
## TODO
- [ ] add info about using with nwaku/nwaku-compose/waku-simulator
- [ ] fix rate limit fetch

View File

@ -0,0 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: [
'waku.org',
'logos.co',
'contributors.free.technology'
],
},
reactStrictMode: true,
}
module.exports = nextConfig

File diff suppressed because it is too large Load Diff

View File

@ -10,10 +10,27 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@waku/rln": "0.1.5-6997987.0", "@fontsource-variable/inter": "^5.2.5",
"@fontsource-variable/jetbrains-mono": "^5.2.5",
"@next/font": "^14.2.15",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@waku/rln": "0.1.5-9901863.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.6.3",
"lucide-react": "^0.487.0",
"next": "15.1.7", "next": "15.1.7",
"next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"sonner": "^2.0.3",
"tailwind-merge": "^3.1.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
@ -24,6 +41,7 @@
"eslint-config-next": "15.1.7", "eslint-config-next": "15.1.7",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@ -2,20 +2,176 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root { @layer base {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root { :root {
--background: #0a0a0a; --font-sans: 'Inter', sans-serif;
--foreground: #ededed; --font-mono: 'JetBrains Mono', monospace;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
} }
} }
body { @layer utilities {
color: var(--foreground); .animate-in {
background: var(--background); animation: animateIn 0.3s ease-in-out forwards;
font-family: Arial, Helvetica, sans-serif; }
.fade-in-50 {
opacity: 0;
animation-name: fadeIn50;
}
@keyframes fadeIn50 {
from { opacity: 0; }
to { opacity: 1; }
}
.duration-300 {
animation-duration: 300ms;
}
.typing-effect {
overflow: hidden;
white-space: nowrap;
border-right: 2px solid theme('colors.primary.DEFAULT');
width: 0;
animation: typing 3s steps(40, end) forwards, blink-caret 1s step-end infinite;
}
@keyframes typing {
from { width: 0 }
to { width: 100% }
}
@keyframes blink-caret {
from, to { border-color: transparent }
50% { border-color: theme('colors.primary.DEFAULT') }
}
}
@layer components {
.terminal-window {
@apply bg-terminal-background border border-terminal-border relative rounded-md overflow-hidden;
box-shadow: 0 0 5px theme('colors.terminal.border'), inset 0 0 5px rgba(0, 0, 0, 0.5);
}
.terminal-header {
@apply bg-muted px-4 py-2 flex items-center justify-between border-b border-terminal-border;
}
.terminal-content {
@apply p-4 font-mono text-sm relative;
}
.terminal-content::before {
content: '';
@apply absolute top-0 left-0 right-0 bottom-0 pointer-events-none;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.1),
rgba(0, 0, 0, 0.1) 1px,
transparent 1px,
transparent 2px
);
}
.terminal-text {
@apply text-terminal-text;
}
.scan-line {
@apply absolute top-0 left-0 w-full h-[2px] bg-white/10 opacity-75 pointer-events-none;
animation: scan-line 6s linear infinite;
}
.cursor-blink::after {
content: '|';
@apply animate-blink ml-[1px];
}
.glow-text {
text-shadow: 0 0 5px currentColor;
}
.button-glow {
@apply transition-all duration-300;
box-shadow: 0 0 5px theme('colors.primary.DEFAULT');
}
.button-glow:hover {
box-shadow: 0 0 10px theme('colors.primary.DEFAULT'), 0 0 20px theme('colors.primary.DEFAULT');
}
.hexagon-bg {
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M30 5.66987L55 20.8349V51.1651L30 66.3301L5 51.1651V20.8349L30 5.66987ZM30 0L0 17.3205V51.9616L30 69.2821L60 51.9616V17.3205L30 0Z' fill='%2320202A' fill-opacity='0.3'/%3E%3C/svg%3E");
background-size: 60px 60px;
}
.circuit-bg {
background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10 10 H90 V90 H10 Z' fill='none' stroke='%2320202A' stroke-width='1'/%3E%3Cpath d='M30 30 H70 V70 H30 Z' fill='none' stroke='%2320202A' stroke-width='1'/%3E%3Cpath d='M50 10 V30 M50 70 V90 M10 50 H30 M70 50 H90' stroke='%2320202A' stroke-width='1'/%3E%3C/svg%3E");
background-size: 100px 100px;
}
.glitch {
position: relative;
animation: glitch-animation 2s infinite;
}
@keyframes glitch-animation {
0% {
transform: none;
opacity: 1;
}
7% {
transform: skew(-0.5deg, -0.9deg);
opacity: 0.75;
}
10% {
transform: none;
opacity: 1;
}
27% {
transform: none;
opacity: 1;
}
30% {
transform: skew(0.8deg, -0.1deg);
opacity: 0.75;
}
35% {
transform: none;
opacity: 1;
}
52% {
transform: none;
opacity: 1;
}
55% {
transform: skew(-1deg, 0.2deg);
opacity: 0.75;
}
50% {
transform: none;
opacity: 1;
}
72% {
transform: none;
opacity: 1;
}
75% {
transform: skew(0.4deg, 1deg);
opacity: 0.75;
}
80% {
transform: none;
opacity: 1;
}
100% {
transform: none;
opacity: 1;
}
}
} }

View File

@ -1,46 +1,73 @@
import type { Metadata } from "next";
import { Inter } from 'next/font/google'
import "./globals.css"; import "./globals.css";
import { Inter as FontSans, JetBrains_Mono as FontMono } from "next/font/google";
import { cn } from "../lib/utils";
import { ThemeProvider } from "../components/theme-provider";
import { Toaster } from "../components/ui/toaster";
import "@fontsource-variable/inter";
import "@fontsource-variable/jetbrains-mono";
import { WalletProvider, RLNImplementationProvider, KeystoreProvider, RLNProvider } from "../contexts/index"; import { WalletProvider, RLNImplementationProvider, KeystoreProvider, RLNProvider } from "../contexts/index";
import { Header } from "../components/Header"; import { Header } from "../components/Header";
import { AppStateProvider } from "../contexts/AppStateContext"; import { AppStateProvider } from "../contexts/AppStateContext";
import { Footer } from "@/components/Footer";
const inter = Inter({ export const fontSans = FontSans({
variable: "--font-inter",
subsets: ["latin"], subsets: ["latin"],
variable: "--font-sans",
}); });
export const metadata: Metadata = { export const fontMono = FontMono({
subsets: ["latin"],
variable: "--font-mono",
});
export const metadata = {
title: "Waku Keystore Management", title: "Waku Keystore Management",
description: "Manage your Waku RLN keystores securely", description: "A simple application to manage Waku RLN keystores",
}; };
export default function RootLayout({ interface RootLayoutProps {
children,
}: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }
export default function RootLayout({ children }: RootLayoutProps) {
return ( return (
<html lang="en"> <html lang="en" suppressHydrationWarning>
<head />
<body <body
className={`${inter.variable} antialiased`} className={cn(
"min-h-screen bg-background font-sans antialiased",
fontSans.variable,
fontMono.variable,
"circuit-bg"
)}
> >
<AppStateProvider> <ThemeProvider
<WalletProvider> attribute="class"
<RLNImplementationProvider> defaultTheme="dark"
<KeystoreProvider> enableSystem={false}
<RLNProvider> storageKey="waku-keystore-theme"
<div className="flex flex-col min-h-screen"> >
<Header /> <AppStateProvider>
<main className="flex-grow"> <WalletProvider>
{children} <RLNImplementationProvider>
</main> <KeystoreProvider>
</div> <RLNProvider>
</RLNProvider> <div className="relative flex min-h-screen flex-col">
</KeystoreProvider> <Header />
</RLNImplementationProvider> <main className="flex-1 container mx-auto py-8">
</WalletProvider> {children}
</AppStateProvider> </main>
<Footer />
</div>
<Toaster />
</RLNProvider>
</KeystoreProvider>
</RLNImplementationProvider>
</WalletProvider>
</AppStateProvider>
</ThemeProvider>
</body> </body>
</html> </html>
); );

View File

@ -0,0 +1,100 @@
import Link from "next/link";
import { Github } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function Footer() {
return (
<footer className="w-full border-t border-terminal-border bg-terminal-background/80 backdrop-blur-sm mt-auto">
<div className="container mx-auto py-8">
<div className="flex flex-col items-center justify-center space-y-8">
{/* Waku Section */}
<div className="flex flex-col items-center space-y-4">
<Link
href="https://waku.org"
target="_blank"
className="flex items-center space-x-3 px-4 py-2 rounded-md transition-all duration-300 hover:bg-terminal-background/50"
>
<span className="text-primary font-mono text-xl font-bold glow-text">
Waku
</span>
</Link>
{/* Community Links */}
<div className="flex items-center space-x-6">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href="https://discord.waku.org"
target="_blank"
className="text-muted-foreground hover:text-primary transition-colors duration-200"
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M20.317 4.492c-1.53-.69-3.17-1.2-4.885-1.49a.075.075 0 0 0-.079.036c-.21.369-.444.85-.608 1.23a18.566 18.566 0 0 0-5.487 0 12.36 12.36 0 0 0-.617-1.23A.077.077 0 0 0 8.562 3c-1.714.29-3.354.8-4.885 1.491a.07.07 0 0 0-.032.027C.533 9.093-.32 13.555.099 17.961a.08.08 0 0 0 .031.055 20.03 20.03 0 0 0 5.993 2.98.078.078 0 0 0 .084-.026 13.83 13.83 0 0 0 1.226-1.963.074.074 0 0 0-.041-.104 13.201 13.201 0 0 1-1.872-.878.075.075 0 0 1-.008-.125c.126-.093.252-.19.372-.287a.075.075 0 0 1 .078-.01c3.927 1.764 8.18 1.764 12.061 0a.075.075 0 0 1 .079.009c.12.098.245.195.372.288a.075.075 0 0 1-.006.125c-.598.344-1.22.635-1.873.877a.075.075 0 0 0-.041.105c.36.687.772 1.341 1.225 1.962a.077.077 0 0 0 .084.028 19.963 19.963 0 0 0 6.002-2.981.076.076 0 0 0 .032-.054c.5-5.094-.838-9.52-3.549-13.442a.06.06 0 0 0-.031-.028zM8.02 15.278c-1.182 0-2.157-1.069-2.157-2.38 0-1.312.956-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.956 2.38-2.157 2.38zm7.975 0c-1.183 0-2.157-1.069-2.157-2.38 0-1.312.955-2.38 2.157-2.38 1.21 0 2.176 1.077 2.157 2.38 0 1.312-.946 2.38-2.157 2.38z"/>
</svg>
</Link>
</TooltipTrigger>
<TooltipContent>
<p className="font-mono text-xs">Join our Discord community for support & feedback</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Link
href="https://github.com/waku-org"
target="_blank"
className="text-muted-foreground hover:text-primary transition-colors duration-200"
>
<Github className="w-5 h-5" />
</Link>
<Link
href="https://docs.waku.org"
target="_blank"
className="text-muted-foreground hover:text-primary transition-colors duration-200 font-mono text-sm"
>
Docs
</Link>
<Link
href="https://github.com/waku-org/specs"
target="_blank"
className="text-muted-foreground hover:text-primary transition-colors duration-200 font-mono text-sm"
>
Specs
</Link>
</div>
</div>
{/* Organizational Structure */}
<div className="flex items-center justify-center space-x-8 text-muted-foreground">
<Link
href="https://logos.co"
target="_blank"
className="font-mono text-sm opacity-60 hover:opacity-100 transition-opacity duration-300"
>
Logos
</Link>
<span className="text-muted-foreground/40"></span>
<Link
href="https://free.technology"
target="_blank"
className="font-mono text-sm opacity-60 hover:opacity-100 transition-opacity duration-300"
>
IFT
</Link>
</div>
</div>
</div>
</footer>
);
}

View File

@ -1,16 +1,19 @@
"use client"; "use client";
import React from 'react'; import { WalletDropdown } from "./WalletDropdown";
import { WalletInfo } from './WalletInfo';
export function Header() { export function Header() {
return ( return (
<header className="bg-gray-900 border-b border-gray-800"> <header className="sticky top-0 z-50 w-full border-b border-terminal-border bg-terminal-background/80 backdrop-blur-sm">
<div className="container mx-auto px-6 h-16 flex justify-between items-center"> <div className="container mx-auto flex h-16 items-center justify-between py-4">
<div className="flex items-center"> <div className="font-mono text-lg font-bold">
<h1 className="text-xl font-medium text-white">Waku Keystore Management</h1> <span className="text-primary glitch">Waku Keystore</span>{" "}
<span className="text-foreground opacity-80">Management</span>
</div>
<div className="flex items-center space-x-4">
<WalletDropdown />
</div> </div>
<WalletInfo />
</div> </div>
</header> </header>
); );

View File

@ -0,0 +1,87 @@
import { useState } from "react";
import { useKeystore } from "@/contexts/keystore";
import { toast } from "sonner";
import { ArrowUpToLine } from "lucide-react";
import { Button } from "./ui/button";
export function KeystoreExporter() {
const [showPasswordInput, setShowPasswordInput] = useState(false);
const [password, setPassword] = useState("");
const [isExporting, setIsExporting] = useState(false);
const { exportEntireKeystore, hasStoredCredentials } = useKeystore();
const handleExport = async () => {
if (!password) {
toast.error("Password is required");
return;
}
try {
setIsExporting(true);
await exportEntireKeystore(password);
toast.success("Keystore exported successfully");
setPassword("");
setShowPasswordInput(false);
setIsExporting(false);
} catch (error) {
setIsExporting(false);
toast.error("Failed to export keystore: " + (error instanceof Error ? error.message : String(error)));
}
};
return (
<div className="space-y-3">
{!showPasswordInput ? (
<Button
onClick={() => setShowPasswordInput(true)}
variant="terminal"
className="group relative overflow-hidden"
disabled={!hasStoredCredentials}
>
<span className="relative z-10 flex items-center">
<ArrowUpToLine className="w-4 h-4 mr-2" />
Export Keystore
</span>
<span className="absolute inset-0 bg-primary/10 transform translate-y-full group-hover:translate-y-0 transition-transform duration-200"></span>
</Button>
) : (
<div className="animate-in fade-in-50 duration-300 space-y-3">
<div className="flex flex-col space-y-3 sm:flex-row sm:space-y-0 sm:space-x-3">
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter keystore password"
className="flex-1 rounded-sm border border-terminal-border bg-background/80 px-3 py-2 font-mono text-xs text-terminal-text placeholder:text-terminal-text/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
disabled={isExporting}
autoFocus
/>
<div className="flex space-x-2">
<Button
onClick={handleExport}
variant="terminal"
disabled={!password || isExporting}
className="whitespace-nowrap"
>
{isExporting ? "Exporting..." : "Export"}
</Button>
<Button
onClick={() => {
setShowPasswordInput(false);
setPassword("");
}}
variant="outline"
className="text-muted-foreground hover:text-muted-foreground"
disabled={isExporting}
>
Cancel
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -1,10 +1,10 @@
"use client"; "use client";
import React, { Children, isValidElement } from 'react'; import React, { Children, isValidElement } from 'react';
import { TabItem, TabNavigation } from './Tabs/TabNavigation'; import { useAppState } from '../contexts/AppStateContext';
import { useAppState } from '@/contexts/AppStateContext'; import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
const tabs: TabItem [] = [ const tabs = [
{ {
id: 'membership', id: 'membership',
label: 'Membership Registration', label: 'Membership Registration',
@ -24,28 +24,47 @@ export function Layout({ children }: { children: React.ReactNode }) {
const { activeTab, setActiveTab } = useAppState(); const { activeTab, setActiveTab } = useAppState();
const childrenArray = Children.toArray(children); const childrenArray = Children.toArray(children);
const getTabContent = (tabId: string) => {
return childrenArray.find((child) => {
if (isValidElement(child) && typeof child.type === 'function') {
const componentName = child.type.name;
return componentToTabId[componentName] === tabId;
}
return false;
});
};
return ( return (
<div className="min-h-screen bg-white dark:bg-gray-900"> <div className="w-full">
<main className="container mx-auto px-4 py-6"> <div className="container mx-auto px-4 py-6 max-w-7xl">
<TabNavigation <div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-1 gap-6">
tabs={tabs} <div className="col-span-1">
activeTab={activeTab} <Tabs
onTabChange={setActiveTab} value={activeTab}
/> onValueChange={setActiveTab}
<div className="mt-6"> className="w-full"
{childrenArray.map((child) => { >
if (isValidElement(child) && typeof child.type === 'function') { <TabsList className="w-full justify-start max-w-md">
const componentName = child.type.name; {tabs.map((tab) => (
const tabId = componentToTabId[componentName]; <TabsTrigger
key={tab.id}
value={tab.id}
className="px-6"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
if (tabId === activeTab) { {tabs.map((tab) => (
return child; <TabsContent key={tab.id} value={tab.id} className="mt-6">
} {getTabContent(tab.id)}
} </TabsContent>
return null; ))}
})} </Tabs>
</div>
</div> </div>
</main> </div>
</div> </div>
); );
} }

View File

@ -1,42 +1,38 @@
"use client"; "use client";
import { useRLNImplementation } from '@/contexts'; import { useRLNImplementation } from '../contexts';
import React from 'react'; import React from 'react';
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
export function RLNImplementationToggle() { export function RLNImplementationToggle() {
const { implementation, setImplementation } = useRLNImplementation(); const { implementation, setImplementation } = useRLNImplementation();
return ( return (
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-3">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300"> <label className="text-sm font-mono text-muted-foreground">
RLN Implementation RLN Implementation
</label> </label>
<div className="flex space-x-4">
<button <ToggleGroup
onClick={() => setImplementation('light')} type="single"
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ value={implementation}
implementation === 'light' onValueChange={(value) => {
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300' if (value) setImplementation(value as 'light' | 'standard');
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700' }}
}`} className="w-full max-w-md"
> >
<ToggleGroupItem value="light" className="flex-1">
Light Light
</button> </ToggleGroupItem>
<button <ToggleGroupItem value="standard" className="flex-1">
onClick={() => setImplementation('standard')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
implementation === 'standard'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
Standard Standard
</button> </ToggleGroupItem>
</div> </ToggleGroup>
<p className="text-xs text-gray-500 dark:text-gray-400">
<p className="text-xs font-mono text-muted-foreground opacity-80">
{implementation === 'light' {implementation === 'light'
? 'Light implementation, without Zerokit. Instant initalisation.' ? 'Light implementation, without Zerokit. Instant initialisation.'
: 'Standard implementation, with Zerokit. Initialisation takes 10-15 seconds for WASM module' : 'Standard implementation, with Zerokit. Initialisation takes 10-15 seconds for WASM module.'
} }
</p> </p>
</div> </div>

View File

@ -1,7 +1,8 @@
"use client"; "use client";
import { useRLN } from '@/contexts'; import { useRLN } from '../contexts';
import React from 'react'; import React from 'react';
import { Button } from './ui/button';
export function RLNInitButton() { export function RLNInitButton() {
const { initializeRLN, isInitialized, isStarted, error, isLoading } = useRLN(); const { initializeRLN, isInitialized, isStarted, error, isLoading } = useRLN();
@ -14,49 +15,30 @@ export function RLNInitButton() {
} }
}; };
const getButtonText = () => {
if (isLoading) return 'Initializing...';
if (isInitialized && isStarted) return 'RLN Initialized';
return 'Initialize RLN';
};
return ( return (
<div className="flex flex-col items-start gap-2"> <div className="flex flex-col gap-2 font-mono">
<button <Button
onClick={handleInitialize} onClick={handleInitialize}
disabled={isLoading || (isInitialized && isStarted)} disabled={isLoading || (isInitialized && isStarted)}
className={` variant={isInitialized && isStarted ? "default" : "terminal"}
px-4 py-2 rounded-lg font-medium transition-colors relative className="w-full sm:w-auto font-mono"
${
isLoading
? 'bg-gray-200 text-gray-500 cursor-not-allowed dark:bg-gray-700 dark:text-gray-400'
: isInitialized && isStarted
? 'bg-green-600 text-white cursor-default dark:bg-green-500'
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
}
`}
> >
{isLoading && ( {isLoading && (
<span className="absolute left-2 top-1/2 -translate-y-1/2"> <svg className="mr-2 h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<svg className="animate-spin h-5 w-5 text-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> </svg>
</svg>
</span>
)} )}
{isInitialized && isStarted && ( {isInitialized && isStarted && (
<span className="absolute left-2 top-1/2 -translate-y-1/2"> <svg className="mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> </svg>
</svg>
</span>
)} )}
<span className={(isLoading || (isInitialized && isStarted)) ? 'pl-7' : ''}> {isLoading ? 'Initializing...' : (isInitialized && isStarted) ? 'RLN Initialized' : 'Initialize RLN'}
{getButtonText()} </Button>
</span>
</button>
{error && ( {error && (
<p className="text-sm text-red-600 dark:text-red-400"> <p className="text-xs text-destructive">
{error} {error}
</p> </p>
)} )}

View File

@ -1,11 +1,15 @@
"use client"; "use client";
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useKeystore } from '@/contexts/keystore'; import { useKeystore } from '../../../contexts/keystore';
import { useRLN } from '@/contexts/rln'; import { readKeystoreFromFile, saveKeystoreCredentialToFile } from '../../../utils/keystore';
import { saveKeystoreToFile, readKeystoreFromFile } from '../../../utils/file';
import { DecryptedCredentials } from '@waku/rln'; import { DecryptedCredentials } from '@waku/rln';
import { useAppState } from '@/contexts/AppStateContext'; import { useAppState } from '../../../contexts/AppStateContext';
import { TerminalWindow } from '../../ui/terminal-window';
import { Button } from '../../ui/button';
import { Copy, Eye, Download, Trash2, ArrowDownToLine } from 'lucide-react';
import { KeystoreExporter } from '../../KeystoreExporter';
import { keystoreManagement, type ContentSegment } from '../../../content/index';
export function KeystoreManagement() { export function KeystoreManagement() {
const { const {
@ -18,13 +22,13 @@ export function KeystoreManagement() {
getDecryptedCredential getDecryptedCredential
} = useKeystore(); } = useKeystore();
const { setGlobalError } = useAppState(); const { setGlobalError } = useAppState();
const { isInitialized, isStarted } = useRLN();
const [exportPassword, setExportPassword] = useState<string>(''); const [exportPassword, setExportPassword] = useState<string>('');
const [selectedCredential, setSelectedCredential] = useState<string | null>(null); const [selectedCredential, setSelectedCredential] = useState<string | null>(null);
const [viewPassword, setViewPassword] = useState<string>(''); const [viewPassword, setViewPassword] = useState<string>('');
const [viewingCredential, setViewingCredential] = useState<string | null>(null); const [viewingCredential, setViewingCredential] = useState<string | null>(null);
const [decryptedInfo, setDecryptedInfo] = useState<DecryptedCredentials | null>(null); const [decryptedInfo, setDecryptedInfo] = useState<DecryptedCredentials | null>(null);
const [isDecrypting, setIsDecrypting] = useState(false); const [isDecrypting, setIsDecrypting] = useState(false);
const [copiedHash, setCopiedHash] = useState<string | null>(null);
React.useEffect(() => { React.useEffect(() => {
if (error) { if (error) {
@ -32,14 +36,14 @@ export function KeystoreManagement() {
} }
}, [error, setGlobalError]); }, [error, setGlobalError]);
const handleExportCredential = async (hash: string) => { const handleExportKeystoreCredential = async (hash: string) => {
try { try {
if (!exportPassword) { if (!exportPassword) {
setGlobalError('Please enter your keystore password to export'); setGlobalError('Please enter your keystore password to export');
return; return;
} }
const keystoreJson = await exportCredential(hash, exportPassword); const keystore = await exportCredential(hash, exportPassword);
saveKeystoreToFile(keystoreJson, `waku-rln-credential-${hash.slice(0, 8)}.json`); saveKeystoreCredentialToFile(keystore);
setExportPassword(''); setExportPassword('');
setSelectedCredential(null); setSelectedCredential(null);
} catch (err) { } catch (err) {
@ -47,10 +51,10 @@ export function KeystoreManagement() {
} }
}; };
const handleImport = async () => { const handleImportKeystore = async () => {
try { try {
const keystoreJson = await readKeystoreFromFile(); const keystore = await readKeystoreFromFile();
const success = importKeystore(keystoreJson); const success = importKeystore(keystore);
if (!success) { if (!success) {
setGlobalError('Failed to import keystore'); setGlobalError('Failed to import keystore');
} }
@ -97,37 +101,107 @@ export function KeystoreManagement() {
} }
}, [viewingCredential, selectedCredential]); }, [viewingCredential, selectedCredential]);
// Add a function to copy text to clipboard with visual feedback
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
.then(() => {
setCopiedHash(text);
setTimeout(() => setCopiedHash(null), 2000);
})
.catch(err => {
console.error('Failed to copy: ', err);
});
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-white dark:bg-gray-800 shadow sm:rounded-lg p-6"> <TerminalWindow className="w-full">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4"> <h2 className="text-lg font-mono font-medium text-primary mb-4 cursor-blink">
Keystore Management {keystoreManagement.title}
</h2> </h2>
<div className="space-y-6"> <div className="space-y-6">
{/* Import Action */} <div className="flex flex-wrap gap-3">
<div> <Button
<button onClick={handleImportKeystore}
onClick={handleImport} variant="terminal"
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800" className="group relative overflow-hidden"
> >
Import Keystore <span className="relative z-10 flex items-center">
</button> <ArrowDownToLine className="w-4 h-4 mr-2" />
{keystoreManagement.buttons.import}
</span>
<span className="absolute inset-0 bg-primary/10 transform translate-y-full group-hover:translate-y-0 transition-transform duration-200"></span>
</Button>
<KeystoreExporter />
</div> </div>
{/* RLN Status */} {/* Warning - RLN not initialized */}
{!isInitialized || !isStarted ? ( {!hasStoredCredentials && (
<div className="bg-yellow-50 dark:bg-yellow-900 p-4 rounded-lg"> <div className="my-4 p-3 border border-warning-DEFAULT/20 bg-warning-DEFAULT/5 rounded">
<p className="text-sm text-yellow-700 dark:text-yellow-300"> <p className="text-sm text-warning-DEFAULT font-mono flex items-center">
Please initialize RLN before managing credentials <span className="mr-2"></span>
{keystoreManagement.noCredentialsWarning}
</p> </p>
</div> </div>
) : null} )}
{/* About Section */}
<div className="border-t border-terminal-border pt-4 mt-4">
<div className="flex items-center mb-3">
<span className="text-primary font-mono font-medium mr-2">{">"}</span>
<h3 className="text-md font-mono font-semibold text-primary">
{keystoreManagement.infoHeader}
</h3>
</div>
<div className="space-y-3">
{keystoreManagement.about.map((paragraph: ContentSegment[], i: number) => (
<p key={i} className="text-sm text-foreground mb-2 opacity-90">
{paragraph.map((segment: ContentSegment, j: number) => (
segment.type === 'link' ? (
<a
key={j}
href={segment.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{segment.content}
</a>
) : (
<span key={j}>{segment.content}</span>
)
))}
</p>
))}
</div>
</div>
{/* Resources Section */}
<div className="border-t border-terminal-border pt-4">
<h3 className="text-md font-mono font-semibold text-primary mb-3">
{keystoreManagement.resources.title}
</h3>
<div className="flex flex-wrap gap-3">
{keystoreManagement.resources.links.map((link: { name: string; url: string }, i: number) => (
<a
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline text-sm"
>
{link.name}
</a>
))}
</div>
</div>
{/* Stored Credentials */} {/* Stored Credentials */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6"> <div className="border-t border-terminal-border pt-6">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4"> <h3 className="text-sm font-mono font-medium text-muted-foreground mb-4">
Stored Credentials {keystoreManagement.storedCredentialsTitle}
</h3> </h3>
{hasStoredCredentials ? ( {hasStoredCredentials ? (
@ -135,116 +209,149 @@ export function KeystoreManagement() {
{storedCredentialsHashes.map((hash) => ( {storedCredentialsHashes.map((hash) => (
<div <div
key={hash} key={hash}
className="p-4 rounded-lg border border-gray-200 dark:border-gray-700" className="p-4 rounded-md border border-terminal-border bg-terminal-background/30 hover:border-terminal-border/80 transition-colors"
> >
<div className="flex flex-col space-y-3"> <div className="flex flex-col space-y-3">
<div className="flex items-start justify-between"> <div className="flex items-center justify-between">
<code className="text-sm text-gray-600 dark:text-gray-400 break-all"> <div className="flex items-center space-x-2">
{hash} <code className="text-sm text-muted-foreground font-mono">
</code> {hash.slice(0, 10)}...{hash.slice(-6)}
<div className="flex space-x-2"> </code>
<button <Button
variant="ghost"
size="sm"
className={`h-6 w-6 p-0 ${copiedHash === hash ? 'text-success-DEFAULT' : 'text-muted-foreground hover:text-primary'}`}
onClick={() => copyToClipboard(hash)}
>
<Copy className="h-3.5 w-3.5" />
</Button>
{copiedHash === hash && (
<span className="text-xs text-success-DEFAULT">Copied!</span>
)}
</div>
<div className="flex items-center space-x-2">
<Button
onClick={() => { onClick={() => {
setViewingCredential(hash === viewingCredential ? null : hash); setViewingCredential(hash === viewingCredential ? null : hash);
setSelectedCredential(null); setSelectedCredential(null);
setViewPassword(''); setViewPassword('');
setDecryptedInfo(null); setDecryptedInfo(null);
}} }}
className="text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300" variant="outline"
size="sm"
className="text-accent hover:text-accent hover:border-accent flex items-center gap-1 py-1"
> >
View <Eye className="w-3 h-3" />
</button> <span>{keystoreManagement.buttons.view}</span>
<button </Button>
<Button
onClick={() => { onClick={() => {
setSelectedCredential(hash === selectedCredential ? null : hash); setSelectedCredential(hash === selectedCredential ? null : hash);
setViewingCredential(null); setViewingCredential(null);
setExportPassword(''); setExportPassword('');
}} }}
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" variant="outline"
size="sm"
className="text-primary hover:text-primary hover:border-primary flex items-center gap-1 py-1"
> >
Export <Download className="w-3 h-3" />
</button> <span>Export</span>
<button </Button>
<Button
onClick={() => handleRemoveCredential(hash)} onClick={() => handleRemoveCredential(hash)}
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" variant="outline"
size="sm"
className="text-destructive hover:text-destructive hover:border-destructive flex items-center gap-1 py-1"
> >
Remove <Trash2 className="w-3 h-3" />
</button> <span>Remove</span>
</Button>
</div> </div>
</div> </div>
{/* View Credential Section */} {/* View Credential Section */}
{viewingCredential === hash && ( {viewingCredential === hash && (
<div className="mt-2 space-y-2"> <div className="mt-3 space-y-3 border-t border-terminal-border/40 pt-3 animate-in fade-in-50 duration-300">
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="password" type="password"
value={viewPassword} value={viewPassword}
onChange={(e) => setViewPassword(e.target.value)} onChange={(e) => setViewPassword(e.target.value)}
placeholder="Enter keystore password" placeholder="Enter credential password"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" className="flex-1 px-3 py-2 border border-terminal-border rounded-md bg-terminal-background text-foreground font-mono focus:ring-1 focus:ring-accent focus:border-accent text-sm"
disabled={isDecrypting} disabled={isDecrypting}
/> />
<button <Button
onClick={() => handleViewCredential(hash)} onClick={() => handleViewCredential(hash)}
disabled={!viewPassword || isDecrypting} disabled={!viewPassword || isDecrypting}
className={`px-4 py-2 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 ${ variant="terminal"
!viewPassword || isDecrypting size="sm"
? 'bg-gray-300 text-gray-500 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400'
: 'bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500'
}`}
> >
{isDecrypting ? 'Decrypting...' : 'Decrypt'} {isDecrypting ? 'Decrypting...' : 'Decrypt'}
</button> </Button>
</div> </div>
{/* Decrypted Information Display */} {/* Decrypted Information Display */}
{decryptedInfo && ( {decryptedInfo && (
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-700 rounded-md"> <div className="mt-3 space-y-2 border-t border-terminal-border/40 pt-3 animate-in fade-in-50 duration-300">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <div className="flex items-center mb-2">
Decrypted Credential Information <span className="text-primary font-mono font-medium mr-2">{">"}</span>
</h4> <h3 className="text-sm font-mono font-semibold text-primary">
<div className="space-y-2"> Credential Details
<pre className="text-xs text-gray-600 dark:text-gray-400 whitespace-pre-wrap bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto max-h-60"> </h3>
<div className="grid grid-cols-1 gap-1"> </div>
<div className="flex flex-col"> <div className="space-y-2 text-xs font-mono">
<span className="font-semibold">ID Commitment:</span> <div className="grid grid-cols-1 gap-2">
<span className="break-all">{decryptedInfo.identity.IDCommitment}</span> <div className="flex flex-col">
</div> <span className="text-muted-foreground">ID Commitment:</span>
<div className="flex flex-col"> <div className="flex items-center mt-1">
<span className="font-semibold">ID Nullifier:</span> <span className="break-all text-accent truncate">{decryptedInfo.identity.IDCommitment}</span>
<span className="break-all">{decryptedInfo.identity.IDNullifier}</span> <Button
</div> variant="ghost"
<div className="flex flex-col"> size="sm"
<span className="font-semibold">ID Secret Hash:</span> className="h-5 w-5 p-0 ml-1 text-muted-foreground hover:text-accent"
<span className="break-all">{decryptedInfo.identity.IDSecretHash}</span> onClick={() => copyToClipboard(decryptedInfo.identity.IDCommitment.toString())}
</div> >
<div className="flex flex-col"> <Copy className="h-3 w-3" />
<span className="font-semibold">Membership Address:</span> </Button>
<span className="break-all">{decryptedInfo.membership.address}</span>
</div>
<div className="flex flex-col">
<span className="font-semibold">Chain ID:</span>
<span>{decryptedInfo.membership.chainId}</span>
</div>
<div className="flex flex-col">
<span className="font-semibold">Tree Index:</span>
<span>{decryptedInfo.membership.treeIndex}</span>
</div>
<div className="flex flex-col">
<span className="font-semibold">Rate Limit:</span>
<span>{decryptedInfo.membership.rateLimit}</span>
</div> </div>
</div> </div>
<div className="flex flex-col border-t border-terminal-border/20 pt-2">
{/* {JSON.stringify(decryptedInfo, null, 2)} */} <span className="text-muted-foreground">ID Nullifier:</span>
</pre> <div className="flex items-center mt-1">
<button <span className="break-all text-accent truncate">{decryptedInfo.identity.IDNullifier}</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 ml-1 text-muted-foreground hover:text-accent"
onClick={() => copyToClipboard(decryptedInfo.identity.IDNullifier.toString())}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex flex-col border-t border-terminal-border/20 pt-2">
<span className="text-muted-foreground">Membership Details:</span>
<div className="grid grid-cols-2 gap-4 mt-2">
<div>
<span className="text-muted-foreground text-xs">Chain ID:</span>
<div className="text-accent">{decryptedInfo.membership.chainId}</div>
</div>
<div>
<span className="text-muted-foreground text-xs">Rate Limit:</span>
<div className="text-accent">{decryptedInfo.membership.rateLimit}</div>
</div>
</div>
</div>
</div>
<Button
onClick={() => setDecryptedInfo(null)} onClick={() => setDecryptedInfo(null)}
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300" variant="ghost"
size="sm"
className="mt-2 text-xs text-muted-foreground hover:text-accent"
> >
Hide Details Hide Details
</button> </Button>
</div> </div>
</div> </div>
)} )}
@ -253,20 +360,24 @@ export function KeystoreManagement() {
{/* Export Credential Section */} {/* Export Credential Section */}
{selectedCredential === hash && ( {selectedCredential === hash && (
<div className="mt-2 space-y-2"> <div className="mt-3 space-y-3 border-t border-terminal-border/40 pt-3 animate-in fade-in-50 duration-300">
<input <input
type="password" type="password"
value={exportPassword} value={exportPassword}
onChange={(e) => setExportPassword(e.target.value)} onChange={(e) => setExportPassword(e.target.value)}
placeholder="Enter keystore password" placeholder="Enter keystore password"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" className="w-full px-3 py-2 border border-terminal-border rounded-md bg-terminal-background text-foreground font-mono focus:ring-1 focus:ring-primary focus:border-primary text-sm"
/> />
<button <Button
onClick={() => handleExportCredential(hash)} onClick={() => handleExportKeystoreCredential(hash)}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800" variant="default"
size="sm"
className="w-full font-mono"
disabled={!exportPassword}
> >
<Download className="w-3 h-3 mr-1" />
Export Credential Export Credential
</button> </Button>
</div> </div>
)} )}
</div> </div>
@ -274,13 +385,13 @@ export function KeystoreManagement() {
))} ))}
</div> </div>
) : ( ) : (
<p className="text-sm text-gray-500 dark:text-gray-400"> <div className="text-sm text-muted-foreground font-mono bg-terminal-background/30 p-4 border border-terminal-border/50 rounded-md text-center">
No credentials stored No credentials stored
</p> </div>
)} )}
</div> </div>
</div> </div>
</div> </TerminalWindow>
</div> </div>
); );
} }

View File

@ -3,10 +3,14 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { RLNImplementationToggle } from '../../RLNImplementationToggle'; import { RLNImplementationToggle } from '../../RLNImplementationToggle';
import { KeystoreEntity } from '@waku/rln'; import { KeystoreEntity } from '@waku/rln';
import { useAppState } from '@/contexts/AppStateContext'; import { useAppState } from '../../../contexts/AppStateContext';
import { useRLN } from '@/contexts/rln/RLNContext'; import { useRLN } from '../../../contexts/rln/RLNContext';
import { useWallet } from '@/contexts/wallet'; import { useWallet } from '../../../contexts/wallet';
import { RLNInitButton } from '@/components/RLNinitButton'; import { RLNInitButton } from '../../RLNinitButton';
import { TerminalWindow } from '../../ui/terminal-window';
import { Slider } from '../../ui/slider';
import { Button } from '../../ui/button';
import { membershipRegistration, type ContentSegment } from '../../../content/index';
export function MembershipRegistration() { export function MembershipRegistration() {
const { setGlobalError } = useAppState(); const { setGlobalError } = useAppState();
@ -34,11 +38,6 @@ export function MembershipRegistration() {
} }
}, [error, setGlobalError]); }, [error, setGlobalError]);
const handleRateLimitChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value);
setRateLimit(isNaN(value) ? rateMinLimit : value);
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -100,165 +99,180 @@ export function MembershipRegistration() {
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-6 max-w-full">
<div className="bg-white dark:bg-gray-800 shadow sm:rounded-lg p-6"> <TerminalWindow className="w-full">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4"> <h2 className="text-lg font-mono font-medium text-primary mb-4 cursor-blink">
RLN Membership Registration {membershipRegistration.title}
</h2> </h2>
<div className="space-y-6"> <div className="space-y-6">
<div className="border-b border-gray-200 dark:border-gray-700 pb-6"> <div className="border-b border-terminal-border pb-6">
<RLNImplementationToggle /> <RLNImplementationToggle />
</div> </div>
{/* Network Warning */} {/* Network Warning */}
{isConnected && !isLineaSepolia && ( {isConnected && !isLineaSepolia && (
<div className="bg-orange-50 dark:bg-orange-900 p-4 rounded-lg"> <div className="mb-4 p-3 border border-destructive/20 bg-destructive/5 rounded">
<p className="text-sm text-orange-700 dark:text-orange-400"> <p className="text-sm text-destructive font-mono flex items-center">
<strong>Warning:</strong> You are not connected to Linea Sepolia network. Please switch networks to register. <span className="mr-2"></span>
<span>{membershipRegistration.networkWarning}</span>
</p> </p>
</div> </div>
)} )}
{/* Informational Box */} {/* Informational Box */}
<div className="bg-blue-50 dark:bg-blue-900 p-4 rounded-lg"> <div className="border-t border-terminal-border pt-4 mt-4">
<h3 className="text-md font-semibold text-blue-800 dark:text-blue-300 mb-2"> <div className="flex items-center mb-3">
About RLN Membership on Linea Sepolia <span className="text-primary font-mono font-medium mr-2">{">"}</span>
</h3> <h3 className="text-md font-mono font-semibold text-primary">
<p className="text-sm text-blue-700 dark:text-blue-400 mb-2"> {membershipRegistration.infoHeader}
RLN (Rate Limiting Nullifier) membership allows you to participate in Waku RLN Relay with rate limiting protection, </h3>
without exposing your private keys on your node. </div>
</p>
<p className="text-sm text-blue-700 dark:text-blue-400 mb-2"> <div className="space-y-3">
This application is configured to use the <strong>Linea Sepolia</strong> testnet for RLN registrations. <h4 className="text-md font-mono font-semibold text-primary cursor-blink">
</p> {membershipRegistration.aboutTitle}
<p className="text-sm text-blue-700 dark:text-blue-400"> </h4>
When you register, your wallet will sign a message that will be used to generate a cryptographic identity {membershipRegistration.about.map((paragraph: ContentSegment[], i: number) => (
for your membership. This allows your node to prove it has permission to send messages without revealing your identity. <p key={i} className="text-sm text-foreground mb-2 opacity-90">
</p> {paragraph.map((segment: ContentSegment, j: number) => (
segment.type === 'link' ? (
<a
key={j}
href={segment.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{segment.content}
</a>
) : (
<span key={j}>{segment.content}</span>
)
))}
</p>
))}
</div>
</div> </div>
<div className="flex items-center space-x-2"> <div className="border-t border-terminal-border pt-6 mt-4">
<RLNInitButton /> <div className="flex items-center space-x-2">
</div> <RLNInitButton />
</div>
{!isConnected ? ( {!isConnected ? (
<div className="text-amber-600 dark:text-amber-400"> <div className="text-warning-DEFAULT font-mono text-sm mt-4 flex items-center">
Please connect your wallet to register a membership <span className="mr-2"></span>
</div> {membershipRegistration.connectWalletPrompt}
) : !isInitialized || !isStarted ? (
<div className="text-amber-600 dark:text-amber-400">
Please initialize RLN before registering a membership
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="rateLimit"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Rate Limit (messages per epoch)
</label>
<div className="flex items-center space-x-4">
<input
type="range"
id="rateLimit"
name="rateLimit"
min={rateMinLimit}
max={rateMaxLimit}
value={rateLimit}
onChange={handleRateLimitChange}
className="w-full"
/>
<span className="text-sm text-gray-600 dark:text-gray-400 w-12">
{rateLimit}
</span>
</div>
</div> </div>
) : !isInitialized || !isStarted ? (
<div> <div className="text-warning-DEFAULT font-mono text-sm mt-4 flex items-center">
<div className="flex items-center mb-2"> <span className="mr-2"></span>
<input {membershipRegistration.initializePrompt}
type="checkbox" </div>
id="saveToKeystore" ) : (
checked={saveToKeystore} <form onSubmit={handleSubmit} className="space-y-4 mt-4">
onChange={(e) => setSaveToKeystore(e.target.checked)} <div>
className="h-4 w-4 text-blue-600 rounded border-gray-300" <label
/> htmlFor="rateLimit"
<label className="block text-sm font-mono text-muted-foreground mb-2"
htmlFor="saveToKeystore"
className="ml-2 text-sm text-gray-700 dark:text-gray-300"
> >
Save credentials to keystore {membershipRegistration.form.rateLimitLabel}
</label> </label>
</div> <div className="flex items-center space-x-4 py-2">
{saveToKeystore && ( <Slider
<div> id="rateLimit"
<label min={rateMinLimit}
htmlFor="keystorePassword" max={rateMaxLimit}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" value={[rateLimit]}
> onValueChange={(value) => setRateLimit(value[0])}
Keystore Password (min 8 characters) className="w-full"
</label>
<input
type="password"
id="keystorePassword"
value={keystorePassword}
onChange={(e) => setKeystorePassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
placeholder="Enter password to encrypt credentials"
/> />
<span className="text-sm text-muted-foreground w-12 font-mono">
{rateLimit}
</span>
</div> </div>
)} </div>
</div>
<button <div className="space-y-2">
type="submit" <div className="flex items-center mb-2">
disabled={isRegistering || !isLineaSepolia || (saveToKeystore && !keystorePassword)} <input
className={`w-full px-4 py-2 text-sm font-medium rounded-md ${ type="checkbox"
isRegistering || !isLineaSepolia || (saveToKeystore && !keystorePassword) id="saveToKeystore"
? 'bg-gray-300 text-gray-500 cursor-not-allowed' checked={saveToKeystore}
: 'bg-blue-600 text-white hover:bg-blue-700' onChange={(e) => setSaveToKeystore(e.target.checked)}
}`} className="h-4 w-4 rounded bg-terminal-background border-terminal-border text-primary focus:ring-primary"
> />
{isRegistering ? 'Registering...' : 'Register Membership'} <label
</button> htmlFor="saveToKeystore"
</form> className="ml-2 text-sm font-mono text-foreground"
)} >
{membershipRegistration.form.saveToKeystoreLabel}
</label>
</div>
{saveToKeystore && (
<div>
<label
htmlFor="keystorePassword"
className="block text-sm font-mono text-muted-foreground mb-2"
>
{membershipRegistration.form.passwordLabel}
</label>
<input
type="password"
id="keystorePassword"
value={keystorePassword}
onChange={(e) => setKeystorePassword(e.target.value)}
placeholder={membershipRegistration.form.passwordPlaceholder}
className="w-full px-3 py-2 bg-terminal-background border border-terminal-border rounded-md text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
)}
</div>
<Button
type="submit"
disabled={isRegistering}
className="w-full"
>
{isRegistering ? membershipRegistration.form.registeringButton : membershipRegistration.form.registerButton}
</Button>
</form>
)}
</div>
{/* Registration Result */} {/* Registration Result */}
{registrationResult.warning && ( {registrationResult.warning && (
<div className="mt-4 p-4 bg-yellow-50 dark:bg-yellow-900 rounded-lg"> <div className="mt-4 p-3 border border-warning-DEFAULT/20 bg-warning-DEFAULT/5 rounded">
<p className="text-sm text-yellow-700 dark:text-yellow-400"> <p className="text-sm text-warning-DEFAULT font-mono flex items-center">
<span className="mr-2"></span>
{registrationResult.warning} {registrationResult.warning}
</p> </p>
</div> </div>
)} )}
{registrationResult.error && ( {registrationResult.error && (
<div className="mt-4 p-4 bg-red-50 dark:bg-red-900 rounded-lg"> <div className="mt-4 p-3 border border-destructive/20 bg-destructive/5 rounded">
<p className="text-sm text-red-700 dark:text-red-400"> <p className="text-sm text-destructive font-mono flex items-center">
<span className="mr-2"></span>
{registrationResult.error} {registrationResult.error}
</p> </p>
</div> </div>
)} )}
{registrationResult.success && ( {registrationResult.success && (
<div className="mt-4 p-4 bg-green-50 dark:bg-green-900 rounded-lg"> <div className="mt-4 p-3 border border-success-DEFAULT/20 bg-success-DEFAULT/5 rounded">
<p className="text-sm text-green-700 dark:text-green-400 mb-2"> <p className="text-sm text-success-DEFAULT font-mono mb-2 flex items-center">
Membership registered successfully! <span className="mr-2"></span>
Membership registered successfully!
</p> </p>
{registrationResult.txHash && ( {registrationResult.txHash && (
<p className="text-xs text-green-600 dark:text-green-500"> <p className="text-xs text-success-DEFAULT font-mono opacity-80 break-all">
Transaction Hash: {registrationResult.txHash} Transaction Hash: {registrationResult.txHash}
</p> </p>
)} )}
{registrationResult.keystoreHash && (
<p className="text-xs text-green-600 dark:text-green-500">
Credentials saved to keystore with hash: {registrationResult.keystoreHash}
</p>
)}
</div> </div>
)} )}
</div> </div>
</div> </TerminalWindow>
</div> </div>
); );
} }

View File

@ -1,40 +0,0 @@
"use client";
import React from 'react';
export type TabItem = {
id: string;
label: string;
};
interface TabNavigationProps {
tabs: TabItem[];
activeTab: string;
onTabChange: (tabId: string) => void;
}
export function TabNavigation({ tabs, activeTab, onTabChange }: TabNavigationProps) {
return (
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="flex space-x-8" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
py-4 px-1 border-b-2 font-medium text-sm
${
activeTab === tab.id
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}
`}
aria-current={activeTab === tab.id ? 'page' : undefined}
>
{tab.label}
</button>
))}
</nav>
</div>
);
}

View File

@ -0,0 +1,75 @@
"use client";
import React from 'react';
import { useWallet } from '../contexts/wallet';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from '../components/ui/dropdown-menu';
import { Button } from '../components/ui/button';
import { ChevronDown } from 'lucide-react';
export function WalletDropdown() {
const { isConnected, address, chainId, connectWallet, disconnectWallet, balance } = useWallet();
if (!isConnected || !address) {
return (
<Button
onClick={connectWallet}
variant="terminal"
size="sm"
className="text-xs"
>
Connect Wallet
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="font-mono text-xs border-terminal-border hover:border-primary hover:text-primary"
>
<span
className={`inline-block h-2 w-2 rounded-full mr-1.5 bg-success-DEFAULT`}
/>
<span className="cursor-blink">
{address.slice(0, 6)}...{address.slice(-4)}
</span>
<ChevronDown className="ml-1 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Wallet</DropdownMenuLabel>
<DropdownMenuItem className="text-xs flex justify-between">
<span className="text-muted-foreground">Address:</span>
<span className="text-primary truncate ml-2">{address}</span>
</DropdownMenuItem>
<DropdownMenuItem className="text-xs flex justify-between">
<span className="text-muted-foreground">Network:</span>
<span className="text-primary ml-2">{chainId === 59141 ? "Linea Sepolia" : `Unknown (${chainId})`}</span>
</DropdownMenuItem>
{balance && (
<DropdownMenuItem className="text-xs flex justify-between">
<span className="text-muted-foreground">Balance:</span>
<span className="text-primary ml-2">{parseFloat(balance).toFixed(4)} ETH</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={disconnectWallet}
className="text-destructive focus:text-destructive cursor-pointer"
>
Disconnect
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,173 +0,0 @@
"use client";
import React, { useState, useRef, useEffect } from 'react';
import { useWallet } from '../contexts/index';
function getNetworkName(chainId: number | null): string {
if (!chainId) return 'Unknown';
switch (chainId) {
case 59141: return 'Linea Sepolia (Supported)';
default: return `Unsupported Network (Chain ID: ${chainId})`;
}
}
// Linea Sepolia Chain ID
const LINEA_SEPOLIA_CHAIN_ID = '0xe705'; // 59141 in hex
// Define interface for provider errors
interface ProviderRpcError extends Error {
code: number;
data?: unknown;
}
export function WalletInfo() {
const { isConnected, address, balance, chainId, connectWallet, disconnectWallet, error } = useWallet();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Function to switch to Linea Sepolia network
const switchToLineaSepolia = async () => {
if (!window.ethereum) {
console.error("MetaMask not installed");
return;
}
try {
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: LINEA_SEPOLIA_CHAIN_ID }],
});
} catch (err) {
const providerError = err as ProviderRpcError;
if (providerError.code === 4902) {
try {
await window.ethereum.request({
method: 'wallet_addEthereumChain',
params: [
{
chainId: LINEA_SEPOLIA_CHAIN_ID,
chainName: 'Linea Sepolia Testnet',
nativeCurrency: {
name: 'Linea Sepolia ETH',
symbol: 'ETH',
decimals: 18,
},
rpcUrls: ['https://linea-sepolia.infura.io/v3/', 'https://rpc.sepolia.linea.build'],
blockExplorerUrls: ['https://sepolia.lineascan.build'],
},
],
});
} catch (addError) {
console.error("Error adding Linea Sepolia chain", addError);
}
} else {
console.error("Error switching to Linea Sepolia chain", providerError);
}
}
};
// Check if user is on unsupported network
const isUnsupportedNetwork = isConnected && chainId !== 59141;
if (!isConnected) {
return (
<div className="flex items-center">
<button
onClick={connectWallet}
className="h-7 px-3 text-xs bg-blue-500/20 text-blue-300 rounded hover:bg-blue-500/30 transition-colors"
>
Connect Wallet
</button>
</div>
);
}
return (
<div className="relative" ref={dropdownRef}>
{error && (
<div className="absolute right-0 -top-2 transform -translate-y-full mb-2 px-2 py-1 text-xs bg-red-500/20 text-red-300 rounded whitespace-nowrap">
<p>{error}</p>
</div>
)}
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-2 px-3 h-8 rounded transition-colors ${
isOpen ? 'bg-gray-800' : 'hover:bg-gray-800'
}`}
>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-300 truncate max-w-[140px]">
{address}
</span>
<span className="font-mono text-xs text-gray-400">
{balance ? `${parseFloat(balance).toFixed(4)} ETH` : 'Loading...'}
</span>
</div>
<svg
className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-72 rounded-lg bg-gray-800 shadow-lg border border-gray-700 overflow-hidden">
<div className="p-3">
<div className="flex flex-col gap-2 text-gray-300">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-gray-400">Address:</span>
<span className="font-mono text-xs truncate">{address}</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-gray-400">Network:</span>
<span className={`font-mono text-xs ${isUnsupportedNetwork ? 'text-orange-400' : ''}`}>
{getNetworkName(chainId)}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-gray-400">Balance:</span>
<span className="font-mono text-xs">
{balance ? `${parseFloat(balance).toFixed(4)} ETH` : 'Loading...'}
</span>
</div>
</div>
</div>
<div className="border-t border-gray-700 p-2 flex flex-col gap-2">
{isUnsupportedNetwork && (
<button
onClick={switchToLineaSepolia}
className="w-full h-7 px-3 text-xs bg-orange-500/20 text-orange-300 rounded hover:bg-orange-500/30 transition-colors"
>
Switch to Linea Sepolia
</button>
)}
<button
onClick={disconnectWallet}
className="w-full h-7 px-3 text-xs bg-red-500/20 text-red-300 rounded hover:bg-red-500/30 transition-colors"
>
Disconnect
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,10 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
type ThemeProviderProps = Parameters<typeof NextThemesProvider>[0];
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -0,0 +1,63 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center font-mono rounded-sm text-sm whitespace-nowrap transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-glow-sm shadow-primary hover:shadow-glow hover:shadow-primary border border-primary/30",
secondary:
"bg-secondary text-secondary-foreground shadow-glow-sm shadow-secondary hover:shadow-glow hover:shadow-secondary border border-secondary/30",
accent:
"bg-accent text-accent-foreground shadow-glow-sm shadow-accent hover:shadow-glow hover:shadow-accent border border-accent/30",
destructive:
"bg-destructive text-destructive-foreground shadow-glow-sm shadow-destructive hover:shadow-glow hover:shadow-destructive border border-destructive/30",
outline:
"border border-primary text-foreground bg-transparent hover:shadow-glow-sm hover:shadow-primary",
terminal:
"bg-terminal-background border border-terminal-border text-terminal-text hover:shadow-glow-sm hover:shadow-terminal-text relative overflow-hidden",
ghost:
"hover:bg-muted hover:text-foreground",
link:
"text-primary hover:text-primary/80 underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-8 px-3 text-xs",
lg: "h-12 px-6 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,77 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "../../lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-terminal-border bg-terminal-background p-1 shadow-md terminal-window",
"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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-mono outline-none focus:bg-primary/10 focus:text-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-xs font-mono text-muted-foreground", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-terminal-border", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
};

View File

@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "../../lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-sm border border-primary bg-terminal-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 shadow-glow-sm shadow-primary" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@ -0,0 +1,59 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "../../lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex items-center justify-center border-b border-muted p-1 font-mono text-muted-foreground mb-4",
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap py-3 px-4 text-sm font-mono transition-all relative",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"data-[state=active]:text-primary data-[state=active]:after:content-[''] data-[state=active]:after:absolute data-[state=active]:after:bottom-0 data-[state=active]:after:left-0 data-[state=active]:after:right-0 data-[state=active]:after:h-[2px] data-[state=active]:after:bg-primary",
"hover:text-primary/80",
"data-[state=active]:before:content-['>_'] data-[state=active]:before:mr-1 data-[state=active]:before:text-primary data-[state=active]:before:animate-blink",
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import { cn } from "../../lib/utils";
interface TerminalWindowProps extends React.HTMLAttributes<HTMLDivElement> {
title?: string;
variant?: "default" | "primary" | "success" | "warning" | "error";
children: React.ReactNode;
}
const TerminalWindow = React.forwardRef<HTMLDivElement, TerminalWindowProps>(
({ className, children, title, variant = "default", ...props }, ref) => {
const variantClasses = {
default: "border-terminal-border",
primary: "border-primary",
success: "border-success-DEFAULT",
warning: "border-warning-DEFAULT",
error: "border-destructive",
};
return (
<div
className={cn(
"terminal-window",
variantClasses[variant],
className
)}
ref={ref}
{...props}
>
{/* Terminal header */}
<div className="terminal-header">
<div className="flex items-center space-x-1">
<div className="h-3 w-3 rounded-full bg-destructive/80"></div>
<div className="h-3 w-3 rounded-full bg-warning/80"></div>
<div className="h-3 w-3 rounded-full bg-success/80"></div>
</div>
{title && (
<div className="text-xs font-mono text-muted-foreground truncate">
{title}
</div>
)}
<div className="w-4"></div> {/* Spacer for alignment */}
</div>
{/* Terminal content */}
<div className="terminal-content">
{children}
<div className="scan-line" />
</div>
</div>
);
}
);
TerminalWindow.displayName = "TerminalWindow";
export { TerminalWindow };

View File

@ -0,0 +1,28 @@
"use client";
import { toast as sonnerToast } from "sonner";
type ToastProps = {
title?: string;
message: string;
type?: "default" | "success" | "error" | "warning";
};
export function toast({ title, message, type = "default" }: ToastProps) {
const displayTitle = title || (
type === "success" ? "Operation Successful" :
type === "error" ? "Error Detected" :
type === "warning" ? "Warning" :
"System Notification"
);
if (type === "success") {
return sonnerToast.success(displayTitle, { description: message });
} else if (type === "error") {
return sonnerToast.error(displayTitle, { description: message });
} else if (type === "warning") {
return sonnerToast.warning(displayTitle, { description: message });
} else {
return sonnerToast(displayTitle, { description: message });
}
}

View File

@ -0,0 +1,31 @@
"use client";
import { Toaster as Sonner } from "sonner";
export function Toaster() {
return (
<Sonner
theme="dark"
position="top-right"
closeButton
richColors
className="font-mono"
toastOptions={{
classNames: {
toast:
"terminal-window group terminal-content border-terminal-border bg-terminal-background text-terminal-text text-sm relative",
title: "text-terminal-text font-mono",
description: "text-terminal-text/80 font-mono text-xs",
actionButton:
"bg-primary text-primary-foreground shadow-glow-sm shadow-primary hover:shadow-glow hover:shadow-primary border border-primary/30",
cancelButton:
"bg-muted text-muted-foreground border border-muted hover:bg-muted/80",
success:
"group-[]:border-success-DEFAULT group-[]:text-success-DEFAULT",
error: "group-[]:border-destructive group-[]:text-destructive-DEFAULT",
warning: "group-[]:border-warning-DEFAULT group-[]:text-warning-DEFAULT",
},
}}
/>
);
}

View File

@ -0,0 +1,67 @@
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const toggleGroupVariants = cva(
"inline-flex bg-terminal-background border border-terminal-border rounded-md p-1 font-mono text-sm",
{
variants: {
variant: {
default: "bg-terminal-background",
outline: "bg-background",
},
},
defaultVariants: {
variant: "default",
},
}
);
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleGroupVariants>
>(({ className, variant, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn(toggleGroupVariants({ variant }), className)}
{...props}
/>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const toggleGroupItemVariants = cva(
"inline-flex items-center justify-center px-3 py-2 rounded transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"data-[state=on]:bg-primary/20 data-[state=on]:text-primary data-[state=on]:shadow-[0_0_5px] data-[state=on]:shadow-primary hover:text-primary/80",
outline:
"data-[state=on]:bg-primary data-[state=on]:text-primary-foreground hover:bg-muted hover:text-primary",
},
},
defaultVariants: {
variant: "default",
},
}
);
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleGroupItemVariants>
>(({ className, variant, ...props }, ref) => (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(toggleGroupItemVariants({ variant }), className)}
{...props}
/>
));
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

@ -0,0 +1,28 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-terminal-background px-3 py-1.5 text-xs text-primary animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border border-terminal-border",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,98 @@
export type ContentSegment = {
type: 'text' | 'link';
content: string;
url?: string;
};
export type Paragraph = ContentSegment[];
// Content for RLN Membership Registration
export const membershipRegistration = {
title: "RLN Membership Registration",
aboutTitle: "About RLN Membership on Linea Sepolia",
about: [
[
{ type: 'text', content: 'RLN (' },
{ type: 'link', content: 'Rate Limiting Nullifier', url: 'https://github.com/Rate-Limiting-Nullifier' },
{ type: 'text', content: ') membership allows you to participate in ' },
{ type: 'link', content: 'Waku RLN Relay', url: 'https://blog.waku.org/explanation-series-rln-relay/' },
{ type: 'text', content: ' with rate limiting protection, without exposing your private keys on your node.' }
],
[
{ type: 'text', content: 'This application is configured to use the ' },
{ type: 'link', content: 'Linea Sepolia', url: 'https://sepolia.lineascan.build/address/0xb9cd878c90e49f797b4431fbf4fb333108cb90e6' },
{ type: 'text', content: ' testnet for RLN registrations.' }
],
[
{ type: 'text', content: 'When you register, your wallet will sign a message that will be used to generate a ' },
{ type: 'link', content: 'cryptographic identity', url: 'https://github.com/waku-org/specs/blob/master/standards/application/rln-keystore.md' },
{ type: 'text', content: ' for your membership. This allows your node to prove it has permission to send messages without revealing your identity.' }
]
] as Paragraph[],
infoHeader: "RLN Membership Info",
connectWalletPrompt: "Please connect your wallet to register a membership",
initializePrompt: "Please initialize RLN before registering a membership",
networkWarning: "You are not connected to Linea Sepolia network. Please switch networks to register.",
form: {
rateLimitLabel: "Rate Limit (messages per epoch)",
saveToKeystoreLabel: "Save credentials to keystore",
passwordLabel: "Keystore Password (min 8 characters)",
passwordPlaceholder: "Enter password to encrypt credentials",
registerButton: "Register Membership",
registeringButton: "Registering..."
}
};
// Content for Keystore Management
export const keystoreManagement = {
title: "Keystore Management",
buttons: {
import: "Import Keystore",
export: "Export Keystore",
view: "View",
decrypt: "Decrypt",
decrypting: "Decrypting...",
remove: "Remove"
},
about: [
[
{ type: 'text', content: 'Keystore management allows you to securely store, import, export and manage your ' },
{ type: 'link', content: 'RLN membership credentials', url: 'https://github.com/waku-org/specs/blob/master/standards/application/rln-keystore.md' },
{ type: 'text', content: '.' }
],
[
{ type: 'text', content: 'Credentials are encrypted with your password and can be used across different devices or applications. Learn more about ' },
{ type: 'link', content: 'keystore security', url: 'https://github.com/waku-org/specs/blob/master/standards/application/rln-keystore.md#security-considerations' },
{ type: 'text', content: '.' }
],
[
{ type: 'text', content: 'You can export your credentials as a file and import them on another device to use the same membership.' }
]
] as Paragraph[],
infoHeader: "About Keystore Management",
noCredentialsWarning: "Please initialize RLN before managing credentials",
storedCredentialsTitle: "Stored Credentials",
passwordPlaceholder: "Enter credential password",
credentialDetailsTitle: "Credential Details",
resources: {
title: "Resources",
links: [
{
name: "RLN Specs",
url: "https://specs.status.im/spec/waku/rln-v1"
},
{
name: "Waku GitHub",
url: "https://github.com/waku-org/waku-rln-contract"
},
{
name: "Keystore Documentation",
url: "https://github.com/waku-org/specs/blob/master/standards/application/rln-keystore.md"
}
]
}
};

View File

@ -12,8 +12,9 @@ interface KeystoreContextType {
hasStoredCredentials: boolean; hasStoredCredentials: boolean;
storedCredentialsHashes: string[]; storedCredentialsHashes: string[];
saveCredentials: (credentials: KeystoreEntity, password: string) => Promise<string>; saveCredentials: (credentials: KeystoreEntity, password: string) => Promise<string>;
exportCredential: (hash: string, password: string) => Promise<string>; exportCredential: (hash: string, password: string) => Promise<Keystore>;
importKeystore: (keystoreJson: string) => boolean; exportEntireKeystore: (password: string) => Promise<void>;
importKeystore: (keystore: Keystore) => boolean;
removeCredential: (hash: string) => void; removeCredential: (hash: string) => void;
getDecryptedCredential: (hash: string, password: string) => Promise<KeystoreEntity | null>; getDecryptedCredential: (hash: string, password: string) => Promise<KeystoreEntity | null>;
} }
@ -97,7 +98,7 @@ export function KeystoreProvider({ children }: { children: ReactNode }) {
} }
}; };
const exportCredential = async (hash: string, password: string): Promise<string> => { const exportCredential = async (hash: string, password: string): Promise<Keystore> => {
if (!keystore) { if (!keystore) {
throw new Error("Keystore not initialized"); throw new Error("Keystore not initialized");
} }
@ -114,19 +115,54 @@ export function KeystoreProvider({ children }: { children: ReactNode }) {
// Add the credential to the new keystore // Add the credential to the new keystore
await singleCredentialKeystore.addCredential(credential, password); await singleCredentialKeystore.addCredential(credential, password);
console.log("Single credential keystore:", singleCredentialKeystore.toString()); console.log("Single credential keystore:", singleCredentialKeystore.toString());
return singleCredentialKeystore.toString(); return singleCredentialKeystore;
}; };
const importKeystore = (keystoreJson: string): boolean => { const exportEntireKeystore = async (password: string): Promise<void> => {
if (!keystore) {
throw new Error("Keystore not initialized");
}
if (storedCredentialsHashes.length === 0) {
throw new Error("No credentials to export");
}
try { try {
const imported = Keystore.fromString(keystoreJson); // Verify the password works for at least one credential
if (imported) { const firstHash = storedCredentialsHashes[0];
setKeystore(imported); const testCredential = await keystore.readCredential(firstHash, password);
setStoredCredentialsHashes(imported.keys());
localStorage.setItem(LOCAL_STORAGE_KEYSTORE_KEY, keystoreJson); if (!testCredential) {
return true; throw new Error("Invalid password");
} }
return false;
// If password is verified, export the entire keystore
const filename = 'waku-rln-keystore.json';
const blob = new Blob([keystore.toString()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
URL.revokeObjectURL(url);
document.body.removeChild(link);
} catch (err) {
console.error("Error exporting keystore:", err);
throw err;
}
};
const importKeystore = (keystore: Keystore): boolean => {
try {
setKeystore(keystore);
setStoredCredentialsHashes(keystore.keys());
localStorage.setItem(LOCAL_STORAGE_KEYSTORE_KEY, keystore.toString());
return true;
} catch (err) { } catch (err) {
console.error("Error importing keystore:", err); console.error("Error importing keystore:", err);
setError(err instanceof Error ? err.message : "Failed to import keystore"); setError(err instanceof Error ? err.message : "Failed to import keystore");
@ -152,6 +188,7 @@ export function KeystoreProvider({ children }: { children: ReactNode }) {
storedCredentialsHashes, storedCredentialsHashes,
saveCredentials, saveCredentials,
exportCredential, exportCredential,
exportEntireKeystore,
importKeystore, importKeystore,
removeCredential, removeCredential,
getDecryptedCredential getDecryptedCredential

View File

@ -0,0 +1,17 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Combines multiple class names into a single string, using clsx for conditional classes
* and twMerge to handle Tailwind CSS class conflicts
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Converts HSL color values to CSS variables
*/
export function hslToVar(hsl: string): string {
return `hsl(var(${hsl}))`;
}

View File

@ -1,62 +0,0 @@
/**
* Utility functions for handling keystore file operations
*/
/**
* Save a keystore JSON string to a file
* @param keystoreJson The keystore JSON as a string
* @param filename Optional filename (defaults to 'waku-rln-keystore.json')
*/
export const saveKeystoreToFile = (keystoreJson: string, filename: string = 'waku-rln-keystore.json'): void => {
const blob = new Blob([keystoreJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
URL.revokeObjectURL(url);
document.body.removeChild(link);
};
/**
* Read a keystore file and return its content as a string
* @returns Promise resolving to the file content as a string
*/
export const readKeystoreFromFile = (): Promise<string> => {
return new Promise((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.onchange = (event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) {
reject(new Error('No file selected'));
return;
}
const reader = new FileReader();
reader.onload = () => {
const content = reader.result as string;
resolve(content);
};
reader.onerror = () => {
reject(new Error('Failed to read file'));
};
reader.readAsText(file);
};
input.click();
});
};

View File

@ -0,0 +1,80 @@
/**
* Utility functions for handling keystore file operations
*/
import { Keystore } from "@waku/rln";
/**
* Save a keystore JSON string to a file
* @param keystoreJson The keystore JSON as a string
* @param filename Optional filename (defaults to 'waku-rln-keystore.json')
*/
export const saveKeystoreCredentialToFile = (keystore: Keystore): void => {
let filename: string;
if (keystore.keys.length > 1) {
filename = 'waku-rln-keystore.json';
} else {
const slicedHash = keystore.keys()[0].slice(0,8)
filename = `waku-rln-credential-${slicedHash}.json`;
}
const blob = new Blob([keystore.toString()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
URL.revokeObjectURL(url);
document.body.removeChild(link);
};
/**
* Read a keystore file and return it as a Keystore object
* @returns Promise resolving to a Keystore object
*/
export const readKeystoreFromFile = (): Promise<Keystore> => {
return new Promise<Keystore>((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.onchange = (event: Event) => {
const target = event.target as HTMLInputElement;
const file: File | undefined = target.files?.[0];
if (!file) {
reject(new Error('No file selected'));
return;
}
const reader = new FileReader();
reader.onload = () => {
try {
const content = reader.result as string;
const keystore = Keystore.fromString(content);
if (!keystore) {
reject(new Error('Failed to create keystore from file'));
return;
}
resolve(keystore);
} catch (e) {
reject(new Error(`Invalid keystore file format: ${e instanceof Error ? e.message : String(e)}`));
}
};
reader.onerror = () => {
reject(new Error('Failed to read file'));
};
reader.readAsText(file);
};
input.click();
});
};

View File

@ -1,4 +1,6 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
import { fontFamily } from "tailwindcss/defaultTheme";
import animatePlugin from "tailwindcss-animate";
export default { export default {
content: [ content: [
@ -6,13 +8,112 @@ export default {
"./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
], ],
darkMode: ["class"],
theme: { theme: {
extend: { extend: {
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
mono: ["var(--font-mono)", ...fontFamily.mono],
},
colors: { colors: {
background: "var(--background)", // Cypherpunk inspired color palette
foreground: "var(--foreground)", background: "hsl(230, 25%, 7%)", // Deep space background
foreground: "hsl(213, 31%, 91%)",
border: "hsl(230, 15%, 20%)",
input: "hsl(230, 15%, 15%)",
// Base accent colors
primary: {
DEFAULT: "hsl(150, 100%, 54%)", // Neon green
foreground: "hsl(230, 25%, 7%)",
muted: "hsla(150, 100%, 54%, 0.2)",
},
secondary: {
DEFAULT: "hsl(280, 100%, 60%)", // Electric purple
foreground: "hsl(230, 25%, 7%)",
muted: "hsla(280, 100%, 60%, 0.2)",
},
accent: {
DEFAULT: "hsl(190, 100%, 50%)", // Digital blue
foreground: "hsl(230, 25%, 7%)",
muted: "hsla(190, 100%, 50%, 0.2)",
},
// Status colors
success: {
DEFAULT: "hsl(142, 76%, 50%)",
muted: "hsla(142, 76%, 50%, 0.2)",
},
warning: {
DEFAULT: "hsl(38, 100%, 50%)",
muted: "hsla(38, 100%, 50%, 0.2)",
},
destructive: {
DEFAULT: "hsl(0, 100%, 55%)",
muted: "hsla(0, 100%, 55%, 0.2)",
foreground: "hsl(210, 40%, 98%)",
},
// UI component colors
muted: {
DEFAULT: "hsl(230, 15%, 25%)",
foreground: "hsl(213, 20%, 65%)",
},
card: {
DEFAULT: "hsl(229, 20%, 10%)",
foreground: "hsl(213, 31%, 91%)",
},
popover: {
DEFAULT: "hsl(229, 20%, 10%)",
foreground: "hsl(213, 31%, 91%)",
},
// Specialized elements
terminal: {
background: "hsl(230, 25%, 5%)",
border: "hsl(150, 100%, 54%, 0.3)",
text: "hsl(150, 100%, 54%)",
muted: "hsl(150, 100%, 25%)",
},
},
borderRadius: {
lg: "0.5rem",
md: "calc(0.5rem - 2px)",
sm: "calc(0.5rem - 4px)",
},
boxShadow: {
glow: "0 0 10px var(--tw-shadow-color), 0 0 20px var(--tw-shadow-color)",
"glow-sm": "0 0 5px var(--tw-shadow-color), 0 0 10px var(--tw-shadow-color)",
},
keyframes: {
"scan-line": {
"0%": { transform: "translateY(0%)" },
"100%": { transform: "translateY(100%)" },
},
blink: {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0" },
},
"terminal-typing": {
"0%": { width: "0" },
"100%": { width: "100%" },
},
flicker: {
"0%, 100%": { opacity: "1" },
"5%, 25%, 75%, 95%": { opacity: "0.8" },
"10%, 30%, 80%, 90%": { opacity: "0.9" },
"15%, 45%, 65%, 85%": { opacity: "0.85" },
"20%, 40%, 60%, 70%": { opacity: "0.75" },
},
},
animation: {
"scan-line": "scan-line 6s linear infinite",
blink: "blink 1s step-end infinite",
"terminal-typing": "terminal-typing 3s steps(40, end)",
flicker: "flicker 5s linear infinite",
}, },
}, },
}, },
plugins: [], plugins: [animatePlugin],
} satisfies Config; } satisfies Config;