setup: route group

This commit is contained in:
Mahyar Khanbolooki
2025-07-31 19:13:24 +03:30
parent 118daf7cc0
commit 52b5b880d0
25 changed files with 169 additions and 111 deletions
+135
View File
@@ -0,0 +1,135 @@
'use client';
import { ef } from '@/lib/helpers/utfNumbers';
import { EmojiHappy, Microphone2, Paperclip2 } from 'iconsax-react';
import React, { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next';
type Props = object
type MessageModel = {
type: "sender" | "receiver",
senderName: string,
content: string,
date: string,
time: string,
}
function ChatIndex({ }: Props) {
const { t } = useTranslation('chat');
const [messages, setMessages] = useState<Array<MessageModel>>([
{
type: "sender",
senderName: "علی مصلحی",
content: 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است',
date: '1403/09/30',
time: '10:07'
},
{
type: 'receiver',
senderName: 'سیما فرهادی',
content: 'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است',
date: '1403/09/30',
time: '10:07'
},
]);
const addMessageOutgoing = (model: MessageModel) => {
setMessages((previous) => {
return [...previous, model]
})
}
const submitMessage = async (formData: FormData) => {
const content = formData.get('textMessage');
if (!content) return;
const model: MessageModel = {
type: 'sender',
senderName: 'علی مصلحی',
content: String(content),
date: '1403/09/30',
time: '10:07'
}
addMessageOutgoing(model);
};
const messagesMemo = useMemo(() => messages, [messages]);
return (
<section className="flex flex-col h-full pt-6">
<h1 className="font-medium">علی مصلحی</h1>
<div className="bg-container rounded-[30px] flex flex-col justify-between mt-6 flex-1 px-6 pt-7 pb-8 overflow-hidden">
<div className="overflow-y-auto flex flex-col-reverse">
<ul className="grid gap-6 pb-6 ">
{messagesMemo.map((v, i) => {
if (v.type === "sender") {
return (
<li key={i} className='bg-[#F6F7FA] rounded-[30px] rounded-tr-none p-6'>
<article>
<p className='text-xs2'>
{v.content}
</p>
<span dir='ltr' className='text-[11px] float-end text-disabled-text mt-2.5'>
{ef(`${v.time} | ${v.date}`)}
</span>
</article>
</li>
)
}
else {
return (
<li key={i} className='bg-[#EBEDF5] rounded-[30px] rounded-tl-none p-6'>
<article>
<h6 className='text-xs font-medium'>
{v.senderName}
</h6>
<p className='text-xs2 mt-2.5'>
{v.content}
</p>
<span dir='ltr' className='text-[11px] float-end text-disabled-text mt-2.5'>
{ef(`${v.time} | ${v.date}`)}
</span>
</article>
</li>
)
}
})}
</ul>
</div>
<section aria-labelledby={t("InputMessage.AriaLabelBy")} className="w-full">
<form
action={submitMessage}
className="focus-within:outline-blue-400 outline outline-transparent transition-colors duration-100 flex items-center gap-0 bg-transparent border border-[#D0D0D0] h-12 rounded-xl py-2.5 px-2"
>
<button type="button" className="active:bg-neutral-100 transition-colors duration-150 rounded-full p-1">
<Microphone2 className="stroke-foreground" size={24} />
</button>
<input
name="textMessage"
role="textbox"
autoComplete="off"
className="w-full text-[11px] h-full outline-none ms-1"
type="text"
placeholder={t("InputMessage.Placeholder")}
/>
<button type="button" className="active:bg-neutral-100 transition-colors duration-150 rounded-full p-1.5">
<EmojiHappy className="stroke-disabled2" size={20} />
</button>
<button type="button" className="active:bg-neutral-100 transition-colors duration-150 rounded-full p-1.5">
<Paperclip2 className="stroke-disabled2" size={20} />
</button>
</form>
</section>
</div>
</section>
)
}
export default ChatIndex
+140
View File
@@ -0,0 +1,140 @@
'use client';
import { motion } from 'framer-motion';
import Image from 'next/image';
import Link from 'next/link';
import React, { useEffect, useRef, useState } from 'react';
const users = [
{ id: 0, name: '1 علی مصلحی', table: 12, avatar: '/assets/images/user-avatar.png' },
{ id: 1, name: '2 علی مصلحی', table: 10, avatar: '/assets/images/user-avatar.png' },
{ id: 2, name: '3 علی مصلحی', table: 11, avatar: '/assets/images/user-avatar.png' },
{ id: 3, name: '4 علی مصلحی', table: 5, avatar: '/assets/images/user-avatar.png' },
{ id: 4, name: '5 علی مصلحی', table: 2, avatar: '/assets/images/user-avatar.png' },
{ id: 5, name: '6 علی مصلحی', table: 1, avatar: '/assets/images/user-avatar.png' },
];
function ChatNearby() {
const containerRef = useRef<HTMLElement>(null);
const [positions, setPositions] = useState<{ top: number; left: number }[]>([]);
useEffect(() => {
if (containerRef.current) {
const maxW = containerRef.current.offsetWidth - 50;
const maxH = containerRef.current.offsetHeight - 80;
const elementSize = 110; // approximate size of each user element
const padding = 80; // minimum distance between elements
const positions: { top: number; left: number }[] = [];
const isTooClose = (top: number, left: number) => {
return positions.some(p => {
const dx = p.left - left;
const dy = p.top - top;
const distance = Math.sqrt(dx * dx + dy * dy);
return distance < padding;
});
};
users.forEach(() => {
let top = 0;
let left = 0;
let tries = 0;
do {
top = Math.random() * (maxH - elementSize);
left = Math.random() * (maxW - elementSize);
tries++;
} while (isTooClose(top, left) && tries < 100);
positions.push({ top, left });
});
setPositions(positions);
}
}, []);
return (
<section ref={containerRef} className="h-full relative">
<section className="relative h-full w-full mt-7">
<motion.div
className='size-[1145px] rounded-full fixed top-1/2 left-1/2 -translate-1/2'
style={{ background: 'linear-gradient(180deg, #EAECF0 4.52%, #DBDEE8 95.02%)', willChange: 'transform' }}
>
</motion.div>
<motion.div
animate={{ scale: [1, 0.95, 1] }}
transition={{ repeat: Infinity, duration: 13 }}
className='size-[824px] rounded-full fixed top-1/2 left-1/2 -translate-1/2'
style={{ background: 'linear-gradient(180deg, #EBF0FC 4.52%, #EBF0FC 95.02%)', willChange: 'transform' }}
>
</motion.div>
<motion.div
animate={{ scale: [0.9, 1] }}
transition={{ repeat: Infinity, duration: 4, repeatType: 'reverse' }}
className='size-[617px] rounded-full fixed top-1/2 left-1/2 -translate-1/2'
style={{ background: 'linear-gradient(180deg, #E6EBFA 0%, #DFE2EC 100%)', willChange: 'transform' }}
>
</motion.div>
<motion.div
animate={{ scale: [1.02, 0.95] }}
transition={{ repeat: Infinity, duration: 2, repeatType: 'reverse' }}
className='size-[425px] rounded-full fixed top-1/2 left-1/2 -translate-1/2'
style={{ background: 'linear-gradient(180deg, #E1E5EF 0%, #D8DDE8 60.2%, #D2D7E3 100%)', willChange: 'transform' }}
>
</motion.div>
<motion.div
animate={{ scale: [1.05, 1] }}
transition={{ repeat: Infinity, duration: 2, repeatType: 'reverse' }}
className='size-[292px] rounded-full fixed top-1/2 left-1/2 -translate-1/2'
style={{ background: 'linear-gradient(180deg, #EAEDF5 0%, #E4E7F0 46.52%, #DDDFEB 100%)', willChange: 'transform' }}
>
</motion.div>
<motion.div
animate={{ scale: [1, 1.05] }}
transition={{ repeat: Infinity, duration: 2, repeatType: 'reverse' }}
className='size-[174px] rounded-full fixed top-1/2 left-1/2 -translate-1/2'
style={{ background: 'linear-gradient(180deg, #F3F7FF 0%, #F2F5FC 100%)', willChange: 'transform' }}
>
</motion.div>
<motion.div
animate={{ scale: [0, 1.2], opacity: [1, 0] }}
transition={{ repeat: Infinity, duration: 2 }}
className='h-full w-dvh rounded-full fixed top-1/2 left-1/2 -translate-1/2 outline outline-solid outline-blue-400'
style={{ willChange: 'transform, opacity' }}
>
</motion.div>
<div>
{positions.length === users.length &&
users.map((v, i) => (
<Link
href={`${v.id}`}
prefetch={false}
key={v.id}
style={{ position: 'absolute', top: positions[i].top, left: positions[i].left }}
className="justify-items-center"
>
<Image height={46} width={46} alt="user avatar" src={v.avatar} />
<h6 className="mt-2 text-xs font-medium">{v.name}</h6>
<div className="mt-[3px] text-xs2 font-medium bg-disabled-text text-accent rounded-[5px] px-[5px] py-[3px]">
میز {v.table}
</div>
</Link>
))}
</div>
</section>
</section>
);
}
export default ChatNearby;
+252
View File
@@ -0,0 +1,252 @@
'use client';
import Button from '@/components/button/PrimaryButton';
import SearchBox from '@/components/input/SearchBox';
import BlurredOverlayContainer from '@/components/overlays/BlurredOverlayContainer';
import { ScrollArea } from '@/components/ui/scrollarea';
import { Radar2, Trash } from 'iconsax-react';
import { Check, CheckCheck } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { LongPressCallbackMeta, LongPressReactEvents, useLongPress } from 'use-long-press'
import { useRouter } from 'next/navigation';
type Props = object
type ChatEntryModel = {
id: string,
username: string,
avatarUrl: string,
update?: {
time: string,
content: string,
count?: number,
seen?: boolean
}
}
function ChatIndex({ }: Props) {
const { t } = useTranslation('chat');
const { t: tDeleteModal } = useTranslation('common', {
keyPrefix: 'DeleteChatModal'
});
const [search, setSearch] = useState('');
const [selectedChats, setSelectedChats] = useState<Array<string>>([]);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const router = useRouter();
const [chatsList, setChatList] = useState<Array<ChatEntryModel>>([
{
id: '0',
username: 'علی مصلحی',
avatarUrl: '/assets/images/user-avatar.png',
update: {
time: '12:03',
content: 'چه خبر',
count: 2,
}
},
{
id: '1',
username: 'علی مصلحی',
avatarUrl: '/assets/images/user-avatar.png',
update: {
time: '12:03',
content: 'چه خبر',
count: 2,
}
},
{
id: '2',
username: 'علی مصلحی',
avatarUrl: '/assets/images/user-avatar.png',
update: {
time: '12:03',
content: 'چه خبر',
seen: true
}
},
{
id: '3',
username: 'علی مصلحی',
avatarUrl: '/assets/images/user-avatar.png',
update: {
time: '12:03',
content: 'چه خبر',
seen: true
}
},
{
id: '4',
username: 'علی مصلحی',
avatarUrl: '/assets/images/user-avatar.png',
update: {
time: '12:03',
content: 'چه خبر',
seen: true
}
},
{
id: '5',
username: 'علی مصلحی',
avatarUrl: '/assets/images/user-avatar.png',
update: {
time: '12:03',
content: 'چه خبر',
seen: false
}
},
]);
const toggleDeleteModalVisiblity = (e?: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
setDeleteModalVisible((state) => {
if (state) {
setSelectedChats([]);
}
return !state;
});
};
const deleteSelectedChats = () => {
if (setSelectedChats.length <= 0) return;
setChatList((prev) => {
return prev.filter(x => !selectedChats.includes(x.id));
})
toggleDeleteModalVisiblity();
}
const longPressCallback = useCallback((e: LongPressReactEvents<Element>, ctx: LongPressCallbackMeta<unknown>) => {
e.preventDefault();
if (!ctx?.context) return;
const id = String(ctx.context);
setSelectedChats((prev) => {
if (prev.includes(id)) return prev.filter(x => x !== id);
return [...prev, id]
})
}, [])
const longPressCancelledCallback = useCallback((id: string, reason?: string) => {
if (reason && reason !== 'cancelled-by-release') return;
if (selectedChats.length > 0) {
setSelectedChats((prev) => {
if (prev.includes(id)) return prev.filter(x => x !== id);
return [...prev, id]
})
} else {
router.push(`chat/${id}`)
}
}, [router, selectedChats.length])
const longPress = useLongPress(longPressCallback, {
onCancel: (event, ctx) => longPressCancelledCallback(String(ctx.context), String(ctx.reason)),
threshold: 500, // In milliseconds
captureEvent: true, // Event won't get cleared after React finish processing it
cancelOnMovement: 100, // Square side size (in pixels) inside which movement won't cancel long press
cancelOutsideElement: true, // Cancel long press when moved mouse / pointer outside element while pressing
});
return (
<div className='pt-8 mb-8'>
<section className='flex justify-between items-center gap-x-4'>
<SearchBox placeholder={t('InputSearch.Placeholder')} value={search} onChange={(e) => setSearch(e.target.value)} />
{selectedChats.length > 0 ?
<span onClick={toggleDeleteModalVisiblity} className='bg-container p-2 rounded-xl active:bg-gray-50'>
<Trash size={24} className='stroke-primary' />
</span>
:
<Link href={'chat/nearby'} className='bg-container p-2 rounded-xl active:bg-gray-50'>
<Radar2 size={24} className='stroke-primary' />
</Link>
}
</section>
<section>
<ScrollArea dir='rtl' className='w-full h-full'>
<ul className='pt-4'>
{chatsList.map((chat, i) => {
return (
<li
key={i}
{...longPress(chat.id)}
className='select-none flex items-center gap-x-2 hover:brightness-105 cursor-default active:brightness-102 p-2 bg-background rounded-xl'
>
<div className='size-[49px] min-w-[49px]'>
{
selectedChats.includes(chat.id) ?
<div className='size-[49px] relative bg-primary rounded-full'>
<Check className='absolute top-1/2 left-1/2 -translate-1/2 stroke-white' />
</div>
:
<Image
src={chat.avatarUrl}
height={49}
width={49}
alt={`${chat.username}'s avatar`}
unoptimized
/>
}
</div>
<div className='flex flex-col justify-between w-full gap-2'>
<h5 className='text-sm2 font-medium'>{chat.username}</h5>
<p className='text-xs'>{chat.update?.content}</p>
</div>
<div className='flex flex-col justify-between items-end gap-2'>
<span className='text-sm2 text-disabled2'>{chat.update?.time}</span>
{chat.update?.count !== undefined && chat.update?.count > 0 ?
<span className='text-xs w-5 h-5 relative bg-foreground text-white rounded-full'>
<span className='absolute top-1/2 left-1/2 -translate-1/2 pt-1'>{chat.update?.count}</span>
</span>
:
chat.update?.seen !== undefined &&
chat.update?.seen ?
<CheckCheck className='stroke-foreground' size={16} /> :
<Check className='stroke-foreground' size={16} />
}
</div>
</li>
)
})}
</ul>
</ScrollArea>
</section>
<BlurredOverlayContainer visible={deleteModalVisible} onClick={toggleDeleteModalVisiblity} inDuration={100} outDuration={100} outDelay={75}>
<div className="absolute top-1/2 left-0 w-full px-6">
<motion.div
animate={{ y: deleteModalVisible ? [10, 0] : [0, 10] }}
transition={{ duration: 0.1, ease: 'easeInOut' }}
className="top-1/2 absolute left-1/2 min-w-xs w-full max-w-sm text-center -translate-1/2 px-6 pt-8 pb-9 drop-shadow-container bg-white/63 rounded-4xl"
>
<h2 className="text-base font-medium">
{tDeleteModal('Heading')}
</h2>
<p className="text-xs font-medium mt-6">
{tDeleteModal('Description')}
</p>
<div className="grid grid-cols-2 gap-5.5 mt-8">
<Button onClick={deleteSelectedChats} className="text-sm font-normal">
{tDeleteModal('ButtonOk')}
</Button>
<Button onClick={toggleDeleteModalVisiblity} className="bg-disabled! text-disabled2! text-sm font-normal">
{tDeleteModal('ButtonCancel')}
</Button>
</div>
</motion.div>
</div>
</BlurredOverlayContainer>
</div>
)
}
export default ChatIndex