Минимальная адаптация под мобилку
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
<html lang="ru" class="dark">
|
<html lang="ru" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0, viewport-fit=cover" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<title>Knot Messenger</title>
|
<title>Knot Messenger</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ const translations = {
|
|||||||
you: 'вы',
|
you: 'вы',
|
||||||
// Stories Editor
|
// Stories Editor
|
||||||
cropVideo: 'Обрезка видео',
|
cropVideo: 'Обрезка видео',
|
||||||
|
cropTool: 'Обрезка',
|
||||||
trimVideo: 'Обрезать видео',
|
trimVideo: 'Обрезать видео',
|
||||||
trim: 'Обрезать',
|
trim: 'Обрезать',
|
||||||
reset: 'Сбросить',
|
reset: 'Сбросить',
|
||||||
@@ -279,6 +280,8 @@ const translations = {
|
|||||||
unmuteVideo: 'Включить звук',
|
unmuteVideo: 'Включить звук',
|
||||||
interactive: 'Интерактив',
|
interactive: 'Интерактив',
|
||||||
apply: 'Применить',
|
apply: 'Применить',
|
||||||
|
zoom: 'Масштаб',
|
||||||
|
rotation: 'Поворот',
|
||||||
// User profile
|
// User profile
|
||||||
mediaTab: 'Медиа',
|
mediaTab: 'Медиа',
|
||||||
gifs: 'GIF',
|
gifs: 'GIF',
|
||||||
@@ -622,6 +625,7 @@ const translations = {
|
|||||||
you: 'you',
|
you: 'you',
|
||||||
// Stories Editor
|
// Stories Editor
|
||||||
cropVideo: 'Video Crop',
|
cropVideo: 'Video Crop',
|
||||||
|
cropTool: 'Crop',
|
||||||
trimVideo: 'Trim Video',
|
trimVideo: 'Trim Video',
|
||||||
trim: 'Trim',
|
trim: 'Trim',
|
||||||
reset: 'Reset',
|
reset: 'Reset',
|
||||||
@@ -653,6 +657,8 @@ const translations = {
|
|||||||
unmuteVideo: 'Unmute Video',
|
unmuteVideo: 'Unmute Video',
|
||||||
interactive: 'Interactive',
|
interactive: 'Interactive',
|
||||||
apply: 'Apply',
|
apply: 'Apply',
|
||||||
|
zoom: 'Zoom',
|
||||||
|
rotation: 'Rotation',
|
||||||
// User profile
|
// User profile
|
||||||
mediaTab: 'Media',
|
mediaTab: 'Media',
|
||||||
gifs: 'GIF',
|
gifs: 'GIF',
|
||||||
|
|||||||
@@ -99,8 +99,16 @@ export default function ImageLightbox({ url, images, initialIndex = 0, onClose }
|
|||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
exit={{ scale: 0.8, opacity: 0 }}
|
exit={{ scale: 0.8, opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
|
drag={gallery && total > 1 ? "x" : false}
|
||||||
|
dragConstraints={{ left: 0, right: 0 }}
|
||||||
|
dragElastic={0.4}
|
||||||
|
onDragEnd={(_, info) => {
|
||||||
|
const swipeThreshold = 50;
|
||||||
|
if (info.offset.x > swipeThreshold) goPrev();
|
||||||
|
else if (info.offset.x < -swipeThreshold) goNext();
|
||||||
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="absolute inset-x-0 inset-y-12 flex items-center justify-center p-4"
|
className="absolute inset-x-0 inset-y-12 flex items-center justify-center p-4 touch-none"
|
||||||
>
|
>
|
||||||
{(currentType === 'video' || currentType === 'gif') ? (
|
{(currentType === 'video' || currentType === 'gif') ? (
|
||||||
<video
|
<video
|
||||||
|
|||||||
@@ -16,41 +16,43 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed left-0 top-0 h-full z-40 bg-surface-container w-20 flex flex-col items-center py-6 slide-on-ice border-none shadow-xl">
|
<nav
|
||||||
<div className="mb-10 flex flex-col items-center">
|
className="lg:fixed lg:left-0 lg:top-0 lg:h-[100dvh] lg:w-20 w-full h-16 fixed bottom-0 left-0 bg-surface-container-low border-t lg:border-t-0 lg:border-r border-white/5 flex lg:flex-col flex-row items-center justify-around lg:justify-start lg:py-8 lg:gap-4 z-50 transition-all safe-area-bottom"
|
||||||
|
>
|
||||||
|
<div className="hidden lg:flex mb-10 flex-col items-center">
|
||||||
<span className="text-2xl font-black text-primary tracking-tighter italic knot-logo-spin">Knot</span>
|
<span className="text-2xl font-black text-primary tracking-tighter italic knot-logo-spin">Knot</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-6 w-full px-2">
|
<div className="flex flex-row lg:flex-col gap-2 lg:gap-6 w-full lg:px-2 items-center justify-around lg:justify-start">
|
||||||
{menuItems.map((item) => (
|
{menuItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => onTabChange(item.id)}
|
onClick={() => onTabChange(item.id)}
|
||||||
className={`flex flex-col items-center justify-center p-3 rounded-xl transition-all duration-300 scale-95 active:scale-90 ${
|
className={`flex flex-col items-center justify-center p-2 lg:p-3 rounded-xl transition-all duration-300 scale-95 lg:scale-100 active:scale-90 flex-1 lg:flex-none ${
|
||||||
activeTab === item.id
|
activeTab === item.id
|
||||||
? 'bg-primary/10 text-primary'
|
? 'bg-primary/10 text-primary'
|
||||||
: 'text-on-surface-variant hover:bg-surface-container-high hover:text-primary'
|
: 'text-on-surface-variant hover:bg-surface-container-high hover:text-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`material-symbols-outlined mb-1 ${activeTab === item.id ? 'fill-1' : ''}`}
|
className={`material-symbols-outlined mb-0.5 lg:mb-1 ${activeTab === item.id ? 'fill-1' : ''}`}
|
||||||
style={{ fontVariationSettings: `'FILL' ${activeTab === item.id ? 1 : 0}` }}
|
style={{ fontVariationSettings: `'FILL' ${activeTab === item.id ? 1 : 0}` }}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-medium">{item.label}</span>
|
<span className="text-[9px] lg:text-[10px] font-bold uppercase tracking-wider">{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="mt-auto group cursor-pointer relative"
|
className="lg:mt-auto group cursor-pointer relative flex items-center justify-center px-4 lg:px-0"
|
||||||
onClick={() => onTabChange('settings')}
|
onClick={() => onTabChange('settings')}
|
||||||
>
|
>
|
||||||
<div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" />
|
<div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" />
|
||||||
<img
|
<img
|
||||||
alt="User Profile"
|
alt="User Profile"
|
||||||
className="relative w-10 h-10 rounded-2xl border-2 border-outline-variant/10 hover:border-primary/50 transition-colors object-cover"
|
className="relative w-8 h-8 lg:w-10 lg:h-10 rounded-2xl border-2 border-outline-variant/10 hover:border-primary/50 transition-colors object-cover"
|
||||||
src={user?.avatar || `https://ui-avatars.com/api/?name=${user?.username || 'user'}&background=3096e5&color=fff`}
|
src={user?.avatar || `https://ui-avatars.com/api/?name=${user?.username || 'user'}&background=3096e5&color=fff`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -662,7 +662,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile, onOpenTelegra
|
|||||||
animate={{ x: 0, opacity: 1 }}
|
animate={{ x: 0, opacity: 1 }}
|
||||||
exit={{ x: -320, opacity: 0 }}
|
exit={{ x: -320, opacity: 0 }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
className="fixed left-3 top-3 bottom-3 w-[340px] max-w-[calc(100vw-24px)] bg-surface-secondary/95 backdrop-blur-3xl shadow-[0_0_100px_rgba(0,0,0,0.5)] border border-border/50 rounded-3xl z-50 flex flex-col overflow-hidden"
|
className="fixed inset-0 lg:left-3 lg:top-3 lg:bottom-3 w-full lg:w-[340px] lg:max-w-[calc(100vw-24px)] bg-surface-secondary/95 backdrop-blur-3xl shadow-[0_0_100px_rgba(0,0,0,0.5)] border-none lg:border lg:border-border/50 lg:rounded-3xl z-50 flex flex-col overflow-hidden"
|
||||||
>
|
>
|
||||||
<AnimatePresence mode="wait" custom={slideDir}>
|
<AnimatePresence mode="wait" custom={slideDir}>
|
||||||
{view === 'main' && renderMain()}
|
{view === 'main' && renderMain()}
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export default function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Список чатов */}
|
{/* Список чатов */}
|
||||||
<div className="flex-1 overflow-y-auto px-2 custom-scrollbar">
|
<div className="flex-1 overflow-y-auto px-2 custom-scrollbar pb-28 lg:pb-4">
|
||||||
{filteredChats.length === 0 ? (
|
{filteredChats.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-on-surface-variant/40 gap-3 px-6">
|
<div className="flex flex-col items-center justify-center h-full text-on-surface-variant/40 gap-3 px-6">
|
||||||
<span className="material-symbols-outlined text-5xl opacity-20">chat_bubble</span>
|
<span className="material-symbols-outlined text-5xl opacity-20">chat_bubble</span>
|
||||||
@@ -218,10 +218,10 @@ export default function Sidebar() {
|
|||||||
{/* Float Action Button equivalent for Web */}
|
{/* Float Action Button equivalent for Web */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNewChat(true)}
|
onClick={() => setShowNewChat(true)}
|
||||||
className="absolute bottom-6 right-6 w-14 h-14 rounded-2xl bg-primary text-black shadow-lg shadow-primary/20 flex items-center justify-center hover:scale-105 active:scale-95 transition-transform slide-on-ice"
|
className="absolute bottom-20 lg:bottom-10 right-4 lg:right-10 w-12 h-12 lg:w-16 lg:h-16 rounded-2xl lg:rounded-3xl bg-primary text-black shadow-[0_10px_30px_-5px_rgba(var(--primary-rgb),0.4)] flex items-center justify-center hover:scale-105 active:scale-95 transition-all slide-on-ice z-30"
|
||||||
title={t('newChat')}
|
title={t('newChat')}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-2xl">add</span>
|
<span className="material-symbols-outlined text-2xl lg:text-3xl">add</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -268,3 +268,30 @@ input:-webkit-autofill:active {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile & Safe Area Utilities */
|
||||||
|
.safe-area-bottom {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.safe-area-top {
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (padding: env(safe-area-inset-top)) {
|
||||||
|
body {
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
padding-left: env(safe-area-inset-left);
|
||||||
|
padding-right: env(safe-area-inset-right);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -373,11 +373,13 @@ export default function ChatPage() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="h-screen w-screen flex bg-surface-dim overflow-hidden antialiased font-body selection:bg-primary/30"
|
className="h-[100dvh] w-screen flex flex-col lg:flex-row bg-surface-dim overflow-hidden"
|
||||||
>
|
>
|
||||||
|
<div className={`${activeChat ? 'hidden lg:block' : 'block'}`}>
|
||||||
<GlobalNavBar activeTab={activeTab} onTabChange={setActiveTab} />
|
<GlobalNavBar activeTab={activeTab} onTabChange={setActiveTab} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<main className="ml-20 flex-1 flex flex-row relative h-full">
|
<main className={`lg:ml-20 flex-1 flex flex-row relative h-full safe-area-bottom`}>
|
||||||
{activeTab === 'chats' ? (
|
{activeTab === 'chats' ? (
|
||||||
<>
|
<>
|
||||||
{/* Chat List (Sidebar) */}
|
{/* Chat List (Sidebar) */}
|
||||||
|
|||||||
@@ -900,13 +900,13 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
openSearch();
|
openSearch();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="p-2 rounded-xl hover:bg-surface-container-highest/40 transition-all duration-300 text-on-surface-variant hover:text-primary slide-on-ice"
|
className="hidden lg:flex p-2 rounded-xl hover:bg-surface-container-highest/40 transition-all duration-300 text-on-surface-variant hover:text-primary slide-on-ice"
|
||||||
>
|
>
|
||||||
{showSearch ? <span className="material-symbols-outlined">close</span> : <span className="material-symbols-outlined">search</span>}
|
{showSearch ? <span className="material-symbols-outlined">close</span> : <span className="material-symbols-outlined">search</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!isFavorites && config?.webRtc?.enabled && (
|
{!isFavorites && config?.webRtc?.enabled && (
|
||||||
<>
|
<div className="hidden lg:flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (chat.type === 'personal' && otherMember) {
|
if (chat.type === 'personal' && otherMember) {
|
||||||
@@ -931,7 +931,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
<span className="material-symbols-outlined">videocam</span>
|
<span className="material-symbols-outlined">videocam</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative" ref={topMenuRef}>
|
<div className="relative" ref={topMenuRef}>
|
||||||
@@ -957,6 +957,40 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
{t('searchMessages')}
|
{t('searchMessages')}
|
||||||
</button>
|
</button>
|
||||||
|
{!isFavorites && config?.webRtc?.enabled && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowTopMenu(false);
|
||||||
|
if (chat.type === 'personal' && otherMember) {
|
||||||
|
onStartCall?.(otherMember.user, 'voice');
|
||||||
|
} else if (chat.type === 'group') {
|
||||||
|
onStartGroupCall?.(chat.id, chat.name || 'Group', 'voice');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="lg:hidden flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Phone size={16} />
|
||||||
|
{t('call')}
|
||||||
|
</button>
|
||||||
|
{config?.webRtc?.enableVideoCalls && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowTopMenu(false);
|
||||||
|
if (chat.type === 'personal' && otherMember) {
|
||||||
|
onStartCall?.(otherMember.user, 'video');
|
||||||
|
} else if (chat.type === 'group') {
|
||||||
|
onStartGroupCall?.(chat.id, chat.name || 'Group', 'video');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="lg:hidden flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Video size={16} />
|
||||||
|
{t('videoCall')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{!isFavorites && chat.type === 'personal' && otherMember && (
|
{!isFavorites && chat.type === 'personal' && otherMember && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1337,7 +1371,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="flex-shrink-0 bg-surface-container-lowest/40 backdrop-blur-xl border-t border-white/5 pb-safe relative z-50">
|
<footer className="flex-shrink-0 bg-surface-container-lowest/40 backdrop-blur-xl border-t border-white/5 safe-area-bottom relative z-50">
|
||||||
<MessageInput chatId={activeChat} />
|
<MessageInput chatId={activeChat} />
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
|||||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, x: 50, scale: 0.95 }}
|
exit={{ opacity: 0, x: 50, scale: 0.95 }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
className="fixed right-3 top-3 bottom-3 w-[900px] max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden ring-1 ring-white/10"
|
className="fixed inset-0 lg:inset-auto lg:right-3 lg:top-3 lg:bottom-3 w-full lg:w-[900px] lg:max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border-none lg:border lg:border-white/5 lg:rounded-[2rem] z-50 flex flex-col overflow-hidden ring-1 ring-white/10"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border/40">
|
<div className="flex items-center justify-between p-4 border-b border-border/40">
|
||||||
|
|||||||
@@ -124,10 +124,10 @@ export default function NewChatModal({ onClose, onOpenTelegramImport }: NewChatM
|
|||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
className="fixed inset-0 z-50 flex items-center justify-center lg:p-4"
|
||||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||||
>
|
>
|
||||||
<div className="w-full max-w-md rounded-[2.5rem] bg-surface-container/60 backdrop-blur-3xl shadow-[0_48px_80px_-16px_rgba(0,0,0,0.7)] border border-white/5 overflow-hidden flex flex-col" role="dialog" aria-modal="true" aria-label={t('newChat')}>
|
<div className="w-full lg:max-w-md h-full lg:h-auto lg:rounded-[2.5rem] bg-surface-container/60 backdrop-blur-3xl shadow-[0_48px_80px_-16px_rgba(0,0,0,0.7)] border-none lg:border lg:border-white/5 overflow-hidden flex flex-col" role="dialog" aria-modal="true" aria-label={t('newChat')}>
|
||||||
{/* Шапка */}
|
{/* Шапка */}
|
||||||
<div className="flex items-center justify-between p-7 pb-4">
|
<div className="flex items-center justify-between p-7 pb-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|||||||
@@ -305,7 +305,22 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
if (!croppedPreview && croppedAreaPixels) {
|
if (!croppedPreview && croppedAreaPixels) {
|
||||||
ctx.drawImage(img, croppedAreaPixels.x, croppedAreaPixels.y, croppedAreaPixels.width, croppedAreaPixels.height, 0, 0, TARGET_W, TARGET_H);
|
ctx.drawImage(img, croppedAreaPixels.x, croppedAreaPixels.y, croppedAreaPixels.width, croppedAreaPixels.height, 0, 0, TARGET_W, TARGET_H);
|
||||||
} else {
|
} else {
|
||||||
ctx.drawImage(img, 0, 0, TARGET_W, TARGET_H);
|
// Calculate "cover" manually to avoid squashing if image ratio != 9:16
|
||||||
|
const targetRatio = TARGET_W / TARGET_H;
|
||||||
|
const imgRatio = img.width / img.height;
|
||||||
|
let sx, sy, sw, sh;
|
||||||
|
if (imgRatio > targetRatio) {
|
||||||
|
sh = img.height;
|
||||||
|
sw = img.height * targetRatio;
|
||||||
|
sx = (img.width - sw) / 2;
|
||||||
|
sy = 0;
|
||||||
|
} else {
|
||||||
|
sw = img.width;
|
||||||
|
sh = img.width / targetRatio;
|
||||||
|
sx = 0;
|
||||||
|
sy = (img.height - sh) / 2;
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, TARGET_W, TARGET_H);
|
||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
@@ -359,46 +374,61 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-[200] bg-[#000] flex flex-col font-body text-white overflow-hidden selection:bg-primary/20">
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-[200] bg-black/90 backdrop-blur-xl flex items-center justify-center lg:p-12">
|
||||||
|
<motion.div initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="w-full h-full lg:max-h-[850px] lg:max-w-[1200px] bg-[#070707] lg:rounded-[3rem] shadow-[0_50px_100px_-30px_rgba(0,0,0,0.8)] lg:border lg:border-white/10 flex flex-col overflow-hidden relative">
|
||||||
<style>{`
|
<style>{`
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Dancing+Script:wght@700&family=Playfair+Display:ital,wght@1,900&family=Fira+Code:wght@700&family=Archivo+Black&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Dancing+Script:wght@700&family=Playfair+Display:ital,wght@1,900&family=Fira+Code:wght@700&family=Archivo+Black&display=swap');
|
||||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
<header className="h-20 px-8 flex items-center justify-between border-b border-white/5 bg-[#0a0a0a] z-50 shadow-2xl">
|
<header className="h-16 lg:h-20 px-4 lg:px-8 flex items-center justify-between border-b border-white/5 bg-[#0a0a0a] z-50 shadow-2xl safe-area-top">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-3 lg:gap-6">
|
||||||
<button onClick={onClose} className="w-10 h-10 rounded-full hover:bg-white/5 flex items-center justify-center text-zinc-400 hover:text-white transition-all">
|
<button onClick={onClose} className="w-10 h-10 rounded-full hover:bg-white/5 flex items-center justify-center text-zinc-400 hover:text-white transition-all">
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-sm font-black uppercase tracking-[0.2em] italic text-zinc-400">{t('storiesEditor')}</h1>
|
<h1 className="text-[10px] lg:text-sm font-black uppercase tracking-[0.1em] lg:tracking-[0.2em] italic text-zinc-400 truncate max-w-[120px] lg:max-w-none">{t('storiesEditor')}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2 lg:gap-4 overflow-x-auto no-scrollbar py-2">
|
||||||
{mediaFile?.type.startsWith('video/') && (
|
{mediaFile?.type.startsWith('video/') && (
|
||||||
<>
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={() => setTool('trim')} className={`p-3 rounded-xl border transition-all ${tool === 'trim' ? 'bg-primary/10 border-primary/40 text-primary' : 'bg-white/5 border-white/10 text-zinc-400 hover:text-white'}`} title={t('trimVideo')}>
|
<button onClick={() => setTool('trim')} className={`p-2 lg:p-3 rounded-xl border transition-all ${tool === 'trim' ? 'bg-primary/10 border-primary/40 text-primary' : 'bg-white/5 border-white/10 text-zinc-400 hover:text-white'}`} title={t('trimVideo')}>
|
||||||
<span className="material-symbols-outlined">content_cut</span>
|
<span className="material-symbols-outlined text-sm lg:text-base">content_cut</span>
|
||||||
<span className="ml-2 text-[10px] font-black uppercase tracking-widest">{t('trim')}</span>
|
<span className="ml-1 lg:ml-2 text-[8px] lg:text-[10px] font-black uppercase tracking-widest hidden sm:inline">{t('trim')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setIsMuted(!isMuted)} className={`p-3 rounded-xl border transition-all ${isMuted ? 'bg-red-500/10 border-red-500/40 text-red-500' : 'bg-white/5 border-white/10 text-zinc-400 hover:text-white'}`} title={isMuted ? t("unmuteVideo") : t("muteVideo")}>
|
<button onClick={() => setIsMuted(!isMuted)} className={`p-2 lg:p-3 rounded-xl border transition-all ${isMuted ? 'bg-red-500/10 border-red-500/40 text-red-500' : 'bg-white/5 border-white/10 text-zinc-400 hover:text-white'}`} title={isMuted ? t("unmuteVideo") : t("muteVideo")}>
|
||||||
{isMuted ? <Wind size={18} /> : <Droplets size={18} />}
|
{isMuted ? <Wind size={16} /> : <Droplets size={16} />}
|
||||||
<span className="ml-2 text-[10px] font-black uppercase tracking-widest">{isMuted ? t('muted') : t('unmuted')}</span>
|
<span className="ml-1 lg:ml-2 text-[8px] lg:text-[10px] font-black uppercase tracking-widest hidden sm:inline">{isMuted ? t('muted') : t('unmuted')}</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => { setStickers([]); setTextObjects([]); setMediaPreview(null); setMediaFile(null); setCroppedPreview(null); clearCanvas(); setBgColor('#1e1e2e'); setIsMuted(false); }} className="px-6 py-3 text-[10px] font-black uppercase tracking-widest text-zinc-500 hover:text-white border border-white/5 rounded-xl hover:bg-white/5 transition-all">{t('reset')}</button>
|
<button onClick={() => { setStickers([]); setTextObjects([]); setMediaPreview(null); setMediaFile(null); setCroppedPreview(null); clearCanvas(); setBgColor('#1e1e2e'); setIsMuted(false); }} className="px-3 lg:px-6 py-2 lg:py-3 text-[8px] lg:text-[10px] font-black uppercase tracking-widest text-zinc-500 hover:text-white border border-white/5 rounded-xl hover:bg-white/5 transition-all whitespace-nowrap">{t('reset')}</button>
|
||||||
<button onClick={handlePublish} disabled={isUploading} className="px-8 py-3 bg-primary text-on-primary rounded-xl font-black text-xs uppercase tracking-widest hover:scale-105 active:scale-95 disabled:opacity-50 transition-all shadow-xl shadow-primary/20">
|
<button onClick={handlePublish} disabled={isUploading} className="px-4 lg:px-8 py-2 lg:py-3 bg-primary text-on-primary rounded-xl font-black text-[10px] lg:text-xs uppercase tracking-widest hover:scale-105 active:scale-95 disabled:opacity-50 transition-all shadow-xl shadow-primary/20 whitespace-nowrap">
|
||||||
{isUploading ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : t('publishStory')}
|
{isUploading ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : t('publishStory')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 flex overflow-hidden">
|
<main className="flex-1 flex flex-col lg:flex-row overflow-hidden min-h-0">
|
||||||
<section className="flex-1 flex items-center justify-center p-12 relative bg-[#070707]">
|
<section className="flex-1 flex items-center justify-center p-4 lg:p-12 relative bg-[#070707] min-h-0">
|
||||||
<div ref={stageRef} className="relative w-full max-w-[400px] aspect-[9/16] bg-black rounded-[3.5rem] border-[12px] border-[#161616] overflow-hidden shadow-[0_50px_100px_-30px_rgba(0,0,0,0.9)] ring-1 ring-white/5">
|
<div ref={stageRef} className="relative w-auto h-full max-h-[500px] sm:max-h-[600px] lg:max-h-none aspect-[9/16] bg-black rounded-[2.5rem] lg:rounded-[3.5rem] border-[8px] lg:border-[12px] border-[#161616] overflow-hidden shadow-[0_50px_100px_-30px_rgba(0,0,0,0.9)] ring-1 ring-white/5">
|
||||||
<div className="absolute inset-0 transition-colors duration-700" style={{ backgroundColor: bgColor }}>
|
<div className="absolute inset-0 transition-colors duration-700" style={{ backgroundColor: bgColor }}>
|
||||||
{(croppedPreview || mediaPreview) && (
|
{(croppedPreview || mediaPreview) && (
|
||||||
<div className="absolute inset-0" style={{ filter: getFilterCss() }}>
|
<div className="absolute inset-0" style={{ filter: getFilterCss() }}>
|
||||||
|
{tool === 'crop' && mediaPreview && !mediaFile?.type.startsWith('video/') ? (
|
||||||
|
<Cropper
|
||||||
|
image={mediaPreview}
|
||||||
|
crop={crop}
|
||||||
|
rotation={rotation}
|
||||||
|
zoom={zoom}
|
||||||
|
aspect={9 / 16}
|
||||||
|
onCropChange={setCrop}
|
||||||
|
onRotationChange={setRotation}
|
||||||
|
onCropComplete={(_, croppedPixels) => setCroppedAreaPixels(croppedPixels)}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{mediaFile?.type.startsWith('video/') ? (
|
{mediaFile?.type.startsWith('video/') ? (
|
||||||
<div className="w-full h-full flex items-center justify-center relative" style={{ background: bgColor }}>
|
<div className="w-full h-full flex items-center justify-center relative" style={{ background: bgColor }}>
|
||||||
<video
|
<video
|
||||||
@@ -443,15 +473,14 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
className="absolute inset-0 z-20 flex items-center justify-center cursor-pointer group"
|
className="absolute inset-0 z-20 flex items-center justify-center cursor-pointer group"
|
||||||
>
|
>
|
||||||
{paused && (
|
{paused && (
|
||||||
<motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="w-20 h-20 rounded-full bg-black/40 backdrop-blur-xl flex items-center justify-center border border-white/20 group-hover:bg-primary/20 group-hover:border-primary/40 transition-all">
|
<motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="w-16 lg:w-20 h-16 lg:h-20 rounded-full bg-black/40 backdrop-blur-xl flex items-center justify-center border border-white/20 group-hover:bg-primary/20 group-hover:border-primary/40 transition-all">
|
||||||
<span className="material-symbols-outlined text-4xl text-white">play_arrow</span>
|
<span className="material-symbols-outlined text-3xl lg:text-4xl text-white">play_arrow</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Controls (Stop/Seek Placeholder) */}
|
<div className="absolute bottom-6 lg:bottom-10 left-0 right-0 z-30 px-6 lg:px-10 pointer-events-none">
|
||||||
<div className="absolute bottom-10 left-0 right-0 z-30 px-10 pointer-events-none">
|
<div className="flex items-center gap-3 lg:gap-4 pointer-events-auto">
|
||||||
<div className="flex items-center gap-4 pointer-events-auto">
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -463,12 +492,12 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
setCurrentPlayTime(startTime);
|
setCurrentPlayTime(startTime);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-10 h-10 rounded-xl bg-black/60 backdrop-blur-md flex items-center justify-center text-white border border-white/10 hover:bg-red-500/20 hover:border-red-500/40 transition-all"
|
className="w-8 lg:w-10 h-8 lg:h-10 rounded-xl bg-black/60 backdrop-blur-md flex items-center justify-center text-white border border-white/10 hover:bg-red-500/20 hover:border-red-500/40 transition-all"
|
||||||
title={t('stop')}
|
title={t('stop')}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined">stop</span>
|
<span className="material-symbols-outlined text-sm lg:text-base">stop</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 h-1.5 bg-white/10 rounded-full overflow-hidden backdrop-blur-md border border-white/5 font-mono">
|
<div className="flex-1 h-1 bg-white/10 rounded-full overflow-hidden backdrop-blur-md border border-white/5 font-mono">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="h-full bg-primary"
|
className="h-full bg-primary"
|
||||||
animate={{ width: `${(( (editorVideoRef.current?.currentTime || 0) - startTime) / (endTime - startTime)) * 100}%` }}
|
animate={{ width: `${(( (editorVideoRef.current?.currentTime || 0) - startTime) / (endTime - startTime)) * 100}%` }}
|
||||||
@@ -481,7 +510,9 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
) : (
|
) : (
|
||||||
<img src={croppedPreview || mediaPreview!} className="w-full h-full object-cover" alt="base" />
|
<img src={croppedPreview || mediaPreview!} className="w-full h-full object-cover" alt="base" />
|
||||||
)}
|
)}
|
||||||
<button onClick={e => { e.stopPropagation(); removeMedia(); }} className="absolute top-8 right-8 w-12 h-12 bg-black/60 text-white rounded-2xl hover:bg-red-500 transition-all z-40 backdrop-blur-md flex items-center justify-center border border-white/10 group"><Trash2 size={20} className="group-hover:scale-110 transition-transform" /></button>
|
</>
|
||||||
|
)}
|
||||||
|
<button onClick={e => { e.stopPropagation(); removeMedia(); }} className="absolute top-4 lg:top-8 right-4 lg:right-8 w-10 lg:w-12 h-10 lg:h-12 bg-black/60 text-white rounded-xl lg:rounded-2xl hover:bg-red-500 transition-all z-40 backdrop-blur-md flex items-center justify-center border border-white/10 group"><Trash2 size={18} className="group-hover:scale-110 transition-transform" /></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -489,26 +520,26 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{stickers.map(s => (
|
{stickers.map(s => (
|
||||||
<motion.div key={s.id} drag dragConstraints={stageRef} dragMomentum={false} initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className={`absolute z-20 cursor-grab active:cursor-grabbing p-4 ${selectedStickerId === s.id ? 'ring-2 ring-primary bg-white/5 rounded-[2rem] backdrop-blur-sm' : ''}`} style={{ left: `${s.x}%`, top: `${s.y}%`, fontSize: `${s.scale * 40}px` }} onClick={() => {setSelectedStickerId(s.id); setSelectedTextId(null); setTool('stickers');}}>
|
<motion.div key={s.id} drag dragConstraints={stageRef} dragMomentum={false} initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className={`absolute z-20 cursor-grab active:cursor-grabbing p-2 lg:p-4 ${selectedStickerId === s.id ? 'ring-2 ring-primary bg-white/5 rounded-[1.5rem] lg:rounded-[2rem] backdrop-blur-sm' : ''}`} style={{ left: `${s.x}%`, top: `${s.y}%`, fontSize: `${s.scale * 30}px` }} onClick={() => {setSelectedStickerId(s.id); setSelectedTextId(null); setTool('stickers');}}>
|
||||||
{s.emoji}
|
{s.emoji}
|
||||||
{selectedStickerId === s.id && <button onClick={(e) => { e.stopPropagation(); setStickers(prev => prev.filter(st => st.id !== s.id)); setSelectedStickerId(null); }} className="absolute -top-3 -right-3 w-8 h-8 bg-red-500 rounded-full flex items-center justify-center text-white shadow-xl border-2 border-[#161616]"><X size={16}/></button>}
|
{selectedStickerId === s.id && <button onClick={(e) => { e.stopPropagation(); setStickers(prev => prev.filter(st => st.id !== s.id)); setSelectedStickerId(null); }} className="absolute -top-2 -right-2 w-6 lg:w-8 h-6 lg:h-8 bg-red-500 rounded-full flex items-center justify-center text-white shadow-xl border-2 border-[#161616]"><X size={12}/></button>}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{textObjects.map(tobj => (
|
{textObjects.map(tobj => (
|
||||||
<motion.div key={tobj.id} drag dragConstraints={stageRef} dragMomentum={false} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className={`absolute z-30 cursor-grab active:cursor-grabbing p-4 group ${selectedTextId === tobj.id ? 'ring-2 ring-primary bg-white/5 rounded-[2rem] backdrop-blur-md' : ''}`} style={{ left: `${tobj.x}%`, top: `${tobj.y}%`, color: tobj.color, fontFamily: tobj.font, fontSize: `${tobj.size}px`, transform: `scale(${tobj.scale})` }} onClick={() => {setSelectedTextId(tobj.id); setSelectedStickerId(null); setTool('text');}}>
|
<motion.div key={tobj.id} drag dragConstraints={stageRef} dragMomentum={false} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className={`absolute z-30 cursor-grab active:cursor-grabbing p-3 lg:p-4 group ${selectedTextId === tobj.id ? 'ring-2 ring-primary bg-white/5 rounded-[1.5rem] lg:rounded-[2rem] backdrop-blur-md' : ''}`} style={{ left: `${tobj.x}%`, top: `${tobj.y}%`, color: tobj.color, fontFamily: tobj.font, fontSize: `${tobj.size / 1.5}px`, transform: `scale(${tobj.scale})` }} onClick={() => {setSelectedTextId(tobj.id); setSelectedStickerId(null); setTool('text');}}>
|
||||||
{tobj.text}
|
{tobj.text}
|
||||||
{selectedTextId === tobj.id && <button onClick={(e) => { e.stopPropagation(); setTextObjects(prev => prev.filter(to => to.id !== tobj.id)); setSelectedTextId(null); }} className="absolute -top-3 -right-3 w-8 h-8 bg-red-500 rounded-full flex items-center justify-center text-white shadow-xl border-2 border-[#161616]"><X size={16}/></button>}
|
{selectedTextId === tobj.id && <button onClick={(e) => { e.stopPropagation(); setTextObjects(prev => prev.filter(to => to.id !== tobj.id)); setSelectedTextId(null); }} className="absolute -top-2 -right-2 w-6 lg:w-8 h-6 lg:h-8 bg-red-500 rounded-full flex items-center justify-center text-white shadow-xl border-2 border-[#161616]"><X size={12}/></button>}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{!mediaPreview && textObjects.length === 0 && stickers.length === 0 && (
|
{!mediaPreview && textObjects.length === 0 && stickers.length === 0 && (
|
||||||
<button onClick={() => fileInputRef.current?.click()} className="absolute inset-0 flex flex-col items-center justify-center gap-6 text-zinc-800 hover:text-white transition-all group">
|
<button onClick={() => fileInputRef.current?.click()} className="absolute inset-0 flex flex-col items-center justify-center gap-4 lg:gap-6 text-zinc-800 hover:text-white transition-all group">
|
||||||
<div className="w-20 h-20 rounded-full border-2 border-dashed border-zinc-800 flex items-center justify-center group-hover:border-primary group-hover:bg-primary/5 group-hover:scale-110 transition-all duration-500">
|
<div className="w-16 lg:w-20 h-16 lg:h-20 rounded-full border-2 border-dashed border-zinc-800 flex items-center justify-center group-hover:border-primary group-hover:bg-primary/5 group-hover:scale-110 transition-all duration-500">
|
||||||
<Camera size={40} className="group-hover:rotate-12 transition-transform" />
|
<Camera size={32} className="group-hover:rotate-12 transition-transform" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[9px] font-black uppercase tracking-[0.4em]">{t('startCreation')}</span>
|
<span className="text-[8px] lg:text-[9px] font-black uppercase tracking-[0.3em] lg:tracking-[0.4em]">{t('startCreation')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -516,83 +547,99 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
<input ref={fileInputRef} type="file" accept="image/*,video/*" className="hidden" onChange={handleMediaSelect} />
|
<input ref={fileInputRef} type="file" accept="image/*,video/*" className="hidden" onChange={handleMediaSelect} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside className="w-[440px] bg-[#0a0a0a] border-l border-white/5 flex flex-col overflow-y-auto">
|
<aside className="lg:w-[360px] w-full bg-[#0a0a0a] border-l border-white/5 flex flex-col overflow-y-auto max-h-[40vh] lg:max-h-none">
|
||||||
<div className="p-10 space-y-12 pb-24">
|
<div className="p-4 lg:p-8 space-y-6 lg:space-y-10 pb-20 lg:pb-24">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-5 lg:grid-cols-2 gap-2 lg:gap-3">
|
||||||
{[
|
{[
|
||||||
{ id: 'text', icon: <Type />, label: 'Текст', action: addText },
|
{ id: 'text', icon: <Type size={18} />, label: t('textTool'), action: addText },
|
||||||
{ id: 'stickers', icon: <Smile />, label: t('stickersTool'), active: tool === 'stickers' },
|
{ id: 'crop', icon: <Scissors size={18} />, label: t('cropTool'), active: tool === 'crop' },
|
||||||
{ id: 'brush', icon: <Brush />, label: t('brushTool'), active: tool === 'brush' },
|
{ id: 'stickers', icon: <Smile size={18} />, label: t('stickersTool'), active: tool === 'stickers' },
|
||||||
{ id: 'filters', icon: <SlidersHorizontal />, label: t('filtersTool'), active: tool === 'filters' },
|
{ id: 'brush', icon: <Brush size={18} />, label: t('brushTool'), active: tool === 'brush' },
|
||||||
|
{ id: 'filters', icon: <SlidersHorizontal size={18} />, label: t('filtersTool'), active: tool === 'filters' },
|
||||||
].map(item => (
|
].map(item => (
|
||||||
<button key={item.id} onClick={() => { if(item.id === 'text') { if(item.action) item.action(); setTool('text'); } else { setTool(item.id as any); } }} className={`flex flex-col items-start justify-between h-32 p-7 rounded-[2.5rem] border transition-all duration-500 ${tool === item.id ? 'bg-primary/10 border-primary/40 text-white shadow-[0_15px_40px_-10px_rgba(var(--primary-rgb),0.3)]' : 'bg-[#121212] border-white/5 text-zinc-500 hover:text-white hover:border-white/10'}`}>
|
<button key={item.id} onClick={() => { if(item.id === 'text') { if(item.action) item.action(); setTool('text'); } else { setTool(item.id as any); } }} className={`flex flex-col items-center lg:items-center justify-center h-20 lg:h-24 p-2 lg:p-4 rounded-xl lg:rounded-3xl border transition-all duration-500 ${tool === item.id ? 'bg-primary/10 border-primary/40 text-white shadow-[0_15px_40px_-10px_rgba(var(--primary-rgb),0.3)]' : 'bg-[#121212] border-white/5 text-zinc-500 hover:text-white hover:border-white/10'}`}>
|
||||||
<div className={tool === item.id ? 'text-primary' : 'text-zinc-700'}>{item.icon}</div>
|
<div className={tool === item.id ? 'text-primary' : 'text-zinc-700'}>{item.icon}</div>
|
||||||
<span className="text-[10px] font-black uppercase tracking-widest">{item.id === 'text' ? t('textTool') : item.label}</span>
|
<span className="text-[7px] lg:text-[10px] font-black uppercase tracking-widest mt-2">{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{tool === 'text' && selectedTextId && (
|
{tool === 'crop' && (
|
||||||
<motion.div key="text-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="space-y-8 bg-[#121212] p-8 rounded-[2.5rem] border border-white/5 shadow-2xl">
|
<motion.div key="crop-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="p-6 lg:p-8 rounded-3xl lg:rounded-[2.5rem] bg-[#121212] border border-white/5 space-y-6 lg:space-y-8 shadow-2xl">
|
||||||
<div className="flex flex-col gap-4">
|
<h4 className="text-[9px] lg:text-[10px] font-black uppercase tracking-widest text-primary">{t('cropTool')}</h4>
|
||||||
<span className="text-[10px] font-black uppercase text-primary tracking-widest">{t('textContent')}</span>
|
<div className="space-y-4">
|
||||||
<input value={textObjects.find(tx => tx.id === selectedTextId)?.text} onChange={e => setTextObjects(prev => prev.map(tx => tx.id === selectedTextId ? { ...tx, text: e.target.value } : tx))} className="bg-transparent border-b border-white/10 w-full py-2 text-2xl font-black outline-none italic" autoFocus />
|
<div className="flex justify-between text-[9px] lg:text-[10px] font-bold text-zinc-600 uppercase tracking-widest"><span>{t('zoom')}</span><span className="text-white">{zoom.toFixed(1)}x</span></div>
|
||||||
|
<input type="range" min="1" max="3" step="0.1" value={zoom} onChange={e => setZoom(parseFloat(e.target.value))} className="w-full accent-primary h-1 bg-white/5 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<span className="text-[10px] font-black uppercase text-zinc-600 tracking-widest">{t('typography')}</span>
|
<div className="flex justify-between text-[9px] lg:text-[10px] font-bold text-zinc-600 uppercase tracking-widest"><span>{t('rotation')}</span><span className="text-white">{rotation}°</span></div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<input type="range" min="0" max="360" value={rotation} onChange={e => setRotation(parseInt(e.target.value))} className="w-full accent-primary h-1 bg-white/5 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<button onClick={handleCropDone} className="w-full py-4 lg:py-5 text-[9px] lg:text-[10px] font-black uppercase tracking-[0.1em] lg:tracking-[0.2em] bg-primary text-white rounded-2xl lg:rounded-3xl hover:scale-105 active:scale-95 transition-all shadow-xl shadow-primary/20">{t('apply')}</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tool === 'text' && selectedTextId && (
|
||||||
|
<motion.div key="text-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="space-y-4 lg:space-y-8 bg-[#121212] p-6 lg:p-8 rounded-3xl lg:rounded-[2.5rem] border border-white/5 shadow-2xl">
|
||||||
|
<div className="flex flex-col gap-2 lg:gap-4">
|
||||||
|
<span className="text-[9px] lg:text-[10px] font-black uppercase text-primary tracking-widest">{t('textContent')}</span>
|
||||||
|
<input value={textObjects.find(tx => tx.id === selectedTextId)?.text} onChange={e => setTextObjects(prev => prev.map(tx => tx.id === selectedTextId ? { ...tx, text: e.target.value } : tx))} className="bg-transparent border-b border-white/10 w-full py-2 text-xl lg:text-2xl font-black outline-none italic" autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 lg:space-y-4">
|
||||||
|
<span className="text-[9px] lg:text-[10px] font-black uppercase text-zinc-600 tracking-widest">{t('typography')}</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5 lg:gap-2">
|
||||||
{FONTS.map(f => (
|
{FONTS.map(f => (
|
||||||
<button key={f.id} onClick={() => setTextObjects(prev => prev.map(tx => tx.id === selectedTextId ? { ...tx, font: f.family } : tx))} className={`px-5 py-2 rounded-2xl text-[9px] font-black uppercase transition-all ${textObjects.find(tx => tx.id === selectedTextId)?.font === f.family ? 'bg-primary text-white shadow-lg' : 'bg-white/5 text-zinc-500 hover:text-white'}`} style={{ fontFamily: f.family }}>{f.name}</button>
|
<button key={f.id} onClick={() => setTextObjects(prev => prev.map(tx => tx.id === selectedTextId ? { ...tx, font: f.family } : tx))} className={`px-4 lg:px-5 py-1.5 lg:py-2 rounded-xl lg:rounded-2xl text-[8px] lg:text-[9px] font-black uppercase transition-all ${textObjects.find(tx => tx.id === selectedTextId)?.font === f.family ? 'bg-primary text-white shadow-lg' : 'bg-white/5 text-zinc-500 hover:text-white'}`} style={{ fontFamily: f.family }}>{f.name}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-3 lg:space-y-4">
|
||||||
<div className="flex justify-between text-[10px] font-black uppercase text-zinc-600"><span>{t('textSize')}</span><span className="text-white">{textObjects.find(tx => tx.id === selectedTextId)?.size}px</span></div>
|
<div className="flex justify-between text-[9px] lg:text-[10px] font-black uppercase text-zinc-600"><span>{t('textSize')}</span><span className="text-white">{textObjects.find(tx => tx.id === selectedTextId)?.size}px</span></div>
|
||||||
<input type="range" min="12" max="150" value={textObjects.find(tx => tx.id === selectedTextId)?.size} onChange={e => setTextObjects(prev => prev.map(tx => tx.id === selectedTextId ? { ...tx, size: parseInt(e.target.value) } : tx))} className="w-full accent-primary h-1 bg-white/5 rounded-full" />
|
<input type="range" min="12" max="150" value={textObjects.find(tx => tx.id === selectedTextId)?.size} onChange={e => setTextObjects(prev => prev.map(tx => tx.id === selectedTextId ? { ...tx, size: parseInt(e.target.value) } : tx))} className="w-full accent-primary h-1 bg-white/5 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between pt-6 border-t border-white/5">
|
<div className="flex items-center justify-between pt-4 lg:pt-6 border-t border-white/5">
|
||||||
<span className="text-[10px] font-black uppercase text-zinc-600">{t('textColor')}</span>
|
<span className="text-[9px] lg:text-[10px] font-black uppercase text-zinc-600">{t('textColor')}</span>
|
||||||
<div className="w-12 h-12 rounded-full border-2 border-white/10 relative shadow-inner" style={{ backgroundColor: textObjects.find(tx => tx.id === selectedTextId)?.color }}>
|
<div className="w-10 lg:w-12 h-10 lg:h-12 rounded-full border-2 border-white/10 relative shadow-inner" style={{ backgroundColor: textObjects.find(tx => tx.id === selectedTextId)?.color }}>
|
||||||
<input type="color" value={textObjects.find(tx => tx.id === selectedTextId)?.color} onChange={e => setTextObjects(prev => prev.map(tx => tx.id === selectedTextId ? { ...tx, color: e.target.value } : tx))} className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
|
<input type="color" value={textObjects.find(tx => tx.id === selectedTextId)?.color} onChange={e => setTextObjects(prev => prev.map(tx => tx.id === selectedTextId ? { ...tx, color: e.target.value } : tx))} className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
|
||||||
<Pipette size={18} className="absolute inset-0 m-auto mix-blend-difference" />
|
<Pipette size={16} className="absolute inset-0 m-auto mix-blend-difference" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tool === 'brush' && (
|
{tool === 'brush' && (
|
||||||
<motion.div key="brush-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="p-8 rounded-[2.5rem] bg-[#121212] border border-white/5 space-y-10 text-center shadow-2xl">
|
<motion.div key="brush-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="p-6 lg:p-8 rounded-3xl lg:rounded-[2.5rem] bg-[#121212] border border-white/5 space-y-6 lg:space-y-10 text-center shadow-2xl">
|
||||||
<div className="w-24 h-24 rounded-full mx-auto border-4 border-white/10 relative shadow-inner cursor-pointer group" style={{ backgroundColor: brushColor }}>
|
<div className="w-16 lg:w-24 h-16 lg:h-24 rounded-full mx-auto border-4 border-white/10 relative shadow-inner cursor-pointer group" style={{ backgroundColor: brushColor }}>
|
||||||
<input type="color" value={brushColor} onChange={e => setBrushColor(e.target.value)} className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
|
<input type="color" value={brushColor} onChange={e => setBrushColor(e.target.value)} className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
|
||||||
<Pipette size={32} className="absolute inset-0 m-auto mix-blend-difference group-hover:scale-125 transition-transform" />
|
<Pipette size={24} className="absolute inset-0 m-auto mix-blend-difference group-hover:scale-125 transition-transform" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between text-[10px] font-bold text-zinc-600 uppercase tracking-widest"><span>{t('brushThickness')}</span><span className="text-white">{brushSize}px</span></div>
|
<div className="flex justify-between text-[9px] lg:text-[10px] font-bold text-zinc-600 uppercase tracking-widest"><span>{t('brushThickness')}</span><span className="text-white">{brushSize}px</span></div>
|
||||||
<input type="range" min="1" max="100" value={brushSize} onChange={e => setBrushSize(parseInt(e.target.value))} className="w-full accent-primary h-1 bg-white/5 rounded-full" />
|
<input type="range" min="1" max="100" value={brushSize} onChange={e => setBrushSize(parseInt(e.target.value))} className="w-full accent-primary h-1 bg-white/5 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<button onClick={clearCanvas} className="w-full py-5 text-[10px] font-black uppercase tracking-[0.2em] text-red-500 bg-red-500/10 rounded-3xl hover:bg-red-500 hover:text-white transition-all border border-red-500/20">{t('clearCanvas')}</button>
|
<button onClick={clearCanvas} className="w-full py-4 lg:py-5 text-[9px] lg:text-[10px] font-black uppercase tracking-[0.1em] lg:tracking-[0.2em] text-red-500 bg-red-500/10 rounded-2xl lg:rounded-3xl hover:bg-red-500 hover:text-white transition-all border border-red-500/20">{t('clearCanvas')}</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tool === 'stickers' && (
|
{tool === 'stickers' && (
|
||||||
<motion.div key="sticker-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="bg-[#121212] rounded-[2.5rem] border border-white/5 overflow-hidden shadow-2xl">
|
<motion.div key="sticker-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="bg-[#121212] rounded-3xl lg:rounded-[2.5rem] border border-white/5 overflow-hidden shadow-2xl">
|
||||||
<Picker data={data} onEmojiSelect={addSticker} theme="dark" set="native" locale="ru" previewPosition="none" skinTonePosition="none" />
|
<Picker data={data} onEmojiSelect={addSticker} theme="dark" set="native" locale="ru" previewPosition="none" skinTonePosition="none" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tool === 'filters' && (
|
{tool === 'filters' && (
|
||||||
<motion.div key="filters-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="p-8 rounded-[2.5rem] bg-[#121212] border border-white/5 space-y-8 shadow-2xl">
|
<motion.div key="filters-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="p-6 lg:p-8 rounded-3xl lg:rounded-[2.5rem] bg-[#121212] border border-white/5 space-y-6 lg:space-y-8 shadow-2xl">
|
||||||
<h4 className="text-[10px] font-black uppercase tracking-widest text-primary">{t('correctionMaster')}</h4>
|
<h4 className="text-[9px] lg:text-[10px] font-black uppercase tracking-widest text-primary">{t('correctionMaster')}</h4>
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 lg:space-y-6">
|
||||||
{[
|
{[
|
||||||
{ id: 'brightness', icon: <Sun size={14}/>, label: t('brightness'), min: 40, max: 160 },
|
{ id: 'brightness', icon: <Sun size={14}/>, label: t('brightness'), min: 40, max: 160 },
|
||||||
{ id: 'contrast', icon: <Contrast size={14}/>, label: t('contrast'), min: 40, max: 160 },
|
{ id: 'contrast', icon: <Contrast size={14}/>, label: t('contrast'), min: 40, max: 160 },
|
||||||
{ id: 'saturate', icon: <Droplets size={14}/>, label: t('saturation'), min: 0, max: 250 },
|
{ id: 'saturate', icon: <Droplets size={14}/>, label: t('saturation'), min: 0, max: 250 },
|
||||||
{ id: 'sepia', icon: <Palette size={14}/>, label: t('sepia'), min: 0, max: 100 },
|
{ id: 'sepia', icon: <Palette size={14}/>, label: t('sepia'), min: 0, max: 100 },
|
||||||
].map(adj => (
|
].map(adj => (
|
||||||
<div key={adj.id} className="space-y-3">
|
<div key={adj.id} className="space-y-2">
|
||||||
<div className="flex items-center justify-between text-[10px] font-bold text-zinc-700 uppercase">
|
<div className="flex items-center justify-between text-[8px] lg:text-[10px] font-bold text-zinc-700 uppercase">
|
||||||
<div className="flex items-center gap-2">{adj.icon} {adj.label}</div>
|
<div className="flex items-center gap-2">{adj.icon} {adj.label}</div>
|
||||||
<span className="text-white">{adjustments[adj.id as keyof typeof adjustments]}%</span>
|
<span className="text-white">{adjustments[adj.id as keyof typeof adjustments]}%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -604,20 +651,20 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!mediaPreview && tool === 'none' && (
|
{!mediaPreview && tool === 'none' && (
|
||||||
<motion.div key="bg-tool" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="p-10 rounded-[2.5rem] bg-[#121212] flex flex-col items-center gap-8 border border-white/5 shadow-2xl">
|
<motion.div key="bg-tool" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="p-6 lg:p-10 rounded-3xl lg:rounded-[2.5rem] bg-[#121212] flex flex-col items-center gap-6 lg:gap-8 border border-white/5 shadow-2xl">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<Palette size={24} className="text-primary" />
|
<Palette size={20} className="text-primary" />
|
||||||
<span className="text-[10px] font-black uppercase text-zinc-500 tracking-widest">{t('bgColor')}</span>
|
<span className="text-[9px] lg:text-[10px] font-black uppercase text-zinc-500 tracking-widest">{t('bgColor')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-28 h-28 rounded-full border-[6px] border-white/5 relative shadow-inner transition-transform hover:scale-105 active:scale-95 cursor-pointer" style={{ backgroundColor: bgColor }}>
|
<div className="w-20 lg:w-28 h-20 lg:h-28 rounded-full border-[6px] border-white/5 relative shadow-inner transition-transform hover:scale-105 active:scale-95 cursor-pointer" style={{ backgroundColor: bgColor }}>
|
||||||
<input type="color" value={bgColor} onChange={e => setBgColor(e.target.value)} className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
|
<input type="color" value={bgColor} onChange={e => setBgColor(e.target.value)} className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
|
||||||
<Pipette size={36} className="absolute inset-0 m-auto mix-blend-difference" />
|
<Pipette size={24} className="absolute inset-0 m-auto mix-blend-difference" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[9px] font-bold text-zinc-700 uppercase">{t('clickToChooseColor')}</span>
|
<span className="text-[8px] lg:text-[9px] font-bold text-zinc-700 uppercase">{t('clickToChooseColor')}</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<div className="pt-10 border-t border-white/5" />
|
<div className="pt-6 lg:pt-10 border-t border-white/5" />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
@@ -958,5 +1005,6 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
|||||||
exit={{ opacity: 0, scale: 0.98, y: 15 }}
|
exit={{ opacity: 0, scale: 0.98, y: 15 }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="relative w-full max-w-[850px] h-[85vh] bg-[#0a0a0a] rounded-[3rem] border border-white/10 shadow-2xl overflow-hidden flex flex-col backdrop-blur-3xl"
|
className="relative w-full lg:max-w-[850px] h-full lg:h-[85vh] bg-[#0a0a0a] lg:rounded-[3rem] border-none lg:border lg:border-white/10 shadow-2xl overflow-hidden flex flex-col backdrop-blur-3xl"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-8 py-5 border-b border-white/5 bg-white/[0.01]">
|
<div className="flex items-center justify-between px-8 py-5 border-b border-white/5 bg-white/[0.01]">
|
||||||
<span className="text-sm font-black uppercase tracking-[0.3em] text-white/50">{t('userProfileUpper')}</span>
|
<span className="text-sm font-black uppercase tracking-[0.3em] text-white/50">{t('userProfileUpper')}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user