mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-11 13:20:37 +02:00
add launchpad and keyboard shortcuts
This commit is contained in:
parent
59821df735
commit
fba092a59f
@ -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 />,
|
||||
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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}>
|
||||
⌘{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
|
||||
|
75
src/views/launchpad/components/feeds-card.tsx
Normal file
75
src/views/launchpad/components/feeds-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
104
src/views/launchpad/components/search-form.tsx
Normal file
104
src/views/launchpad/components/search-form.tsx
Normal 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">
|
||||
⌘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>
|
||||
);
|
||||
}
|
68
src/views/launchpad/index.tsx
Normal file
68
src/views/launchpad/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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()}
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user