This commit is contained in:
Marco Pedone 2026-04-22 16:56:14 +02:00
parent 27fa96b698
commit f5d1530683
9 changed files with 0 additions and 1841 deletions

View file

@ -1,196 +0,0 @@
"use client";
import { differenceInDays, differenceInMinutes, format } from "date-fns";
import { Paperclip } from "lucide-react";
import Link from "next/link";
import { ExtIcon } from "~/components/area-riservata/allegati";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTrigger,
} from "~/components/custom_ui/credenza";
import { LoadingPage } from "~/components/loading";
import { Button, buttonVariants } from "~/components/ui/button";
import { UploadComponent } from "~/components/upload_modal";
import { cn } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import type { Session } from "~/server/api/trpc";
import type { ChatUserInfo } from "~/server/services/chat.service";
import { api } from "~/utils/api";
type ChatAttachmentsProps = {
userData: Session;
chatUserData: ChatUserInfo;
};
export const ChatAttachments = ({
userData,
chatUserData,
}: ChatAttachmentsProps) => {
const { t } = useTranslation();
const {
data: attachments,
isLoading: attachmentsLoading,
refetch,
} = api.storage.retrieveUserFileData.useQuery(
{ userId: chatUserData.id },
{ refetchOnMount: true },
);
return (
<Credenza onOpenChange={(o) => o && refetch()}>
<CredenzaTrigger asChild>
<Button
aria-label="Allegati Chat"
className="relative size-9"
size="icon"
variant="ghost"
>
{attachments && attachments.length > 0 && (
<span className="absolute -top-1 right-0 rounded-full bg-blue-500 px-1 font-bold text-white text-xs">
{attachments.length}
</span>
)}
<Paperclip className="text-muted-foreground" size={20} />
</Button>
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh] md:max-w-xl">
<CredenzaHeader>
<CredenzaTitle className="sr-only">Allegati</CredenzaTitle>
<CredenzaDescription className="sr-only">
Chat Attachments
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="max-h-[80vh] space-y-5 overflow-auto pb-5 md:px-1">
<UploadComponent
isAdmin={userData.isAdmin}
userId={chatUserData.id}
/>
<div className="h-auto max-h-160 max-w-lg overflow-auto rounded-lg">
{attachmentsLoading ? (
<LoadingPage />
) : (
<>
{!attachments || attachments.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-center font-semibold text-gray-500 text-lg">
{t.allegati.nessun_allegato}
</p>
</div>
) : (
<ul className="px-2">
<h3 className="font-semibold text-gray-700 text-lg">
{t.allegati.allegati_recenti}
</h3>
{attachments?.map((file, idx) => {
if (idx >= 3) return null;
return (
<li
className="flex flex-row items-center justify-between border-b py-1"
key={`${file.originalName}-${
// biome-ignore lint/suspicious/noArrayIndexKey: <ok>
idx
}`}
>
<Link
aria-label="Visualizza Allegato"
href={`/area-riservata/allegato-view/${file.id}`}
onTouchStart={() => console.log("touching")}
target="_blank"
>
<Button
className="h-8 truncate p-0 text-base has-[>svg]:px-0"
variant="link"
>
<ExtIcon mimeType={file.mimeType} />
<span className="max-w-[60vw] truncate">
{file.originalName}
</span>
</Button>
</Link>
<span
className="text-muted-foreground text-xs"
key={new Date().toString()}
>
{TimeSince(new Date(file.uploadedAt))}
</span>
</li>
);
})}
</ul>
)}
</>
)}
</div>
<Link
aria-label="Visualizza Allegati"
className={cn(
buttonVariants({
variant: "outline",
}),
"relative w-full",
)}
href="/area-riservata/allegati"
>
<Paperclip /> {t.allegati.i_tuoi_allegati}
{attachments && attachments.length > 0 && (
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-red-600 text-white text-xs">
{attachments.length}
</span>
)}
</Link>
</CredenzaBody>
<CredenzaFooter className="flex flex-col items-center justify-between gap-2">
<CredenzaClose asChild>
<Button className="w-full">{t.chiudi}</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
};
const TimeSince = (date: Date) => {
const minsSince = Math.abs(differenceInMinutes(date, new Date()));
const daysSince = Math.abs(differenceInDays(date, new Date()));
const relevantMinsIncrements = [1, 3, 5, 10, 15, 30, 60, 120];
if (daysSince === 0) {
if (minsSince < 1) {
return "ora";
}
const closestToNow = relevantMinsIncrements.reduce((prev, curr) => {
return Math.abs(curr - minsSince) < Math.abs(prev - minsSince)
? curr
: prev;
});
if (closestToNow === 60) {
return "~1 ora fa";
}
if (closestToNow === 120 && minsSince < 121) {
return "~2 ore fa";
}
return `~${closestToNow} min fa`;
}
if (daysSince === 1) {
return "ieri";
}
if (daysSince < 30) {
return `${daysSince} giorni fa`;
}
return format(date, "dd/MM/yyyy");
};

View file

