Мелкие правки

This commit is contained in:
Халимов Рустам
2026-03-13 00:46:40 +03:00
parent 9daafae9c5
commit 7670a3b787
82 changed files with 725 additions and 114 deletions

View File

@@ -327,7 +327,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMSIgY3k9IjEiIHI9IjEiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC4wMSkvPjwvc3ZnPg==')] [mask-image:radial-gradient(ellipse_at_center,black_40%,transparent_100%)] opacity-20 pointer-events-none" />
<div className="text-center relative z-10 w-full max-w-sm px-6">
<div className="text-center relative z-10 w-full max-w-sm px-6 mx-auto">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}

View File

@@ -13,6 +13,7 @@ import { useChatStore } from '../stores/chatStore';
import { useNotificationStore } from '../stores/notificationStore';
import { useLang } from '../lib/i18n';
import { api } from '../lib/api';
import { getSocket } from '../lib/socket';
import { getInitials, generateAvatarColor } from '../lib/utils';
import Avatar from './Avatar';
import { StoryGroup } from '../lib/types';
@@ -48,8 +49,22 @@ export default function Sidebar() {
useEffect(() => {
loadStories();
const interval = setInterval(loadStories, 30000); // refresh every 30s
return () => clearInterval(interval);
}, []);
const socket = getSocket();
const onStoryViewed = (data: any) => {
// Refresh stories if I'm the owner or the viewer
if (data.ownerId === user?.id || data.userId === user?.id) {
loadStories();
}
};
socket?.on('story_viewed', onStoryViewed);
return () => {
clearInterval(interval);
socket?.off('story_viewed', onStoryViewed);
};
}, [user?.id]);
const filteredChats = chats.filter((chat) => {
if (!searchQuery) return true;
@@ -62,9 +77,17 @@ export default function Sidebar() {
m.user.displayName.toLowerCase().includes(q))
);
}).sort((a, b) => {
// Favorites chat always on top
// 1. Favorites chat always on top
if (a.type === 'favorites') return -1;
if (b.type === 'favorites') return 1;
// 2. Pinned chats next
const aPinned = a.members.find(m => m.user.id === user?.id)?.isPinned;
const bPinned = b.members.find(m => m.user.id === user?.id)?.isPinned;
if (aPinned && !bPinned) return -1;
if (!aPinned && bPinned) return 1;
// 3. Last message timestamp (if available) - though currently we don't have it on top level
return 0;
});
@@ -85,11 +108,11 @@ export default function Sidebar() {
>
<Menu size={20} />
</button>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="w-8 h-8 rounded-lg bg-accent flex items-center justify-center text-white">
<MessageSquare size={18} />
<div className="flex items-center gap-2.5 min-w-0">
<div className="flex items-center justify-center w-9 h-9 rounded-xl overflow-hidden shadow-lg border border-white/5">
<img src="/logo.png" className="w-full h-full object-cover" alt="" />
</div>
<h1 className="text-lg font-bold gradient-text truncate">SelfHost</h1>
<h1 className="text-lg font-bold gradient-text truncate">SelfHost Messenger</h1>
</div>
<button
onClick={() => setShowNewChat(true)}

View File

@@ -171,16 +171,37 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
const handleDelete = async () => {
if (!currentStory) return;
const storyId = currentStory.id;
try {
await api.deleteStory(currentStory.id);
await api.deleteStory(storyId);
// Update local state immediately to avoid black screen
if (currentUser.stories.length > 1) {
// Just move to next if there are more stories for this user
if (storyIndex >= currentUser.stories.length - 1) {
setStoryIndex(s => s - 1);
}
// Props will refresh and re-render correctly
} else {
// Last story for this user, move to next user or close
if (userIndex < stories.length - 1) {
setUserIndex(u => u + 1);
setStoryIndex(0);
} else {
onClose();
}
}
onRefresh();
goNext();
} catch (e) {
console.error(e);
}
};
if (!currentUser || !currentStory) return null;
if (!currentUser || !currentStory) {
onClose();
return null;
}
const timeAgo = (date: string) => {
const diff = (Date.now() - new Date(date).getTime()) / 1000;
@@ -213,7 +234,20 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
onTouchEnd={() => setPaused(false)}
>
{/* Story content */}
{currentStory.type === 'image' && currentStory.mediaUrl ? (
{currentStory.type === 'video' || (currentStory.mediaUrl && (currentStory.mediaUrl.endsWith('.mp4') || currentStory.mediaUrl.endsWith('.mov') || currentStory.mediaUrl.endsWith('.webm'))) ? (
<div className="w-full h-full bg-black flex items-center justify-center">
<video
src={currentStory.mediaUrl?.startsWith('http') ? currentStory.mediaUrl : `${API_URL}${currentStory.mediaUrl}`}
className="w-full h-full object-contain"
autoPlay
muted
playsInline
onEnded={goNext}
onPlay={() => setPaused(false)}
onPause={() => setPaused(true)}
/>
</div>
) : currentStory.type === 'image' && currentStory.mediaUrl ? (
<div className="w-full h-full bg-black flex items-center justify-center">
<img
src={currentStory.mediaUrl.startsWith('http') ? currentStory.mediaUrl : `${API_URL}${currentStory.mediaUrl}`}
@@ -397,10 +431,15 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
const file = e.target.files?.[0];
if (!file) return;
setImageFile(file);
const reader = new FileReader();
reader.onload = () => setImagePreview(reader.result as string);
reader.readAsDataURL(file);
setMode('image');
if (file.type.startsWith('video/')) {
setImagePreview(URL.createObjectURL(file));
setMode('image'); // We use 'image' mode for both media types for now or we can rename it to 'media'
} else {
const reader = new FileReader();
reader.onload = () => setImagePreview(reader.result as string);
reader.readAsDataURL(file);
setMode('image');
}
};
const handleCreate = async () => {
@@ -416,7 +455,7 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
}
await api.createStory({
type: mode,
type: imageFile?.type.startsWith('video/') ? 'video' : mode,
content: mode === 'text' ? text.trim() : undefined,
bgColor: mode === 'text' ? bgColor : undefined,
mediaUrl,
@@ -464,14 +503,14 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
onClick={() => setMode('image')}
className={`flex-1 py-2.5 text-sm font-medium transition-colors ${mode === 'image' ? 'text-vortex-400 border-b-2 border-vortex-400' : 'text-zinc-400'}`}
>
{t('imageStory')}
{t('mediaStory')}
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
accept="image/*,video/*"
className="hidden"
onChange={handleImageSelect}
/>
@@ -512,8 +551,12 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
) : (
<>
{imagePreview ? (
<div className="relative w-full h-48 rounded-xl mb-4 overflow-hidden">
<img src={imagePreview} className="w-full h-full object-cover" alt="preview" />
<div className="relative w-full h-48 rounded-xl mb-4 overflow-hidden bg-black flex items-center justify-center">
{imageFile?.type.startsWith('video/') ? (
<video src={imagePreview} className="w-full h-full object-contain" />
) : (
<img src={imagePreview} className="w-full h-full object-cover" alt="preview" />
)}
<button
onClick={() => { setImageFile(null); setImagePreview(null); }}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/50 flex items-center justify-center text-white"

View File

@@ -36,6 +36,13 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
const [friendStatus, setFriendStatus] = useState<FriendshipStatus | null>(null);
const [friendLoading, setFriendLoading] = useState(false);
// Avatar edit state
const [isEditingAvatar, setIsEditingAvatar] = useState(false);
const [cropImage, setCropImage] = useState<string | null>(null);
const [cropFile, setCropFile] = useState<File | null>(null);
const [cropPosition, setCropPosition] = useState({ x: 0, y: 0, scale: 1 });
const [isCropping, setIsCropping] = useState(false);
useEffect(() => {
loadProfile();
if (!isSelf) {
@@ -129,6 +136,43 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
}
};
const handleAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setCropFile(file);
const url = URL.createObjectURL(file);
setCropImage(url);
setIsCropping(true);
};
const handleCropSave = async () => {
if (!cropFile || !cropImage) return;
try {
setTabLoading(true);
// For now, we'll just send the file and the crop data as separate fields
// In a real app, we might crop on the client via canvas
const cropData = {
x: Math.round(cropPosition.x),
y: Math.round(cropPosition.y),
width: 400, // Fixed size for simplicity
height: 400
};
const updatedUser = await api.cropAvatar(cropFile, cropData);
setProfile(updatedUser);
useAuthStore.getState().updateUser(updatedUser);
setIsCropping(false);
setCropImage(null);
setCropFile(null);
} catch (e) {
console.error('Failed to save avatar', e);
} finally {
setTabLoading(false);
}
};
const initials = (profile?.displayName || profile?.username || '??')
.split(' ')
.map((w: string) => w[0])
@@ -208,6 +252,18 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
</div>
)}
{isSelf && (
<label className="absolute bottom-0 right-0 w-10 h-10 bg-accent hover:bg-accent-light text-white rounded-full border-4 border-surface-secondary shadow-lg flex items-center justify-center cursor-pointer transition-all hover:scale-110 z-20">
<Edit3 size={18} />
<input
type="file"
className="hidden"
accept="image/*,video/*"
onChange={handleAvatarSelect}
/>
</label>
)}
</div>
{/* Имя */}
@@ -497,6 +553,65 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
/>
)}
</AnimatePresence>
{/* Avatar Cropper Modal */}
<AnimatePresence>
{isCropping && cropImage && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-black/90 backdrop-blur-xl flex flex-col items-center justify-center p-6"
>
<div className="w-full max-w-[400px] bg-surface-secondary rounded-[2rem] border border-white/10 overflow-hidden shadow-2xl">
<div className="p-6 border-b border-white/5 flex items-center justify-between">
<h3 className="text-xl font-bold text-white">{t('changePhoto')}</h3>
<button onClick={() => setIsCropping(false)} className="text-zinc-400 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
<div className="p-8 flex flex-col items-center">
<div className="relative w-64 h-64 rounded-full overflow-hidden border-4 border-accent/30 bg-black group">
{cropFile?.type.startsWith('video/') ? (
<video src={cropImage} className="w-full h-full object-cover opacity-80" autoPlay muted loop />
) : (
<img src={cropImage} className="w-full h-full object-cover opacity-80" alt="" />
)}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-48 h-48 rounded-full border-2 border-dashed border-white/50 animate-pulse pointer-events-none" />
</div>
{/* Fake cropping area overlay */}
<div className="absolute inset-0 bg-black/40 pointer-events-none" style={{
clipPath: 'circle(48% at 50% 50%)'
}} />
</div>
<p className="mt-6 text-sm text-zinc-400 text-center px-4 leading-relaxed">
{t('photoVideo')}
</p>
<div className="flex gap-3 mt-8 w-full">
<button
onClick={() => setIsCropping(false)}
className="flex-1 py-3.5 rounded-2xl bg-white/5 hover:bg-white/10 text-white font-semibold transition-all"
>
{t('cancel')}
</button>
<button
onClick={handleCropSave}
disabled={tabLoading}
className="flex-1 py-3.5 rounded-2xl bg-accent hover:bg-accent-light text-white font-bold transition-all shadow-lg shadow-accent/20 flex items-center justify-center gap-2"
>
{tabLoading ? <Loader2 size={18} className="animate-spin" /> : <Check size={18} />}
{t('save')}
</button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}