mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-23 00:07:55 +00:00
Reorganize repository into monorepo structure
Restructured the project to support multiple frontend applications: - Move web app to web/ directory - Create pezkuwi-sdk-ui/ for Polkadot SDK clone (planned) - Create mobile/ directory for mobile app development - Add shared/ directory with common utilities, types, and blockchain code - Update README.md with comprehensive documentation - Remove obsolete DKSweb/ directory This monorepo structure enables better code sharing and organized development across web, mobile, and SDK UI projects.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Bell, Check, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'info' | 'success' | 'warning' | 'error' | 'system';
|
||||
read: boolean;
|
||||
action_url?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function NotificationBell() {
|
||||
const { user } = useAuth();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadNotifications();
|
||||
subscribeToNotifications();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const loadNotifications = async () => {
|
||||
if (!user) return;
|
||||
|
||||
const { data } = await supabase
|
||||
.from('notifications')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (data) {
|
||||
setNotifications(data);
|
||||
setUnreadCount(data.filter(n => !n.read).length);
|
||||
}
|
||||
};
|
||||
|
||||
const subscribeToNotifications = () => {
|
||||
const channel = supabase
|
||||
.channel('notifications')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'notifications',
|
||||
filter: `user_id=eq.${user?.id}`,
|
||||
},
|
||||
(payload) => {
|
||||
setNotifications(prev => [payload.new as Notification, ...prev]);
|
||||
setUnreadCount(prev => prev + 1);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
};
|
||||
|
||||
const markAsRead = async (notificationId: string) => {
|
||||
await supabase.functions.invoke('notifications-manager', {
|
||||
body: {
|
||||
action: 'markRead',
|
||||
userId: user?.id,
|
||||
notificationId
|
||||
}
|
||||
});
|
||||
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === notificationId ? { ...n, read: true } : n)
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
await supabase.functions.invoke('notifications-manager', {
|
||||
body: {
|
||||
action: 'markAllRead',
|
||||
userId: user?.id
|
||||
}
|
||||
});
|
||||
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
||||
setUnreadCount(0);
|
||||
};
|
||||
|
||||
const deleteNotification = async (notificationId: string) => {
|
||||
await supabase.functions.invoke('notifications-manager', {
|
||||
body: {
|
||||
action: 'delete',
|
||||
userId: user?.id,
|
||||
notificationId
|
||||
}
|
||||
});
|
||||
|
||||
setNotifications(prev => prev.filter(n => n.id !== notificationId));
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success': return 'text-green-600';
|
||||
case 'warning': return 'text-yellow-600';
|
||||
case 'error': return 'text-red-600';
|
||||
case 'system': return 'text-blue-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h3 className="font-semibold">Notifications</h3>
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={markAllAsRead}
|
||||
>
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="h-96">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
No notifications
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`p-4 hover:bg-muted/50 transition-colors ${
|
||||
!notification.read ? 'bg-muted/20' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className={`font-medium ${getTypeColor(notification.type)}`}>
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{!notification.read && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => markAsRead(notification.id)}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Bell, MessageCircle, AtSign, Heart, Award, TrendingUp, X, Check, Settings } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useWebSocket } from '@/contexts/WebSocketContext';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: 'mention' | 'reply' | 'vote' | 'badge' | 'proposal';
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
read: boolean;
|
||||
actionUrl?: string;
|
||||
sender?: {
|
||||
name: string;
|
||||
avatar: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const NotificationCenter: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { subscribe, unsubscribe } = useWebSocket();
|
||||
const { toast } = useToast();
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [settings, setSettings] = useState({
|
||||
mentions: true,
|
||||
replies: true,
|
||||
votes: true,
|
||||
badges: true,
|
||||
proposals: true,
|
||||
pushEnabled: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Request notification permission
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
|
||||
// Subscribe to WebSocket events
|
||||
const handleMention = (data: any) => {
|
||||
const notification: Notification = {
|
||||
id: Date.now().toString(),
|
||||
type: 'mention',
|
||||
title: 'You were mentioned',
|
||||
message: `${data.sender} mentioned you in a discussion`,
|
||||
timestamp: new Date(),
|
||||
read: false,
|
||||
actionUrl: data.url,
|
||||
sender: data.senderInfo,
|
||||
};
|
||||
addNotification(notification);
|
||||
};
|
||||
|
||||
const handleReply = (data: any) => {
|
||||
const notification: Notification = {
|
||||
id: Date.now().toString(),
|
||||
type: 'reply',
|
||||
title: 'New reply',
|
||||
message: `${data.sender} replied to your comment`,
|
||||
timestamp: new Date(),
|
||||
read: false,
|
||||
actionUrl: data.url,
|
||||
sender: data.senderInfo,
|
||||
};
|
||||
addNotification(notification);
|
||||
};
|
||||
|
||||
subscribe('mention', handleMention);
|
||||
subscribe('reply', handleReply);
|
||||
|
||||
return () => {
|
||||
unsubscribe('mention', handleMention);
|
||||
unsubscribe('reply', handleReply);
|
||||
};
|
||||
}, [subscribe, unsubscribe]);
|
||||
|
||||
const addNotification = (notification: Notification) => {
|
||||
setNotifications(prev => [notification, ...prev]);
|
||||
setUnreadCount(prev => prev + 1);
|
||||
|
||||
// Show toast
|
||||
toast({
|
||||
title: notification.title,
|
||||
description: notification.message,
|
||||
});
|
||||
|
||||
// Show push notification if enabled
|
||||
if (settings.pushEnabled && 'Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification(notification.title, {
|
||||
body: notification.message,
|
||||
icon: '/logo.png',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const markAsRead = (id: string) => {
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.id === id ? { ...n, read: true } : n)
|
||||
);
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const markAllAsRead = () => {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
||||
setUnreadCount(0);
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'mention': return <AtSign className="h-4 w-4" />;
|
||||
case 'reply': return <MessageCircle className="h-4 w-4" />;
|
||||
case 'vote': return <Heart className="h-4 w-4" />;
|
||||
case 'badge': return <Award className="h-4 w-4" />;
|
||||
case 'proposal': return <TrendingUp className="h-4 w-4" />;
|
||||
default: return <Bell className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<Card className="absolute right-0 top-12 w-96 z-50">
|
||||
<Tabs defaultValue="all" className="w-full">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="unread">Unread</TabsTrigger>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsOpen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TabsContent value="all" className="p-0">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{notifications.length} notifications
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={markAllAsRead}>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Mark all read
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="h-96">
|
||||
{notifications.map(notification => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`p-4 border-b hover:bg-accent cursor-pointer ${
|
||||
!notification.read ? 'bg-accent/50' : ''
|
||||
}`}
|
||||
onClick={() => markAsRead(notification.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-full bg-primary/10">
|
||||
{getIcon(notification.type)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">{notification.title}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{new Date(notification.timestamp).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="unread" className="p-0">
|
||||
<ScrollArea className="h-96">
|
||||
{notifications.filter(n => !n.read).map(notification => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="p-4 border-b hover:bg-accent cursor-pointer bg-accent/50"
|
||||
onClick={() => markAsRead(notification.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-full bg-primary/10">
|
||||
{getIcon(notification.type)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">{notification.title}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{notification.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="push">Push Notifications</Label>
|
||||
<Switch
|
||||
id="push"
|
||||
checked={settings.pushEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked && 'Notification' in window) {
|
||||
Notification.requestPermission().then(permission => {
|
||||
setSettings(prev => ({ ...prev, pushEnabled: permission === 'granted' }));
|
||||
});
|
||||
} else {
|
||||
setSettings(prev => ({ ...prev, pushEnabled: checked }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="mentions">Mentions</Label>
|
||||
<Switch
|
||||
id="mentions"
|
||||
checked={settings.mentions}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings(prev => ({ ...prev, mentions: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="replies">Replies</Label>
|
||||
<Switch
|
||||
id="replies"
|
||||
checked={settings.replies}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings(prev => ({ ...prev, replies: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user