feat: adjust layout toggle and map resize handling
This commit is contained in:
@@ -1,12 +1,33 @@
|
|||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
import { VisitProvider } from '../features/visits/context/VisitProvider';
|
import { VisitProvider } from '../features/visits/context/VisitProvider';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
import { PrimaryLayout } from '../components/layout/PrimaryLayout';
|
import { PrimaryLayout } from '../components/layout/PrimaryLayout';
|
||||||
import { VisitSidebar } from '../components/layout/VisitSidebar';
|
import { VisitSidebar } from '../components/layout/VisitSidebar';
|
||||||
import { TravelMap } from '../features/map/TravelMap';
|
import { TravelMap } from '../features/map/TravelMap';
|
||||||
import { VisitModal } from '../components/overlay/VisitModal';
|
import { VisitModal } from '../components/overlay/VisitModal';
|
||||||
|
import { useVisitModalStore } from '../state/useVisitModalStore';
|
||||||
|
|
||||||
function HomePage() {
|
function HomePage() {
|
||||||
return <PrimaryLayout sidebar={<VisitSidebar />} content={<TravelMap />} />;
|
const openForCreate = useVisitModalStore((state) => state.openForCreate);
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||||
|
|
||||||
|
const handleSidebarToggle = useCallback(
|
||||||
|
(isOpen: boolean) => {
|
||||||
|
setIsSidebarOpen(isOpen);
|
||||||
|
if (!isOpen) {
|
||||||
|
useVisitModalStore.getState().close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrimaryLayout
|
||||||
|
sidebar={<VisitSidebar />}
|
||||||
|
content={<TravelMap onTriggerCreate={openForCreate} sidebarOpen={isSidebarOpen} />}
|
||||||
|
onSidebarToggle={handleSidebarToggle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
|
@@ -1,22 +1,40 @@
|
|||||||
import type { ReactNode } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||||
|
|
||||||
interface PrimaryLayoutProps {
|
interface PrimaryLayoutProps {
|
||||||
sidebar: ReactNode;
|
sidebar: ReactNode;
|
||||||
content: ReactNode;
|
content: ReactNode;
|
||||||
|
onSidebarToggle?: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PrimaryLayout({ sidebar, content }: PrimaryLayoutProps) {
|
export function PrimaryLayout({ sidebar, content, onSidebarToggle }: PrimaryLayoutProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSidebarToggle?.(isSidebarOpen);
|
||||||
|
}, [isSidebarOpen, onSidebarToggle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
<header className="border-b border-slate-200 bg-white">
|
<header className="border-b border-slate-200 bg-white">
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
|
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold">{t('common.appName')}</h1>
|
<button
|
||||||
<p className="text-sm text-slate-500">{t('common.tagline')}</p>
|
type="button"
|
||||||
|
aria-label={t(isSidebarOpen ? 'sidebar.toggleClose' : 'sidebar.toggleOpen')}
|
||||||
|
aria-controls="visit-sidebar"
|
||||||
|
aria-expanded={isSidebarOpen}
|
||||||
|
className="rounded bg-slate-100 p-2 text-slate-600 transition hover:bg-slate-200 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
onClick={() => setIsSidebarOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<span className="block text-xl leading-none">{isSidebarOpen ? '⟨' : '☰'}</span>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">{t('common.appName')}</h1>
|
||||||
|
<p className="text-sm text-slate-500">{t('common.tagline')}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<nav className="flex items-center gap-3 text-sm text-slate-500">
|
<nav className="flex items-center gap-3 text-sm text-slate-500">
|
||||||
@@ -32,9 +50,17 @@ export function PrimaryLayout({ sidebar, content }: PrimaryLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex flex-1 overflow-hidden">
|
<main className="flex flex-1 overflow-hidden">
|
||||||
<section className="w-full max-w-lg border-r border-slate-200">
|
<aside
|
||||||
{sidebar}
|
id="visit-sidebar"
|
||||||
</section>
|
aria-hidden={!isSidebarOpen}
|
||||||
|
className={`flex-shrink-0 min-w-0 overflow-hidden transition-[flex-basis,max-width] duration-300 ease-in-out ${
|
||||||
|
isSidebarOpen
|
||||||
|
? 'basis-[25%] max-w-[25%] border-r border-slate-200'
|
||||||
|
: 'pointer-events-none basis-0 max-w-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSidebarOpen ? <div className="h-full">{sidebar}</div> : null}
|
||||||
|
</aside>
|
||||||
<section className="flex-1">{content}</section>
|
<section className="flex-1">{content}</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,11 +1,13 @@
|
|||||||
import { MapContainer, Marker, Popup, TileLayer, useMapEvents } from 'react-leaflet';
|
import { MapContainer, Marker, Popup, TileLayer, useMapEvents } from 'react-leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import type { LeafletMouseEvent } from 'leaflet';
|
import type { LeafletMouseEvent } from 'leaflet';
|
||||||
import { ensureLeafletConfig } from './leafletConfig';
|
import { ensureLeafletConfig } from './leafletConfig';
|
||||||
import { useVisitModalStore } from '../../state/useVisitModalStore';
|
import { useVisitModalStore } from '../../state/useVisitModalStore';
|
||||||
import { useVisitsQuery } from '../visits/hooks/useVisitQueries';
|
import { useVisitsQuery } from '../visits/hooks/useVisitQueries';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useResizeMap } from '../visits/hooks/useResizeMap';
|
||||||
|
import type { Map as LeafletMap } from 'leaflet';
|
||||||
|
|
||||||
const INITIAL_POSITION: [number, number] = [20, 0];
|
const INITIAL_POSITION: [number, number] = [20, 0];
|
||||||
const INITIAL_ZOOM = 2;
|
const INITIAL_ZOOM = 2;
|
||||||
@@ -23,7 +25,12 @@ function MapEvents() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TravelMap() {
|
interface TravelMapProps {
|
||||||
|
onTriggerCreate?: (location?: { lat: number; lng: number }) => void;
|
||||||
|
sidebarOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TravelMap({ onTriggerCreate, sidebarOpen = true }: TravelMapProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ensureLeafletConfig();
|
ensureLeafletConfig();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -32,10 +39,21 @@ export function TravelMap() {
|
|||||||
|
|
||||||
const openForCreate = useVisitModalStore((state) => state.openForCreate);
|
const openForCreate = useVisitModalStore((state) => state.openForCreate);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const mapRef = useRef<LeafletMap | null>(null);
|
||||||
|
useResizeMap(mapRef, [sidebarOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<MapContainer className="h-full w-full z-0" center={INITIAL_POSITION} zoom={INITIAL_ZOOM} scrollWheelZoom>
|
<MapContainer
|
||||||
|
whenCreated={(mapInstance) => {
|
||||||
|
mapRef.current = mapInstance;
|
||||||
|
mapInstance.invalidateSize();
|
||||||
|
}}
|
||||||
|
className="h-full w-full z-0"
|
||||||
|
center={INITIAL_POSITION}
|
||||||
|
zoom={INITIAL_ZOOM}
|
||||||
|
scrollWheelZoom
|
||||||
|
>
|
||||||
<TileLayer
|
<TileLayer
|
||||||
attribution='\u00a9 <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
attribution='\u00a9 <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
@@ -60,7 +78,7 @@ export function TravelMap() {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={t('sidebar.add')}
|
aria-label={t('sidebar.add')}
|
||||||
className="absolute bottom-5 right-5 z-10 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-2xl font-bold text-white shadow-lg transition hover:bg-primary/90"
|
className="absolute bottom-5 right-5 z-10 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-2xl font-bold text-white shadow-lg transition hover:bg-primary/90"
|
||||||
onClick={() => openForCreate()}
|
onClick={() => (onTriggerCreate ? onTriggerCreate() : openForCreate())}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
|
13
client/src/features/visits/hooks/useResizeMap.ts
Normal file
13
client/src/features/visits/hooks/useResizeMap.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useEffect, type DependencyList } from 'react';
|
||||||
|
import type { Map as LeafletMap } from 'leaflet';
|
||||||
|
import { useVisitModalStore } from '../../../state/useVisitModalStore';
|
||||||
|
|
||||||
|
export function useResizeMap(mapRef: React.RefObject<LeafletMap | null>, deps: DependencyList = []) {
|
||||||
|
const isModalOpen = useVisitModalStore((state) => state.isOpen);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
setTimeout(() => map.invalidateSize(), 0);
|
||||||
|
}, [mapRef, isModalOpen, ...deps]);
|
||||||
|
}
|
@@ -18,7 +18,9 @@
|
|||||||
"add": "Add Visit",
|
"add": "Add Visit",
|
||||||
"drawerTitleCreate": "Add Visit",
|
"drawerTitleCreate": "Add Visit",
|
||||||
"drawerTitleEdit": "Edit Visit",
|
"drawerTitleEdit": "Edit Visit",
|
||||||
"close": "Close"
|
"close": "Close",
|
||||||
|
"toggleOpen": "Show sidebar",
|
||||||
|
"toggleClose": "Hide sidebar"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"title": "Stats",
|
"title": "Stats",
|
||||||
|
@@ -18,7 +18,9 @@
|
|||||||
"add": "新增足跡",
|
"add": "新增足跡",
|
||||||
"drawerTitleCreate": "新增足跡",
|
"drawerTitleCreate": "新增足跡",
|
||||||
"drawerTitleEdit": "編輯足跡",
|
"drawerTitleEdit": "編輯足跡",
|
||||||
"close": "關閉"
|
"close": "關閉",
|
||||||
|
"toggleOpen": "展開側邊欄",
|
||||||
|
"toggleClose": "收合側邊欄"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"title": "統計",
|
"title": "統計",
|
||||||
|
Reference in New Issue
Block a user