feat(hackathon): Improve dark mode and UI/UX in Hackathon website (#64)
* feat: update landing page * feat: Add favicon and SEO (JSON-LD schema, meta tags) - Add favicon image - Add JSON-LD schema for Hackathon event - Add react-helmet-async for managing meta tags * chore: remove react-helmet-async * chore: update landing page * chore: update UI for signup and login - add back button for better UX * feat: add image loading state and skeleton UI for team dashboard * fix: landing navbar not sticky to top, fix onboarding UI * feat(hackathon): update UI - Update dashboard, onboarding user, edit profile, and team layout to make it more consistent * fix: optimize placeholder banner image & minor fix dark mode * feat(hackathon): update landing page dark mode color scheme * feat(hackathon): update auth page dark mode color scheme * feat(hackathon): dashboard UI improvement and dark mode adjustment * feat(hackathon): browse team page - update dark mode color scheme - add some border style to give outline and depth - change emoji to icon * feat(hackathon): update team page - update dark mode color scheme for team page: view, team edit, and submit project page - fix bug in chat where messages are displayed double - make the form field size consistent * feat(hackathon): update dark mode onboarding and edit profile --------- Co-authored-by: Maulana Sodiqin <sodiqincahyana1@gmail.com> Co-authored-by: Muhammad Zakir Ramadhan <61570975+zakirkun@users.noreply.github.com>
This commit is contained in:
co-authored by
Maulana Sodiqin
Muhammad Zakir Ramadhan
parent
66b8b064ba
commit
bc5b072ad8
@@ -1,8 +1,15 @@
|
||||
import { FC, ReactElement, useState, useRef, useEffect } from 'react';
|
||||
import { FC, ReactElement, useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { useTeamById, useTeamMessages, useSendMessage, useDeleteMessage, useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import {
|
||||
useTeamById,
|
||||
useTeamMessages,
|
||||
useSendMessage,
|
||||
useDeleteMessage,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
const TeamChatPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
@@ -13,16 +20,54 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
|
||||
const { data: teamData } = useTeamById(teamId || '');
|
||||
const { data: messages, isLoading } = useTeamMessages(teamId || '');
|
||||
const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage(teamId || '');
|
||||
const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage(
|
||||
teamId || ''
|
||||
);
|
||||
const { mutateAsync: deleteMessage } = useDeleteMessage(teamId || '');
|
||||
|
||||
const team = teamData?.data;
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
// Message type used for UI rendering
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user?: {
|
||||
avatar?: string;
|
||||
fullname?: string;
|
||||
};
|
||||
message: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Inline delete confirmation UI state
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
|
||||
// Prepare messages: de-duplicate by id and sort by created_at
|
||||
const displayMessages = useMemo(() => {
|
||||
const raw: ChatMessage[] = Array.isArray(messages)
|
||||
? (messages as ChatMessage[])
|
||||
: [];
|
||||
const seen = new Set<string>();
|
||||
const dedup: ChatMessage[] = [];
|
||||
for (const m of raw) {
|
||||
const key = m.id ?? `${m.user_id}-${m.created_at}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
dedup.push(m);
|
||||
}
|
||||
}
|
||||
dedup.sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
return dedup;
|
||||
}, [messages]);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
}, [displayMessages]);
|
||||
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -38,11 +83,10 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
};
|
||||
|
||||
const handleDeleteMessage = async (messageId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this message?')) return;
|
||||
|
||||
try {
|
||||
await deleteMessage(messageId);
|
||||
toast.success('Message deleted');
|
||||
setDeleteTargetId(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete message:', error);
|
||||
toast.error('Failed to delete message');
|
||||
@@ -63,16 +107,23 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
<div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700 shadow-sm dark:shadow-neutral-950/50">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 shadow-sm dark:shadow-gray-950/50">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Team Chat</h1>
|
||||
<p className="text-gray-600 dark:text-neutral-400 text-sm mt-0.5">{team?.name}</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Team Chat
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mt-0.5">
|
||||
{team?.name}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/teams/${teamId}`)}
|
||||
>
|
||||
Back to Team
|
||||
</Button>
|
||||
</div>
|
||||
@@ -86,21 +137,27 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 dark:border-primary-400"></div>
|
||||
</div>
|
||||
) : messages && messages.length > 0 ? (
|
||||
) : displayMessages && displayMessages.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{messages.map((msg) => {
|
||||
{displayMessages.map((msg) => {
|
||||
const isOwnMessage = msg.user_id === currentUserId;
|
||||
const isLeader = team?.leader_id === currentUserId;
|
||||
const canDelete = isOwnMessage || isLeader;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
|
||||
key={`${msg.id ?? 'message'}-${msg.created_at}`}
|
||||
className={`flex ${
|
||||
isOwnMessage ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className={`flex gap-3 max-w-lg ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}>
|
||||
<div
|
||||
className={`flex gap-3 max-w-lg ${
|
||||
isOwnMessage ? 'flex-row-reverse' : 'flex-row'
|
||||
}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="shrink-0">
|
||||
{msg.user?.avatar ? (
|
||||
<img
|
||||
src={msg.user.avatar}
|
||||
@@ -115,13 +172,17 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
{/* Message Bubble */}
|
||||
<div className={`flex-1 ${isOwnMessage ? 'text-right' : 'text-left'}`}>
|
||||
<div className={`inline-block ${isOwnMessage ? 'items-end' : 'items-start'}`}>
|
||||
<div className={`flex-1 text-left`}>
|
||||
<div
|
||||
className={`inline-block ${
|
||||
isOwnMessage ? 'items-end' : 'items-start'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="font-semibold text-sm text-gray-900 dark:text-white">
|
||||
{isOwnMessage ? 'You' : msg.user?.fullname}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-neutral-500">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-500">
|
||||
{formatTime(msg.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -129,24 +190,58 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
className={`relative group rounded-2xl px-4 py-2.5 ${
|
||||
isOwnMessage
|
||||
? 'bg-blue-600 dark:bg-primary-600 text-white'
|
||||
: 'bg-white dark:bg-neutral-800 text-gray-900 dark:text-white border border-gray-200 dark:border-neutral-700'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{msg.message}</p>
|
||||
<p className="text-sm whitespace-pre-wrap wrap-break-word font-sans">
|
||||
{msg.message}
|
||||
</p>
|
||||
|
||||
{/* Delete button */}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => handleDeleteMessage(msg.id)}
|
||||
className={`absolute top-1 ${isOwnMessage ? 'left-1' : 'right-1'} opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-gray-200 dark:hover:bg-neutral-700 ${isOwnMessage ? 'hover:bg-blue-700 dark:hover:bg-primary-700' : ''}`}
|
||||
onClick={() =>
|
||||
setDeleteTargetId(
|
||||
deleteTargetId === msg.id ? null : msg.id
|
||||
)
|
||||
}
|
||||
className={`absolute top-1 ${
|
||||
isOwnMessage ? 'left-1' : 'right-1'
|
||||
} opacity-0 hover:opacity-100 p-1 rounded hover:bg-gray-200 dark:hover:bg-neutral-700 cursor-pointer ${
|
||||
isOwnMessage
|
||||
? 'hover:bg-blue-700 dark:hover:bg-primary-700'
|
||||
: ''
|
||||
}`}
|
||||
title="Delete message"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{/* Icon trash */}
|
||||
<Icon
|
||||
icon="mdi:trash-can-outline"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canDelete && deleteTargetId === msg.id && (
|
||||
<div
|
||||
className={`mt-2 flex gap-2 ${
|
||||
isOwnMessage ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleDeleteMessage(msg.id)}
|
||||
className="px-2 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700 cursor-pointer"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTargetId(null)}
|
||||
className="px-2 py-1 text-xs rounded bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-neutral-700 dark:text-white dark:hover:bg-neutral-600 cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,11 +252,14 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<div className="text-6xl mb-4">💬</div>
|
||||
<Icon
|
||||
icon="mdi:chat-outline"
|
||||
className="text-6xl mb-4 text-gray-400"
|
||||
/>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
No messages yet
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-neutral-400">
|
||||
<p className="text-gray-600 dark:text-gray-400 font-sans">
|
||||
Be the first to start the conversation!
|
||||
</p>
|
||||
</div>
|
||||
@@ -170,7 +268,7 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
{/* Message Input */}
|
||||
<div className="bg-white dark:bg-neutral-900 border-t dark:border-neutral-700 shadow-lg dark:shadow-neutral-950/50">
|
||||
<div className="bg-white dark:bg-gray-900 border-t dark:border-gray-700 shadow-lg dark:shadow-gray-950/50">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<form onSubmit={handleSendMessage} className="flex gap-3">
|
||||
<input
|
||||
@@ -178,7 +276,7 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
className="flex-1 px-4 py-3 border border-gray-300 dark:border-neutral-600 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-primary-500 focus:border-transparent"
|
||||
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-primary-500 focus:border-transparent"
|
||||
disabled={isSending}
|
||||
/>
|
||||
<Button
|
||||
@@ -193,8 +291,18 @@ const TeamChatPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
Send
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user