Правки профиля, работа с ссылками

This commit is contained in:
Халимов Рустам
2026-03-14 22:35:33 +03:00
parent c31a531a6a
commit be0a50b702
6 changed files with 369 additions and 132 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

@@ -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)}
/>

View 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>
);
}

View File

@@ -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" />}

View File

@@ -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}

View File

@@ -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"}