Мелкие правки
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user