add launchpad and keyboard shortcuts

This commit is contained in:
hzrd149 2023-12-28 11:37:34 -06:00
parent 59821df735
commit fba092a59f
8 changed files with 312 additions and 23 deletions

View File

@ -79,6 +79,7 @@ import TransformNoteView from "./views/tools/transform-note";
import SatelliteCDNView from "./views/tools/satellite-cdn";
import OtherStuffView from "./views/other-stuff";
import { RouteProviders } from "./providers/route";
import LaunchpadView from "./views/launchpad";
const UserTracksTab = lazy(() => import("./views/user/tracks"));
const ToolsHomeView = lazy(() => import("./views/tools"));
@ -200,6 +201,14 @@ const router = createHashRouter([
path: "map",
element: <MapView />,
},
{
path: "launchpad",
element: (
<RouteProviders>
<LaunchpadView />
</RouteProviders>
),
},
{
path: "/",
element: <RootPage />,

View File

@ -1,6 +1,5 @@
import React, { useEffect } from "react";
import { Box, Container, Flex, Spacer, useDisclosure } from "@chakra-ui/react";
import { useKeyPressEvent } from "react-use";
import React from "react";
import { Box, Container, Flex, Spacer } from "@chakra-ui/react";
import { ErrorBoundary } from "../error-boundary";
import { ReloadPrompt } from "../reload-prompt";
@ -10,25 +9,10 @@ import useSubject from "../../hooks/use-subject";
import accountService from "../../services/account";
import GhostToolbar from "./ghost-toolbar";
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
import SearchModal from "../search-modal";
import { useLocation } from "react-router-dom";
export default function Layout({ children }: { children: React.ReactNode }) {
const isMobile = useBreakpointValue({ base: true, md: false });
const isGhost = useSubject(accountService.isGhost);
const searchModal = useDisclosure();
useKeyPressEvent("k", (e) => {
if (e.ctrlKey) {
e.preventDefault();
searchModal.onOpen();
}
});
const location = useLocation();
useEffect(() => {
searchModal.onClose();
}, [location.pathname]);
return (
<>
@ -64,7 +48,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<Spacer display={["none", null, "block"]} />
</Flex>
{isGhost && <GhostToolbar />}
{searchModal.isOpen && <SearchModal isOpen onClose={searchModal.onClose} />}
</>
);
}

View File

@ -1,4 +1,5 @@
import { Box, Button, ButtonProps, Link, Text, useDisclosure } from "@chakra-ui/react";
import { useRef } from "react";
import { Box, Button, ButtonProps, Code, Link, Text, useDisclosure } from "@chakra-ui/react";
import { Link as RouterLink, useLocation } from "react-router-dom";
import { nip19 } from "nostr-tools";
import dayjs from "dayjs";
@ -19,10 +20,31 @@ import {
} from "../icons";
import useCurrentAccount from "../../hooks/use-current-account";
import accountService from "../../services/account";
import { useLocalStorage } from "react-use";
import { useKeyPressEvent, useLocalStorage } from "react-use";
import ZapModal from "../event-zap-modal";
import PuzzlePiece01 from "../icons/puzzle-piece-01";
import Package from "../icons/package";
import Rocket02 from "../icons/rocket-02";
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
function KBD({ letter }: { letter: string }) {
const ref = useRef<HTMLDivElement | null>(null);
useKeyPressEvent(
(e) => e.ctrlKey && e.key === letter,
(e) => {
if (ref.current?.parentElement) {
e.preventDefault();
ref.current.parentElement.click();
}
},
);
return (
<Code fontSize="md" ml="auto" mr="2" textDecoration="none" textTransform="capitalize" ref={ref}>
&#8984;{letter}
</Code>
);
}
export default function NavItems() {
const location = useLocation();
@ -31,6 +53,8 @@ export default function NavItems() {
const donateModal = useDisclosure();
const [lastDonate, setLastDonate] = useLocalStorage<number>("last-donate");
const showShortcuts = useBreakpointValue({ base: false, md: true });
const buttonProps: ButtonProps = {
py: "2",
justifyContent: "flex-start",
@ -39,6 +63,7 @@ export default function NavItems() {
let active = "notes";
if (location.pathname.startsWith("/notifications")) active = "notifications";
else if (location.pathname.startsWith("/launchpad")) active = "launchpad";
else if (location.pathname.startsWith("/dvm")) active = "dvm";
else if (location.pathname.startsWith("/dm")) active = "dm";
else if (location.pathname.startsWith("/streams")) active = "streams";
@ -69,6 +94,16 @@ export default function NavItems() {
return (
<>
<Button
as={RouterLink}
to="/launchpad"
leftIcon={<Rocket02 boxSize={6} />}
colorScheme={active === "launchpad" ? "primary" : undefined}
{...buttonProps}
>
Launchpad
{showShortcuts && <KBD letter="l" />}
</Button>
<Button
as={RouterLink}
to="/"
@ -97,6 +132,7 @@ export default function NavItems() {
{...buttonProps}
>
Notifications
{showShortcuts && <KBD letter="i" />}
</Button>
<Button
as={RouterLink}
@ -106,6 +142,7 @@ export default function NavItems() {
{...buttonProps}
>
Messages
{showShortcuts && <KBD letter="m" />}
</Button>
</>
)}
@ -117,6 +154,7 @@ export default function NavItems() {
{...buttonProps}
>
Search
{showShortcuts && <KBD letter="k" />}
</Button>
{account?.pubkey && (
<Button
@ -176,6 +214,7 @@ export default function NavItems() {
{...buttonProps}
>
More
{showShortcuts && <KBD letter="o" />}
</Button>
<Box h="4" />
<Button

View File

@ -0,0 +1,75 @@
import {
AvatarGroup,
Card,
CardBody,
CardHeader,
CardProps,
Flex,
Heading,
IconButton,
LinkBox,
Text,
} from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import useUserLists from "../../../hooks/use-user-lists";
import { NostrEvent } from "../../../types/nostr-event";
import { PEOPLE_LIST_KIND, getListName, getPubkeysFromList } from "../../../helpers/nostr/lists";
import UserAvatar from "../../../components/user-avatar";
import useCurrentAccount from "../../../hooks/use-current-account";
import HoverLinkOverlay from "../../../components/hover-link-overlay";
import { getEventCoordinate, getEventUID } from "../../../helpers/nostr/events";
import Plus from "../../../components/icons/plus";
import useUserContactList from "../../../hooks/use-user-contact-list";
function Feed({ list, ...props }: { list: NostrEvent } & Omit<CardProps, "children">) {
const people = getPubkeysFromList(list);
return (
<Card as={LinkBox} {...props}>
<CardHeader p="4" fontWeight="bold">
{getListName(list)}
</CardHeader>
<CardBody px="4" pt="0" pb="4" overflow="hidden" display="flex" gap="2" alignItems="center">
<AvatarGroup>
{people.slice(0, 6).map((person) => (
<UserAvatar key={person.pubkey} pubkey={person.pubkey} />
))}
</AvatarGroup>
{people.length > 6 && <Text>+{people.length - 6}</Text>}
</CardBody>
<HoverLinkOverlay as={RouterLink} to={`/?people=${getEventCoordinate(list)}`} />
</Card>
);
}
export default function FeedsCard() {
const account = useCurrentAccount();
const contacts = useUserContactList();
const lists = useUserLists(account?.pubkey).filter((list) => list.kind === PEOPLE_LIST_KIND);
return (
<Card as={LinkBox} variant="outline">
<CardHeader display="flex" justifyContent="space-between">
<Heading size="lg">Feeds</Heading>
<IconButton
as={RouterLink}
to="/lists/browse"
aria-label="View Lists"
title="View Lists"
icon={<Plus boxSize={5} />}
size="sm"
/>
</CardHeader>
<CardBody overflowX="auto" overflowY="hidden" pt="0">
<Flex gap="4">
{contacts && <Feed list={contacts} w="xs" flexShrink={0} />}
{lists.slice(0, 10).map((list) => (
<Feed key={getEventUID(list)} list={list} w="xs" flexShrink={0} />
))}
</Flex>
</CardBody>
</Card>
);
}

View File

@ -0,0 +1,104 @@
import { FormEventHandler, useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Card,
Code,
Flex,
FlexProps,
Input,
InputGroup,
InputRightElement,
useDisclosure,
} from "@chakra-ui/react";
import { matchSorter } from "match-sorter";
import { useAsync, useKeyPressEvent, useThrottle } from "react-use";
import { nip19 } from "nostr-tools";
import { Link as RouterLink } from "react-router-dom";
import { useUserSearchDirectoryContext } from "../../../providers/global/user-directory-provider";
import UserAvatar from "../../../components/user-avatar";
import UserName from "../../../components/user-name";
function UserOption({ pubkey }: { pubkey: string }) {
return (
<Flex as={RouterLink} to={`/u/${nip19.npubEncode(pubkey)}`} p="2" gap="2" alignItems="center">
<UserAvatar pubkey={pubkey} size="sm" />
<UserName fontWeight="bold" pubkey={pubkey} />
</Flex>
);
}
export default function SearchForm({ ...props }: Omit<FlexProps, "children">) {
const getDirectory = useUserSearchDirectoryContext();
const navigate = useNavigate();
const autoComplete = useDisclosure();
const ref = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState("");
const queryThrottle = useThrottle(query);
const { value: localUsers = [] } = useAsync(async () => {
if (queryThrottle.trim().length < 2) return [];
const dir = await getDirectory();
return matchSorter(dir, queryThrottle.trim(), { keys: ["names"] }).slice(0, 10);
}, [queryThrottle]);
useEffect(() => {
if (localUsers.length > 0 && !autoComplete.isOpen) autoComplete.onOpen();
}, [localUsers, autoComplete.isOpen]);
const handleSubmit = useCallback<FormEventHandler>(
(e) => {
e.preventDefault();
navigate("/search?q=" + encodeURIComponent(query));
},
[query],
);
useKeyPressEvent(
(e) => e.ctrlKey && e.key === "k",
(e) => {
e.preventDefault();
ref.current?.focus();
},
);
return (
<Flex as="form" onSubmit={handleSubmit} position="relative" {...props}>
<InputGroup size="lg">
<Input
borderRadius="lg"
placeholder="Search users"
autoComplete="off"
value={query}
onChange={(e) => setQuery(e.target.value)}
// onBlur={autoComplete.onClose}
ref={ref}
/>
<InputRightElement hideBelow="md">
<Code mx="2" fontSize="lg">
&#8984;K
</Code>
</InputRightElement>
</InputGroup>
{autoComplete.isOpen && (
<Card
display="flex"
direction="column"
maxH="lg"
overflowX="hidden"
overflowY="auto"
position="absolute"
top="3rem"
right="0"
left="0"
zIndex={10}
>
{localUsers.map(({ pubkey }) => (
<UserOption key={pubkey} pubkey={pubkey} />
))}
</Card>
)}
</Flex>
);
}

View File

@ -0,0 +1,68 @@
import { useContext } from "react";
import { useKeyPressEvent } from "react-use";
import { Button, Code, Container, Flex, IconButton } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom";
import VerticalPageLayout from "../../components/vertical-page-layout";
import RequireCurrentAccount from "../../providers/route/require-current-account";
import { PostModalContext } from "../../providers/route/post-modal-provider";
import AccountSwitcher from "../../components/layout/account-switcher";
import { SettingsIcon } from "../../components/icons";
import { ErrorBoundary } from "../../components/error-boundary";
import FeedsCard from "./components/feeds-card";
import SearchForm from "./components/search-form";
function LaunchpadPage() {
const { openModal } = useContext(PostModalContext);
useKeyPressEvent("n", () => !(document.activeElement instanceof HTMLInputElement) && openModal());
return (
<VerticalPageLayout gap="4">
<Flex justifyContent="space-between">
<AccountSwitcher />
<IconButton
as={RouterLink}
icon={<SettingsIcon boxSize={6} />}
aria-label="Settings"
title="Settings"
size="lg"
borderRadius="50%"
to="/settings"
/>
</Flex>
<Flex gap="4">
<Button colorScheme="primary" size="lg" onClick={() => openModal()} variant="outline">
New Note
<Code ml="2" fontSize="lg" hideBelow="md">
N
</Code>
</Button>
<SearchForm flex={1} />
</Flex>
<FeedsCard />
</VerticalPageLayout>
);
}
export default function LaunchpadView() {
return (
<RequireCurrentAccount>
<Container
// set base to "md" so that when layout switches to column it is full width
size={{ base: "md", md: "md", lg: "lg", xl: "xl", "2xl": "2xl" }}
display="flex"
flexGrow={1}
padding="0"
flexDirection="column"
mx="auto"
minH="50vh"
overflow="hidden"
>
<ErrorBoundary>
<LaunchpadPage />
</ErrorBoundary>
</Container>
</RequireCurrentAccount>
);
}

View File

@ -6,6 +6,7 @@ import AppCard, { App } from "./component/app-card";
import useRouteSearchValue from "../../hooks/use-route-search-value";
import useRecentApps from "./use-recent-apps";
import { allApps, externalTools, internalTools } from "./apps";
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
const tabs = ["all", "tools", "3rd-party-tools"];
@ -13,6 +14,7 @@ export default function OtherStuffView() {
const [search, setSearch] = useState("");
const tab = useRouteSearchValue("tab", "all");
const { recentApps, useApp } = useRecentApps();
const autoFocusSearch = useBreakpointValue({ base: false, lg: true });
const sortByRecent = (a: App, b: App) => recentApps.indexOf(b.id) - recentApps.indexOf(a.id);
const sortByName = (a: App, b: App) => {
@ -98,6 +100,7 @@ export default function OtherStuffView() {
maxW="sm"
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus={autoFocusSearch}
/>
{renderContent()}

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { Button, ButtonGroup, Flex, IconButton, Input, Link, useDisclosure } from "@chakra-ui/react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { SEARCH_RELAYS } from "../../const";
import { safeDecode } from "../../helpers/nip19";
@ -19,11 +19,14 @@ import CommunitySearchResults from "./community-results";
import PeopleListProvider from "../../providers/local/people-list-provider";
import PeopleListSelection from "../../components/people-list-selection/people-list-selection";
import useRouteSearchValue from "../../hooks/use-route-search-value";
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
export function SearchPage() {
const navigate = useNavigate();
const qrScannerModal = useDisclosure();
const autoFocusSearch = useBreakpointValue({ base: false, lg: true });
const typeParam = useRouteSearchValue("type", "users");
const queryParam = useRouteSearchValue("q", "");
@ -88,7 +91,12 @@ export function SearchPage() {
{!!navigator.clipboard?.readText && (
<IconButton onClick={readClipboard} icon={<CopyToClipboardIcon />} aria-label="Read clipboard" />
)}
<Input type="search" value={searchInput} onChange={(e) => setSearchInput(e.target.value)} />
<Input
type="search"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
autoFocus={autoFocusSearch}
/>
<Button type="submit">Search</Button>
</Flex>
</Flex>