refactor: improve layout structure and consistency across components

- Refactored layout structure in multiple components to enhance consistency and maintainability by introducing outer and inner wrapper classes.
- Updated the `MonthNavHeader` component for shared month navigation functionality, improving code reuse.
- Adjusted padding and margin properties in various components to ensure a cohesive design and better responsiveness.
- Removed unnecessary padding from certain elements to streamline the layout and improve visual clarity.
This commit is contained in:
2026-03-06 17:19:31 +03:00
parent 26a9443e1b
commit 43cd3bbd7d
10 changed files with 186 additions and 150 deletions

View File

@@ -10,14 +10,15 @@ import { useEffect } from "react";
import { useAdminPage, AdminDutyList, ReassignSheet } from "@/components/admin"; import { useAdminPage, AdminDutyList, ReassignSheet } from "@/components/admin";
import { useTranslation } from "@/i18n/use-translation"; import { useTranslation } from "@/i18n/use-translation";
import { useAppStore } from "@/store/app-store"; import { useAppStore } from "@/store/app-store";
import { MonthNavHeader } from "@/components/calendar/MonthNavHeader";
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen"; import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
import { LoadingState } from "@/components/states/LoadingState"; import { LoadingState } from "@/components/states/LoadingState";
import { ErrorState } from "@/components/states/ErrorState"; import { ErrorState } from "@/components/states/ErrorState";
import { Button } from "@/components/ui/button";
import { ChevronLeft as ChevronLeftIcon, ChevronRight as ChevronRightIcon } from "lucide-react";
const PAGE_WRAPPER_CLASS = const OUTER_CLASS =
"content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6"; "content-safe min-h-[var(--tg-viewport-stable-height,100vh)] bg-background";
const INNER_CLASS =
"mx-auto flex w-full max-w-[var(--max-width-app)] flex-col";
export default function AdminPage() { export default function AdminPage() {
const { t, monthName } = useTranslation(); const { t, monthName } = useTranslation();
@@ -31,31 +32,37 @@ export default function AdminPage() {
if (!admin.isAllowed) { if (!admin.isAllowed) {
return ( return (
<div className={PAGE_WRAPPER_CLASS}> <div className={OUTER_CLASS}>
<AccessDeniedScreen primaryAction="reload" /> <div className={INNER_CLASS}>
<AccessDeniedScreen primaryAction="reload" />
</div>
</div> </div>
); );
} }
if (admin.adminCheckComplete === null) { if (admin.adminCheckComplete === null) {
return ( return (
<div className={PAGE_WRAPPER_CLASS}> <div className={OUTER_CLASS}>
<div className={INNER_CLASS}>
<div className="py-4 flex flex-col items-center gap-2"> <div className="py-4 flex flex-col items-center gap-2">
<LoadingState /> <LoadingState />
<p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p> <p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p>
</div> </div>
</div>
</div> </div>
); );
} }
if (admin.adminAccessDenied) { if (admin.adminAccessDenied) {
return ( return (
<div className={PAGE_WRAPPER_CLASS}> <div className={OUTER_CLASS}>
<div className={INNER_CLASS}>
<div className="flex flex-col gap-4 py-6"> <div className="flex flex-col gap-4 py-6">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{admin.adminAccessDeniedDetail ?? t("admin.access_denied")} {admin.adminAccessDeniedDetail ?? t("admin.access_denied")}
</p> </p>
</div> </div>
</div>
</div> </div>
); );
} }
@@ -64,46 +71,17 @@ export default function AdminPage() {
const year = admin.adminMonth.getFullYear(); const year = admin.adminMonth.getFullYear();
return ( return (
<div className={PAGE_WRAPPER_CLASS}> <div className={OUTER_CLASS}>
<div className={INNER_CLASS}>
<header className="sticky top-[var(--app-safe-top)] z-10 flex flex-col items-center border-b border-border bg-background py-3"> <header className="sticky top-[var(--app-safe-top)] z-10 flex flex-col items-center border-b border-border bg-background py-3">
<div className="flex w-full items-center justify-between px-1"> <MonthNavHeader
<Button month={admin.adminMonth}
type="button" disabled={admin.loading}
variant="secondary" onPrevMonth={admin.onPrevMonth}
size="icon" onNextMonth={admin.onNextMonth}
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50" titleAriaLabel={`${t("admin.title")}, ${monthName(month)} ${year}`}
aria-label={t("nav.prev_month")} className="w-full px-1"
disabled={admin.loading} />
onClick={admin.onPrevMonth}
>
<ChevronLeftIcon className="size-5" aria-hidden />
</Button>
<h1
className="m-0 flex flex-col items-center justify-center gap-0 leading-none"
aria-label={`${t("admin.title")}, ${monthName(month)} ${year}`}
>
<span className="text-xs font-normal leading-none text-muted">
{year}
</span>
<span className="text-[1.1rem] font-semibold leading-tight sm:text-[1.25rem]">
{monthName(month)}
</span>
</h1>
<Button
type="button"
variant="secondary"
size="icon"
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
aria-label={t("nav.next_month")}
disabled={admin.loading}
onClick={admin.onNextMonth}
>
<ChevronRightIcon className="size-5" aria-hidden />
</Button>
</div>
<p className="text-sm text-muted-foreground m-0 mt-1">
{admin.loading ? "…" : t("admin.duties_count", { count: String(admin.dutyOnly.length) })}
</p>
</header> </header>
{admin.successMessage && ( {admin.successMessage && (
@@ -157,6 +135,7 @@ export default function AdminPage() {
onCloseAnimationEnd={admin.closeReassign} onCloseAnimationEnd={admin.closeReassign}
t={t} t={t}
/> />
</div>
</div> </div>
); );
} }

View File

@@ -56,15 +56,19 @@ export default function Home() {
}, [setCurrentView, setSelectedDay]); }, [setCurrentView, setSelectedDay]);
const content = accessDenied ? ( const content = accessDenied ? (
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6"> <div className="content-safe min-h-[var(--tg-viewport-stable-height,100vh)] bg-background">
<AccessDeniedScreen primaryAction="reload" /> <div className="mx-auto flex w-full max-w-[var(--max-width-app)] flex-col">
<AccessDeniedScreen primaryAction="reload" />
</div>
</div> </div>
) : currentView === "currentDuty" ? ( ) : currentView === "currentDuty" ? (
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6"> <div className="content-safe min-h-[var(--tg-viewport-stable-height,100vh)] bg-background">
<CurrentDutyView <div className="mx-auto flex w-full max-w-[var(--max-width-app)] flex-col">
onBack={handleBackFromCurrentDuty} <CurrentDutyView
openedFromPin={startParam === "duty"} onBack={handleBackFromCurrentDuty}
/> openedFromPin={startParam === "duty"}
/>
</div>
</div> </div>
) : ( ) : (
<CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} /> <CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />

View File

@@ -168,41 +168,43 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
}, [isAdmin, router]); }, [isAdmin, router]);
return ( return (
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6"> <div className="content-safe min-h-[var(--tg-viewport-stable-height,100vh)] bg-background">
<div <div className="mx-auto flex w-full max-w-[var(--max-width-app)] flex-col">
ref={calendarStickyRef} <div
className="sticky top-[var(--app-safe-top)] z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2 touch-pan-y" ref={calendarStickyRef}
> className="sticky top-[var(--app-safe-top)] z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2 touch-pan-y"
<CalendarHeader >
month={currentMonth} <CalendarHeader
disabled={navDisabled} month={currentMonth}
onPrevMonth={handlePrevMonth} disabled={navDisabled}
onNextMonth={handleNextMonth} onPrevMonth={handlePrevMonth}
/> onNextMonth={handleNextMonth}
<CalendarGrid />
currentMonth={currentMonth} <CalendarGrid
currentMonth={currentMonth}
duties={duties}
calendarEvents={calendarEvents}
onDayClick={handleDayClick}
/>
</div>
{error && (
<ErrorState message={error} onRetry={retry} className="my-3" />
)}
{!error && (
<DutyList
scrollMarginTop={stickyBlockHeight}
className="mt-2"
/>
)}
<DayDetail
ref={dayDetailRef}
duties={duties} duties={duties}
calendarEvents={calendarEvents} calendarEvents={calendarEvents}
onDayClick={handleDayClick} onClose={handleCloseDayDetail}
/> />
</div> </div>
{error && (
<ErrorState message={error} onRetry={retry} className="my-3" />
)}
{!error && (
<DutyList
scrollMarginTop={stickyBlockHeight}
className="mt-2"
/>
)}
<DayDetail
ref={dayDetailRef}
duties={duties}
calendarEvents={calendarEvents}
onClose={handleCloseDayDetail}
/>
</div> </div>
); );
} }