@ -1,171 +0,0 @@
"use client";
import { SendHorizontal, SmilePlusIcon, ThumbsUp } from "lucide-react";
import {
type ChangeEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { ChatAttachments } from "~/components/chat/chat-attachments";
import {
type AutosizeTextAreaRef,
AutosizeTextarea,
} from "~/components/custom_ui/autoResizeTextArea";
import {
EmojiPicker,
EmojiPickerContent,
EmojiPickerSearch,
} from "~/components/custom_ui/emoji-picker";
import { Button } from "~/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { useThrottledIsTypingMutation } from "~/hooks/chatHooks";
import { useMediaQuery } from "~/hooks/use-media-query";
import { useTranslation } from "~/providers/I18nProvider";
import type { ChatsChatid } from "~/schemas/public/Chats";
import type { Session } from "~/server/api/trpc";
import type { ChatUserInfo } from "~/server/services/chat.service";
import { api } from "~/utils/api";
type ChatBottombarProps = {
chatId: ChatsChatid;
userData: Session;
chatUserData: ChatUserInfo;
};
export default function ChatBottombar({
chatId,
userData,
chatUserData,
}: ChatBottombarProps) {
const { locale } = useTranslation();
const [message, setMessage] = useState("");
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<AutosizeTextAreaRef>(null);
const { mutate: addMutation } = api.messagesSSE.add.useMutation({});
const handleThumbsUp = useCallback(() => {
addMutation({
chatId,
message: "👍",
sender: userData.id,
});
setMessage("");
}, [chatId, addMutation, userData]);
const handleSend = () => {
if (message.length !== 0) {
addMutation({
chatId,
message: message.trim(),
sender: userData.id,
});
setMessage("");
if (inputRef.current) {
inputRef.current.textArea.focus();
inputRef.current.textArea.style.height = "auto";
}
}
};
const isTypingMutation = useThrottledIsTypingMutation({
chatId,
username: userData.username,
});
useEffect(() => {
// update isTyping state
isTypingMutation(isFocused && message.trim().length > 0);
}, [isFocused, message, isTypingMutation]);
useEffect(() => {
if (inputRef.current) {
inputRef.current.textArea.focus();
}
}, []);
const [openEmoji, setOpenEmoji] = useState(false);
const isMobile = useMediaQuery("(min-width: 426px)");
return (
<div className="flex min-h-18.5 w-full items-center justify-between gap-2 p-2">
<div className="flex">
<ChatAttachments chatUserData={chatUserData} userData={userData} />
</div>
<div className="relative w-full">
<AutosizeTextarea
autoComplete="off"
className="flex h-14 min-h-14 w-full resize-none items-center overflow-hidden rounded-xl border bg-background pr-8 outline-hidden ring-0 hover:border-neutral-400 focus:border-neutral-600 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
minHeight={56}
name="message"
onBlur={() => setIsFocused(false)}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value);
}}
onFocus={() => setIsFocused(true)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) {
setMessage((prev) => `${prev}\n`);
} else {
handleSend();
}
}
}}
placeholder="Aa"
ref={inputRef}
value={message}
/>
{isMobile && (
<div className="absolute top-0 right-2 flex h-full items-center">
<Popover onOpenChange={setOpenEmoji} open={openEmoji}>
<PopoverTrigger asChild>
<Button variant="ghost">
<SmilePlusIcon className="stroke-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-fit p-0">
<EmojiPicker
className="h-84"
locale={locale as "it" | "en"}
onEmojiSelect={({ emoji }) => {
setMessage(message + emoji);
// if (inputRef.current) {
// inputRef.current.textArea.focus();
// }
}}
>
<EmojiPickerSearch
placeholder={locale === "en" ? "Search..." : "Cerca..."}
/>
<EmojiPickerContent />
</EmojiPicker>
</PopoverContent>
</Popover>
</div>
)}
</div>
<Button
aria-label="Send Message"
className="shrink-0"
onClick={
message.length !== 0 ? () => handleSend() : () => handleThumbsUp()
}
variant="ghost"
>
{message.length !== 0 ? (
<SendHorizontal className="text-muted-foreground" size={20} />
) : (
<ThumbsUp className="text-muted-foreground" size={20} />
)}
</Button>
</div>
);
}

View file

