refactor(frontend): remove requests image warnings

This commit is contained in:
lizerk 2026-04-13 13:19:54 +03:00
parent 2cfa1099f3
commit ba41dd2b0f
6 changed files with 89 additions and 35 deletions

View File

@ -162,6 +162,7 @@
Файлы:
- `requests/RequestAttachmentPreviewModal.tsx`
- `requests/RequestAvatar.tsx`
- `requests/RequestComposeModal.tsx`
- `requests/RequestDetailModal.tsx`
- `requests/RequestListControls.tsx`
@ -183,6 +184,7 @@
- feature container для list shell, который собирает rows + empty state + load more
- отдельный mode-panel для alternate flow вроде swipe-review
- attachment preview modal как отдельный feature block, а не хвост page-level JSX
- локальный avatar/media wrapper вокруг `next/image`, когда feature уже использует несколько однотипных аватаров и preview
- participant badge pattern для request-like доменов
- participant-only UI decisions
- conditional action rendering по данным API

View File

@ -13,7 +13,7 @@
| Зона | Статус | Что уже хорошо | Основные проблемы | Рекомендуемый следующий шаг |
| --- | --- | --- | --- | --- |
| `requests` | `mixed` | compose-настроение, participant-only логика, UI вокруг `can_decide`, compose/list/detail/controls/preview разведены по feature components, screen orchestration вынесена в hook, list/swipe flow собраны в feature containers, dense row приведён к явной anatomy с отдельными detail/comments panels | visual debt сузился, но ещё остаются image warnings и контракт controls block | использовать как референс для разделения orchestration/UI, participant-heavy request flows и anatomy плотной строки, затем добить image/controls cleanup |
| `requests` | `mixed` | compose-настроение, participant-only логика, UI вокруг `can_decide`, compose/list/detail/controls/preview разведены по feature components, screen orchestration вынесена в hook, list/swipe flow собраны в feature containers, dense row приведён к явной anatomy с отдельными detail/comments panels, avatar/preview media contract выровнен через local wrapper | основная незавершённость теперь в контракте controls block | использовать как референс для разделения orchestration/UI, participant-heavy request flows, anatomy плотной строки и локального media-wrapper паттерна, затем добить controls cleanup |
| `settings` | `mixed` | сильная surface hierarchy, хорошие primary sections | встречаются custom radii и локальная геометрическая неоднородность | рефакторить карточки и кастомные радиусы без смены общего layout |
| `documents` | `needs refactor` | богатый набор сценариев, detail/upload/comment patterns уже выделены в компоненты | смешение `rounded-md`, `rounded-lg`, `rounded-xl`, разные уровни visual density | сделать отдельный pass по карточкам и metadata/upload flows |
| `equipment` | `needs refactor` | понятные формы и вторичные блоки | много локальных card/form решений, геометрия плавает | после `documents` пройтись по secondary cards и action forms |

View File

@ -1,5 +1,7 @@
import Image from "next/image";
import { Modal } from "@/components/ui";
import type { RequestAttachmentPreview } from "@/hooks/useRequestsPage";
import { resolveMediaUrl } from "@/lib/url";
import { FileSignature } from "lucide-react";
type RequestAttachmentPreviewModalProps = {
@ -14,6 +16,7 @@ export function RequestAttachmentPreviewModal({
if (!preview) return null;
const extension = preview.url.split(".").pop()?.toLowerCase() || "";
const previewUrl = resolveMediaUrl(preview.url);
return (
<Modal
@ -23,7 +26,7 @@ export function RequestAttachmentPreviewModal({
size="lg"
footer={(
<a
href={preview.url}
href={previewUrl}
download
className="app-action-secondary rounded-lg px-3 py-1.5 text-xs font-medium"
>
@ -33,21 +36,26 @@ export function RequestAttachmentPreviewModal({
>
<div className="flex-1 overflow-auto">
{["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"].includes(extension) ? (
<img
src={preview.url}
alt={preview.name}
className="mx-auto max-h-[70vh] rounded-lg object-contain"
/>
<div className="relative mx-auto h-[70vh] overflow-hidden rounded-lg bg-[var(--surface-secondary)]">
<Image
src={previewUrl}
alt={preview.name}
fill
unoptimized
sizes="(max-width: 1024px) 100vw, 960px"
className="object-contain"
/>
</div>
) : ["mp4", "webm", "ogg", "mov"].includes(extension) ? (
<video src={preview.url} controls className="mx-auto max-h-[70vh] rounded-lg" />
<video src={previewUrl} controls className="mx-auto max-h-[70vh] rounded-lg" />
) : ["mp3", "wav", "aac"].includes(extension) ? (
<audio src={preview.url} controls className="mx-auto mt-8" />
<audio src={previewUrl} controls className="mx-auto mt-8" />
) : (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<FileSignature size={40} className="app-text-muted" />
<p className="app-text-muted text-sm">{preview.name}</p>
<a
href={preview.url}
href={previewUrl}
download
className="app-action-primary rounded-lg px-4 py-2 text-sm font-medium"
>

View File

@ -0,0 +1,48 @@
import Image from "next/image";
import { resolveMediaUrl } from "@/lib/url";
const avatarSizeMap = {
sm: { className: "h-6 w-6 text-[11px]", px: 24 },
md: { className: "h-7 w-7 text-xs", px: 28 },
lg: { className: "h-8 w-8 text-xs", px: 32 },
} as const;
type RequestAvatarProps = {
alt: string;
fallback: string;
size?: keyof typeof avatarSizeMap;
src?: string | null;
};
export function RequestAvatar({
alt,
fallback,
size = "md",
src,
}: RequestAvatarProps) {
const config = avatarSizeMap[size];
const resolvedSrc = resolveMediaUrl(src);
if (resolvedSrc) {
return (
<span className={`app-avatar-frame relative shrink-0 overflow-hidden rounded-full ${config.className}`}>
<Image
src={resolvedSrc}
alt={alt}
width={config.px}
height={config.px}
unoptimized
className="h-full w-full object-cover"
/>
</span>
);
}
return (
<span
className={`app-avatar-fallback flex shrink-0 items-center justify-center rounded-full font-semibold ${config.className}`}
>
{fallback}
</span>
);
}

View File

@ -9,6 +9,7 @@ import { displayUserName, formatDate, formatDateTime, userProfileLink } from "@/
import type { Request, RequestComment, User } from "@/types/api";
import { Ban, ChevronDown, ChevronRight, MessageSquare, Paperclip, Pencil, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
import type { ReactNode, Ref } from "react";
import { RequestAvatar } from "./RequestAvatar";
import { RequestUserBadge } from "./RequestUserBadge";
type RequestListItemProps = {
@ -137,6 +138,7 @@ export function RequestListItem({
}: RequestListItemProps) {
const requestAuthor = request.employee || request.created_by;
const authorName = displayUserName(requestAuthor);
const authorFallback = (requestAuthor?.first_name?.[0] || requestAuthor?.last_name?.[0] || "?").toUpperCase();
const authorLink = requestAuthor ? userProfileLink(requestAuthor, currentUserId) : null;
const statusKey = String(request.status || "").toLowerCase();
const status = statusMeta[statusKey] ?? defaultStatusMeta;
@ -204,24 +206,23 @@ export function RequestListItem({
<div className="flex flex-wrap items-center gap-2">
{authorLink ? (
<Link href={authorLink} className="group flex min-w-0 items-center gap-2">
{requestAuthor?.avatar ? (
<img
src={requestAuthor.avatar}
alt={authorName}
className="app-avatar-frame h-8 w-8 shrink-0 rounded-full object-cover"
/>
) : (
<span className="app-avatar-fallback flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{(requestAuthor?.first_name?.[0] || requestAuthor?.last_name?.[0] || "?").toUpperCase()}
</span>
)}
<RequestAvatar
alt={authorName}
fallback={authorFallback}
size="lg"
src={requestAuthor?.avatar}
/>
<span className="truncate text-sm font-medium text-[var(--foreground)] group-hover:text-[var(--accent-primary-strong)]">
{authorName}
</span>
</Link>
) : (
<div className="flex min-w-0 items-center gap-2">
<span className="app-badge flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold">?</span>
<RequestAvatar
alt={authorName}
fallback={authorFallback}
size="lg"
/>
<span className="truncate text-sm font-medium text-[var(--foreground)]">{authorName}</span>
</div>
)}

View File

@ -1,6 +1,7 @@
import Link from "next/link";
import { displayUserName, userProfileLink } from "@/lib/shared";
import type { User } from "@/types/api";
import { RequestAvatar } from "./RequestAvatar";
type RequestUserBadgeProps = {
currentUserId?: number | null;
@ -15,21 +16,15 @@ export function RequestUserBadge({
}: RequestUserBadgeProps) {
const personLink = userProfileLink(person, currentUserId);
const personName = displayUserName(person);
const fallback = (person.first_name?.[0] || person.last_name?.[0] || "?").toUpperCase();
const chip = (
<>
{person.avatar ? (
<img
src={person.avatar}
alt={personName}
className={`app-avatar-frame ${large ? "h-7 w-7" : "h-6 w-6"} shrink-0 rounded-full object-cover`}
/>
) : (
<span
className={`app-avatar-fallback ${large ? "h-7 w-7 text-xs" : "h-6 w-6 text-[11px]"} flex shrink-0 items-center justify-center rounded-full font-semibold`}
>
{(person.first_name?.[0] || person.last_name?.[0] || "?").toUpperCase()}
</span>
)}
<RequestAvatar
alt={personName}
fallback={fallback}
size={large ? "md" : "sm"}
src={person.avatar}
/>
<span className="break-words">{personName}</span>
</>
);