Files
cats/client/src/pages/UploadPage.tsx
heagbokat 91ab44c6fb feat: UI redesign — polished layout, modal, nav, profile
- CSS: refined shadows with border ring, dark bg #0A0A0A, larger card
  radius (16/22px), modal-backdrop blur, scrollbar styling, new
  dropzone + profile-grid CSS classes
- Navbar: paw emoji logo, labels under icons, accent gradient bar,
  active-state ring on avatar
- Login / Register: auth card with decorative background blobs,
  form wrapped in card, cleaner spacing
- CatModal: two-column layout on desktop (photo left, info right),
  modal-backdrop blur, comment input with user avatar, like label
- ProfilePage: 88px avatar with camera overlay, inline stat bar with
  icons, grid section divider, Stat helper component
- CatCard: points badge in header (amber), caption text secondary
  color, ring on avatar
- FeedSidebar: color-coded rank badges, hover rows, star icon
- UploadPage: new dropzone-idle/dropzone-active CSS classes,
  filename chip on preview, UserAvatar in caption row, HEIC/AVIF in
  accepted formats hint, 500-char counter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 00:30:41 +03:00

180 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDropzone } from 'react-dropzone';
import { useUploadCat } from '@/hooks/useCats';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/components/Toast';
import { Button } from '@/components/ui/button';
import UserAvatar from '@/components/UserAvatar';
import { ArrowLeft, Upload, X, ImagePlus } from 'lucide-react';
const ACCEPTED_FORMATS = 'JPG, PNG, GIF, WebP, HEIC, AVIF';
export default function UploadPage() {
const navigate = useNavigate();
const { user, updateUser } = useAuth();
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [caption, setCaption] = useState('');
const uploadMutation = useUploadCat();
const { toast } = useToast();
const onDrop = useCallback((accepted: File[]) => {
const f = accepted[0];
if (f) {
setFile(f);
if (preview) URL.revokeObjectURL(preview);
setPreview(URL.createObjectURL(f));
}
}, [preview]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.heif', '.avif'],
},
maxFiles: 1,
maxSize: 20 * 1024 * 1024,
});
const handleUpload = async () => {
if (!file) return;
const fd = new FormData();
fd.append('image', file);
fd.append('caption', caption);
try {
const result = await uploadMutation.mutateAsync(fd);
if (user) updateUser({ ...user, points: (user.points || 0) + 1 });
toast('+1 балл 🎉');
navigate(`/cat/${result.cat.id}`);
} catch (err: any) {
toast(err?.response?.data?.error || 'Ошибка загрузки', 'error');
}
};
const clearFile = () => {
setFile(null);
if (preview) URL.revokeObjectURL(preview);
setPreview(null);
};
return (
<div className="mx-auto max-w-[520px] px-5 py-6">
<button
onClick={() => navigate(-1)}
className="mb-6 flex items-center gap-1.5 text-xs text-[var(--fg-tertiary)] hover:text-[var(--fg)] transition-colors"
>
<ArrowLeft className="h-4 w-4" strokeWidth={1.5} />
Назад
</button>
<h1 className="text-xl font-extrabold tracking-tight mb-6">Новая публикация</h1>
{!preview ? (
<div
{...getRootProps()}
className={`dropzone-idle text-center p-16 ${isDragActive ? 'dropzone-active' : ''}`}
>
<input {...getInputProps()} />
<div className="flex justify-center mb-5">
<div className={`h-16 w-16 rounded-2xl flex items-center justify-center transition-colors ${
isDragActive ? 'bg-[var(--accent)] text-white' : 'bg-[var(--accent-light)] text-[var(--accent)]'
}`}>
<ImagePlus className="h-7 w-7" strokeWidth={1.5} />
</div>
</div>
<p className="text-sm font-semibold mb-1.5">
{isDragActive ? 'Отпустите фото здесь' : 'Перетащите фото сюда'}
</p>
<p className="text-xs text-[var(--fg-tertiary)] mb-6">
или нажмите, чтобы выбрать файл
</p>
<Button variant="outline" size="sm" className="rounded-xl text-xs px-6 h-9">
Выбрать файл
</Button>
<p className="mt-5 text-[10px] text-[var(--fg-tertiary)] tracking-wide">
{ACCEPTED_FORMATS} · до 20 МБ
</p>
</div>
) : (
<div className="space-y-5 animate-fade-up">
{/* Превью */}
<div className="relative bg-[var(--bg)] rounded-2xl overflow-hidden shadow-sm">
<img
src={preview}
alt="Preview"
className="max-h-[440px] w-full object-contain"
/>
<button
onClick={clearFile}
className="absolute right-3 top-3 h-8 w-8 flex items-center justify-center rounded-xl bg-black/50 text-white hover:bg-black/70 transition-all backdrop-blur-sm"
>
<X className="h-4 w-4" strokeWidth={2} />
</button>
{file && (
<div className="absolute bottom-3 left-3 px-2.5 py-1 rounded-lg bg-black/50 text-white text-[10px] font-medium backdrop-blur-sm">
{file.name}
</div>
)}
</div>
{/* Подпись */}
<div className="card p-4 flex items-start gap-3">
<UserAvatar
avatarUrl={user?.avatar_url}
username={user?.username || '?'}
className="h-9 w-9 shrink-0"
fallbackClassName="text-xs avatar-colored bg-[var(--accent)]"
/>
<div className="flex-1">
<p className="text-sm font-semibold mb-1.5">@{user?.username}</p>
<textarea
placeholder="Добавьте подпись..."
value={caption}
onChange={e => setCaption(e.target.value)}
maxLength={500}
rows={3}
className="w-full resize-none text-sm bg-transparent outline-none placeholder:text-[var(--fg-tertiary)] leading-relaxed"
/>
<div className="flex justify-end mt-1">
<span className={`text-[10px] stat-value ${caption.length > 450 ? 'text-[var(--danger)]' : 'text-[var(--fg-tertiary)]'}`}>
{caption.length}/500
</span>
</div>
</div>
</div>
<div className="divider" />
{/* Публикация */}
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--fg-tertiary)] flex items-center gap-1.5 bg-[var(--accent-light)] text-[var(--accent)] px-3 py-1.5 rounded-full font-semibold">
<Upload className="h-3 w-3" strokeWidth={1.5} />
+1 балл
</span>
<Button
onClick={handleUpload}
disabled={uploadMutation.isPending}
size="sm"
className="rounded-xl text-sm px-7 h-10 bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-white disabled:opacity-60 shadow-sm"
>
{uploadMutation.isPending ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />
Публикуем...
</span>
) : 'Опубликовать'}
</Button>
</div>
{uploadMutation.isError && (
<div className="flex items-center gap-2 px-4 py-3 rounded-xl bg-[var(--danger-light)] text-[var(--danger)] text-xs font-medium animate-fade-up">
Не удалось загрузить. Попробуйте снова.
</div>
)}
</div>
)}
</div>
);
}