feat: Implement AI Chat Agent, Email Notifications, and UI enhancements
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { User } from "@shared/schema";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Loader2, Share2, Check, Search } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ShareTaskModalProps {
|
||||
taskId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ShareTaskModal({ taskId, open, onOpenChange }: ShareTaskModalProps) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
const [userToConfirm, setUserToConfirm] = useState<{ id: string; username: string } | null>(null);
|
||||
|
||||
const { data: users, isLoading: searchLoading } = useQuery<Pick<User, "id" | "username">[]>({
|
||||
queryKey: ["/api/users/search", debouncedQuery],
|
||||
queryFn: async () => {
|
||||
if (debouncedQuery.length < 2) return [];
|
||||
const res = await fetch(`/api/users/search?q=${encodeURIComponent(debouncedQuery)}`);
|
||||
if (!res.ok) throw new Error("Failed to search users");
|
||||
return res.json();
|
||||
},
|
||||
enabled: debouncedQuery.length >= 2,
|
||||
});
|
||||
|
||||
const { data: sharedUsers, refetch: refetchShared, isLoading: sharedLoading } = useQuery<Pick<User, "id" | "username">[]>({
|
||||
queryKey: [`/api/tasks/${taskId}/shared-users`], // Needs to be dependent on taskId
|
||||
enabled: open, // Only fetch when open
|
||||
});
|
||||
|
||||
const shareMutation = useMutation({
|
||||
mutationFn: async (targetUserId: string) => {
|
||||
const res = await apiRequest("POST", `/api/tasks/${taskId}/share`, { targetUserId });
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: t('share.successTask') });
|
||||
setUserToConfirm(null);
|
||||
setSearchQuery("");
|
||||
setDebouncedQuery(""); // Clear search
|
||||
refetchShared(); // Update list
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: t('share.errorTask'), variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const unshareMutation = useMutation({
|
||||
mutationFn: async (targetUserId: string) => {
|
||||
const res = await apiRequest("DELETE", `/api/tasks/${taskId}/share/${targetUserId}`);
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: t('share.removeSuccess') });
|
||||
refetchShared();
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: t('share.errorTask'), variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
// Confirmation State
|
||||
const confirmShare = () => {
|
||||
if (userToConfirm) {
|
||||
shareMutation.mutate(userToConfirm.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(val) => {
|
||||
if (!val) {
|
||||
setUserToConfirm(null); // Reset on close
|
||||
setSearchQuery("");
|
||||
setDebouncedQuery("");
|
||||
}
|
||||
onOpenChange(val);
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Share2 className="w-5 h-5" />
|
||||
{t('share.taskTitle')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{userToConfirm ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="bg-muted/50 p-4 rounded-lg text-center">
|
||||
<Avatar className="h-16 w-16 mx-auto mb-2">
|
||||
<AvatarFallback className="text-lg">{userToConfirm.username.substring(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<h3 className="font-semibold text-lg">{userToConfirm.username}</h3>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('share.confirmShareDesc', { username: userToConfirm.username })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={() => setUserToConfirm(null)}>
|
||||
{t('share.cancel')}
|
||||
</Button>
|
||||
<Button onClick={confirmShare} disabled={shareMutation.isPending}>
|
||||
{shareMutation.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{t('share.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Current Shared Users */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">{t('share.sharedWith')}</h4>
|
||||
<div className="space-y-2">
|
||||
{sharedLoading ? (
|
||||
<div className="flex justify-center py-2"><Loader2 className="w-4 h-4 animate-spin text-muted-foreground" /></div>
|
||||
) : sharedUsers && sharedUsers.length > 0 ? (
|
||||
<div className="max-h-[150px] overflow-y-auto space-y-2 pr-1">
|
||||
{sharedUsers.map(user => (
|
||||
<div key={user.id} className="flex items-center justify-between p-2 bg-muted/40 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="text-xs">{user.username.substring(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm">{user.username}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => unshareMutation.mutate(user.id)}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
<span className="text-xs">{t('share.unshare')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic pl-1">Not shared with anyone yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-x-0 border-t my-2" />
|
||||
</div>
|
||||
|
||||
{/* Search to Add */}
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('share.searchPlaceholder')}
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
// Simple manual debounce for now
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
// Debounce could be improved, but sufficient
|
||||
setDebouncedQuery(searchQuery);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[180px] rounded-md border p-2">
|
||||
{searchLoading && debouncedQuery.length >= 2 ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : users && users.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{users.filter(u => !sharedUsers?.find(su => su.id === u.id)).map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between p-2 hover:bg-muted cursor-pointer rounded-lg transition-colors"
|
||||
onClick={() => setUserToConfirm(user)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>{user.username.substring(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium">{user.username}</span>
|
||||
</div>
|
||||
<Button size="icon" variant="ghost" className="h-8 w-8">
|
||||
<Share2 className="w-4 h-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{users.filter(u => !sharedUsers?.find(su => su.id === u.id)).length === 0 && (
|
||||
<div className="text-center p-4 text-sm text-muted-foreground">
|
||||
No new users found to share with.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : debouncedQuery.length >= 2 ? (
|
||||
<div className="text-center p-4 text-sm text-muted-foreground">
|
||||
{t('share.noUsers')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-4 text-sm text-muted-foreground">
|
||||
{t('share.typeToSearch')}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user