mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
spaces function
This commit is contained in:
parent
6a14bea506
commit
2c6a96e96f
17 changed files with 683 additions and 71 deletions
|
|
@ -3,18 +3,44 @@
|
|||
import { getAllUserMemoriesAndSpaces } from "@/app/actions/fetchers";
|
||||
import { Content, StoredSpace } from "@/server/db/schema";
|
||||
import { MemoriesIcon, NextIcon, SearchIcon, UrlIcon } from "@repo/ui/icons";
|
||||
import { NotebookIcon, PaperclipIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
MenuIcon,
|
||||
MoveIcon,
|
||||
NotebookIcon,
|
||||
PaperclipIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import Masonry from "react-layout-masonry";
|
||||
import { getRawTweet } from "@repo/shared-types/utils";
|
||||
import { MyTweet } from "./render-tweet";
|
||||
import { MyTweet } from "../../../components/twitter/render-tweet";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@repo/ui/shadcn/dropdown-menu";
|
||||
import { Button } from "@repo/ui/shadcn/button";
|
||||
import { deleteItem, moveItem } from "@/app/actions/doers";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function MemoriesPage({
|
||||
memoriesAndSpaces,
|
||||
title = "Your Memories",
|
||||
currentSpace,
|
||||
}: {
|
||||
memoriesAndSpaces: { memories: Content[]; spaces: StoredSpace[] };
|
||||
title?: string;
|
||||
currentSpace?: StoredSpace;
|
||||
}) {
|
||||
const [filter, setFilter] = useState("All");
|
||||
|
||||
|
|
@ -69,9 +95,24 @@ export function MemoriesPage({
|
|||
key={`${memoriesAndSpaces.memories.length + memoriesAndSpaces.spaces.length}`}
|
||||
className="px-2 md:px-32 py-36 h-full flex mx-auto w-full flex-col gap-6"
|
||||
>
|
||||
<h2 className="text-white w-full font-medium text-3xl text-left font-semibold">
|
||||
My Memories
|
||||
{currentSpace && (
|
||||
<Link href={"/memories"} className="flex gap-2 items-center">
|
||||
<ArrowLeftIcon className="w-3 h-3" /> Back to all memories
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<h2 className="text-white w-full text-3xl text-left font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
{currentSpace && (
|
||||
<div className="flex gap-4 items-center">
|
||||
Space
|
||||
<div className="flex items-center gap-2 bg-secondary p-2 rounded-xl">
|
||||
<Image src={MemoriesIcon} alt="Spaces icon" className="w-3 h-3" />
|
||||
<span className="text-[#fff]">{currentSpace.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Filters setFilter={setFilter} filter={filter} />
|
||||
|
||||
|
|
@ -99,6 +140,8 @@ export function MemoriesPage({
|
|||
"/placeholder-image.svg" // TODO: add this placeholder
|
||||
}
|
||||
description={(item.data as Content).description ?? ""}
|
||||
spaces={memoriesAndSpaces.spaces}
|
||||
id={(item.data as Content).id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -108,6 +151,7 @@ export function MemoriesPage({
|
|||
<TabComponent
|
||||
title={(item.data as StoredSpace).name}
|
||||
description={`${(item.data as StoredSpace).numItems} memories`}
|
||||
id={(item.data as StoredSpace).id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -122,19 +166,24 @@ export function MemoriesPage({
|
|||
function TabComponent({
|
||||
title,
|
||||
description,
|
||||
id,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
id: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 bg-[#161f2a]/30 backdrop-blur-md border-2 border-border w-full rounded-xl p-4 -z-10">
|
||||
<Link
|
||||
href={`/space/${id}`}
|
||||
className="flex flex-col gap-4 bg-[#161f2a]/30 backdrop-blur-md border-2 border-border w-full rounded-xl p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Image alt="Spaces icon" src={MemoriesIcon} className="size-3" /> Space
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<div className="h-12 w-12 flex justify-center items-center rounded-md">
|
||||
{title.slice(0, 2).toUpperCase()}
|
||||
{title.slice(0, 2).toUpperCase()} {id}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow px-4">
|
||||
|
|
@ -145,7 +194,7 @@ function TabComponent({
|
|||
<Image src={NextIcon} alt="Search icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +205,8 @@ function LinkComponent({
|
|||
url,
|
||||
image,
|
||||
description,
|
||||
spaces,
|
||||
id,
|
||||
}: {
|
||||
type: string;
|
||||
content: string;
|
||||
|
|
@ -163,34 +214,95 @@ function LinkComponent({
|
|||
url: string;
|
||||
image?: string;
|
||||
description: string;
|
||||
spaces: StoredSpace[];
|
||||
id: number;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={url.replace("https://supermemory.ai", "").split("#")[0] ?? "/"}
|
||||
className={`bg-secondary border-2 border-border rounded-xl ${type === "tweet" ? "" : "p-4"} hover:scale-105 transition duration-200`}
|
||||
<div
|
||||
className={`bg-secondary group relative border-2 border-border rounded-xl ${type === "tweet" ? "" : "p-4"} hover:scale-105 transition duration-200`}
|
||||
>
|
||||
{type === "page" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<PaperclipIcon className="w-3 h-3" /> Page
|
||||
</div>
|
||||
<div className="text-lg text-[#fff] mt-4 line-clamp-2">{title}</div>
|
||||
<div>
|
||||
{url.replace("https://supermemory.ai", "").split("#")[0] ?? "/"}
|
||||
</div>
|
||||
</>
|
||||
) : type === "note" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<NotebookIcon className="w-3 h-3" /> Note
|
||||
</div>
|
||||
<div className="text-lg text-[#fff] mt-4 line-clamp-2">{title}</div>
|
||||
<div className="line-clamp-3 mt-2">{content.replace(title, "")}</div>
|
||||
</>
|
||||
) : type === "tweet" ? (
|
||||
<MyTweet tweet={JSON.parse(getRawTweet(content) ?? "{}")} />
|
||||
) : null}
|
||||
</Link>
|
||||
<Link
|
||||
href={url.replace("https://supermemory.ai", "").split("#")[0] ?? "/"}
|
||||
>
|
||||
{type === "page" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<PaperclipIcon className="w-3 h-3" /> Page
|
||||
</div>
|
||||
<div className="text-lg text-[#fff] mt-4 line-clamp-2">{title}</div>
|
||||
<div>
|
||||
{url.replace("https://supermemory.ai", "").split("#")[0] ?? "/"}
|
||||
</div>
|
||||
</>
|
||||
) : type === "note" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<NotebookIcon className="w-3 h-3" /> Note
|
||||
</div>
|
||||
<div className="text-lg text-[#fff] mt-4 line-clamp-2">{title}</div>
|
||||
<div className="line-clamp-3 mt-2">
|
||||
{content.replace(title, "")}
|
||||
</div>
|
||||
</>
|
||||
) : type === "tweet" ? (
|
||||
<MyTweet tweet={JSON.parse(getRawTweet(content) ?? "{}")} />
|
||||
) : null}
|
||||
</Link>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger className="top-5 right-5 absolute opacity-0 group-focus:opacity-100 group-hover:opacity-100 transition duration-200">
|
||||
<MenuIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{spaces.length > 0 && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<MoveIcon className="mr-2 h-4 w-4" />
|
||||
<span>Add to space</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{spaces.map((space) => (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
className="w-full h-full"
|
||||
onClick={async () => {
|
||||
toast.info("Adding to space...");
|
||||
|
||||
console.log(id, space.id);
|
||||
const response = await moveItem(id, [space.id]);
|
||||
|
||||
if (response.success) {
|
||||
toast.success("Moved to space");
|
||||
console.log("Moved to space");
|
||||
} else {
|
||||
toast.error("Failed to move to space");
|
||||
console.error("Failed to move to space");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{space.name}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await deleteItem(id);
|
||||
}}
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { getAllUserMemoriesAndSpaces } from "@/app/actions/fetchers";
|
||||
import { redirect } from "next/navigation";
|
||||
import MemoriesPage from "./content";
|
||||
import MemoriesPage from "../content";
|
||||
|
||||
async function Page() {
|
||||
const { success, data } = await getAllUserMemoriesAndSpaces();
|
||||
17
apps/web/app/(dash)/(memories)/space/[spaceid]/page.tsx
Normal file
17
apps/web/app/(dash)/(memories)/space/[spaceid]/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { getMemoriesInsideSpace } from "@/app/actions/fetchers";
|
||||
import { redirect } from "next/navigation";
|
||||
import MemoriesPage from "../../content";
|
||||
|
||||
async function Page({ params: { spaceid } }: { params: { spaceid: number } }) {
|
||||
const { success, data } = await getMemoriesInsideSpace(spaceid);
|
||||
if (!success ?? !data) return redirect("/home");
|
||||
return (
|
||||
<MemoriesPage
|
||||
memoriesAndSpaces={{ memories: data.memories, spaces: [] }}
|
||||
title={data.spaces[0]?.name}
|
||||
currentSpace={data.spaces[0]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import type { Tweet } from "react-tweet/api";
|
||||
import {
|
||||
type TwitterComponents,
|
||||
TweetContainer,
|
||||
TweetHeader,
|
||||
TweetInReplyTo,
|
||||
TweetBody,
|
||||
TweetMedia,
|
||||
TweetInfo,
|
||||
QuotedTweet,
|
||||
enrichTweet,
|
||||
} from "react-tweet";
|
||||
|
||||
type Props = {
|
||||
tweet: Tweet;
|
||||
components?: TwitterComponents;
|
||||
};
|
||||
|
||||
export const MyTweet = ({ tweet: t, components }: Props) => {
|
||||
const tweet = enrichTweet(t);
|
||||
return (
|
||||
<TweetContainer className="bg-transparent !m-0 !p-0 !z-0">
|
||||
<TweetHeader tweet={tweet} components={components} />
|
||||
{tweet.in_reply_to_status_id_str && <TweetInReplyTo tweet={tweet} />}
|
||||
<TweetBody tweet={tweet} />
|
||||
{tweet.mediaDetails?.length ? (
|
||||
<TweetMedia tweet={tweet} components={components} />
|
||||
) : null}
|
||||
{tweet.quoted_tweet && <QuotedTweet tweet={tweet.quoted_tweet} />}
|
||||
<TweetInfo tweet={tweet} />
|
||||
</TweetContainer>
|
||||
);
|
||||
};
|
||||
|
|
@ -191,7 +191,8 @@ export const createMemory = async (input: {
|
|||
storeToSpaces = [];
|
||||
}
|
||||
|
||||
console.log(storeToSpaces);
|
||||
console.log("SAVING URL: ", metadata.baseUrl);
|
||||
|
||||
const vectorSaveResponse = await fetch(
|
||||
`${process.env.BACKEND_BASE_URL}/api/add`,
|
||||
{
|
||||
|
|
@ -298,10 +299,12 @@ export const createMemory = async (input: {
|
|||
.all();
|
||||
|
||||
await Promise.all(
|
||||
spaceData.map(async (space) => {
|
||||
spaceData.map(async (s) => {
|
||||
await db
|
||||
.insert(contentToSpace)
|
||||
.values({ contentId: contentId, spaceId: space.id });
|
||||
.values({ contentId: contentId, spaceId: s.id });
|
||||
|
||||
db.update(space).set({ numItems: s.numItems + 1 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -437,6 +440,124 @@ export const linkTelegramToUser = async (
|
|||
};
|
||||
};
|
||||
|
||||
export const deleteItem = async (id: number) => {
|
||||
const data = await auth();
|
||||
|
||||
if (!data || !data.user || !data.user.id) {
|
||||
redirect("/signin");
|
||||
return { error: "Not authenticated", success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const deletedItem = await db
|
||||
.delete(storedContent)
|
||||
.where(eq(storedContent.id, id))
|
||||
.returning();
|
||||
|
||||
if (!deletedItem) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Failed to delete item",
|
||||
};
|
||||
}
|
||||
|
||||
const actualUrl = deletedItem[0]?.url.split("#supermemory-user-")[0];
|
||||
|
||||
console.log(
|
||||
"ACTUAL URL BADBAL;KFJDLKASJFLKDSJFLKDSJFKD LSFJSLKDJF :",
|
||||
actualUrl,
|
||||
);
|
||||
|
||||
await fetch(`${process.env.BACKEND_BASE_URL}/api/delete`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
websiteUrl: actualUrl,
|
||||
user: data.user.id,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + process.env.BACKEND_SECURITY_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/memories");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "in-sync",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error,
|
||||
message: "An error occured while saving your canvas",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: also move in vectorize
|
||||
export const moveItem = async (
|
||||
id: number,
|
||||
spaces: number[],
|
||||
fromSpace?: number | undefined,
|
||||
): ServerActionReturnType<boolean> => {
|
||||
const data = await auth();
|
||||
|
||||
if (!data || !data.user || !data.user.id) {
|
||||
redirect("/signin");
|
||||
return { error: "Not authenticated", success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
if (fromSpace) {
|
||||
await db
|
||||
.delete(contentToSpace)
|
||||
.where(
|
||||
and(
|
||||
eq(contentToSpace.contentId, id),
|
||||
eq(contentToSpace.spaceId, fromSpace),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const addedItem = await db
|
||||
.insert(contentToSpace)
|
||||
.values(spaces.map((spaceId) => ({ contentId: id, spaceId })))
|
||||
.returning();
|
||||
|
||||
if (!(addedItem.length > 0)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Failed to move item",
|
||||
};
|
||||
}
|
||||
|
||||
await db
|
||||
.update(space)
|
||||
.set({ numItems: sql<number>`numItems + 1` })
|
||||
.where(eq(space.id, addedItem[0]?.spaceId!));
|
||||
|
||||
if (!addedItem) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Failed to move item",
|
||||
};
|
||||
}
|
||||
|
||||
revalidatePath("/memories");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: (error as Error).message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const createCanvas = async () => {
|
||||
const data = await auth();
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
chatThreads,
|
||||
Content,
|
||||
contentToSpace,
|
||||
space,
|
||||
storedContent,
|
||||
StoredSpace,
|
||||
users,
|
||||
|
|
@ -75,6 +76,43 @@ export const getAllMemories = async (
|
|||
return { success: true, data: contentNotInAnySpace };
|
||||
};
|
||||
|
||||
export const getMemoriesInsideSpace = async (
|
||||
spaceId: number,
|
||||
): ServerActionReturnType<{ memories: Content[]; spaces: StoredSpace[] }> => {
|
||||
const data = await auth();
|
||||
|
||||
if (!data || !data.user) {
|
||||
redirect("/signin");
|
||||
return { error: "Not authenticated", success: false };
|
||||
}
|
||||
|
||||
const memories = await db
|
||||
.select()
|
||||
.from(storedContent)
|
||||
.where(
|
||||
inArray(
|
||||
storedContent.id,
|
||||
db
|
||||
.select({ contentId: contentToSpace.contentId })
|
||||
.from(contentToSpace)
|
||||
.where(eq(contentToSpace.spaceId, spaceId)),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
|
||||
const queriedSpace = await db.query.space.findFirst({
|
||||
where: and(eq(users, data.user.id), eq(space.id, spaceId)),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
memories: memories,
|
||||
spaces: queriedSpace ? [queriedSpace] : [],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const getAllUserMemoriesAndSpaces = async (): ServerActionReturnType<{
|
||||
spaces: StoredSpace[];
|
||||
memories: Content[];
|
||||
|
|
|
|||
|
|
@ -128,10 +128,12 @@ const createMemoryFromAPI = async (input: {
|
|||
.all();
|
||||
|
||||
await Promise.all(
|
||||
spaceData.map(async (space) => {
|
||||
spaceData.map(async (s) => {
|
||||
await db
|
||||
.insert(contentToSpace)
|
||||
.values({ contentId: contentId, spaceId: space.id });
|
||||
.values({ contentId: contentId, spaceId: s.id });
|
||||
|
||||
await db.update(space).set({ numItems: s.numItems + 1 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
9
apps/web/components/twitter/icons/icons.module.css
Normal file
9
apps/web/components/twitter/icons/icons.module.css
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.verified {
|
||||
margin-left: 0.125rem;
|
||||
max-width: 20px;
|
||||
max-height: 20px;
|
||||
height: 1.25em;
|
||||
fill: currentColor;
|
||||
user-select: none;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
3
apps/web/components/twitter/icons/index.ts
Normal file
3
apps/web/components/twitter/icons/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./verified";
|
||||
export * from "./verified-business";
|
||||
export * from "./verified-government";
|
||||
53
apps/web/components/twitter/icons/verified-business.tsx
Normal file
53
apps/web/components/twitter/icons/verified-business.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import s from "./icons.module.css";
|
||||
|
||||
export const VerifiedBusiness = () => (
|
||||
<svg
|
||||
viewBox="0 0 22 22"
|
||||
aria-label="Verified account"
|
||||
role="img"
|
||||
className={s.verified}
|
||||
>
|
||||
<g>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="0-a"
|
||||
x1="4.411"
|
||||
x2="18.083"
|
||||
y1="2.495"
|
||||
y2="21.508"
|
||||
>
|
||||
<stop offset="0" stopColor="#f4e72a"></stop>
|
||||
<stop offset=".539" stopColor="#cd8105"></stop>
|
||||
<stop offset=".68" stopColor="#cb7b00"></stop>
|
||||
<stop offset="1" stopColor="#f4ec26"></stop>
|
||||
<stop offset="1" stopColor="#f4e72a"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="0-b"
|
||||
x1="5.355"
|
||||
x2="16.361"
|
||||
y1="3.395"
|
||||
y2="19.133"
|
||||
>
|
||||
<stop offset="0" stopColor="#f9e87f"></stop>
|
||||
<stop offset=".406" stopColor="#e2b719"></stop>
|
||||
<stop offset=".989" stopColor="#e2b719"></stop>
|
||||
</linearGradient>
|
||||
<g clipRule="evenodd" fillRule="evenodd">
|
||||
<path
|
||||
d="M13.324 3.848L11 1.6 8.676 3.848l-3.201-.453-.559 3.184L2.06 8.095 3.48 11l-1.42 2.904 2.856 1.516.559 3.184 3.201-.452L11 20.4l2.324-2.248 3.201.452.559-3.184 2.856-1.516L18.52 11l1.42-2.905-2.856-1.516-.559-3.184zm-7.09 7.575l3.428 3.428 5.683-6.206-1.347-1.247-4.4 4.795-2.072-2.072z"
|
||||
fill="url(#0-a)"
|
||||
></path>
|
||||
<path
|
||||
d="M13.101 4.533L11 2.5 8.899 4.533l-2.895-.41-.505 2.88-2.583 1.37L4.2 11l-1.284 2.627 2.583 1.37.505 2.88 2.895-.41L11 19.5l2.101-2.033 2.895.41.505-2.88 2.583-1.37L17.8 11l1.284-2.627-2.583-1.37-.505-2.88zm-6.868 6.89l3.429 3.428 5.683-6.206-1.347-1.247-4.4 4.795-2.072-2.072z"
|
||||
fill="url(#0-b)"
|
||||
></path>
|
||||
<path
|
||||
d="M6.233 11.423l3.429 3.428 5.65-6.17.038-.033-.005 1.398-5.683 6.206-3.429-3.429-.003-1.405.005.003z"
|
||||
fill="#d18800"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
18
apps/web/components/twitter/icons/verified-government.tsx
Normal file
18
apps/web/components/twitter/icons/verified-government.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import s from "./icons.module.css";
|
||||
|
||||
export const VerifiedGovernment = () => (
|
||||
<svg
|
||||
viewBox="0 0 22 22"
|
||||
aria-label="Verified account"
|
||||
role="img"
|
||||
className={s.verified}
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M12.05 2.056c-.568-.608-1.532-.608-2.1 0l-1.393 1.49c-.284.303-.685.47-1.1.455L5.42 3.932c-.832-.028-1.514.654-1.486 1.486l.069 2.039c.014.415-.152.816-.456 1.1l-1.49 1.392c-.608.568-.608 1.533 0 2.101l1.49 1.393c.304.284.47.684.456 1.1l-.07 2.038c-.027.832.655 1.514 1.487 1.486l2.038-.069c.415-.014.816.152 1.1.455l1.392 1.49c.569.609 1.533.609 2.102 0l1.393-1.49c.283-.303.684-.47 1.099-.455l2.038.069c.832.028 1.515-.654 1.486-1.486L18 14.542c-.015-.415.152-.815.455-1.099l1.49-1.393c.608-.568.608-1.533 0-2.101l-1.49-1.393c-.303-.283-.47-.684-.455-1.1l.068-2.038c.029-.832-.654-1.514-1.486-1.486l-2.038.07c-.415.013-.816-.153-1.1-.456zm-5.817 9.367l3.429 3.428 5.683-6.206-1.347-1.247-4.4 4.795-2.072-2.072z"
|
||||
fillRule="evenodd"
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
14
apps/web/components/twitter/icons/verified.tsx
Normal file
14
apps/web/components/twitter/icons/verified.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import s from "./icons.module.css";
|
||||
|
||||
export const Verified = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-label="Verified account"
|
||||
role="img"
|
||||
className={s.verified}
|
||||
>
|
||||
<g>
|
||||
<path d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.33 2.19c-1.4-.46-2.91-.2-3.92.81s-1.26 2.52-.8 3.91c-1.31.67-2.2 1.91-2.2 3.34s.89 2.67 2.2 3.34c-.46 1.39-.21 2.9.8 3.91s2.52 1.26 3.91.81c.67 1.31 1.91 2.19 3.34 2.19s2.68-.88 3.34-2.19c1.39.45 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.71 4.2L6.8 12.46l1.41-1.42 2.26 2.26 4.8-5.23 1.47 1.36-6.2 6.77z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
117
apps/web/components/twitter/render-tweet.tsx
Normal file
117
apps/web/components/twitter/render-tweet.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import type { Tweet } from "react-tweet/api";
|
||||
import {
|
||||
type TwitterComponents,
|
||||
TweetContainer,
|
||||
TweetInReplyTo,
|
||||
TweetBody,
|
||||
TweetMedia,
|
||||
TweetInfo,
|
||||
QuotedTweet,
|
||||
enrichTweet,
|
||||
EnrichedTweet,
|
||||
} from "react-tweet";
|
||||
import clsx from "clsx";
|
||||
import s from "./tweet-header.module.css";
|
||||
import { VerifiedBadge } from "./verified-badge";
|
||||
|
||||
type Props = {
|
||||
tweet: Tweet;
|
||||
components?: TwitterComponents;
|
||||
};
|
||||
|
||||
type AvatarImgProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
const AvatarImg = (props: AvatarImgProps) => <img {...props} />;
|
||||
|
||||
const TweetHeader = ({
|
||||
tweet,
|
||||
components,
|
||||
}: {
|
||||
tweet: EnrichedTweet;
|
||||
components?: TwitterComponents;
|
||||
}) => {
|
||||
const Img = components?.AvatarImg ?? AvatarImg;
|
||||
const { user } = tweet;
|
||||
|
||||
return (
|
||||
<div className={s.header}>
|
||||
<a
|
||||
href={tweet.url}
|
||||
className={s.avatar}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
s.avatarOverflow,
|
||||
user.profile_image_shape === "Square" && s.avatarSquare,
|
||||
)}
|
||||
>
|
||||
<Img
|
||||
src={user.profile_image_url_https}
|
||||
alt={user.name}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.avatarOverflow}>
|
||||
<div className={s.avatarShadow}></div>
|
||||
</div>
|
||||
</a>
|
||||
<div className={s.author}>
|
||||
<a
|
||||
href={tweet.url}
|
||||
className={s.authorLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className={s.authorLinkText}>
|
||||
<span title={user.name}>{user.name}</span>
|
||||
</div>
|
||||
<VerifiedBadge user={user} className={s.authorVerified} />
|
||||
</a>
|
||||
<div className={s.authorMeta}>
|
||||
<a
|
||||
href={tweet.url}
|
||||
className={s.username}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span title={`@${user.screen_name}`}>@{user.screen_name}</span>
|
||||
</a>
|
||||
<div className={s.authorFollow}>
|
||||
<span className={s.separator}>·</span>
|
||||
<a
|
||||
href={user.follow_url}
|
||||
className={s.follow}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Follow
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MyTweet = ({ tweet: t, components }: Props) => {
|
||||
const tweet = enrichTweet(t);
|
||||
return (
|
||||
<TweetContainer className="bg-transparent !m-0 !p-0 !z-0">
|
||||
<TweetHeader tweet={tweet} components={components} />
|
||||
{tweet.in_reply_to_status_id_str && <TweetInReplyTo tweet={tweet} />}
|
||||
<TweetBody tweet={tweet} />
|
||||
{tweet.mediaDetails?.length ? (
|
||||
<TweetMedia tweet={tweet} components={components} />
|
||||
) : null}
|
||||
{tweet.quoted_tweet && <QuotedTweet tweet={tweet.quoted_tweet} />}
|
||||
<TweetInfo tweet={tweet} />
|
||||
</TweetContainer>
|
||||
);
|
||||
};
|
||||
96
apps/web/components/twitter/tweet-header.module.css
Normal file
96
apps/web/components/twitter/tweet-header.module.css
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
.header {
|
||||
display: flex;
|
||||
padding-bottom: 0.75rem;
|
||||
line-height: var(--tweet-header-line-height);
|
||||
font-size: var(--tweet-header-font-size);
|
||||
white-space: nowrap;
|
||||
overflow-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: relative;
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
}
|
||||
.avatarOverflow {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.avatarSquare {
|
||||
border-radius: 4px;
|
||||
}
|
||||
.avatarShadow {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transition-property: background-color;
|
||||
transition-duration: 0.2s;
|
||||
box-shadow: rgb(0 0 0 / 3%) 0px 0px 2px inset;
|
||||
}
|
||||
.avatarShadow:hover {
|
||||
background-color: rgba(26, 26, 26, 0.15);
|
||||
}
|
||||
|
||||
.author {
|
||||
max-width: calc(100% - 84px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
.authorLink {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.authorLink:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.authorVerified {
|
||||
display: inline-flex;
|
||||
}
|
||||
.authorLinkText {
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.authorMeta {
|
||||
display: flex;
|
||||
}
|
||||
.authorFollow {
|
||||
display: flex;
|
||||
}
|
||||
.username {
|
||||
color: var(--tweet-font-color-secondary);
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.follow {
|
||||
color: var(--tweet-color-blue-secondary);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
.follow:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.separator {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.twitterIcon {
|
||||
width: 23.75px;
|
||||
height: 23.75px;
|
||||
color: var(--tweet-twitter-icon-color);
|
||||
fill: currentColor;
|
||||
user-select: none;
|
||||
}
|
||||
10
apps/web/components/twitter/verified-badge.module.css
Normal file
10
apps/web/components/twitter/verified-badge.module.css
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.verifiedOld {
|
||||
color: var(--tweet-verified-old-color);
|
||||
}
|
||||
.verifiedBlue {
|
||||
color: var(--tweet-verified-blue-color);
|
||||
}
|
||||
.verifiedGovernment {
|
||||
/* color: var(--tweet-verified-government-color); */
|
||||
color: rgb(130, 154, 171);
|
||||
}
|
||||
34
apps/web/components/twitter/verified-badge.tsx
Normal file
34
apps/web/components/twitter/verified-badge.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import clsx from "clsx";
|
||||
import { Verified, VerifiedBusiness, VerifiedGovernment } from "./icons/index";
|
||||
import s from "./verified-badge.module.css";
|
||||
|
||||
type Props = {
|
||||
user: any;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const VerifiedBadge = ({ user, className }: Props) => {
|
||||
const verified = user.verified || user.is_blue_verified || user.verified_type;
|
||||
let icon = <Verified />;
|
||||
let iconClassName: string | null = s.verifiedBlue ?? null;
|
||||
|
||||
if (verified) {
|
||||
if (!user.is_blue_verified) {
|
||||
iconClassName = s.verifiedOld!;
|
||||
}
|
||||
switch (user.verified_type) {
|
||||
case "Government":
|
||||
icon = <VerifiedGovernment />;
|
||||
iconClassName = s.verifiedGovernment!;
|
||||
break;
|
||||
case "Business":
|
||||
icon = <VerifiedBusiness />;
|
||||
iconClassName = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return verified ? (
|
||||
<div className={clsx(className, iconClassName)}>{icon}</div>
|
||||
) : null;
|
||||
};
|
||||
|
|
@ -57,6 +57,7 @@
|
|||
"@million/lint": "^1.0.0-rc.18",
|
||||
"@mozilla/readability": "^0.5.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue