-
{
- ev.target.src = NoImage
- }}
- />
+ <>
+
{
+ ev.target.src = NoImage
+ this.onImageLoad()
+ }}
+ onLoad={this.onImageLoad}
+ />
+ {imageLoading &&
+
+
}
+ >
diff --git a/ui/src/components/RecentPodcasts/styles.scss b/ui/src/components/RecentPodcasts/styles.scss
index d9d3a562..37a8d143 100644
--- a/ui/src/components/RecentPodcasts/styles.scss
+++ b/ui/src/components/RecentPodcasts/styles.scss
@@ -2,10 +2,8 @@
--player-bg-color: #ffffff;
--player-border-color: #d6d6d6;
--player-cover-art-border-color: lighten(#d6d6d6, 0.2);
-}
-@media (prefers-color-scheme: dark) {
- :root {
+ &[data-theme="dark"] {
--player-bg-color: var(--bg-main);
--player-border-color: #302d2d;
--player-cover-art-border-color: darken(#302d2d, 0.2);
@@ -118,12 +116,28 @@
flex-direction: column;
align-items: center;
justify-content: center;
- // padding: 25px 25px 0 25px;
+
img {
width: 450px;
height: 450px;
+ object-fit: contain;
border-radius: 14px;
border: 1px solid var(--player-cover-art-border-color);
+
+ &.image-loading {
+ height: 0;
+ position: absolute;
+ }
+ }
+ .image-loading-placeholder {
+ width: 450px;
+ height: 450px;
+ display: grid;
+ align-items: center;
+
+ div {
+ justify-self: center;
+ }
}
z-index: 99999;
}
@@ -159,7 +173,10 @@
width: 275px;
height: 275px;
border-radius: 10px;
- border: 1px solid lighten(#d6d6d6, 0.2);
+ }
+ .image-loading-placeholder {
+ width: 275px;
+ height: 275px;
}
}
}
diff --git a/ui/src/components/ResultItem/styles.scss b/ui/src/components/ResultItem/styles.scss
index bd812075..dbaedd69 100644
--- a/ui/src/components/ResultItem/styles.scss
+++ b/ui/src/components/ResultItem/styles.scss
@@ -4,10 +4,8 @@
--category-bg-color: #f3f3f3;
--category-border-color: #cecece;
--cover-art-border-color: lighten(#d6d6d6, 0.2);
-}
-@media (prefers-color-scheme: dark) {
- :root {
+ &[data-theme="dark"] {
--bg-color: #180e0e;
--border-color: #505050;
--category-bg-color: #302d2d;
@@ -43,6 +41,7 @@
img {
height: 140px;
width: 140px;
+ object-fit: contain;
border-radius: 10px;
border: 1px solid var(--cover-art-border-color);
}
@@ -140,11 +139,8 @@
}
.result-category {
font-size: 14px;
- background: #f3f3f3;
- border: 1px solid #cecece;
border-radius: 5px;
margin: 3px;
-
padding: 4px 12px;
}
}
diff --git a/ui/src/components/SearchBar/styles.scss b/ui/src/components/SearchBar/styles.scss
index 9885549f..e095e7a5 100644
--- a/ui/src/components/SearchBar/styles.scss
+++ b/ui/src/components/SearchBar/styles.scss
@@ -2,10 +2,8 @@
--input-bg-color: #f7f7f7;
--input-border-color: #d6d6d6;
--input-placeholder-color: #b6b6b6;
-}
-@media (prefers-color-scheme: dark) {
- :root {
+ &[data-theme="dark"] {
--input-bg-color: #000000;
--input-border-color: #303030;
--input-placeholder-color: #969696;
diff --git a/ui/src/components/ThemeButton/index.tsx b/ui/src/components/ThemeButton/index.tsx
new file mode 100644
index 00000000..e6fda17c
--- /dev/null
+++ b/ui/src/components/ThemeButton/index.tsx
@@ -0,0 +1,140 @@
+/**
+ * Display button for toggling stream sync on/off
+ */
+import React, {ReactNode} from "react"
+import DarkImage from "../../../images/dark_mode.svg"
+import LightImage from "../../../images/light_mode.svg"
+
+import "./styles.scss"
+
+/**
+ * Theme options
+ */
+export enum Theme {
+ /**
+ * Use light theme
+ */
+ light = "light",
+ /**
+ * Use dark theme
+ */
+ dark = "dark",
+ /**
+ * Automatically detect theme
+ */
+ auto = "auto",
+}
+
+/**
+ * Arguments/properties of ThemeButton
+ */
+interface ThemeButtonProps {
+}
+
+/**
+ * States for ThemeButton
+ */
+interface ThemeButtonState {
+ /**
+ * Current theme
+ */
+ theme: Theme,
+}
+
+export default class ThemeButton extends React.PureComponent
{
+ STORAGE_KEY = "theme"
+ defaultState: ThemeButtonState = {
+ theme: Theme.auto,
+ }
+ state: ThemeButtonState = {
+ theme: Theme.auto,
+ }
+
+ constructor(props) {
+ super(props)
+
+ this.handleClick = this.handleClick.bind(this)
+ }
+
+ componentDidMount(): void {
+ let {theme} = this.state
+ const storageTheme = localStorage.getItem(this.STORAGE_KEY)
+ let auto = true
+
+ if (storageTheme) {
+ theme = storageTheme as Theme
+ }
+
+ // check for auto theme
+ if (theme === Theme.auto) {
+ // detect
+ if (window.matchMedia) {
+ theme = window.matchMedia('(prefers-color-scheme: light)').matches ? Theme.light : Theme.dark
+ } else {
+ // can't detect
+ theme = Theme.dark
+ auto = false
+ }
+ } else {
+ auto = false
+ }
+
+ this.setState(
+ {
+ theme: theme,
+ },
+ () => {
+ document.documentElement.setAttribute("data-theme", theme)
+ localStorage.setItem(this.STORAGE_KEY, auto ? Theme.auto : theme)
+ }
+ )
+ }
+
+ /**
+ * Handle sync button onClick event
+ */
+ private readonly handleClick = (): void => {
+ const {theme} = this.state
+ let newTheme = this.oppositeTheme(theme)
+ this.setState(
+ {
+ theme: newTheme,
+ },
+ () => {
+ document.documentElement.setAttribute("data-theme", newTheme)
+ localStorage.setItem(this.STORAGE_KEY, newTheme)
+ }
+ )
+ }
+
+ /**
+ * Get the opposite theme
+ *
+ * @param theme current theme
+ * @return Opposite theme
+ **/
+ private oppositeTheme = (theme: Theme): Theme => {
+ if (theme == Theme.auto)
+ return theme
+ return theme === Theme.dark ? Theme.light : Theme.dark
+ }
+
+ render = (): ReactNode => {
+ const {theme} = this.state
+
+ const lightMode = theme === Theme.light
+ const image = lightMode ? LightImage : DarkImage
+ const altText = lightMode ? "Switch to Dark Mode" : "Switch to Light Mode"
+ return (
+
+
+
+ )
+ }
+}
diff --git a/ui/src/components/ThemeButton/styles.scss b/ui/src/components/ThemeButton/styles.scss
new file mode 100644
index 00000000..09268dac
--- /dev/null
+++ b/ui/src/components/ThemeButton/styles.scss
@@ -0,0 +1,21 @@
+.theme-button {
+ width: 20px;
+ height: 20px;
+
+ position: fixed;
+ right: 15px;
+ bottom: 15px;
+
+ text-align: center;
+ z-index: 99999;
+
+ &:hover {
+ cursor: grab;
+ }
+
+ img {
+ width: 100%;
+ height: 100%;
+ background: inherit;
+ }
+}
diff --git a/ui/src/components/TopBar/index.tsx b/ui/src/components/TopBar/index.tsx
index 928eb3e0..e8c1740e 100644
--- a/ui/src/components/TopBar/index.tsx
+++ b/ui/src/components/TopBar/index.tsx
@@ -23,6 +23,7 @@ interface IState {
export default class TopBar extends React.PureComponent {
static defaultProps = {}
+ wrapperRef: React.Ref = React.createRef();
constructor(props: IProps) {
super(props)
@@ -34,11 +35,34 @@ export default class TopBar extends React.PureComponent {
this.onSearchChange = this.onSearchChange.bind(this)
this.onSearchSubmit = this.onSearchSubmit.bind(this)
+ this.handleClickOutside = this.handleClickOutside.bind(this);
+ }
+
+ componentDidMount() {
+ document.addEventListener("mousedown", this.handleClickOutside);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener("mousedown", this.handleClickOutside);
}
onSearchChange(evt: React.ChangeEvent) {
evt.preventDefault()
- this.setState({ search: evt.target.value })
+ this.setState({search: evt.target.value})
+ }
+
+ /**
+ * Alert if clicked on outside of element
+ */
+ handleClickOutside(event) {
+ // @ts-ignore
+ if (this.wrapperRef && !this.wrapperRef.current.contains(event.target)) {
+ setTimeout(() => {
+ this.setState({
+ dropdownOpen: false,
+ })
+ }, 100)
+ }
}
onSearchSubmit(evt: React.ChangeEvent) {
@@ -54,7 +78,7 @@ export default class TopBar extends React.PureComponent {
}
render() {
- const { search, dropdownOpen } = this.state
+ const {search, dropdownOpen} = this.state
return (