feat: enhance audit logging, add MCP settings, and production docker setup
continuous-integration/drone/push Build is passing

- Implemented comprehensive audit logging for Tasks, Users, Settings, Goals, Labels, AI Chat, and Rewards.
- Added Admin UI for MCP Server settings and Audit Logs.
- Created docker-compose-production.yml with Traefik configuration.
- Fixed backend bugs (missing storage methods, route closure).
- Added Audit Logging Guidelines.
This commit is contained in:
2025-12-15 15:53:31 +01:00
parent fdf321cde9
commit d1736c5991
35 changed files with 5165 additions and 499 deletions
+118 -2
View File
@@ -7,12 +7,12 @@ import { Progress } from "@/components/ui/progress";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
import { Trophy, Target, TrendingUp, Plus, CheckCircle2, Circle, Flame } from 'lucide-react';
import { Trophy, Target, TrendingUp, Plus, CheckCircle2, Circle, Flame, Scroll, BookOpen, Hammer, Award, Medal, Star, Crown, Zap, Sparkles, Sun } from 'lucide-react';
import { useState, useCallback } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Goal, Reward, User } from '@shared/schema';
import { getLevelFromXP, getRankKey } from '@/lib/gamification';
import { getLevelFromXP, getRankKey, LEVEL_THRESHOLDS } from '@/lib/gamification';
import { RewardCard } from '@/components/gamification/RewardCard';
import { useToast } from "@/hooks/use-toast";
@@ -174,6 +174,7 @@ export default function AchievementsPage({ user }: { user: User }) {
<TabsTrigger value="rewards">{t('rewards.shopTitle')}</TabsTrigger>
<TabsTrigger value="inventory">{t('achievements.inventory')}</TabsTrigger>
<TabsTrigger value="history">{t('achievements.history')}</TabsTrigger>
<TabsTrigger value="rules">{t('gamification.rules.title')}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
@@ -517,6 +518,121 @@ export default function AchievementsPage({ user }: { user: User }) {
</CardContent>
</Card>
</TabsContent>
<TabsContent value="rules">
<div className="grid gap-6 md:grid-cols-2">
{/* Level Requirements */}
<Card className="border-primary/10 shadow-md">
<CardHeader className="bg-muted/30 pb-4">
<div className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-primary" />
<div>
<CardTitle>{t('gamification.rules.levelRequirements')}</CardTitle>
<CardDescription>{t('gamification.rules.xpSystem')}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y divide-border">
{LEVEL_THRESHOLDS.slice(0, 10).map((threshold, index) => {
const level = index + 1;
const rankKey = getRankKey(level);
const isCurrentLevel = getLevelFromXP(user.xp) === level;
// Visual Configuration for Ranks
const getRankStyle = (l: number) => {
if (l >= 10) return { icon: Sun, color: "text-rose-500", bg: "bg-rose-500/10", border: "border-rose-500/20" };
if (l >= 9) return { icon: Sparkles, color: "text-purple-500", bg: "bg-purple-500/10", border: "border-purple-500/20" };
if (l >= 8) return { icon: Zap, color: "text-violet-500", bg: "bg-violet-500/10", border: "border-violet-500/20" };
if (l >= 7) return { icon: Crown, color: "text-yellow-600", bg: "bg-yellow-600/10", border: "border-yellow-600/20" };
if (l >= 6) return { icon: Star, color: "text-yellow-500", bg: "bg-yellow-500/10", border: "border-yellow-500/20" };
if (l >= 5) return { icon: Medal, color: "text-orange-500", bg: "bg-orange-500/10", border: "border-orange-500/20" };
if (l >= 4) return { icon: Award, color: "text-blue-500", bg: "bg-blue-500/10", border: "border-blue-500/20" };
if (l >= 3) return { icon: Hammer, color: "text-cyan-500", bg: "bg-cyan-500/10", border: "border-cyan-500/20" };
if (l >= 2) return { icon: BookOpen, color: "text-green-500", bg: "bg-green-500/10", border: "border-green-500/20" };
return { icon: Scroll, color: "text-slate-500", bg: "bg-slate-500/10", border: "border-slate-500/20" };
};
const style = getRankStyle(level);
const RankIcon = style.icon;
return (
<div
key={index}
className={`flex items-center justify-between p-4 transition-all hover:bg-muted/50 ${isCurrentLevel ? 'bg-primary/5 ring-1 ring-inset ring-primary/20' : ''}`}
>
<div className="flex items-center gap-4">
<div className={`h-10 w-10 rounded-lg ${style.bg} ${style.color} flex items-center justify-center border ${style.border}`}>
<RankIcon className="h-5 w-5" />
</div>
<div>
<div className={`font-semibold ${isCurrentLevel ? 'text-primary' : ''}`}>
{t(`ranks.${rankKey}`)}
{isCurrentLevel && <span className="ml-2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">{t('gamification.level', { level })}</span>}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
{t('gamification.rules.level', { level })}
</div>
</div>
</div>
<div className="text-right">
<div className="font-mono font-medium text-sm">
{t('gamification.rules.xp', { xp: threshold })}
</div>
{isCurrentLevel && (
<div className="text-[10px] text-primary font-medium mt-0.5">
Current
</div>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* XP Rewards */}
<Card className="border-primary/10 shadow-md h-fit">
<CardHeader className="bg-muted/30 pb-4">
<div className="flex items-center gap-2">
<Target className="h-5 w-5 text-green-500" />
<div>
<CardTitle>{t('gamification.rules.actions')}</CardTitle>
<CardDescription>{t('gamification.rules.xpSystem')}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y divide-border">
{[
{ action: 'createTask', points: 10, icon: Plus, color: 'text-blue-500', bg: 'bg-blue-500/10' },
{ action: 'createSubtask', points: 5, icon: Plus, color: 'text-cyan-500', bg: 'bg-cyan-500/10' },
{ action: 'updateTask', points: 2, icon: CheckCircle2, color: 'text-slate-500', bg: 'bg-slate-500/10' },
{ action: 'completeTask', points: 50, icon: CheckCircle2, color: 'text-green-500', bg: 'bg-green-500/10' },
{ action: 'completeTaskLate', points: 20, icon: CheckCircle2, color: 'text-yellow-500', bg: 'bg-yellow-500/10' },
{ action: 'aiAction', points: 5, icon: Sparkles, color: 'text-purple-500', bg: 'bg-purple-500/10' },
{ action: 'dailyStreak', points: 100, icon: Flame, color: 'text-orange-500', bg: 'bg-orange-500/10' },
].map((item, index) => {
const ActionIcon = item.icon;
return (
<div key={index} className="flex items-center justify-between p-4 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-md ${item.bg} ${item.color}`}>
<ActionIcon className="h-4 w-4" />
</div>
<span className="font-medium text-sm">{t(`gamification.rules.${item.action}`)}</span>
</div>
<div className="font-bold text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/30 px-2 py-1 rounded text-xs">
+{item.points} XP
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs >
</div >
);
+26 -3
View File
@@ -3,8 +3,11 @@ import { SMTPSettingsCard } from "@/components/admin/SMTPSettingsCard";
import { AiSettingsCard } from "@/components/admin/AiSettingsCard";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { ArrowLeft, Shield, Bot, FileText, Settings } from "lucide-react";
import { useLocation } from "wouter";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AuditLogsTable } from "@/components/admin/AuditLogsTable";
import { McpSettingsCard } from "@/components/admin/McpSettingsCard";
export default function AdminSettings() {
const { t } = useTranslation();
@@ -23,8 +26,28 @@ export default function AdminSettings() {
</div>
<div className="grid gap-6">
<AiSettingsCard />
<SMTPSettingsCard />
<Tabs defaultValue="settings" className="space-y-4">
<TabsList>
<TabsTrigger value="settings" className="flex items-center gap-2">
<Settings className="h-4 w-4" />
General & AI
</TabsTrigger>
<TabsTrigger value="audit" className="flex items-center gap-2">
<FileText className="h-4 w-4" />
Audit Logs
</TabsTrigger>
</TabsList>
<TabsContent value="settings" className="space-y-6">
<AiSettingsCard />
<McpSettingsCard />
<SMTPSettingsCard />
</TabsContent>
<TabsContent value="audit">
<AuditLogsTable />
</TabsContent>
</Tabs>
</div>
</div>
);
+652
View File
@@ -0,0 +1,652 @@
import { useState, useEffect, useRef, useLayoutEffect } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Plus, MessageSquare, Trash2, Send, Bot, User as UserIcon, Loader2, Sparkles, AlertCircle, Pencil, Check, ChevronDown, History, X } from "lucide-react";
import { apiRequest } from "@/lib/queryClient";
import { cn } from "@/lib/utils";
import { formatDistanceToNow } from "date-fns";
import { de } from "date-fns/locale";
import { useToast } from "@/hooks/use-toast";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
// Types
type Conversation = {
id: string;
title: string;
updatedAt: string;
};
type Message = {
id: string;
role: 'user' | 'assistant';
content: string;
createdAt: string;
};
const TypewriterMessage = ({ content, onComplete }: { content: string, onComplete?: () => void }) => {
const [displayedContent, setDisplayedContent] = useState("");
const indexRef = useRef(0);
// Optimization: Render faster
const SPEED_MS = 1;
const CHARS_PER_TICK = 5;
useEffect(() => {
indexRef.current = 0;
setDisplayedContent("");
const interval = setInterval(() => {
setDisplayedContent((prev) => {
if (indexRef.current >= content.length) {
clearInterval(interval);
onComplete?.();
return content;
}
const nextSlice = content.slice(indexRef.current, indexRef.current + CHARS_PER_TICK);
indexRef.current += CHARS_PER_TICK;
return prev + nextSlice;
});
}, SPEED_MS);
return () => clearInterval(interval);
}, [content]);
return (
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{displayedContent}</ReactMarkdown>
</div>
);
};
export default function AiChatPage() {
const { t, i18n } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
// 'new' indicates a temporary draft state
const [selectedConversationId, setSelectedConversationId] = useState<string | 'new' | null>(null);
const [input, setInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const [latestMessageId, setLatestMessageId] = useState<string | null>(null);
// Rename state
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [deleteId, setDeleteId] = useState<string | null>(null);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [editMessageContent, setEditMessageContent] = useState("");
// Fetch Conversations
const { data: conversations, isLoading: isLoadingConvs } = useQuery<Conversation[]>({
queryKey: ["/api/ai/conversations"],
});
// Select latest conversation on load if none selected
useEffect(() => {
if (conversations && conversations.length > 0 && !selectedConversationId) {
setSelectedConversationId(conversations[0].id);
}
}, [conversations, selectedConversationId]);
// Fetch Messages for selected conversation (disabled if 'new')
const { data: messages, isLoading: isLoadingMessages } = useQuery<Message[]>({
queryKey: ["/api/ai/conversations", selectedConversationId, "messages"],
enabled: !!selectedConversationId && selectedConversationId !== 'new',
});
const scrollToBottom = () => {
if (scrollRef.current) {
scrollRef.current.scrollIntoView({ behavior: "smooth", block: "end" });
}
}
useLayoutEffect(() => {
scrollToBottom();
}, [messages, selectedConversationId, latestMessageId]);
// Mutations
const createConversationMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest("POST", "/api/ai/conversations", { title: t('ai.newChatDefault', 'New Chat') });
if (!res.ok) throw new Error("Failed to create conversation");
return res.json();
},
onSuccess: (newConv) => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
// Do NOT set selected ID here, handled in handleSend to avoid race conditions or double sets
},
});
const deleteConversationMutation = useMutation({
mutationFn: async (id: string) => {
await apiRequest("DELETE", `/api/ai/conversations/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
if (selectedConversationId !== 'new') {
setSelectedConversationId(null);
}
toast({ title: t('ai.chatDeleted', 'Chat deleted') });
},
});
const renameConversationMutation = useMutation({
mutationFn: async ({ id, title }: { id: string; title: string }) => {
await apiRequest("PATCH", `/api/ai/conversations/${id}`, { title });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
setEditingId(null);
toast({ title: t('ai.chatRenamed', 'Chat renamed') });
},
});
const generateTitleMutation = useMutation({
mutationFn: async ({ messages }: { conversationId: string, messages: any[] }) => {
const res = await apiRequest("POST", "/api/ai/generate-title", { messages });
if (!res.ok) throw new Error("Failed to generate title");
return res.json();
},
onSuccess: (data, vars) => {
renameConversationMutation.mutate({ id: vars.conversationId, title: data.title });
}
});
const sendMessageMutation = useMutation({
mutationFn: async ({ conversationId, content }: { conversationId: string, content: string }) => {
const res = await apiRequest("POST", "/api/ai/chat", {
conversationId,
content,
clientTime: new Date().toLocaleString()
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || t('ai.sendFailed', 'Failed to send message'));
}
return res.json();
},
onMutate: async ({ conversationId, content }) => {
await queryClient.cancelQueries({ queryKey: ["/api/ai/conversations", conversationId, "messages"] });
const previousMessages = queryClient.getQueryData<Message[]>(["/api/ai/conversations", conversationId, "messages"]);
queryClient.setQueryData(["/api/ai/conversations", conversationId, "messages"], (old: Message[] = []) => [
...old,
{ id: 'temp-' + Date.now(), role: 'user', content: content, createdAt: new Date().toISOString() }
]);
const previousInput = input;
setInput("");
return { previousMessages, newContent: previousInput };
},
onError: (err: any, vars, context: any) => {
if (context?.previousMessages) {
queryClient.setQueryData(["/api/ai/conversations", vars.conversationId, "messages"], context.previousMessages);
}
if (context?.newContent) {
setInput(context.newContent);
}
toast({
title: t('ai.errorSending', 'Error sending message'),
description: err.message,
variant: "destructive"
});
},
onSuccess: (botMessage, vars, context) => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations", vars.conversationId, "messages"] });
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations"] });
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
queryClient.invalidateQueries({ queryKey: ["/api/goals"] });
setLatestMessageId(botMessage.id);
// Auto-title if this was the first exchange (previous messages empty or undefined)
if (!context?.previousMessages || context.previousMessages.length === 0) {
const messagesForTitle = [
{ role: 'user', content: vars.content },
{ role: 'assistant', content: botMessage.content }
];
generateTitleMutation.mutate({ conversationId: vars.conversationId, messages: messagesForTitle });
}
},
});
const editMessageMutation = useMutation({
mutationFn: async ({ id, content }: { id: string, content: string }) => {
const res = await apiRequest("PUT", `/api/ai/chat/${id}`, {
content,
clientTime: new Date().toLocaleString()
});
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.error || t('ai.editFailed', 'Failed to edit message'));
}
return res.json();
},
onSuccess: (botMessage, vars) => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/conversations", selectedConversationId, "messages"] });
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] }); // In case tasks were updated during regen
setEditingMessageId(null);
setLatestMessageId(botMessage.id);
toast({ title: t('ai.messageEdited', 'Message edited & answer regenerated') });
},
onError: (err: any) => {
toast({
title: t('ai.errorEditing', 'Error editing message'),
description: err.message,
variant: "destructive"
});
}
});
const handleSend = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!input.trim() || sendMessageMutation.isPending || createConversationMutation.isPending) return;
let conversationId = selectedConversationId;
// If drafting a new chat, create it now
if (conversationId === 'new') {
try {
const newConv = await createConversationMutation.mutateAsync();
conversationId = newConv.id;
setSelectedConversationId(conversationId);
} catch (error) {
toast({
title: t('ai.error', 'Error'),
description: t('ai.createFailed', 'Failed to create new conversation'),
variant: "destructive"
});
return;
}
}
if (conversationId && conversationId !== 'new') {
sendMessageMutation.mutate({ conversationId, content: input });
}
};
const handleCreateNew = () => {
setSelectedConversationId('new');
setInput("");
// No mutation call here.
};
const handleDelete = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
setDeleteId(id);
};
const confirmDelete = () => {
if (deleteId) {
deleteConversationMutation.mutate(deleteId);
setDeleteId(null);
}
};
const startEditing = (conv: Conversation, e: React.MouseEvent) => {
e.stopPropagation();
setEditingId(conv.id);
setEditName(conv.title);
};
const saveName = () => {
if (!editingId || !editName.trim()) {
setEditingId(null);
return;
}
renameConversationMutation.mutate({ id: editingId, title: editName });
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') saveName();
if (e.key === 'Escape') setEditingId(null);
e.stopPropagation(); // prevent closing dropdown
};
const handleStartEditMessage = (msg: Message) => {
setEditingMessageId(msg.id);
setEditMessageContent(msg.content);
};
const handleSaveEditMessage = () => {
if (!editingMessageId || !editMessageContent.trim()) return;
editMessageMutation.mutate({ id: editingMessageId, content: editMessageContent });
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowUp' && !input && !editingMessageId && messages) {
e.preventDefault();
// Find last user message
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
if (lastUserMsg) {
handleStartEditMessage(lastUserMsg);
}
}
};
const currentTitle = selectedConversationId === 'new'
? t('ai.newChat', 'New Chat')
: conversations?.find(c => c.id === selectedConversationId)?.title || t('ai.assistant', 'AI Assistant');
return (
<div className="flex flex-col h-[calc(100vh-4rem)] -m-4 md:-m-6 bg-background relative">
{/* Header / Top Navigation Bar */}
<div className="h-16 border-b flex items-center px-4 md:px-6 justify-between shrink-0 bg-background/80 backdrop-blur-sm z-30 sticky top-0 shadow-sm">
{/* Left: Branding, History & Current Title */}
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<Sparkles className="w-4 h-4 text-violet-500" />
</div>
{/* Logic: If editing current title, show input. Else show Dropdown. */}
{editingId === selectedConversationId ? (
<div className="flex items-center gap-2 flex-1 max-w-[300px]">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={saveName}
autoFocus
className="h-9"
/>
<Button size="icon" variant="ghost" onClick={() => setEditingId(null)}>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<div className="flex items-center gap-2 min-w-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-auto py-2 px-3 font-semibold text-lg flex gap-2 items-center hover:bg-muted/50 transition-colors rounded-lg max-w-full">
<div className="flex flex-col items-start leading-none min-w-0">
<span className="bg-gradient-to-r from-violet-600 to-indigo-600 bg-clip-text text-transparent text-lg truncate max-w-[200px] md:max-w-[400px]">
{currentTitle}
</span>
<span className="text-[10px] text-muted-foreground font-normal flex items-center gap-1">
{t('ai.selectConversation', 'Switch Chat')} <ChevronDown className="w-3 h-3" />
</span>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[300px] max-h-[500px] overflow-y-auto">
<DropdownMenuLabel>{t('ai.recentChats', 'Recent Chats')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{conversations?.map(conv => (
<DropdownMenuItem
key={conv.id}
onClick={() => setSelectedConversationId(conv.id)}
className={cn("flex justify-between items-center cursor-pointer py-3 group", selectedConversationId === conv.id ? "bg-muted" : "")}
>
{editingId === conv.id ? (
<div className="flex items-center gap-2 flex-1 onClick-stop">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
onBlur={saveName}
autoFocus
className="h-8 text-xs"
/>
</div>
) : (
<>
<div className="flex items-center gap-2 overflow-hidden flex-1">
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="truncate font-medium">{conv.title}</span>
</div>
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground"
onClick={(e) => startEditing(conv, e)}
>
<Pencil className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={(e) => handleDelete(conv.id, e)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</>
)}
</DropdownMenuItem>
))}
{(!conversations || conversations.length === 0) && (
<div className="p-4 text-center text-sm text-muted-foreground">
{t('ai.noChats', 'No recent chats')}
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Edit Button: Only show if we have a REAL selected conversation (not 'new') */}
{selectedConversationId && selectedConversationId !== 'new' && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:bg-muted"
onClick={(e) => {
const conv = conversations?.find(c => c.id === selectedConversationId);
if (conv) startEditing(conv, e);
}}
>
<Pencil className="w-4 h-4" />
</Button>
)}
</div>
)}
</div>
{/* Right: New Chat Action */}
<Button onClick={handleCreateNew} size="sm" className="gap-2 shadow-sm rounded-full shrink-0">
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">{t('ai.newChat', 'New Chat')}</span>
</Button>
</div>
{/* Main Chat Area */}
<div className="flex-1 flex flex-col min-w-0 relative overflow-hidden">
{!selectedConversationId ? (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground p-8 text-center animate-in fade-in zoom-in duration-300">
<div className="w-24 h-24 bg-gradient-to-br from-violet-500/10 to-indigo-500/10 rounded-full flex items-center justify-center mb-6 animate-pulse">
<Sparkles className="w-12 h-12 text-violet-500" />
</div>
<h3 className="text-3xl font-bold mb-3 tracking-tight text-foreground">
{t('ai.welcomeTitle', 'How can I help you?')}
</h3>
<p className="max-w-md mb-8 text-lg opacity-80 leading-relaxed">
{t('ai.welcomeDesc', 'I can assist you with your tasks, planning, and more.')}
</p>
<Button size="lg" onClick={handleCreateNew} className="rounded-full px-8 h-12 text-base shadow-lg hover:shadow-xl transition-all">
{t('ai.startChat', 'Start a New Chat')}
</Button>
</div>
) : (
<>
{/* Messages: Render if not 'new', or if 'new' show empty */}
<ScrollArea className="flex-1 px-4 md:px-8 pt-4 md:pt-8 pb-2">
<div className="space-y-8 max-w-3xl mx-auto pb-4">
{selectedConversationId !== 'new' && messages?.map((msg) => (
<div key={msg.id} className={cn("flex gap-4 animate-in slide-in-from-bottom-2 duration-300 group", msg.role === 'user' ? "flex-row-reverse" : "flex-row")}>
<div className={cn(
"w-9 h-9 rounded-full flex items-center justify-center shrink-0 shadow-sm transition-transform group-hover:scale-105",
msg.role === 'user' ? "bg-primary text-primary-foreground" : "bg-gradient-to-br from-violet-500 to-indigo-600 text-white"
)}>
{msg.role === 'user' ? <UserIcon className="w-5 h-5" /> : <Sparkles className="w-4 h-4" />}
</div>
<div className={cn(
"flex flex-col gap-1 min-w-0 max-w-[85%]",
msg.role === 'user' ? "items-end" : "items-start"
)}>
<div className={cn(
"rounded-2xl px-6 py-4 text-sm shadow-sm leading-relaxed relative",
editingMessageId === msg.id ? "w-full min-w-[300px] border-primary ring-2 ring-primary/20" : "",
msg.role === 'user'
? "bg-primary text-primary-foreground rounded-tr-none"
: "bg-background border shadow-md text-foreground rounded-tl-none"
)}>
{editingMessageId === msg.id ? (
<div className="flex flex-col gap-2 w-full">
<textarea
value={editMessageContent}
onChange={(e) => setEditMessageContent(e.target.value)}
className="w-full bg-transparent border-0 focus:ring-0 p-0 text-inherit resize-none min-h-[60px]"
autoFocus
/>
<div className="flex justify-end gap-2 mt-2">
<Button size="sm" variant="secondary" onClick={() => setEditingMessageId(null)} className="h-7 text-xs bg-white/20 hover:bg-white/30 text-inherit border-0">
{t('common.cancel', 'Cancel')}
</Button>
<Button size="sm" onClick={handleSaveEditMessage} disabled={editMessageMutation.isPending} className="h-7 text-xs bg-white text-primary hover:bg-white/90">
{editMessageMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : t('common.save', 'Regenerate')}
</Button>
</div>
</div>
) : (
<>
{/* Edit Button for User Messages */}
{msg.role === 'user' && !editingMessageId && (
<button
onClick={() => handleStartEditMessage(msg)}
className="absolute -left-8 top-1/2 -translate-y-1/2 p-1.5 rounded-full bg-muted/80 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity hover:bg-muted hover:text-foreground"
title={t('common.edit', 'Edit')}
>
<Pencil className="w-3 h-3" />
</button>
)}
{msg.role === 'assistant' ? (
msg.id === latestMessageId ? (
<TypewriterMessage content={msg.content} onComplete={() => scrollToBottom()} />
) : (
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
</div>
)
) : (
<div className="whitespace-pre-wrap">{msg.content}</div>
)}
</>
)}
</div>
{msg.createdAt && (
<span className="text-[10px] text-muted-foreground opacity-0 group-hover:opacity-60 transition-opacity px-2">
{formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true, locale: i18n.language?.startsWith('de') ? de : undefined })}
</span>
)}
</div>
</div>
))}
{(sendMessageMutation.isPending || createConversationMutation.isPending || editMessageMutation.isPending) && (
<div className="flex gap-4 animate-pulse">
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-violet-500 to-indigo-600 flex items-center justify-center shrink-0 opacity-80">
<Sparkles className="w-4 h-4 text-white" />
</div>
<div className="bg-background border rounded-2xl rounded-tl-none px-6 py-4 flex items-center gap-2 shadow-sm">
<span className="flex gap-1.5">
<span className="w-2 h-2 rounded-full bg-violet-500 animate-[bounce_1.4s_infinite] [animation-delay:-0.32s]"></span>
<span className="w-2 h-2 rounded-full bg-indigo-500 animate-[bounce_1.4s_infinite] [animation-delay:-0.16s]"></span>
<span className="w-2 h-2 rounded-full bg-blue-500 animate-[bounce_1.4s_infinite]"></span>
</span>
<span className="text-xs text-muted-foreground font-medium ml-2">{editMessageMutation.isPending ? t('ai.regenerating', 'Regenerating...') : t('ai.thinking', 'Thinking...')}</span>
</div>
</div>
)}
<div ref={scrollRef} className="h-px" />
</div>
</ScrollArea>
{/* Input Area */}
<div className="pt-2 px-4 md:px-6 md:pt-2 pb-6 bg-background/50 backdrop-blur-sm shrink-0">
<div className="max-w-3xl mx-auto space-y-3">
<form
className="relative flex items-center gap-2 bg-muted/40 border rounded-2xl px-4 py-2.5 shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary transition-all hover:bg-muted/60"
onSubmit={handleSend}
>
<Sparkles className="w-5 h-5 text-muted-foreground/70" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleInputKeyDown}
placeholder={t('ai.placeholder', 'Ask me anything about your tasks...')}
className="flex-1 border-0 bg-transparent shadow-none focus-visible:ring-0 px-3 h-11 text-base placeholder:text-muted-foreground/60"
autoFocus
disabled={sendMessageMutation.isPending || createConversationMutation.isPending || editMessageMutation.isPending}
/>
<Button
type="submit"
disabled={!input.trim() || sendMessageMutation.isPending || createConversationMutation.isPending || editMessageMutation.isPending}
size="icon"
className={cn(
"h-9 w-9 rounded-xl shrink-0 transition-all duration-300",
input.trim()
? "opacity-100 scale-100 bg-primary text-primary-foreground shadow-md hover:scale-105"
: "opacity-0 scale-75"
)}
>
<Send className="w-4 h-4" />
</Button>
</form>
<div className="text-center">
<span className="text-[11px] text-muted-foreground/60 flex items-center justify-center gap-1.5">
<AlertCircle className="w-3 h-3" />
{t('ai.disclaimer', 'AI can make mistakes. Verify important information.')}
</span>
</div>
</div>
</div>
</>
)}
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('ai.deleteConfirmTitle', 'Delete Chat')}</AlertDialogTitle>
<AlertDialogDescription>
{t('ai.deleteConfirmDesc', 'Are you sure you want to delete this conversation? This cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel', 'Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{t('common.delete', 'Delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useQuery } from '@tanstack/react-query';
import { Task, Label, User } from '@shared/schema';
import TaskCard from '@/components/TaskCard';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { CalendarOff } from 'lucide-react';
interface UnscheduledTasksProps {
user: User;
onToggleCompletion: (taskId: string, currentStatus: string) => void;
onDelete: (taskId: string) => void;
onUpdate: (taskId: string, updates: Partial<Task>) => void;
onSelect: (task: Task) => void;
}
export default function UnscheduledTasksPage({ user, onToggleCompletion, onDelete, onUpdate, onSelect }: UnscheduledTasksProps) {
const { t } = useTranslation();
const { data: tasks = [] } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
select: (data) => data
.filter(task => !task.dueDate && task.status !== 'done')
.map(task => ({
...task,
dueDate: task.dueDate ? new Date(task.dueDate) : null
}))
});
const { data: labels = [] } = useQuery<Label[]>({
queryKey: ['/api/labels']
});
// We need a dummy toggleTaskCompletion and onDelete for display purposes,
// or we pass the real ones if we lift state up.
// Ideally we use the mutations directly in TaskCard or pass them from App.tsx context.
// For now, let's assume TaskCard handles some, but it takes props.
// Checking TaskCard props... it needs `onToggleCompletion`, `onDelete`, `onUpdate`, `onSelect`.
// This suggests I should wrap this page in App.tsx or pass these handlers down.
// Refactoring: I'll create this component but it might need to accept props from App.tsx
// if I want it to be fully functional without duplicating handler logic.
// Alternatively, I can implement the handlers here using mutations.
return (
<div className="p-6 space-y-6 overflow-y-auto h-full">
<div className="flex items-center gap-3">
<div className="p-3 bg-muted rounded-xl">
<CalendarOff className="w-6 h-6 text-muted-foreground" />
</div>
<div>
<h1 className="text-2xl font-bold">{t('unscheduled.title')}</h1>
<p className="text-muted-foreground">{tasks.length} {t('taskList.taskCount', { count: tasks.length })}</p>
</div>
</div>
{tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center h-[50vh] text-center space-y-4">
<div className="p-6 bg-muted/30 rounded-full">
<CalendarOff className="w-12 h-12 text-muted-foreground/50" />
</div>
<div>
<h3 className="text-lg font-medium">{t('unscheduled.empty')}</h3>
<p className="text-muted-foreground max-w-sm mx-auto mt-2">
{t('taskList.noUnscheduledTasks')}
</p>
</div>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{tasks.map(task => (
<TaskCard
key={task.id}
task={task}
onStatusChange={(status) => onUpdate(task.id, { status })}
onDelete={() => onDelete(task.id)}
onUpdate={(updates) => onUpdate(task.id, updates)}
onEdit={() => onSelect(task)}
/>
))}
</div>
)}
</div>
);
}
+50 -1
View File
@@ -6,7 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Globe, Tag, Plus, Edit, Trash2, Layers, User as UserIcon, ShieldCheck, Share2, Server, Copy, Settings as SettingsIcon } from 'lucide-react';
import { Globe, Tag, Plus, Edit, Trash2, Layers, User as UserIcon, ShieldCheck, Share2, Server, Copy, Settings as SettingsIcon, Bell } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { ShareAccessModal } from '@/components/ShareAccessModal';
import { ChangePasswordModal } from '@/components/ChangePasswordModal';
@@ -17,6 +17,39 @@ import { User } from '@shared/schema';
import { queryClient, apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { useLocation } from "wouter";
import { useNotifications } from '@/hooks/use-notifications';
const NotificationSettings = () => {
const { t } = useTranslation();
const { enabled, toggleEnabled, permission, requestPermission } = useNotifications({ poll: false });
const handleToggle = (checked: boolean) => {
if (checked && permission !== 'granted') {
requestPermission();
} else {
toggleEnabled(checked);
}
};
return (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<p className="font-medium">{t('notifications.enableBrowser')}</p>
<p className="text-sm text-muted-foreground">
{permission === 'denied' ?
<span className="text-destructive">Permission denied by browser. Please reset site permissions.</span> :
t('notifications.description')
}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={permission === 'denied'}
/>
</div>
);
};
interface SettingsProps {
onNavigateToTemplates: () => void;
@@ -228,6 +261,22 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
</>
)}
{/* Notifications Settings */}
<Card data-testid="card-notifications">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="w-5 h-5" />
{t('notifications.title')}
</CardTitle>
<CardDescription>
{t('notifications.description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<NotificationSettings />
</CardContent>
</Card>
{/* Social & Privacy */}
<Card>
<CardHeader>