feat(web): sprint1 ui core with global toasts and improved chat layout
Some checks failed
CI / test (push) Failing after 19s
Some checks failed
CI / test (push) Failing after 19s
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { ToastViewport } from "../components/ToastViewport";
|
||||
import { AuthPage } from "../pages/AuthPage";
|
||||
import { ChatsPage } from "../pages/ChatsPage";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
@@ -27,5 +28,10 @@ export function App() {
|
||||
if (!accessToken || !me) {
|
||||
return <AuthPage />;
|
||||
}
|
||||
return <ChatsPage />;
|
||||
return (
|
||||
<>
|
||||
<ChatsPage />
|
||||
<ToastViewport />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } fr
|
||||
import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, UserSearchItem } from "../chat/types";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { useUiStore } from "../store/uiStore";
|
||||
|
||||
interface Props {
|
||||
chatId: number | null;
|
||||
@@ -28,6 +29,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
const me = useAuthStore((s) => s.me);
|
||||
const loadChats = useChatStore((s) => s.loadChats);
|
||||
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
|
||||
const showToast = useUiStore((s) => s.showToast);
|
||||
const [chat, setChat] = useState<ChatDetail | null>(null);
|
||||
const [members, setMembers] = useState<ChatMember[]>([]);
|
||||
const [memberUsers, setMemberUsers] = useState<Record<number, AuthUser>>({});
|
||||
@@ -223,6 +225,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
setInviteLink(link.invite_url);
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(link.invite_url);
|
||||
showToast("Invite link copied");
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to create invite link");
|
||||
@@ -489,7 +492,9 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(attachmentCtx.url);
|
||||
showToast("Link copied");
|
||||
} catch {
|
||||
showToast("Copy failed");
|
||||
return;
|
||||
} finally {
|
||||
setAttachmentCtx(null);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import type { Message, MessageReaction } from "../chat/types";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { useChatStore } from "../store/chatStore";
|
||||
import { useUiStore } from "../store/uiStore";
|
||||
import { formatTime } from "../utils/format";
|
||||
import { formatMessageHtml } from "../utils/formatMessage";
|
||||
|
||||
@@ -45,6 +46,7 @@ export function MessageList() {
|
||||
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
|
||||
const removeMessage = useChatStore((s) => s.removeMessage);
|
||||
const restoreMessages = useChatStore((s) => s.restoreMessages);
|
||||
const showToast = useUiStore((s) => s.showToast);
|
||||
|
||||
const [ctx, setCtx] = useState<ContextMenuState>(null);
|
||||
const [forwardMessageId, setForwardMessageId] = useState<number | null>(null);
|
||||
@@ -58,7 +60,6 @@ export function MessageList() {
|
||||
const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null);
|
||||
const [undoTick, setUndoTick] = useState(0);
|
||||
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
const messages = useMemo(() => {
|
||||
if (!activeChatId) {
|
||||
@@ -113,14 +114,6 @@ export function MessageList() {
|
||||
setReactionsByMessage({});
|
||||
}, [activeChatId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => setToast(null), 2200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingDelete) {
|
||||
return;
|
||||
@@ -339,6 +332,12 @@ export function MessageList() {
|
||||
|
||||
{messages.map((message, messageIndex) => {
|
||||
const own = message.sender_id === me?.id;
|
||||
const prev = messageIndex > 0 ? messages[messageIndex - 1] : null;
|
||||
const groupedWithPrev = Boolean(
|
||||
prev &&
|
||||
prev.sender_id === message.sender_id &&
|
||||
Math.abs(new Date(message.created_at).getTime() - new Date(prev.created_at).getTime()) < 4 * 60 * 1000
|
||||
);
|
||||
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
|
||||
const isSelected = selectedIds.has(message.id);
|
||||
const messageReactions = reactionsByMessage[message.id] ?? [];
|
||||
@@ -355,13 +354,13 @@ export function MessageList() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`}>
|
||||
<div className={`${groupedWithPrev ? "mb-1" : "mb-2"} flex ${own ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
id={`message-${message.id}`}
|
||||
className={`max-w-[90%] rounded-2xl px-3 py-2.5 shadow-sm md:max-w-[70%] ${
|
||||
className={`max-w-[90%] px-3 py-2.5 shadow-sm md:max-w-[70%] ${
|
||||
own
|
||||
? "rounded-br-md bg-gradient-to-b from-sky-500/95 to-sky-600/90 text-slate-950"
|
||||
: "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
|
||||
? `${groupedWithPrev ? "rounded-2xl rounded-tr-md" : "rounded-2xl rounded-br-md"} bg-gradient-to-b from-sky-500/95 to-sky-600/90 text-slate-950`
|
||||
: `${groupedWithPrev ? "rounded-2xl rounded-tl-md" : "rounded-2xl rounded-bl-md"} border border-slate-700/60 bg-slate-900/80 text-slate-100`
|
||||
} ${isSelected ? "ring-2 ring-sky-400/80" : ""} ${focusedMessageId === message.id ? "ring-2 ring-amber-300" : ""}`}
|
||||
onClick={() => {
|
||||
if (selectedIds.size > 0) {
|
||||
@@ -534,9 +533,9 @@ export function MessageList() {
|
||||
}
|
||||
try {
|
||||
await downloadFileFromUrl(url);
|
||||
setToast("File downloaded");
|
||||
showToast("File downloaded");
|
||||
} catch {
|
||||
setToast("Download failed");
|
||||
showToast("Download failed");
|
||||
} finally {
|
||||
setCtx(null);
|
||||
}
|
||||
@@ -554,9 +553,9 @@ export function MessageList() {
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setToast("Link copied");
|
||||
showToast("Link copied");
|
||||
} catch {
|
||||
setToast("Copy failed");
|
||||
showToast("Copy failed");
|
||||
} finally {
|
||||
setCtx(null);
|
||||
}
|
||||
@@ -657,11 +656,6 @@ export function MessageList() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{toast ? (
|
||||
<div className="pointer-events-none absolute bottom-3 left-0 right-0 z-[120] flex justify-center px-3">
|
||||
<div className="rounded-lg border border-slate-700/80 bg-slate-900/95 px-3 py-2 text-xs text-slate-100 shadow-xl">{toast}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
30
web/src/components/ToastViewport.tsx
Normal file
30
web/src/components/ToastViewport.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useUiStore } from "../store/uiStore";
|
||||
|
||||
export function ToastViewport() {
|
||||
const message = useUiStore((s) => s.toastMessage);
|
||||
const clearToast = useUiStore((s) => s.clearToast);
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => clearToast(), 2200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [message, clearToast]);
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed bottom-3 left-0 right-0 z-[300] flex justify-center px-3">
|
||||
<div className="rounded-lg border border-slate-700/80 bg-slate-900/95 px-3 py-2 text-xs text-slate-100 shadow-xl">
|
||||
{message}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,21 +104,21 @@ export function ChatsPage() {
|
||||
}, [notificationsOpen]);
|
||||
|
||||
return (
|
||||
<main className="h-screen w-full p-2 text-text md:p-4">
|
||||
<div className="mx-auto flex h-full max-w-[1500px] gap-2 md:gap-4">
|
||||
<main className="h-screen w-full p-2 text-text md:p-3">
|
||||
<div className="mx-auto flex h-full max-w-[1500px] gap-2 md:gap-3">
|
||||
<section className={`tg-panel overflow-hidden rounded-2xl ${activeChatId ? "hidden md:block md:w-[360px]" : "w-full md:w-[360px]"}`}>
|
||||
<ChatList />
|
||||
</section>
|
||||
|
||||
<section className={`tg-panel tg-chat-wallpaper min-w-0 flex-1 overflow-hidden rounded-2xl ${activeChatId ? "flex" : "hidden md:flex"} flex-col`}>
|
||||
<div className="flex items-center justify-between border-b border-slate-700/50 bg-slate-900/55 px-4 py-3">
|
||||
<div className="flex h-16 items-center justify-between border-b border-slate-700/50 bg-slate-900/65 px-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button className="rounded-full bg-slate-700/70 px-2 py-1 text-xs md:hidden" onClick={() => setActiveChatId(null)}>
|
||||
Back
|
||||
</button>
|
||||
{activeChatId ? (
|
||||
<button className="rounded-full bg-slate-700/70 px-2 py-1 text-xs" onClick={() => setInfoOpen(true)}>
|
||||
Info
|
||||
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-700/70 text-xs font-semibold" onClick={() => setInfoOpen(true)}>
|
||||
i
|
||||
</button>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
@@ -126,9 +126,9 @@ export function ChatsPage() {
|
||||
<p className="truncate text-xs text-slate-300/80">{activeChat ? headerMetaLabel(activeChat) : "Select a chat"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
className="relative rounded-full bg-slate-700/70 px-3 py-1.5 text-xs font-semibold hover:bg-slate-600/80"
|
||||
className="relative rounded-full bg-slate-700/70 px-2.5 py-1.5 text-xs font-semibold hover:bg-slate-600/80"
|
||||
onClick={() => setNotificationsOpen(true)}
|
||||
>
|
||||
Notifications
|
||||
@@ -136,10 +136,10 @@ export function ChatsPage() {
|
||||
<span className="ml-1 rounded-full bg-sky-500 px-1.5 py-0.5 text-[10px] text-slate-950">{notifications.length}</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button className="rounded-full bg-slate-700/70 px-3 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={() => setSearchOpen(true)}>
|
||||
<button className="rounded-full bg-slate-700/70 px-2.5 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={() => setSearchOpen(true)}>
|
||||
Search
|
||||
</button>
|
||||
<button className="rounded-full bg-slate-700/70 px-3 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={logout}>
|
||||
<button className="rounded-full bg-slate-700/70 px-2.5 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
14
web/src/store/uiStore.ts
Normal file
14
web/src/store/uiStore.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface UiState {
|
||||
toastMessage: string | null;
|
||||
showToast: (message: string) => void;
|
||||
clearToast: () => void;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>((set) => ({
|
||||
toastMessage: null,
|
||||
showToast: (message) => set({ toastMessage: message }),
|
||||
clearToast: () => set({ toastMessage: null })
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user