Compare commits
2 Commits
fix/kan-24
...
feat/unsub
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a3c56730c | ||
|
|
eb3288336f |
@@ -2,10 +2,8 @@ import Link from "next/link";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
|
||||||
import LottieIcon from "~/components/LottieIcon";
|
import LottieIcon from "~/components/LottieIcon";
|
||||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
|
||||||
|
|
||||||
const Button: React.FC<{
|
const Button: React.FC<{
|
||||||
href: string;
|
href: string;
|
||||||
@@ -14,20 +12,10 @@ const Button: React.FC<{
|
|||||||
json: object;
|
json: object;
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
onCloseSideNav?: () => void;
|
onCloseSideNav?: () => void;
|
||||||
keyboardShortcut: KeyboardShortcut;
|
}> = ({ href, current, name, json, isCollapsed = false, onCloseSideNav }) => {
|
||||||
}> = ({
|
|
||||||
href,
|
|
||||||
current,
|
|
||||||
name,
|
|
||||||
json,
|
|
||||||
isCollapsed = false,
|
|
||||||
keyboardShortcut,
|
|
||||||
onCloseSideNav,
|
|
||||||
}) => {
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { keys: shortcutKeys } = useKeyboardShortcut(keyboardShortcut);
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
const handleMouseEnter = () => {
|
||||||
setIsHovered(true);
|
setIsHovered(true);
|
||||||
@@ -46,27 +34,18 @@ const Button: React.FC<{
|
|||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"group flex h-[34px] items-center justify-between rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
|
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
|
||||||
current
|
current
|
||||||
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
|
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
|
||||||
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
|
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
|
||||||
|
isCollapsed
|
||||||
|
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
|
||||||
|
: "gap-x-3",
|
||||||
)}
|
)}
|
||||||
title={isCollapsed ? name : undefined}
|
title={isCollapsed ? name : undefined}
|
||||||
>
|
>
|
||||||
<div
|
<LottieIcon index={index} json={json} isPlaying={isHovered} />
|
||||||
className={twMerge(
|
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
|
||||||
"flex items-center",
|
|
||||||
isCollapsed
|
|
||||||
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
|
|
||||||
: "gap-x-3",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<LottieIcon index={index} json={json} isPlaying={isHovered} />
|
|
||||||
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
|
|
||||||
</div>
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="hidden md:group-hover:inline-flex">{shortcutKeys}</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { twMerge } from "tailwind-merge";
|
|||||||
import type { Subscription } from "@kan/shared/utils";
|
import type { Subscription } from "@kan/shared/utils";
|
||||||
import { hasActiveSubscription } from "@kan/shared/utils";
|
import { hasActiveSubscription } from "@kan/shared/utils";
|
||||||
|
|
||||||
import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
|
||||||
import boardsIconDark from "~/assets/boards-dark.json";
|
import boardsIconDark from "~/assets/boards-dark.json";
|
||||||
import boardsIconLight from "~/assets/boards-light.json";
|
import boardsIconLight from "~/assets/boards-light.json";
|
||||||
import membersIconDark from "~/assets/members-dark.json";
|
import membersIconDark from "~/assets/members-dark.json";
|
||||||
@@ -87,59 +86,26 @@ export default function SideNavigation({
|
|||||||
|
|
||||||
const isDarkMode = resolvedTheme === "dark";
|
const isDarkMode = resolvedTheme === "dark";
|
||||||
|
|
||||||
const navigation: {
|
const navigation = [
|
||||||
name: string;
|
|
||||||
href: string;
|
|
||||||
icon: object;
|
|
||||||
keyboardShortcut: KeyboardShortcut;
|
|
||||||
}[] = [
|
|
||||||
{
|
{
|
||||||
name: t`Boards`,
|
name: t`Boards`,
|
||||||
href: "/boards",
|
href: "/boards",
|
||||||
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
||||||
keyboardShortcut: {
|
|
||||||
type: "SEQUENCE",
|
|
||||||
strokes: [{ key: "G" }, { key: "B" }],
|
|
||||||
action: () => router.push("/boards"),
|
|
||||||
group: "NAVIGATION",
|
|
||||||
description: t`Go to boards`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t`Templates`,
|
name: t`Templates`,
|
||||||
href: "/templates",
|
href: "/templates",
|
||||||
icon: isDarkMode ? templatesIconDark : templatesIconLight,
|
icon: isDarkMode ? templatesIconDark : templatesIconLight,
|
||||||
keyboardShortcut: {
|
|
||||||
type: "SEQUENCE",
|
|
||||||
strokes: [{ key: "G" }, { key: "T" }],
|
|
||||||
action: () => router.push("/templates"),
|
|
||||||
group: "NAVIGATION",
|
|
||||||
description: t`Go to templates`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t`Members`,
|
name: t`Members`,
|
||||||
href: "/members",
|
href: "/members",
|
||||||
icon: isDarkMode ? membersIconDark : membersIconLight,
|
icon: isDarkMode ? membersIconDark : membersIconLight,
|
||||||
keyboardShortcut: {
|
|
||||||
type: "SEQUENCE",
|
|
||||||
strokes: [{ key: "G" }, { key: "M" }],
|
|
||||||
action: () => router.push("/members"),
|
|
||||||
group: "NAVIGATION",
|
|
||||||
description: t`Go to members`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t`Settings`,
|
name: t`Settings`,
|
||||||
href: "/settings",
|
href: "/settings",
|
||||||
icon: isDarkMode ? settingsIconDark : settingsIconLight,
|
icon: isDarkMode ? settingsIconDark : settingsIconLight,
|
||||||
keyboardShortcut: {
|
|
||||||
type: "SEQUENCE",
|
|
||||||
strokes: [{ key: "G" }, { key: "S" }],
|
|
||||||
action: () => router.push("/settings"),
|
|
||||||
group: "NAVIGATION",
|
|
||||||
description: t`Go to settings`,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -197,7 +163,6 @@ export default function SideNavigation({
|
|||||||
json={item.icon}
|
json={item.icon}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onCloseSideNav={onCloseSideNav}
|
onCloseSideNav={onCloseSideNav}
|
||||||
keyboardShortcut={item.keyboardShortcut}
|
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import type { Root } from "react-dom/client";
|
|
||||||
import type { Placement } from "tippy.js";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import tippy from "tippy.js";
|
|
||||||
|
|
||||||
interface TooltipProps {
|
|
||||||
children: ReactNode;
|
|
||||||
content: ReactNode;
|
|
||||||
placement?: Placement;
|
|
||||||
delay?: number | [number, number];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Tooltip({
|
|
||||||
children,
|
|
||||||
content,
|
|
||||||
placement = "bottom",
|
|
||||||
delay = [500, 0],
|
|
||||||
}: TooltipProps) {
|
|
||||||
const triggerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const rootRef = useRef<Root | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!triggerRef.current) return;
|
|
||||||
|
|
||||||
const container = document.createElement("div");
|
|
||||||
const root = createRoot(container);
|
|
||||||
rootRef.current = root;
|
|
||||||
root.render(content);
|
|
||||||
|
|
||||||
const instance = tippy(triggerRef.current, {
|
|
||||||
content: container,
|
|
||||||
placement,
|
|
||||||
delay,
|
|
||||||
interactive: false,
|
|
||||||
theme: "tooltip",
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
instance.destroy();
|
|
||||||
rootRef.current?.unmount();
|
|
||||||
};
|
|
||||||
}, [content, placement, delay]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={triggerRef} className="inline-flex">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import { twMerge } from "tailwind-merge";
|
|||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||||
import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
import { getAvatarUrl } from "~/utils/helpers";
|
||||||
|
|
||||||
@@ -32,7 +31,6 @@ export default function UserMenu({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
const { openLegend } = useKeyboardShortcuts();
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
@@ -171,19 +169,6 @@ export default function UserMenu({
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</div>
|
</div>
|
||||||
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
|
||||||
<Menu.Item>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (onCloseSideNav && isMobile) {
|
|
||||||
onCloseSideNav();
|
|
||||||
}
|
|
||||||
openLegend();
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
|
||||||
>
|
|
||||||
{t`Shortcuts`}
|
|
||||||
</button>
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
<Link
|
<Link
|
||||||
href="mailto:support@kan.bn"
|
href="mailto:support@kan.bn"
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { Button, Menu, Transition } from "@headlessui/react";
|
import { Button, Menu, Transition } from "@headlessui/react";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Fragment, useState } from "react";
|
import { Fragment, useEffect, useState } from "react";
|
||||||
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import CommandPallette from "./CommandPallette";
|
import CommandPallette from "./CommandPallette";
|
||||||
import { Tooltip } from "./Tooltip";
|
|
||||||
|
|
||||||
export default function WorkspaceMenu({
|
export default function WorkspaceMenu({
|
||||||
isCollapsed = false,
|
isCollapsed = false,
|
||||||
@@ -20,17 +18,17 @@ export default function WorkspaceMenu({
|
|||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const { tooltipContent: commandPaletteShortcutTooltipContent } =
|
useEffect(() => {
|
||||||
useKeyboardShortcut({
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
type: "PRESS",
|
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
|
||||||
stroke: {
|
event.preventDefault();
|
||||||
key: "k",
|
setIsOpen(true);
|
||||||
modifiers: ["META"],
|
}
|
||||||
},
|
};
|
||||||
action: () => setIsOpen(true),
|
|
||||||
description: t`Open command menu`,
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
group: "GENERAL",
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
});
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -86,17 +84,15 @@ export default function WorkspaceMenu({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
<Tooltip content={commandPaletteShortcutTooltipContent}>
|
<Button
|
||||||
<Button
|
className={twMerge(
|
||||||
className={twMerge(
|
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
|
||||||
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
|
isCollapsed && "md:mb-2 md:h-9 md:w-9",
|
||||||
isCollapsed && "md:mb-2 md:h-9 md:w-9",
|
)}
|
||||||
)}
|
onClick={() => setIsOpen(true)}
|
||||||
onClick={() => setIsOpen(true)}
|
>
|
||||||
>
|
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
|
||||||
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
|
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
|
||||||
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
|
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
|
||||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT: z.string().optional(),
|
NEXT_PUBLIC_USE_STANDALONE_OUTPUT: z.string().optional(),
|
||||||
NEXT_PUBLIC_BASE_URL: z.string().url().optional(),
|
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||||
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
|
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
|
||||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
||||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
|
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import posthog from "posthog-js";
|
|||||||
import { PostHogProvider } from "posthog-js/react";
|
import { PostHogProvider } from "posthog-js/react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts";
|
|
||||||
import { LinguiProviderWrapper } from "~/providers/lingui";
|
import { LinguiProviderWrapper } from "~/providers/lingui";
|
||||||
import { ModalProvider } from "~/providers/modal";
|
import { ModalProvider } from "~/providers/modal";
|
||||||
import { PopupProvider } from "~/providers/popup";
|
import { PopupProvider } from "~/providers/popup";
|
||||||
@@ -81,23 +80,21 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
|
|||||||
)}
|
)}
|
||||||
<script src="/__ENV.js" />
|
<script src="/__ENV.js" />
|
||||||
<main className="font-sans">
|
<main className="font-sans">
|
||||||
<KeyboardShortcutProvider>
|
<LinguiProviderWrapper>
|
||||||
<LinguiProviderWrapper>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ModalProvider>
|
||||||
<ModalProvider>
|
<PopupProvider>
|
||||||
<PopupProvider>
|
{posthogKey ? (
|
||||||
{posthogKey ? (
|
<PostHogProvider client={posthog}>
|
||||||
<PostHogProvider client={posthog}>
|
{getLayout(<Component {...pageProps} />)}
|
||||||
{getLayout(<Component {...pageProps} />)}
|
</PostHogProvider>
|
||||||
</PostHogProvider>
|
) : (
|
||||||
) : (
|
getLayout(<Component {...pageProps} />)
|
||||||
getLayout(<Component {...pageProps} />)
|
)}
|
||||||
)}
|
</PopupProvider>
|
||||||
</PopupProvider>
|
</ModalProvider>
|
||||||
</ModalProvider>
|
</ThemeProvider>
|
||||||
</ThemeProvider>
|
</LinguiProviderWrapper>
|
||||||
</LinguiProviderWrapper>
|
|
||||||
</KeyboardShortcutProvider>
|
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ export default async function handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const downloadFilename = typeof filename === "string"
|
const downloadUrl = decodeURIComponent(url);
|
||||||
? encodeURIComponent(filename)
|
const downloadFilename =
|
||||||
: "attachment";
|
(typeof filename === "string" ? decodeURIComponent(filename) : null) ??
|
||||||
|
"attachment";
|
||||||
|
|
||||||
const upstream = await fetch(url);
|
const upstream = await fetch(downloadUrl);
|
||||||
|
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
return res.status(upstream.status).json({
|
return res.status(upstream.status).json({
|
||||||
@@ -35,7 +36,7 @@ export default async function handler(
|
|||||||
res.setHeader("Content-Type", contentType);
|
res.setHeader("Content-Type", contentType);
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
`attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`,
|
`attachment; filename="${downloadFilename}"`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const buffer = await upstream.arrayBuffer();
|
const buffer = await upstream.arrayBuffer();
|
||||||
|
|||||||
@@ -1,592 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogBackdrop,
|
|
||||||
DialogPanel,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@headlessui/react";
|
|
||||||
import { t } from "@lingui/core/macro";
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { HiXMark } from "react-icons/hi2";
|
|
||||||
|
|
||||||
import { env } from "~/env";
|
|
||||||
import { useEventListener } from "~/hooks/useEventListener";
|
|
||||||
|
|
||||||
const ModifierKey = {
|
|
||||||
CONTROL: "CONTROL",
|
|
||||||
META: "META",
|
|
||||||
ALT: "ALT",
|
|
||||||
SHIFT: "SHIFT",
|
|
||||||
} as const;
|
|
||||||
type ModifierKey = (typeof ModifierKey)[keyof typeof ModifierKey];
|
|
||||||
|
|
||||||
const ModifierKeyInfo: Record<
|
|
||||||
ModifierKey,
|
|
||||||
{
|
|
||||||
macSymbol: string;
|
|
||||||
winName: string;
|
|
||||||
linuxName: string;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
CONTROL: { macSymbol: "⌃", winName: "Ctrl", linuxName: "Ctrl" },
|
|
||||||
META: { macSymbol: "⌘", winName: "Win", linuxName: "Super" },
|
|
||||||
ALT: { macSymbol: "⌥", winName: "Alt", linuxName: "Alt" },
|
|
||||||
SHIFT: { macSymbol: "⇧", winName: "Shift", linuxName: "Shift" },
|
|
||||||
};
|
|
||||||
|
|
||||||
const ShortcutGroup = {
|
|
||||||
GENERAL: "GENERAL",
|
|
||||||
NAVIGATION: "NAVIGATION",
|
|
||||||
ACTIONS: "ACTIONS",
|
|
||||||
} as const;
|
|
||||||
type ShortcutGroup = (typeof ShortcutGroup)[keyof typeof ShortcutGroup];
|
|
||||||
|
|
||||||
const getShortcutGroupInfo = (): Record<ShortcutGroup, { label: string }> => ({
|
|
||||||
GENERAL: { label: t`General` },
|
|
||||||
NAVIGATION: { label: t`Navigation` },
|
|
||||||
ACTIONS: { label: t`Actions` },
|
|
||||||
});
|
|
||||||
|
|
||||||
interface KeyStroke {
|
|
||||||
key: string;
|
|
||||||
modifiers?: ModifierKey[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Press {
|
|
||||||
type: "PRESS";
|
|
||||||
stroke: KeyStroke;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Sequence {
|
|
||||||
type: "SEQUENCE";
|
|
||||||
strokes: KeyStroke[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type KeyboardShortcut = {
|
|
||||||
action: () => void;
|
|
||||||
description: string;
|
|
||||||
group: ShortcutGroup;
|
|
||||||
} & (Press | Sequence);
|
|
||||||
|
|
||||||
interface ShortcutTreeStepNode {
|
|
||||||
type: "STEP";
|
|
||||||
children: ShortcutTreeLevel;
|
|
||||||
}
|
|
||||||
interface ShortcutTreeActionNode {
|
|
||||||
type: "ACTION";
|
|
||||||
shortcut: KeyboardShortcut;
|
|
||||||
}
|
|
||||||
type ShortcutTreeNode = ShortcutTreeStepNode | ShortcutTreeActionNode;
|
|
||||||
type ShortcutTreeLevel = Record<string, ShortcutTreeNode>;
|
|
||||||
|
|
||||||
const SEQUENCE_TIMEOUT_MS = 1000;
|
|
||||||
|
|
||||||
const ShortcutConflictCode = {
|
|
||||||
ACTION_ON_SEQUENCE: "ACTION_ON_SEQUENCE",
|
|
||||||
DUPLICATE_ACTION: "DUPLICATE_ACTION",
|
|
||||||
SEQUENCE_ON_ACTION: "SEQUENCE_ON_ACTION",
|
|
||||||
} as const;
|
|
||||||
type ShortcutConflictCode = keyof typeof ShortcutConflictCode;
|
|
||||||
|
|
||||||
interface ShortcutConflictErrorOptions {
|
|
||||||
code: ShortcutConflictCode;
|
|
||||||
shortcut: KeyboardShortcut;
|
|
||||||
conflictPath: string;
|
|
||||||
existingShortcut?: KeyboardShortcut;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ShortcutConflictError extends Error {
|
|
||||||
public readonly code: ShortcutConflictCode;
|
|
||||||
public readonly shortcut: KeyboardShortcut;
|
|
||||||
public readonly conflictPath: string;
|
|
||||||
public readonly existingShortcut?: KeyboardShortcut;
|
|
||||||
|
|
||||||
constructor(options: ShortcutConflictErrorOptions) {
|
|
||||||
super(ShortcutConflictError.formatMessage(options));
|
|
||||||
this.name = "ShortcutConflictError";
|
|
||||||
this.code = options.code;
|
|
||||||
this.shortcut = options.shortcut;
|
|
||||||
this.conflictPath = options.conflictPath;
|
|
||||||
this.existingShortcut = options.existingShortcut;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static stringifyShortcut(shortcut: KeyboardShortcut): string {
|
|
||||||
if (shortcut.type === "SEQUENCE") {
|
|
||||||
return shortcut.strokes.map(serializeKeyStroke).join(" → ");
|
|
||||||
}
|
|
||||||
return serializeKeyStroke(shortcut.stroke);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static formatMessage(options: ShortcutConflictErrorOptions): string {
|
|
||||||
const formatted = ShortcutConflictError.stringifyShortcut(options.shortcut);
|
|
||||||
switch (options.code) {
|
|
||||||
case ShortcutConflictCode.ACTION_ON_SEQUENCE:
|
|
||||||
return `Cannot register "${formatted}": desired action conflicts with existing sequence at "${options.conflictPath}"`;
|
|
||||||
case ShortcutConflictCode.DUPLICATE_ACTION:
|
|
||||||
return `Cannot register "${formatted}": action already registered`;
|
|
||||||
case ShortcutConflictCode.SEQUENCE_ON_ACTION:
|
|
||||||
return `Cannot register "${formatted}": desired sequence conflicts with existing action at "${options.conflictPath}"`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KeyboardShortcutContextType {
|
|
||||||
registerShortcut: (shortcut: KeyboardShortcut) => () => void;
|
|
||||||
openLegend: () => void;
|
|
||||||
openLegendKeys: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const KeyboardShortcutContext = createContext<
|
|
||||||
KeyboardShortcutContextType | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
export function KeyboardShortcutProvider({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const treeRootRef = useRef<ShortcutTreeLevel>({});
|
|
||||||
const currentNodeRef = useRef<ShortcutTreeLevel>(treeRootRef.current);
|
|
||||||
const shortcutsRef = useRef<Map<string, KeyboardShortcut>>(new Map());
|
|
||||||
const sequenceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
const [isLegendOpen, setIsLegendOpen] = useState(false);
|
|
||||||
|
|
||||||
const openLegendShortcut: KeyboardShortcut = useMemo(
|
|
||||||
() => ({
|
|
||||||
type: "PRESS",
|
|
||||||
stroke: {
|
|
||||||
key: "/",
|
|
||||||
modifiers: ["META"],
|
|
||||||
},
|
|
||||||
action: () => setIsLegendOpen(true),
|
|
||||||
description: t`Open keyboard shortcuts`,
|
|
||||||
group: ShortcutGroup.GENERAL,
|
|
||||||
}),
|
|
||||||
[setIsLegendOpen],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
|
||||||
if (isTypingInInput(event)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sequenceTimeoutRef.current) {
|
|
||||||
clearTimeout(sequenceTimeoutRef.current);
|
|
||||||
sequenceTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serializedKey = serializeEvent(event);
|
|
||||||
const node = currentNodeRef.current[serializedKey];
|
|
||||||
|
|
||||||
if (!node) {
|
|
||||||
currentNodeRef.current = treeRootRef.current;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (node.type) {
|
|
||||||
case "STEP":
|
|
||||||
event.preventDefault();
|
|
||||||
currentNodeRef.current = node.children;
|
|
||||||
sequenceTimeoutRef.current = setTimeout(() => {
|
|
||||||
currentNodeRef.current = treeRootRef.current;
|
|
||||||
}, SEQUENCE_TIMEOUT_MS);
|
|
||||||
return;
|
|
||||||
case "ACTION":
|
|
||||||
event.preventDefault();
|
|
||||||
node.shortcut.action();
|
|
||||||
currentNodeRef.current = treeRootRef.current;
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const registerShortcut = useCallback(
|
|
||||||
(shortcut: KeyboardShortcut): (() => void) => {
|
|
||||||
const strokes =
|
|
||||||
shortcut.type === "SEQUENCE" ? shortcut.strokes : [shortcut.stroke];
|
|
||||||
|
|
||||||
const path = strokes.map(serializeKeyStroke);
|
|
||||||
const pathKey = path.join(" → ");
|
|
||||||
|
|
||||||
path.reduce((currentLevel, key, i) => {
|
|
||||||
const isLast = i === path.length - 1;
|
|
||||||
const existingNode = currentLevel[key];
|
|
||||||
|
|
||||||
if (env.NODE_ENV === "development" && existingNode) {
|
|
||||||
const conflictPath = path.slice(0, i + 1).join(" → ");
|
|
||||||
validateNoConflict(existingNode, isLast, shortcut, conflictPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLast) {
|
|
||||||
currentLevel[key] = {
|
|
||||||
type: "ACTION",
|
|
||||||
shortcut,
|
|
||||||
};
|
|
||||||
return currentLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingNode?.type === "STEP") {
|
|
||||||
return existingNode.children;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newNode: ShortcutTreeStepNode = {
|
|
||||||
type: "STEP",
|
|
||||||
children: {},
|
|
||||||
};
|
|
||||||
currentLevel[key] = newNode;
|
|
||||||
return newNode.children;
|
|
||||||
}, treeRootRef.current);
|
|
||||||
|
|
||||||
shortcutsRef.current.set(pathKey, shortcut);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
removePathAndPrune(treeRootRef.current, path);
|
|
||||||
shortcutsRef.current.delete(pathKey);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEventListener("keydown", handleKeyDown);
|
|
||||||
|
|
||||||
// Register built-in shortcut to open legend
|
|
||||||
useEffect(() => {
|
|
||||||
const cleanup = registerShortcut(openLegendShortcut);
|
|
||||||
return cleanup;
|
|
||||||
}, [registerShortcut, openLegendShortcut]);
|
|
||||||
|
|
||||||
// Cleanup timeout on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (sequenceTimeoutRef.current) {
|
|
||||||
clearTimeout(sequenceTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const shortcutsArray = Array.from(shortcutsRef.current.values());
|
|
||||||
const groupedShortcuts = shortcutsArray.reduce<
|
|
||||||
Partial<Record<ShortcutGroup, KeyboardShortcut[]>>
|
|
||||||
>((acc, shortcut) => {
|
|
||||||
const group = shortcut.group;
|
|
||||||
acc[group] ??= [];
|
|
||||||
acc[group].push(shortcut);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const openLegend = useCallback(() => {
|
|
||||||
setIsLegendOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openLegendKeys = useMemo(
|
|
||||||
() => <FormattedShortcut shortcut={openLegendShortcut} />,
|
|
||||||
[openLegendShortcut],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KeyboardShortcutContext.Provider
|
|
||||||
value={{ registerShortcut, openLegend, openLegendKeys }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
|
|
||||||
{/* Shortcut Legend */}
|
|
||||||
<Dialog
|
|
||||||
className="relative z-50"
|
|
||||||
open={isLegendOpen}
|
|
||||||
onClose={() => setIsLegendOpen(false)}
|
|
||||||
>
|
|
||||||
<DialogBackdrop
|
|
||||||
transition
|
|
||||||
className="data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 flex min-h-full w-screen items-center justify-center overflow-y-auto p-4">
|
|
||||||
<DialogPanel
|
|
||||||
transition
|
|
||||||
className="relative w-full max-w-sm transform overflow-hidden rounded-lg border border-light-600 bg-white shadow-3xl-light dark:border-dark-600 dark:bg-dark-100 dark:shadow-3xl-dark"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between border-b border-light-300 px-6 py-4 dark:border-dark-300">
|
|
||||||
<DialogTitle className="text-[14px] font-semibold text-neutral-900 dark:text-dark-1000">
|
|
||||||
{t`Keyboard Shortcuts`}
|
|
||||||
</DialogTitle>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsLegendOpen(false)}
|
|
||||||
className="rounded p-1 hover:bg-light-200 dark:hover:bg-dark-200"
|
|
||||||
>
|
|
||||||
<HiXMark className="h-5 w-5 text-neutral-700 dark:text-dark-700" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-[60vh] overflow-y-auto p-6">
|
|
||||||
{shortcutsArray.length === 0 ? (
|
|
||||||
<p className="text-center text-sm text-neutral-600 dark:text-dark-600">
|
|
||||||
{t`No keyboard shortcuts registered.`}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{Object.values(ShortcutGroup).map((group, idx) => {
|
|
||||||
const shortcuts = groupedShortcuts[group];
|
|
||||||
if (!shortcuts?.length) return null;
|
|
||||||
const groupInfo = getShortcutGroupInfo();
|
|
||||||
return (
|
|
||||||
<div key={`${group}-${idx}`}>
|
|
||||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-light-1000 dark:text-dark-1000">
|
|
||||||
{groupInfo[group].label}
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-col gap-y-2">
|
|
||||||
{shortcuts.map((shortcut) => (
|
|
||||||
<ShortcutListItem
|
|
||||||
key={shortcut.description}
|
|
||||||
shortcut={shortcut}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogPanel>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</KeyboardShortcutContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to register a keyboard shortcut. Cleanup is handled automatically.
|
|
||||||
* Returns both formatted keys and pre-built tooltip content.
|
|
||||||
*
|
|
||||||
* IMPORTANT: The shortcut object reference must be stable. If it changes
|
|
||||||
* on every render, the shortcut will be continuously registered and unregistered.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const { keys, tooltipContent } = useKeyboardShortcut({
|
|
||||||
* type: "PRESS",
|
|
||||||
* stroke: { key: "k", modifiers: ["META"] },
|
|
||||||
* action: () => openCommandPalette(),
|
|
||||||
* description: "Open command palette",
|
|
||||||
* group: "GENERAL"
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Use tooltip for Tooltip component
|
|
||||||
* <Tooltip content={tooltipContent}>
|
|
||||||
* <button>Search</button>
|
|
||||||
* </Tooltip>
|
|
||||||
*
|
|
||||||
* // Or use keys directly for custom formatting
|
|
||||||
* <span>Press {keys} to search</span>
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useKeyboardShortcuts(): KeyboardShortcutContextType {
|
|
||||||
const context = useContext(KeyboardShortcutContext);
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
"useKeyboardShortcuts must be used within KeyboardShortcutProvider",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useKeyboardShortcut(shortcut: KeyboardShortcut): {
|
|
||||||
keys: ReactNode;
|
|
||||||
tooltipContent: ReactNode;
|
|
||||||
} {
|
|
||||||
const context = useContext(KeyboardShortcutContext);
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
"useKeyboardShortcut must be used within KeyboardShortcutProvider",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { registerShortcut } = context;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cleanup = registerShortcut(shortcut);
|
|
||||||
return cleanup;
|
|
||||||
}, [shortcut, registerShortcut]);
|
|
||||||
|
|
||||||
const keys = <FormattedShortcut shortcut={shortcut} />;
|
|
||||||
const tooltipContent = (
|
|
||||||
<div className="flex flex-row items-center gap-2 text-[11px]">
|
|
||||||
{shortcut.description} {keys}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return { keys, tooltipContent };
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShortcutListItem({ shortcut }: { shortcut: KeyboardShortcut }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-sm text-dark-50 dark:text-dark-900">
|
|
||||||
{shortcut.description}
|
|
||||||
</span>
|
|
||||||
<FormattedShortcut shortcut={shortcut} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) {
|
|
||||||
const kbdClassName =
|
|
||||||
"inline-flex h-5 w-5 items-center justify-center rounded border border-light-400 bg-light-200 px-1.5 py-0.5 font-mono text-[8px] font-semibold text-center text-neutral-900 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-950";
|
|
||||||
|
|
||||||
const stringifyModifier = (modifier: ModifierKey): string => {
|
|
||||||
const isMac =
|
|
||||||
typeof navigator !== "undefined" && navigator.userAgent.includes("Mac");
|
|
||||||
const isLinux =
|
|
||||||
typeof navigator !== "undefined" && navigator.userAgent.includes("Linux");
|
|
||||||
|
|
||||||
const info = ModifierKeyInfo[modifier];
|
|
||||||
if (isMac) return info.macSymbol;
|
|
||||||
if (isLinux) return info.linuxName;
|
|
||||||
return info.winName;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatStroke = (stroke: KeyStroke): ReactNode[] => {
|
|
||||||
const parts: ReactNode[] = [];
|
|
||||||
const modifierStrings = stroke.modifiers
|
|
||||||
? stroke.modifiers.map(stringifyModifier)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
modifierStrings.forEach((mod) => {
|
|
||||||
parts.push(<kbd className={kbdClassName}>{mod}</kbd>);
|
|
||||||
});
|
|
||||||
|
|
||||||
parts.push(<kbd className={kbdClassName}>{stroke.key.toUpperCase()}</kbd>);
|
|
||||||
|
|
||||||
return parts;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (shortcut.type === "SEQUENCE") {
|
|
||||||
const parts: ReactNode[] = [];
|
|
||||||
shortcut.strokes.forEach((stroke) => {
|
|
||||||
parts.push(...formatStroke(stroke));
|
|
||||||
});
|
|
||||||
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="inline-flex flex-shrink-0 items-center gap-1 text-[11px]">
|
|
||||||
{formatStroke(shortcut.stroke)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Checks for conflicts given existing nodes and current path
|
|
||||||
* Throws an error if conflict is found
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
function validateNoConflict(
|
|
||||||
existingNode: ShortcutTreeNode,
|
|
||||||
isLastKey: boolean,
|
|
||||||
shortcut: KeyboardShortcut,
|
|
||||||
conflictPath: string,
|
|
||||||
): void {
|
|
||||||
if (isLastKey) {
|
|
||||||
if (existingNode.type === "STEP") {
|
|
||||||
throw new ShortcutConflictError({
|
|
||||||
code: ShortcutConflictCode.ACTION_ON_SEQUENCE,
|
|
||||||
shortcut,
|
|
||||||
conflictPath,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new ShortcutConflictError({
|
|
||||||
code: ShortcutConflictCode.DUPLICATE_ACTION,
|
|
||||||
shortcut,
|
|
||||||
conflictPath,
|
|
||||||
existingShortcut: existingNode.shortcut,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (existingNode.type === "ACTION") {
|
|
||||||
throw new ShortcutConflictError({
|
|
||||||
code: ShortcutConflictCode.SEQUENCE_ON_ACTION,
|
|
||||||
shortcut,
|
|
||||||
conflictPath,
|
|
||||||
existingShortcut: existingNode.shortcut,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the user is currently typing in an input field
|
|
||||||
*/
|
|
||||||
function isTypingInInput(event: KeyboardEvent): boolean {
|
|
||||||
if (!event.target) return false;
|
|
||||||
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
const tagName = target.tagName.toLowerCase();
|
|
||||||
const isInput = tagName === "input" || tagName === "textarea";
|
|
||||||
const isContentEditable = target.isContentEditable;
|
|
||||||
|
|
||||||
return isInput || isContentEditable;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serializes a KeyStroke to a consistent string format
|
|
||||||
* Returns lowercase string like "ctrl+shift+k" with alphabetically sorted modifiers
|
|
||||||
*/
|
|
||||||
function serializeKeyStroke(stroke: KeyStroke): string {
|
|
||||||
const key = stroke.key.toLowerCase();
|
|
||||||
if (!stroke.modifiers || stroke.modifiers.length === 0) return key;
|
|
||||||
const sorted = [...stroke.modifiers].sort();
|
|
||||||
return `${sorted.join("+")}+${key}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a keyboard event to a KeyStroke
|
|
||||||
*/
|
|
||||||
function eventToKeyStroke(event: KeyboardEvent): KeyStroke {
|
|
||||||
const modifiers: ModifierKey[] = [];
|
|
||||||
if (event.altKey) modifiers.push(ModifierKey.ALT);
|
|
||||||
if (event.ctrlKey) modifiers.push(ModifierKey.CONTROL);
|
|
||||||
if (event.metaKey) modifiers.push(ModifierKey.META);
|
|
||||||
// Only include shift if the key is a letter (shift wasn't consumed to produce the character)
|
|
||||||
if (event.shiftKey && /^[a-zA-Z]$/.test(event.key))
|
|
||||||
modifiers.push(ModifierKey.SHIFT);
|
|
||||||
return { key: event.key, modifiers };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serializes a keyboard event to a consistent string format
|
|
||||||
*/
|
|
||||||
function serializeEvent(event: KeyboardEvent): string {
|
|
||||||
return serializeKeyStroke(eventToKeyStroke(event));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a path from the tree and prunes empty branches
|
|
||||||
*/
|
|
||||||
function removePathAndPrune(tree: ShortcutTreeLevel, path: string[]): void {
|
|
||||||
const [first, ...rest] = path;
|
|
||||||
if (!first) return;
|
|
||||||
|
|
||||||
if (rest.length === 0) {
|
|
||||||
delete tree[first];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = tree[first];
|
|
||||||
if (!node || node.type !== "STEP") return;
|
|
||||||
|
|
||||||
removePathAndPrune(node.children, rest);
|
|
||||||
|
|
||||||
if (Object.keys(node.children).length === 0) {
|
|
||||||
delete tree[first];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,17 +39,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tippy.js tooltip theme */
|
|
||||||
.tippy-box[data-theme~="tooltip"] {
|
|
||||||
@apply rounded-md border border-light-600 bg-white px-2 py-1 text-sm text-neutral-900 shadow-lg dark:border-dark-600 dark:bg-dark-100 dark:text-dark-1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tippy-box[data-theme~="tooltip"] > .tippy-arrow::before {
|
|
||||||
@apply border-t-light-600 dark:border-t-dark-600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tippy-box[data-theme~="tooltip"][data-placement^="bottom"]
|
|
||||||
> .tippy-arrow::before {
|
|
||||||
@apply border-b-light-600 dark:border-b-dark-600;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
|||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
|
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
|
||||||
import { Tooltip } from "~/components/Tooltip";
|
|
||||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
@@ -56,15 +54,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
useState<PublicListId>("");
|
useState<PublicListId>("");
|
||||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||||
|
|
||||||
const { tooltipContent: createListShortcutTooltipContent } =
|
|
||||||
useKeyboardShortcut({
|
|
||||||
type: "PRESS",
|
|
||||||
stroke: { key: "C" },
|
|
||||||
action: () => boardId && openNewListForm(boardId),
|
|
||||||
description: t`Create new list`,
|
|
||||||
group: "ACTIONS",
|
|
||||||
});
|
|
||||||
|
|
||||||
const boardId = params?.boardId
|
const boardId = params?.boardId
|
||||||
? Array.isArray(params.boardId)
|
? Array.isArray(params.boardId)
|
||||||
? params.boardId[0]
|
? params.boardId[0]
|
||||||
@@ -438,22 +427,20 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Tooltip content={createListShortcutTooltipContent}>
|
<Button
|
||||||
<Button
|
iconLeft={
|
||||||
iconLeft={
|
<HiOutlinePlusSmall
|
||||||
<HiOutlinePlusSmall
|
className="-mr-0.5 h-5 w-5"
|
||||||
className="-mr-0.5 h-5 w-5"
|
aria-hidden="true"
|
||||||
aria-hidden="true"
|
/>
|
||||||
/>
|
}
|
||||||
}
|
onClick={() => {
|
||||||
onClick={() => {
|
if (boardId) openNewListForm(boardId);
|
||||||
if (boardId) openNewListForm(boardId);
|
}}
|
||||||
}}
|
disabled={!boardData}
|
||||||
disabled={!boardData}
|
>
|
||||||
>
|
{t`New list`}
|
||||||
{t`New list`}
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<BoardDropdown
|
<BoardDropdown
|
||||||
isTemplate={!!isTemplate}
|
isTemplate={!!isTemplate}
|
||||||
isLoading={!boardData}
|
isLoading={!boardData}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import FeedbackModal from "~/components/FeedbackModal";
|
|||||||
import Modal from "~/components/modal";
|
import Modal from "~/components/modal";
|
||||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import { Tooltip } from "~/components/Tooltip";
|
|
||||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { BoardsList } from "./components/BoardsList";
|
import { BoardsList } from "./components/BoardsList";
|
||||||
@@ -18,15 +16,6 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
const { openModal, modalContentType, isOpen } = useModal();
|
const { openModal, modalContentType, isOpen } = useModal();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
|
|
||||||
const { tooltipContent: createModalShortcutTooltipContent } =
|
|
||||||
useKeyboardShortcut({
|
|
||||||
type: "PRESS",
|
|
||||||
stroke: { key: "C" },
|
|
||||||
action: () => openModal("NEW_BOARD"),
|
|
||||||
description: t`Create new ${isTemplate ? "template" : "board"}`,
|
|
||||||
group: "ACTIONS",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead
|
<PageHead
|
||||||
@@ -50,18 +39,16 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
{t`Import`}
|
{t`Import`}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Tooltip content={createModalShortcutTooltipContent}>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="primary"
|
||||||
variant="primary"
|
onClick={() => openModal("NEW_BOARD")}
|
||||||
onClick={() => openModal("NEW_BOARD")}
|
iconLeft={
|
||||||
iconLeft={
|
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
|
||||||
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
|
}
|
||||||
}
|
>
|
||||||
>
|
{t`New`}
|
||||||
{t`New`}
|
</Button>
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -67,23 +67,22 @@ export function CardModal({
|
|||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<button
|
<button
|
||||||
className="absolute right-[2rem] top-[2rem] rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
className="absolute right-[2rem] top-[2rem] rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||||
onClick={(e) => {
|
onClick={async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
closeModal();
|
closeModal();
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
void router.replace(
|
try {
|
||||||
{
|
await router.replace(
|
||||||
pathname: router.pathname,
|
`/${workspaceSlug}/${boardSlug}`,
|
||||||
query: {
|
undefined,
|
||||||
...router.query,
|
{
|
||||||
workspaceSlug,
|
shallow: true,
|
||||||
boardSlug: [boardSlug],
|
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
undefined,
|
} catch (error) {
|
||||||
{ shallow: true },
|
console.error(error);
|
||||||
);
|
}
|
||||||
}, 400);
|
}, 400);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -76,8 +76,7 @@ export default function PublicBoardView() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pathWithoutQuery = router.asPath.split("?")[0];
|
const splitPath = router.asPath.split("/");
|
||||||
const splitPath = pathWithoutQuery.split("/");
|
|
||||||
const cardPublicId = splitPath.length > 3 ? splitPath[3] : null;
|
const cardPublicId = splitPath.length > 3 ? splitPath[3] : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -173,36 +172,27 @@ export default function PublicBoardView() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
|
<div className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
|
||||||
{list.cards.map((card) => {
|
{list.cards.map((card) => (
|
||||||
return (
|
<Link
|
||||||
<Link
|
key={card.publicId}
|
||||||
key={card.publicId}
|
href={`/${data.workspace.slug}/${data.slug}/${card.publicId}`}
|
||||||
href={{
|
className={`mb-2 flex !cursor-pointer flex-col`}
|
||||||
pathname: router.pathname,
|
shallow={true}
|
||||||
query: {
|
onClick={() => {
|
||||||
...router.query,
|
openModal("CARD");
|
||||||
workspaceSlug: data.workspace.slug,
|
}}
|
||||||
boardSlug: [data.slug, card.publicId],
|
>
|
||||||
},
|
<Card
|
||||||
}}
|
title={card.title}
|
||||||
className={`mb-2 flex !cursor-pointer flex-col`}
|
labels={card.labels}
|
||||||
shallow={true}
|
checklists={card.checklists ?? []}
|
||||||
onClick={() => {
|
members={[]}
|
||||||
openModal("CARD");
|
description={card.description}
|
||||||
}}
|
comments={card.comments ?? []}
|
||||||
>
|
attachments={card.attachments}
|
||||||
<Card
|
/>
|
||||||
title={card.title}
|
</Link>
|
||||||
labels={card.labels}
|
))}
|
||||||
checklists={card.checklists ?? []}
|
|
||||||
members={[]}
|
|
||||||
description={card.description}
|
|
||||||
comments={card.comments ?? []}
|
|
||||||
attachments={card.attachments}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { env } from "next-runtime-env";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import * as inviteLinkRepo from "@kan/db/repository/inviteLink.repo";
|
import * as inviteLinkRepo from "@kan/db/repository/inviteLink.repo";
|
||||||
@@ -308,7 +307,7 @@ export const memberRouter = createTRPCRouter({
|
|||||||
return {
|
return {
|
||||||
id: activeInviteLink.id,
|
id: activeInviteLink.id,
|
||||||
inviteCode: activeInviteLink.code,
|
inviteCode: activeInviteLink.code,
|
||||||
inviteLink: `${env("NEXT_PUBLIC_BASE_URL")}/invite/${activeInviteLink.code}`,
|
inviteLink: `${process.env.NEXT_PUBLIC_BASE_URL}/invite/${activeInviteLink.code}`,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
expiresAt: activeInviteLink.expiresAt ?? undefined,
|
expiresAt: activeInviteLink.expiresAt ?? undefined,
|
||||||
};
|
};
|
||||||
@@ -416,7 +415,7 @@ export const memberRouter = createTRPCRouter({
|
|||||||
return {
|
return {
|
||||||
publicId: inviteLink.publicId,
|
publicId: inviteLink.publicId,
|
||||||
inviteCode: inviteLink.code,
|
inviteCode: inviteLink.code,
|
||||||
inviteLink: `${env("NEXT_PUBLIC_BASE_URL")}/invite/${inviteLink.code}`,
|
inviteLink: `${process.env.NEXT_PUBLIC_BASE_URL}/invite/${inviteLink.code}`,
|
||||||
expiresAt: inviteLink.expiresAt,
|
expiresAt: inviteLink.expiresAt,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -36,8 +36,6 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@kan/prettier-config",
|
"prettier": "@kan/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jose": "^6.1.2",
|
"nanoid": "^5.0.9"
|
||||||
"nanoid": "^5.0.9",
|
|
||||||
"next-runtime-env": "^1.7.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { SignJWT } from "jose";
|
import { SignJWT } from "jose";
|
||||||
import { env } from "next-runtime-env";
|
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ const encoder = new TextEncoder();
|
|||||||
export async function createEmailUnsubscribeLink(
|
export async function createEmailUnsubscribeLink(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const baseUrl = env("NEXT_PUBLIC_BASE_URL");
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
|
||||||
const secret = process.env.EMAIL_UNSUBSCRIBE_SECRET;
|
const secret = process.env.EMAIL_UNSUBSCRIBE_SECRET;
|
||||||
|
|
||||||
if (!baseUrl || !secret) {
|
if (!baseUrl || !secret) {
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -483,15 +483,9 @@ importers:
|
|||||||
|
|
||||||
packages/shared:
|
packages/shared:
|
||||||
dependencies:
|
dependencies:
|
||||||
jose:
|
|
||||||
specifier: ^6.1.2
|
|
||||||
version: 6.1.2
|
|
||||||
nanoid:
|
nanoid:
|
||||||
specifier: ^5.0.9
|
specifier: ^5.0.9
|
||||||
version: 5.1.5
|
version: 5.1.5
|
||||||
next-runtime-env:
|
|
||||||
specifier: ^1.7.2
|
|
||||||
version: 1.8.0
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@kan/eslint-config':
|
'@kan/eslint-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
|
|||||||
Reference in New Issue
Block a user