Skip to content

Commit ad02e46

Browse files
feat: Add dark mode toggle to Feast UI (feast-dev#5314)
1 parent 308255d commit ad02e46

File tree

7 files changed

+173
-12
lines changed

7 files changed

+173
-12
lines changed

ui/src/App.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,8 @@ html {
22
background: url("assets/feast-icon-white.svg") no-repeat bottom left;
33
background-size: 20vh;
44
}
5+
6+
body.euiTheme--dark html {
7+
background: url("assets/feast-icon-white.svg") no-repeat bottom left;
8+
background-size: 20vh;
9+
}

ui/src/FeastUISansProviders.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import "./index.css";
55

66
import { Routes, Route } from "react-router-dom";
77
import { EuiProvider, EuiErrorBoundary } from "@elastic/eui";
8+
import { ThemeProvider, useTheme } from "./contexts/ThemeContext";
89

910
import ProjectOverviewPage from "./pages/ProjectOverviewPage";
1011
import Layout from "./pages/Layout";
@@ -70,7 +71,29 @@ const FeastUISansProviders = ({
7071
};
7172

7273
return (
73-
<EuiProvider colorMode="light">
74+
<ThemeProvider>
75+
<FeastUISansProvidersInner
76+
basename={basename}
77+
projectListContext={projectListContext}
78+
feastUIConfigs={feastUIConfigs}
79+
/>
80+
</ThemeProvider>
81+
);
82+
};
83+
84+
const FeastUISansProvidersInner = ({
85+
basename,
86+
projectListContext,
87+
feastUIConfigs,
88+
}: {
89+
basename: string;
90+
projectListContext: ProjectsListContextInterface;
91+
feastUIConfigs?: FeastUIConfigs;
92+
}) => {
93+
const { colorMode } = useTheme();
94+
95+
return (
96+
<EuiProvider colorMode={colorMode}>
7497
<EuiErrorBoundary>
7598
<TabsRegistryContext.Provider
7699
value={feastUIConfigs?.tabsRegistry || {}}

ui/src/components/RegistryVisualization.tsx

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { EuiPanel, EuiTitle, EuiSpacer, EuiLoadingSpinner } from "@elastic/eui";
1919
import { FEAST_FCO_TYPES } from "../parsers/types";
2020
import { EntityRelation } from "../parsers/parseEntityRelationships";
2121
import { feast } from "../protos";
22+
import { useTheme } from "../contexts/ThemeContext";
2223

2324
const edgeAnimationStyle = `
2425
@keyframes dashdraw {
@@ -369,28 +370,44 @@ const getLayoutedElements = (
369370
};
370371
};
371372
const Legend = () => {
373+
const { colorMode } = useTheme();
372374
const types = [
373375
{ type: FEAST_FCO_TYPES.featureService, label: "Feature Service" },
374376
{ type: FEAST_FCO_TYPES.featureView, label: "Feature View" },
375377
{ type: FEAST_FCO_TYPES.entity, label: "Entity" },
376378
{ type: FEAST_FCO_TYPES.dataSource, label: "Data Source" },
377379
];
378380

381+
const isDarkMode = colorMode === "dark";
382+
const backgroundColor = isDarkMode ? "#1D1E24" : "white";
383+
const borderColor = isDarkMode ? "#343741" : "#ddd";
384+
const textColor = isDarkMode ? "#DFE5EF" : "#333";
385+
const boxShadow = isDarkMode
386+
? "0 2px 5px rgba(0,0,0,0.3)"
387+
: "0 2px 5px rgba(0,0,0,0.1)";
388+
379389
return (
380390
<div
381391
style={{
382392
position: "absolute",
383393
left: 10,
384394
top: 10,
385-
background: "white",
386-
border: "1px solid #ddd",
395+
background: backgroundColor,
396+
border: `1px solid ${borderColor}`,
387397
borderRadius: 5,
388398
padding: 10,
389399
zIndex: 10,
390-
boxShadow: "0 2px 5px rgba(0,0,0,0.1)",
400+
boxShadow: boxShadow,
391401
}}
392402
>
393-
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 5 }}>
403+
<div
404+
style={{
405+
fontSize: 14,
406+
fontWeight: 600,
407+
marginBottom: 5,
408+
color: textColor,
409+
}}
410+
>
394411
Legend
395412
</div>
396413
{types.map((item) => (
@@ -414,7 +431,7 @@ const Legend = () => {
414431
>
415432
{getNodeIcon(item.type)}
416433
</div>
417-
<div style={{ fontSize: 12 }}>{item.label}</div>
434+
<div style={{ fontSize: 12, color: textColor }}>{item.label}</div>
418435
</div>
419436
))}
420437
</div>
@@ -600,13 +617,45 @@ const RegistryVisualization: React.FC<RegistryVisualizationProps> = ({
600617

601618
// Filter relationships based on filterNode if provided
602619
if (filterNode) {
620+
const connectedNodes = new Set<string>();
621+
622+
const filterNodeId = `${getNodePrefix(filterNode.type)}-${filterNode.name}`;
623+
connectedNodes.add(filterNodeId);
624+
625+
// Function to recursively find all connected nodes
626+
const findConnectedNodes = (nodeId: string, isDownstream: boolean) => {
627+
relationshipsToShow.forEach((rel) => {
628+
const sourceId = `${getNodePrefix(rel.source.type)}-${rel.source.name}`;
629+
const targetId = `${getNodePrefix(rel.target.type)}-${rel.target.name}`;
630+
631+
if (
632+
isDownstream &&
633+
sourceId === nodeId &&
634+
!connectedNodes.has(targetId)
635+
) {
636+
connectedNodes.add(targetId);
637+
findConnectedNodes(targetId, isDownstream);
638+
}
639+
640+
if (
641+
!isDownstream &&
642+
targetId === nodeId &&
643+
!connectedNodes.has(sourceId)
644+
) {
645+
connectedNodes.add(sourceId);
646+
findConnectedNodes(sourceId, isDownstream);
647+
}
648+
});
649+
};
650+
651+
findConnectedNodes(filterNodeId, true);
652+
653+
findConnectedNodes(filterNodeId, false);
654+
603655
relationshipsToShow = relationshipsToShow.filter((rel) => {
604-
return (
605-
(rel.source.type === filterNode.type &&
606-
rel.source.name === filterNode.name) ||
607-
(rel.target.type === filterNode.type &&
608-
rel.target.name === filterNode.name)
609-
);
656+
const sourceId = `${getNodePrefix(rel.source.type)}-${rel.source.name}`;
657+
const targetId = `${getNodePrefix(rel.target.type)}-${rel.target.name}`;
658+
return connectedNodes.has(sourceId) && connectedNodes.has(targetId);
610659
});
611660
}
612661

ui/src/components/ThemeToggle.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from "react";
2+
import { EuiButtonIcon, EuiToolTip, useGeneratedHtmlId } from "@elastic/eui";
3+
import { useTheme } from "../contexts/ThemeContext";
4+
5+
const ThemeToggle: React.FC = () => {
6+
const { colorMode, toggleColorMode } = useTheme();
7+
const buttonId = useGeneratedHtmlId({ prefix: "themeToggle" });
8+
9+
return (
10+
<EuiToolTip
11+
position="right"
12+
content={`Switch to ${colorMode === "light" ? "dark" : "light"} theme`}
13+
>
14+
<EuiButtonIcon
15+
id={buttonId}
16+
onClick={toggleColorMode}
17+
iconType={colorMode === "light" ? "moon" : "sun"}
18+
aria-label={`Switch to ${colorMode === "light" ? "dark" : "light"} theme`}
19+
color="text"
20+
/>
21+
</EuiToolTip>
22+
);
23+
};
24+
25+
export default ThemeToggle;

ui/src/contexts/ThemeContext.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React, { createContext, useState, useContext, useEffect } from "react";
2+
3+
type ThemeMode = "light" | "dark";
4+
5+
interface ThemeContextType {
6+
colorMode: ThemeMode;
7+
setColorMode: (mode: ThemeMode) => void;
8+
toggleColorMode: () => void;
9+
}
10+
11+
const ThemeContext = createContext<ThemeContextType>({
12+
colorMode: "light",
13+
setColorMode: () => {},
14+
toggleColorMode: () => {},
15+
});
16+
17+
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({
18+
children,
19+
}) => {
20+
const [colorMode, setColorMode] = useState<ThemeMode>(() => {
21+
const savedTheme = localStorage.getItem("feast-theme");
22+
return (savedTheme === "dark" ? "dark" : "light") as ThemeMode;
23+
});
24+
25+
useEffect(() => {
26+
localStorage.setItem("feast-theme", colorMode);
27+
28+
if (colorMode === "dark") {
29+
document.body.classList.add("euiTheme--dark");
30+
} else {
31+
document.body.classList.remove("euiTheme--dark");
32+
}
33+
}, [colorMode]);
34+
35+
const toggleColorMode = () => {
36+
setColorMode((prevMode) => (prevMode === "light" ? "dark" : "light"));
37+
};
38+
39+
return (
40+
<ThemeContext.Provider value={{ colorMode, setColorMode, toggleColorMode }}>
41+
{children}
42+
</ThemeContext.Provider>
43+
);
44+
};
45+
46+
export const useTheme = () => useContext(ThemeContext);
47+
48+
export default ThemeContext;

ui/src/index.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ html {
1010
background-attachment: fixed;
1111
}
1212

13+
/* Add dark mode specific styles */
14+
body.euiTheme--dark html {
15+
background: url("assets/feast-icon-grey.svg") no-repeat -6vh 56vh;
16+
background-size: 50vh;
17+
filter: brightness(0.7); /* Darken the background image for dark mode */
18+
}
19+
1320
body {
1421
margin: 0;
1522
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",

ui/src/pages/Layout.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useLoadProjectsList } from "../contexts/ProjectListContext";
1717
import ProjectSelector from "../components/ProjectSelector";
1818
import Sidebar from "./Sidebar";
1919
import FeastWordMark from "../graphics/FeastWordMark";
20+
import ThemeToggle from "../components/ThemeToggle";
2021

2122
const Layout = () => {
2223
// Registry Path Context has to be inside Layout
@@ -48,6 +49,9 @@ const Layout = () => {
4849
<React.Fragment>
4950
<EuiHorizontalRule margin="s" />
5051
<Sidebar />
52+
<EuiSpacer size="l" />
53+
<EuiHorizontalRule margin="s" />
54+
<ThemeToggle />
5155
</React.Fragment>
5256
)}
5357
</EuiPageSidebar>

0 commit comments

Comments
 (0)