refactor(frontend): unify comment composer controls
This commit is contained in:
parent
c96b9453a5
commit
b9a6de7b43
@ -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` |
|
||||
|
||||
@ -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-*`
|
||||
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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}
|
||||
|
||||
108
frontend/src/components/shared/CommentControls.tsx
Normal file
108
frontend/src/components/shared/CommentControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user