Skip to content

Commit 3a211f0

Browse files
Merge pull request #30 from TextureHQ/feat/unified-explore
feat(explore): unified explore with entity tabs + Map/List/Hybrid toggle
2 parents 443cfc6 + 9a175ba commit 3a211f0

12 files changed

+934
-159
lines changed

components/explorer/ExplorerContext.tsx

Lines changed: 105 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,17 @@ import type { FeatureCollection } from "geojson";
1717
// Types
1818
// ---------------------------------------------------------------------------
1919

20+
export type LayoutMode = "hybrid" | "list" | "map";
21+
export type EntityTab = "utilities" | "grid-operators" | "power-plants" | "programs" | "transmission-lines";
2022
export type ViewMode = "landing" | "list" | "detail";
21-
export type ListView = "utilities" | "grid-operators" | "programs";
2223
export type DetailView = "utility" | "iso" | "rto" | "ba" | "program";
23-
export type EntityView = ListView | DetailView;
24+
export type ListView = EntityTab;
25+
export type EntityView = EntityTab | DetailView;
2426

2527
export interface ExplorerState {
28+
layout: LayoutMode;
29+
tab: EntityTab;
2630
mode: ViewMode;
27-
view: EntityView | null;
2831
slug: string | null;
2932
// List filters (persisted in URL)
3033
q: string;
@@ -35,26 +38,35 @@ export interface ExplorerState {
3538
highlightGeoJSON: FeatureCollection | null;
3639
hoveredSlug: string | null;
3740
// Navigation history for back button
38-
previousView: { view: EntityView | null; slug: string | null } | null;
41+
previousView: { tab: EntityTab; slug: string | null } | null;
3942
}
4043

4144
type ExplorerAction =
42-
| { type: "NAVIGATE_LANDING" }
43-
| { type: "NAVIGATE_LIST"; view: ListView }
45+
| { type: "NAVIGATE_TAB"; tab: EntityTab }
4446
| { type: "NAVIGATE_DETAIL"; view: DetailView; slug: string }
47+
| { type: "SET_LAYOUT"; layout: LayoutMode }
4548
| { type: "SET_SEARCH"; q: string }
4649
| { type: "SET_SEGMENT"; segment: string }
4750
| { type: "SET_TYPE"; typeFilter: string }
4851
| { type: "SET_JURISDICTIONS"; jurisdictions: string[] }
4952
| { type: "SET_HIGHLIGHT"; geoJSON: FeatureCollection | null }
5053
| { type: "SET_HOVERED_SLUG"; slug: string | null }
51-
| { type: "SYNC_FROM_URL"; mode: ViewMode; view: EntityView | null; slug: string | null; q: string; segment: string; typeFilter: string; jurisdictions: string[] };
54+
| {
55+
type: "SYNC_FROM_URL";
56+
layout: LayoutMode;
57+
tab: EntityTab;
58+
slug: string | null;
59+
q: string;
60+
segment: string;
61+
typeFilter: string;
62+
jurisdictions: string[];
63+
};
5264

5365
interface ExplorerContextValue {
5466
state: ExplorerState;
55-
navigateToLanding: () => void;
56-
navigateToList: (view: ListView) => void;
67+
navigateToTab: (tab: EntityTab) => void;
5768
navigateToDetail: (view: DetailView, slug: string) => void;
69+
setLayout: (layout: LayoutMode) => void;
5870
setSearch: (q: string) => void;
5971
setSegment: (segment: string) => void;
6072
setTypeFilter: (type: string) => void;
@@ -69,8 +81,9 @@ interface ExplorerContextValue {
6981
// ---------------------------------------------------------------------------
7082

7183
const initialState: ExplorerState = {
84+
layout: "hybrid",
85+
tab: "utilities",
7286
mode: "list",
73-
view: "utilities",
7487
slug: null,
7588
q: "",
7689
segment: "all",
@@ -83,11 +96,11 @@ const initialState: ExplorerState = {
8396

8497
function reducer(state: ExplorerState, action: ExplorerAction): ExplorerState {
8598
switch (action.type) {
86-
case "NAVIGATE_LANDING":
99+
case "NAVIGATE_TAB":
87100
return {
88101
...state,
102+
tab: action.tab,
89103
mode: "list",
90-
view: "utilities",
91104
slug: null,
92105
q: "",
93106
segment: "all",
@@ -98,27 +111,18 @@ function reducer(state: ExplorerState, action: ExplorerAction): ExplorerState {
98111
previousView: null,
99112
};
100113

101-
case "NAVIGATE_LIST":
102-
return {
103-
...state,
104-
mode: "list",
105-
view: action.view,
106-
slug: null,
107-
highlightGeoJSON: null,
108-
hoveredSlug: null,
109-
previousView: { view: state.view, slug: state.slug },
110-
};
111-
112114
case "NAVIGATE_DETAIL":
113115
return {
114116
...state,
115117
mode: "detail",
116-
view: action.view,
117118
slug: action.slug,
118119
hoveredSlug: null,
119-
previousView: { view: state.view, slug: state.slug },
120+
previousView: { tab: state.tab, slug: state.slug },
120121
};
121122

123+
case "SET_LAYOUT":
124+
return { ...state, layout: action.layout };
125+
122126
case "SET_SEARCH":
123127
return { ...state, q: action.q };
124128

@@ -140,13 +144,14 @@ function reducer(state: ExplorerState, action: ExplorerAction): ExplorerState {
140144
case "SYNC_FROM_URL":
141145
return {
142146
...state,
143-
mode: action.mode,
144-
view: action.view,
147+
layout: action.layout,
148+
tab: action.tab,
145149
slug: action.slug,
146150
q: action.q,
147151
segment: action.segment,
148152
type: action.typeFilter,
149153
jurisdictions: action.jurisdictions,
154+
mode: action.slug ? "detail" : "list",
150155
};
151156

152157
default:
@@ -160,25 +165,35 @@ function reducer(state: ExplorerState, action: ExplorerAction): ExplorerState {
160165

161166
function stateToSearchParams(state: ExplorerState): string {
162167
const params = new URLSearchParams();
163-
if (state.view) params.set("view", state.view);
168+
params.set("tab", state.tab);
169+
if (state.layout !== "hybrid") params.set("layout", state.layout);
164170
if (state.slug) params.set("slug", state.slug);
165171
if (state.q) params.set("q", state.q);
166172
if (state.segment && state.segment !== "all") params.set("segment", state.segment);
167173
if (state.type && state.type !== "all") params.set("type", state.type);
168-
if (state.jurisdictions && state.jurisdictions.length > 0) params.set("jurisdictions", state.jurisdictions.join(","));
174+
if (state.jurisdictions && state.jurisdictions.length > 0)
175+
params.set("jurisdictions", state.jurisdictions.join(","));
169176
const str = params.toString();
170177
return str ? `/explore?${str}` : "/explore";
171178
}
172179

173-
function parseViewMode(view: string | null): { mode: ViewMode; view: EntityView | null } {
174-
if (!view) return { mode: "list", view: "utilities" };
175-
if (view === "utilities" || view === "grid-operators" || view === "programs") {
176-
return { mode: "list", view };
177-
}
178-
if (view === "utility" || view === "iso" || view === "rto" || view === "ba" || view === "program") {
179-
return { mode: "detail", view };
180-
}
181-
return { mode: "list", view: "utilities" };
180+
function parseTab(value: string | null): EntityTab {
181+
const valid: EntityTab[] = [
182+
"utilities",
183+
"grid-operators",
184+
"power-plants",
185+
"programs",
186+
"transmission-lines",
187+
];
188+
// backwards-compat: old "view" param values that were list views
189+
if (value === "grid-operators" || value === "programs") return value;
190+
if (valid.includes(value as EntityTab)) return value as EntityTab;
191+
return "utilities";
192+
}
193+
194+
function parseLayout(value: string | null): LayoutMode {
195+
if (value === "list" || value === "map" || value === "hybrid") return value;
196+
return "hybrid";
182197
}
183198

184199
// ---------------------------------------------------------------------------
@@ -205,20 +220,24 @@ export function ExplorerProvider({ children }: { children: ReactNode }) {
205220

206221
// Sync state FROM URL on mount and on popstate (browser back/forward)
207222
useEffect(() => {
208-
const viewParam = searchParams.get("view");
223+
// Support old ?view= param for backwards compat
224+
const tabParam = searchParams.get("tab") ?? searchParams.get("view");
209225
const slugParam = searchParams.get("slug");
226+
const layoutParam = searchParams.get("layout");
210227
const qParam = searchParams.get("q") ?? "";
211228
const segmentParam = searchParams.get("segment") ?? "all";
212229
const typeParam = searchParams.get("type") ?? "all";
213230
const jurisdictionsParam = searchParams.get("jurisdictions");
214-
const jurisdictionsFromUrl = jurisdictionsParam ? jurisdictionsParam.split(",").filter(Boolean) : [];
231+
const jurisdictionsFromUrl = jurisdictionsParam
232+
? jurisdictionsParam.split(",").filter(Boolean)
233+
: [];
215234

216-
const { mode, view } = parseViewMode(viewParam);
235+
const tab = parseTab(tabParam);
236+
const layout = parseLayout(layoutParam);
217237

218-
// Only sync if URL differs from current state
219238
if (
220-
mode !== state.mode ||
221-
view !== state.view ||
239+
layout !== state.layout ||
240+
tab !== state.tab ||
222241
slugParam !== state.slug ||
223242
qParam !== state.q ||
224243
segmentParam !== state.segment ||
@@ -228,8 +247,8 @@ export function ExplorerProvider({ children }: { children: ReactNode }) {
228247
isUrlSync.current = true;
229248
dispatch({
230249
type: "SYNC_FROM_URL",
231-
mode,
232-
view,
250+
layout,
251+
tab,
233252
slug: slugParam,
234253
q: qParam,
235254
segment: segmentParam,
@@ -249,18 +268,33 @@ export function ExplorerProvider({ children }: { children: ReactNode }) {
249268
const url = stateToSearchParams(state);
250269
router.push(url, { scroll: false });
251270
// eslint-disable-next-line react-hooks/exhaustive-deps
252-
}, [state.mode, state.view, state.slug, state.q, state.segment, state.type, state.jurisdictions]);
271+
}, [state.layout, state.tab, state.slug, state.q, state.segment, state.type, state.jurisdictions]);
253272

254-
const navigateToLanding = useCallback(() => dispatch({ type: "NAVIGATE_LANDING" }), []);
255-
const navigateToList = useCallback((view: ListView) => dispatch({ type: "NAVIGATE_LIST", view }), []);
273+
const navigateToTab = useCallback(
274+
(tab: EntityTab) => dispatch({ type: "NAVIGATE_TAB", tab }),
275+
[]
276+
);
256277
const navigateToDetail = useCallback(
257278
(view: DetailView, slug: string) => dispatch({ type: "NAVIGATE_DETAIL", view, slug }),
258279
[]
259280
);
281+
const setLayout = useCallback(
282+
(layout: LayoutMode) => dispatch({ type: "SET_LAYOUT", layout }),
283+
[]
284+
);
260285
const setSearch = useCallback((q: string) => dispatch({ type: "SET_SEARCH", q }), []);
261-
const setSegment = useCallback((segment: string) => dispatch({ type: "SET_SEGMENT", segment }), []);
262-
const setTypeFilter = useCallback((type: string) => dispatch({ type: "SET_TYPE", typeFilter: type }), []);
263-
const setJurisdictions = useCallback((jurisdictions: string[]) => dispatch({ type: "SET_JURISDICTIONS", jurisdictions }), []);
286+
const setSegment = useCallback(
287+
(segment: string) => dispatch({ type: "SET_SEGMENT", segment }),
288+
[]
289+
);
290+
const setTypeFilter = useCallback(
291+
(type: string) => dispatch({ type: "SET_TYPE", typeFilter: type }),
292+
[]
293+
);
294+
const setJurisdictions = useCallback(
295+
(jurisdictions: string[]) => dispatch({ type: "SET_JURISDICTIONS", jurisdictions }),
296+
[]
297+
);
264298
const setHighlight = useCallback(
265299
(geoJSON: FeatureCollection | null) => dispatch({ type: "SET_HIGHLIGHT", geoJSON }),
266300
[]
@@ -272,26 +306,19 @@ export function ExplorerProvider({ children }: { children: ReactNode }) {
272306

273307
const goBack = useCallback(() => {
274308
const prev = state.previousView;
275-
if (!prev || !prev.view) {
276-
dispatch({ type: "NAVIGATE_LANDING" });
309+
if (!prev) {
310+
dispatch({ type: "NAVIGATE_TAB", tab: state.tab });
277311
return;
278312
}
279-
// If previous was a list view, navigate back to list
280-
if (prev.view === "utilities" || prev.view === "grid-operators" || prev.view === "programs") {
281-
dispatch({ type: "NAVIGATE_LIST", view: prev.view });
282-
} else if (prev.slug) {
283-
dispatch({ type: "NAVIGATE_DETAIL", view: prev.view, slug: prev.slug });
284-
} else {
285-
dispatch({ type: "NAVIGATE_LANDING" });
286-
}
287-
}, [state.previousView]);
313+
dispatch({ type: "NAVIGATE_TAB", tab: prev.tab });
314+
}, [state.previousView, state.tab]);
288315

289316
const value = useMemo<ExplorerContextValue>(
290317
() => ({
291318
state,
292-
navigateToLanding,
293-
navigateToList,
319+
navigateToTab,
294320
navigateToDetail,
321+
setLayout,
295322
setSearch,
296323
setSegment,
297324
setTypeFilter,
@@ -300,7 +327,19 @@ export function ExplorerProvider({ children }: { children: ReactNode }) {
300327
setHoveredSlug,
301328
goBack,
302329
}),
303-
[state, navigateToLanding, navigateToList, navigateToDetail, setSearch, setSegment, setTypeFilter, setJurisdictions, setHighlight, setHoveredSlug, goBack]
330+
[
331+
state,
332+
navigateToTab,
333+
navigateToDetail,
334+
setLayout,
335+
setSearch,
336+
setSegment,
337+
setTypeFilter,
338+
setJurisdictions,
339+
setHighlight,
340+
setHoveredSlug,
341+
goBack,
342+
]
304343
);
305344

306345
return <ExplorerCtx.Provider value={value}>{children}</ExplorerCtx.Provider>;

0 commit comments

Comments
 (0)