feat(web): scenario list screen + routing (Meus cenários)
Add react-router-dom with two routes: "/" → MyScenariosPage (scenario grid with search, create, open, delete against the scenarios API) and "/editor" → AppShell. "Sair do cenário" returns to the list. New: use-scenarios hook, ScenarioCard, MyScenariosPage (+ tests, 235 web tests green). API: backfill legacy initialConditions rows missing instanceId so GET /scenarios serializes under the updated schema. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e28474b6b
commit
e2b50cde87
11 files changed
+301
-9
No files matched your search
@@ -6,9 +6,13 @@ import { eq } from 'drizzle-orm'
|
||||
import { ScenarioDTO, CreateScenarioDTO } from '@ymir/types'
|
||||
|
||||
function rowToScenario(row: typeof scenarios.$inferSelect): ScenarioDTO {
|
||||
// Legacy rows persisted before InitialCondition gained `instanceId` stored the
|
||||
// body id under `vesselId`. Backfill it so the response satisfies the schema.
|
||||
const initialConditions = (JSON.parse(row.initialConditions) as Array<Record<string, number>>)
|
||||
.map((ic) => ({ instanceId: ic.instanceId ?? ic.vesselId, ...ic }))
|
||||
return {
|
||||
...row,
|
||||
initialConditions: JSON.parse(row.initialConditions),
|
||||
initialConditions,
|
||||
description: row.description ?? undefined,
|
||||
areaId: row.areaId ?? undefined,
|
||||
createdAt: row.createdAt,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { MyScenariosPage } from './features/scenarios/MyScenariosPage'
|
||||
import { AppShell } from './features/shell/AppShell'
|
||||
|
||||
export default function App() {
|
||||
return <AppShell />
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<MyScenariosPage />} />
|
||||
<Route path="/editor" element={<AppShell />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { MyScenariosPage } from './MyScenariosPage'
|
||||
|
||||
const navigate = vi.fn()
|
||||
vi.mock('react-router-dom', async (orig) => ({
|
||||
...(await orig<typeof import('react-router-dom')>()),
|
||||
useNavigate: () => navigate,
|
||||
}))
|
||||
|
||||
const scenarios = [
|
||||
{ id: 1, name: 'Aproximação Santos', description: 'Manobra de atracação', duration: 3600, dt: 0.05, initialConditions: [], createdAt: '2026-01-02T00:00:00Z' },
|
||||
{ id: 2, name: 'Saída Guanabara', duration: 3600, dt: 0.05, initialConditions: [], createdAt: '2026-01-03T00:00:00Z' },
|
||||
]
|
||||
|
||||
function renderPage() {
|
||||
return render(<MemoryRouter><MyScenariosPage /></MemoryRouter>)
|
||||
}
|
||||
|
||||
describe('MyScenariosPage', () => {
|
||||
beforeEach(() => {
|
||||
navigate.mockReset()
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(scenarios) }))
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('lists scenarios from the API', async () => {
|
||||
renderPage()
|
||||
expect(await screen.findByText('Aproximação Santos')).toBeInTheDocument()
|
||||
expect(screen.getByText('Saída Guanabara')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('scenario-card')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('filters by search query', async () => {
|
||||
renderPage()
|
||||
await screen.findByText('Aproximação Santos')
|
||||
fireEvent.change(screen.getByLabelText(/Buscar cenários/i), { target: { value: 'santos' } })
|
||||
expect(screen.getByText('Aproximação Santos')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Saída Guanabara')).toBeNull()
|
||||
})
|
||||
|
||||
it('navigates to the editor on "Novo cenário"', async () => {
|
||||
renderPage()
|
||||
await screen.findByText('Aproximação Santos')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Novo cenário/i }))
|
||||
expect(navigate).toHaveBeenCalledWith('/editor')
|
||||
})
|
||||
|
||||
it('opening a card navigates to the editor with the scenario id', async () => {
|
||||
renderPage()
|
||||
await screen.findByText('Aproximação Santos')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Abrir cenário Aproximação Santos/i }))
|
||||
expect(navigate).toHaveBeenCalledWith('/editor?scenario=1')
|
||||
})
|
||||
|
||||
it('deleting a card calls DELETE and removes it', async () => {
|
||||
renderPage()
|
||||
await screen.findByText('Aproximação Santos')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Excluir cenário Aproximação Santos/i }))
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/scenarios/1'),
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
))
|
||||
await waitFor(() => expect(screen.queryByText('Aproximação Santos')).toBeNull())
|
||||
})
|
||||
|
||||
it('shows the empty state when there are no scenarios', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve([]) }))
|
||||
renderPage()
|
||||
expect(await screen.findByText(/Nenhum cenário criado ainda/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import type { ScenarioDTO } from '@ymir/types'
|
||||
import { useScenarios } from './use-scenarios'
|
||||
import { ScenarioCard } from './ScenarioCard'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { tokens } from '../../theme/tokens'
|
||||
|
||||
/** Landing screen: lists saved scenarios with search + create (Figma "Meus cenários"). */
|
||||
export function MyScenariosPage() {
|
||||
const { scenarios, loading, error, remove } = useScenarios()
|
||||
const [query, setQuery] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const filtered = useMemo(
|
||||
() => scenarios.filter(s => s.name.toLowerCase().includes(query.toLowerCase())),
|
||||
[scenarios, query],
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||
<aside style={{ width: tokens.size.sidebar, minWidth: tokens.size.sidebar, background: tokens.color.surface, borderRight: `1px solid ${tokens.color.border}`, padding: tokens.space.lg, display: 'flex', flexDirection: 'column', gap: tokens.space.md }}>
|
||||
<div style={{ fontSize: tokens.fontSize.sm, fontWeight: tokens.fontWeight.bold, letterSpacing: '0.08em', color: tokens.color.textSubtle }}>INSTRUCTOR</div>
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: tokens.space.xs }}>
|
||||
<span style={{ padding: `${tokens.space.sm}px ${tokens.space.md}px`, borderRadius: tokens.radius.md, background: tokens.color.surfaceActive, fontWeight: tokens.fontWeight.medium, fontSize: tokens.fontSize.body }}>Meus cenários</span>
|
||||
<span style={{ padding: `${tokens.space.sm}px ${tokens.space.md}px`, color: tokens.color.textSubtle, fontSize: tokens.fontSize.body }}>Configurações</span>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main style={{ flex: 1, overflowY: 'auto', padding: tokens.space.xxl, background: tokens.color.surfaceAlt }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: tokens.space.lg }}>
|
||||
<h1 style={{ margin: 0, fontSize: tokens.fontSize.panelTitle, fontWeight: tokens.fontWeight.bold }}>Meus cenários</h1>
|
||||
<Button variant="primary" onClick={() => navigate('/editor')}>+ Novo cenário</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Buscar cenários"
|
||||
aria-label="Buscar cenários"
|
||||
style={{ width: '100%', maxWidth: 360, height: tokens.size.inputHeight, padding: `0 ${tokens.space.md}px`, marginBottom: tokens.space.lg, border: `1px solid ${tokens.color.border}`, borderRadius: tokens.radius.button, fontSize: tokens.fontSize.body, fontFamily: tokens.font.sans }}
|
||||
/>
|
||||
|
||||
{loading && <p style={{ color: tokens.color.textSubtle }}>Carregando cenários...</p>}
|
||||
{error && <p style={{ color: tokens.color.danger }}>Erro ao carregar cenários.</p>}
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<p style={{ color: tokens.color.textHcSubtle }}>
|
||||
{scenarios.length === 0 ? 'Nenhum cenário criado ainda.' : 'Nenhum cenário encontrado.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: tokens.space.lg }}>
|
||||
{filtered.map(s => (
|
||||
<ScenarioCard
|
||||
key={s.id}
|
||||
scenario={s}
|
||||
onOpen={(sc: ScenarioDTO) => navigate(`/editor?scenario=${sc.id}`)}
|
||||
onDelete={(sc: ScenarioDTO) => void remove(sc.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ScenarioDTO } from '@ymir/types'
|
||||
import { tokens } from '../../theme/tokens'
|
||||
|
||||
export interface ScenarioCardProps {
|
||||
scenario: ScenarioDTO
|
||||
onOpen: (s: ScenarioDTO) => void
|
||||
onDelete: (s: ScenarioDTO) => void
|
||||
}
|
||||
|
||||
/** Grid card for a saved scenario (Figma "Meus cenários"). */
|
||||
export function ScenarioCard({ scenario, onOpen, onDelete }: ScenarioCardProps) {
|
||||
const date = new Date(scenario.createdAt)
|
||||
const dateLabel = isNaN(date.getTime()) ? '' : date.toLocaleDateString('pt-BR')
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="scenario-card"
|
||||
style={{
|
||||
background: tokens.color.surface,
|
||||
border: `1px solid ${tokens.color.border}`,
|
||||
borderRadius: tokens.radius.panel,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: tokens.shadow.sm,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(scenario)}
|
||||
aria-label={`Abrir cenário ${scenario.name}`}
|
||||
style={{
|
||||
border: 'none', padding: 0, cursor: 'pointer', textAlign: 'left',
|
||||
background: tokens.color.surfaceAlt, height: 120,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: tokens.color.textHcSubtle, fontSize: tokens.fontSize.title,
|
||||
}}
|
||||
>
|
||||
⚓
|
||||
</button>
|
||||
<div style={{ padding: tokens.space.md, display: 'flex', flexDirection: 'column', gap: tokens.space.xs }}>
|
||||
<div style={{ fontSize: tokens.fontSize.body, fontWeight: tokens.fontWeight.semibold, color: tokens.color.textPrimary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{scenario.name}
|
||||
</div>
|
||||
{scenario.description && (
|
||||
<div style={{ fontSize: tokens.fontSize.label, color: tokens.color.textSubtle, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{scenario.description}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: tokens.space.xs }}>
|
||||
<span className="mono" style={{ fontSize: tokens.fontSize.xs, color: tokens.color.textHcSubtle }}>{dateLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(scenario)}
|
||||
aria-label={`Excluir cenário ${scenario.name}`}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: tokens.color.textSubtle, fontSize: tokens.fontSize.body }}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { ScenarioDTO } from '@ymir/types'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'
|
||||
|
||||
export function useScenarios() {
|
||||
const [scenarios, setScenarios] = useState<ScenarioDTO[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
const refresh = useCallback(() => setTick(t => t + 1), [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetch(`${API_BASE}/scenarios`)
|
||||
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<ScenarioDTO[]> })
|
||||
.then(data => { if (!cancelled) { setScenarios(data); setLoading(false) } })
|
||||
.catch(err => { if (!cancelled) { setError(String(err)); setLoading(false) } })
|
||||
return () => { cancelled = true }
|
||||
}, [tick])
|
||||
|
||||
const remove = useCallback(async (id: number) => {
|
||||
await fetch(`${API_BASE}/scenarios/${id}`, { method: 'DELETE' })
|
||||
setScenarios(s => s.filter(x => x.id !== id))
|
||||
}, [])
|
||||
|
||||
return { scenarios, loading, error, refresh, remove }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { AppShell } from './AppShell'
|
||||
import { useScenarioStore } from '../scenario-creator/store'
|
||||
import { useSimulationStore } from '../../stores/simulationStore'
|
||||
@@ -31,7 +32,7 @@ describe('AppShell', () => {
|
||||
})
|
||||
|
||||
it('renders the top bar (breadcrumb + save) and floating controls', () => {
|
||||
render(<AppShell />)
|
||||
render(<MemoryRouter><AppShell /></MemoryRouter>)
|
||||
expect(screen.getByRole('button', { name: /Salvar cenário/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Aproximar/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Afastar/i })).toBeInTheDocument()
|
||||
@@ -39,7 +40,7 @@ describe('AppShell', () => {
|
||||
})
|
||||
|
||||
it('opens the no-ownship alert when saving with no vessels', () => {
|
||||
render(<AppShell />)
|
||||
render(<MemoryRouter><AppShell /></MemoryRouter>)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Salvar cenário/i }))
|
||||
expect(screen.getByText('Seu cenário não possui um ownship')).toBeInTheDocument()
|
||||
// sidebar hooks fetch /areas + /vessels on mount; what must NOT happen is a scenario save
|
||||
@@ -50,7 +51,7 @@ describe('AppShell', () => {
|
||||
})
|
||||
|
||||
it('"Salvar mesmo assim" confirms the save and closes the dialog', async () => {
|
||||
render(<AppShell />)
|
||||
render(<MemoryRouter><AppShell /></MemoryRouter>)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Salvar cenário/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Salvar mesmo assim/i }))
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
|
||||
@@ -63,7 +64,7 @@ describe('AppShell', () => {
|
||||
it('saves directly (no dialog) when a vessel exists', async () => {
|
||||
useScenarioStore.getState().setArea(mockArea)
|
||||
useScenarioStore.getState().addVessel({ vesselId: 1, name: 'VLCC' })
|
||||
render(<AppShell />)
|
||||
render(<MemoryRouter><AppShell /></MemoryRouter>)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Salvar cenário/i }))
|
||||
expect(screen.queryByText(/não possui um ownship/i)).toBeNull()
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalled())
|
||||
@@ -72,7 +73,7 @@ describe('AppShell', () => {
|
||||
it('Build triggers scenario save + simulation start', async () => {
|
||||
useScenarioStore.getState().setArea(mockArea)
|
||||
useScenarioStore.getState().addVessel({ vesselId: 1, name: 'VLCC' })
|
||||
render(<AppShell />)
|
||||
render(<MemoryRouter><AppShell /></MemoryRouter>)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Build simulation/i }))
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalled())
|
||||
expect(['loading', 'running']).toContain(useSimulationStore.getState().status)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { TopBar } from '../../ui/TopBar'
|
||||
import { IconButton } from '../../ui/IconButton'
|
||||
import { Button } from '../../ui/Button'
|
||||
@@ -32,6 +33,7 @@ export function AppShell() {
|
||||
const { status, start, stop, loadScenario } = useSimulationStore()
|
||||
const { zoomIn, zoomOut } = useMapStore()
|
||||
const [confirmNoOwnship, setConfirmNoOwnship] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function saveScenario() {
|
||||
try {
|
||||
@@ -84,7 +86,7 @@ export function AppShell() {
|
||||
|
||||
const right = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: tokens.space.sm }}>
|
||||
<Button variant="secondary" size="sm">Sair do cenário</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate('/')}>Sair do cenário</Button>
|
||||
<Button variant="dark" size="sm" icon="💾" onClick={handleSave}>Salvar cenário</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './theme/global.css'
|
||||
import { applyThemeVars } from './theme/tokens'
|
||||
@@ -9,6 +10,8 @@ applyThemeVars()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
Generated
+40
@@ -69,6 +69,9 @@ importers:
|
||||
react-leaflet:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react-router-dom:
|
||||
specifier: ^7.18.1
|
||||
version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
zustand:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)
|
||||
@@ -1311,6 +1314,10 @@ packages:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
cookie@1.1.1:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
css-tree@3.2.1:
|
||||
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||
@@ -1838,6 +1845,23 @@ packages:
|
||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
react-router-dom@7.18.1:
|
||||
resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
|
||||
react-router@7.18.1:
|
||||
resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
react@19.2.7:
|
||||
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3050,6 +3074,8 @@ snapshots:
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
css-tree@3.2.1:
|
||||
dependencies:
|
||||
mdn-data: 2.27.1
|
||||
@@ -3579,6 +3605,20 @@ snapshots:
|
||||
|
||||
react-refresh@0.17.0: {}
|
||||
|
||||
react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
react-router: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
|
||||
react-router@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
cookie: 1.1.1
|
||||
react: 19.2.7
|
||||
set-cookie-parser: 2.7.2
|
||||
optionalDependencies:
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
react@19.2.7: {}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
|
||||
Reference in new issue
Block a user