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 { VisitProvider } from '../features/visits/context/VisitProvider';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { PrimaryLayout } from '../components/layout/PrimaryLayout';
|
||||
import { VisitSidebar } from '../components/layout/VisitSidebar';
|
||||
import { TravelMap } from '../features/map/TravelMap';
|
||||
import { VisitModal } from '../components/overlay/VisitModal';
|
||||
import { useVisitModalStore } from '../state/useVisitModalStore';
|
||||
|
||||
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() {
|
||||
|
@@ -1,22 +1,40 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
|
||||
interface PrimaryLayoutProps {
|
||||
sidebar: ReactNode;
|
||||
content: ReactNode;
|
||||
onSidebarToggle?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export function PrimaryLayout({ sidebar, content }: PrimaryLayoutProps) {
|
||||
export function PrimaryLayout({ sidebar, content, onSidebarToggle }: PrimaryLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
onSidebarToggle?.(isSidebarOpen);
|
||||
}, [isSidebarOpen, onSidebarToggle]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold">{t('common.appName')}</h1>
|
||||
<p className="text-sm text-slate-500">{t('common.tagline')}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
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 className="flex items-center gap-4">
|
||||
<nav className="flex items-center gap-3 text-sm text-slate-500">
|
||||
@@ -32,9 +50,17 @@ export function PrimaryLayout({ sidebar, content }: PrimaryLayoutProps) {
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 overflow-hidden">
|
||||
<section className="w-full max-w-lg border-r border-slate-200">
|
||||
{sidebar}
|
||||
</section>
|
||||
<aside
|
||||
id="visit-sidebar"
|
||||
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>
|
||||
</main>
|
||||
</div>
|
||||
|
@@ -1,11 +1,13 @@
|
||||
import { MapContainer, Marker, Popup, TileLayer, useMapEvents } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { LeafletMouseEvent } from 'leaflet';
|
||||
import { ensureLeafletConfig } from './leafletConfig';
|
||||
import { useVisitModalStore } from '../../state/useVisitModalStore';
|
||||
import { useVisitsQuery } from '../visits/hooks/useVisitQueries';
|
||||
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_ZOOM = 2;
|
||||
@@ -23,7 +25,12 @@ function MapEvents() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TravelMap() {
|
||||
interface TravelMapProps {
|
||||
onTriggerCreate?: (location?: { lat: number; lng: number }) => void;
|
||||
sidebarOpen?: boolean;
|
||||
}
|
||||
|
||||
export function TravelMap({ onTriggerCreate, sidebarOpen = true }: TravelMapProps) {
|
||||
useEffect(() => {
|
||||
ensureLeafletConfig();
|
||||
}, []);
|
||||
@@ -32,10 +39,21 @@ export function TravelMap() {
|
||||
|
||||
const openForCreate = useVisitModalStore((state) => state.openForCreate);
|
||||
const { t } = useTranslation();
|
||||
const mapRef = useRef<LeafletMap | null>(null);
|
||||
useResizeMap(mapRef, [sidebarOpen]);
|
||||
|
||||
return (
|
||||
<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
|
||||
attribution='\u00a9 <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
@@ -60,7 +78,7 @@ export function TravelMap() {
|
||||
type="button"
|
||||
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"
|
||||
onClick={() => openForCreate()}
|
||||
onClick={() => (onTriggerCreate ? onTriggerCreate() : openForCreate())}
|
||||
>
|
||||
+
|
||||
</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",
|
||||
"drawerTitleCreate": "Add Visit",
|
||||
"drawerTitleEdit": "Edit Visit",
|
||||
"close": "Close"
|
||||
"close": "Close",
|
||||
"toggleOpen": "Show sidebar",
|
||||
"toggleClose": "Hide sidebar"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Stats",
|
||||
|
@@ -18,7 +18,9 @@
|
||||
"add": "新增足跡",
|
||||
"drawerTitleCreate": "新增足跡",
|
||||
"drawerTitleEdit": "編輯足跡",
|
||||
"close": "關閉"
|
||||
"close": "關閉",
|
||||
"toggleOpen": "展開側邊欄",
|
||||
"toggleClose": "收合側邊欄"
|
||||
},
|
||||
"summary": {
|
||||
"title": "統計",
|
||||
|
Reference in New Issue
Block a user