refactor(frontend): unify comment composer controls

This commit is contained in:
lizerk 2026-04-13 15:33:57 +03:00
parent c96b9453a5
commit b9a6de7b43
8 changed files with 185 additions and 97 deletions

View File

@ -135,6 +135,7 @@ Target state:
| Обычный input / textarea / select | `app-input` / `app-select` + `rounded-lg` | нет | `ok` |
| Searchable dropdown trigger | shared `SearchableSelect*` + `rounded-lg` | compose inline-row может быть крупнее по shell | `ok` |
| Compose address row | shared select pattern на базе `app-input` | `rounded-2xl` допустим для compose-shell строки | `ok` |
| Inline comment composer | shared `CommentControls` + icon-only send action | feed/footer variant может добавлять attachment actions перед input | `ok` |
| Helper / alert / selected block | `app-selected`, `app-feedback-*`, `app-surface-muted` + `rounded-lg`/`rounded-xl` | зависит от веса блока | `needs refactor` |
| Pills / status chips / avatar | `rounded-full` | нет | `ok` |
| `rounded-md` у новых карточек и полей | не использовать | только legacy до отдельной переработки | `legacy` |

View File

@ -254,6 +254,7 @@
- modal: `Modal`
- searchable dropdown: `SearchableSelect*`
- compose address line: `SearchableSelectMulti` с compose-like layout
- comment composer / delete action: `shared/CommentControls.tsx`
- primary/secondary surface: `app-surface` / `app-surface-muted`
- feedback block: `app-selected`, `app-feedback-*`

View File

@ -8,6 +8,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Archive, ArrowRightLeft, ArrowUpDown, Check, ChevronDown, ChevronRight, Copy, Download, ExternalLink, Filter, MessageSquare, Monitor, Pencil, Plus, QrCode, Search, Shield, Trash2, Wrench } from "lucide-react";
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { formatDate, formatMoney } from "@/lib/shared";
import { useEquipmentPage } from "@/hooks/useEquipmentPage";
import { Modal } from "@/components/ui";
@ -759,7 +760,11 @@ function EquipmentPageContent() {
<span className="font-medium">{displayUserName(c.author)}</span>
<div className="flex items-center gap-2">
<span className="app-text-muted">{formatDate(c.created_at)}</span>
{canDel && <button type="button" onClick={() => handleDeleteComment(item.id, c.id)} className="app-action-danger rounded-md px-1.5 py-0.5">удалить</button>}
{canDel ? (
<CommentDeleteButton
onClick={() => handleDeleteComment(item.id, c.id)}
/>
) : null}
</div>
</div>
<p className="app-text-wrap">{c.text}</p>
@ -768,9 +773,13 @@ function EquipmentPageContent() {
})
)}
</div>
<div className="mt-2 flex items-center gap-2">
<input value={commentDrafts[item.id] || ""} onChange={(e) => setCommentDrafts((p) => ({ ...p, [item.id]: e.target.value }))} placeholder="Добавить комментарий" className="app-input flex-1 rounded-lg px-3 py-2 text-xs" />
<button type="button" onClick={() => handleAddComment(item.id)} disabled={busyKey === `comment-${item.id}`} className="app-action-primary rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-60">Отправить</button>
<div className="mt-2">
<CommentComposer
value={commentDrafts[item.id] || ""}
onChange={(value) => setCommentDrafts((p) => ({ ...p, [item.id]: value }))}
onSubmit={() => handleAddComment(item.id)}
disabled={busyKey === `comment-${item.id}`}
/>
</div>
</div>
)}

View File

