wip: migrate frontend to new backend

This commit is contained in:
2024-02-09 15:33:27 +07:00
parent ec78cf8bf7
commit 739ba63e6c
55 changed files with 351 additions and 933 deletions

View File

@@ -5,48 +5,33 @@ import {
RefreshIcon,
TrashIcon,
} from "@lume/icons";
import { useColumn } from "@lume/storage";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { InterestModal } from "./interestModal";
import { useColumnContext } from "./provider";
export function ColumnHeader({
id,
title,
queryKey,
}: {
id: number;
title: string;
queryKey?: string[];
}) {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { moveColumn, removeColumn } = useColumnContext();
const { move, remove } = useColumn();
const column = useColumnContext();
const queryClient = useQueryClient();
const refresh = async () => {
if (queryKey) await queryClient.refetchQueries({ queryKey });
};
const moveLeft = async () => {
moveColumn(id, "left");
};
const moveRight = async () => {
moveColumn(id, "right");
};
const deleteWidget = async () => {
await removeColumn(id);
};
return (
<DropdownMenu.Root>
<div className="flex items-center justify-center gap-2 px-3 w-full border-b h-11 shrink-0 border-neutral-100 dark:border-neutral-900">
<DropdownMenu.Trigger asChild>
<div className="inline-flex items-center gap-1.5">
<div className="text-[13px] font-medium">{title}</div>
<div className="text-[13px] font-medium">{column.title}</div>
<ChevronDownIcon className="size-5" />
</div>
</DropdownMenu.Trigger>
@@ -65,18 +50,10 @@ export function ColumnHeader({
{t("global.refresh")}
</button>
</DropdownMenu.Item>
{queryKey?.[0] === "foryou-9998" ? (
<DropdownMenu.Item asChild>
<InterestModal
queryKey={queryKey}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
/>
</DropdownMenu.Item>
) : null}
<DropdownMenu.Item asChild>
<button
type="button"
onClick={moveLeft}
onClick={() => move(column.id, "left")}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
<MoveLeftIcon className="size-4" />
@@ -86,7 +63,7 @@ export function ColumnHeader({
<DropdownMenu.Item asChild>
<button
type="button"
onClick={moveRight}
onClick={() => move(column.id, "right")}
className="inline-flex items-center gap-3 px-3 text-sm font-medium rounded-lg h-9 text-black/70 hover:bg-black/10 hover:text-black focus:outline-none dark:text-white/70 dark:hover:bg-white/10 dark:hover:text-white"
>
<MoveRightIcon className="size-4" />
@@ -97,7 +74,7 @@ export function ColumnHeader({
<DropdownMenu.Item asChild>
<button
type="button"
onClick={deleteWidget}
onClick={() => remove(column.id)}
className="inline-flex items-center gap-3 px-3 text-sm font-medium text-red-500 rounded-lg h-9 hover:bg-red-500 hover:text-red-50 focus:outline-none"
>
<TrashIcon className="size-4" />

View File

@@ -2,9 +2,11 @@ import { Route } from "react-router-dom";
import { ColumnContent } from "./content";
import { ColumnHeader } from "./header";
import { ColumnLiveWidget } from "./live";
import { ColumnProvider } from "./provider";
import { ColumnRoot } from "./root";
export const Column = {
Provider: ColumnProvider,
Root: ColumnRoot,
Live: ColumnLiveWidget,
Header: ColumnHeader,

View File

@@ -1,157 +0,0 @@
import { ArrowLeftIcon, EditInterestIcon, LoaderIcon } from "@lume/icons";
import { useStorage } from "@lume/storage";
import { TOPICS, cn } from "@lume/utils";
import * as Dialog from "@radix-ui/react-dialog";
import { useQueryClient } from "@tanstack/react-query";
import { ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
export function InterestModal({
queryKey,
className,
children,
}: { queryKey: string[]; className?: string; children?: ReactNode }) {
const storage = useStorage();
const queryClient = useQueryClient();
const [t] = useTranslation();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [hashtags, setHashtags] = useState(storage.interests?.hashtags || []);
const toggleHashtag = (item: string) => {
const arr = hashtags.includes(item)
? hashtags.filter((i) => i !== item)
: [...hashtags, item];
setHashtags(arr);
};
const toggleAll = (item: string[]) => {
const sets = new Set([...hashtags, ...item]);
setHashtags([...sets]);
};
const submit = async () => {
try {
setLoading(true);
const save = await storage.createSetting(
"interests",
JSON.stringify({ hashtags }),
);
if (save) {
storage.interests = { hashtags, users: [], words: [] };
await queryClient.refetchQueries({ queryKey });
}
setLoading(false);
setOpen(false);
} catch (e) {
setLoading(false);
toast.error(String(e));
}
};
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger
className={cn(
"inline-flex items-center gap-3 px-3 rounded-lg h-9 focus:outline-none",
className,
)}
>
{children ? (
children
) : (
<>
<EditInterestIcon className="size-4" />
{t("interests.edit")}
</>
)}
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/10 backdrop-blur-sm dark:bg-white/10" />
<Dialog.Content className="fixed inset-0 z-50 flex items-center justify-center min-h-full">
<div
data-tauri-drag-region
className="h-20 absolute top-0 left-0 w-full"
/>
<div className="relative w-full max-w-xl xl:max-w-2xl bg-white h-[600px] xl:h-[700px] rounded-xl dark:bg-black overflow-hidden">
<div className="w-full h-full flex flex-col">
<div className="h-16 shrink-0 px-8 border-b border-neutral-100 dark:border-neutral-900 flex w-full items-center justify-between">
<div className="flex flex-col">
<h3 className="font-semibold">{t("interests.edit")}</h3>
</div>
</div>
<div className="w-full flex-1 min-h-0 flex flex-col justify-between">
<div className="flex-1 min-h-0 overflow-y-auto px-8 py-8">
<div className="flex flex-col gap-8">
{TOPICS.map((topic) => (
<div key={topic.title} className="flex flex-col gap-4">
<div className="w-full flex items-center justify-between">
<div className="inline-flex items-center gap-2.5">
<img
src={topic.icon}
alt={topic.title}
className="size-8 object-cover rounded-lg"
/>
<h3 className="text-lg font-semibold">
{topic.title}
</h3>
</div>
<button
type="button"
onClick={() => toggleAll(topic.content)}
className="text-sm font-medium text-blue-500"
>
{t("interests.followAll")}
</button>
</div>
<div className="flex flex-wrap items-center gap-3">
{topic.content.map((hashtag) => (
<button
key={hashtag}
type="button"
onClick={() => toggleHashtag(hashtag)}
className={cn(
"inline-flex items-center rounded-full bg-neutral-100 dark:bg-neutral-900 border border-transparent px-2 py-1 text-sm font-medium",
hashtags.includes(hashtag)
? "border-blue-500 text-blue-500"
: "",
)}
>
{hashtag}
</button>
))}
</div>
</div>
))}
</div>
</div>
<div className="h-16 shrink-0 w-full flex items-center px-8 justify-center gap-2 border-t border-neutral-100 dark:border-neutral-900 bg-neutral-50 dark:bg-neutral-950">
<Dialog.Close className="inline-flex h-9 flex-1 gap-2 shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium dark:bg-neutral-900 dark:hover:bg-neutral-800 hover:bg-blue-200">
<ArrowLeftIcon className="size-4" />
{t("global.cancel")}
</Dialog.Close>
<button
type="button"
onClick={submit}
className="inline-flex h-9 flex-1 shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
t("global.save")
)}
</button>
</div>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -1,7 +1,5 @@
import { ArrowUpIcon } from "@lume/icons";
import { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk";
import { useEffect, useState } from "react";
import { useArk } from "../../hooks/useArk";
export function ColumnLiveWidget({
filter,

View File

@@ -1,136 +1,14 @@
import { useStorage } from "@lume/storage";
import { IColumn } from "@lume/types";
import { COL_TYPES } from "@lume/utils";
import {
type MutableRefObject,
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { type VListHandle } from "virtua";
import { LumeColumn } from "@lume/types";
import { ReactNode, createContext, useContext } from "react";
type ColumnContext = {
columns: IColumn[];
vlistRef: MutableRefObject<VListHandle>;
addColumn: (column: IColumn) => Promise<void>;
removeColumn: (id: number) => Promise<void>;
moveColumn: (id: number, position: "left" | "right") => void;
updateColumn: (id: number, title: string, content: string) => Promise<void>;
loadAllColumns: () => Promise<IColumn[]>;
};
const ColumnContext = createContext<ColumnContext>(null);
export function ColumnProvider({ children }: { children: ReactNode }) {
const storage = useStorage();
const vlistRef = useRef<VListHandle>(null);
const [columns, setColumns] = useState<IColumn[]>([
{
id: 9999,
title: "Newsfeed",
content: "",
kind: COL_TYPES.newsfeed,
},
{
id: 9998,
title: "For You",
content: "",
kind: COL_TYPES.foryou,
},
]);
const loadAllColumns = useCallback(async () => {
return await storage.getColumns();
}, []);
const addColumn = useCallback(async (column: IColumn) => {
const result = await storage.createColumn(
column.kind,
column.title,
column.content,
);
if (result) {
setColumns((prev) => [...prev, result]);
vlistRef?.current.scrollToIndex(columns.length);
}
}, []);
const removeColumn = useCallback(async (id: number) => {
if (id === 9998 || id === 9999) {
toast.info("You cannot remove default column");
return;
}
await storage.removeColumn(id);
setColumns((prev) => prev.filter((t) => t.id !== id));
}, []);
const updateColumn = useCallback(
async (id: number, title: string, content: string) => {
const res = await storage.updateColumn(id, title, content);
if (res) {
const newCols = columns.map((col) => {
if (col.id === id) {
return { ...col, title, content };
}
return col;
});
setColumns(newCols);
}
},
[columns],
);
const moveColumn = useCallback(
(id: number, position: "left" | "right") => {
const newCols = [...columns];
const col = newCols.find((el) => el.id === id);
const colIndex = newCols.findIndex((el) => el.id === id);
newCols.splice(colIndex, 1);
if (position === "left") newCols.splice(colIndex - 1, 0, col);
if (position === "right") newCols.splice(colIndex + 1, 0, col);
setColumns(newCols);
},
[columns],
);
useEffect(() => {
let isMounted = true;
loadAllColumns().then((data) => {
if (isMounted) setColumns((prev) => [...prev, ...data]);
});
return () => {
isMounted = false;
};
}, []);
const ColumnContext = createContext<LumeColumn>(null);
export function ColumnProvider({
column,
children,
}: { column: LumeColumn; children: ReactNode }) {
return (
<ColumnContext.Provider
value={{
columns,
vlistRef,
addColumn,
removeColumn,
moveColumn,
updateColumn,
loadAllColumns,
}}
>
{children}
</ColumnContext.Provider>
<ColumnContext.Provider value={column}>{children}</ColumnContext.Provider>
);
}

View File

@@ -1,6 +1,4 @@
import { NDKAppHandlerEvent } from "@nostr-dev-kit/ndk";
import { useQuery } from "@tanstack/react-query";
import { useArk } from "../../hooks/useArk";
export function AppHandler({ tag }: { tag: string[] }) {
const ark = useArk();

View File

@@ -1,19 +1,7 @@
import { COL_TYPES } from "@lume/utils";
import { useColumnContext } from "../../column/provider";
export function Hashtag({ tag }: { tag: string }) {
const { addColumn } = useColumnContext();
return (
<button
type="button"
onClick={async () =>
await addColumn({
kind: COL_TYPES.hashtag,
title: tag,
content: tag,
})
}
className="text-blue-500 break-all cursor-default hover:text-blue-600"
>
{tag}

View File

@@ -1,11 +1,10 @@
import { PinIcon } from "@lume/icons";
import { COL_TYPES, NOSTR_MENTIONS } from "@lume/utils";
import { NOSTR_MENTIONS } from "@lume/utils";
import { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import reactStringReplace from "react-string-replace";
import { useEvent } from "../../../hooks/useEvent";
import { useColumnContext } from "../../column/provider";
import { User } from "../../user";
import { Hashtag } from "./hashtag";
import { MentionUser } from "./user";
@@ -15,7 +14,6 @@ export function MentionNote({
openable = true,
}: { eventId: string; openable?: boolean }) {
const { t } = useTranslation();
const { addColumn } = useColumnContext();
const { isLoading, isError, data } = useEvent(eventId);
const richContent = useMemo(() => {
@@ -133,13 +131,6 @@ export function MentionNote({
</Link>
<button
type="button"
onClick={async () =>
await addColumn({
kind: COL_TYPES.thread,
title: "Thread",
content: data.id,
})
}
className="inline-flex items-center justify-center rounded-md text-neutral-600 dark:text-neutral-400 size-6 bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
>
<PinIcon className="size-4" />

View File

@@ -1,10 +1,9 @@
import { HorizontalDotsIcon } from "@lume/icons";
import { COL_TYPES } from "@lume/utils";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { useArk } from "../../hooks/useArk";
import { useArk } from "../../provider";
import { useNoteContext } from "./provider";
export function NoteMenu() {
@@ -19,7 +18,7 @@ export function NoteMenu() {
};
const copyRaw = async () => {
await writeText(JSON.stringify(await event.toNostrEvent()));
await writeText(JSON.stringify(event));
};
const copyNpub = async () => {

View File

@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useArk } from "../../hooks/useArk";
import { useArk } from "../../provider";
import { AppHandler } from "./appHandler";
import { useNoteContext } from "./provider";

View File

@@ -1,6 +1,6 @@
import { CheckCircleIcon, DownloadIcon } from "@lume/icons";
import { downloadDir } from "@tauri-apps/api/path";
import { Window } from "@tauri-apps/api/window";
import { WebviewWindow } from "@tauri-apps/api/webview";
import { download } from "@tauri-apps/plugin-upload";
import { SyntheticEvent, useState } from "react";
@@ -23,7 +23,7 @@ export function ImagePreview({ url }: { url: string }) {
const open = async () => {
const name = new URL(url).pathname.split("/").pop();
return new Window("image-viewer", {
return new WebviewWindow("image-viewer", {
url,
title: name,
});

View File

@@ -1,5 +1,5 @@
import { NavArrowDownIcon } from "@lume/icons";
import { NDKEventWithReplies } from "@lume/types";
import { EventWithReplies } from "@lume/types";
import { cn } from "@lume/utils";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react";
@@ -10,7 +10,7 @@ import { ChildReply } from "./childReply";
export function Reply({
event,
}: {
event: NDKEventWithReplies;
event: EventWithReplies;
}) {
const [t] = useTranslation();
const [open, setOpen] = useState(false);

View File

@@ -4,7 +4,7 @@ import { cn } from "@lume/utils";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Note } from "..";
import { useArk } from "../../../hooks/useArk";
import { useArk } from "../../../provider";
import { User } from "../../user";
export function RepostNote({

View File

@@ -1,10 +1,9 @@
import { PinIcon } from "@lume/icons";
import { COL_TYPES, cn } from "@lume/utils";
import { cn } from "@lume/utils";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Note } from ".";
import { useArk } from "../../hooks/useArk";
import { useColumnContext } from "../column/provider";
import { useArk } from "../../provider";
import { useNoteContext } from "./provider";
export function NoteThread({

View File

@@ -2,14 +2,11 @@ import { LoaderIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useArk } from "../../hooks/useArk";
export function UserFollowButton({
target,
className,
}: { target: string; className?: string }) {
const ark = useArk();
const [t] = useTranslation();
const [loading, setLoading] = useState(false);
const [followed, setFollowed] = useState(false);

View File

@@ -8,7 +8,7 @@ export function UserNip05({ className }: { className?: string }) {
const { isLoading, data: verified } = useQuery({
queryKey: ["nip05", user?.profile.nip05],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
queryFn: async () => {
if (!user) return false;
if (!user.profile.nip05) return false;
return false;

View File

@@ -1,7 +1,7 @@
import { Metadata } from "@lume/types";
import { useQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { ReactNode, createContext, useContext } from "react";
import { useArk } from "../../hooks/useArk";
const UserContext = createContext<{ pubkey: string; profile: Metadata }>(null);
@@ -10,13 +10,12 @@ export function UserProvider({
children,
embed,
}: { pubkey: string; children: ReactNode; embed?: string }) {
const ark = useArk();
const { data: profile } = useQuery({
queryKey: ["user", pubkey],
queryFn: async () => {
if (embed) return JSON.parse(embed) as Metadata;
const profile = await ark.get_profile(pubkey);
const profile: Metadata = await invoke("get_profile", { id: pubkey });
if (!profile)
throw new Error(