Правки профиля, работа с ссылками
This commit is contained in:
BIN
apps/server-net/uploads/b7d55a9a-c7cd-413f-92b8-e221ea3782ac.png
Normal file
BIN
apps/server-net/uploads/b7d55a9a-c7cd-413f-92b8-e221ea3782ac.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 MiB |
@@ -39,7 +39,7 @@ interface GroupSettingsProps {
|
||||
export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
const { user } = useAuthStore();
|
||||
const { updateChat } = useChatStore();
|
||||
const { t } = useLang();
|
||||
const { t, lang } = useLang();
|
||||
|
||||
const currentMember = chat.members.find((m) => m.user.id === user?.id);
|
||||
const [removeTargetId, setRemoveTargetId] = useState<string | null>(null);
|
||||
@@ -240,17 +240,72 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTabData(activeTab);
|
||||
}, [activeTab]);
|
||||
loadTabData('media');
|
||||
loadTabData('files');
|
||||
loadTabData('links');
|
||||
}, [chat.id]);
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||
const allMedia = sharedMedia.flatMap(msg => (msg.media || []).map(m => ({
|
||||
...m,
|
||||
url: getMediaUrl(m.url),
|
||||
thumbnail: m.thumbnail ? getMediaUrl(m.thumbnail) : undefined,
|
||||
messageId: msg.id
|
||||
messageId: msg.id,
|
||||
createdAt: msg.createdAt
|
||||
})));
|
||||
|
||||
const sortedMedia = [...allMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
const sortedFiles = [...sharedFiles].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
const sortedLinks = [...sharedLinks].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
const renderGrouped = <T extends { createdAt: string }>(
|
||||
sortedItems: T[],
|
||||
renderItem: (item: T, originalIndex: number) => React.ReactNode,
|
||||
gridClass?: string
|
||||
) => {
|
||||
let currentGroup: { dateStr: string; items: {item: T, idx: number}[] } | null = null;
|
||||
const groups: { dateStr: string; items: {item: T, idx: number}[] }[] = [];
|
||||
|
||||
sortedItems.forEach((item, idx) => {
|
||||
const date = new Date(item.createdAt);
|
||||
const dateStr = date.toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
if (currentGroup?.dateStr !== dateStr) {
|
||||
currentGroup = { dateStr, items: [] };
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
currentGroup.items.push({item, idx});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 pb-4 px-1">
|
||||
{groups.map((g, i) => (
|
||||
<div key={i}>
|
||||
<div className="sticky top-0 z-10 bg-black/60 backdrop-blur-md px-3 py-1.5 mb-1.5 shadow-sm border-y border-white/5">
|
||||
<span className="text-[10px] font-bold text-vortex-300 uppercase tracking-widest">{g.dateStr}</span>
|
||||
</div>
|
||||
<div className={gridClass || "flex flex-col gap-0.5"}>
|
||||
{g.items.map(({item, idx}) => renderItem(item, idx))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const tabsConfig = [
|
||||
{ key: 'media' as const, label: t('mediaTab'), icon: ImageIcon, count: sortedMedia.length },
|
||||
{ key: 'files' as const, label: t('filesTab'), icon: FileText, count: sortedFiles.flatMap(msg => msg.media || []).length },
|
||||
{ key: 'links' as const, label: t('linksTab'), icon: LinkIcon, count: sortedLinks.flatMap(msg => msg.links || []).length },
|
||||
];
|
||||
|
||||
const availableTabs = tabsConfig.filter(tab => !loadedTabs.has(tab.key) || tab.count > 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedTabs.size === 3 && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) {
|
||||
setActiveTab(availableTabs[0].key);
|
||||
}
|
||||
}, [loadedTabs, activeTab]); // availableTabs removed from dependencies to avoid infinite loops since its reference runs on every render
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
@@ -423,37 +478,36 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
|
||||
|
||||
{/* Media / Files / Links Tabs */}
|
||||
<div className="mx-4 mb-6 border border-white/5 bg-black/20 rounded-2xl overflow-hidden backdrop-blur-xl">
|
||||
<div className="flex border-b border-white/5">
|
||||
{[
|
||||
{ key: 'media' as const, label: t('mediaTab'), icon: ImageIcon },
|
||||
{ key: 'files' as const, label: t('filesTab'), icon: FileText },
|
||||
{ key: 'links' as const, label: t('linksTab'), icon: LinkIcon },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex-1 flex flex-col items-center justify-center gap-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-all ${
|
||||
activeTab === tab.key
|
||||
? 'bg-white/5 text-vortex-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={16} />
|
||||
<span className="truncate w-full px-1">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{availableTabs.length > 0 ? (
|
||||
<div className="mx-4 mb-6 border border-white/5 bg-black/20 rounded-2xl overflow-hidden backdrop-blur-xl">
|
||||
<div className="flex border-b border-white/5">
|
||||
{availableTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-bold uppercase tracking-widest transition-all ${
|
||||
activeTab === tab.key
|
||||
? 'bg-white/5 text-vortex-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<tab.icon size={16} />
|
||||
{loadedTabs.has(tab.key) && <span className="text-xs bg-black/40 px-1.5 rounded-full">{tab.count}</span>}
|
||||
</div>
|
||||
<span className="truncate w-full px-1">{tab.label as string}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-[200px] max-h-[300px] overflow-y-auto">
|
||||
<div className="min-h-[200px] max-h-[300px] overflow-y-auto">
|
||||
{tabLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 size={24} className="text-zinc-500 animate-spin" />
|
||||
</div>
|
||||
) : activeTab === 'media' ? (
|
||||
allMedia.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-0.5 p-1">
|
||||
{allMedia.map((m, idx) => (
|
||||
sortedMedia.length > 0 ? (
|
||||
renderGrouped(sortedMedia, (m, idx) => (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
@@ -486,18 +540,17 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
), "grid grid-cols-3 gap-0.5 px-1")
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">{t('sharedPhotos')}</p>
|
||||
</div>
|
||||
)
|
||||
) : activeTab === 'files' ? (
|
||||
sharedFiles.length > 0 ? (
|
||||
<div className="divide-y divide-white/5">
|
||||
{sharedFiles.flatMap((msg) =>
|
||||
(msg.media || []).map((m) => (
|
||||
sortedFiles.length > 0 ? (
|
||||
renderGrouped(sortedFiles, (msg, idx) => (
|
||||
<div key={msg.id} className="divide-y divide-border border-b border-border">
|
||||
{(msg.media || []).map((m) => (
|
||||
<div key={m.id} className="relative group/file">
|
||||
<a
|
||||
href={getMediaUrl(m.url)}
|
||||
@@ -516,19 +569,18 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
<Download size={14} className="text-zinc-600" />
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">{t('sharedFiles')}</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
sharedLinks.length > 0 ? (
|
||||
<div className="divide-y divide-white/5">
|
||||
{sharedLinks.map((msg) => (
|
||||
<div key={msg.id} className="p-4 hover:bg-white/5 transition-colors">
|
||||
sortedLinks.length > 0 ? (
|
||||
renderGrouped(sortedLinks, (msg, idx) => (
|
||||
<div key={msg.id} className="p-4 hover:bg-white/5 transition-colors border-b border-white/5">
|
||||
{msg.links?.map((link, i) => (
|
||||
<a
|
||||
key={i}
|
||||
@@ -543,16 +595,25 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
))}
|
||||
{msg.content && <p className="text-[11px] text-zinc-500 line-clamp-1">{msg.content}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">{t('sharedLinks')}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : loadedTabs.size === 3 ? (
|
||||
<div className="mx-4 mb-6 flex flex-col items-center justify-center py-10 px-4 text-center border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
|
||||
<ImageIcon size={32} className="text-zinc-600 mb-3" />
|
||||
<p className="text-sm text-zinc-500">{(t('sharedPhotos' as any) || 'Нет вложений') as string}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-4 mb-6 flex items-center justify-center py-10 border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
|
||||
<Loader2 size={24} className="text-zinc-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members */}
|
||||
<div className="px-4 pb-4">
|
||||
@@ -700,10 +761,7 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
<AnimatePresence>
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
images={sharedMedia.flatMap(msg => (msg.media || []).map(m => ({
|
||||
url: getMediaUrl(m.url),
|
||||
type: m.type
|
||||
})))}
|
||||
images={sortedMedia.map((m) => ({ url: getMediaUrl(m.url), type: m.type }))}
|
||||
initialIndex={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
|
||||
99
apps/web/src/components/LinkPreview.tsx
Normal file
99
apps/web/src/components/LinkPreview.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface LinkPreviewProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface MicrolinkData {
|
||||
publisher?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: { url: string };
|
||||
logo?: { url: string };
|
||||
}
|
||||
|
||||
export default function LinkPreview({ url }: LinkPreviewProps) {
|
||||
const [data, setData] = useState<MicrolinkData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
setLoading(true);
|
||||
|
||||
// Check cache first to avoid rate limiting
|
||||
const cacheKey = `link_preview_${url}`;
|
||||
const cached = sessionStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached);
|
||||
if (isMounted) {
|
||||
setData(parsed);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
fetch(`https://api.microlink.io?url=${encodeURIComponent(url)}`)
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
if (isMounted && res.status === 'success' && res.data) {
|
||||
setData(res.data);
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(res.data));
|
||||
}
|
||||
if (isMounted) setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch link preview:', err);
|
||||
if (isMounted) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mt-2 text-xs text-vortex-400 opacity-70 italic border-l-[3px] border-vortex-500/50 pl-2">
|
||||
Загрузка предпросмотра...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || (!data.title && !data.description && !data.image)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Domain for publisher fallback
|
||||
let domain = data.publisher;
|
||||
if (!domain) {
|
||||
try {
|
||||
domain = new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block mt-2 border-l-[3px] border-vortex-500 bg-black/20 rounded-r-lg overflow-hidden hover:bg-black/30 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-2.5 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-vortex-400">
|
||||
{data.logo?.url && <img src={data.logo.url} alt="" className="w-3.5 h-3.5 rounded-sm object-cover" />}
|
||||
<span className="truncate">{domain}</span>
|
||||
</div>
|
||||
{data.title && <div className="text-sm font-bold text-white leading-tight break-words">{data.title}</div>}
|
||||
{data.description && <div className="text-[13px] text-zinc-300 line-clamp-3 leading-snug">{data.description}</div>}
|
||||
</div>
|
||||
{data.image?.url && (
|
||||
<div className="w-full relative overflow-hidden bg-black/20" style={{ maxHeight: '300px' }}>
|
||||
<img src={data.image.url} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { useLang } from '../lib/i18n';
|
||||
import { extractWaveform, getMediaUrl } from '../lib/utils';
|
||||
import type { Message, MediaItem, Reaction, ChatMember } from '../lib/types';
|
||||
import ImageLightbox from './ImageLightbox';
|
||||
import LinkPreview from './LinkPreview';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
@@ -303,11 +304,28 @@ function MessageBubble({
|
||||
const senderName = message.sender?.displayName || message.sender?.username || '';
|
||||
const senderAvatar = message.sender?.avatar;
|
||||
|
||||
const firstUrlMatch = message.content?.match(/https?:\/\/[^\s]+/);
|
||||
const firstUrl = firstUrlMatch ? firstUrlMatch[0] : null;
|
||||
|
||||
const renderFormattedText = (text: string) => {
|
||||
if (!text) return text;
|
||||
const parts = text.split(/(\*\*[\s\S]*?\*\*|\*[\s\S]*?\*|_[\s\S]*?_|~[\s\S]*?~|`[\s\S]*?`|@\w+)/g);
|
||||
const parts = text.split(/(\*\*[\s\S]*?\*\*|\*[\s\S]*?\*|_[\s\S]*?_|~[\s\S]*?~|`[\s\S]*?`|@\w+|https?:\/\/[^\s]+)/g);
|
||||
|
||||
return parts.map((part, i) => {
|
||||
if (part.match(/^https?:\/\/[^\s]+$/)) {
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={part}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sky-400 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{part}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (part.startsWith('**') && part.endsWith('**')) return <strong key={i} className="font-bold">{part.slice(2, -2)}</strong>;
|
||||
if (part.startsWith('_') && part.endsWith('_')) return <em key={i} className="italic">{part.slice(1, -1)}</em>;
|
||||
if (part.startsWith('*') && part.endsWith('*')) return <em key={i} className="italic">{part.slice(1, -1)}</em>;
|
||||
@@ -457,7 +475,7 @@ function MessageBubble({
|
||||
onContextMenu={handleContextMenu}
|
||||
onDoubleClick={handleReply}
|
||||
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
|
||||
className={`cursor-pointer rounded-[1.25rem] overflow-hidden transition-all duration-300 ${hasImage && !message.content
|
||||
className={`cursor-pointer rounded-[1.25rem] overflow-hidden transition-all duration-300 ${hasImage && !message.content && !message.forwardedFrom
|
||||
? 'p-0 shadow-none border-none'
|
||||
: isMine
|
||||
? 'bubble-sent text-white shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105'
|
||||
@@ -473,7 +491,7 @@ function MessageBubble({
|
||||
<div className="text-[11px] font-bold uppercase tracking-wider opacity-70 leading-none mb-1">
|
||||
{t('forwardedFrom')}
|
||||
</div>
|
||||
<div className="font-semibold text-accent-light">
|
||||
<div className={`font-semibold ${isMine ? 'text-white' : 'text-accent-light'}`}>
|
||||
{message.forwardedFrom.displayName || message.forwardedFrom.username}
|
||||
</div>
|
||||
</div>
|
||||
@@ -481,7 +499,12 @@ function MessageBubble({
|
||||
|
||||
{/* Изображения и Видео (Галерея) */}
|
||||
{(hasImage || hasVideo) && (
|
||||
<div className={`${message.content ? 'mb-2 -mx-4 -mt-2.5' : ''} bg-black/20 overflow-hidden`}>
|
||||
<div className={`
|
||||
${(message.content || message.forwardedFrom) ? '-mx-4' : ''}
|
||||
${(message.content || message.forwardedFrom) ? (message.forwardedFrom ? 'mt-2' : '-mt-2.5') : ''}
|
||||
${(message.content || message.forwardedFrom) ? (message.content ? 'mb-2' : '-mb-2.5') : ''}
|
||||
bg-black/20 overflow-hidden
|
||||
`}>
|
||||
<div className={`grid gap-[2px] ${media.filter(m => m.type === 'image' || m.type === 'video').length >= 3
|
||||
? 'grid-cols-3'
|
||||
: media.filter(m => m.type === 'image' || m.type === 'video').length === 2
|
||||
@@ -669,11 +692,18 @@ function MessageBubble({
|
||||
|
||||
{/* Текст */}
|
||||
{message.content && (
|
||||
<div className="flex items-end gap-2">
|
||||
<p className="text-sm whitespace-pre-wrap break-words flex-1 leading-relaxed">
|
||||
{renderFormattedText(message.content)}
|
||||
</p>
|
||||
<span className={`text-[10px] flex-shrink-0 flex items-center gap-0.5 self-end ${isMine ? 'text-white/50' : 'text-zinc-500'
|
||||
<div className="flex items-end gap-2 text-sm w-full">
|
||||
<div className="flex-1 min-w-0 w-full">
|
||||
<p className="whitespace-pre-wrap break-words leading-relaxed w-full">
|
||||
{renderFormattedText(message.content)}
|
||||
</p>
|
||||
{firstUrl && !hasImage && !hasVideo && !hasFile && (
|
||||
<div className="w-full mt-1 mb-1 relative overflow-hidden">
|
||||
<LinkPreview url={firstUrl} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-[10px] flex-shrink-0 flex items-center gap-0.5 self-end mb-0.5 ${isMine ? 'text-white/50' : 'text-zinc-500'
|
||||
}`}>
|
||||
{message.isEdited && <span>{t('edited')}</span>}
|
||||
{message.scheduledAt && <Clock size={11} className="text-amber-400 mr-0.5" />}
|
||||
|
||||
@@ -68,9 +68,49 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
const allMedia = sharedMedia.flatMap(msg => (msg.media || []).map(m => ({
|
||||
...m,
|
||||
url: getMediaUrl(m.url),
|
||||
messageId: msg.id
|
||||
messageId: msg.id,
|
||||
createdAt: msg.createdAt
|
||||
})));
|
||||
|
||||
const sortedStories = [...userStories].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
const sortedMedia = [...allMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
const sortedFiles = [...sharedFiles].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
const sortedLinks = [...sharedLinks].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
const renderGrouped = <T extends { createdAt: string }>(
|
||||
sortedItems: T[],
|
||||
renderItem: (item: T, originalIndex: number) => React.ReactNode,
|
||||
gridClass?: string
|
||||
) => {
|
||||
let currentGroup: { dateStr: string; items: {item: T, idx: number}[] } | null = null;
|
||||
const groups: { dateStr: string; items: {item: T, idx: number}[] }[] = [];
|
||||
|
||||
sortedItems.forEach((item, idx) => {
|
||||
const date = new Date(item.createdAt);
|
||||
const dateStr = date.toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
if (currentGroup?.dateStr !== dateStr) {
|
||||
currentGroup = { dateStr, items: [] };
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
currentGroup.items.push({item, idx});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 pb-4">
|
||||
{groups.map((g, i) => (
|
||||
<div key={i}>
|
||||
<div className="sticky top-0 z-10 bg-black/60 backdrop-blur-md px-3 py-1.5 mb-1.5 shadow-sm border-y border-white/5">
|
||||
<span className="text-[10px] font-bold text-vortex-300 uppercase tracking-widest">{g.dateStr}</span>
|
||||
</div>
|
||||
<div className={gridClass || "flex flex-col gap-0.5"}>
|
||||
{g.items.map(({item, idx}) => renderItem(item, idx))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
if (!isSelf) {
|
||||
@@ -101,8 +141,12 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
}, [chatId, userId, loadedTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTabData(activeTab);
|
||||
}, [activeTab, loadTabData]);
|
||||
const tabsToLoad: MediaTab[] = ['publications'];
|
||||
if (chatId) {
|
||||
tabsToLoad.push('media', 'files', 'links');
|
||||
}
|
||||
tabsToLoad.forEach(tab => loadTabData(tab));
|
||||
}, [chatId, loadTabData]);
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
@@ -252,13 +296,23 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
const tabs: { key: MediaTab; label: string; icon: React.ElementType }[] = [
|
||||
{ key: 'publications', label: (t('publicationsTab') || 'Публикации') as string, icon: Play },
|
||||
{ key: 'media', label: t('mediaTab') as string, icon: ImageIcon },
|
||||
{ key: 'files', label: t('filesTab') as string, icon: FileText },
|
||||
{ key: 'links', label: t('linksTab') as string, icon: LinkIcon },
|
||||
const tabsConfig = [
|
||||
{ key: 'publications' as const, label: t('publicationsTab') || 'Публикации', icon: Play, count: sortedStories.length },
|
||||
...(chatId ? [
|
||||
{ key: 'media' as const, label: t('mediaTab'), icon: ImageIcon, count: sortedMedia.length },
|
||||
{ key: 'files' as const, label: t('filesTab'), icon: FileText, count: sortedFiles.flatMap(msg => msg.media || []).length },
|
||||
{ key: 'links' as const, label: t('linksTab'), icon: LinkIcon, count: sortedLinks.flatMap(msg => msg.links || []).length },
|
||||
] : []),
|
||||
];
|
||||
|
||||
const availableTabs = tabsConfig.filter(tab => !loadedTabs.has(tab.key) || tab.count > 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedTabs.size === tabsConfig.length && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) {
|
||||
setActiveTab(availableTabs[0].key as any);
|
||||
}
|
||||
}, [loadedTabs, activeTab, tabsConfig.length]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
@@ -338,15 +392,8 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
)}
|
||||
</div>
|
||||
|
||||
{profile.isOnline && (
|
||||
<div className="absolute bottom-3 right-3 flex items-center justify-center">
|
||||
<div className="absolute w-7 h-7 bg-emerald-500 rounded-full animate-ping opacity-60" />
|
||||
<div className="w-7 h-7 bg-emerald-500 rounded-full border-[5px] border-surface-secondary shadow-[0_0_15px_rgba(16,185,129,0.8)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelf && (
|
||||
<div className="absolute top-0 right-0 p-1.5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-20">
|
||||
<div className="absolute -top-2 left-1/2 -translate-x-1/2 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all transform -translate-y-2 group-hover:translate-y-0 z-30">
|
||||
<label className="w-9 h-9 bg-accent hover:bg-accent-light text-white rounded-full border-2 border-surface-secondary shadow-lg flex items-center justify-center cursor-pointer transition-all hover:scale-110">
|
||||
<Camera size={16} />
|
||||
<input
|
||||
@@ -362,10 +409,10 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
{isSelf && profile.avatar && (
|
||||
<button
|
||||
onClick={handleRemoveAvatar}
|
||||
className="absolute h-9 px-3 -bottom-2 left-1/2 -translate-x-1/2 bg-red-500/90 backdrop-blur-md hover:bg-red-500 rounded-full flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-all shadow-[0_0_20px_rgba(239,68,68,0.4)] border border-red-400/30 transform translate-y-2 group-hover:translate-y-0 z-30"
|
||||
className="absolute w-9 h-9 -bottom-2 left-1/2 -translate-x-1/2 bg-red-500/90 backdrop-blur-md hover:bg-red-500 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all shadow-[0_0_20px_rgba(239,68,68,0.4)] border border-red-400/30 transform translate-y-2 group-hover:translate-y-0 z-30 hover:scale-110"
|
||||
title={t('removePhoto') as string}
|
||||
>
|
||||
<Trash2 size={12} className="text-white" />
|
||||
<span className="text-xs font-semibold text-white">{t('removePhoto')}</span>
|
||||
<Trash2 size={16} className="text-white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -541,38 +588,36 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
</div>
|
||||
|
||||
{/* Медиа / Файлы / Ссылки */}
|
||||
<div className="border-t border-white/5 bg-black/10 mt-2 backdrop-blur-md">
|
||||
<div className="flex border-b border-white/5 h-14">
|
||||
{[
|
||||
{ key: 'publications' as const, label: t('publicationsTab') || 'Публикации', icon: Play },
|
||||
{ key: 'media' as const, label: t('mediaTab'), icon: ImageIcon },
|
||||
{ key: 'files' as const, label: t('filesTab'), icon: FileText },
|
||||
{ key: 'links' as const, label: t('linksTab'), icon: LinkIcon },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex-1 flex flex-col items-center justify-center gap-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-all ${
|
||||
activeTab === tab.key
|
||||
? 'bg-white/5 text-vortex-400'
|
||||
: 'text-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={16} />
|
||||
<span className="truncate w-full px-1">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{availableTabs.length > 0 ? (
|
||||
<div className="border-t border-white/5 bg-black/10 mt-2 backdrop-blur-md">
|
||||
<div className="flex border-b border-white/5 h-14">
|
||||
{availableTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex-1 flex flex-col items-center justify-center gap-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-all ${
|
||||
activeTab === tab.key
|
||||
? 'bg-white/5 text-vortex-400'
|
||||
: 'text-zinc-500 hover:text-white/70'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<tab.icon size={16} />
|
||||
{loadedTabs.has(tab.key) && <span className="text-xs bg-black/40 px-1.5 rounded-full">{tab.count}</span>}
|
||||
</div>
|
||||
<span className="truncate w-full px-1">{tab.label as string}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{tabLoading ? (
|
||||
<div className="flex items-center justify-center py-10 text-zinc-500">
|
||||
<div className="w-6 h-6 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : activeTab === 'publications' ? (
|
||||
userStories.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-0.5 p-1">
|
||||
{userStories.map((s, idx) => (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{tabLoading ? (
|
||||
<div className="flex items-center justify-center py-10 text-zinc-500">
|
||||
<div className="w-6 h-6 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : activeTab === 'publications' ? (
|
||||
sortedStories.length > 0 ? (
|
||||
renderGrouped(sortedStories, (s, idx) => (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => {
|
||||
@@ -605,17 +650,15 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
<span className="text-[10px] text-white font-medium">{s.viewCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
), "grid grid-cols-3 gap-0.5 px-1")
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-10 px-4 text-center">
|
||||
<p className="text-xs text-zinc-500 italic">Нет публикаций</p>
|
||||
</div>
|
||||
)
|
||||
) : activeTab === 'media' ? (
|
||||
allMedia.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-0.5 p-1">
|
||||
{allMedia.map((m, idx) => (
|
||||
sortedMedia.length > 0 ? (
|
||||
renderGrouped(sortedMedia, (m, idx) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="relative aspect-square bg-zinc-900 overflow-hidden group cursor-pointer"
|
||||
@@ -655,18 +698,17 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
<ExternalLink size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
), "grid grid-cols-3 gap-0.5 px-1")
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-xs text-zinc-600 italic">{t('sharedPhotos') as string}</p>
|
||||
</div>
|
||||
)
|
||||
) : activeTab === 'files' ? (
|
||||
sharedFiles.length > 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
{sharedFiles.flatMap((msg) =>
|
||||
(msg.media || []).map((m) => (
|
||||
sortedFiles.length > 0 ? (
|
||||
renderGrouped(sortedFiles, (msg, idx) => (
|
||||
<div key={msg.id} className="divide-y divide-border border-b border-border">
|
||||
{(msg.media || []).map((m) => (
|
||||
<div key={m.id} className="relative group/file">
|
||||
<a
|
||||
href={getMediaUrl(m.url)}
|
||||
@@ -693,21 +735,20 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-xs text-zinc-600 italic">{t('sharedFiles') as string}</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
sharedLinks.length > 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
{sharedLinks.map((msg) => (
|
||||
<div key={msg.id} className="px-4 py-3 hover:bg-white/5 transition-colors relative group/link">
|
||||
sortedLinks.length > 0 ? (
|
||||
renderGrouped(sortedLinks, (msg, idx) => (
|
||||
<div key={msg.id} className="px-4 py-3 hover:bg-white/5 transition-colors relative group/link border-b border-white/5">
|
||||
<p className="text-xs text-zinc-500 mb-1.5 font-medium pr-8">
|
||||
{msg.sender?.displayName || msg.sender?.username} · {new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US')}
|
||||
{msg.sender?.displayName || msg.sender?.username}
|
||||
</p>
|
||||
{(msg.links || []).map((link: string, i: number) => (
|
||||
<a
|
||||
@@ -732,8 +773,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-xs text-zinc-600 italic">{t('sharedLinks') as string}</p>
|
||||
@@ -742,6 +782,16 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : loadedTabs.size === tabsConfig.length ? (
|
||||
<div className="m-4 flex flex-col items-center justify-center py-10 px-4 text-center border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
|
||||
<ImageIcon size={32} className="text-zinc-600 mb-3" />
|
||||
<p className="text-sm text-zinc-500">{(t('sharedPhotos' as any) || 'Нет вложений') as string}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="m-4 flex items-center justify-center py-10 border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
|
||||
<div className="w-6 h-6 border-2 border-zinc-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-zinc-500">
|
||||
@@ -754,7 +804,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
<AnimatePresence>
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
images={allMedia.map((m) => ({ url: getMediaUrl(m.url), type: m.type }))}
|
||||
images={sortedMedia.map((m) => ({ url: getMediaUrl(m.url), type: m.type }))}
|
||||
initialIndex={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
@@ -770,7 +820,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
|
||||
displayName: profile.displayName,
|
||||
avatar: profile.avatar
|
||||
},
|
||||
stories: userStories,
|
||||
stories: sortedStories,
|
||||
hasUnviewed: false
|
||||
}]}
|
||||
initialUserIndex={0}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/avatar.tsx","./src/components/callmodal.tsx","./src/components/chatlistitem.tsx","./src/components/chatview.tsx","./src/components/confirmmodal.tsx","./src/components/datepicker.tsx","./src/components/emojipicker.tsx","./src/components/forwardmodal.tsx","./src/components/groupcallmodal.tsx","./src/components/groupsettings.tsx","./src/components/imagelightbox.tsx","./src/components/messagebubble.tsx","./src/components/messageinput.tsx","./src/components/newchatmodal.tsx","./src/components/notificationprovider.tsx","./src/components/sidemenu.tsx","./src/components/sidebar.tsx","./src/components/storyviewer.tsx","./src/components/typingindicator.tsx","./src/components/userprofile.tsx","./src/lib/api.ts","./src/lib/hooks.ts","./src/lib/i18n.ts","./src/lib/imagecrop.ts","./src/lib/socket.ts","./src/lib/sounds.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/pages/authpage.tsx","./src/pages/chatpage.tsx","./src/stores/authstore.ts","./src/stores/chatstore.ts","./src/stores/notificationstore.ts","./src/stores/themestore.ts","./src/stores/usestorystore.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/avatar.tsx","./src/components/callmodal.tsx","./src/components/chatlistitem.tsx","./src/components/chatview.tsx","./src/components/confirmmodal.tsx","./src/components/datepicker.tsx","./src/components/emojipicker.tsx","./src/components/forwardmodal.tsx","./src/components/groupcallmodal.tsx","./src/components/groupsettings.tsx","./src/components/imagelightbox.tsx","./src/components/linkpreview.tsx","./src/components/messagebubble.tsx","./src/components/messageinput.tsx","./src/components/newchatmodal.tsx","./src/components/notificationprovider.tsx","./src/components/sidemenu.tsx","./src/components/sidebar.tsx","./src/components/storyviewer.tsx","./src/components/typingindicator.tsx","./src/components/userprofile.tsx","./src/lib/api.ts","./src/lib/hooks.ts","./src/lib/i18n.ts","./src/lib/imagecrop.ts","./src/lib/socket.ts","./src/lib/sounds.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/pages/authpage.tsx","./src/pages/chatpage.tsx","./src/stores/authstore.ts","./src/stores/chatstore.ts","./src/stores/notificationstore.ts","./src/stores/themestore.ts","./src/stores/usestorystore.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user