Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MVP Keyboard shortcuts + text search #82

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import {useRouter} from "next/router";
import {Update} from "../utils/types";
import {useTheme} from "next-themes";

export default function Modal({isOpen, setIsOpen, children, wide = false}: {
export default function Modal({isOpen, setIsOpen, children, wide = false, className}: {
isOpen: boolean,
setIsOpen: Dispatch<SetStateAction<boolean>>,
children: ReactNode,
wide?: boolean,
className?: string,
}) {
const modalClasses = "top-24 left-1/2 fixed bg-white dark:bg-gray-900 p-4 rounded-md shadow-xl mx-4 overflow-y-auto";
const modalClasses = "top-24 left-1/2 fixed bg-white dark:bg-gray-900 p-4 rounded-md shadow-xl mx-4 overflow-y-auto " + className;
const {theme} = useTheme();
return (
<ReactModal
Expand Down
205 changes: 205 additions & 0 deletions components/QuickSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { useState } from 'react';
import { FiSearch } from "react-icons/fi";
import Skeleton from "react-loading-skeleton";
import useSWR from "swr";
import {fetcher, waitForEl} from "../utils/utils";
import { DatedObj, Update, User } from "../utils/types";
import Modal from "./Modal";
import { format } from "date-fns";
import Link from 'next/link';

type UpdateGraphObj = Update & {
user: User
}

const QuickSwitcher = (props: { isOpen: boolean, onRequestClose: () => (any) }) => {
const [query, setQuery] = useState<string>("");
const [page, setPage] = useState<number>(0);
const [selectedIndex, setSelectedIndex] = useState<number>(0);
const { data } = useSWR<{ data: DatedObj<UpdateGraphObj>[], count: number }>(`/api/search?query=${query}&page=${page}`, query.length ? fetcher : async () => []);

const onRequestClose = (x) => {
props.onRequestClose();
setQuery("");
setPage(0);
setSelectedIndex(0);
}

// Height of area of modal that is not scroll
const heightOfInput = 32 + 24
// = height + margin-top (mt-6 => 6 * 4)

return (
<Modal
isOpen={props.isOpen}
setIsOpen={onRequestClose}
// onRequestClose={onRequestClose}
className="px-0 flex flex-col overflow-y-hidden"
wide={true}
>
{/* Because I want scrollbar to be snug against border of modal, i can't add padding x or y to the modal directly. */}
{/* Every direct child of modal has px-4 */}
{/* Also modal has py-6 so top should have mt-6 and bottom mb-6 */}
<div className="flex items-center border-gray-100 px-4 mt-6">
<FiSearch className="text-gray-400 mr-6" />
<input
value={query}
onChange={e => {
setQuery(e.target.value);
setPage(0);
setSelectedIndex(0);
}}
id="quick-switcher-input"
placeholder="Go to update"
className="w-full focus:online-none outline-none py-1 text-gray-500"
autoFocus
onKeyDown={e => {
if (data && data.data && data.data.length) {
if (e.key === "ArrowDown") {
e.preventDefault()
const newSelectedIndex = selectedIndex === (data.data.length - 1) ? 0 : selectedIndex + 1
setSelectedIndex(newSelectedIndex)

// Scroll to selected element
const modal = document.getElementById("quick-switcher-scroll-area")
if (newSelectedIndex !== (data.data.length - 1)) {
// Scroll such that the lower edge of the element we want is at the bottom of the modal viewing area
var elmntAfter = document.getElementById(`searched-doc-${newSelectedIndex + 1}`);
modal.scroll(0, elmntAfter.offsetTop - modal.offsetHeight - heightOfInput)
} else {
// Is last element
var elmnt = document.getElementById(`searched-doc-${newSelectedIndex}`);
modal.scroll(0, elmnt.offsetTop - heightOfInput)
}
} else if (e.key === "ArrowUp") {
e.preventDefault()
const newSelectedIndex = selectedIndex === 0 ? (data.data.length - 1) : (selectedIndex - 1)
setSelectedIndex(newSelectedIndex)

// Scroll to selected element
var elmnt = document.getElementById(`searched-doc-${newSelectedIndex}`);
const modal = document.getElementById("quick-switcher-scroll-area")
modal.scroll(0, (elmnt.offsetTop - heightOfInput))
} else if (e.key === "Enter") {
waitForEl(`searched-doc-${selectedIndex}`)
}
}
}}
/>
</div>
<hr />
<div className="flex-grow px-4 pb-6 overflow-y-auto" id="quick-switcher-scroll-area">
{ /* Every outermost element inside this div has px-8 */}
{(data) ? (data.data && data.data.length) ? (
<div className="break-words overflow-hidden flex flex-col">
{data.data.map((u, idx) => {

let buttonChildren = (
<>
{/* @ts-ignore */}
<div className="py-2">
<p className="text-xs text-gray-700 font-medium">{format(new Date(u.date), "M/d/yy")} • {u.user.name}</p>
<SearchNameH3 query={query}>{`${u.title || "Unknown update"}`}</SearchNameH3>
</div>
<SearchBody update={u} query={query} />
</>
)
let onClick = () => {
onRequestClose(false)
}

return (
<Link
key={u._id}
className={("py-2 px-8 text-left") + (idx === selectedIndex ? " bg-gray-100" : "")}
id={`searched-doc-${idx}`}
onClick={onClick}
onMouseEnter={() => setSelectedIndex(idx)}
href={"/@" + u.user.urlName + "/" + u.url}
>
<div className="w-full">
{buttonChildren}
</div>
</Link>
)
})}
{/* Pagination bar */}
<div className="px-8 flex gap-4 text-sm text-gray-400 mt-6">
{data.count > 10 && Array.from(Array(Math.ceil(data.count / 10)).keys()).map(n =>
<button onClick={() => {
setPage(n);
setSelectedIndex(0);
waitForEl("quick-switcher-input")
}} className="hover:bg-gray-50 disabled:bg-gray-50 rounded-md px-4 py-2" key={n} disabled={n === page}>{n + 1}</button>
)}
</div>
<p className="px-8 text-sm text-gray-400 mt-2 text-right">
Showing results {page * 10 + 1}-{(page * 10 + 10) < data.count ? (page * 10 + 10) : data.count} out of {data.count}
</p>
</div>
) : (query.length ? (
<p className="text-gray-400 px-8 text-sm mt-2">No documents containing the given query were found.</p>
) : <></>) : (
<div className="px-8 mt-2">
<p className="text-gray-400 text-sm">Loading...</p>
<Skeleton height={32} count={5} className="my-2" />
</div>
)}
</div>
</Modal>
)
}

const includesAQueryWord = (string: string, queryWords: string[]) => {
for (let word of queryWords) {
if (string.toLowerCase().includes(word.toLowerCase())) return true
}
return false
}

const SearchNameH3 = ({ children, query }: { children: string, query: string }) => {
const queryWords = query.split(" ")
const nameWords = children.split(" ")
const newNameWords = nameWords.map(word => (
includesAQueryWord(word, queryWords)
? <span className="font-bold text-gray-700">{word}</span>
: <span className="font-semibold text-gray-600">{word}</span>
))
return (
<h3>{newNameWords.map((element, idx) => (
idx === 0
? <span key={idx}>{element}</span>
: <span key={idx}> {element}</span>
))}</h3>
)
}

const SearchBody = ({ update, query }: {update: Update, query: string}) => {
if (!update.body) return;
// s.body.substr(s.body.indexOf(query) - 50, 100)
const queryWords = query.split(" ")
const paragraphsArr = update.body.split(`
`)
const newParagraphs = paragraphsArr.filter(p => (
includesAQueryWord(p, queryWords)
)).map(p => {
// Some really jank shit for bolding certain words
const paragraphWords = p.split(" ")
const newParagraphWords = paragraphWords.map(w => includesAQueryWord(w, queryWords) ? <b className="text-gray-500">{w}</b> : <span>{w}</span>)
// return newParagraphWords.join(" ")
return newParagraphWords
})
return (
// newParagraphs.map( (p, idx) => <pre className="whitespace-pre-wrap text-gray-400 text-sm" key={idx}>
// {p}
// </pre>)
<div className="text-gray-400 text-sm">
{newParagraphs.map((p, idx) => <p key={idx} className="mb-2">{
p.map((f, id) => <span key={id}>{f} </span>)
}</p>)}
</div>
)
}


export default QuickSwitcher
28 changes: 28 additions & 0 deletions components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import {IoMdExit} from "react-icons/io";
import SignInButton from "./SignInButton";
import FloatingCta from "./FloatingCTA";
import NavbarNotificationMenu from "./NavbarNotificationMenu";
import {useKey} from "../utils/hooks";
import Mousetrap from "mousetrap";
import 'mousetrap/plugins/global-bind/mousetrap-global-bind';
import QuickSwitcher from "./QuickSwitcher";


export default function Navbar() {
const router = useRouter();
Expand All @@ -23,6 +28,7 @@ export default function Navbar() {
const { data: notificationData, error: notificationsError }: responseInterface<{ notifications: RichNotif[] }, any> = useSWR(session ? `/api/get-notifications?iter=${notificationsIter}` : null, fetcher);
const [ notifications, setNotifications ] = useState<RichNotif[]>([]);
const numNotifications = notifications.filter(d => !d.read).length
const [isQuickSwitcher, setIsQuickSwitcher] = useState<boolean>(false);

useEffect(() => {
if (notificationData && notificationData.notifications) {
Expand All @@ -36,6 +42,26 @@ export default function Navbar() {

const {theme, setTheme} = useTheme();

useKey("KeyF", () => {if (router.route !== "/") router.push("/")})
useKey("KeyE", () => {if (router.route !== "/explore") router.push("/explore")})
useKey("KeyP", () => {if (router.route !== "/profile" && session) router.push("/@" + data.data.urlName)})
useKey("KeyN", () => {if (router.route !== "/new-update" && session) router.push("/new-update")})

useEffect(() => {

function onQuickSwitcherShortcut(e) {
e.preventDefault();
setIsQuickSwitcher(prev => !prev);
}

Mousetrap.bindGlobal(['command+k', 'ctrl+k'], onQuickSwitcherShortcut);

return () => {
Mousetrap.unbind(['command+k', 'ctrl+k'], onQuickSwitcherShortcut);
}
});


const NavbarDarkModeButton = () => (
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")} className="up-button text">
<FiMoon/>
Expand Down Expand Up @@ -64,6 +90,8 @@ export default function Navbar() {

return (
<>
<QuickSwitcher isOpen={isQuickSwitcher} onRequestClose={() => setIsQuickSwitcher(false)}/>

<div className="w-full sticky mb-8 top-0 z-30 bg-white dark:bg-gray-900">
<div className="max-w-7xl mx-auto h-16 flex items-center px-4">
<Link href="/"><a><img src="/logo.svg" className="h-12"/></a></Link>
Expand Down
28 changes: 27 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "updately",
"version": "0.11.0",
"version": "0.11.1",
"private": true,
"license": "BSD-3-Clause",
"scripts": {
Expand All @@ -17,6 +17,7 @@
"date-fns": "^2.16.1",
"html-react-parser": "^0.14.2",
"mongoose": "^5.10.16",
"mousetrap": "^1.6.5",
"next": "^12.1.6",
"next-auth": "^4.3.4",
"next-response-helpers": "^0.2.0",
Expand All @@ -26,6 +27,7 @@
"react": "^18.0.8",
"react-dom": "^18.0.8",
"react-icons": "^4.4.0",
"react-loading-skeleton": "^3.3.1",
"react-mentions": "^4.3.0",
"react-modal": "^3.16.1",
"react-select": "^4.3.1",
Expand Down
Loading