-
This commit is contained in:
parent
224a5f86da
commit
f4fbaff747
@ -1792,7 +1792,7 @@ class EmployeeViewSet(viewsets.ModelViewSet):
|
||||
|
||||
# Аватар
|
||||
if avatar_bytes:
|
||||
user.photo.save(
|
||||
user.avatar.save(
|
||||
f"avatar_{user.id}.jpg",
|
||||
ContentFile(avatar_bytes),
|
||||
save=False,
|
||||
|
||||
@ -32,6 +32,7 @@ def _file_to_data_uri(uploaded) -> str:
|
||||
"""
|
||||
Превращаем загруженный файл (InMemoryUploadedFile/TemporaryUploadedFile)
|
||||
в data URI (data:image/...;base64,xxxx).
|
||||
Deprecated: используйте прямую отправку файлов через multipart/form-data.
|
||||
"""
|
||||
if not uploaded:
|
||||
return ""
|
||||
@ -495,18 +496,18 @@ def employee_edit(request, pk: int):
|
||||
avatar_file.name,
|
||||
avatar_file.size,
|
||||
)
|
||||
# Конвертируем в data URI для Base64ImageField
|
||||
data_uri = _file_to_data_uri(avatar_file)
|
||||
payload['avatar'] = data_uri
|
||||
logger.info(f"[employee_edit] Avatar converted, data URI length: {len(data_uri)}")
|
||||
else:
|
||||
logger.warning("[employee_edit] No avatar in request.FILES")
|
||||
|
||||
logger.info(f"[employee_edit] Final payload keys: {list(payload.keys())}")
|
||||
|
||||
# Отправляем в DRF как JSON (с base64 аватаром)
|
||||
# Отправляем в DRF (multipart если есть файл, иначе JSON)
|
||||
try:
|
||||
resp = api.patch(f"v1/employees/{pk}/", json=payload)
|
||||
if avatar_file:
|
||||
files = {"avatar": (avatar_file.name, avatar_file.read(), avatar_file.content_type)}
|
||||
resp = api.patch(f"v1/employees/{pk}/", data=payload, files=files)
|
||||
else:
|
||||
resp = api.patch(f"v1/employees/{pk}/", json=payload)
|
||||
logger.info(f"[employee_edit] API response status: {resp.status}")
|
||||
except Exception as e:
|
||||
logger.error(f"[employee_edit] API call error: {e}")
|
||||
@ -612,13 +613,6 @@ def employee_edit_me(request):
|
||||
avatar_file.name,
|
||||
avatar_file.size,
|
||||
)
|
||||
# Конвертируем в data URI для Base64ImageField
|
||||
data_uri = _file_to_data_uri(avatar_file)
|
||||
payload['avatar'] = data_uri
|
||||
logger.info(
|
||||
"[employee_edit_me] Avatar converted to data URI, length: %s",
|
||||
len(data_uri),
|
||||
)
|
||||
else:
|
||||
logger.warning("[employee_edit_me] No avatar in request.FILES")
|
||||
|
||||
@ -627,9 +621,13 @@ def employee_edit_me(request):
|
||||
list(payload.keys()),
|
||||
)
|
||||
|
||||
# Отправляем в DRF как JSON (с base64 аватаром)
|
||||
# Отправляем в DRF (multipart если есть файл, иначе JSON)
|
||||
try:
|
||||
resp = api.patch("v1/employees/me/", json=payload)
|
||||
if avatar_file:
|
||||
files = {"avatar": (avatar_file.name, avatar_file.read(), avatar_file.content_type)}
|
||||
resp = api.patch("v1/employees/me/", data=payload, files=files)
|
||||
else:
|
||||
resp = api.patch("v1/employees/me/", json=payload)
|
||||
logger.info(
|
||||
"[employee_edit_me] API response status: %s",
|
||||
resp.status,
|
||||
@ -710,8 +708,6 @@ def employee_create(request):
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if request.FILES.get("avatar"):
|
||||
data["avatar"] = _file_to_data_uri(request.FILES["avatar"])
|
||||
skills = request.POST.getlist("skills")
|
||||
if skills:
|
||||
data["skills"] = skills
|
||||
@ -719,7 +715,16 @@ def employee_create(request):
|
||||
if password:
|
||||
data["password"] = password # API сгенерирует сам, если не передали
|
||||
|
||||
resp = api.post("v1/employees/", json=data)
|
||||
# Если есть аватар - multipart, иначе JSON
|
||||
files = {}
|
||||
if request.FILES.get("avatar"):
|
||||
avatar_file = request.FILES["avatar"]
|
||||
files["avatar"] = (avatar_file.name, avatar_file.read(), avatar_file.content_type)
|
||||
|
||||
if files:
|
||||
resp = api.post("v1/employees/", data=data, files=files)
|
||||
else:
|
||||
resp = api.post("v1/employees/", json=data)
|
||||
if not resp.ok:
|
||||
messages.error(
|
||||
request, f"Не удалось создать: {resp.status} — {resp.text}"
|
||||
@ -775,10 +780,6 @@ def employee_create_modal(request):
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Обработка аватара
|
||||
if request.FILES.get("avatar"):
|
||||
data["avatar"] = _file_to_data_uri(request.FILES["avatar"])
|
||||
|
||||
# Обработка навыков (если будут добавлены в форму)
|
||||
skills = request.POST.getlist("skills")
|
||||
if skills:
|
||||
@ -789,8 +790,19 @@ def employee_create_modal(request):
|
||||
if password:
|
||||
data["password"] = password
|
||||
|
||||
# Если есть аватар - отправляем multipart, иначе JSON
|
||||
files = {}
|
||||
if request.FILES.get("avatar"):
|
||||
avatar_file = request.FILES["avatar"]
|
||||
files["avatar"] = (avatar_file.name, avatar_file.read(), avatar_file.content_type)
|
||||
|
||||
# Отправляем запрос к API
|
||||
resp = api.post("v1/employees/", json=data)
|
||||
if files:
|
||||
# С файлом - multipart/form-data
|
||||
resp = api.post("v1/employees/", data=data, files=files)
|
||||
else:
|
||||
# Без файла - JSON
|
||||
resp = api.post("v1/employees/", json=data)
|
||||
|
||||
if not resp.ok:
|
||||
# Парсим ошибки от API
|
||||
|
||||
182
frontend/src/components/feed/CommentsList.tsx
Normal file
182
frontend/src/components/feed/CommentsList.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { feedApi } from '@/api/feed';
|
||||
import type { Comment } from '@/types';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
const commentSchema = z.object({
|
||||
text: z.string().min(1, 'Комментарий не может быть пустым').max(1000, 'Максимум 1000 символов'),
|
||||
});
|
||||
|
||||
type CommentFormData = z.infer<typeof commentSchema>;
|
||||
|
||||
interface CommentsListProps {
|
||||
postId: number;
|
||||
}
|
||||
|
||||
export function CommentsList({ postId }: CommentsListProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const { register, handleSubmit, formState: { errors }, reset } = useForm<CommentFormData>({
|
||||
resolver: zodResolver(commentSchema),
|
||||
});
|
||||
|
||||
const { data: comments, isLoading } = useQuery({
|
||||
queryKey: ['comments', postId],
|
||||
queryFn: () => feedApi.getComments(postId),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CommentFormData) => feedApi.createComment(postId, data.text),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||
reset();
|
||||
setShowForm(false);
|
||||
},
|
||||
});
|
||||
|
||||
const likeMutation = useMutation({
|
||||
mutationFn: (comment: Comment) =>
|
||||
comment.liked_by_me
|
||||
? feedApi.unlikeComment(comment.id)
|
||||
: feedApi.likeComment(comment.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
|
||||
},
|
||||
});
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
|
||||
if (diffMins < 1) return 'только что';
|
||||
if (diffMins < 60) return `${diffMins} мин назад`;
|
||||
if (diffHours < 24) return `${diffHours} ч назад`;
|
||||
return date.toLocaleDateString('ru-RU');
|
||||
};
|
||||
|
||||
const onSubmit = (data: CommentFormData) => {
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-4 text-gray-500">Загрузка...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Форма добавления */}
|
||||
{showForm ? (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-2">
|
||||
<textarea
|
||||
{...register('text')}
|
||||
placeholder="Напишите комментарий..."
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
/>
|
||||
{errors.text && (
|
||||
<p className="text-sm text-red-600">{errors.text.message}</p>
|
||||
)}
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
isLoading={createMutation.isPending}
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowForm(true)}
|
||||
className="w-full"
|
||||
>
|
||||
💬 Добавить комментарий
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Список комментариев */}
|
||||
{comments && comments.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
{/* Аватар автора */}
|
||||
{comment.author.photo ? (
|
||||
<img
|
||||
src={comment.author.photo}
|
||||
alt={comment.author.full_name}
|
||||
className="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-blue-600 font-medium text-xs">
|
||||
{comment.author.first_name?.[0]}{comment.author.last_name?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Заголовок */}
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="font-medium text-sm text-gray-900">
|
||||
{comment.author.full_name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatDate(comment.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Текст комментария */}
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap break-words">
|
||||
{comment.text}
|
||||
</p>
|
||||
|
||||
{/* Лайк */}
|
||||
<button
|
||||
onClick={() => likeMutation.mutate(comment)}
|
||||
disabled={likeMutation.isPending}
|
||||
className={`mt-2 flex items-center space-x-1 text-xs ${
|
||||
comment.liked_by_me ? 'text-blue-600' : 'text-gray-500'
|
||||
} hover:text-blue-600 transition-colors`}
|
||||
>
|
||||
<span>{comment.liked_by_me ? '❤️' : '🤍'}</span>
|
||||
{comment.likes_count > 0 && (
|
||||
<span>{comment.likes_count}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-sm text-gray-500 py-4">
|
||||
Комментариев пока нет. Будьте первым!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user