feat(tasks): add task card covers
This commit is contained in:
parent
3643bea2fc
commit
bfb175088c
@ -304,6 +304,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
linked_objects_count = serializers.SerializerMethodField()
|
||||
comments_count = serializers.SerializerMethodField()
|
||||
attachments_count = serializers.SerializerMethodField()
|
||||
cover_attachment = serializers.SerializerMethodField()
|
||||
checklist_total = serializers.SerializerMethodField()
|
||||
checklist_completed = serializers.SerializerMethodField()
|
||||
|
||||
@ -346,6 +347,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
"linked_objects_count",
|
||||
"comments_count",
|
||||
"attachments_count",
|
||||
"cover_attachment",
|
||||
"checklist_total",
|
||||
"checklist_completed",
|
||||
"created_at",
|
||||
@ -375,6 +377,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
"linked_objects_count",
|
||||
"comments_count",
|
||||
"attachments_count",
|
||||
"cover_attachment",
|
||||
"checklist_total",
|
||||
"checklist_completed",
|
||||
"created_at",
|
||||
@ -479,6 +482,29 @@ class TaskSerializer(serializers.ModelSerializer):
|
||||
return obj.attachments_count
|
||||
return obj.attachments.count()
|
||||
|
||||
@extend_schema_field(serializers.DictField(allow_null=True))
|
||||
def get_cover_attachment(self, obj):
|
||||
cover_attachments = getattr(obj, "cover_attachments", None)
|
||||
if cover_attachments is not None:
|
||||
attachment = cover_attachments[0] if cover_attachments else None
|
||||
else:
|
||||
prefetched = getattr(obj, "_prefetched_objects_cache", {}).get(
|
||||
"attachments"
|
||||
)
|
||||
if prefetched is not None:
|
||||
attachment = next(
|
||||
(item for item in prefetched if item.is_cover),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
attachment = obj.attachments.filter(is_cover=True).first()
|
||||
if attachment is None:
|
||||
return None
|
||||
return TaskAttachmentSerializer(
|
||||
attachment,
|
||||
context=self.context,
|
||||
).data
|
||||
|
||||
@extend_schema_field(serializers.IntegerField())
|
||||
def get_checklist_total(self, obj):
|
||||
if hasattr(obj, "checklist_total"):
|
||||
@ -1002,6 +1028,7 @@ class TaskAttachmentSerializer(serializers.ModelSerializer):
|
||||
"file_name",
|
||||
"file_size",
|
||||
"mime_type",
|
||||
"is_cover",
|
||||
"uploaded_by",
|
||||
"created_at",
|
||||
"download_url",
|
||||
|
||||
@ -935,7 +935,18 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
)
|
||||
.select_related("board", "column", "row", "created_by", "assignee")
|
||||
.prefetch_related("labels", "participants", "checklist_items")
|
||||
.prefetch_related(
|
||||
"labels",
|
||||
"participants",
|
||||
"checklist_items",
|
||||
Prefetch(
|
||||
"attachments",
|
||||
queryset=TaskAttachment.objects.filter(
|
||||
is_cover=True
|
||||
).select_related("uploaded_by"),
|
||||
to_attr="cover_attachments",
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
linked_posts_count=Count(
|
||||
"linked_objects",
|
||||
@ -1422,6 +1433,7 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
attachment_id = attachment.id
|
||||
file_name = attachment.file_name
|
||||
was_cover = attachment.is_cover
|
||||
attachment.delete()
|
||||
create_task_activity(
|
||||
task,
|
||||
@ -1435,10 +1447,81 @@ class TaskViewSet(viewsets.ModelViewSet):
|
||||
"attachments_updated",
|
||||
"task",
|
||||
task.id,
|
||||
extra={"attachments_count": task.attachments.count()},
|
||||
extra={
|
||||
"attachments_count": task.attachments.count(),
|
||||
**({"cover_attachment_id": None} if was_cover else {}),
|
||||
},
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["post", "delete"], url_path="cover")
|
||||
def cover(self, request, pk=None):
|
||||
task = self.get_object()
|
||||
attachment_id = request.data.get("attachment_id")
|
||||
if request.method == "POST" and not attachment_id:
|
||||
return Response(
|
||||
{"attachment_id": ["Это поле обязательно."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
locked_task = Task.objects.select_for_update().get(pk=task.pk)
|
||||
previous_id = (
|
||||
locked_task.attachments.filter(is_cover=True)
|
||||
.values_list("id", flat=True)
|
||||
.first()
|
||||
)
|
||||
next_attachment = None
|
||||
if request.method == "POST":
|
||||
next_attachment = get_object_or_404(
|
||||
locked_task.attachments,
|
||||
id=attachment_id,
|
||||
)
|
||||
content_type = next_attachment.mime_type or (
|
||||
mimetypes.guess_type(next_attachment.file_name)[0] or ""
|
||||
)
|
||||
if not content_type.lower().startswith("image/"):
|
||||
return Response(
|
||||
{"attachment_id": ["Обложкой может быть только изображение."]},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
next_id = next_attachment.id if next_attachment else None
|
||||
if previous_id != next_id:
|
||||
locked_task.attachments.filter(is_cover=True).update(is_cover=False)
|
||||
if next_attachment:
|
||||
TaskAttachment.objects.filter(pk=next_attachment.pk).update(
|
||||
is_cover=True
|
||||
)
|
||||
locked_task.save(update_fields=["updated_at"])
|
||||
|
||||
if previous_id != next_id:
|
||||
create_task_activity(
|
||||
locked_task,
|
||||
request.user,
|
||||
TaskActivityAction.UPDATED,
|
||||
metadata={
|
||||
"fields": ["cover_attachment_id"],
|
||||
"cover_attachment_id": next_id,
|
||||
},
|
||||
)
|
||||
notify_task_updated(
|
||||
locked_task,
|
||||
request.user,
|
||||
{"cover_attachment_id": previous_id},
|
||||
{"cover_attachment_id": next_id},
|
||||
)
|
||||
send_task_board_update(
|
||||
locked_task.board,
|
||||
"updated",
|
||||
"task",
|
||||
locked_task.id,
|
||||
extra={"cover_attachment_id": next_id},
|
||||
)
|
||||
|
||||
refreshed_task = self.get_queryset().get(pk=locked_task.pk)
|
||||
return Response(self.get_serializer(refreshed_task).data)
|
||||
|
||||
@action(detail=True, methods=["get", "post"], url_path="checklist")
|
||||
def checklist(self, request, pk=None):
|
||||
task = self.get_object()
|
||||
|
||||
@ -114,8 +114,15 @@ class TaskAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(TaskAttachment)
|
||||
class TaskAttachmentAdmin(admin.ModelAdmin):
|
||||
list_display = ["file_name", "task", "uploaded_by", "file_size", "created_at"]
|
||||
list_filter = ["created_at"]
|
||||
list_display = [
|
||||
"file_name",
|
||||
"task",
|
||||
"is_cover",
|
||||
"uploaded_by",
|
||||
"file_size",
|
||||
"created_at",
|
||||
]
|
||||
list_filter = ["is_cover", "created_at"]
|
||||
search_fields = ["file_name", "task__title", "uploaded_by__email"]
|
||||
autocomplete_fields = ["task", "uploaded_by"]
|
||||
|
||||
|
||||
23
backend/tasks/migrations/0029_taskattachment_is_cover.py
Normal file
23
backend/tasks/migrations/0029_taskattachment_is_cover.py
Normal file
@ -0,0 +1,23 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("tasks", "0028_unique_done_column_per_board"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="taskattachment",
|
||||
name="is_cover",
|
||||
field=models.BooleanField(default=False, verbose_name="Обложка задачи"),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="taskattachment",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(is_cover=True),
|
||||
fields=("task",),
|
||||
name="uniq_task_cover_attachment",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -381,6 +381,7 @@ class TaskAttachment(models.Model):
|
||||
file_name = models.CharField("Название файла", max_length=255)
|
||||
file_size = models.PositiveBigIntegerField("Размер файла (байты)")
|
||||
mime_type = models.CharField("MIME-тип", max_length=255, blank=True)
|
||||
is_cover = models.BooleanField("Обложка задачи", default=False)
|
||||
uploaded_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
@ -394,6 +395,13 @@ class TaskAttachment(models.Model):
|
||||
verbose_name_plural = "Вложения задач"
|
||||
ordering = ["created_at", "id"]
|
||||
indexes = [models.Index(fields=["task", "created_at"])]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["task"],
|
||||
condition=models.Q(is_cover=True),
|
||||
name="uniq_task_cover_attachment",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.task}: {self.file_name}"
|
||||
|
||||
@ -123,6 +123,7 @@ class MessageTemplates:
|
||||
"priority": "приоритет",
|
||||
"label_ids": "метки",
|
||||
"participant_ids": "участников",
|
||||
"cover_attachment_id": "обложку",
|
||||
}
|
||||
changed = ", ".join(field_names.get(field, field) for field in fields)
|
||||
return (
|
||||
|
||||
@ -264,7 +264,13 @@ def notify_task_updated(task: Task, actor, previous: dict, current: dict):
|
||||
|
||||
generic_fields = [
|
||||
field
|
||||
for field in ("title", "description", "priority", "label_ids")
|
||||
for field in (
|
||||
"title",
|
||||
"description",
|
||||
"priority",
|
||||
"label_ids",
|
||||
"cover_attachment_id",
|
||||
)
|
||||
if previous.get(field) != current.get(field)
|
||||
]
|
||||
if participant_ids_changed:
|
||||
|
||||
@ -1498,6 +1498,107 @@ def test_task_attachment_has_no_size_limit(
|
||||
assert TaskAttachment.objects.filter(task=task, file_name="large.txt").exists()
|
||||
|
||||
|
||||
def test_task_cover_uses_image_attachment_and_keeps_file_when_cleared(
|
||||
api_client,
|
||||
user,
|
||||
tmp_path,
|
||||
settings,
|
||||
):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
member = make_user("task-cover-member@example.com", "+79994440105")
|
||||
outsider = make_user("task-cover-outsider@example.com", "+79994440106")
|
||||
board = TaskBoard.objects.create(name="Доска с обложкой", created_by=user)
|
||||
board.members.add(member)
|
||||
column = TaskColumn.objects.create(board=board, name="Новые", position=1000)
|
||||
task = Task.objects.create(
|
||||
board=board,
|
||||
column=column,
|
||||
title="Задача с обложкой",
|
||||
created_by=user,
|
||||
)
|
||||
first_image = TaskAttachment.objects.create(
|
||||
task=task,
|
||||
file=SimpleUploadedFile("first.png", b"first image", content_type="image/png"),
|
||||
file_name="first.png",
|
||||
file_size=11,
|
||||
mime_type="image/png",
|
||||
uploaded_by=user,
|
||||
)
|
||||
second_image = TaskAttachment.objects.create(
|
||||
task=task,
|
||||
file=SimpleUploadedFile("second.jpg", b"second image", content_type="image/jpeg"),
|
||||
file_name="second.jpg",
|
||||
file_size=12,
|
||||
mime_type="image/jpeg",
|
||||
uploaded_by=user,
|
||||
)
|
||||
document = TaskAttachment.objects.create(
|
||||
task=task,
|
||||
file=SimpleUploadedFile("brief.pdf", b"pdf", content_type="application/pdf"),
|
||||
file_name="brief.pdf",
|
||||
file_size=3,
|
||||
mime_type="application/pdf",
|
||||
uploaded_by=user,
|
||||
)
|
||||
cover_url = reverse("api:v1:tasks:task-cover", kwargs={"pk": task.id})
|
||||
|
||||
api_client.force_authenticate(user=member)
|
||||
first_response = api_client.post(
|
||||
cover_url,
|
||||
{"attachment_id": first_image.id},
|
||||
format="json",
|
||||
)
|
||||
assert first_response.status_code == status.HTTP_200_OK
|
||||
assert first_response.data["cover_attachment"]["id"] == first_image.id
|
||||
assert first_response.data["cover_attachment"]["is_cover"] is True
|
||||
|
||||
second_response = api_client.post(
|
||||
cover_url,
|
||||
{"attachment_id": second_image.id},
|
||||
format="json",
|
||||
)
|
||||
assert second_response.status_code == status.HTTP_200_OK
|
||||
first_image.refresh_from_db()
|
||||
second_image.refresh_from_db()
|
||||
assert first_image.is_cover is False
|
||||
assert second_image.is_cover is True
|
||||
assert TaskAttachment.objects.filter(task=task, is_cover=True).count() == 1
|
||||
board_response = api_client.get(
|
||||
reverse("api:v1:tasks:task-board-detail", kwargs={"pk": board.id})
|
||||
)
|
||||
assert board_response.status_code == status.HTTP_200_OK
|
||||
serialized_task = next(
|
||||
item for item in board_response.data["tasks"] if item["id"] == task.id
|
||||
)
|
||||
assert serialized_task["cover_attachment"]["id"] == second_image.id
|
||||
|
||||
invalid_response = api_client.post(
|
||||
cover_url,
|
||||
{"attachment_id": document.id},
|
||||
format="json",
|
||||
)
|
||||
assert invalid_response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
second_image.refresh_from_db()
|
||||
assert second_image.is_cover is True
|
||||
|
||||
api_client.force_authenticate(user=outsider)
|
||||
assert (
|
||||
api_client.post(
|
||||
cover_url,
|
||||
{"attachment_id": first_image.id},
|
||||
format="json",
|
||||
).status_code
|
||||
== status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=member)
|
||||
clear_response = api_client.delete(cover_url)
|
||||
assert clear_response.status_code == status.HTTP_200_OK
|
||||
assert clear_response.data["cover_attachment"] is None
|
||||
assert TaskAttachment.objects.filter(task=task).count() == 3
|
||||
assert not TaskAttachment.objects.filter(task=task, is_cover=True).exists()
|
||||
|
||||
|
||||
def test_task_external_links_follow_board_access_and_support_lifecycle(
|
||||
api_client,
|
||||
user,
|
||||
|
||||
@ -74,6 +74,7 @@ import {
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import TaskBoardAvatar from "@/components/tasks/TaskBoardAvatar";
|
||||
import TaskBoardNavigator from "@/components/tasks/TaskBoardNavigator";
|
||||
import TaskCoverImage from "@/components/tasks/TaskCoverImage";
|
||||
import { RequestAvatar } from "@/components/requests/RequestAvatar";
|
||||
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
|
||||
import { Modal } from "@/components/ui";
|
||||
@ -417,6 +418,16 @@ function formatFileSize(size: number): string {
|
||||
})} МБ`;
|
||||
}
|
||||
|
||||
function isImageAttachment(attachment: TaskAttachment): boolean {
|
||||
if (attachment.mime_type?.toLowerCase().startsWith("image/")) return true;
|
||||
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(attachment.file_name);
|
||||
}
|
||||
|
||||
function isImageFile(file: File): boolean {
|
||||
if (file.type.toLowerCase().startsWith("image/")) return true;
|
||||
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|webp)$/i.test(file.name);
|
||||
}
|
||||
|
||||
function getMessageCreatedLabel(message?: Message | null) {
|
||||
if (!message) return "";
|
||||
return message.created || (message.created_at ? formatDate(message.created_at) : "");
|
||||
@ -471,6 +482,13 @@ function TaskCardView({
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
{task.cover_attachment ? (
|
||||
<TaskCoverImage
|
||||
taskId={task.id}
|
||||
attachment={task.cover_attachment}
|
||||
className="-mx-3 -mt-3 mb-3 aspect-[16/7] rounded-t-[11px] border-b border-[var(--border-subtle)]"
|
||||
/>
|
||||
) : null}
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1 overflow-hidden whitespace-normal text-left">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
@ -2410,12 +2428,18 @@ function TaskFormResourceRow({
|
||||
title,
|
||||
meta,
|
||||
pending = false,
|
||||
canUseAsCover = false,
|
||||
isCover = false,
|
||||
onToggleCover,
|
||||
onRemove,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
meta?: string;
|
||||
pending?: boolean;
|
||||
canUseAsCover?: boolean;
|
||||
isCover?: boolean;
|
||||
onToggleCover?: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
@ -2438,6 +2462,18 @@ function TaskFormResourceRow({
|
||||
новое
|
||||
</span>
|
||||
) : null}
|
||||
{canUseAsCover && onToggleCover ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCover}
|
||||
className={`${isCover ? "app-action-primary" : "app-icon-button"} flex h-8 w-8 shrink-0 items-center justify-center rounded-lg`}
|
||||
title={isCover ? "Убрать обложку" : "Сделать обложкой"}
|
||||
aria-label={isCover ? `Убрать обложку ${title}` : `Сделать обложкой ${title}`}
|
||||
aria-pressed={isCover}
|
||||
>
|
||||
<ImagePlus size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
@ -2696,6 +2732,8 @@ function TasksPageContent() {
|
||||
const [taskFormRemovedChecklistItemIds, setTaskFormRemovedChecklistItemIds] = useState<number[]>([]);
|
||||
const [taskFormDirtyChecklistItemIds, setTaskFormDirtyChecklistItemIds] = useState<number[]>([]);
|
||||
const [taskFormAttachments, setTaskFormAttachments] = useState<TaskAttachment[]>([]);
|
||||
const [taskFormCoverAttachmentId, setTaskFormCoverAttachmentId] = useState<number | null>(null);
|
||||
const [taskFormCoverPendingFile, setTaskFormCoverPendingFile] = useState<File | null>(null);
|
||||
const [taskFormDocuments, setTaskFormDocuments] = useState<TaskLinkedDocument[]>([]);
|
||||
const [taskFormEvents, setTaskFormEvents] = useState<TaskLinkedCalendarEvent[]>([]);
|
||||
const [taskFormExternalLinks, setTaskFormExternalLinks] = useState<TaskExternalLink[]>([]);
|
||||
@ -3002,6 +3040,8 @@ function TasksPageContent() {
|
||||
setTaskFormRemovedChecklistItemIds([]);
|
||||
setTaskFormDirtyChecklistItemIds([]);
|
||||
setTaskFormAttachments([]);
|
||||
setTaskFormCoverAttachmentId(null);
|
||||
setTaskFormCoverPendingFile(null);
|
||||
setTaskFormDocuments([]);
|
||||
setTaskFormEvents([]);
|
||||
setTaskFormExternalLinks([]);
|
||||
@ -3029,6 +3069,10 @@ function TasksPageContent() {
|
||||
if (requestId !== taskFormResourcesRequestRef.current) return;
|
||||
setTaskFormChecklistItems(checklist);
|
||||
setTaskFormAttachments(attachments);
|
||||
setTaskFormCoverAttachmentId(
|
||||
attachments.find((attachment) => attachment.is_cover)?.id ?? null,
|
||||
);
|
||||
setTaskFormCoverPendingFile(null);
|
||||
setTaskFormDocuments(documents);
|
||||
setTaskFormEvents(events);
|
||||
setTaskFormExternalLinks(externalLinks);
|
||||
@ -4472,6 +4516,8 @@ function TasksPageContent() {
|
||||
}, [board, loadBoard, saving]);
|
||||
|
||||
const persistTaskFormResources = useCallback(async (taskId: number) => {
|
||||
let selectedCoverAttachmentId = taskFormCoverAttachmentId;
|
||||
|
||||
for (const itemId of [...taskFormRemovedChecklistItemIds]) {
|
||||
await apiClient.deleteTaskChecklistItem(taskId, itemId);
|
||||
setTaskFormRemovedChecklistItemIds((current) => current.filter((id) => id !== itemId));
|
||||
@ -4504,6 +4550,9 @@ function TasksPageContent() {
|
||||
|
||||
for (const attachmentId of [...taskFormRemovedAttachmentIds]) {
|
||||
await apiClient.deleteTaskAttachment(taskId, attachmentId);
|
||||
if (selectedCoverAttachmentId === attachmentId) {
|
||||
selectedCoverAttachmentId = null;
|
||||
}
|
||||
setTaskFormRemovedAttachmentIds((current) => (
|
||||
current.filter((id) => id !== attachmentId)
|
||||
));
|
||||
@ -4555,9 +4604,20 @@ function TasksPageContent() {
|
||||
|
||||
for (const file of [...taskFormPendingFiles]) {
|
||||
const uploaded = await apiClient.uploadTaskAttachments(taskId, [file]);
|
||||
if (file === taskFormCoverPendingFile && uploaded[0]) {
|
||||
selectedCoverAttachmentId = uploaded[0].id;
|
||||
}
|
||||
setTaskFormPendingFiles((current) => current.filter((item) => item !== file));
|
||||
setTaskFormAttachments((current) => [...current, ...uploaded]);
|
||||
}
|
||||
|
||||
if (selectedCoverAttachmentId) {
|
||||
await apiClient.setTaskCover(taskId, selectedCoverAttachmentId);
|
||||
} else {
|
||||
await apiClient.clearTaskCover(taskId);
|
||||
}
|
||||
setTaskFormCoverAttachmentId(selectedCoverAttachmentId);
|
||||
setTaskFormCoverPendingFile(null);
|
||||
}, [
|
||||
taskFormChecklistItems,
|
||||
taskFormDirtyChecklistItemIds,
|
||||
@ -4566,6 +4626,8 @@ function TasksPageContent() {
|
||||
taskFormPendingExternalLinks,
|
||||
taskFormPendingFiles,
|
||||
taskFormPendingChecklistItems,
|
||||
taskFormCoverAttachmentId,
|
||||
taskFormCoverPendingFile,
|
||||
taskFormRemovedAttachmentIds,
|
||||
taskFormRemovedChecklistItemIds,
|
||||
taskFormRemovedDocumentLinkIds,
|
||||
@ -4760,6 +4822,29 @@ function TasksPageContent() {
|
||||
}
|
||||
}, [activityOpen, board, loadBoard, loadTaskActivity, taskAttachmentsUploading, viewTask]);
|
||||
|
||||
const toggleViewedTaskCover = useCallback(async (attachment: TaskAttachment) => {
|
||||
if (!board || !viewTask || taskAttachmentsUploading) return;
|
||||
|
||||
setTaskAttachmentsUploading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updatedTask = attachment.is_cover
|
||||
? await apiClient.clearTaskCover(viewTask.id)
|
||||
: await apiClient.setTaskCover(viewTask.id, attachment.id);
|
||||
const coverId = updatedTask.cover_attachment?.id ?? null;
|
||||
setTaskAttachments((current) => current.map((item) => ({
|
||||
...item,
|
||||
is_cover: item.id === coverId,
|
||||
})));
|
||||
await loadBoard(board.id);
|
||||
if (activityOpen) await loadTaskActivity(viewTask.id);
|
||||
} catch (attachmentError) {
|
||||
setError(getTaskError(attachmentError, "Не удалось изменить обложку задачи"));
|
||||
} finally {
|
||||
setTaskAttachmentsUploading(false);
|
||||
}
|
||||
}, [activityOpen, board, loadBoard, loadTaskActivity, taskAttachmentsUploading, viewTask]);
|
||||
|
||||
const addViewedTaskChecklistItem = useCallback(async () => {
|
||||
const title = taskChecklistDraft.trim();
|
||||
if (!board || !viewTask || !title || taskChecklistSaving) return;
|
||||
@ -4905,6 +4990,26 @@ function TasksPageContent() {
|
||||
const removeExistingTaskFormAttachment = useCallback((attachment: TaskAttachment) => {
|
||||
setTaskFormAttachments((current) => current.filter((item) => item.id !== attachment.id));
|
||||
setTaskFormRemovedAttachmentIds((current) => [...current, attachment.id]);
|
||||
setTaskFormCoverAttachmentId((current) => (
|
||||
current === attachment.id ? null : current
|
||||
));
|
||||
}, []);
|
||||
|
||||
const removePendingTaskFormFile = useCallback((file: File) => {
|
||||
setTaskFormPendingFiles((current) => current.filter((item) => item !== file));
|
||||
setTaskFormCoverPendingFile((current) => (current === file ? null : current));
|
||||
}, []);
|
||||
|
||||
const toggleExistingTaskFormCover = useCallback((attachment: TaskAttachment) => {
|
||||
setTaskFormCoverAttachmentId((current) => (
|
||||
current === attachment.id ? null : attachment.id
|
||||
));
|
||||
setTaskFormCoverPendingFile(null);
|
||||
}, []);
|
||||
|
||||
const togglePendingTaskFormCover = useCallback((file: File) => {
|
||||
setTaskFormCoverPendingFile((current) => (current === file ? null : file));
|
||||
setTaskFormCoverAttachmentId(null);
|
||||
}, []);
|
||||
|
||||
const removeExistingTaskFormDocument = useCallback((link: TaskLinkedDocument) => {
|
||||
@ -6929,6 +7034,21 @@ function TasksPageContent() {
|
||||
</span>
|
||||
<Download size={14} className="app-text-muted shrink-0" />
|
||||
</button>
|
||||
{isImageAttachment(attachment) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleViewedTaskCover(attachment)}
|
||||
disabled={taskAttachmentsUploading}
|
||||
className={`${attachment.is_cover ? "app-action-primary" : "app-icon-button"} flex h-8 w-8 shrink-0 items-center justify-center rounded-lg disabled:opacity-50`}
|
||||
title={attachment.is_cover ? "Убрать обложку" : "Сделать обложкой"}
|
||||
aria-label={attachment.is_cover
|
||||
? `Убрать обложку ${attachment.file_name}`
|
||||
: `Сделать обложкой ${attachment.file_name}`}
|
||||
aria-pressed={attachment.is_cover}
|
||||
>
|
||||
<ImagePlus size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void deleteViewedTaskAttachment(attachment)}
|
||||
@ -8140,6 +8260,9 @@ function TasksPageContent() {
|
||||
icon={FileText}
|
||||
title={attachment.file_name}
|
||||
meta={formatFileSize(attachment.file_size)}
|
||||
canUseAsCover={isImageAttachment(attachment)}
|
||||
isCover={taskFormCoverAttachmentId === attachment.id}
|
||||
onToggleCover={() => toggleExistingTaskFormCover(attachment)}
|
||||
onRemove={() => removeExistingTaskFormAttachment(attachment)}
|
||||
/>
|
||||
))}
|
||||
@ -8150,9 +8273,10 @@ function TasksPageContent() {
|
||||
title={file.name}
|
||||
meta={formatFileSize(file.size)}
|
||||
pending
|
||||
onRemove={() => setTaskFormPendingFiles((current) => (
|
||||
current.filter((item) => item !== file)
|
||||
))}
|
||||
canUseAsCover={isImageFile(file)}
|
||||
isCover={taskFormCoverPendingFile === file}
|
||||
onToggleCover={() => togglePendingTaskFormCover(file)}
|
||||
onRemove={() => removePendingTaskFormFile(file)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
77
frontend/src/components/tasks/TaskCoverImage.tsx
Normal file
77
frontend/src/components/tasks/TaskCoverImage.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { ImagePlus, Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { TaskAttachment } from "@/types/api";
|
||||
|
||||
export default function TaskCoverImage({
|
||||
taskId,
|
||||
attachment,
|
||||
className = "",
|
||||
}: {
|
||||
taskId: number;
|
||||
attachment: TaskAttachment;
|
||||
className?: string;
|
||||
}) {
|
||||
const [loadedImage, setLoadedImage] = useState<{
|
||||
attachmentId: number;
|
||||
source: string;
|
||||
} | null>(null);
|
||||
const [failedAttachmentId, setFailedAttachmentId] = useState<number | null>(null);
|
||||
const attachmentId = attachment.id;
|
||||
const downloadUrl = attachment.download_url;
|
||||
const fileName = attachment.file_name;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
void apiClient
|
||||
.downloadTaskAttachment(taskId, {
|
||||
id: attachmentId,
|
||||
download_url: downloadUrl,
|
||||
file_name: fileName,
|
||||
})
|
||||
.then(({ blob }) => {
|
||||
if (!active) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setLoadedImage({ attachmentId, source: objectUrl });
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setFailedAttachmentId(attachmentId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [attachmentId, downloadUrl, fileName, taskId]);
|
||||
|
||||
const source = loadedImage?.attachmentId === attachment.id
|
||||
? loadedImage.source
|
||||
: null;
|
||||
const failed = failedAttachmentId === attachment.id;
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden bg-[var(--surface-secondary)] ${className}`}>
|
||||
{source ? (
|
||||
<Image
|
||||
src={source}
|
||||
alt=""
|
||||
fill
|
||||
unoptimized
|
||||
sizes="272px"
|
||||
className="object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<span className="app-text-muted absolute inset-0 flex items-center justify-center">
|
||||
{failed ? <ImagePlus size={20} /> : <Loader2 size={18} className="animate-spin" />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -211,7 +211,7 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
|
||||
},
|
||||
downloadTaskAttachment: async (
|
||||
taskId: number,
|
||||
attachment: TaskAttachment,
|
||||
attachment: Pick<TaskAttachment, "id" | "download_url" | "file_name">,
|
||||
): Promise<{ blob: Blob; filename: string }> => {
|
||||
const response = await requestRaw(
|
||||
attachment.download_url || `/api/v1/tasks/${taskId}/attachments/${attachment.id}/download/`,
|
||||
@ -225,6 +225,15 @@ export function createTasksApi(request: RequestFn, requestRaw: RawRequestFn) {
|
||||
request(`/api/v1/tasks/${taskId}/attachments/${attachmentId}/`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
setTaskCover: (taskId: number, attachmentId: number): Promise<TaskCard> =>
|
||||
request(`/api/v1/tasks/${taskId}/cover/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ attachment_id: attachmentId }),
|
||||
}),
|
||||
clearTaskCover: (taskId: number): Promise<TaskCard> =>
|
||||
request(`/api/v1/tasks/${taskId}/cover/`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getTaskChecklist: (id: number): Promise<TaskChecklistItem[]> =>
|
||||
request(`/api/v1/tasks/${id}/checklist/`),
|
||||
addTaskChecklistItem: (
|
||||
|
||||
@ -240,6 +240,7 @@ export interface TaskCard {
|
||||
linked_objects_count?: number;
|
||||
comments_count?: number;
|
||||
attachments_count?: number;
|
||||
cover_attachment?: TaskAttachment | null;
|
||||
checklist_total?: number;
|
||||
checklist_completed?: number;
|
||||
created_at: string;
|
||||
@ -540,6 +541,7 @@ export interface TaskAttachment {
|
||||
file_name: string;
|
||||
file_size: number;
|
||||
mime_type?: string;
|
||||
is_cover: boolean;
|
||||
uploaded_by?: User;
|
||||
created_at: string;
|
||||
download_url: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user