diff --git a/ui/images/dark_mode.svg b/ui/images/dark_mode.svg new file mode 100644 index 00000000..e83e1125 --- /dev/null +++ b/ui/images/dark_mode.svg @@ -0,0 +1 @@ + diff --git a/ui/images/light_mode.svg b/ui/images/light_mode.svg new file mode 100644 index 00000000..6eda6ed9 --- /dev/null +++ b/ui/images/light_mode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/images/transcript.svg b/ui/images/transcript.svg index 3b182309..81d2de23 100644 --- a/ui/images/transcript.svg +++ b/ui/images/transcript.svg @@ -21,7 +21,6 @@ - - + diff --git a/ui/src/components/Button/styles.scss b/ui/src/components/Button/styles.scss index ecfab374..3f8768ef 100644 --- a/ui/src/components/Button/styles.scss +++ b/ui/src/components/Button/styles.scss @@ -1,9 +1,9 @@ :root { + --button-background: #F0F0F0; --button-hover-color: #f2f2f2; -} -@media (prefers-color-scheme: dark) { - :root { + &[data-theme="dark"] { + --button-background: #1a1a1a; --button-hover-color: #302d2d; } } @@ -14,6 +14,7 @@ font-family: var(--font-family-exp); font-size: 16px; color: var(--fg-main); + background-color: var(--button-background); padding: 8px 20px; text-align: center; white-space: nowrap; diff --git a/ui/src/components/EpisodeItem/index.tsx b/ui/src/components/EpisodeItem/index.tsx index cd079295..cfa15f19 100644 --- a/ui/src/components/EpisodeItem/index.tsx +++ b/ui/src/components/EpisodeItem/index.tsx @@ -143,7 +143,7 @@ export default class EpisodeItem extends React.PureComponent { {episodeEnclosure ? a { + a { font-size: 16px; font-family: var(--font-family); color: var(--fg-main); @@ -77,13 +77,13 @@ } } } - @media (prefers-color-scheme: dark) { - .rhap_container { - background-color: var(--bg-main); - } - .rhap_time { - color: var(--fg-main); - } +} +:root[data-theme="dark"] { + .rhap_container { + background-color: var(--bg-main); + } + .rhap_time { + color: var(--fg-main); } } @@ -91,4 +91,7 @@ .player-show-title p { max-width: 350px; } + .player-podcast-name { + max-width: 350px; + } } diff --git a/ui/src/components/PodcastHeader/index.tsx b/ui/src/components/PodcastHeader/index.tsx index e8518b2d..2547458f 100644 --- a/ui/src/components/PodcastHeader/index.tsx +++ b/ui/src/components/PodcastHeader/index.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import Button from "../Button"; import Value from '../Value' import NoImage from '../../../images/no-cover-art.png' @@ -151,7 +152,7 @@ export default class PodcastHeader extends React.PureComponent : "" } {feedURL ? - + : "" } diff --git a/ui/src/components/PodcastHeader/styles.scss b/ui/src/components/PodcastHeader/styles.scss index 061b741e..d11b8897 100644 --- a/ui/src/components/PodcastHeader/styles.scss +++ b/ui/src/components/PodcastHeader/styles.scss @@ -2,10 +2,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"] { --category-bg-color: #302d2d; --category-border-color: #505050; --cover-art-border-color: var(--border-color); @@ -26,6 +24,7 @@ img { height: 300px; width: 300px; + object-fit: contain; border-radius: 10px; border: 1px solid var(--cover-art-border-color); } diff --git a/ui/src/components/RecentPodcasts/index.tsx b/ui/src/components/RecentPodcasts/index.tsx index 20b37848..b84255f7 100644 --- a/ui/src/components/RecentPodcasts/index.tsx +++ b/ui/src/components/RecentPodcasts/index.tsx @@ -7,7 +7,7 @@ import NoImage from '../../../images/no-cover-art.png' import ForwardIcon from '../../../images/chevron-forward-outline.svg' import 'react-h5-audio-player/src/styles.scss' import './styles.scss' -import {Link} from "react-router-dom"; +import { Link } from "react-router-dom"; interface IProps { title?: string @@ -17,16 +17,20 @@ interface IProps { interface IState { index: number + imageLoading: boolean } export default class RecentPodcasts extends React.Component { static defaultProps = {} state = { index: 0, + imageLoading: true } constructor(props: IProps) { super(props) + // fix this in handlers + this.onImageLoad = this.onImageLoad.bind(this) } selectPodcast(index: number, evt) { @@ -35,6 +39,20 @@ export default class RecentPodcasts extends React.Component { this.setState({index}) } + getImage(selectedPodcast) { + return selectedPodcast.image || selectedPodcast.feedImage || NoImage + } + + updateIndex(newIndex: number) { + const {index} = this.state + const {podcasts} = this.props + const imageLoading = this.getImage(podcasts[index]) !== this.getImage(podcasts[newIndex]) + this.setState({ + index: newIndex, + imageLoading: imageLoading, + }) + } + onBack() { const {index} = this.state const {podcasts} = this.props @@ -42,9 +60,7 @@ export default class RecentPodcasts extends React.Component { if (backIndex < 0) { backIndex = podcasts.length - 1 } - this.setState({ - index: backIndex, - }) + this.updateIndex(backIndex) } onForward() { @@ -54,15 +70,20 @@ export default class RecentPodcasts extends React.Component { if (nextIndex >= podcasts.length) { nextIndex = 0 } + this.updateIndex(nextIndex) + } + + onImageLoad() { this.setState({ - index: nextIndex, + imageLoading: false, }) } render() { const {loading, title, podcasts} = this.props - const {index} = this.state + const {index, imageLoading} = this.state const selectedPodcast = podcasts[index] + const imageLoadingClass = imageLoading ? "image-loading" : "" return (
{loading ? ( @@ -92,13 +113,21 @@ export default class RecentPodcasts extends React.Component { )}
- { - 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 ( +
+ {altText} +
+ ) + } +} 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 (