diff --git a/src/components/SideNav/SideNav.module.css b/src/components/SideNav/SideNav.module.css index 2647acf7..88f5f306 100644 --- a/src/components/SideNav/SideNav.module.css +++ b/src/components/SideNav/SideNav.module.css @@ -9,60 +9,21 @@ position: relative; } -.sideNav__actions { +.sideNavActions { align-items: stretch; } -.sideNav__title { +.sideNavTitle { margin: 10px 12px; - font-size: 11px; + font-size: .875rem; font-weight: bold; + color: var(--text-muted); flex: 1; } -.sideNav__body { +.sideNavItems { flex: 1 1 auto; + display: flex; + flex-direction: column; overflow: auto; } - -.item__link { - font-size: 1rem; - display: block; - color: inherit; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - cursor: pointer; - padding: 8px 12px; - text-decoration: none; - border-style: solid; - border-color: transparent; - border-width: 1px 0; - - &:hover { - background-color: var(--sidebar-item-active-bg); - color: var(--text); - } - - &:global(.isActive) { - z-index: 10; - border-style: solid; - border-color: var(--border-color); - border-width: 1px 0; - background-color: var(--sidebar-item-active-bg); - color: var(--main-color); - } -} - -.item__input { - font-size: 1rem; - display: block; - width: 100%; - background-color: var(--sidebar-item-active-bg); - color: var(--main-color); - padding: 8px 12px; - outline: none; - border-style: solid; - border-color: var(--border-color); - border-width: 1px 0; -} diff --git a/src/components/SideNav/SideNav.tsx b/src/components/SideNav/SideNav.tsx index 6fdab5c4..b06f8ca0 100644 --- a/src/components/SideNav/SideNav.tsx +++ b/src/components/SideNav/SideNav.tsx @@ -1,179 +1,22 @@ -import { Menu, MenuItem, PredefinedMenuItem } from '@tauri-apps/api/menu'; import type React from 'react'; -import { useCallback, useState } from 'react'; -import type { Playlist } from '../../generated/typings'; -import database from '../../lib/database'; -import { logAndNotifyError } from '../../lib/utils'; -import PlaylistsAPI from '../../stores/PlaylistsAPI'; - -import { useNavigate } from 'react-router-dom'; -import ButtonIcon from '../../elements/ButtonIcon/ButtonIcon'; import Flexbox from '../../elements/Flexbox/Flexbox'; -import useInvalidate from '../../hooks/useInvalidate'; -import SideNavLink from '../SideNavLink/SideNavLink'; import styles from './SideNav.module.css'; type Props = { + children: React.ReactNode; title: string; - playlists: Playlist[]; + actions: React.ReactNode; }; -// TODO: finish making this component playlist agnostic export default function SideNav(props: Props) { - const invalidate = useInvalidate(); - const navigate = useNavigate(); - - const [renamed, setRenamed] = useState(null); - - const showContextMenu = useCallback( - async (e: React.MouseEvent, playlistID: string) => { - e.preventDefault(); - - const menuItems = await Promise.all([ - MenuItem.new({ - text: 'Rename', - action: () => { - setRenamed(playlistID); - }, - }), - MenuItem.new({ - text: 'Delete', - action: async () => { - await PlaylistsAPI.remove(playlistID); - invalidate(); - }, - }), - PredefinedMenuItem.new({ item: 'Separator' }), - MenuItem.new({ - text: 'Duplicate', - action: async () => { - await PlaylistsAPI.duplicate(playlistID); - invalidate(); - }, - }), - PredefinedMenuItem.new({ item: 'Separator' }), - MenuItem.new({ - text: 'Export', - action: async () => { - await database.exportPlaylist(playlistID); - }, - }), - ]); - - const menu = await Menu.new({ - items: menuItems, - }); - - await menu.popup().catch(logAndNotifyError); - }, - [invalidate], - ); - - const createPlaylist = useCallback(async () => { - // TODO: 'new playlist 1', 'new playlist 2' ... - const playlist = await PlaylistsAPI.create('New playlist', [], false); - - if (playlist) { - invalidate(); - navigate(`/playlists/${playlist._id}`); - } - }, [navigate, invalidate]); - - const onRename = useCallback( - async (playlistID: string, name: string) => { - await PlaylistsAPI.rename(playlistID, name); - invalidate(); - }, - [invalidate], - ); - - const keyDown = useCallback( - async (e: React.KeyboardEvent) => { - e.persist(); - - switch (e.nativeEvent.code) { - case 'Enter': { - // Enter - if (renamed && e.currentTarget) { - await onRename(renamed, e.currentTarget.value); - setRenamed(null); - } - break; - } - case 'Escape': { - // Escape - setRenamed(null); - break; - } - default: { - break; - } - } - }, - [onRename, renamed], - ); - - const blur = useCallback( - async (e: React.FocusEvent) => { - if (renamed) { - await onRename(renamed, e.currentTarget.value); - } - - setRenamed(null); - }, - [onRename, renamed], - ); - - const focus = useCallback((e: React.FocusEvent) => { - e.currentTarget.select(); - }, []); - - const { playlists } = props; - - const nav = playlists.map((elem) => { - let navItemContent; - - if (elem._id === renamed) { - navItemContent = ( - ref?.focus()} - /> - ); - } else { - navItemContent = ( - - {elem.name} - - ); - } - - return
{navItemContent}
; - }); - return (
-

{props.title}

-
- -
+

{props.title}

+
{props.actions}
-
{nav}
+
{props.children}
); } diff --git a/src/components/SideNavLink/SideNavLink.module.css b/src/components/SideNavLink/SideNavLink.module.css index 5bfafc72..d1997b80 100644 --- a/src/components/SideNavLink/SideNavLink.module.css +++ b/src/components/SideNavLink/SideNavLink.module.css @@ -1,6 +1,46 @@ -.playlistLink { - &:hover, +.sideNavLink { + display: block; + width: 100%; + line-height: 1; + font-size: 1rem; + color: inherit; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + cursor: pointer; + padding: 8px 12px; + text-decoration: none; + border-style: solid; + border-color: transparent; + border-width: 1px 0; + + &:hover { + background-color: var(--sidebar-item-active-bg); + color: var(--text); + text-decoration: none; + } + &:focus { text-decoration: none; } + + &:global(.isActive) { + outline: none; + z-index: 10; + border-style: solid; + border-color: var(--border-color); + border-width: 1px 0; + background-color: var(--sidebar-item-active-bg); + color: var(--main-color); + } +} + +.sideNavLinkInput { + appearance: none; + display: block; + padding: 0; + border: none; + background: transparent; + font-size: 1rem; + line-height: 1; } diff --git a/src/components/SideNavLink/SideNavLink.tsx b/src/components/SideNavLink/SideNavLink.tsx index 45296893..65f4eeb1 100644 --- a/src/components/SideNavLink/SideNavLink.tsx +++ b/src/components/SideNavLink/SideNavLink.tsx @@ -1,29 +1,127 @@ -import type React from 'react'; import { NavLink } from 'react-router-dom'; -import PlaylistsAPI from '../../stores/PlaylistsAPI'; - +import { + Menu, + MenuItem, + type MenuItemOptions, + PredefinedMenuItem, + type PredefinedMenuItemOptions, +} from '@tauri-apps/api/menu'; +import { useCallback, useState } from 'react'; +import { logAndNotifyError } from '../../lib/utils'; import styles from './SideNavLink.module.css'; type Props = { - children: React.ReactNode; - className?: string; - playlistID: string; - onContextMenu: (e: React.MouseEvent, playlistID: string) => void; + label: string; + id: string; + href: string; + onRename?: (id: string, name: string) => void; + contextMenuItems?: Array; }; export default function SideNavLink(props: Props) { + const [renamed, setRenamed] = useState(false); + + const onContextMenu: React.MouseEventHandler = useCallback( + async (event) => { + if (!props.onRename && !props.contextMenuItems) { + return; + } + + event.preventDefault(); + + const contextMenuItems = props.contextMenuItems ?? []; + + const menuItemsBuilders = contextMenuItems.map((item) => { + if ('item' in item) { + return PredefinedMenuItem.new(item); + } + + return MenuItem.new(item); + }); + + const items = await Promise.all([ + MenuItem.new({ + text: 'Rename', + action: () => { + setRenamed(true); + }, + }), + ...menuItemsBuilders, + ]); + + const menu = await Menu.new({ + items, + }); + + await menu.popup().catch(logAndNotifyError); + }, + [props.onRename, props.contextMenuItems], + ); + + const keyDown: React.KeyboardEventHandler = useCallback( + async (e) => { + e.persist(); + + switch (e.nativeEvent.code) { + case 'Enter': { + // Enter + if (renamed && e.currentTarget && props.onRename) { + props.onRename(props.id, e.currentTarget.value); + setRenamed(false); + } + break; + } + case 'Escape': { + // Escape + setRenamed(false); + break; + } + default: { + break; + } + } + }, + [props.onRename, props.id, renamed], + ); + + const onBlur = useCallback( + async (e: React.FocusEvent) => { + if (renamed && props.onRename) { + props.onRename(props.id, e.currentTarget.value); + } + + setRenamed(false); + }, + [props.onRename, props.id, renamed], + ); + + const onFocus = useCallback((e: React.FocusEvent) => { + e.currentTarget.select(); + }, []); + return ( - `${props.className} ${styles.playlistLink} ${isActive && 'isActive'}` + `${styles.sideNavLink} ${isActive && 'isActive'}` } - to={`/playlists/${props.playlistID}`} - onContextMenu={(e) => props.onContextMenu(e, props.playlistID)} + to={props.href} + onContextMenu={onContextMenu} draggable={false} - onDoubleClick={() => PlaylistsAPI.play(props.playlistID)} > - {props.children} + {renamed ? ( + ref?.focus()} + /> + ) : ( + props.label + )} ); } diff --git a/src/views/ViewPlaylists.tsx b/src/views/ViewPlaylists.tsx index 664b89c5..04c362fa 100644 --- a/src/views/ViewPlaylists.tsx +++ b/src/views/ViewPlaylists.tsx @@ -1,16 +1,23 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { type LoaderFunctionArgs, Outlet, redirect, useLoaderData, + useNavigate, } from 'react-router-dom'; import SideNav from '../components/SideNav/SideNav'; +import ButtonIcon from '../elements/ButtonIcon/ButtonIcon'; import * as ViewMessage from '../elements/ViewMessage/ViewMessage'; import database from '../lib/database'; import PlaylistsAPI from '../stores/PlaylistsAPI'; +import type { + MenuItemOptions, + PredefinedMenuItemOptions, +} from '@tauri-apps/api/menu'; +import SideNavLink from '../components/SideNavLink/SideNavLink'; import useInvalidate from '../hooks/useInvalidate'; import type { LoaderData } from '../types/museeks'; import appStyles from './Root.module.css'; @@ -19,12 +26,69 @@ import styles from './ViewPlaylists.module.css'; export default function ViewPlaylists() { const { playlists } = useLoaderData() as PlaylistsLoaderData; const invalidate = useInvalidate(); + const navigate = useNavigate(); const createPlaylist = useCallback(async () => { - await PlaylistsAPI.create('New playlist', [], false); - invalidate(); - }, [invalidate]); + // TODO: 'new playlist 1', 'new playlist 2' ... + const playlist = await PlaylistsAPI.create('New playlist', [], false); + if (playlist) { + invalidate(); + navigate(`/playlists/${playlist._id}`); + } + }, [navigate, invalidate]); + + const renamePlaylist = useCallback( + async (playlistID: string, name: string) => { + await PlaylistsAPI.rename(playlistID, name); + invalidate(); + }, + [invalidate], + ); + + const sideNavItems = useMemo(() => { + return playlists.map((playlist) => { + const contextMenuItems: Array< + MenuItemOptions | PredefinedMenuItemOptions + > = [ + { + text: 'Delete', + action: async () => { + await PlaylistsAPI.remove(playlist._id); + invalidate(); + }, + }, + { item: 'Separator' }, + { + text: 'Duplicate', + action: async () => { + await PlaylistsAPI.duplicate(playlist._id); + invalidate(); + }, + }, + { item: 'Separator' }, + { + text: 'Export', + action: async () => { + await database.exportPlaylist(playlist._id); + }, + }, + ]; + + return ( + + ); + }); + }, [playlists, renamePlaylist, invalidate]); + + // Empty and List states let playlistContent; if (playlists.length === 0) { @@ -49,7 +113,18 @@ export default function ViewPlaylists() { return (
- + + } + > + {sideNavItems} +
{playlistContent}
);