From 16e6d234e5fd548a0cb1a0af0682a2fd0930135d Mon Sep 17 00:00:00 2001 From: reya Date: Thu, 14 Mar 2024 14:22:41 +0700 Subject: [PATCH] feat: space --- apps/desktop2/index.html | 2 +- apps/desktop2/src/app.css | 48 +- apps/desktop2/src/components/accounts.tsx | 5 +- apps/desktop2/src/components/balance.tsx | 10 +- .../newsfeed.tsx} | 24 +- apps/desktop2/src/components/repost.tsx | 9 +- apps/desktop2/src/components/text.tsx | 2 +- apps/desktop2/src/routes/$account.tsx | 111 +- .../src/routes/$account/activity.lazy.tsx | 13 - .../src/routes/$account/home.lazy.tsx | 23 + apps/desktop2/src/routes/$account/home.tsx | 68 - .../src/routes/$account/home/global.lazy.tsx | 91 -- .../src/routes/$account/space.lazy.tsx | 13 - apps/desktop2/src/routes/editor/index.tsx | 3 +- apps/desktop2/src/routes/index.tsx | 6 +- apps/desktop2/src/routes/zap.$id.lazy.tsx | 2 +- packages/ark/package.json | 1 + packages/icons/src/settings.tsx | 4 +- packages/ui/src/box.tsx | 2 +- packages/ui/src/cmdk/command-score.ts | 176 --- packages/ui/src/cmdk/index.tsx | 1132 ----------------- packages/ui/src/column/content.tsx | 16 + packages/ui/src/column/header.tsx | 82 ++ packages/ui/src/column/index.ts | 9 + packages/ui/src/column/root.tsx | 21 + packages/ui/src/index.ts | 1 + packages/ui/src/note/buttons/zap.tsx | 5 +- packages/ui/src/search/dialog.tsx | 172 --- pnpm-lock.yaml | 3 + src-tauri/Cargo.toml | 1 + src-tauri/src/nostr/metadata.rs | 6 +- src-tauri/tauri.linux.conf.json | 7 +- src-tauri/tauri.macos.conf.json | 11 +- src-tauri/tauri.windows.conf.json | 7 +- 34 files changed, 249 insertions(+), 1837 deletions(-) rename apps/desktop2/src/{routes/$account/home/local.lazy.tsx => components/newsfeed.tsx} (87%) delete mode 100644 apps/desktop2/src/routes/$account/activity.lazy.tsx create mode 100644 apps/desktop2/src/routes/$account/home.lazy.tsx delete mode 100644 apps/desktop2/src/routes/$account/home.tsx delete mode 100644 apps/desktop2/src/routes/$account/home/global.lazy.tsx delete mode 100644 apps/desktop2/src/routes/$account/space.lazy.tsx delete mode 100644 packages/ui/src/cmdk/command-score.ts delete mode 100644 packages/ui/src/cmdk/index.tsx create mode 100644 packages/ui/src/column/content.tsx create mode 100644 packages/ui/src/column/header.tsx create mode 100644 packages/ui/src/column/index.ts create mode 100644 packages/ui/src/column/root.tsx delete mode 100644 packages/ui/src/search/dialog.tsx diff --git a/apps/desktop2/index.html b/apps/desktop2/index.html index 9f289e37..6bad771c 100644 --- a/apps/desktop2/index.html +++ b/apps/desktop2/index.html @@ -6,7 +6,7 @@ Lume Desktop
diff --git a/apps/desktop2/src/app.css b/apps/desktop2/src/app.css index 821c7fab..7e96b9d2 100644 --- a/apps/desktop2/src/app.css +++ b/apps/desktop2/src/app.css @@ -2,6 +2,32 @@ @tailwind components; @tailwind utilities; +@layer utilities { + .content-break { + word-break: break-word; + word-wrap: break-word; + overflow-wrap: break-word; + } + + .shadow-toolbar { + box-shadow: + 0 0 #0000, + 0 0 #0000, + 0 8px 24px 0 rgba(0, 0, 0, 0.2), + 0 2px 8px 0 rgba(0, 0, 0, 0.08), + inset 0 0 0 1px rgba(0, 0, 0, 0.2), + inset 0 0 0 2px hsla(0, 0%, 100%, 0.14); + } + + .shadow-primary { + filter: drop-shadow(0px 0px 4px rgba(66, 65, 73, 0.14)); + } +} + +/* + Overide some default styles +*/ + html { font-size: 14px; } @@ -22,25 +48,3 @@ input::-ms-clear { ::-webkit-input-placeholder { line-height: normal; } - -media-controller { - @apply w-full overflow-hidden rounded-xl; -} - -@layer utilities { - .content-break { - word-break: break-word; - word-wrap: break-word; - overflow-wrap: break-word; - } - - .shadow-toolbar { - box-shadow: - 0 0 #0000, - 0 0 #0000, - 0 8px 24px 0 rgba(0, 0, 0, 0.2), - 0 2px 8px 0 rgba(0, 0, 0, 0.08), - inset 0 0 0 1px rgba(0, 0, 0, 0.2), - inset 0 0 0 2px hsla(0, 0%, 100%, 0.14); - } -} diff --git a/apps/desktop2/src/components/accounts.tsx b/apps/desktop2/src/components/accounts.tsx index c9f2e382..fc8c0505 100644 --- a/apps/desktop2/src/components/accounts.tsx +++ b/apps/desktop2/src/components/accounts.tsx @@ -24,7 +24,7 @@ export function Accounts() { }, []); return ( -
+
{accounts ? accounts.map((account) => // @ts-ignore, useless @@ -45,8 +45,7 @@ function Inactive({ pubkey }: { pubkey: string }) { const changeAccount = async (npub: string) => { const select = await ark.load_selected_account(npub); - if (select) - navigate({ to: "/$account/home/local", params: { account: npub } }); + if (select) navigate({ to: "/$account/home", params: { account: npub } }); }; return ( diff --git a/apps/desktop2/src/components/balance.tsx b/apps/desktop2/src/components/balance.tsx index 5be1f4ec..87f96408 100644 --- a/apps/desktop2/src/components/balance.tsx +++ b/apps/desktop2/src/components/balance.tsx @@ -2,16 +2,8 @@ import { useArk } from "@lume/ark"; import { User } from "@lume/ui"; import { getBitcoinDisplayValues } from "@lume/utils"; import { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -export function Balance({ - recipient, - account, -}: { - recipient: string; - account: string; -}) { - const [t] = useTranslation(); +export function Balance({ account }: { account: string }) { const [balance, setBalance] = useState(0); const ark = useArk(); diff --git a/apps/desktop2/src/routes/$account/home/local.lazy.tsx b/apps/desktop2/src/components/newsfeed.tsx similarity index 87% rename from apps/desktop2/src/routes/$account/home/local.lazy.tsx rename to apps/desktop2/src/components/newsfeed.tsx index b815cf3c..d4d7768e 100644 --- a/apps/desktop2/src/routes/$account/home/local.lazy.tsx +++ b/apps/desktop2/src/components/newsfeed.tsx @@ -4,19 +4,16 @@ import { TextNote } from "@/components/text"; import { useArk } from "@lume/ark"; import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons"; import { Event, Kind } from "@lume/types"; +import { Column } from "@lume/ui"; import { FETCH_LIMIT } from "@lume/utils"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { Link } from "@tanstack/react-router"; -import { createLazyFileRoute } from "@tanstack/react-router"; +import { Link, useParams } from "@tanstack/react-router"; import { Virtualizer } from "virtua"; -export const Route = createLazyFileRoute("/$account/home/local")({ - component: Screen, -}); - -function Screen() { +export function Newsfeed() { const ark = useArk(); - const { account } = Route.useParams(); + // @ts-ignore, just work!!! + const { account } = useParams({ strict: false }); const { data, hasNextPage, @@ -56,8 +53,9 @@ function Screen() { }; return ( -
-
+ + + {isLoading || isRefetching ? (
@@ -69,7 +67,7 @@ function Screen() {

Empty newsfeed. Or you view the{" "} Global Newsfeed @@ -102,7 +100,7 @@ function Screen() { ) : null}

-
-
+ + ); } diff --git a/apps/desktop2/src/components/repost.tsx b/apps/desktop2/src/components/repost.tsx index cca86d0f..b956256e 100644 --- a/apps/desktop2/src/components/repost.tsx +++ b/apps/desktop2/src/components/repost.tsx @@ -44,7 +44,12 @@ export function RepostNote({ if (isError || !repostEvent) { return ( - +
@@ -71,7 +76,7 @@ export function RepostNote({ return ( diff --git a/apps/desktop2/src/components/text.tsx b/apps/desktop2/src/components/text.tsx index fab9e334..bd02f43e 100644 --- a/apps/desktop2/src/components/text.tsx +++ b/apps/desktop2/src/components/text.tsx @@ -13,7 +13,7 @@ export function TextNote({ diff --git a/apps/desktop2/src/routes/$account.tsx b/apps/desktop2/src/routes/$account.tsx index 9e00558b..e221e91a 100644 --- a/apps/desktop2/src/routes/$account.tsx +++ b/apps/desktop2/src/routes/$account.tsx @@ -1,20 +1,8 @@ -import { - BellFilledIcon, - BellIcon, - ComposeFilledIcon, - HomeFilledIcon, - HomeIcon, - HorizontalDotsIcon, - SettingsIcon, - SpaceFilledIcon, - SpaceIcon, -} from "@lume/icons"; -import { Link } from "@tanstack/react-router"; +import { ComposeFilledIcon, PlusIcon } from "@lume/icons"; import { Outlet, createFileRoute } from "@tanstack/react-router"; import { cn } from "@lume/utils"; import { Accounts } from "@/components/accounts"; import { useArk } from "@lume/ark"; -import { Box } from "@lume/ui"; export const Route = createFileRoute("/$account")({ component: App, @@ -25,17 +13,25 @@ function App() { const context = Route.useRouteContext(); return ( -
+
-
+ +
+
-
- +
- -
- ); -} - -function Navigation() { - // @ts-ignore, useless - const { account } = Route.useParams(); - - return ( -
- - {({ isActive }) => ( -
- {isActive ? ( - - ) : ( - - )} - Home -
- )} - - - {({ isActive }) => ( -
- {isActive ? ( - - ) : ( - - )} - Space -
- )} - - - {({ isActive }) => ( -
- {isActive ? ( - - ) : ( - - )} - Activity -
- )} - +
); } diff --git a/apps/desktop2/src/routes/$account/activity.lazy.tsx b/apps/desktop2/src/routes/$account/activity.lazy.tsx deleted file mode 100644 index 9ae4e2cf..00000000 --- a/apps/desktop2/src/routes/$account/activity.lazy.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { createLazyFileRoute } from "@tanstack/react-router"; - -export const Route = createLazyFileRoute("/$account/activity")({ - component: Activity, -}); - -function Activity() { - return ( -
-

Activity

-
- ); -} diff --git a/apps/desktop2/src/routes/$account/home.lazy.tsx b/apps/desktop2/src/routes/$account/home.lazy.tsx new file mode 100644 index 00000000..d4f5e834 --- /dev/null +++ b/apps/desktop2/src/routes/$account/home.lazy.tsx @@ -0,0 +1,23 @@ +import { Newsfeed } from "@/components/newsfeed"; +import { createLazyFileRoute } from "@tanstack/react-router"; +import { VList } from "virtua"; + +export const Route = createLazyFileRoute("/$account/home")({ + component: Screen, +}); + +function Screen() { + return ( +
+ + +
todo!
+
+
+ ); +} diff --git a/apps/desktop2/src/routes/$account/home.tsx b/apps/desktop2/src/routes/$account/home.tsx deleted file mode 100644 index 34cdf3ad..00000000 --- a/apps/desktop2/src/routes/$account/home.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { GlobalIcon, LoaderIcon, LocalIcon, RefreshIcon } from "@lume/icons"; -import { cn } from "@lume/utils"; -import { useQueryClient } from "@tanstack/react-query"; -import { Link } from "@tanstack/react-router"; -import { Outlet, createFileRoute } from "@tanstack/react-router"; - -export const Route = createFileRoute("/$account/home")({ - component: Screen, -}); - -function Screen() { - const queryClient = useQueryClient(); - const { account } = Route.useParams(); - - const refresh = async () => { - const queryKey = `${window.location.pathname.split("/").at(-1)}_newsfeed`; - await queryClient.refetchQueries({ queryKey: [queryKey, account] }); - }; - - return ( -
-
-
- - {({ isActive }) => ( -
- - Local -
- )} - - - {({ isActive }) => ( -
- - Global -
- )} - -
-
- -
-
- -
- ); -} diff --git a/apps/desktop2/src/routes/$account/home/global.lazy.tsx b/apps/desktop2/src/routes/$account/home/global.lazy.tsx deleted file mode 100644 index bd8c6772..00000000 --- a/apps/desktop2/src/routes/$account/home/global.lazy.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { RepostNote } from "@/components/repost"; -import { Suggest } from "@/components/suggest"; -import { TextNote } from "@/components/text"; -import { useArk } from "@lume/ark"; -import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons"; -import { Event, Kind } from "@lume/types"; -import { FETCH_LIMIT } from "@lume/utils"; -import { useInfiniteQuery } from "@tanstack/react-query"; -import { createLazyFileRoute } from "@tanstack/react-router"; -import { Virtualizer } from "virtua"; - -export const Route = createLazyFileRoute("/$account/home/global")({ - component: Screen, -}); - -function Screen() { - const ark = useArk(); - const { account } = Route.useParams(); - const { - data, - hasNextPage, - isLoading, - isRefetching, - isFetchingNextPage, - fetchNextPage, - } = useInfiniteQuery({ - queryKey: ["global_newsfeed", account], - initialPageParam: 0, - queryFn: async ({ pageParam }: { pageParam: number }) => { - const events = await ark.get_events( - "global", - FETCH_LIMIT, - pageParam, - true, - ); - return events; - }, - getNextPageParam: (lastPage) => { - const lastEvent = lastPage?.at(-1); - if (!lastEvent) return; - return lastEvent.created_at - 1; - }, - select: (data) => data?.pages.flatMap((page) => page), - refetchOnWindowFocus: false, - }); - - const renderItem = (event: Event) => { - if (!event) return; - switch (event.kind) { - case Kind.Repost: - return ; - default: - return ; - } - }; - - return ( -
-
- {isLoading || isRefetching ? ( -
- -
- ) : ( - - {data.map((item) => renderItem(item))} - - )} -
- {hasNextPage ? ( - - ) : null} -
-
-
- ); -} diff --git a/apps/desktop2/src/routes/$account/space.lazy.tsx b/apps/desktop2/src/routes/$account/space.lazy.tsx deleted file mode 100644 index 857a601e..00000000 --- a/apps/desktop2/src/routes/$account/space.lazy.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { createLazyFileRoute } from "@tanstack/react-router"; - -export const Route = createLazyFileRoute("/$account/space")({ - component: Space, -}); - -function Space() { - return ( -
-

Space

-
- ); -} diff --git a/apps/desktop2/src/routes/editor/index.tsx b/apps/desktop2/src/routes/editor/index.tsx index 7552355c..01d852dd 100644 --- a/apps/desktop2/src/routes/editor/index.tsx +++ b/apps/desktop2/src/routes/editor/index.tsx @@ -149,11 +149,12 @@ function Screen() { if (eventId) { await sendNativeNotification("You've publish new post successfully."); - return reset(); } // stop loading setLoading(false); + // reset form + reset(); } catch (e) { setLoading(false); await sendNativeNotification(String(e)); diff --git a/apps/desktop2/src/routes/index.tsx b/apps/desktop2/src/routes/index.tsx index 5001db5c..2769ad37 100644 --- a/apps/desktop2/src/routes/index.tsx +++ b/apps/desktop2/src/routes/index.tsx @@ -15,7 +15,7 @@ export const Route = createFileRoute("/")({ case 0: const guest = await ark.create_guest_account(); throw redirect({ - to: "/$account/home/local", + to: "/$account/home", params: { account: guest }, search: { guest: true }, replace: true, @@ -30,7 +30,7 @@ export const Route = createFileRoute("/")({ if (loadedAccount) { throw redirect({ - to: "/$account/home/local", + to: "/$account/home", params: { account }, replace: true, }); @@ -54,7 +54,7 @@ function Screen() { const loadAccount = await ark.load_selected_account(npub); if (loadAccount) { navigate({ - to: "/$account/home/local", + to: "/$account/home", params: { account: npub }, replace: true, }); diff --git a/apps/desktop2/src/routes/zap.$id.lazy.tsx b/apps/desktop2/src/routes/zap.$id.lazy.tsx index b84a4c72..604f3fdc 100644 --- a/apps/desktop2/src/routes/zap.$id.lazy.tsx +++ b/apps/desktop2/src/routes/zap.$id.lazy.tsx @@ -48,7 +48,7 @@ function Screen() { return ( - +
diff --git a/packages/ark/package.json b/packages/ark/package.json index d9253ec3..f5f8aff5 100644 --- a/packages/ark/package.json +++ b/packages/ark/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7", "@tanstack/react-query": "^5.24.1", + "@tanstack/react-router": "^1.18.1", "get-urls": "^12.1.0", "media-chrome": "^2.2.5", "minidenticons": "^4.2.1", diff --git a/packages/icons/src/settings.tsx b/packages/icons/src/settings.tsx index ac69645d..4c343e87 100644 --- a/packages/icons/src/settings.tsx +++ b/packages/icons/src/settings.tsx @@ -7,14 +7,12 @@ export function SettingsIcon(
diff --git a/packages/ui/src/cmdk/command-score.ts b/packages/ui/src/cmdk/command-score.ts deleted file mode 100644 index 386eba22..00000000 --- a/packages/ui/src/cmdk/command-score.ts +++ /dev/null @@ -1,176 +0,0 @@ -// The scores are arranged so that a continuous match of characters will -// result in a total score of 1. -// -// The best case, this character is a match, and either this is the start -// of the string, or the previous character was also a match. -var SCORE_CONTINUE_MATCH = 1, - // A new match at the start of a word scores better than a new match - // elsewhere as it's more likely that the user will type the starts - // of fragments. - // NOTE: We score word jumps between spaces slightly higher than slashes, brackets - // hyphens, etc. - SCORE_SPACE_WORD_JUMP = 0.9, - SCORE_NON_SPACE_WORD_JUMP = 0.8, - // Any other match isn't ideal, but we include it for completeness. - SCORE_CHARACTER_JUMP = 0.17, - // If the user transposed two letters, it should be significantly penalized. - // - // i.e. "ouch" is more likely than "curtain" when "uc" is typed. - SCORE_TRANSPOSITION = 0.1, - // The goodness of a match should decay slightly with each missing - // character. - // - // i.e. "bad" is more likely than "bard" when "bd" is typed. - // - // This will not change the order of suggestions based on SCORE_* until - // 100 characters are inserted between matches. - PENALTY_SKIPPED = 0.999, - // The goodness of an exact-case match should be higher than a - // case-insensitive match by a small amount. - // - // i.e. "HTML" is more likely than "haml" when "HM" is typed. - // - // This will not change the order of suggestions based on SCORE_* until - // 1000 characters are inserted between matches. - PENALTY_CASE_MISMATCH = 0.9999, - // Match higher for letters closer to the beginning of the word - PENALTY_DISTANCE_FROM_START = 0.9, - // If the word has more characters than the user typed, it should - // be penalised slightly. - // - // i.e. "html" is more likely than "html5" if I type "html". - // - // However, it may well be the case that there's a sensible secondary - // ordering (like alphabetical) that it makes sense to rely on when - // there are many prefix matches, so we don't make the penalty increase - // with the number of tokens. - PENALTY_NOT_COMPLETE = 0.99; - -var IS_GAP_REGEXP = /[\\\/_+.#"@\[\(\{&]/, - COUNT_GAPS_REGEXP = /[\\\/_+.#"@\[\(\{&]/g, - IS_SPACE_REGEXP = /[\s-]/, - COUNT_SPACE_REGEXP = /[\s-]/g; - -function commandScoreInner( - string, - abbreviation, - lowerString, - lowerAbbreviation, - stringIndex, - abbreviationIndex, - memoizedResults, -) { - if (abbreviationIndex === abbreviation.length) { - if (stringIndex === string.length) { - return SCORE_CONTINUE_MATCH; - } - return PENALTY_NOT_COMPLETE; - } - - var memoizeKey = `${stringIndex},${abbreviationIndex}`; - if (memoizedResults[memoizeKey] !== undefined) { - return memoizedResults[memoizeKey]; - } - - var abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex); - var index = lowerString.indexOf(abbreviationChar, stringIndex); - var highScore = 0; - - var score, transposedScore, wordBreaks, spaceBreaks; - - while (index >= 0) { - score = commandScoreInner( - string, - abbreviation, - lowerString, - lowerAbbreviation, - index + 1, - abbreviationIndex + 1, - memoizedResults, - ); - if (score > highScore) { - if (index === stringIndex) { - score *= SCORE_CONTINUE_MATCH; - } else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) { - score *= SCORE_NON_SPACE_WORD_JUMP; - wordBreaks = string - .slice(stringIndex, index - 1) - .match(COUNT_GAPS_REGEXP); - if (wordBreaks && stringIndex > 0) { - score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length); - } - } else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) { - score *= SCORE_SPACE_WORD_JUMP; - spaceBreaks = string - .slice(stringIndex, index - 1) - .match(COUNT_SPACE_REGEXP); - if (spaceBreaks && stringIndex > 0) { - score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length); - } - } else { - score *= SCORE_CHARACTER_JUMP; - if (stringIndex > 0) { - score *= Math.pow(PENALTY_SKIPPED, index - stringIndex); - } - } - - if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) { - score *= PENALTY_CASE_MISMATCH; - } - } - - if ( - (score < SCORE_TRANSPOSITION && - lowerString.charAt(index - 1) === - lowerAbbreviation.charAt(abbreviationIndex + 1)) || - (lowerAbbreviation.charAt(abbreviationIndex + 1) === - lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428 - lowerString.charAt(index - 1) !== - lowerAbbreviation.charAt(abbreviationIndex)) - ) { - transposedScore = commandScoreInner( - string, - abbreviation, - lowerString, - lowerAbbreviation, - index + 1, - abbreviationIndex + 2, - memoizedResults, - ); - - if (transposedScore * SCORE_TRANSPOSITION > score) { - score = transposedScore * SCORE_TRANSPOSITION; - } - } - - if (score > highScore) { - highScore = score; - } - - index = lowerString.indexOf(abbreviationChar, index + 1); - } - - memoizedResults[memoizeKey] = highScore; - return highScore; -} - -function formatInput(string) { - // convert all valid space characters to space so they match each other - return string.toLowerCase().replace(COUNT_SPACE_REGEXP, " "); -} - -export function commandScore(string: string, abbreviation: string): number { - /* NOTE: - * in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase() - * was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster. - */ - return commandScoreInner( - string, - abbreviation, - formatInput(string), - formatInput(abbreviation), - 0, - 0, - {}, - ); -} diff --git a/packages/ui/src/cmdk/index.tsx b/packages/ui/src/cmdk/index.tsx deleted file mode 100644 index 95015b35..00000000 --- a/packages/ui/src/cmdk/index.tsx +++ /dev/null @@ -1,1132 +0,0 @@ -import * as RadixDialog from "@radix-ui/react-dialog"; -import * as React from "react"; -import { commandScore } from "./command-score"; - -type Children = { children?: React.ReactNode }; -type DivProps = React.HTMLAttributes; - -type LoadingProps = Children & - DivProps & { - /** Estimated progress of loading asynchronous options. */ - progress?: number; - }; -type EmptyProps = Children & DivProps & {}; -type SeparatorProps = DivProps & { - /** Whether this separator should always be rendered. Useful if you disable automatic filtering. */ - alwaysRender?: boolean; -}; -type DialogProps = RadixDialog.DialogProps & - CommandProps & { - /** Provide a className to the Dialog overlay. */ - overlayClassName?: string; - /** Provide a className to the Dialog content. */ - contentClassName?: string; - /** Provide a custom element the Dialog should portal into. */ - container?: HTMLElement; - }; -type ListProps = Children & DivProps & {}; -type ItemProps = Children & - Omit & { - /** Whether this item is currently disabled. */ - disabled?: boolean; - /** Event handler for when this item is selected, either via click or keyboard selection. */ - onSelect?: (value: string) => void; - /** - * A unique value for this item. - * If no value is provided, it will be inferred from `children` or the rendered `textContent`. If your `textContent` changes between renders, you _must_ provide a stable, unique `value`. - */ - value?: string; - /** Whether this item is forcibly rendered regardless of filtering. */ - forceMount?: boolean; - }; -type GroupProps = Children & - Omit & { - /** Optional heading to render for this group. */ - heading?: React.ReactNode; - /** If no heading is provided, you must provide a value that is unique for this group. */ - value?: string; - /** Whether this group is forcibly rendered regardless of filtering. */ - forceMount?: boolean; - }; -type InputProps = Omit< - React.InputHTMLAttributes, - "value" | "onChange" | "type" -> & { - /** - * Optional controlled state for the value of the search input. - */ - value?: string; - /** - * Event handler called when the search value changes. - */ - onValueChange?: (search: string) => void; -}; -type CommandProps = Children & - DivProps & { - /** - * Accessible label for this command menu. Not shown visibly. - */ - label?: string; - /** - * Optionally set to `false` to turn off the automatic filtering and sorting. - * If `false`, you must conditionally render valid items based on the search query yourself. - */ - shouldFilter?: boolean; - /** - * Custom filter function for whether each command menu item should matches the given search query. - * It should return a number between 0 and 1, with 1 being the best match and 0 being hidden entirely. - * By default, uses the `command-score` library. - */ - filter?: (value: string, search: string) => number; - /** - * Optional default item value when it is initially rendered. - */ - defaultValue?: string; - /** - * Optional controlled state of the selected command menu item. - */ - value?: string; - /** - * Event handler called when the selected item of the menu changes. - */ - onValueChange?: (value: string) => void; - /** - * Optionally set to `true` to turn on looping around when using the arrow keys. - */ - loop?: boolean; - /** - * Set to `false` to disable ctrl+n/j/p/k shortcuts. Defaults to `true`. - */ - vimBindings?: boolean; - }; - -type Context = { - value: (id: string, value: string) => void; - item: (id: string, groupId: string) => () => void; - group: (id: string) => () => void; - filter: () => boolean; - label: string; - commandRef: React.RefObject; - // Ids - listId: string; - labelId: string; - inputId: string; -}; -type State = { - search: string; - value: string; - filtered: { count: number; items: Map; groups: Set }; -}; -type Store = { - subscribe: (callback: () => void) => () => void; - snapshot: () => State; - setState: ( - key: K, - value: State[K], - opts?: any, - ) => void; - emit: () => void; -}; -type Group = { - id: string; - forceMount?: boolean; -}; - -const LIST_SELECTOR = `[cmdk-list-sizer=""]`; -const GROUP_SELECTOR = `[cmdk-group=""]`; -const GROUP_ITEMS_SELECTOR = `[cmdk-group-items=""]`; -const GROUP_HEADING_SELECTOR = `[cmdk-group-heading=""]`; -const ITEM_SELECTOR = `[cmdk-item=""]`; -const VALID_ITEM_SELECTOR = `${ITEM_SELECTOR}:not([aria-disabled="true"])`; -const SELECT_EVENT = `cmdk-item-select`; -const VALUE_ATTR = `data-value`; -const defaultFilter: CommandProps["filter"] = (value, search) => - commandScore(value, search); - -// @ts-ignore -const CommandContext = React.createContext(undefined); -const useCommand = () => React.useContext(CommandContext); -// @ts-ignore -const StoreContext = React.createContext(undefined); -const useStore = () => React.useContext(StoreContext); -// @ts-ignore -const GroupContext = React.createContext(undefined); - -const Command = React.forwardRef( - (props, forwardedRef) => { - const ref = React.useRef(null); - const state = useLazyRef(() => ({ - /** Value of the search query. */ - search: "", - /** Currently selected item value. */ - value: props.value ?? props.defaultValue?.toLowerCase() ?? "", - filtered: { - /** The count of all visible items. */ - count: 0, - /** Map from visible item id to its search score. */ - items: new Map(), - /** Set of groups with at least one visible item. */ - groups: new Set(), - }, - })); - const allItems = useLazyRef>(() => new Set()); // [...itemIds] - const allGroups = useLazyRef>>(() => new Map()); // groupId → [...itemIds] - const ids = useLazyRef>(() => new Map()); // id → value - const listeners = useLazyRef void>>(() => new Set()); // [...rerenders] - const propsRef = useAsRef(props); - const { - label, - children, - value, - onValueChange, - filter, - shouldFilter, - vimBindings = true, - ...etc - } = props; - - const listId = React.useId(); - const labelId = React.useId(); - const inputId = React.useId(); - - const schedule = useScheduleLayoutEffect(); - - /** Controlled mode `value` handling. */ - useLayoutEffect(() => { - if (value !== undefined) { - const v = value.trim().toLowerCase(); - state.current.value = v; - schedule(6, scrollSelectedIntoView); - store.emit(); - } - }, [value]); - - const store: Store = React.useMemo(() => { - return { - subscribe: (cb) => { - listeners.current.add(cb); - return () => listeners.current.delete(cb); - }, - snapshot: () => { - return state.current; - }, - setState: (key, value, opts) => { - if (Object.is(state.current[key], value)) return; - state.current[key] = value; - - if (key === "search") { - // Filter synchronously before emitting back to children - filterItems(); - sort(); - schedule(1, selectFirstItem); - } else if (key === "value") { - if (propsRef.current?.value !== undefined) { - // If controlled, just call the callback instead of updating state internally - const newValue = (value ?? "") as string; - propsRef.current.onValueChange?.(newValue); - return; - // opts is a boolean referring to whether it should NOT be scrolled into view - } else if (!opts) { - // Scroll the selected item into view - schedule(5, scrollSelectedIntoView); - } - } - - // Notify subscribers that state has changed - store.emit(); - }, - emit: () => { - listeners.current.forEach((l) => l()); - }, - }; - }, []); - - const context: Context = React.useMemo( - () => ({ - // Keep id → value mapping up-to-date - value: (id, value) => { - if (value !== ids.current.get(id)) { - ids.current.set(id, value); - state.current.filtered.items.set(id, score(value)); - schedule(2, () => { - sort(); - store.emit(); - }); - } - }, - // Track item lifecycle (mount, unmount) - item: (id, groupId) => { - allItems.current.add(id); - - // Track this item within the group - if (groupId) { - if (!allGroups.current.has(groupId)) { - allGroups.current.set(groupId, new Set([id])); - } else { - allGroups.current.get(groupId).add(id); - } - } - - // Batch this, multiple items can mount in one pass - // and we should not be filtering/sorting/emitting each time - schedule(3, () => { - filterItems(); - sort(); - - // Could be initial mount, select the first item if none already selected - if (!state.current.value) { - selectFirstItem(); - } - - store.emit(); - }); - - return () => { - ids.current.delete(id); - allItems.current.delete(id); - state.current.filtered.items.delete(id); - const selectedItem = getSelectedItem(); - - // Batch this, multiple items could be removed in one pass - schedule(4, () => { - filterItems(); - - // The item removed have been the selected one, - // so selection should be moved to the first - if (selectedItem?.getAttribute("id") === id) selectFirstItem(); - - store.emit(); - }); - }; - }, - // Track group lifecycle (mount, unmount) - group: (id) => { - if (!allGroups.current.has(id)) { - allGroups.current.set(id, new Set()); - } - - return () => { - ids.current.delete(id); - allGroups.current.delete(id); - }; - }, - filter: () => { - return propsRef.current.shouldFilter; - }, - label: label || props["aria-label"], - commandRef: ref, - listId, - inputId, - labelId, - }), - [], - ); - - function score(value: string) { - const filter = propsRef.current?.filter ?? defaultFilter; - return value ? filter(value, state.current.search) : 0; - } - - /** Sorts items by score, and groups by highest item score. */ - function sort() { - if ( - !ref.current || - !state.current.search || - // Explicitly false, because true | undefined is the default - propsRef.current.shouldFilter === false - ) { - return; - } - - const scores = state.current.filtered.items; - - // Sort the groups - const groups: [string, number][] = []; - state.current.filtered.groups.forEach((value) => { - const items = allGroups.current.get(value); - - // Get the maximum score of the group's items - let max = 0; - items.forEach((item) => { - const score = scores.get(item); - max = Math.max(score, max); - }); - - groups.push([value, max]); - }); - - // Sort items within groups to bottom - // Sort items outside of groups - // Sort groups to bottom (pushes all non-grouped items to the top) - const list = ref.current.querySelector(LIST_SELECTOR); - - // Sort the items - getValidItems() - .sort((a, b) => { - const valueA = a.getAttribute(VALUE_ATTR); - const valueB = b.getAttribute(VALUE_ATTR); - return (scores.get(valueB) ?? 0) - (scores.get(valueA) ?? 0); - }) - .forEach((item) => { - const group = item.closest(GROUP_ITEMS_SELECTOR); - - if (group) { - group.appendChild( - item.parentElement === group - ? item - : item.closest(`${GROUP_ITEMS_SELECTOR} > *`), - ); - } else { - list.appendChild( - item.parentElement === list - ? item - : item.closest(`${GROUP_ITEMS_SELECTOR} > *`), - ); - } - }); - - groups - .sort((a, b) => b[1] - a[1]) - .forEach((group) => { - const element = ref.current.querySelector( - `${GROUP_SELECTOR}[${VALUE_ATTR}="${group[0]}"]`, - ); - element?.parentElement.appendChild(element); - }); - } - - function selectFirstItem() { - const item = getValidItems().find((item) => !item.ariaDisabled); - const value = item?.getAttribute(VALUE_ATTR); - store.setState("value", value || undefined); - } - - /** Filters the current items. */ - function filterItems() { - if ( - !state.current.search || - // Explicitly false, because true | undefined is the default - propsRef.current.shouldFilter === false - ) { - state.current.filtered.count = allItems.current.size; - // Do nothing, each item will know to show itself because search is empty - return; - } - - // Reset the groups - state.current.filtered.groups = new Set(); - let itemCount = 0; - - // Check which items should be included - for (const id of allItems.current) { - const value = ids.current.get(id); - const rank = score(value); - state.current.filtered.items.set(id, rank); - if (rank > 0) itemCount++; - } - - // Check which groups have at least 1 item shown - for (const [groupId, group] of allGroups.current) { - for (const itemId of group) { - if (state.current.filtered.items.get(itemId) > 0) { - state.current.filtered.groups.add(groupId); - break; - } - } - } - - state.current.filtered.count = itemCount; - } - - function scrollSelectedIntoView() { - const item = getSelectedItem(); - - if (item) { - if (item.parentElement?.firstChild === item) { - // First item in Group, ensure heading is in view - item - .closest(GROUP_SELECTOR) - ?.querySelector(GROUP_HEADING_SELECTOR) - ?.scrollIntoView({ block: "nearest" }); - } - - // Ensure the item is always in view - item.scrollIntoView({ block: "nearest" }); - } - } - - /** Getters */ - - function getSelectedItem() { - return ref.current?.querySelector( - `${ITEM_SELECTOR}[aria-selected="true"]`, - ); - } - - function getValidItems() { - return Array.from(ref.current.querySelectorAll(VALID_ITEM_SELECTOR)); - } - - /** Setters */ - - function updateSelectedToIndex(index: number) { - const items = getValidItems(); - const item = items[index]; - if (item) store.setState("value", item.getAttribute(VALUE_ATTR)); - } - - function updateSelectedByChange(change: 1 | -1) { - const selected = getSelectedItem(); - const items = getValidItems(); - const index = items.findIndex((item) => item === selected); - - // Get item at this index - let newSelected = items[index + change]; - - if (propsRef.current?.loop) { - newSelected = - index + change < 0 - ? items[items.length - 1] - : index + change === items.length - ? items[0] - : items[index + change]; - } - - if (newSelected) - store.setState("value", newSelected.getAttribute(VALUE_ATTR)); - } - - function updateSelectedToGroup(change: 1 | -1) { - const selected = getSelectedItem(); - let group = selected?.closest(GROUP_SELECTOR); - let item: HTMLElement; - - while (group && !item) { - group = - change > 0 - ? findNextSibling(group, GROUP_SELECTOR) - : findPreviousSibling(group, GROUP_SELECTOR); - item = group?.querySelector(VALID_ITEM_SELECTOR); - } - - if (item) { - store.setState("value", item.getAttribute(VALUE_ATTR)); - } else { - updateSelectedByChange(change); - } - } - - const last = () => updateSelectedToIndex(getValidItems().length - 1); - - const next = (e: React.KeyboardEvent) => { - e.preventDefault(); - - if (e.metaKey) { - // Last item - last(); - } else if (e.altKey) { - // Next group - updateSelectedToGroup(1); - } else { - // Next item - updateSelectedByChange(1); - } - }; - - const prev = (e: React.KeyboardEvent) => { - e.preventDefault(); - - if (e.metaKey) { - // First item - updateSelectedToIndex(0); - } else if (e.altKey) { - // Previous group - updateSelectedToGroup(-1); - } else { - // Previous item - updateSelectedByChange(-1); - } - }; - - return ( -
{ - etc.onKeyDown?.(e); - - if (!e.defaultPrevented) { - switch (e.key) { - case "n": - case "j": { - // vim keybind down - if (vimBindings && e.ctrlKey) { - next(e); - } - break; - } - case "ArrowDown": { - next(e); - break; - } - case "p": - case "k": { - // vim keybind up - if (vimBindings && e.ctrlKey) { - prev(e); - } - break; - } - case "ArrowUp": { - prev(e); - break; - } - case "Home": { - // First item - e.preventDefault(); - updateSelectedToIndex(0); - break; - } - case "End": { - // Last item - e.preventDefault(); - last(); - break; - } - case "Enter": { - // Check if IME composition is finished before triggering onSelect - // This prevents unwanted triggering while user is still inputting text with IME - if (!e.nativeEvent.isComposing) { - // Trigger item onSelect - e.preventDefault(); - const item = getSelectedItem(); - if (item) { - const event = new Event(SELECT_EVENT); - item.dispatchEvent(event); - } - } - } - } - } - }} - > - - - - {children} - - -
- ); - }, -); - -/** - * Command menu item. Becomes active on pointer enter or through keyboard navigation. - * Preferably pass a `value`, otherwise the value will be inferred from `children` or - * the rendered item's `textContent`. - */ -const Item = React.forwardRef( - (props, forwardedRef) => { - const id = React.useId(); - const ref = React.useRef(null); - const groupContext = React.useContext(GroupContext); - const context = useCommand(); - const propsRef = useAsRef(props); - const forceMount = propsRef.current?.forceMount ?? groupContext?.forceMount; - - useLayoutEffect(() => { - return context.item(id, groupContext?.id); - }, []); - - const value = useValue(id, ref, [props.value, props.children, ref]); - - const store = useStore(); - const selected = useCmdk( - (state) => state.value && state.value === value.current, - ); - const render = useCmdk((state) => - forceMount - ? true - : context.filter() === false - ? true - : !state.search - ? true - : state.filtered.items.get(id) > 0, - ); - - React.useEffect(() => { - const element = ref.current; - if (!element || props.disabled) return; - element.addEventListener(SELECT_EVENT, onSelect); - return () => element.removeEventListener(SELECT_EVENT, onSelect); - }, [render, props.onSelect, props.disabled]); - - function onSelect() { - select(); - propsRef.current.onSelect?.(value.current); - } - - function select() { - store.setState("value", value.current, true); - } - - if (!render) return null; - - const { disabled, value: _, onSelect: __, ...etc } = props; - - return ( -
- {props.children} -
- ); - }, -); - -/** - * Group command menu items together with a heading. - * Grouped items are always shown together. - */ -const Group = React.forwardRef( - (props, forwardedRef) => { - const { heading, children, forceMount, ...etc } = props; - const id = React.useId(); - const ref = React.useRef(null); - const headingRef = React.useRef(null); - const headingId = React.useId(); - const context = useCommand(); - const render = useCmdk((state) => - forceMount - ? true - : context.filter() === false - ? true - : !state.search - ? true - : state.filtered.groups.has(id), - ); - - useLayoutEffect(() => { - return context.group(id); - }, []); - - useValue(id, ref, [props.value, props.heading, headingRef]); - - const contextValue = React.useMemo( - () => ({ id, forceMount }), - [forceMount], - ); - const inner = ( - - {children} - - ); - - return ( - - ); - }, -); - -/** - * A visual and semantic separator between items or groups. - * Visible when the search query is empty or `alwaysRender` is true, hidden otherwise. - */ -const Separator = React.forwardRef( - (props, forwardedRef) => { - const { alwaysRender, ...etc } = props; - const ref = React.useRef(null); - const render = useCmdk((state) => !state.search); - - if (!alwaysRender && !render) return null; - return ( -
- ); - }, -); - -/** - * Command menu input. - * All props are forwarded to the underyling `input` element. - */ -const Input = React.forwardRef( - (props, forwardedRef) => { - const { onValueChange, ...etc } = props; - const isControlled = props.value != null; - const store = useStore(); - const search = useCmdk((state) => state.search); - const value = useCmdk((state) => state.value); - const context = useCommand(); - - const selectedItemId = React.useMemo(() => { - const item = context.commandRef.current?.querySelector( - `${ITEM_SELECTOR}[${VALUE_ATTR}="${value}"]`, - ); - return item?.getAttribute("id"); - }, [value, context.commandRef]); - - React.useEffect(() => { - if (props.value != null) { - store.setState("search", props.value); - } - }, [props.value]); - - return ( - { - if (!isControlled) { - store.setState("search", e.target.value); - } - - onValueChange?.(e.target.value); - }} - /> - ); - }, -); - -/** - * Contains `Item`, `Group`, and `Separator`. - * Use the `--cmdk-list-height` CSS variable to animate height based on the number of results. - */ -const List = React.forwardRef( - (props, forwardedRef) => { - const { children, ...etc } = props; - const ref = React.useRef(null); - const height = React.useRef(null); - const context = useCommand(); - - React.useEffect(() => { - if (height.current && ref.current) { - const el = height.current; - const wrapper = ref.current; - let animationFrame; - const observer = new ResizeObserver(() => { - animationFrame = requestAnimationFrame(() => { - const height = el.offsetHeight; - wrapper.style.setProperty( - `--cmdk-list-height`, - height.toFixed(1) + "px", - ); - }); - }); - observer.observe(el); - return () => { - cancelAnimationFrame(animationFrame); - observer.unobserve(el); - }; - } - }, []); - - return ( -
-
- {children} -
-
- ); - }, -); - -/** - * Renders the command menu in a Radix Dialog. - */ -const Dialog = React.forwardRef( - (props, forwardedRef) => { - const { - open, - onOpenChange, - overlayClassName, - contentClassName, - container, - ...etc - } = props; - return ( - - - - - - - - - ); - }, -); - -/** - * Automatically renders when there are no results for the search query. - */ -const Empty = React.forwardRef( - (props, forwardedRef) => { - const isFirstRender = React.useRef(true); - const render = useCmdk((state) => state.filtered.count === 0); - - React.useEffect(() => { - isFirstRender.current = false; - }, []); - - if (isFirstRender.current || !render) return null; - return ( -
- ); - }, -); - -/** - * You should conditionally render this with `progress` while loading asynchronous items. - */ -const Loading = React.forwardRef( - (props, forwardedRef) => { - const { progress, children, ...etc } = props; - - return ( -
-
{children}
-
- ); - }, -); - -const pkg = Object.assign(Command, { - List, - Item, - Input, - Group, - Separator, - Dialog, - Empty, - Loading, -}); - -export { useCmdk as useCommandState }; -export { pkg as Command }; - -export { Command as CommandRoot }; -export { List as CommandList }; -export { Item as CommandItem }; -export { Input as CommandInput }; -export { Group as CommandGroup }; -export { Separator as CommandSeparator }; -export { Dialog as CommandDialog }; -export { Empty as CommandEmpty }; -export { Loading as CommandLoading }; - -/** - * - * - * Helpers - * - * - */ - -function findNextSibling(el: Element, selector: string) { - let sibling = el.nextElementSibling; - - while (sibling) { - if (sibling.matches(selector)) return sibling; - sibling = sibling.nextElementSibling; - } -} - -function findPreviousSibling(el: Element, selector: string) { - let sibling = el.previousElementSibling; - - while (sibling) { - if (sibling.matches(selector)) return sibling; - sibling = sibling.previousElementSibling; - } -} - -function useAsRef(data: T) { - const ref = React.useRef(data); - - useLayoutEffect(() => { - ref.current = data; - }); - - return ref; -} - -const useLayoutEffect = - typeof window === "undefined" ? React.useEffect : React.useLayoutEffect; - -function useLazyRef(fn: () => T) { - const ref = React.useRef(); - - if (ref.current === undefined) { - ref.current = fn(); - } - - return ref as React.MutableRefObject; -} - -// ESM is still a nightmare with Next.js so I'm just gonna copy the package code in -// https://github.com/gregberge/react-merge-refs -// Copyright (c) 2020 Greg Bergé -function mergeRefs( - refs: Array | React.LegacyRef>, -): React.RefCallback { - return (value) => { - refs.forEach((ref) => { - if (typeof ref === "function") { - ref(value); - } else if (ref != null) { - (ref as React.MutableRefObject).current = value; - } - }); - }; -} - -/** Run a selector against the store state. */ -function useCmdk(selector: (state: State) => T) { - const store = useStore(); - const cb = () => selector(store.snapshot()); - return React.useSyncExternalStore(store.subscribe, cb, cb); -} - -function useValue( - id: string, - ref: React.RefObject, - deps: (string | React.ReactNode | React.RefObject)[], -) { - const valueRef = React.useRef(); - const context = useCommand(); - - useLayoutEffect(() => { - const value = (() => { - for (const part of deps) { - if (typeof part === "string") { - return part.trim().toLowerCase(); - } - - if (typeof part === "object" && "current" in part) { - if (part.current) { - return part.current.textContent?.trim().toLowerCase(); - } - return valueRef.current; - } - } - })(); - - context.value(id, value); - ref.current?.setAttribute(VALUE_ATTR, value); - valueRef.current = value; - }); - - return valueRef; -} - -/** Imperatively run a function on the next layout effect cycle. */ -const useScheduleLayoutEffect = () => { - const [s, ss] = React.useState(); - const fns = useLazyRef(() => new Map void>()); - - useLayoutEffect(() => { - fns.current.forEach((f) => f()); - fns.current = new Map(); - }, [s]); - - return (id: string | number, cb: () => void) => { - fns.current.set(id, cb); - ss({}); - }; -}; - -const srOnlyStyles = { - position: "absolute", - width: "1px", - height: "1px", - padding: "0", - margin: "-1px", - overflow: "hidden", - clip: "rect(0, 0, 0, 0)", - whiteSpace: "nowrap", - borderWidth: "0", -} as const; diff --git a/packages/ui/src/column/content.tsx b/packages/ui/src/column/content.tsx new file mode 100644 index 00000000..3c079945 --- /dev/null +++ b/packages/ui/src/column/content.tsx @@ -0,0 +1,16 @@ +import { cn } from "@lume/utils"; +import { ReactNode } from "react"; + +export function ColumnContent({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/ui/src/column/header.tsx b/packages/ui/src/column/header.tsx new file mode 100644 index 00000000..bed1900d --- /dev/null +++ b/packages/ui/src/column/header.tsx @@ -0,0 +1,82 @@ +import { + ChevronDownIcon, + MoveLeftIcon, + MoveRightIcon, + RefreshIcon, + TrashIcon, +} from "@lume/icons"; +import { cn } from "@lume/utils"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { useTranslation } from "react-i18next"; + +export function ColumnHeader({ + title, + className, +}: { + title: string; + className?: string; +}) { + const { t } = useTranslation(); + + return ( + +
+ +
+
{title}
+ +
+
+ + + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/packages/ui/src/column/index.ts b/packages/ui/src/column/index.ts new file mode 100644 index 00000000..abd5b32a --- /dev/null +++ b/packages/ui/src/column/index.ts @@ -0,0 +1,9 @@ +import { ColumnContent } from "./content"; +import { ColumnHeader } from "./header"; +import { ColumnRoot } from "./root"; + +export const Column = { + Root: ColumnRoot, + Header: ColumnHeader, + Content: ColumnContent, +}; diff --git a/packages/ui/src/column/root.tsx b/packages/ui/src/column/root.tsx new file mode 100644 index 00000000..9371a481 --- /dev/null +++ b/packages/ui/src/column/root.tsx @@ -0,0 +1,21 @@ +import { cn } from "@lume/utils"; +import { ReactNode } from "react"; + +export function ColumnRoot({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 8ab1ac2d..566e7dcb 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,5 +1,6 @@ export * from "./user"; export * from "./note"; +export * from "./column"; // UI export * from "./container"; diff --git a/packages/ui/src/note/buttons/zap.tsx b/packages/ui/src/note/buttons/zap.tsx index c01dfe82..2e5bf03e 100644 --- a/packages/ui/src/note/buttons/zap.tsx +++ b/packages/ui/src/note/buttons/zap.tsx @@ -2,18 +2,21 @@ import { useArk } from "@lume/ark"; import { ZapIcon } from "@lume/icons"; import { toast } from "sonner"; import { useNoteContext } from "../provider"; +import { useSearch } from "@tanstack/react-router"; export function NoteZap() { const ark = useArk(); const event = useNoteContext(); + const { account } = useSearch({ strict: false }); + const zap = async () => { try { const nwc = await ark.load_nwc(); if (!nwc) { ark.open_nwc(); } else { - ark.open_zap(event.id, event.pubkey); + ark.open_zap(event.id, event.pubkey, account); } } catch (e) { toast.error(String(e)); diff --git a/packages/ui/src/search/dialog.tsx b/packages/ui/src/search/dialog.tsx deleted file mode 100644 index 88ee251c..00000000 --- a/packages/ui/src/search/dialog.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { Note, User, useArk } from "@lume/ark"; -import { LoaderIcon, SearchIcon } from "@lume/icons"; -import { COL_TYPES, searchAtom } from "@lume/utils"; -import { type NDKEvent, NDKKind } from "@nostr-dev-kit/ndk"; -import { useAtom } from "jotai"; -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useDebounce } from "use-debounce"; -import { Command } from "../cmdk"; - -export function SearchDialog() { - const ark = useArk(); - - const [open, setOpen] = useAtom(searchAtom); - const [loading, setLoading] = useState(false); - const [events, setEvents] = useState([]); - const [search, setSearch] = useState(""); - const [value] = useDebounce(search, 1200); - - const { t } = useTranslation(); - - const searchEvents = async () => { - if (!value.length) return; - - // start loading - setLoading(true); - - // search events, require nostr.band relay - const events = await ark.getEvents({ - kinds: [NDKKind.Text, NDKKind.Metadata], - search: value, - limit: 20, - }); - - // update state - setLoading(false); - setEvents(events); - }; - - const selectEvent = (kind: NDKKind, value: string) => { - if (!value.length) return; - - if (kind === NDKKind.Metadata) { - // add new column - addColumn({ - kind: COL_TYPES.user, - title: "User", - content: value, - }); - } else { - // add new column - addColumn({ - kind: COL_TYPES.thread, - title: "", - content: value, - }); - } - - // update state - setOpen(false); - vlistRef?.current.scrollToIndex(columns.length); - }; - - useEffect(() => { - searchEvents(); - }, [value]); - - // Toggle the menu when ⌘K is pressed - useEffect(() => { - const down = (e) => { - if (e.key === "k" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - setOpen((open) => !open); - } - }; - - document.addEventListener("keydown", down); - return () => document.removeEventListener("keydown", down); - }, []); - - return ( - -
- -
- - {loading ? ( - - - - ) : !events.length ? ( - - {t("global.noResult")} - - ) : ( - <> - - {events - .filter((ev) => ev.kind === NDKKind.Metadata) - .map((event) => ( - selectEvent(event.kind, value)} - className="py-3 px-3 bg-neutral-50 dark:bg-neutral-950 rounded-xl my-3 focus:ring-1 focus:ring-blue-500" - > - - -
- -
- - -
-
- -
-
-
- ))} -
- - {events - .filter((ev) => ev.kind === NDKKind.Text) - .map((event) => ( - selectEvent(event.kind, value)} - className="py-3 px-3 bg-neutral-50 dark:bg-neutral-950 rounded-xl my-3" - > - - - -
- {event.content} -
-
-
-
- ))} -
- - )} - {!loading && !events.length ? ( -
-
- -
- {t("search.empty")} -
- ) : null} -
-
- ); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06380c33..4166d7cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -248,6 +248,9 @@ importers: '@tanstack/react-query': specifier: ^5.24.1 version: 5.24.1(react@18.2.0) + '@tanstack/react-router': + specifier: ^1.18.1 + version: 1.18.1(react-dom@18.2.0)(react@18.2.0) get-urls: specifier: ^12.1.0 version: 12.1.0 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 335fd1aa..c36eadb5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,6 +16,7 @@ tokio = { version = "1", features = ["full"] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } tauri = { version = "2.0.0-beta", features = [ + "unstable", "tray-icon", "macos-private-api", "native-tls-vendored", diff --git a/src-tauri/src/nostr/metadata.rs b/src-tauri/src/nostr/metadata.rs index 0007e514..4f67a3da 100644 --- a/src-tauri/src/nostr/metadata.rs +++ b/src-tauri/src/nostr/metadata.rs @@ -294,7 +294,7 @@ pub async fn set_nwc(uri: &str, state: State<'_, Nostr>) -> Result } #[tauri::command] -pub async fn load_nwc(state: State<'_, Nostr>) -> Result { +pub async fn load_nwc(state: State<'_, Nostr>) -> Result { let client = &state.client; let keyring = Entry::new("Lume Secret Storage", "NWC").unwrap(); @@ -305,10 +305,10 @@ pub async fn load_nwc(state: State<'_, Nostr>) -> Result { client.set_zapper(nwc).await; Ok(true) } else { - Err(false) + Err("Cannot connect to NWC".into()) } } - Err(_) => Err(false), + Err(_) => Ok(false), } } diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json index 0712704c..31f87328 100644 --- a/src-tauri/tauri.linux.conf.json +++ b/src-tauri/tauri.linux.conf.json @@ -6,11 +6,10 @@ "title": "Lume", "label": "main", "titleBarStyle": "Overlay", - "width": 1080, + "width": 500, "height": 800, - "minWidth": 1080, - "minHeight": 800, - "center": true + "minWidth": 500, + "minHeight": 800 } ] } diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 7f0b505b..81b4f0a2 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -6,13 +6,16 @@ "title": "Lume", "label": "main", "titleBarStyle": "Overlay", - "width": 1080, + "width": 500, "height": 800, - "minWidth": 1080, + "minWidth": 500, "minHeight": 800, - "center": true, "hiddenTitle": true, - "decorations": true + "decorations": true, + "transparent": true, + "windowEffects": { + "effects": ["windowBackground"] + } } ] } diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index 4afd7720..1f49693f 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -5,11 +5,10 @@ { "title": "Lume", "label": "main", - "width": 1080, + "width": 500, "height": 800, - "minWidth": 1080, - "minHeight": 800, - "center": true + "minWidth": 500, + "minHeight": 800 } ] }