@ -1,198 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority";
import { Check, CheckCheck } from "lucide-react";
import * as React from "react";
import MessageLoading from "~/components/chat/message-loading";
import { Button, type ButtonProps } from "~/components/ui/button";
import { cn } from "~/lib/utils";
// ChatBubble
const chatBubbleVariant = cva(
"flex gap-2 max-w-[60%] items-end relative group",
{
defaultVariants: {
layout: "default",
variant: "received",
},
variants: {
layout: {
ai: "max-w-full w-full items-center",
default: "",
},
variant: {
received: "self-start",
sent: "self-end flex-row-reverse",
},
},
},
);
interface ChatBubbleProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof chatBubbleVariant> {}
const ChatBubble = React.forwardRef<HTMLDivElement, ChatBubbleProps>(
({ className, variant, layout, children, ...props }, ref) => (
<div
className={cn(
chatBubbleVariant({ className, layout, variant }),
"group relative",
)}
ref={ref}
{...props}
>
{React.Children.map(children, (child) =>
React.isValidElement(child) && typeof child.type !== "string"
? React.cloneElement(child, {
layout,
variant,
} as Partial<React.ComponentProps<typeof child.type>>)
: child,
)}
</div>
),
);
ChatBubble.displayName = "ChatBubble";
type ChatBubbleReadStatusProps = {
isread: boolean;
};
export const ChatBubbleReadStatus = ({ isread }: ChatBubbleReadStatusProps) => {
if (isread) {
return <CheckCheck className="mt-2 size-4 text-green-500" />;
}
return <Check className="mt-2 size-4 text-muted-foreground" />;
};
// ChatBubbleMessage
const chatBubbleMessageVariants = cva("p-2", {
defaultVariants: {
layout: "default",
variant: "received",
},
variants: {
layout: {
ai: "border-t w-full rounded-none bg-transparent",
default: "",
},
variant: {
received:
"bg-secondary text-secondary-foreground rounded-r-lg rounded-tl-lg",
sent: "bg-primary text-primary-foreground rounded-l-lg rounded-tr-lg",
},
},
});
interface ChatBubbleMessageProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof chatBubbleMessageVariants> {
isLoading?: boolean;
}
const ChatBubbleMessage = React.forwardRef<
HTMLDivElement,
ChatBubbleMessageProps
>(
(
{ className, variant, layout, isLoading = false, children, ...props },
ref,
) => (
<div
className={cn(
chatBubbleMessageVariants({ className, layout, variant }),
"wrap-break-word max-w-full whitespace-pre-wrap",
)}
ref={ref}
{...props}
>
{isLoading ? (
<div className="flex items-center space-x-2">
<MessageLoading />
</div>
) : (
children
)}
</div>
),
);
ChatBubbleMessage.displayName = "ChatBubbleMessage";
// ChatBubbleTimestamp
interface ChatBubbleTimestampProps
extends React.HTMLAttributes<HTMLDivElement> {
timestamp: string;
}
const ChatBubbleTimestamp: React.FC<ChatBubbleTimestampProps> = ({
timestamp,
className,
...props
}) => (
<div className={cn("mt-2 text-right text-xs", className)} {...props}>
{timestamp}
</div>
);
// ChatBubbleAction
type ChatBubbleActionProps = ButtonProps & {
icon: React.ReactNode;
};
const ChatBubbleAction: React.FC<ChatBubbleActionProps> = ({
icon,
onClick,
className,
variant = "ghost",
size = "icon",
...props
}) => (
<Button
aria-label="Chat Bubble Action"
className={className}
onClick={onClick}
size={size}
variant={variant}
{...props}
>
{icon}
</Button>
);
interface ChatBubbleActionWrapperProps
extends React.HTMLAttributes<HTMLDivElement> {
variant?: "sent" | "received";
className?: string;
show: boolean;
}
const ChatBubbleActionWrapper = React.forwardRef<
HTMLDivElement,
ChatBubbleActionWrapperProps
>(({ variant, className, show, children, ...props }, ref) => (
<>
{show && (
<div
className={cn(
"absolute top-1/2 flex -translate-y-1/2 opacity-0 transition-opacity duration-200 group-hover:opacity-100",
variant === "sent"
? "-left-1 -translate-x-full flex-row-reverse"
: "-right-1 translate-x-full",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
)}
</>
));
ChatBubbleActionWrapper.displayName = "ChatBubbleActionWrapper";
export {
ChatBubble,
ChatBubbleMessage,
ChatBubbleTimestamp,
ChatBubbleAction,
ChatBubbleActionWrapper,
};

View file

@ -1,439 +0,0 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Pencil, Trash2 } from "lucide-react";
import {
type FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import toast from "react-hot-toast";
import {
ChatBubble,
ChatBubbleAction,
ChatBubbleActionWrapper,
ChatBubbleMessage,
ChatBubbleReadStatus,
ChatBubbleTimestamp,
} from "~/components/chat/chat-bubble";
import { ChatMessageList } from "~/components/chat/chat-message-list";
import LoadingButton from "~/components/custom_ui/loading-button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import Input from "~/components/ui/input";
import { getUserColor, UserAvatar } from "~/components/user_avatar";
import type { LiveChat } from "~/hooks/chatHooks";
import { cn } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import type { ChatsChatid } from "~/schemas/public/Chats";
import type { MessagesMessageid } from "~/schemas/public/Messages";
import type { UsersId } from "~/schemas/public/Users";
import type { Session } from "~/server/api/trpc";
import type { ChatMessage } from "~/server/services/messages.service";
import type { WhoIsTyping } from "~/server/sse";
import { api } from "~/utils/api";
type ChatListProps = {
chatId: ChatsChatid;
messages: ChatMessage[];
userData: Session;
query: LiveChat["query"];
typingStatus: WhoIsTyping[];
};
export const ChatList = ({
chatId,
messages,
userData,
query,
typingStatus,
}: ChatListProps) => {
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [editModalOpen, setEditModalOpen] = useState(false);
const [msgSelected, setMsgSelected] = useState<ChatMessage | null>(null);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const handleEditOpen = (status: boolean) => {
setEditModalOpen(status);
};
const handleDeleteOpen = (status: boolean) => {
setDeleteModalOpen(status);
};
const { mutate: deleteMutation } = api.messagesSSE.delete.useMutation({
onError: () => {
toast.error("Error deleting message", { id: "deleteMessage" });
},
onMutate: () => {
toast("Deleting message...", { icon: "🗑️", id: "deleteMessage" });
},
onSuccess: () => {
toast("Message deleted", { icon: "🗑️", id: "deleteMessage" });
},
});
const { mutate: editMutation } = api.messagesSSE.edit.useMutation({
onError: () => {
toast.error("Error editing message", { id: "editMessage" });
},
onMutate: () => {
toast("Editing message...", { icon: "📝", id: "editMessage" });
},
onSuccess: () => {
toast("Message edited", { icon: "📝", id: "editMessage" });
},
});
const handleEditSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const formData = new FormData(form);
if (!formData.get("message-edit") || !formData.get("message-id")) {
return;
}
editMutation({
chatId,
message: formData.get("message-edit") as string,
messageId: formData.get("message-id") as MessagesMessageid,
});
setEditModalOpen(false);
},
[editMutation],
);
const [locked, setLocked] = useState(false);
useEffect(() => {
if (
messagesContainerRef.current &&
!locked &&
!query.isLoading &&
!query.isFetching
) {
messagesContainerRef.current.scrollTop =
messagesContainerRef.current.scrollHeight;
}
setLocked(false);
}, [messages, typingStatus]);
return (
<div className="flex h-full w-full flex-col overflow-y-auto">
<ChatMessageList ref={messagesContainerRef}>
<AnimatePresence key="chat-messages">
{query.hasNextPage && (
<LoadingButton
aria-label="Load More Messages"
className="mb-3"
disabled={!query.hasNextPage || query.isFetchingNextPage}
key="load-more"
loading={query.isFetchingNextPage}
onClick={async () => {
setLocked(true);
await query.fetchNextPage();
}}
variant="secondary"
>
{query.isFetchingNextPage
? "Caricamento..."
: "Carica messaggi precedenti"}
</LoadingButton>
)}
{messages.map((message, index) => {
const variant = getMessageVariant(message.sender, userData.id);
const isNextSameSender =
index + 1 < messages.length &&
messages[index + 1]?.sender === message.sender;
const isPreviousSameSender =
index - 1 >= 0 && messages[index - 1]?.sender === message.sender;
const isPrevSameDay =
index - 1 >= 0 &&
// biome-ignore lint/style/noNonNullAssertion: <non lo so>
new Date(messages[index - 1]!.time).toDateString() ===
new Date(message.time).toDateString();
return (
<motion.div
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
className={cn(
"flex flex-col gap-2 pb-4",
isNextSameSender && "pb-0.5",
)}
exit={{ opacity: 0, scale: 1, x: 0, y: 1 }}
initial={{ opacity: 0, scale: 1, x: 0, y: 50 }}
key={message.messageid}
layout
style={{ originX: 0.5, originY: 0.5 }}
transition={{
layout: {
bounce: 0.3,
duration: 0.3,
type: "spring",
},
opacity: { duration: 0.1 },
}}
>
{!isPrevSameDay && (
<span className="mx-auto rounded-md bg-muted px-2 py-1 text-center text-muted-foreground text-xs">
{new Date(message.time).toLocaleDateString("it", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
})}
</span>
)}
<ChatBubble variant={variant}>
{isNextSameSender ? (
<div className="size-6"></div>
) : (
<UserAvatar className="size-6" userId={message.sender} />
)}
<div className="flex flex-col gap-1">
{!isPreviousSameSender && (
<p
className={cn(
"font-semibold text-sm",
variant === "sent" && "text-right",
)}
style={{
color: getUserColor(message.sender),
}}
>
{message.sender_nome}
</p>
)}
<ChatBubbleMessage
className={cn(isNextSameSender && "rounded-lg")}
isLoading={false}
>
{message.message}
<div className="flex items-center gap-2">
{userData.id === message.sender && (
<ChatBubbleReadStatus isread={message.isread} />
)}
{message.time && (
<ChatBubbleTimestamp
timestamp={new Date(message.time).toLocaleString(
"it",
{
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
month: "short",
},
)}
/>
)}
</div>
</ChatBubbleMessage>
</div>
<ChatBubbleActionWrapper show={userData.isAdmin}>
<ChatBubbleAction
className="size-7 hover:bg-red-50 hover:text-red-500"
icon={<Trash2 className="size-4" />}
key={"delete"}
onClick={() => {
setMsgSelected(message);
handleDeleteOpen(true);
}}
/>
<ChatBubbleAction
className="size-7 hover:bg-blue-50 hover:text-blue-500"
icon={<Pencil className="size-4" />}
key={"edit"}
onClick={() => {
setMsgSelected(message);
handleEditOpen(true);
}}
/>
</ChatBubbleActionWrapper>
</ChatBubble>
</motion.div>
);
})}
<Typers
currentTypers={typingStatus}
messageLenght={messages.length}
userId={userData.id}
/>
</AnimatePresence>
</ChatMessageList>
<EditMessageDialog
handleEditSubmit={handleEditSubmit}
msgEdit={msgSelected}
onOpenChange={handleEditOpen}
open={editModalOpen}
/>
<DeleteMessageDialog
handleDeleteSubmit={(messageId) => {
deleteMutation({ chatId, messageId });
}}
msgDelete={msgSelected}
onOpenChange={handleDeleteOpen}
open={deleteModalOpen}
/>
</div>
);
};
const Typers = ({
currentTypers,
messageLenght,
userId,
}: {
currentTypers: WhoIsTyping[];
messageLenght: number;
userId: UsersId;
}) => {
return (
<>
{currentTypers.map((typer) => {
if (typer.userId === userId) return null;
const variant = "received";
return (
<motion.div
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
className="flex flex-col gap-2 py-2"
exit={{ opacity: 0, scale: 1, x: 0, y: 1 }}
initial={{ opacity: 0, scale: 1, x: 0, y: 50 }}
key={`typers${messageLenght}${typer.userId}`}
layout
style={{ originX: 0.5, originY: 0.5 }}
transition={{
layout: {
bounce: 0.3,
duration: 0.3,
type: "spring",
},
opacity: { duration: 0.1 },
}}
>
<ChatBubble variant={variant}>
<UserAvatar className="size-6" userId={typer.userId} />
<ChatBubbleMessage isLoading={true}></ChatBubbleMessage>
</ChatBubble>
</motion.div>
);
})}
</>
);
};
const EditMessageDialog = ({
open,
onOpenChange,
msgEdit,
handleEditSubmit,
}: {
open: boolean;
onOpenChange: (status: boolean) => void;
msgEdit: ChatMessage | null;
handleEditSubmit: (e: FormEvent<HTMLFormElement>) => void;
}) => {
const { t } = useTranslation();
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.modifica}</DialogTitle>
<DialogDescription className="sr-only">Edit</DialogDescription>
</DialogHeader>
{msgEdit && (
<form method="post" onSubmit={handleEditSubmit}>
<div className="flex items-center gap-3">
<label className="sr-only" htmlFor="id">
ID
</label>
<Input
className="hidden"
id="id"
name="message-id"
readOnly
type="text"
value={msgEdit.messageid}
/>
<label className="sr-only" htmlFor="message-edit">
Modifica messaggio
</label>
<Input
className="mt-0"
defaultValue={msgEdit.message || undefined}
id="message-edit"
name="message-edit"
placeholder="Modifica messaggio"
type="text"
/>
<Button type="submit">{t.salva}</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
};
const DeleteMessageDialog = ({
open,
onOpenChange,
msgDelete,
handleDeleteSubmit,
}: {
open: boolean;
onOpenChange: (status: boolean) => void;
msgDelete: ChatMessage | null;
handleDeleteSubmit: (msgId: MessagesMessageid) => void;
}) => {
const { t } = useTranslation();
return (
<>
{msgDelete && (
<AlertDialog onOpenChange={onOpenChange} open={open}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Vuoi eliminare il messaggio?</AlertDialogTitle>
<AlertDialogDescription>
Questa azione non può essere annullata.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t.annulla}</AlertDialogCancel>
<AlertDialogAction
aria-label="Delete Message"
onClick={() => handleDeleteSubmit(msgDelete.messageid)}
>
{t.procedi}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</>
);
};
const getMessageVariant = (messageName: string, selectedUserName: string) =>
messageName !== selectedUserName ? "received" : "sent";

View file

@ -1,23 +0,0 @@
import { forwardRef, type HTMLAttributes } from "react";
import { cn } from "~/lib/utils";
type ChatMessageListProps = HTMLAttributes<HTMLDivElement>;
const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
({ className, children, ...props }, ref) => (
<div
className={cn(
"flex h-full w-full flex-col overflow-y-auto p-4",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
),
);
ChatMessageList.displayName = "ChatMessageList";
export { ChatMessageList };

View file

@ -1,447 +0,0 @@
import { Search, SquarePen } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useCallback, useState } from "react";
import { Etichetta, EtichetteModal } from "~/components/etichette";
import { LoadingPage } from "~/components/loading";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { Button, buttonVariants } from "~/components/ui/button";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "~/components/ui/command";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "~/components/ui/context-menu";
import Input from "~/components/ui/input";
import { UserAvatar } from "~/components/user_avatar";
import { useMediaQuery } from "~/hooks/use-media-query";
import { cn } from "~/lib/utils";
import type { ChatsChatid } from "~/schemas/public/Chats";
import type { ActiveChatsType } from "~/server/controllers/chat.controller";
import type { NonNullableUser } from "~/server/controllers/user.controller";
import { api } from "~/utils/api";
type LastMessage = {
message: string | null;
time: Date;
isLastSender: boolean;
};
export const ChatSidebar = ({ chatId }: { chatId: ChatsChatid | null }) => {
const { data: activeChats, isLoading: loadingActiveChats } =
api.chat.getActiveChats.useQuery(undefined, {
refetchInterval: 5000, // Refetch every 5 seconds, Good enough for a chat sidebar
refetchOnWindowFocus: true,
});
const { data: inactiveUsers, isLoading: loadingInactiveUsers } =
api.users.getActiveUsersWithoutChat.useQuery();
const router = useRouter();
const isDesktop = useMediaQuery("(min-width: 768px)");
const [selectedChat, setSelectedChat] = useState<ChatsChatid | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [searchDialogOpen, setSearchDialogOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [editEtichettaModalOpen, setEditEtichettaModalOpen] = useState(false);
const utils = api.useUtils();
const { mutate: deletechat } = api.chat.deleteChat.useMutation({
onSuccess: async () => {
await utils.chat.getActiveChats.invalidate();
await utils.users.getActiveUsersWithoutChat.invalidate();
},
});
const getVariant = (lastmessage: LastMessage | null) => {
if (lastmessage) {
return lastmessage.isLastSender ? "secondary" : "grey";
}
return "grey";
};
const getLastMessage = (lastmessage: LastMessage | null) => {
if (lastmessage) {
return `${lastmessage.isLastSender ? "Tu: " : ""}${lastmessage.message}`;
}
return "";
};
const handleChatSelection = useCallback(
async (new_chatid: ChatsChatid) => {
if (chatId === new_chatid) return;
await router.push(`/area-riservata/admin/chats/${new_chatid}`);
setSearchDialogOpen(false);
},
[chatId, router],
);
if (loadingActiveChats || loadingInactiveUsers) return <LoadingPage />;
if (!activeChats || !inactiveUsers) return <div>Errore caricamento</div>;
return (
<div className="group relative flex h-full flex-col gap-4 py-2 data-[collapsed=true]:p-2">
<div className="mx-2 flex flex-col items-center justify-between gap-2 sm:flex-row sm:gap-0">
{isDesktop ? (
<div className="relative">
<Search className="absolute top-[0.8rem] left-2 size-4 text-muted-foreground" />
<label className="sr-only" htmlFor="search">
Search Chats
</label>
<Input className="m-0 pl-8" id="search" placeholder="Search" />
</div>
) : (
<Button
aria-label="Open Search Dialog"
className="size-8"
onClick={() => setSearchDialogOpen(true)}
size="icon"
variant="ghost"
>
<Search size={20} />
</Button>
)}
<SearchUserComponent
activeChats={activeChats}
handleChatSelection={handleChatSelection}
isOpen={searchDialogOpen}
OpenChange={setSearchDialogOpen}
/>
<Button
aria-label="Open Add User Dialog"
className="size-8"
onClick={() => setAddDialogOpen(true)}
size="icon"
variant="ghost"
>
<SquarePen size={20} />
</Button>
<NewUserComponent
inactiveUsers={inactiveUsers}
isOpen={addDialogOpen}
OpenChange={setAddDialogOpen}
/>
</div>
<nav className="grid gap-1 rounded-lg group-data-[collapsed=true]:justify-center group-data-[collapsed=true]:px-2">
{activeChats.map((chat) => (
<ContextMenu key={chat.userinfo.username}>
<ContextMenuTrigger asChild>
<Button
aria-label="Select Chat"
className={cn(
buttonVariants({
size: !isDesktop ? "icon" : "xl",
variant: getVariant(chat.lastmessage),
}),
!isDesktop ? "p-1" : "justify-start gap-4",
"h-fit w-full",
)}
onClick={() => handleChatSelection(chat.id)}
>
<UserAvatar
className="size-10"
userId={chat.userinfo.id}
username={chat.userinfo.username}
/>
{isDesktop && (
<div className="flex h-full max-w-28 flex-col text-left align-top">
<span>{chat.userinfo.username}</span>
<div className="flex flex-wrap gap-2">
{chat.etichette.map((etichetta) => (
<Etichetta
className="text-xs"
color_hex={etichetta.color_hex}
key={etichetta.id_etichetta}
title={etichetta.title}
/>
))}
</div>
<span className="truncate text-muted-foreground text-xs">
{getLastMessage(chat.lastmessage)}
</span>
<span className="truncate text-muted-foreground text-xs">
{chat.lastmessage &&
new Date(chat.lastmessage.time).toLocaleString("it", {
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
month: "2-digit",
year: "numeric",
})}
</span>
</div>
)}
</Button>
</ContextMenuTrigger>
<ChatSidebarContextMenu
chatid={chat.id}
setDeleteModalOpen={setDeleteModalOpen}
setEditEtichettaModalOpen={setEditEtichettaModalOpen}
setSelectedChat={setSelectedChat}
/>
</ContextMenu>
))}
{selectedChat && (
<>
<AlertDialog
onOpenChange={setDeleteModalOpen}
open={deleteModalOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Sei assolutamente sicuro?</AlertDialogTitle>
<AlertDialogDescription>
Questa azione non può essere annullata. Questo eliminerà
definitivamente la chat e rimuoverà i dati dai nostri
server.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annulla</AlertDialogCancel>
<AlertDialogAction
aria-label="Delete Chat"
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => {
if (selectedChat) {
deletechat({
chatId: selectedChat,
});
}
}}
>
Continua
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<EtichetteModal
chatId={selectedChat}
onOpenChange={setEditEtichettaModalOpen}
open={editEtichettaModalOpen}
/>
</>
)}
</nav>
</div>
);
};
const SearchUserComponent = ({
isOpen,
OpenChange,
activeChats,
handleChatSelection,
}: {
isOpen: boolean;
OpenChange: (status: boolean) => void;
activeChats: ActiveChatsType[];
handleChatSelection: (chatId: ChatsChatid) => void;
}) => {
return (
<CommandDialog onOpenChange={OpenChange} open={isOpen}>
<CommandInput
className="border-none ring-0 focus:ring-0 focus-visible:ring-0"
placeholder="Digita il nome utente..."
/>
<CommandList>
<CommandEmpty>Nessun risultato</CommandEmpty>
<CommandGroup
className="pb-2"
heading={activeChats.length > 0 ? "Suggerimenti" : undefined}
>
{activeChats?.map((chat) => (
<CommandItem
className="rounded-md"
key={chat.id}
onSelect={() => {
handleChatSelection(chat.id);
}}
>
<span className="flex items-center gap-2">
<UserAvatar
className="size-8"
userId={chat.userinfo.id}
username={chat.userinfo.username}
/>
<span>
<span className="block font-medium">
{chat.userinfo.username}
</span>
</span>
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
);
};
const NewUserComponent = ({
isOpen,
OpenChange,
inactiveUsers,
}: {
isOpen: boolean;
OpenChange: (status: boolean) => void;
inactiveUsers: NonNullableUser[];
}) => {
const utils = api.useUtils();
const { mutate: createNewChat } = api.chatSSE.create.useMutation({
onSuccess: async () => {
await utils.chat.getActiveChats.invalidate();
OpenChange(false);
},
});
return (
<CommandDialog onOpenChange={OpenChange} open={isOpen}>
<CommandInput
className="border-none ring-0 focus:ring-0 focus-visible:ring-0"
placeholder="Digita il nome utente..."
/>
<CommandList>
<CommandEmpty>Nessun risultato</CommandEmpty>
<CommandGroup
className="pb-2"
heading={inactiveUsers.length > 0 ? "Suggerimenti" : undefined}
>
{inactiveUsers?.map((user) => (
<CommandItem
className="rounded-md"
key={user.id}
onSelect={() => {
createNewChat({
userId: user.id,
});
}}
>
<span className="flex items-center gap-2">
<UserAvatar
className="size-8"
userId={user.id}
username={user.username}
/>
<span>
<span className="block font-medium">{user.username}</span>
</span>
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
);
};
const ChatSidebarContextMenu = ({
chatid,
setSelectedChat,
setDeleteModalOpen,
setEditEtichettaModalOpen,
}: {
chatid: ChatsChatid;
setSelectedChat: (chat: ChatsChatid | null) => void;
setDeleteModalOpen: (status: boolean) => void;
setEditEtichettaModalOpen: (status: boolean) => void;
}) => {
const { data: chatData } = api.chat.getChatInfobyId.useQuery({
chatId: chatid,
});
if (!chatData) return null;
return (
<>
<ContextMenuContent>
<ContextMenuSub>
<ContextMenuSubTrigger>Etichette</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
<ContextMenuItem asChild>
<button
aria-label="Edit Chat Labels"
className="w-full cursor-pointer"
onClick={() => {
setSelectedChat(chatid);
setEditEtichettaModalOpen(true);
}}
type="button"
>
Modifica
</button>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem>
<Link
aria-label="View User Profile"
href={`/area-riservata/admin/user-view/edit-user/${chatData.user_ref}`}
target="_blank"
>
Profilo
</Link>
</ContextMenuItem>
<ContextMenuItem>
<Link
aria-label="View User Orders"
href={`/area-riservata/admin/user-view/ordini/${chatData.user_ref}`}
target="_blank"
>
Ordini
</Link>
</ContextMenuItem>
<ContextMenuItem>
<Link
aria-label="View User Attachments"
href={`/area-riservata/admin/user-view/allegati/${chatData.user_ref}`}
target="_blank"
>
Allegati
</Link>
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>Altro</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
<ContextMenuItem
aria-label="Edit Chat"
className="cursor-pointer rounded-md bg-red-500 text-white hover:bg-red-500/90 focus:bg-red-500/90 focus:text-white"
onClick={() => {
setSelectedChat(chatid);
setDeleteModalOpen(true);
}}
>
Elimina Chat
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</>
);
};

View file

@ -1,268 +0,0 @@
"use client";
import {
Info,
Paperclip,
ShieldUser,
ShoppingBag,
User,
UserCog,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { useCallback } from "react";
import toast from "react-hot-toast";
import { EtichetteDisplayer } from "~/components/etichette";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { Button, buttonVariants } from "~/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { Separator } from "~/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { UserAvatar } from "~/components/user_avatar";
import { cn } from "~/lib/utils";
import type { ChatsChatid } from "~/schemas/public/Chats";
import type { Session } from "~/server/api/trpc";
import type { ChatUserInfo } from "~/server/services/chat.service";
import type { OnlineStatus } from "~/server/sse";
import { api } from "~/utils/api";
type ChatTopbarProps = {
chatId: ChatsChatid;
userData: Session;
chatUserData: ChatUserInfo;
onlineStatus: OnlineStatus[];
};
export default function ChatTopbar({
chatId,
userData,
chatUserData,
onlineStatus,
}: ChatTopbarProps) {
const utils = api.useUtils();
const { mutate: deletechat } = api.chat.deleteChat.useMutation({
onSuccess: async () => {
await utils.chat.getActiveChats.invalidate();
await utils.users.getActiveUsersWithoutChat.invalidate();
await router.push("/area-riservata/admin/chats/");
},
});
const router = useRouter();
const { mutate: swap } = api.auth.swapUser.useMutation({
onSuccess: async () => {
toast.success("Utente cambiato con successo");
await router.push("/area-riservata/load-page");
},
});
const handleDeleteChat = useCallback(() => {
deletechat({ chatId });
}, [chatId, deletechat]);
const handleSwapUser = useCallback(() => {
swap({ id: chatUserData.id });
}, [chatUserData, swap]);
return (
<div className="flex h-18 w-full items-center justify-between border-b px-4 py-2">
<div className="flex items-center gap-2">
{!userData.isAdmin ? (
<Image
alt="Infoalloggi logo"
className="size-10 rounded-full"
height={40}
src={"/favicon/favicon.png"}
width={40}
/>
) : (
<UserAvatar
className="size-10"
userId={chatUserData.id}
username={chatUserData.username}
/>
)}
<div className="flex flex-col gap-1">
<div className="flex gap-4">
<span className="font-medium">
{!userData.isAdmin ? "Chat Infoalloggi" : chatUserData.username}
</span>
{userData.isAdmin && <EtichetteDisplayer chatId={chatId} />}
</div>
<div className="flex flex-row items-center gap-2">
<UserStatus onlineStatus={onlineStatus} userData={userData} />
</div>
</div>
</div>
<div>
{userData.isAdmin && (
<AdminPopover
chatUserData={chatUserData}
handleDeleteChat={handleDeleteChat}
handleSwapUser={handleSwapUser}
/>
)}
</div>
</div>
);
}
const UserStatus = ({
onlineStatus,
userData,
}: {
onlineStatus: OnlineStatus[];
userData: Session;
}) => (
<div className="flex flex-row items-center gap-2">
{onlineStatus.map((user, idx) =>
user.isAdmin ? (
<TooltipProvider delayDuration={0} key={user.userId}>
<Tooltip>
<TooltipTrigger>
<span
className={cn(
"flex flex-row items-center font-bold text-green-500 text-xs",
)}
>
<ShieldUser className="mr-[0.2rem]" size={15} />
<span>
{user.nome}
{idx < onlineStatus.length - 1 && ","}
</span>
</span>
</TooltipTrigger>
<TooltipContent>
<p>Admin Infoalloggi</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span
className={cn("flex flex-row font-bold text-green-500 text-xs")}
key={user.userId}
>
<span>
{user.nome}
{!userData.isAdmin && " (Tu)"}
{idx < onlineStatus.length - 1 && ", "}
</span>
</span>
),
)}
</div>
);
const AdminPopover = ({
chatUserData,
handleSwapUser,
handleDeleteChat,
}: {
chatUserData: ChatUserInfo;
handleSwapUser: () => void;
handleDeleteChat: () => void;
}) => (
<Popover>
<PopoverTrigger
className={cn(
buttonVariants({ size: "icon", variant: "outline" }),
"size-10",
"dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white",
)}
>
<Info className="text-muted-foreground" size={24} />
</PopoverTrigger>
<PopoverContent className="mr-4 w-fit px-4">
<div className="flex flex-col gap-2">
<Link
aria-label="Visualizza Profilo Utente"
className={cn(
buttonVariants({ variant: "outline" }),
"justify-start gap-2",
)}
href={`/area-riservata/admin/user-view/edit-user/${chatUserData.id}`}
>
<UserCog /> Profilo
</Link>
<Link
aria-label="Visualizza Ordini Utente"
className={cn(
buttonVariants({ variant: "outline" }),
"justify-start gap-2",
)}
href={`/area-riservata/admin/user-view/ordini/${chatUserData.id}`}
>
<ShoppingBag /> Ordini
</Link>
<Link
aria-label="Visualizza Allegati Utente"
className={cn(
buttonVariants({ variant: "outline" }),
"justify-start gap-2",
)}
href={`/area-riservata/admin/user-view/allegati/${chatUserData.id}`}
>
<Paperclip /> Allegati
</Link>
<button
aria-label="Cambia Utente"
className={cn(
buttonVariants({ variant: "outline" }),
"justify-start gap-2",
)}
onClick={handleSwapUser}
type="button"
>
<User /> Login
</button>
<Separator />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive">
Cancella Chat
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Sei assolutamente sicuro?</AlertDialogTitle>
<AlertDialogDescription>
Questa azione non può essere annullata. Questo eliminerà
definitivamente la chat e rimuoverà i dati dai nostri server.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annulla</AlertDialogCancel>
<AlertDialogAction
aria-label="Continua Eliminazione Chat"
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={handleDeleteChat}
>
Continua
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</PopoverContent>
</Popover>
);

View file

@ -1,53 +0,0 @@
"use client";
import { memo } from "react";
import ChatBottombar from "~/components/chat/chat-bottombar";
import { ChatList } from "~/components/chat/chat-list";
import ChatTopbar from "~/components/chat/chat-topbar";
import { useLiveChat } from "~/hooks/chatHooks";
import type { ChatsChatid } from "~/schemas/public/Chats";
import type { Session } from "~/server/api/trpc";
import { api } from "~/utils/api";
const Chat = memo(
({ chatId, userData }: { chatId: ChatsChatid; userData: Session }) => {
const liveChat = useLiveChat(chatId, userData);
const [userinfo] = api.chat.getUserInfosByChat.useSuspenseQuery({
chatId,
});
return (
<div className="flex h-full w-full flex-col justify-between">
<div className="shrink-0">
<ChatTopbar
chatId={chatId}
chatUserData={userinfo}
onlineStatus={liveChat.onlineUsers}
userData={userData}
/>
</div>
<ChatList
chatId={chatId}
messages={liveChat.messages}
query={liveChat.query}
typingStatus={liveChat.typingStatus}
userData={userData}
/>
<div className="shrink-0 border-t">
<ChatBottombar
chatId={chatId}
chatUserData={userinfo}
userData={userData}
/>
</div>
</div>
);
},
(prevProps, nextProps) =>
prevProps.chatId === nextProps.chatId &&
prevProps.userData.id === nextProps.userData.id,
);
Chat.displayName = "Chat";
export default Chat;

View file

@ -1,46 +0,0 @@
// @hidden
export default function MessageLoading() {
return (
<svg
className="text-foreground"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Loading</title>
<circle cx="4" cy="12" fill="currentColor" r="2">
<animate
attributeName="cy"
begin="0;spinner_OcgL.end+0.25s"
calcMode="spline"
dur="0.6s"
id="spinner_qFRN"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
values="12;6;12"
/>
</circle>
<circle cx="12" cy="12" fill="currentColor" r="2">
<animate
attributeName="cy"
begin="spinner_qFRN.begin+0.1s"
calcMode="spline"
dur="0.6s"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
values="12;6;12"
/>
</circle>
<circle cx="20" cy="12" fill="currentColor" r="2">
<animate
attributeName="cy"
begin="spinner_qFRN.begin+0.2s"
calcMode="spline"
dur="0.6s"
id="spinner_OcgL"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
values="12;6;12"
/>
</circle>
</svg>
);
}