Переделаны истории
This commit is contained in:
@@ -10,7 +10,7 @@ export class StoryApi {
|
||||
return httpClient.request<StoryGroup>(`/stories/user/${userId}`);
|
||||
}
|
||||
|
||||
static async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string }) {
|
||||
static async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string; privacy?: string; filter?: string }) {
|
||||
return httpClient.request<{ id: string }>('/stories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Camera, Type, Check, Palette, Smile, Brush, Filter, RotateCcw, Eye, MoreVertical, Globe, Users, Lock, ChevronLeft, Trash2, Scissors, Music, Eraser, Minus, Plus, Maximize, Trash, SlidersHorizontal, Sun, Contrast, Droplets, Wind, Pipette, Type as FontIcon, AlignCenter } from 'lucide-react';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import { ChatApi } from '../../../chats/infrastructure/chatApi';
|
||||
import { StoryApi } from '../../infrastructure/storyApi';
|
||||
import Cropper from 'react-easy-crop';
|
||||
import { getMediaUrl } from '../../../../core/utils/utils';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import Avatar from '../../../../core/presentation/components/ui/Avatar';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import data from '@emoji-mart/data';
|
||||
|
||||
const FONTS = [
|
||||
{ id: 'inter', name: 'Standard', family: 'Inter, sans-serif' },
|
||||
{ id: 'serif', name: 'Playfair', family: '"Playfair Display", serif' },
|
||||
{ id: 'mono', name: 'Retro', family: '"Fira Code", monospace' },
|
||||
{ id: 'script', name: 'Elegant', family: '"Dancing Script", cursive' },
|
||||
{ id: 'black', name: 'Impact', family: '"Archivo Black", sans-serif' },
|
||||
];
|
||||
|
||||
interface TextObject {
|
||||
id: number;
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
font: string;
|
||||
size: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
interface CreateStoryModalProps {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) {
|
||||
const { t } = useLang();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
||||
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
|
||||
const [croppedPreview, setCroppedPreview] = useState<string | null>(null);
|
||||
const [bgColor, setBgColor] = useState('#1e1e2e');
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [tool, setTool] = useState<'none' | 'crop' | 'brush' | 'stickers' | 'filters' | 'privacy' | 'text'>('none');
|
||||
const [privacy, setPrivacy] = useState<'all' | 'contacts' | 'selected'>('all');
|
||||
|
||||
const [textObjects, setTextObjects] = useState<TextObject[]>([]);
|
||||
const [selectedTextId, setSelectedTextId] = useState<number | null>(null);
|
||||
const [stickers, setStickers] = useState<Array<{ id: number, emoji: string, x: number, y: number, scale: number }>>([]);
|
||||
const [selectedStickerId, setSelectedStickerId] = useState<number | null>(null);
|
||||
|
||||
const [brushColor, setBrushColor] = useState('#ffffff');
|
||||
const [brushSize, setBrushSize] = useState(8);
|
||||
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<any>(null);
|
||||
const [adjustments, setAdjustments] = useState({ brightness: 100, contrast: 100, saturate: 100, sepia: 0, grayscale: 0 });
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const isDrawingRef = useRef(false);
|
||||
|
||||
// Scroll Lock implementation
|
||||
useEffect(() => {
|
||||
const originalStyle = window.getComputedStyle(document.body).overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = originalStyle;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const TARGET_W = 1080;
|
||||
const TARGET_H = 1920;
|
||||
const SCALE_FACTOR = TARGET_W / 400;
|
||||
|
||||
const getFilterCss = () => `brightness(${adjustments.brightness}%) contrast(${adjustments.contrast}%) saturate(${adjustments.saturate}%) sepia(${adjustments.sepia}%) grayscale(${adjustments.grayscale}%)`;
|
||||
|
||||
const handleMediaSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setMediaFile(file);
|
||||
const url = URL.createObjectURL(file);
|
||||
setMediaPreview(url);
|
||||
setCroppedPreview(null);
|
||||
setTool('crop');
|
||||
};
|
||||
|
||||
const removeMedia = () => {
|
||||
setMediaFile(null);
|
||||
setMediaPreview(null);
|
||||
setCroppedPreview(null);
|
||||
setCroppedAreaPixels(null);
|
||||
setTool('none');
|
||||
};
|
||||
|
||||
const addText = () => {
|
||||
const id = Date.now();
|
||||
setTextObjects([...textObjects, { id, text: 'Текст', x: 20, y: 40, color: '#ffffff', font: FONTS[0].family, size: 42, scale: 1 }]);
|
||||
setSelectedTextId(id);
|
||||
setTool('none');
|
||||
setTimeout(() => setTool('text'), 50);
|
||||
};
|
||||
|
||||
const addSticker = (emoji: any) => {
|
||||
const id = Date.now();
|
||||
setStickers([...stickers, { id, emoji: emoji.native, x: 30, y: 50, scale: 1.5 }]);
|
||||
setSelectedStickerId(id);
|
||||
};
|
||||
|
||||
const clearCanvas = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
};
|
||||
|
||||
const loadImage = (src: string): Promise<HTMLImageElement> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = async () => {
|
||||
try {
|
||||
if ('decode' in img) await img.decode();
|
||||
resolve(img);
|
||||
} catch(e) {
|
||||
resolve(img); // Fallback
|
||||
}
|
||||
};
|
||||
img.onerror = (e) => reject(e);
|
||||
img.src = src;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCropDone = async () => {
|
||||
if (croppedAreaPixels && mediaPreview) {
|
||||
try {
|
||||
const image = await loadImage(mediaPreview);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = croppedAreaPixels.width;
|
||||
canvas.height = croppedAreaPixels.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(image, croppedAreaPixels.x, croppedAreaPixels.y, croppedAreaPixels.width, croppedAreaPixels.height, 0, 0, croppedAreaPixels.width, croppedAreaPixels.height);
|
||||
canvas.toBlob(blob => {
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
setCroppedPreview(url);
|
||||
}
|
||||
}, 'image/jpeg', 0.95);
|
||||
} catch (e) {
|
||||
console.error('Crop bake error', e);
|
||||
}
|
||||
}
|
||||
setTool('none');
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = TARGET_W;
|
||||
canvas.height = TARGET_H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas init failed');
|
||||
|
||||
// 1. BG Color
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, TARGET_W, TARGET_H);
|
||||
|
||||
// 2. Base Image Layer
|
||||
const baseSrc = croppedPreview || mediaPreview;
|
||||
if (baseSrc && (mediaFile?.type.startsWith('image/') || !mediaFile)) {
|
||||
const img = await loadImage(baseSrc);
|
||||
ctx.save();
|
||||
ctx.filter = getFilterCss();
|
||||
|
||||
if (!croppedPreview && croppedAreaPixels) {
|
||||
ctx.drawImage(img, croppedAreaPixels.x, croppedAreaPixels.y, croppedAreaPixels.width, croppedAreaPixels.height, 0, 0, TARGET_W, TARGET_H);
|
||||
} else {
|
||||
ctx.drawImage(img, 0, 0, TARGET_W, TARGET_H);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 3. Brush Layer
|
||||
if (canvasRef.current) {
|
||||
ctx.drawImage(canvasRef.current, 0, 0, TARGET_W, TARGET_H);
|
||||
}
|
||||
|
||||
// 4. Overlays
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
stickers.forEach(s => {
|
||||
ctx.font = `${s.scale * 100}px "Apple Color Emoji", "Segoe UI Emoji", serif`;
|
||||
ctx.fillText(s.emoji, (s.x / 100) * TARGET_W, (s.y / 100) * TARGET_H);
|
||||
});
|
||||
|
||||
textObjects.forEach(t => {
|
||||
const fontSize = t.size * SCALE_FACTOR * t.scale;
|
||||
ctx.font = `bold ${fontSize}px ${t.font}`;
|
||||
ctx.fillStyle = t.color;
|
||||
ctx.fillText(t.text, (t.x / 100) * TARGET_W, (t.y / 100) * TARGET_H);
|
||||
});
|
||||
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(b => b ? resolve(b) : reject('Blob error'), 'image/jpeg', 0.95);
|
||||
});
|
||||
|
||||
const fileToUpload = new File([blob], `story_${Date.now()}.jpg`, { type: 'image/jpeg' });
|
||||
const { url } = await ChatApi.uploadFile(fileToUpload);
|
||||
if (!url) throw new Error('Upload failed');
|
||||
|
||||
// Using PascalCase matching the DTO just in case, and including original content
|
||||
await StoryApi.createStory({
|
||||
type: 'image',
|
||||
mediaUrl: url,
|
||||
bgColor: bgColor,
|
||||
privacy: privacy, // Note: might not be in backend but safe to send
|
||||
} as any);
|
||||
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error('Publishing error:', e);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<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');
|
||||
`}</style>
|
||||
|
||||
<header className="h-20 px-8 flex items-center justify-between border-b border-white/5 bg-[#0a0a0a] z-50 shadow-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/20 flex items-center justify-center text-primary border border-primary/20 italic font-black shadow-[0_0_20px_rgba(var(--primary-rgb),0.3)]">S</div>
|
||||
<h1 className="text-sm font-black uppercase tracking-widest italic text-zinc-400">Stories Editor</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => { setStickers([]); setTextObjects([]); setMediaPreview(null); setMediaFile(null); setCroppedPreview(null); clearCanvas(); setBgColor('#1e1e2e'); }} 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">Сброс</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">
|
||||
{isUploading ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : 'Опубликовать'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex overflow-hidden">
|
||||
<section className="flex-1 flex items-center justify-center p-12 relative bg-[#070707]">
|
||||
<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 className="absolute inset-0 transition-colors duration-700" style={{ backgroundColor: bgColor }}>
|
||||
{(croppedPreview || mediaPreview) && (
|
||||
<div className="absolute inset-0" style={{ filter: getFilterCss() }}>
|
||||
{mediaFile?.type.startsWith('video/') ? (
|
||||
<video src={mediaPreview!} className="w-full h-full object-cover" autoPlay muted loop />
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<canvas ref={canvasRef} width={TARGET_W} height={TARGET_H} className={`absolute inset-0 z-10 w-full h-full ${tool === 'brush' ? 'cursor-crosshair' : 'pointer-events-none'}`} onMouseDown={e => { isDrawingRef.current = true; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const rect = canvas.getBoundingClientRect(); const x = (e.clientX - rect.left) * (canvas.width / rect.width); const y = (e.clientY - rect.top) * (canvas.height / rect.height); ctx.beginPath(); ctx.moveTo(x, y); ctx.strokeStyle = brushColor; ctx.lineWidth = brushSize * SCALE_FACTOR; ctx.lineCap = 'round'; }} onMouseMove={e => { if (!isDrawingRef.current) return; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const rect = canvas.getBoundingClientRect(); const x = (e.clientX - rect.left) * (canvas.width / rect.width); const y = (e.clientY - rect.top) * (canvas.height / rect.height); ctx.lineTo(x, y); ctx.stroke(); }} onMouseUp={() => isDrawingRef.current = false} />
|
||||
|
||||
<AnimatePresence>
|
||||
{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');}}>
|
||||
{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>}
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{textObjects.map(t => (
|
||||
<motion.div key={t.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 === t.id ? 'ring-2 ring-primary bg-white/5 rounded-[2rem] backdrop-blur-md' : ''}`} style={{ left: `${t.x}%`, top: `${t.y}%`, color: t.color, fontFamily: t.font, fontSize: `${t.size}px`, transform: `scale(${t.scale})` }} onClick={() => {setSelectedTextId(t.id); setSelectedStickerId(null); setTool('text');}}>
|
||||
{t.text}
|
||||
{selectedTextId === t.id && <button onClick={(e) => { e.stopPropagation(); setTextObjects(prev => prev.filter(to => to.id !== t.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>}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{!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">
|
||||
<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">
|
||||
<Camera size={40} className="group-hover:rotate-12 transition-transform" />
|
||||
</div>
|
||||
<span className="text-[9px] font-black uppercase tracking-[0.4em]">Начать создание</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tool === 'crop' && mediaPreview && (
|
||||
<div className="absolute inset-0 z-[100] bg-black">
|
||||
<Cropper image={mediaPreview} crop={crop} zoom={zoom} rotation={rotation} aspect={9/16} onCropChange={setCrop} onZoomChange={setZoom} onCropComplete={(_, pixels) => setCroppedAreaPixels(pixels)} />
|
||||
<button onClick={handleCropDone} className="absolute bottom-10 left-1/2 -translate-x-1/2 px-10 py-4 bg-primary text-white rounded-full font-black text-xs uppercase z-[110] shadow-2xl hover:scale-105 active:scale-95 transition-all">Готово</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*,video/*" className="hidden" onChange={handleMediaSelect} />
|
||||
</section>
|
||||
|
||||
<aside className="w-[440px] bg-[#0a0a0a] border-l border-white/5 flex flex-col overflow-y-auto">
|
||||
<div className="p-10 space-y-12 pb-24">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{ id: 'media', icon: <Camera />, label: 'Медиа', action: () => fileInputRef.current?.click() },
|
||||
{ id: 'text', icon: <Type />, label: 'Текст', action: addText },
|
||||
{ id: 'stickers', icon: <Smile />, label: 'Стикеры', active: tool === 'stickers' },
|
||||
{ id: 'brush', icon: <Brush />, label: 'Кисти', active: tool === 'brush' },
|
||||
{ id: 'filters', icon: <SlidersHorizontal />, label: 'Коррекция', active: tool === 'filters' },
|
||||
].map(item => (
|
||||
<button key={item.id} onClick={() => { if(item.action) item.action(); 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'}`}>
|
||||
<div className={tool === item.id ? 'text-primary' : 'text-zinc-700'}>{item.icon}</div>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{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-8 bg-[#121212] p-8 rounded-[2.5rem] border border-white/5 shadow-2xl">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-[10px] font-black uppercase text-primary tracking-widest">Контент</span>
|
||||
<input value={textObjects.find(t => t.id === selectedTextId)?.text} onChange={e => setTextObjects(prev => prev.map(t => t.id === selectedTextId ? { ...t, text: e.target.value } : t))} className="bg-transparent border-b border-white/10 w-full py-2 text-2xl font-black outline-none italic" autoFocus />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<span className="text-[10px] font-black uppercase text-zinc-600 tracking-widest">Типографика</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FONTS.map(f => (
|
||||
<button key={f.id} onClick={() => setTextObjects(prev => prev.map(t => t.id === selectedTextId ? { ...t, font: f.family } : t))} className={`px-5 py-2 rounded-2xl text-[9px] font-black uppercase transition-all ${textObjects.find(t => t.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 className="space-y-4">
|
||||
<div className="flex justify-between text-[10px] font-black uppercase text-zinc-600"><span>Размер</span><span className="text-white">{textObjects.find(t => t.id === selectedTextId)?.size}px</span></div>
|
||||
<input type="range" min="12" max="150" value={textObjects.find(t => t.id === selectedTextId)?.size} onChange={e => setTextObjects(prev => prev.map(t => t.id === selectedTextId ? { ...t, size: parseInt(e.target.value) } : t))} className="w-full accent-primary h-1 bg-white/5 rounded-full" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-6 border-t border-white/5">
|
||||
<span className="text-[10px] font-black uppercase text-zinc-600">Цвет текста</span>
|
||||
<div className="w-12 h-12 rounded-full border-2 border-white/10 relative shadow-inner" style={{ backgroundColor: textObjects.find(t => t.id === selectedTextId)?.color }}>
|
||||
<input type="color" value={textObjects.find(t => t.id === selectedTextId)?.color} onChange={e => setTextObjects(prev => prev.map(t => t.id === selectedTextId ? { ...t, color: e.target.value } : t))} className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" />
|
||||
<Pipette size={18} className="absolute inset-0 m-auto mix-blend-difference" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
<div className="w-24 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" />
|
||||
<Pipette size={32} className="absolute inset-0 m-auto mix-blend-difference group-hover:scale-125 transition-transform" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-[10px] font-bold text-zinc-600 uppercase tracking-widest"><span>Толщина</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" />
|
||||
</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">Очистить холст</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
<Picker data={data} onEmojiSelect={addSticker} theme="dark" set="native" locale="ru" previewPosition="none" skinTonePosition="none" />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
<h4 className="text-[10px] font-black uppercase tracking-widest text-primary">Мастер коррекции</h4>
|
||||
<div className="space-y-6">
|
||||
{[
|
||||
{ id: 'brightness', icon: <Sun size={14}/>, label: 'Яркость', min: 40, max: 160 },
|
||||
{ id: 'contrast', icon: <Contrast size={14}/>, label: 'Контраст', min: 40, max: 160 },
|
||||
{ id: 'saturate', icon: <Droplets size={14}/>, label: 'Насыщенность', min: 0, max: 250 },
|
||||
{ id: 'sepia', icon: <Palette size={14}/>, label: 'Сепия', min: 0, max: 100 },
|
||||
].map(adj => (
|
||||
<div key={adj.id} className="space-y-3">
|
||||
<div className="flex items-center justify-between text-[10px] font-bold text-zinc-700 uppercase">
|
||||
<div className="flex items-center gap-2">{adj.icon} {adj.label}</div>
|
||||
<span className="text-white">{adjustments[adj.id as keyof typeof adjustments]}%</span>
|
||||
</div>
|
||||
<input type="range" min={adj.min} max={adj.max} value={adjustments[adj.id as keyof typeof adjustments]} onChange={e => setAdjustments(prev => ({ ...prev, [adj.id]: parseInt(e.target.value) }))} className="w-full accent-primary h-1 bg-white/5 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!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">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Palette size={24} className="text-primary" />
|
||||
<span className="text-[10px] font-black uppercase text-zinc-500 tracking-widest">Цвет фона</span>
|
||||
</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 }}>
|
||||
<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" />
|
||||
</div>
|
||||
<span className="text-[9px] font-bold text-zinc-700 uppercase">Нажмите для выбора цвета</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="pt-10 border-t border-white/5" />
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import data from '@emoji-mart/data';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
|
||||
interface EmojiOnlyPickerProps {
|
||||
onSelect: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function EmojiOnlyPicker({ onSelect, onClose }: EmojiOnlyPickerProps) {
|
||||
const { lang } = useLang();
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const el = anchorRef.current?.parentElement;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const w = 350; // Slightly narrower for stories
|
||||
let left = rect.right - w;
|
||||
if (left < 8) left = 8;
|
||||
setPos({ top: rect.top - 8, left });
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={anchorRef} className="hidden" />
|
||||
{createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9990]" onClick={onClose} />
|
||||
<div
|
||||
className="fixed z-[9991] rounded-[2rem] shadow-[0_32px_64px_-16px_rgba(0,0,0,0.6)] border border-white/10 overflow-hidden flex flex-col backdrop-blur-3xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: 350,
|
||||
height: 400,
|
||||
bottom: pos ? `${window.innerHeight - pos.top}px` : undefined,
|
||||
left: pos ? pos.left : undefined,
|
||||
background: 'rgba(19, 19, 19, 0.85)',
|
||||
visibility: pos ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<div className="px-5 py-3 border-b border-white/5 bg-black/20 flex-shrink-0">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">{lang === 'ru' ? 'Выбор эмодзи' : 'Choose Emoji'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={(e: { native: string }) => onSelect(e.native)}
|
||||
theme="dark"
|
||||
locale={lang === 'ru' ? 'ru' : 'en'}
|
||||
set="native"
|
||||
previewPosition="none"
|
||||
skinTonePosition="search"
|
||||
perLine={8}
|
||||
emojiSize={24}
|
||||
emojiButtonSize={32}
|
||||
maxFrequentRows={1}
|
||||
navPosition="top"
|
||||
dynamicWidth={false}
|
||||
background="transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user