@ -208,7 +208,7 @@ function HomePageContent() {
async function loadPosts() {
try {
await refreshPosts();
} catch (err: any) {
} catch (err: unknown) {
console.error('Ошибка загрузки ленты:', err);
setError('Не удалось загрузить ленту');
} finally {
@ -376,8 +376,8 @@ function HomePageContent() {
try {
const response = await apiClient.getPostLikers(postId);
setLikesUsersMap((prev) => ({ ...prev, [postId]: response.results || [] }));
} catch (err: any) {
const message = String(err?.message || "");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "";
if (
message.includes("404") ||
message.includes("NetworkError") ||
@ -579,8 +579,8 @@ function HomePageContent() {
setCreatePostOpen(false);
setEditingPostId(null);
} catch (err: any) {
const message = String(err?.message || "Не удалось сохранить публикацию");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Не удалось сохранить публикацию";
setCreateError(message);
} finally {
setCreateSubmitting(false);
@ -980,7 +980,7 @@ function HomePageContent() {
)}
</div>
)}
<div className="flex items-center gap-2">
<div className="grid grid-cols-[auto_auto_auto] gap-2 sm:grid-cols-[auto_auto_minmax(0,1fr)_auto] sm:items-center">
<input ref={commentImageRef} type="file" accept="image/*" className="hidden" onChange={(e) => { setCommentImage(e.target.files?.[0] || null); e.target.value = ""; }} />
<input ref={commentAttachmentRef} type="file" className="hidden" onChange={(e) => { setCommentAttachment(e.target.files?.[0] || null); e.target.value = ""; }} />
<button
@ -1010,13 +1010,13 @@ function HomePageContent() {
}
}}
placeholder="Напишите комментарий..."
className="app-input h-10 flex-1 rounded-lg px-3 text-sm"
className="app-input order-4 col-span-3 min-w-0 h-10 w-full rounded-lg px-3 text-sm sm:order-3 sm:col-span-1"
/>
<button
type="button"
disabled={commentSending || (!newComment.trim() && !commentImage && !commentAttachment)}
onClick={handleCreateComment}
className="app-action-primary flex h-10 w-10 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-50"
className="app-action-primary order-3 flex h-10 w-10 items-center justify-center rounded-lg disabled:cursor-not-allowed disabled:opacity-50 sm:order-4"
>
<Send size={16} />
</button>

View File

@ -31,6 +31,7 @@ import {
X,
XCircle,
} from "lucide-react";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import { formatDate, formatMoney } from "@/lib/shared";
import { useProcurementPage } from "@/hooks/useProcurementPage";
@ -870,9 +871,9 @@ export default function ProcurementPage() {
<div className="flex items-center gap-2">
<span className="app-text-muted">{fmt(comment.created_at)}</span>
{canDeleteCommentThis ? (
<button type="button" onClick={() => void handleDeleteComment(req.id, comment.id)} className="app-action-danger rounded-md px-1.5 py-0.5">
удалить
</button>
<CommentDeleteButton
onClick={() => handleDeleteComment(req.id, comment.id)}
/>
) : null}
</div>
</div>
@ -882,9 +883,13 @@ export default function ProcurementPage() {
})
)}
</div>
<div className="mt-2 flex items-center gap-2">
<input value={commentDrafts[req.id] || ""} onChange={(e) => setCommentDrafts((previous) => ({ ...previous, [req.id]: e.target.value }))} placeholder="Добавить комментарий" className="app-input flex-1 rounded-lg px-3 py-2 text-xs" />
<button type="button" onClick={() => void handleAddComment(req.id)} disabled={busyKey === `comment-${req.id}`} className="app-action-primary rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-60">Отправить</button>
<div className="mt-2">
<CommentComposer
value={commentDrafts[req.id] || ""}
onChange={(value) => setCommentDrafts((previous) => ({ ...previous, [req.id]: value }))}
onSubmit={() => handleAddComment(req.id)}
disabled={busyKey === `comment-${req.id}`}
/>
</div>
</div>
) : null}
@ -1006,9 +1011,9 @@ export default function ProcurementPage() {
<div className="flex items-center gap-2">
<span className="app-text-muted">{fmt(comment.created_at)}</span>
{canDeleteCommentThis ? (
<button type="button" onClick={() => void handleDeleteComment(selectedRequest.id, comment.id)} className="app-action-danger rounded-md px-1.5 py-0.5">
удалить
</button>
<CommentDeleteButton
onClick={() => handleDeleteComment(selectedRequest.id, comment.id)}
/>
) : null}
</div>
</div>
@ -1018,9 +1023,13 @@ export default function ProcurementPage() {
})
)}
</div>
<div className="mt-3 flex items-center gap-2">
<input value={commentDrafts[selectedRequest.id] || ""} onChange={(e) => setCommentDrafts((previous) => ({ ...previous, [selectedRequest.id]: e.target.value }))} placeholder="Добавить комментарий" className="app-input flex-1 rounded-lg px-3 py-2 text-xs" />
<button type="button" onClick={() => void handleAddComment(selectedRequest.id)} disabled={busyKey === `comment-${selectedRequest.id}`} className="app-action-primary rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-60">Отправить</button>
<div className="mt-3">
<CommentComposer
value={commentDrafts[selectedRequest.id] || ""}
onChange={(value) => setCommentDrafts((previous) => ({ ...previous, [selectedRequest.id]: value }))}
onSubmit={() => handleAddComment(selectedRequest.id)}
disabled={busyKey === `comment-${selectedRequest.id}`}
/>
</div>
</div>
</div>

