Create chat modal UI & session login for WS auth
Backend: initialize a Django session on token obtain (login) so AuthMiddlewareStack can authenticate WebSocket connections; validate the token serializer and map TokenError -> InvalidToken; call django_logout on logout to destroy the session. Frontend: add a CreateChatModal component (DM/group creation) with user search, selection, validation, API mutation and cache invalidation; wire modal into ChatSidebar and add Czech translations for the new UI strings.
This commit is contained in:
@@ -7,10 +7,12 @@ import Avatar from "@/components/ui/Avatar";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import EmptyState from "@/components/ui/EmptyState";
|
||||
import IconButton from "@/components/ui/IconButton";
|
||||
import CreateChatModal from "./CreateChatModal";
|
||||
|
||||
export default function ChatSidebar() {
|
||||
const { t } = useTranslation("social");
|
||||
const [query, setQuery] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const { data, isLoading } = useApiSocialChatsList(
|
||||
query ? { search: query } : undefined,
|
||||
@@ -24,7 +26,11 @@ export default function ChatSidebar() {
|
||||
<h2 className="text-sm font-semibold text-brand-text">
|
||||
{t("chat.sidebar.title")}
|
||||
</h2>
|
||||
<IconButton icon={<FiPlus size={16} />} label={t("chat.sidebar.new")} />
|
||||
<IconButton
|
||||
icon={<FiPlus size={16} />}
|
||||
label={t("chat.sidebar.new")}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="relative px-3 py-2">
|
||||
@@ -86,6 +92,11 @@ export default function ChatSidebar() {
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<CreateChatModal
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
332
frontend/src/components/social/chat/CreateChatModal.tsx
Normal file
332
frontend/src/components/social/chat/CreateChatModal.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiX, FiSearch, FiCheck } from "react-icons/fi";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useApiSocialChatsCreate,
|
||||
getApiSocialChatsListQueryKey,
|
||||
} from "@/api/generated/private/chat/chat";
|
||||
import { useApiAccountUsersList } from "@/api/generated/private/account/account";
|
||||
import type { CustomUser } from "@/api/generated/private/models";
|
||||
import { ChatTypeEnum } from "@/api/generated/private/models";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Input from "@/components/ui/Input";
|
||||
import FormErrorBanner from "@/components/ui/FormErrorBanner";
|
||||
import Avatar from "@/components/ui/Avatar";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ChatTab = "DM" | "GROUP";
|
||||
|
||||
export default function CreateChatModal({ open, onClose }: Props) {
|
||||
const { t } = useTranslation("social");
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [tab, setTab] = useState<ChatTab>("DM");
|
||||
const [name, setName] = useState("");
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
const [selectedUsers, setSelectedUsers] = useState<CustomUser[]>([]);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const searchRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Debounce the user search
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQuery(userQuery.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [userQuery]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handler(e: MouseEvent) {
|
||||
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
// Reset form on tab change
|
||||
const switchTab = (t: ChatTab) => {
|
||||
setTab(t);
|
||||
setName("");
|
||||
setUserQuery("");
|
||||
setDebouncedQuery("");
|
||||
setSelectedUsers([]);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
// User search query
|
||||
const { data: usersData, isLoading: usersLoading } = useApiAccountUsersList(
|
||||
debouncedQuery.length >= 2 ? { username: debouncedQuery } : undefined,
|
||||
{ query: { enabled: debouncedQuery.length >= 2 } },
|
||||
);
|
||||
|
||||
const users = usersData?.results ?? [];
|
||||
const filteredUsers = users.filter(
|
||||
(u) => !selectedUsers.some((s) => s.id === u.id),
|
||||
);
|
||||
|
||||
// Create mutation
|
||||
const { mutate: createChat, isPending } = useApiSocialChatsCreate({
|
||||
mutation: {
|
||||
onSuccess: async (chat) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getApiSocialChatsListQueryKey(),
|
||||
});
|
||||
onClose();
|
||||
navigate(`/social/chats/${chat.id}`);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg =
|
||||
(err as { response?: { data?: { detail?: string; name?: string[] } } })
|
||||
?.response?.data?.detail ??
|
||||
(err as { response?: { data?: { name?: string[] } } })?.response?.data
|
||||
?.name?.[0] ??
|
||||
t("chat.create.error");
|
||||
setError(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function toggleUser(user: CustomUser) {
|
||||
setSelectedUsers((prev) =>
|
||||
prev.some((u) => u.id === user.id)
|
||||
? prev.filter((u) => u.id !== user.id)
|
||||
: [...prev, user],
|
||||
);
|
||||
setUserQuery("");
|
||||
setShowDropdown(false);
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (tab === "GROUP" && !name.trim()) {
|
||||
setError(t("chat.create.nameRequired"));
|
||||
return;
|
||||
}
|
||||
if (tab === "DM" && selectedUsers.length === 0) {
|
||||
setError(t("chat.create.recipientRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
createChat({
|
||||
data: {
|
||||
chat_type: tab === "DM" ? ChatTypeEnum.DM : ChatTypeEnum.GROUP,
|
||||
name: tab === "GROUP" ? name.trim() : undefined,
|
||||
members: selectedUsers.map((u) => u.id),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("chat.create.title")}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="relative z-10 w-full max-w-md rounded-2xl border border-brand-lines/20 bg-brand-bgLight shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-brand-lines/15 px-5 py-4">
|
||||
<h2 className="text-base font-semibold text-brand-text">
|
||||
{t("chat.create.title")}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-brand-text/60 hover:bg-brand-lines/15 hover:text-brand-text transition-colors"
|
||||
aria-label={t("chat.create.close")}
|
||||
>
|
||||
<FiX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div className="flex border-b border-brand-lines/15 px-5 pt-4 gap-1">
|
||||
{(["DM", "GROUP"] as ChatTab[]).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => switchTab(type)}
|
||||
className={[
|
||||
"px-4 py-2 text-sm font-medium rounded-t-lg transition-colors",
|
||||
tab === type
|
||||
? "border-b-2 border-brand-accent text-brand-accent"
|
||||
: "text-brand-text/60 hover:text-brand-text",
|
||||
].join(" ")}
|
||||
>
|
||||
{type === "DM"
|
||||
? t("chat.create.tabDM")
|
||||
: t("chat.create.tabGroup")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="px-5 py-5 space-y-4">
|
||||
{/* Group name */}
|
||||
{tab === "GROUP" && (
|
||||
<Input
|
||||
label={t("chat.create.nameLabel")}
|
||||
placeholder={t("chat.create.namePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={255}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User search */}
|
||||
<div ref={searchRef} className="relative">
|
||||
<label className="mb-1.5 block text-sm font-medium text-brand-text/90">
|
||||
{tab === "DM"
|
||||
? t("chat.create.recipientLabel")
|
||||
: t("chat.create.membersLabel")}
|
||||
</label>
|
||||
|
||||
{/* Selected user chips */}
|
||||
{selectedUsers.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{selectedUsers.map((u) => (
|
||||
<span
|
||||
key={u.id}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-brand-accent/15 border border-brand-accent/30 px-2.5 py-1 text-xs text-brand-accent"
|
||||
>
|
||||
{u.username}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleUser(u)}
|
||||
className="hover:text-brand-text transition-colors"
|
||||
aria-label={`Remove ${u.username}`}
|
||||
>
|
||||
<FiX size={11} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search input */}
|
||||
{(tab === "GROUP" || selectedUsers.length === 0) && (
|
||||
<div className="relative">
|
||||
<FiSearch
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-brand-text/40"
|
||||
size={14}
|
||||
/>
|
||||
<input
|
||||
value={userQuery}
|
||||
onChange={(e) => {
|
||||
setUserQuery(e.target.value);
|
||||
setShowDropdown(true);
|
||||
}}
|
||||
onFocus={() => userQuery.length >= 2 && setShowDropdown(true)}
|
||||
placeholder={t("chat.create.searchPlaceholder")}
|
||||
className="w-full rounded-xl border border-brand-lines/25 bg-brand-bgLight/40 py-2.5 pl-8 pr-3 text-sm text-brand-text placeholder:text-brand-text/40 focus:border-brand-accent focus:outline-none focus:ring-2 focus:ring-brand-accent/30"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropdown */}
|
||||
{showDropdown && debouncedQuery.length >= 2 && (
|
||||
<div className="absolute left-0 right-0 top-full z-20 mt-1 max-h-52 overflow-y-auto rounded-xl border border-brand-lines/20 bg-brand-bg shadow-xl">
|
||||
{usersLoading && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spinner size={18} />
|
||||
</div>
|
||||
)}
|
||||
{!usersLoading && filteredUsers.length === 0 && (
|
||||
<p className="px-4 py-3 text-sm text-brand-text/50">
|
||||
{t("chat.create.noUsers")}
|
||||
</p>
|
||||
)}
|
||||
{filteredUsers.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
onClick={() => toggleUser(user)}
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-brand-lines/10 transition-colors"
|
||||
>
|
||||
<Avatar
|
||||
name={user.username}
|
||||
src={user.avatar ?? undefined}
|
||||
size={28}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-brand-text">
|
||||
{user.username}
|
||||
</div>
|
||||
{(user.first_name || user.last_name) && (
|
||||
<div className="truncate text-xs text-brand-text/50">
|
||||
{[user.first_name, user.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedUsers.some((s) => s.id === user.id) && (
|
||||
<FiCheck size={14} className="shrink-0 text-brand-accent" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormErrorBanner message={error} />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("chat.create.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={isPending}
|
||||
>
|
||||
{t("chat.create.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,6 +61,23 @@
|
||||
"empty": "Zatím žádné konverzace.",
|
||||
"new": "Nová konverzace"
|
||||
},
|
||||
"create": {
|
||||
"title": "Nová konverzace",
|
||||
"close": "Zavřít",
|
||||
"tabDM": "Přímá zpráva",
|
||||
"tabGroup": "Skupinový chat",
|
||||
"nameLabel": "Název skupiny",
|
||||
"namePlaceholder": "Zadejte název chatu...",
|
||||
"nameRequired": "Název skupiny je povinný.",
|
||||
"recipientLabel": "Příjemce",
|
||||
"membersLabel": "Přidat členy",
|
||||
"searchPlaceholder": "Hledat uživatele...",
|
||||
"noUsers": "Žádní uživatelé nenalezeni.",
|
||||
"recipientRequired": "Vyberte příjemce.",
|
||||
"cancel": "Zrušit",
|
||||
"submit": "Vytvořit",
|
||||
"error": "Nepodařilo se vytvořit konverzaci."
|
||||
},
|
||||
"room": {
|
||||
"selectChat": "Vyberte konverzaci nalevo",
|
||||
"typing": "{{user}} píše...",
|
||||
|
||||
Reference in New Issue
Block a user