Merge branch 'develop'

This commit is contained in:
lizerk 2026-08-12 13:16:58 +03:00
commit 4817641831
4 changed files with 165 additions and 11 deletions

View File

@ -17,6 +17,8 @@ import {
Mail,
Monitor,
Moon,
MousePointer2,
PanelLeftClose,
QrCode,
Save,
Smartphone,
@ -28,6 +30,7 @@ import { AppShell } from "@/components/AppShell";
import NotificationDeliveryApps from "@/components/settings/NotificationDeliveryApps";
import { usePwa } from "@/contexts/PwaContext";
import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext";
import { useDesktopCompactPanelBehavior } from "@/hooks/useDesktopCompactPanelBehavior";
import {
formatSessionDateTime,
getSessionDeviceKind,
@ -62,6 +65,21 @@ const mobileNavCards = [
},
] as const;
const desktopCompactPanelCards = [
{
value: "hover",
title: "Раскрывать при наведении",
description: "Меню и календарь временно выезжают поверх страницы при наведении мыши.",
Icon: MousePointer2,
},
{
value: "compact",
title: "Всегда компактные",
description: "Меню и календарь остаются в виде узких панелей со значками.",
Icon: PanelLeftClose,
},
] as const;
function getSessionDeviceIcon(deviceName?: string | null) {
return getSessionDeviceKind(deviceName) === "mobile" ? Smartphone : Monitor;
}
@ -136,6 +154,7 @@ function SectionCard({
export default function SettingsPage() {
const { mobileNavPlacement, setMobileNavPlacement } = useMobileNavPlacement();
const [desktopCompactPanelBehavior, setDesktopCompactPanelBehavior] = useDesktopCompactPanelBehavior();
const { canInstall, install, isInstalled, isRegistrationReady } = usePwa();
const {
activeVerbCount,
@ -416,6 +435,48 @@ export default function SettingsPage() {
})}
</div>
</div>
<div className="app-surface-muted mt-4 rounded-2xl p-4">
<div className="mb-4">
<p className="text-sm font-medium text-[var(--foreground)]">Боковые панели в расширенном режиме</p>
<p className="app-text-muted mt-1 text-sm">
Определяет поведение компактного меню и календаря на этом устройстве.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
{desktopCompactPanelCards.map(({ value, title, description, Icon }) => {
const active = desktopCompactPanelBehavior === value;
return (
<button
key={value}
type="button"
onClick={() => setDesktopCompactPanelBehavior(value)}
className={`rounded-2xl p-4 text-left transition ${
active ? "app-selected" : "app-surface hover:bg-[var(--surface-tertiary)]"
}`}
>
<div className="mb-4 flex items-start justify-between gap-3">
<span
className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl ${
active ? "app-action-primary text-white" : "bg-[var(--surface-elevated)] text-[var(--muted-foreground)]"
}`}
>
<Icon size={20} />
</span>
<span className={`app-badge inline-flex h-6 min-w-6 justify-center px-2 text-xs font-semibold ${active ? "app-badge-accent" : ""}`}>
{active ? <Check size={14} /> : value}
</span>
</div>
<p className="text-sm font-semibold text-[var(--foreground)]">{title}</p>
<p className="app-text-muted mt-1 text-sm">{description}</p>
</button>
);
})}
</div>
</div>
</SectionCard>
<SectionCard

View File

@ -11,6 +11,7 @@ import { NotificationCenter, NotificationPanel } from "@/components/Notification
import { CalendarSidebar } from "@/components/calendar/CalendarSidebar";
import { PushOnboardingPrompt } from "@/components/PushOnboardingPrompt";
import { useCalendarModals } from "@/hooks/useCalendarModals";
import { useDesktopCompactPanelBehavior } from "@/hooks/useDesktopCompactPanelBehavior";
import { CalendarModals } from "@/components/layout/CalendarModals";
import { MobileLeftDrawer, MobileCalendarDrawer } from "@/components/layout/MobileDrawers";
import { usePwa } from "@/contexts/PwaContext";
@ -416,17 +417,38 @@ function RoutedLeftNavContent(props: Omit<LeftNavContentProps, "documentSection"
);
}
function LeftNav({ fixedDesktop = false, compact = false }: { fixedDesktop?: boolean; compact?: boolean }) {
function LeftNav({
fixedDesktop = false,
compact = false,
expandOnHover = true,
}: {
fixedDesktop?: boolean;
compact?: boolean;
expandOnHover?: boolean;
}) {
const [hoverExpanded, setHoverExpanded] = useState(false);
const expanded = compact && expandOnHover && hoverExpanded;
return (
<aside className={`hidden shrink-0 transition-[width] duration-200 lg:block ${compact ? "w-14" : "w-64"}`}>
<div
className={`space-y-4 lg:overflow-y-auto ${
className={`space-y-4 transition-[width] duration-200 lg:overflow-y-auto ${expanded ? "z-[35] lg:w-64" : ""} ${
fixedDesktop
? `lg:fixed lg:top-0 lg:bottom-0 lg:pt-[4.5rem] lg:pb-8 ${compact ? "lg:w-14" : "lg:w-64"}`
? `lg:fixed lg:top-0 lg:bottom-0 lg:pt-[4.5rem] lg:pb-8 ${compact ? `lg:left-3 ${expanded ? "lg:w-64" : "lg:w-14"}` : "lg:w-64"}`
: "lg:sticky lg:top-8 lg:max-h-[calc(100dvh-7.5rem)] lg:pb-2"
}`}
onMouseEnter={() => {
if (compact && expandOnHover) setHoverExpanded(true);
}}
onMouseLeave={() => setHoverExpanded(false)}
onFocus={() => {
if (compact && expandOnHover) setHoverExpanded(true);
}}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setHoverExpanded(false);
}}
>
<RoutedLeftNavContent compact={compact} />
<RoutedLeftNavContent compact={compact && !expanded} />
</div>
</aside>
);
@ -605,6 +627,8 @@ export function AppShell({
}: AppShellProps) {
const { user, loading, status: authStatus, error: authError, retryAuthentication } = useUser();
const { mobileNavPlacement } = useMobileNavPlacement();
const [desktopCompactPanelBehavior] = useDesktopCompactPanelBehavior();
const expandCompactPanelsOnHover = desktopCompactPanelBehavior === "hover";
const router = useRouter();
const pathname = usePathname();
const [isMobileLeftNavOpen, setIsMobileLeftNavOpen] = useState(false);
@ -708,7 +732,13 @@ export function AppShell({
/>
</div>
<div className={`mx-auto flex w-full flex-1 min-h-0 max-w-6xl ${desktopWideMode ? "lg:max-w-none" : ""} ${standaloneAppMode ? "gap-0 px-0 py-0" : desktopImmersiveMode ? "gap-0 px-0 py-0 lg:gap-0 lg:px-3 lg:py-3" : mobileFullscreen ? `gap-0 px-0 py-0 lg:py-4 ${desktopWideMode ? "lg:gap-3 lg:px-3" : "lg:gap-6 lg:px-8"}` : `gap-6 px-4 py-4 sm:px-8 lg:py-4 ${desktopWideMode ? "lg:gap-3 lg:px-3" : ""}`}`}>
{!desktopImmersiveMode && !standaloneAppMode ? <LeftNav fixedDesktop compact={desktopWideMode} /> : null}
{!desktopImmersiveMode && !standaloneAppMode ? (
<LeftNav
fixedDesktop
compact={desktopWideMode}
expandOnHover={expandCompactPanelsOnHover}
/>
) : null}
<main className={`flex-1 min-w-0 min-h-0 space-y-6 ${mobileFullscreen ? 'overflow-visible' : ''} ${desktopWideMode ? "lg:flex lg:flex-col lg:gap-4 lg:space-y-0 lg:overflow-hidden" : ""}`}>
{!mobileFullscreen && !standaloneAppMode && !suppressPushOnboardingPrompt ? <PushOnboardingPrompt /> : null}
{children}
@ -723,6 +753,7 @@ export function AppShell({
onCalendarChange={cal.handleCalendarChange}
fixedDesktop
compact={desktopWideMode}
expandOnHover={expandCompactPanelsOnHover}
onExpand={() => onDesktopWideModeChange?.(false)}
/> : null}
</div>

View File

@ -1,7 +1,7 @@
"use client";
import { CalendarDays } from "lucide-react";
import { memo } from "react";
import { memo, useState } from "react";
import { CalendarCard } from "@/components/calendar/CalendarCard";
import { type CalendarParticipantsTarget } from "@/lib/calendar/ui";
import type { CalendarEvent, CalendarEventDraft } from "@/services/calendarService";
@ -16,6 +16,7 @@ interface CalendarSidebarProps {
onCalendarChange: (calendarId: number | null) => void;
fixedDesktop?: boolean;
compact?: boolean;
expandOnHover?: boolean;
onExpand?: () => void;
}
@ -26,31 +27,46 @@ export const CalendarSidebar = memo(function CalendarSidebar(props: CalendarSide
const {
fixedDesktop = false,
compact = false,
expandOnHover = true,
onExpand,
...calendarProps
} = props;
const [hoverExpanded, setHoverExpanded] = useState(false);
const [calendarActivated, setCalendarActivated] = useState(false);
const expanded = expandOnHover && hoverExpanded;
if (compact) {
return (
<aside className="hidden w-14 shrink-0 transition-[width] duration-200 lg:block">
<div
className={`lg:w-14 ${
className={`z-[35] overflow-hidden transition-[width] duration-200 ${expanded ? "lg:w-72" : "lg:w-14"} ${
fixedDesktop
? "lg:fixed lg:top-0 lg:bottom-0 lg:pt-[4.5rem] lg:pb-8"
? "lg:fixed lg:bottom-0 lg:right-3 lg:top-0 lg:pt-[4.5rem] lg:pb-8"
: "lg:sticky lg:top-8 lg:max-h-[calc(100dvh-7.5rem)] lg:pb-2"
}`}
onMouseEnter={() => {
if (!expandOnHover) return;
setCalendarActivated(true);
setHoverExpanded(true);
}}
onMouseLeave={() => setHoverExpanded(false)}
>
<div className="app-surface flex justify-center rounded-2xl p-2">
<div className={`${expanded ? "hidden" : "app-surface flex"} justify-center rounded-2xl p-2`}>
<button
type="button"
onClick={onExpand}
className="app-icon-button flex h-10 w-10 items-center justify-center rounded-lg"
title="Развернуть календарь"
aria-label="Развернуть календарь"
title={expandOnHover ? "Наведите, чтобы открыть календарь; нажмите, чтобы закрепить" : "Развернуть календарь"}
aria-label={expandOnHover ? "Открыть календарь; нажатие закрепляет обычный режим" : "Развернуть календарь"}
>
<CalendarDays size={18} />
</button>
</div>
{calendarActivated ? (
<div className={expanded ? "block w-72" : "hidden w-72"}>
<CalendarCard {...calendarProps} />
</div>
) : null}
</div>
</aside>
);

View File

@ -0,0 +1,46 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
export type DesktopCompactPanelBehavior = "hover" | "compact";
const STORAGE_KEY = "desktop-compact-panel-behavior";
const CHANGE_EVENT = "desktop-compact-panel-behavior-change";
const DEFAULT_BEHAVIOR: DesktopCompactPanelBehavior = "hover";
function readBehavior(): DesktopCompactPanelBehavior {
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored === "compact" || stored === "hover" ? stored : DEFAULT_BEHAVIOR;
} catch {
return DEFAULT_BEHAVIOR;
}
}
export function useDesktopCompactPanelBehavior() {
const subscribe = useCallback((notify: () => void) => {
const handleStorage = (event: StorageEvent) => {
if (!event.key || event.key === STORAGE_KEY) notify();
};
window.addEventListener("storage", handleStorage);
window.addEventListener(CHANGE_EVENT, notify);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(CHANGE_EVENT, notify);
};
}, []);
const behavior = useSyncExternalStore(subscribe, readBehavior, () => DEFAULT_BEHAVIOR);
const setBehavior = useCallback((nextBehavior: DesktopCompactPanelBehavior) => {
try {
window.localStorage.setItem(STORAGE_KEY, nextBehavior);
} catch {
// Ignore unavailable browser storage.
}
window.dispatchEvent(new Event(CHANGE_EVENT));
}, []);
return [behavior, setBehavior] as const;
}