View File

@ -1,10 +1,11 @@
"use client";
import { useState, useEffect } from "react";
import { MessageCircle, Send, Edit2, Trash2, CornerDownRight } from "lucide-react";
import { MessageCircle, Edit2, CornerDownRight } from "lucide-react";
import { apiClient } from "@/lib/api";
import { toast } from "sonner";
import { useUser } from "@/contexts/UserContext";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
interface Comment {
id: number;
@ -187,13 +188,7 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
>
<Edit2 size={14} />
</button>
<button
onClick={() => handleDeleteComment(comment.id)}
className="app-action-danger rounded p-1"
title="Удалить"
>
<Trash2 size={14} />
</button>
<CommentDeleteButton onClick={() => handleDeleteComment(comment.id)} />
</div>
)}
</div>
@ -251,34 +246,18 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
{/* Reply form */}
{replyTo === comment.id && (
<div className="app-divider ml-6 mt-2 border-l-2 pl-4">
<div className="flex gap-2">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Напишите ответ..."
className="app-input flex-1 rounded-lg p-2 text-sm"
rows={2}
/>
</div>
<div className="mt-2 flex gap-2">
<button
onClick={handleSubmitComment}
disabled={submitting || !newComment.trim()}
className="app-action-primary inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium disabled:opacity-50"
>
<Send size={12} />
Отправить
</button>
<button
onClick={() => {
setReplyTo(null);
setNewComment("");
}}
className="app-action-secondary rounded-lg px-3 py-1.5 text-xs font-medium"
>
Отмена
</button>
</div>
<CommentComposer
value={newComment}
onChange={setNewComment}
onSubmit={handleSubmitComment}
onCancel={() => {
setReplyTo(null);
setNewComment("");
}}
disabled={submitting}
multiline
placeholder="Напишите ответ..."
/>
</div>
)}
</div>
@ -304,23 +283,15 @@ export function DocumentComments({ documentId }: DocumentCommentsProps) {
{/* New comment form */}
{!replyTo && (
<div className="space-y-2">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Добавить комментарий..."
className="app-input w-full rounded-lg p-3 text-sm"
rows={3}
/>
<button
onClick={handleSubmitComment}
disabled={submitting || !newComment.trim()}
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
>
<Send size={14} />
Отправить
</button>
</div>
<CommentComposer
value={newComment}
onChange={setNewComment}
onSubmit={handleSubmitComment}
disabled={submitting}
multiline
rows={3}
placeholder="Добавить комментарий..."
/>
)}
{/* Comments list */}

View File

@ -19,6 +19,7 @@ import {
Trash2,
} from "lucide-react";
import type { Ref } from "react";
import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls";
import { RequestAvatar } from "./RequestAvatar";
import { RequestUserBadge } from "./RequestUserBadge";
@ -403,13 +404,9 @@ export function RequestListItem({
<div className="flex items-center gap-2">
<span className="app-text-muted">{formatDate(comment.created_at)}</span>
{Boolean(comment.author?.id && currentUserId === comment.author.id) ? (
<button
type="button"
onClick={() => void onDeleteComment(request.id, comment.id)}
className="app-action-danger rounded-lg px-1.5 py-0.5"
>
удалить
</button>
<CommentDeleteButton
onClick={() => onDeleteComment(request.id, comment.id)}
/>
) : null}
</div>
</div>
@ -418,21 +415,13 @@ export function RequestListItem({
))
)}
</div>
<div className="mt-2 flex items-center gap-2">
<input
<div className="mt-2">
<CommentComposer
value={commentDraft}
onChange={(event) => onSetCommentDraft(request.id, event.target.value)}
placeholder="Добавить комментарий"
className="app-input flex-1 rounded-lg px-3 py-2 text-xs"
onChange={(value) => onSetCommentDraft(request.id, value)}
onSubmit={() => onAddComment(request.id)}
disabled={busyKey === `comment-${request.id}`}
/>
<button
type="button"
onClick={() => void onAddComment(request.id)}
disabled={busyKey === `comment-${request.id}` || !commentDraft.trim()}
className="app-action-primary rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-60"
>
Отправить
</button>
</div>
</div>
) : null}

