feat(ui): unsaved changes warning + chat date separators
Unsaved changes: - New useUnsavedChanges hook: browser beforeunload warning - New useFormDirtyState hook: isDirty/markDirty/markClean tracking - SettingsPage: wired up dirty tracking with markClean on save Chat date separators: - DateSeparator component with centered date label and hr lines - Inserted between messages from different days - Formats: Today, Yesterday, or full date (e.g. "Monday, February 10") Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
279b2bc937
commit
0e063aafb3
4 changed files with 143 additions and 62 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import React, { useEffect, useRef, useMemo } from 'react';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import { useUser } from '@/features/auth/hooks/useUser';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
|
@ -13,6 +13,34 @@ import {
|
|||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
|
||||
function formatMessageDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor(
|
||||
(now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function DateSeparator({ date }: { date: string }) {
|
||||
const formatted = formatMessageDate(date);
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-caption shrink-0 px-2">{formatted}</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessages() {
|
||||
const { currentConversationId, conversations, messages, typingUsers } = useChatStore();
|
||||
const { data: user } = useUser();
|
||||
|
|
@ -80,71 +108,81 @@ export function ChatMessages() {
|
|||
<p>Aucun message dans cette conversation</p>
|
||||
</div>
|
||||
) : (
|
||||
conversationMessages.map((message) => {
|
||||
conversationMessages.map((message, index) => {
|
||||
const isOwn = message.sender_id === user?.id;
|
||||
const prevMessage = index > 0 ? conversationMessages[index - 1] : null;
|
||||
const showDateSeparator =
|
||||
!prevMessage ||
|
||||
new Date(message.created_at).toDateString() !==
|
||||
new Date(prevMessage.created_at).toDateString();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${isOwn ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`max-w-[70%] ${isOwn ? 'order-2' : 'order-1'}`}>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isOwn ? 'Vous' : `Utilisateur ${message.sender_id}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(message.created_at).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Card
|
||||
className={`${isOwn ? 'bg-primary text-primary-foreground' : ''}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className="text-sm"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeChatMessage(message.content),
|
||||
}}
|
||||
/>
|
||||
<React.Fragment key={message.id}>
|
||||
{showDateSeparator && (
|
||||
<DateSeparator date={message.created_at} />
|
||||
)}
|
||||
<div
|
||||
className={`flex ${isOwn ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`max-w-[70%] ${isOwn ? 'order-2' : 'order-1'}`}>
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isOwn ? 'Vous' : `Utilisateur ${message.sender_id}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(message.created_at).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Card
|
||||
className={`${isOwn ? 'bg-primary text-primary-foreground' : ''}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<p
|
||||
className="text-sm"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeChatMessage(message.content),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Réactions */}
|
||||
{message.reactions && Object.keys(message.reactions).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{Object.entries(message.reactions).map(([emoji, userIds]) => (
|
||||
<Button
|
||||
key={emoji}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{emoji} {userIds.length}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Réactions */}
|
||||
{message.reactions && Object.keys(message.reactions).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{Object.entries(message.reactions).map(([emoji, userIds]) => (
|
||||
<Button
|
||||
key={emoji}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{emoji} {userIds.length}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions sur le message */}
|
||||
<div className="flex items-center space-x-1 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2">
|
||||
<ThumbsUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2">
|
||||
<ThumbsDown className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2">
|
||||
<Reply className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2">
|
||||
<Smile className="h-3 w-3" />
|
||||
</Button>
|
||||
{/* Actions sur le message */}
|
||||
<div className="flex items-center space-x-1 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2">
|
||||
<ThumbsUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2">
|
||||
<ThumbsDown className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2">
|
||||
<Reply className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2">
|
||||
<Smile className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useUser } from '@/features/auth/hooks/useUser';
|
||||
import { usersApi } from '@/services/api/users';
|
||||
import { UserSettings } from '../types/settings';
|
||||
|
|
@ -11,6 +11,7 @@ import toast from '@/utils/toast';
|
|||
import { settingsSchema } from '../schemas/settingsSchema';
|
||||
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
|
||||
import { Save, Shield, Sliders } from 'lucide-react';
|
||||
import { useUnsavedChanges, useFormDirtyState } from '@/hooks/useUnsavedChanges';
|
||||
|
||||
function SettingsPageSkeleton() {
|
||||
return (
|
||||
|
|
@ -72,6 +73,13 @@ export function SettingsPage() {
|
|||
const [mutationError, setMutationError] = useState<Error | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const lastMutationRef = useRef<(() => Promise<void>) | null>(null);
|
||||
const { isDirty, markDirty, markClean } = useFormDirtyState();
|
||||
useUnsavedChanges(isDirty);
|
||||
|
||||
const handleSettingsChange = useCallback((newSettings: UserSettings) => {
|
||||
setSettings(newSettings);
|
||||
markDirty();
|
||||
}, [markDirty]);
|
||||
|
||||
const loadSettings = async () => {
|
||||
if (!user?.id) {
|
||||
|
|
@ -107,6 +115,7 @@ export function SettingsPage() {
|
|||
setMutationError(null);
|
||||
setRetryCount(0);
|
||||
lastMutationRef.current = null;
|
||||
markClean();
|
||||
};
|
||||
|
||||
lastMutationRef.current = performMutation;
|
||||
|
|
@ -166,7 +175,7 @@ export function SettingsPage() {
|
|||
<h2 className="font-bold text-lg">Global Preferences</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<SettingsTabs settings={settings} onChange={setSettings} />
|
||||
<SettingsTabs settings={settings} onChange={handleSettingsChange} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export { useKeyboardNavigation } from './useKeyboardNavigation';
|
|||
export { useGlobalKeyboardShortcuts } from './useGlobalKeyboardShortcuts';
|
||||
export { usePreload, usePreloadRoute } from './usePreload';
|
||||
export { useCopyToClipboard } from './useCopyToClipboard';
|
||||
export { useUnsavedChanges, useFormDirtyState } from './useUnsavedChanges';
|
||||
|
||||
// Hook types
|
||||
export type {
|
||||
|
|
|
|||
33
apps/web/src/hooks/useUnsavedChanges.ts
Normal file
33
apps/web/src/hooks/useUnsavedChanges.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useEffect, useCallback, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Hook that warns users when they try to navigate away with unsaved changes.
|
||||
* Shows a browser confirmation dialog on page unload and can be used
|
||||
* with React Router's navigation blocking.
|
||||
*/
|
||||
export function useUnsavedChanges(hasChanges: boolean) {
|
||||
useEffect(() => {
|
||||
if (!hasChanges) return;
|
||||
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [hasChanges]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to track form dirty state.
|
||||
* Returns isDirty and methods to mark dirty/clean.
|
||||
*/
|
||||
export function useFormDirtyState() {
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
const markDirty = useCallback(() => setIsDirty(true), []);
|
||||
const markClean = useCallback(() => setIsDirty(false), []);
|
||||
|
||||
return { isDirty, markDirty, markClean };
|
||||
}
|
||||
Loading…
Reference in a new issue