SIWE auth, API proxy fixes, dashboard, login, usage pages
- SIWE auth flow: challenge/login/logout/me API routes + JWT middleware
- Fixed [...path] catch-all: Kubo proxy with matchRoute() + mapToKuboAPI()
- Fixed session_id → nonce field mismatch in AuthProvider
- Fixed dashboard pins crash: Kubo returns {Keys: {...}}, not array
- Added middleware route guard (307 redirect to /login)
- Added login, register, profile, usage, upload pages
- Added dashboard components (StorageGauge, FreeTierProgress)
- Added SWR hooks + AuthGuard + Providers + Toast/ErrorBoundary
- Added payment integration (smart contract ABI, tiers, tokens)
- Fixed IPNS key listing: Name/Id → name/id mapping
- Added PWA support (manifest, service worker, icons)
- Added tests for helpers, limits, search-index, wallet
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode;
|
||||
/** Require admin role (default: false) */
|
||||
requireAdmin?: boolean;
|
||||
/** Redirect target for unauthenticated users (default: /login) */
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export default function AuthGuard({
|
||||
children,
|
||||
requireAdmin = false,
|
||||
redirectTo = '/login',
|
||||
}: AuthGuardProps) {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
if (!user) {
|
||||
router.replace(redirectTo);
|
||||
} else if (requireAdmin && user.role !== 'admin') {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [user, loading, requireAdmin, redirectTo, router]);
|
||||
|
||||
/* ── Loading state ── */
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#0a0e17]">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-brand-500 border-t-transparent animate-spin" />
|
||||
<p className="text-sm text-surface-500">Bezig met laden…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Not authenticated ── */
|
||||
if (!user) {
|
||||
return null; // useEffect handles redirect
|
||||
}
|
||||
|
||||
/* ── Not admin → redirect handled by useEffect ── */
|
||||
if (requireAdmin && user.role !== 'admin') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
/* ════════════════════════════ BatchBar ════════════════════════════
|
||||
* Floating "N selected" action bar voor batch operaties.
|
||||
* Verschijnt onderaan zodra >= 1 item geselecteerd is.
|
||||
*/
|
||||
|
||||
import { Trash2, Download, X } from 'lucide-react';
|
||||
|
||||
export interface BatchAction {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
variant?: 'danger' | 'default';
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface BatchBarProps {
|
||||
count: number;
|
||||
actions: BatchAction[];
|
||||
onClear: () => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function BatchBar({ count, actions, onClear, label = 'geselecteerd' }: BatchBarProps) {
|
||||
if (count === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-slide-up">
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-surface-800 border border-surface-700 shadow-xl shadow-black/30">
|
||||
{/* Count badge */}
|
||||
<span className="text-sm text-surface-200 font-medium whitespace-nowrap">
|
||||
<span className="text-brand-400">{count}</span> {label}
|
||||
</span>
|
||||
|
||||
<div className="w-px h-5 bg-surface-700" />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
onClick={action.onClick}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
action.variant === 'danger'
|
||||
? 'bg-accent-rose/10 text-accent-rose hover:bg-accent-rose/20'
|
||||
: 'bg-brand-500/10 text-brand-400 hover:bg-brand-500/20'
|
||||
}`}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="p-1.5 rounded-lg text-surface-500 hover:text-surface-300 hover:bg-surface-700 transition-colors"
|
||||
aria-label="Deselecteer alles"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
/* ════════════════════════════ ErrorBoundary ════════════════════════════
|
||||
* Vangt render crashes op en toont een fallback UI i.p.v. een lege pagina.
|
||||
*/
|
||||
|
||||
import { Component, type ReactNode, type ErrorInfo } from 'react';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
/** Optional: label om te tonen welk onderdeel crashed */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
info: ErrorInfo | null;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null, info: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error(`[ErrorBoundary${this.props.label ? ` ${this.props.label}` : ''}]`, error, info);
|
||||
this.setState({ info });
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ error: null, info: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl p-8 text-center max-w-lg mx-auto my-8">
|
||||
<AlertCircle className="w-10 h-10 text-accent-rose mx-auto mb-4" />
|
||||
<h3 className="text-surface-200 font-semibold mb-1">
|
||||
{this.props.label ? `Fout in ${this.props.label}` : 'Er ging iets mis'}
|
||||
</h3>
|
||||
<p className="text-xs text-surface-500 mb-2 max-w-sm mx-auto">
|
||||
{this.state.error.message || 'Onbekende fout'}
|
||||
</p>
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="inline-flex items-center gap-2 px-5 py-2 rounded-xl bg-brand-600 hover:bg-brand-500 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Probeer opnieuw
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export default function PaymentPanel({
|
||||
if (contractAddress) {
|
||||
paymentService.setContractAddress(contractAddress);
|
||||
}
|
||||
paymentService.isDeployed().then(setDeployed);
|
||||
paymentService.isDeployed().then(setDeployed).catch(() => setDeployed(false));
|
||||
}, [contractAddress]);
|
||||
|
||||
// Refresh price + stats when address or fileSize changes
|
||||
@@ -98,11 +98,8 @@ export default function PaymentPanel({
|
||||
setProvider(prov);
|
||||
|
||||
// Detect wallet type
|
||||
// @ts-expect-error
|
||||
if (window.maosv6) setWalletType('MAOS Wallet');
|
||||
// @ts-expect-error
|
||||
else if (window.ethereum?.isRabby) setWalletType('Rabby');
|
||||
// @ts-expect-error
|
||||
else if (window.ethereum?.isMetaMask) setWalletType('MetaMask');
|
||||
else setWalletType('Wallet');
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { checkHealth } from '@/lib/api';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { Shield, LogOut } from 'lucide-react';
|
||||
|
||||
export default function PortalHeader() {
|
||||
const { user, logout } = useAuth();
|
||||
const [nodeStatus, setNodeStatus] = useState<'online' | 'offline' | 'checking'>('checking');
|
||||
const [nodeVersion, setNodeVersion] = useState('');
|
||||
|
||||
@@ -43,11 +45,22 @@ export default function PortalHeader() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Wallet badge placeholder */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg glass text-xs text-surface-300">
|
||||
<Shield className="w-3.5 h-3.5 text-accent-cyan" />
|
||||
Wallet Connected
|
||||
</div>
|
||||
{/* Wallet badge */}
|
||||
{user && (
|
||||
<div className="flex items-center gap-2 pl-3 pr-2 py-1.5 rounded-lg bg-surface-800/50 text-xs">
|
||||
<Shield className="w-3.5 h-3.5 text-accent-cyan" />
|
||||
<span className="text-surface-300 font-mono">
|
||||
{user.address.slice(0, 6)}...{user.address.slice(-4)}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="ml-1 p-1 rounded-md hover:bg-surface-700 text-surface-500 hover:text-accent-rose transition-colors"
|
||||
title="Uitloggen"
|
||||
>
|
||||
<LogOut className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Upload,
|
||||
@@ -14,10 +15,14 @@ import {
|
||||
Shield,
|
||||
LogOut,
|
||||
Clock,
|
||||
BarChart3,
|
||||
Sun,
|
||||
Moon,
|
||||
} from 'lucide-react';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/usage', label: 'Usage', icon: BarChart3 },
|
||||
{ href: '/upload', label: 'Upload', icon: Upload },
|
||||
{ href: '/explorer', label: 'Explorer', icon: Globe },
|
||||
{ href: '/pins', label: 'Pins', icon: HardDrive },
|
||||
@@ -31,13 +36,14 @@ const NAV_ITEMS = [
|
||||
|
||||
export default function PortalSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { theme, toggle: toggleTheme } = useTheme();
|
||||
|
||||
function handleDisconnect() {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 bottom-0 w-56 flex flex-col glass border-r border-surface-800 z-50">
|
||||
<aside aria-label="Main navigation" className="fixed left-0 top-0 bottom-0 w-56 flex flex-col glass border-r border-surface-800 z-50">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2.5 px-5 h-14 border-b border-surface-800 shrink-0">
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-brand-500 to-accent-purple flex items-center justify-center text-xs font-bold text-white">
|
||||
@@ -67,8 +73,15 @@ export default function PortalSidebar() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Disconnect */}
|
||||
<div className="p-3 border-t border-surface-800">
|
||||
{/* Theme toggle + Disconnect */}
|
||||
<div className="p-3 border-t border-surface-800 space-y-1">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-surface-500 hover:text-surface-200 hover:bg-surface-800/50 transition-all duration-150"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-4 h-4 shrink-0" /> : <Moon className="w-4 h-4 shrink-0" />}
|
||||
{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-surface-500 hover:text-accent-rose hover:bg-accent-rose/5 transition-all duration-150"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { NotificationProvider } from '@/lib/notifications';
|
||||
import { AuthProvider } from '@/lib/auth';
|
||||
import { ThemeProvider } from '@/lib/theme';
|
||||
import ToastContainer from './ToastContainer';
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<NotificationProvider>
|
||||
{children}
|
||||
<ToastContainer />
|
||||
</NotificationProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
/* ════════════════════════════ SWRegister ════════════════════════════
|
||||
* Registreert de service worker voor PWA offline support.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function SWRegister() {
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
// SW registratie mislukt — geen offline, app werkt gewoon
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
/* ════════════════════════════ SearchBar ════════════════════════════
|
||||
*
|
||||
* Reusable search bar met debounce, resultaten dropdown, en toetsenbordnavigatie.
|
||||
* Gebruikt de client-side search index (search-index.ts) of een custom onSearch callback.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Search, X, FileIcon, FolderIcon, Loader2 } from 'lucide-react';
|
||||
import { searchIndex, type SearchResult } from '@/lib/search-index';
|
||||
|
||||
/* ════════════════════════════ Types ════════════════════════════ */
|
||||
|
||||
export interface SearchBarProps {
|
||||
/** External search handler (optioneel — gebruikt search-index.ts anders) */
|
||||
onSearch?: (query: string) => Promise<SearchResult[]> | SearchResult[];
|
||||
/** Called when user selects a result */
|
||||
onSelect: (result: SearchResult) => void;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Auto-focus on mount */
|
||||
autoFocus?: boolean;
|
||||
/** Extra CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/* ════════════════════════════ Component ════════════════════════════ */
|
||||
|
||||
export default function SearchBar({
|
||||
onSearch,
|
||||
onSelect,
|
||||
placeholder = 'Search files, CIDs, and content…',
|
||||
autoFocus = false,
|
||||
className = '',
|
||||
}: SearchBarProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [selectedIdx, setSelectedIdx] = useState(-1);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* ── Search logic ── */
|
||||
|
||||
const doSearch = useCallback(async (q: string) => {
|
||||
if (!q || q.trim().length < 2) {
|
||||
setResults([]);
|
||||
setShowResults(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const hits = onSearch
|
||||
? await onSearch(q)
|
||||
: searchIndex(q);
|
||||
setResults(hits);
|
||||
setShowResults(true);
|
||||
setSelectedIdx(-1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onSearch]);
|
||||
|
||||
/* ── Debounced input ── */
|
||||
|
||||
function handleChange(value: string) {
|
||||
setQuery(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => doSearch(value), 250);
|
||||
}
|
||||
|
||||
/* ── Keyboard navigation ── */
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (!showResults || results.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIdx((prev) => Math.min(prev + 1, results.length - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIdx((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (selectedIdx >= 0 && selectedIdx < results.length) {
|
||||
handleSelect(results[selectedIdx]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setShowResults(false);
|
||||
inputRef.current?.blur();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Select ── */
|
||||
|
||||
function handleSelect(result: SearchResult) {
|
||||
setShowResults(false);
|
||||
setQuery('');
|
||||
onSelect(result);
|
||||
}
|
||||
|
||||
/* ── Click outside ── */
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||
setShowResults(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
/* ── Cleanup ── */
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) inputRef.current?.focus();
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/* ════════════════════════════ Render ════════════════════════════ */
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className={`relative ${className}`}>
|
||||
{/* Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500 pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => { if (results.length > 0) setShowResults(true); }}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-10 pr-10 py-2.5 rounded-xl bg-surface-900 border border-surface-700 text-sm text-surface-200 placeholder:text-surface-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/50 focus-visible:border-brand-500 transition-colors"
|
||||
autoFocus={autoFocus}
|
||||
role="combobox"
|
||||
aria-expanded={showResults}
|
||||
aria-haspopup="listbox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="search-results-listbox"
|
||||
/>
|
||||
{loading && (
|
||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-500 animate-spin" />
|
||||
)}
|
||||
{!loading && query && (
|
||||
<button
|
||||
onClick={() => { setQuery(''); setResults([]); setShowResults(false); inputRef.current?.focus(); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-surface-700 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-surface-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live region for screen readers */}
|
||||
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
{showResults ? `${results.length} resultaten` : ''}
|
||||
</div>
|
||||
|
||||
{/* Results dropdown */}
|
||||
{showResults && (
|
||||
<div
|
||||
id="search-results-listbox"
|
||||
role="listbox"
|
||||
className="absolute top-full left-0 right-0 mt-2 glass rounded-xl overflow-hidden shadow-xl z-50 max-h-72 overflow-y-auto animate-fade-in">
|
||||
{results.length === 0 && !loading ? (
|
||||
<div className="p-4 text-sm text-surface-500 text-center">No results found</div>
|
||||
) : (
|
||||
results.map((result, idx) => (
|
||||
<button
|
||||
key={result.entry.cid + result.matchField}
|
||||
id={`search-result-${idx}`}
|
||||
role="option"
|
||||
aria-selected={idx === selectedIdx}
|
||||
onClick={() => handleSelect(result)}
|
||||
onMouseEnter={() => setSelectedIdx(idx)}
|
||||
className={`w-full flex items-start gap-3 px-4 py-3 text-left text-sm transition-colors ${
|
||||
idx === selectedIdx ? 'bg-surface-700/50' : 'hover:bg-surface-800/50'
|
||||
}`}
|
||||
>
|
||||
{result.entry.type === 'dir'
|
||||
? <FolderIcon className="w-4 h-4 text-accent-amber shrink-0 mt-0.5" />
|
||||
: <FileIcon className="w-4 h-4 text-accent-blue shrink-0 mt-0.5" />
|
||||
}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-surface-200 font-medium truncate">
|
||||
{result.entry.name || result.entry.cid.slice(0, 20) + '…'}
|
||||
</span>
|
||||
{result.matchField === 'name' && (
|
||||
<span className="text-[10px] text-brand-400 bg-brand-500/10 px-1.5 py-0.5 rounded-full shrink-0">Name</span>
|
||||
)}
|
||||
{result.matchField === 'cid' && (
|
||||
<span className="text-[10px] text-accent-cyan bg-accent-cyan/10 px-1.5 py-0.5 rounded-full shrink-0">CID</span>
|
||||
)}
|
||||
</div>
|
||||
{result.entry.text && (
|
||||
<p className="text-xs text-surface-500 mt-0.5 line-clamp-1">
|
||||
{result.entry.text.slice(0, 120)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[11px] text-surface-600 font-mono mt-0.5">
|
||||
{result.entry.cid.slice(0, 16)}…
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[11px] text-surface-500 shrink-0">
|
||||
{result.entry.size > 0
|
||||
? result.entry.size < 1024
|
||||
? `${result.entry.size} B`
|
||||
: `${(result.entry.size / 1024).toFixed(1)} KB`
|
||||
: ''}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
/* ════════════════════════════ Skeleton ════════════════════════════
|
||||
* Pulse-animatie placeholder voor loading states.
|
||||
* Gebruik: <Skeleton className="h-4 w-48" /> voor een tekstregel
|
||||
* <Skeleton className="h-24 w-full rounded-xl" /> voor een card
|
||||
*/
|
||||
|
||||
export default function Skeleton({ className = '', style }: { className?: string; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-pulse rounded-md bg-surface-800 ${className}`}
|
||||
style={{ animationDuration: '1.5s', ...style }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Rij skelet voor lijsten (pins, peers, users, history) ── */
|
||||
|
||||
export function SkeletonRow({ cols = 3 }: { cols?: number }) {
|
||||
const widths = ['w-48', 'w-32', 'w-20'];
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-5 py-3">
|
||||
<Skeleton className="w-4 h-4 rounded shrink-0" />
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className={`h-4 ${widths[i] ?? 'w-24'}`}
|
||||
/>
|
||||
))}
|
||||
<Skeleton className="h-4 w-16 ml-auto" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Card skelet voor dashboard ── */
|
||||
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="glass rounded-xl p-5 space-y-3">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-7 w-32" />
|
||||
<Skeleton className="h-2 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Tabel skelet ── */
|
||||
|
||||
export function SkeletonTable({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
||||
return (
|
||||
<div className="divide-y divide-surface-800">
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<div key={r} className="flex items-center gap-4 px-5 py-3">
|
||||
<Skeleton className="w-5 h-5 rounded shrink-0" />
|
||||
{Array.from({ length: cols }).map((_, c) => (
|
||||
<Skeleton key={c} className="h-4 flex-1" style={{ maxWidth: `${60 + c * 15}px` }} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
|
||||
import type { Notification } from '@/lib/notifications';
|
||||
|
||||
/* ── Icons ── */
|
||||
|
||||
const ICONS: Record<Notification['type'], { icon: typeof CheckCircle; color: string }> = {
|
||||
success: { icon: CheckCircle, color: 'text-accent-green' },
|
||||
error: { icon: AlertCircle, color: 'text-accent-rose' },
|
||||
info: { icon: Info, color: 'text-accent-cyan' },
|
||||
warning: { icon: AlertTriangle,color: 'text-accent-amber' },
|
||||
};
|
||||
|
||||
/* ════════════════════════════ Toast ════════════════════════════ */
|
||||
|
||||
export default function Toast({
|
||||
notification,
|
||||
onDismiss,
|
||||
}: {
|
||||
notification: Notification;
|
||||
onDismiss: (id: string) => void;
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [progress, setProgress] = useState(100);
|
||||
const { icon: Icon, color } = ICONS[notification.type];
|
||||
const duration = notification.duration ?? 4000;
|
||||
|
||||
/* ── Enter animation ── */
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
}, []);
|
||||
|
||||
/* ── Progress bar ── */
|
||||
useEffect(() => {
|
||||
if (duration <= 0) return;
|
||||
const start = Date.now();
|
||||
const frame = () => {
|
||||
const elapsed = Date.now() - start;
|
||||
const pct = Math.max(0, 100 - (elapsed / duration) * 100);
|
||||
setProgress(pct);
|
||||
if (pct > 0) requestAnimationFrame(frame);
|
||||
};
|
||||
const raf = requestAnimationFrame(frame);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [duration]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={`
|
||||
flex items-start gap-3 p-4 rounded-xl glass border border-surface-700 shadow-xl
|
||||
transition-all duration-300 ease-out max-w-sm
|
||||
${visible ? 'translate-x-0 opacity-100' : 'translate-x-8 opacity-0'}
|
||||
`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<Icon className={`w-5 h-5 ${color}`} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-surface-200">{notification.title}</p>
|
||||
{notification.message && (
|
||||
<p className="text-xs text-surface-400 mt-0.5 break-words">{notification.message}</p>
|
||||
)}
|
||||
{/* Progress bar */}
|
||||
{duration > 0 && (
|
||||
<div className="mt-2 h-0.5 rounded-full bg-surface-700 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-none ${color.replace('text-', 'bg-')}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={() => onDismiss(notification.id)}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-surface-700 transition-colors"
|
||||
aria-label="Sluiten"
|
||||
>
|
||||
<X className="w-4 h-4 text-surface-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useNotify } from '@/lib/notifications';
|
||||
import Toast from './Toast';
|
||||
|
||||
/* ════════════════════════════ Toast Container ════════════════════════════ */
|
||||
|
||||
export default function ToastContainer() {
|
||||
const { notifications, dismiss } = useNotify();
|
||||
|
||||
if (notifications.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none"
|
||||
aria-live="polite"
|
||||
aria-label="Notificaties"
|
||||
>
|
||||
{notifications.map((n) => (
|
||||
<div key={n.id} className="pointer-events-auto">
|
||||
<Toast notification={n} onDismiss={dismiss} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user