View File

@ -0,0 +1,108 @@
"use client";
import { Send, Trash2 } from "lucide-react";
type CommentComposerProps = {
disabled?: boolean;
multiline?: boolean;
onCancel?: () => void;
onChange: (value: string) => void;
onSubmit: () => void | Promise<void>;
placeholder?: string;
rows?: number;
value: string;
};
export function CommentComposer({
disabled = false,
multiline = false,
onCancel,
onChange,
onSubmit,
placeholder = "Добавить комментарий",
rows = 2,
value,
}: CommentComposerProps) {
const submitDisabled = disabled || !value.trim();
if (multiline) {
return (
<div className="space-y-2">
<textarea
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className="app-input w-full rounded-lg p-3 text-sm resize-none"
rows={rows}
/>
<div className="flex flex-wrap items-center justify-end gap-2">
{onCancel ? (
<button
type="button"
onClick={onCancel}
className="app-action-secondary rounded-lg px-3 py-1.5 text-xs font-medium"
>
Отмена
</button>
) : null}
<button
type="button"
onClick={() => void onSubmit()}
disabled={submitDisabled}
className="app-action-primary inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg disabled:opacity-50"
title="Отправить комментарий"
aria-label="Отправить комментарий"
>
<Send size={14} />
</button>
</div>
</div>
);
}
return (
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className="app-input min-w-0 w-full rounded-lg px-3 py-2 text-xs"
/>
<div className="flex items-center justify-end">
<button
type="button"
onClick={() => void onSubmit()}
disabled={submitDisabled}
className="app-action-primary inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg disabled:opacity-50"
title="Отправить комментарий"
aria-label="Отправить комментарий"
>
<Send size={14} />
</button>
</div>
</div>
);
}
type CommentDeleteButtonProps = {
disabled?: boolean;
onClick: () => void | Promise<void>;
};
export function CommentDeleteButton({
disabled = false,
onClick,
}: CommentDeleteButtonProps) {
return (
<button
type="button"
onClick={() => void onClick()}
disabled={disabled}
className="app-action-danger inline-flex h-7 w-7 items-center justify-center rounded-lg disabled:opacity-50"
title="Удалить комментарий"
aria-label="Удалить комментарий"
>
<Trash2 size={14} />
</button>
);
}