feat: column manager

This commit is contained in:
reya 2024-03-18 10:50:08 +07:00
parent c8e014f33e
commit 05b52564e0
17 changed files with 269 additions and 87 deletions

View File

@ -32,6 +32,7 @@
"slate": "^0.102.0", "slate": "^0.102.0",
"slate-react": "^0.102.0", "slate-react": "^0.102.0",
"sonner": "^1.4.3", "sonner": "^1.4.3",
"use-debounce": "^10.0.0",
"virtua": "^0.29.0" "virtua": "^0.29.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -9,20 +9,23 @@ import { LumeColumn } from "@lume/types";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { type UnlistenFn } from "@tauri-apps/api/event"; import { type UnlistenFn } from "@tauri-apps/api/event";
export function Column({ export function Col({
column, column,
account,
isScroll, isScroll,
}: { }: {
column: LumeColumn; column: LumeColumn;
account: string;
isScroll: boolean; isScroll: boolean;
}) { }) {
const mainWindow = useMemo(() => getCurrent(), []); const mainWindow = useMemo(() => getCurrent(), []);
const childWindow = useRef<Webview>(null); const childWindow = useRef<Webview>(null);
const divRef = useRef<HTMLDivElement>(null); const container = useRef<HTMLDivElement>(null);
const initialRect = useRef<DOMRect>(null); const initialRect = useRef<DOMRect>(null);
const unlisten = useRef<UnlistenFn>(null); const unlisten = useRef<UnlistenFn>(null);
const handleResize = useDebouncedCallback(() => { const handleResize = useDebouncedCallback(() => {
const newRect = divRef.current.getBoundingClientRect(); if (!childWindow.current) return;
const newRect = container.current.getBoundingClientRect();
if (initialRect.current.height !== newRect.height) { if (initialRect.current.height !== newRect.height) {
childWindow.current.setSize( childWindow.current.setSize(
new LogicalSize(newRect.width, newRect.height), new LogicalSize(newRect.width, newRect.height),
@ -37,8 +40,9 @@ export function Column({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!childWindow.current) return;
if (isScroll) { if (isScroll) {
const newRect = divRef.current.getBoundingClientRect(); const newRect = container.current.getBoundingClientRect();
childWindow.current.setPosition( childWindow.current.setPosition(
new LogicalPosition(newRect.x, newRect.y), new LogicalPosition(newRect.x, newRect.y),
); );
@ -47,16 +51,17 @@ export function Column({
useEffect(() => { useEffect(() => {
if (!mainWindow) return; if (!mainWindow) return;
if (!divRef.current) return; if (!container.current) return;
if (childWindow.current) return; if (childWindow.current) return;
const rect = divRef.current.getBoundingClientRect(); const rect = container.current.getBoundingClientRect();
const name = column.name.toLowerCase().replace(/\W/g, ""); const name = `column-${column.name.toLowerCase().replace(/\W/g, "")}`;
const url = column.name + `?account=${account}&name=${column.name}`;
// create new webview // create new webview
initialRect.current = rect; initialRect.current = rect;
childWindow.current = new Webview(mainWindow, name, { childWindow.current = new Webview(mainWindow, name, {
url: column.content, url,
x: rect.x, x: rect.x,
y: rect.y, y: rect.y,
width: rect.width, width: rect.width,
@ -70,18 +75,9 @@ export function Column({
return () => { return () => {
if (unlisten.current) unlisten.current(); if (unlisten.current) unlisten.current();
if (childWindow.current) childWindow.current.close();
}; };
}, []); }, []);
return ( return <div ref={container} className="h-full w-[440px] shrink-0 p-2" />;
<div className="shadow-primary relative flex h-full w-[420px] shrink-0 flex-col rounded-xl bg-white dark:bg-black">
<div className="flex h-11 w-full shrink-0 items-center justify-center gap-2 border-b border-neutral-100 dark:border-neutral-900">
<div className="inline-flex items-center gap-1.5">
<div className="text-[13px] font-medium">{column.name}</div>
</div>
</div>
<div ref={divRef} className="flex-1" />
<div className="h-6 w-full shrink-0 border-t border-neutral-100 dark:border-neutral-900" />
</div>
);
} }

View File

@ -0,0 +1,39 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@lume/icons";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
export function Toolbar({
moveLeft,
moveRight,
}: {
moveLeft: () => void;
moveRight: () => void;
}) {
const [domReady, setDomReady] = useState(false);
useEffect(() => {
setDomReady(true);
}, []);
return domReady
? createPortal(
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => moveLeft()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-neutral-200 dark:text-neutral-200 dark:hover:bg-neutral-800"
>
<ArrowLeftIcon className="size-5" />
</button>
<button
type="button"
onClick={() => moveRight()}
className="inline-flex size-8 items-center justify-center rounded-full text-neutral-800 hover:bg-neutral-200 dark:text-neutral-200 dark:hover:bg-neutral-800"
>
<ArrowRightIcon className="size-5" />
</button>
</div>,
document.getElementById("toolbar"),
)
: null;
}

View File

@ -1,17 +1,17 @@
import { Col } from "@/components/col";
import { Toolbar } from "@/components/toolbar";
import { LoaderIcon } from "@lume/icons"; import { LoaderIcon } from "@lume/icons";
import { Column } from "@lume/ui";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react"; import { useRef, useState } from "react";
import { VList, VListHandle } from "virtua";
export const Route = createFileRoute("/$account/home")({ export const Route = createFileRoute("/$account/home")({
component: Screen, component: Screen,
pendingComponent: Pending, pendingComponent: Pending,
loader: async () => { loader: async () => {
const columns = [ const columns = [
{ name: "Tauri v2", content: "https://beta.tauri.app" }, { name: "Newsfeed", content: "/columns/newsfeed" },
{ name: "Tauri v1", content: "https://tauri.app" }, { name: "Default", content: "/columns/default" },
{ name: "Lume", content: "https://lume.nu" },
{ name: "Snort", content: "https://snort.social" },
]; ];
return columns; return columns;
}, },
@ -19,22 +19,71 @@ export const Route = createFileRoute("/$account/home")({
function Screen() { function Screen() {
const data = Route.useLoaderData(); const data = Route.useLoaderData();
const search = Route.useSearch();
const vlistRef = useRef<VListHandle>(null);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isScroll, setIsScroll] = useState(false); const [isScroll, setIsScroll] = useState(false);
const moveLeft = () => {
const prevIndex = Math.max(selectedIndex - 1, 0);
setSelectedIndex(prevIndex);
vlistRef.current.scrollToIndex(prevIndex, {
align: "start",
});
};
const moveRight = () => {
const nextIndex = Math.min(selectedIndex + 1, data.length - 1);
setSelectedIndex(nextIndex);
vlistRef.current.scrollToIndex(nextIndex, {
align: "end",
});
};
return ( return (
<div className="relative h-full w-full"> <div className="h-full w-full">
<div <VList
onScroll={() => setIsScroll((state) => !state)} ref={vlistRef}
className="flex h-full w-full flex-nowrap gap-3 overflow-x-auto px-3 pb-3 pt-1.5 focus:outline-none" horizontal
itemSize={440}
tabIndex={0}
onKeyDown={(e) => {
if (!vlistRef.current) return;
switch (e.code) {
case "ArrowUp":
case "ArrowLeft": {
e.preventDefault();
moveLeft();
break;
}
case "ArrowDown":
case "ArrowRight": {
e.preventDefault();
moveRight();
break;
}
}
}}
onScroll={() => {
setIsScroll(true);
}}
onScrollEnd={() => {
setIsScroll(false);
}}
className="scrollbar-none h-full w-full overflow-x-auto focus:outline-none"
> >
{data.map((column, index) => ( {data.map((column, index) => (
<Column <Col
key={column.name + index} key={column.name + index}
column={column} column={column}
// @ts-ignore, yolo !!!
account={search.acccount}
isScroll={isScroll} isScroll={isScroll}
/> />
))} ))}
</div> </VList>
<Toolbar moveLeft={moveLeft} moveRight={moveRight} />
</div> </div>
); );
} }

View File

@ -1,4 +1,9 @@
import { ComposeFilledIcon, PlusIcon } from "@lume/icons"; import {
ArrowLeftIcon,
ArrowRightIcon,
ComposeFilledIcon,
PlusIcon,
} from "@lume/icons";
import { Outlet, createFileRoute } from "@tanstack/react-router"; import { Outlet, createFileRoute } from "@tanstack/react-router";
import { cn } from "@lume/utils"; import { cn } from "@lume/utils";
import { Accounts } from "@/components/accounts"; import { Accounts } from "@/components/accounts";
@ -10,7 +15,7 @@ export const Route = createFileRoute("/$account")({
function App() { function App() {
const ark = useArk(); const ark = useArk();
const context = Route.useRouteContext(); const { platform } = Route.useRouteContext();
return ( return (
<div className="flex h-screen w-screen flex-col"> <div className="flex h-screen w-screen flex-col">
@ -18,7 +23,7 @@ function App() {
data-tauri-drag-region data-tauri-drag-region
className={cn( className={cn(
"flex h-11 shrink-0 items-center justify-between pr-2", "flex h-11 shrink-0 items-center justify-between pr-2",
context.platform === "macos" ? "ml-2 pl-20" : "pl-4", platform === "macos" ? "ml-2 pl-20" : "pl-4",
)} )}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -40,6 +45,7 @@ function App() {
<ComposeFilledIcon className="size-4" /> <ComposeFilledIcon className="size-4" />
New post New post
</button> </button>
<div id="toolbar" />
</div> </div>
</div> </div>
<div className="flex-1"> <div className="flex-1">

View File

@ -0,0 +1,16 @@
import { Column } from "@lume/ui";
import { createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/default")({
component: Screen,
});
function Screen() {
return (
<Column.Root>
<Column.Content className="flex flex-col items-center justify-center">
<p>TODO</p>
</Column.Content>
</Column.Root>
);
}

View File

@ -1,19 +1,22 @@
import { RepostNote } from "@/components/repost"; import { RepostNote } from "@/components/repost";
import { Suggest } from "@/components/suggest"; import { Suggest } from "@/components/suggest";
import { TextNote } from "@/components/text"; import { TextNote } from "@/components/text";
import { useArk } from "@lume/ark"; import { useEvents } from "@lume/ark";
import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons"; import { LoaderIcon, ArrowRightCircleIcon, InfoIcon } from "@lume/icons";
import { Event, Kind } from "@lume/types"; import { Event, Kind } from "@lume/types";
import { Column } from "@lume/ui"; import { Column } from "@lume/ui";
import { FETCH_LIMIT } from "@lume/utils"; import { createLazyFileRoute } from "@tanstack/react-router";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next";
import { Link, useParams } from "@tanstack/react-router";
import { Virtualizer } from "virtua"; import { Virtualizer } from "virtua";
export function Newsfeed() { export const Route = createLazyFileRoute("/newsfeed")({
const ark = useArk(); component: Screen,
});
export function Screen() {
// @ts-ignore, just work!!! // @ts-ignore, just work!!!
const { account } = useParams({ strict: false }); const { name, account } = Route.useSearch();
const { t } = useTranslation();
const { const {
data, data,
hasNextPage, hasNextPage,
@ -21,26 +24,7 @@ export function Newsfeed() {
isRefetching, isRefetching,
isFetchingNextPage, isFetchingNextPage,
fetchNextPage, fetchNextPage,
} = useInfiniteQuery({ } = useEvents("local", account);
queryKey: ["local_newsfeed", account],
initialPageParam: 0,
queryFn: async ({ pageParam }: { pageParam: number }) => {
const events = await ark.get_events(
"local",
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) => { const renderItem = (event: Event) => {
if (!event) return; if (!event) return;
@ -54,7 +38,7 @@ export function Newsfeed() {
return ( return (
<Column.Root> <Column.Root>
<Column.Header title="Newsfeed" /> <Column.Header name={name} />
<Column.Content> <Column.Content>
{isLoading || isRefetching ? ( {isLoading || isRefetching ? (
<div className="flex h-20 w-full flex-col items-center justify-center gap-1"> <div className="flex h-20 w-full flex-col items-center justify-center gap-1">
@ -64,15 +48,10 @@ export function Newsfeed() {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950"> <div className="flex items-center gap-2 rounded-xl bg-neutral-50 p-5 dark:bg-neutral-950">
<InfoIcon className="size-6" /> <InfoIcon className="size-6" />
<p> <div>
Empty newsfeed. Or you view the{" "} <p className="leading-tight">{t("emptyFeedTitle")}</p>
<Link <p className="leading-tight">{t("emptyFeedSubtitle")}</p>
to="/$account/home" </div>
className="text-blue-500 hover:text-blue-600"
>
Global Newsfeed
</Link>
</p>
</div> </div>
<Suggest /> <Suggest />
</div> </div>

View File

@ -2,4 +2,5 @@ export * from "./ark";
export * from "./context"; export * from "./context";
export * from "./hooks/useArk"; export * from "./hooks/useArk";
export * from "./hooks/useEvent"; export * from "./hooks/useEvent";
export * from "./hooks/useEvents";
export * from "./hooks/useProfile"; export * from "./hooks/useProfile";

View File

@ -1,18 +1,13 @@
export function ArrowLeftIcon(props: JSX.IntrinsicElements['svg']) { export function ArrowLeftIcon(props: JSX.IntrinsicElements["svg"]) {
return ( return (
<svg <svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
{...props} <path
xmlns="http://www.w3.org/2000/svg" stroke="currentColor"
viewBox="0 0 24 24" strokeLinecap="round"
width="24" strokeLinejoin="round"
height="24" strokeWidth="2"
fill="none" d="m10 6-6 6 6 6m-5-6h15"
stroke="currentColor" />
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M8.83 6a30.23 30.23 0 0 0-5.62 5.406A.949.949 0 0 0 3 12m5.83 6a30.233 30.233 0 0 1-5.62-5.406A.949.949 0 0 1 3 12m0 0h18" />
</svg> </svg>
); );
} }

View File

@ -0,0 +1,16 @@
import { cn } from "@lume/utils";
import { ReactNode } from "react";
export function ColumnContent({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={cn("flex-1 overflow-y-auto overflow-x-hidden", className)}>
{children}
</div>
);
}

View File

@ -0,0 +1,22 @@
import { cn } from "@lume/utils";
export function ColumnHeader({
name,
className,
}: {
name: string;
className?: string;
}) {
return (
<div
className={cn(
"flex h-11 w-full shrink-0 items-center justify-center gap-2 border-b border-neutral-100 dark:border-neutral-900",
className,
)}
>
<div className="inline-flex items-center gap-1.5">
<div className="text-[13px] font-medium">{name}</div>
</div>
</div>
);
}

View File

@ -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,
};

View File

@ -0,0 +1,23 @@
import { cn } from "@lume/utils";
import { ReactNode } from "react";
export function ColumnRoot({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className="h-full w-full p-2">
<div
className={cn(
"shadow-primary relative flex h-full w-full flex-col rounded-xl bg-white dark:bg-black",
className,
)}
>
{children}
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
export * from "./user"; export * from "./user";
export * from "./note"; export * from "./note";
export * from "./column";
// UI // UI
export * from "./column";
export * from "./container"; export * from "./container";
export * from "./box"; export * from "./box";

View File

@ -129,6 +129,9 @@ importers:
sonner: sonner:
specifier: ^1.4.3 specifier: ^1.4.3
version: 1.4.3(react-dom@18.2.0)(react@18.2.0) version: 1.4.3(react-dom@18.2.0)(react@18.2.0)
use-debounce:
specifier: ^10.0.0
version: 10.0.0(react@18.2.0)
virtua: virtua:
specifier: ^0.29.0 specifier: ^0.29.0
version: 0.29.0(react-dom@18.2.0)(react@18.2.0) version: 0.29.0(react-dom@18.2.0)(react@18.2.0)
@ -310,6 +313,31 @@ importers:
specifier: ^5.4.2 specifier: ^5.4.2
version: 5.4.2 version: 5.4.2
packages/column-newsfeed:
dependencies:
react:
specifier: ^18.2.0
version: 18.2.0
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
devDependencies:
'@types/react':
specifier: ^18.2.64
version: 18.2.66
'@types/react-dom':
specifier: ^18.2.21
version: 18.2.22
'@vitejs/plugin-react-swc':
specifier: ^3.5.0
version: 3.6.0(vite@5.1.6)
typescript:
specifier: ^5.2.2
version: 5.4.2
vite:
specifier: ^5.1.6
version: 5.1.6
packages/icons: packages/icons:
dependencies: dependencies:
react: react:

View File

@ -31,6 +31,7 @@
"updater:default", "updater:default",
"window:allow-start-dragging", "window:allow-start-dragging",
"window:allow-create", "window:allow-create",
"window:allow-close",
"store:allow-get", "store:allow-get",
"clipboard-manager:allow-write", "clipboard-manager:allow-write",
"clipboard-manager:allow-read", "clipboard-manager:allow-read",
@ -38,6 +39,7 @@
"webview:allow-create-webview", "webview:allow-create-webview",
"webview:allow-set-webview-size", "webview:allow-set-webview-size",
"webview:allow-set-webview-position", "webview:allow-set-webview-position",
"webview:allow-webview-close",
"dialog:allow-open", "dialog:allow-open",
"fs:allow-read-file", "fs:allow-read-file",
"shell:allow-open", "shell:allow-open",

View File

@ -1 +1 @@
{"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","local":true,"windows":["main","splash","editor","settings","nwc","zap-*","event-*","user-*","column-*"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","updater:allow-check","updater:default","window:allow-start-dragging","window:allow-create","store:allow-get","clipboard-manager:allow-write","clipboard-manager:allow-read","webview:allow-create-webview-window","webview:allow-create-webview","webview:allow-set-webview-size","webview:allow-set-webview-position","dialog:allow-open","fs:allow-read-file","shell:allow-open",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"}]}],"platforms":["linux","macOS","windows"]}} {"desktop-capability":{"identifier":"desktop-capability","description":"Capability for the desktop","local":true,"windows":["main","splash","editor","settings","nwc","zap-*","event-*","user-*","column-*"],"permissions":["path:default","event:default","window:default","app:default","resources:default","menu:default","tray:default","notification:allow-is-permission-granted","notification:allow-request-permission","notification:default","os:allow-locale","os:allow-platform","updater:allow-check","updater:default","window:allow-start-dragging","window:allow-create","window:allow-close","store:allow-get","clipboard-manager:allow-write","clipboard-manager:allow-read","webview:allow-create-webview-window","webview:allow-create-webview","webview:allow-set-webview-size","webview:allow-set-webview-position","webview:allow-webview-close","dialog:allow-open","fs:allow-read-file","shell:allow-open",{"identifier":"http:default","allow":[{"url":"http://**/"},{"url":"https://**/"}]},{"identifier":"fs:allow-read-text-file","allow":[{"path":"$RESOURCE/locales/*"}]}],"platforms":["linux","macOS","windows"]}}