View File

@@ -5,13 +5,9 @@
"use client"; "use client";
import { Button } from "@/components/ui/button";
import { useTranslation } from "@/i18n/use-translation"; import { useTranslation } from "@/i18n/use-translation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import { MonthNavHeader } from "@/components/calendar/MonthNavHeader";
ChevronLeft as ChevronLeftIcon,
ChevronRight as ChevronRightIcon,
} from "lucide-react";
export interface CalendarHeaderProps { export interface CalendarHeaderProps {
/** Currently displayed month (used for title). */ /** Currently displayed month (used for title). */
@@ -30,51 +26,19 @@ export function CalendarHeader({
onNextMonth, onNextMonth,
className, className,
}: CalendarHeaderProps) { }: CalendarHeaderProps) {
const { t, monthName, weekdayLabels } = useTranslation(); const { weekdayLabels } = useTranslation();
const year = month.getFullYear();
const monthIndex = month.getMonth();
const labels = weekdayLabels(); const labels = weekdayLabels();
return ( return (
<header className={cn("flex flex-col", className)}> <header className={cn("flex flex-col", className)}>
<div className="flex items-center justify-between mb-3"> <MonthNavHeader
<Button month={month}
type="button" disabled={disabled}
variant="secondary" onPrevMonth={onPrevMonth}
size="icon" onNextMonth={onNextMonth}
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50" ariaLive
aria-label={t("nav.prev_month")} className="mb-3"
disabled={disabled} />
onClick={onPrevMonth}
>
<ChevronLeftIcon className="size-5" aria-hidden />
</Button>
<div className="flex min-h-[2rem] flex-col items-center justify-center gap-0">
<h1
className="m-0 flex flex-col items-center justify-center gap-0 leading-none"
aria-live="polite"
aria-atomic="true"
>
<span className="text-xs font-normal leading-none text-muted">
{year}
</span>
<span className="text-[1.1rem] font-semibold leading-tight sm:text-[1.25rem]">
{monthName(monthIndex)}
</span>
</h1>
</div>
<Button
type="button"
variant="secondary"
size="icon"
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
aria-label={t("nav.next_month")}
disabled={disabled}
onClick={onNextMonth}
>
<ChevronRightIcon className="size-5" aria-hidden />
</Button>
</div>
<div className="grid grid-cols-7 gap-0.5 mb-1.5 text-center text-[0.75rem] text-muted"> <div className="grid grid-cols-7 gap-0.5 mb-1.5 text-center text-[0.75rem] text-muted">
{labels.map((label, i) => ( {labels.map((label, i) => (
<span key={i} aria-hidden> <span key={i} aria-hidden>

View File

@@ -0,0 +1,86 @@
/**
* Shared month navigation row: prev button, year + month title, next button.
* Used by CalendarHeader and admin page for consistent layout and spacing.
*/
"use client";
import { Button } from "@/components/ui/button";
import { useTranslation } from "@/i18n/use-translation";
import { cn } from "@/lib/utils";
import {
ChevronLeft as ChevronLeftIcon,
ChevronRight as ChevronRightIcon,
} from "lucide-react";
export interface MonthNavHeaderProps {
/** Currently displayed month (used for title). */
month: Date;
/** Whether month navigation is disabled (e.g. during loading). */
disabled?: boolean;
onPrevMonth: () => void;
onNextMonth: () => void;
/** Optional aria-label for the month title (e.g. admin page). */
titleAriaLabel?: string;
/** When true, title is announced on change (e.g. calendar). */
ariaLive?: boolean;
className?: string;
}
const NAV_BUTTON_CLASS =
"size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50";
export function MonthNavHeader({
month,
disabled = false,
onPrevMonth,
onNextMonth,
titleAriaLabel,
ariaLive = false,
className,
}: MonthNavHeaderProps) {
const { t, monthName } = useTranslation();
const year = month.getFullYear();
const monthIndex = month.getMonth();
return (
<div className={cn("flex items-center justify-between", className)}>
<Button
type="button"
variant="secondary"
size="icon"
className={NAV_BUTTON_CLASS}
aria-label={t("nav.prev_month")}
disabled={disabled}
onClick={onPrevMonth}
>
<ChevronLeftIcon className="size-5" aria-hidden />
</Button>
<div className="flex min-h-[2rem] flex-col items-center justify-center gap-0">
<h1
className="m-0 flex flex-col items-center justify-center gap-0 leading-none"
{...(titleAriaLabel ? { "aria-label": titleAriaLabel } : {})}
{...(ariaLive ? { "aria-live": "polite", "aria-atomic": true } : {})}
>
<span className="text-xs font-normal leading-none text-muted">
{year}
</span>
<span className="text-[1.1rem] font-semibold leading-tight sm:text-[1.25rem]">
{monthName(monthIndex)}
</span>
</h1>
</div>
<Button
type="button"
variant="secondary"
size="icon"
className={NAV_BUTTON_CLASS}
aria-label={t("nav.next_month")}
disabled={disabled}
onClick={onNextMonth}
>
<ChevronRightIcon className="size-5" aria-hidden />
</Button>
</div>
);
}

View File

@@ -168,7 +168,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
if (state === "loading") { if (state === "loading") {
return ( return (
<div <div
className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4" className="flex min-h-[50vh] flex-col items-center justify-center gap-4"
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-label={t("loading")} aria-label={t("loading")}
@@ -221,7 +221,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
loadTodayDuties(); loadTodayDuties();
}; };
return ( return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4"> <div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
<Card className="w-full max-w-[var(--max-width-app)]"> <Card className="w-full max-w-[var(--max-width-app)]">
<CardContent className="pt-6"> <CardContent className="pt-6">
<p className="text-error">{errorMessage}</p> <p className="text-error">{errorMessage}</p>
@@ -249,7 +249,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
if (!duty) { if (!duty) {
return ( return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4"> <div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
<Card className="current-duty-card--no-duty w-full max-w-[var(--max-width-app)] border-t-4 border-t-muted"> <Card className="current-duty-card--no-duty w-full max-w-[var(--max-width-app)] border-t-4 border-t-muted">
<CardHeader> <CardHeader>
<CardTitle>{t("current_duty.title")}</CardTitle> <CardTitle>{t("current_duty.title")}</CardTitle>
@@ -307,7 +307,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
Boolean(duty.username && String(duty.username).trim()); Boolean(duty.username && String(duty.username).trim());
return ( return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4"> <div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
<Card <Card
className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty animate-in fade-in-0 slide-in-from-bottom-4 duration-300 motion-reduce:animate-none motion-reduce:duration-0" className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty animate-in fade-in-0 slide-in-from-bottom-4 duration-300 motion-reduce:animate-none motion-reduce:duration-0"
role="article" role="article"

View File

@@ -168,7 +168,7 @@ export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
if (!open || !selectedDay) return null; if (!open || !selectedDay) return null;
const panelClassName = const panelClassName =
"max-w-[min(360px,calc(100vw-24px))] max-h-[70vh] overflow-auto bg-surface text-[var(--text)] rounded-xl shadow-lg p-4 pt-9"; "max-w-[min(360px,calc(100vw - var(--app-safe-left, 0) - var(--app-safe-right, 0) - 24px))] max-h-[70vh] overflow-auto bg-surface text-[var(--text)] rounded-xl shadow-lg p-4 pt-9";
const closeButton = ( const closeButton = (
<Button <Button
type="button" type="button"

View File

@@ -20,8 +20,9 @@ export interface FullScreenStateShellProps {
className?: string; className?: string;
} }
const WRAPPER_CLASS = const OUTER_CLASS =
"content-safe flex min-h-[var(--tg-viewport-stable-height,100vh)] flex-col items-center justify-center gap-4 bg-background px-4 text-foreground"; "content-safe flex min-h-[var(--tg-viewport-stable-height,100vh)] flex-col items-center justify-center gap-4 bg-background text-foreground";
const INNER_CLASS = "mx-auto w-full max-w-[var(--max-width-app)] px-3 flex flex-col items-center gap-4";
/** /**
* Full-screen centered shell with title, optional description, and primary action. * Full-screen centered shell with title, optional description, and primary action.
@@ -37,15 +38,17 @@ export function FullScreenStateShell({
}: FullScreenStateShellProps) { }: FullScreenStateShellProps) {
return ( return (
<div <div
className={className ? `${WRAPPER_CLASS} ${className}` : WRAPPER_CLASS} className={className ? `${OUTER_CLASS} ${className}` : OUTER_CLASS}
role={role} role={role}
> >
<h1 className="text-xl font-semibold">{title}</h1> <div className={INNER_CLASS}>
{description != null && ( <h1 className="text-xl font-semibold">{title}</h1>
<p className="text-center text-muted-foreground">{description}</p> {description != null && (
)} <p className="text-center text-muted-foreground">{description}</p>
{children} )}
{primaryAction} {children}
{primaryAction}
</div>
</div> </div>
); );
} }

View File

@@ -20,7 +20,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-4 sm:px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className className
)} )}
{...props} {...props}
@@ -65,7 +65,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-content" data-slot="card-content"
className={cn("px-6", className)} className={cn("px-4 sm:px-6", className)}
{...props} {...props}
/> />
) )
@@ -75,7 +75,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-footer" data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} className={cn("flex items-center px-4 sm:px-6 [.border-t]:pt-6", className)}
{...props} {...props}
/> />
) )

View File

@@ -96,7 +96,6 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
"admin.no_duties": "No duties this month.", "admin.no_duties": "No duties this month.",
"admin.no_users_for_assign": "No users available for assignment.", "admin.no_users_for_assign": "No users available for assignment.",
"admin.save": "Save", "admin.save": "Save",
"admin.duties_count": "{count} duties this month",
"admin.list_aria": "List of duties to reassign", "admin.list_aria": "List of duties to reassign",
"admin.reassign_aria": "Reassign duty: {date}, {time}, {name}", "admin.reassign_aria": "Reassign duty: {date}, {time}, {name}",
"admin.section_aria": "Duties for {date}", "admin.section_aria": "Duties for {date}",
@@ -195,7 +194,6 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
"admin.no_duties": "В этом месяце дежурств нет.", "admin.no_duties": "В этом месяце дежурств нет.",
"admin.no_users_for_assign": "Нет пользователей для назначения.", "admin.no_users_for_assign": "Нет пользователей для назначения.",
"admin.save": "Сохранить", "admin.save": "Сохранить",
"admin.duties_count": "{count} дежурств в этом месяце",
"admin.list_aria": "Список дежурств для перераспределения", "admin.list_aria": "Список дежурств для перераспределения",
"admin.reassign_aria": "Переназначить дежурство: {date}, {time}, {name}", "admin.reassign_aria": "Переназначить дежурство: {date}, {time}, {name}",
"admin.section_aria": "Дежурства за {date}", "admin.section_aria": "Дежурства за {date}",