aboutsummaryrefslogtreecommitdiff
path: root/apps/web/src/components
diff options
context:
space:
mode:
authorDhravya <[email protected]>2024-05-18 17:48:49 -0500
committerDhravya <[email protected]>2024-05-18 17:48:49 -0500
commit61753bce6c5b54bf9652251c6a8cebc8b5c81996 (patch)
treefcf84b5a6f55908829a743433353946f0b8b5b48 /apps/web/src/components
parentfeat: Rewrite backend to hono ⚡ (diff)
downloadsupermemory-61753bce6c5b54bf9652251c6a8cebc8b5c81996.tar.xz
supermemory-61753bce6c5b54bf9652251c6a8cebc8b5c81996.zip
added browser rendering
Diffstat (limited to 'apps/web/src/components')
-rw-r--r--apps/web/src/components/Main-2.tsx709
-rw-r--r--apps/web/src/components/Sidebar/SettingsTab.tsx99
-rw-r--r--apps/web/src/components/dev/SessionProviderWrapper.tsx12
-rw-r--r--apps/web/src/components/dev/tailwindindicator.tsx16
4 files changed, 836 insertions, 0 deletions
diff --git a/apps/web/src/components/Main-2.tsx b/apps/web/src/components/Main-2.tsx
new file mode 100644
index 00000000..1b602712
--- /dev/null
+++ b/apps/web/src/components/Main-2.tsx
@@ -0,0 +1,709 @@
+"use client";
+import { MemoryDrawer } from "./MemoryDrawer";
+import useViewport from "@/hooks/useViewport";
+import { AnimatePresence } from "framer-motion";
+import { cn } from "@/lib/utils";
+
+import { Editor } from "novel";
+import { useAutoAnimate } from "@formkit/auto-animate/react";
+import {
+ MemoryWithImage,
+ MemoryWithImages3,
+ MemoryWithImages2,
+} from "@/assets/MemoryWithImages";
+import { Input, InputWithIcon } from "./ui/input";
+import {
+ ArrowUpRight,
+ Edit3,
+ Loader,
+ Minus,
+ MoreHorizontal,
+ Plus,
+ Search,
+ Sparkles,
+ Text,
+ Trash2,
+} from "lucide-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "./ui/dropdown-menu";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { Variant, useAnimate, motion } from "framer-motion";
+import { SearchResult, useMemory } from "@/contexts/MemoryContext";
+import { SpaceIcon } from "@/assets/Memories";
+import { Dialog, DialogContent } from "./ui/dialog";
+import useTouchHold from "@/hooks/useTouchHold";
+import { DialogTrigger } from "@radix-ui/react-dialog";
+import {
+ AddExistingMemoryToSpace,
+ AddMemoryPage,
+ NoteAddPage,
+ SpaceAddPage,
+} from "./Sidebar/AddMemoryDialog";
+import { ExpandedSpace } from "./Sidebar/ExpandedSpace";
+import { StoredContent, StoredSpace } from "@/server/db/schema";
+import { useDebounce } from "@/hooks/useDebounce";
+import { NoteEdit } from "./Sidebar/EditNoteDialog";
+import DeleteConfirmation from "./Sidebar/DeleteConfirmation";
+
+import { ProfileDrawer } from "./ProfileDrawer";
+
+function supportsDVH() {
+ try {
+ return CSS.supports("height: 100dvh");
+ } catch {
+ return false;
+ }
+}
+
+function pseudoRandomizeColorWithName(name: string) {
+ const colorsAvailable = [
+ "99e9f2",
+ "a5d8ff",
+ "d0bfff",
+ "eebefa",
+ "fcc2d7",
+ "b2f2bb",
+ "96f2d7",
+ "ffec99",
+ "ffd8a8",
+ "ffc9c9",
+ ];
+
+ const colorIndex =
+ name
+ .split("")
+ .map((char) => char.charCodeAt(0))
+ .reduce((acc, charCode) => acc + charCode, 0) % colorsAvailable.length;
+
+ return colorsAvailable[colorIndex];
+}
+
+export default function Main({ sidebarOpen }: { sidebarOpen: boolean }) {
+ const { width } = useViewport();
+
+ const [parent, enableAnimations] = useAutoAnimate();
+ const { spaces, deleteSpace, freeMemories, search } = useMemory();
+
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [addMemoryState, setAddMemoryState] = useState<
+ "page" | "note" | "space" | "existing-memory" | null
+ >(null);
+
+ const [expandedSpace, setExpandedSpace] = useState<number | null>(null);
+
+ const [searchQuery, setSearcyQuery] = useState("");
+ const [searchLoading, setSearchLoading] = useState(false);
+ const query = useDebounce(searchQuery, 500);
+
+ const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
+
+ useEffect(() => {
+ const q = query.trim();
+ if (q.length < 1) {
+ setSearchResults([]);
+ return;
+ }
+
+ setSearchLoading(true);
+
+ (async () => {
+ setSearchResults(await search(q));
+ setSearchLoading(false);
+ })();
+ }, [query]);
+
+ // useEffect(() => {
+ // if (!isOpen) {
+ // setExpandedSpace(null);
+ // }
+ // }, [isOpen]);
+
+ if (expandedSpace) {
+ return (
+ <ExpandedSpace
+ spaceId={expandedSpace}
+ back={() => setExpandedSpace(null)}
+ // close={() => setExpandedSpace(null)}
+ />
+ );
+ }
+
+ return (
+ <>
+ <AnimatePresence mode="wait">
+ <main
+ data-sidebar-open={sidebarOpen}
+ className={cn(
+ "sidebar relative flex w-full flex-col items-end gap-5 overflow-auto bg-[#FFF] px-5 pt-5 transition-[padding-left,padding-top,padding-right] delay-200 duration-200 md:items-center md:gap-10 md:px-72 [&[data-sidebar-open='true']]:pr-10 [&[data-sidebar-open='true']]:delay-0 md:[&[data-sidebar-open='true']]:pl-[calc(2.5rem+30vw)]",
+ )}
+ >
+ <div className="mt-16 w-full">
+ <div className="flex justify-between gap-4">
+ <h1 className="w-full text-3xl font-medium tracking-tight">
+ Your Memories
+ </h1>
+ <div className="flex w-full">
+ <AddMemoryModal type={addMemoryState}>
+ <DropdownMenu
+ open={isDropdownOpen}
+ onOpenChange={setIsDropdownOpen}
+ >
+ <DropdownMenuTrigger asChild>
+ <button className="focus-visible:ring-rgray-7 ml-auto flex items-center justify-center rounded-md px-3 py-2 transition hover:bg-stone-200 focus-visible:bg-white focus-visible:outline-none focus-visible:ring-2">
+ <Plus className="mr-2 h-5 w-5" />
+ Add
+ </button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent
+ onCloseAutoFocus={(e) => e.preventDefault()}
+ >
+ <DialogTrigger className="block w-full">
+ <DropdownMenuItem
+ onClick={() => {
+ setAddMemoryState("page");
+ }}
+ >
+ <Sparkles className="mr-2 h-4 w-4" />
+ Page to Memory
+ </DropdownMenuItem>
+ </DialogTrigger>
+ <DialogTrigger className="block w-full">
+ <DropdownMenuItem
+ onClick={() => {
+ setAddMemoryState("note");
+ }}
+ >
+ <Text className="mr-2 h-4 w-4" />
+ Note
+ </DropdownMenuItem>
+ </DialogTrigger>
+ <DialogTrigger className="block w-full">
+ <DropdownMenuItem
+ onClick={() => {
+ setAddMemoryState("space");
+ }}
+ >
+ <SpaceIcon className="mr-2 h-4 w-4" />
+ Space
+ </DropdownMenuItem>
+ </DialogTrigger>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </AddMemoryModal>
+ </div>
+ </div>
+ <InputWithIcon
+ placeholder="Search"
+ icon={
+ searchLoading ? (
+ <Loader className="h-5 w-5 animate-spin opacity-50" />
+ ) : (
+ <Search className="h-5 w-5 opacity-50" />
+ )
+ }
+ className="mt-4 w-full text-black"
+ value={searchQuery}
+ onChange={(e) => setSearcyQuery(e.target.value)}
+ />
+ </div>
+ <div
+ ref={parent}
+ className="grid w-full grid-flow-row grid-cols-3 gap-4 px-2 py-5"
+ >
+ {typeof window !== "undefined" ? (
+ query.trim().length > 0 ? (
+ <>
+ {searchResults.map(({ type, space, memory }, i) => (
+ <>
+ {type === "memory" && (
+ <MemoryItem
+ {...memory!}
+ key={i}
+ onDelete={() => {
+ setSearchResults((prev) =>
+ prev.filter((i) => i.memory?.id !== memory.id),
+ );
+ }}
+ />
+ )}
+ {type === "space" && (
+ <SpaceItem
+ {...space!}
+ key={i}
+ onDelete={() => {
+ setSearchResults((prev) =>
+ prev.filter((i) => i.space?.id !== space.id),
+ );
+ deleteSpace(space.id);
+ }}
+ />
+ )}
+ </>
+ ))}
+ </>
+ ) : (
+ <>
+ {spaces.map((space) => (
+ <SpaceItem
+ onDelete={() => deleteSpace(space.id)}
+ key={space.id}
+ onClick={() => setExpandedSpace(space.id)}
+ {...space}
+ />
+ ))}
+ {freeMemories.map((m) => (
+ <MemoryItem {...m} key={m.id} />
+ ))}
+ </>
+ )
+ ) : (
+ <>
+ {Array.from({
+ length: spaces.length + freeMemories.length,
+ }).map((_, i) => (
+ <div className="h-32 w-full animate-pulse rounded-2xl bg-stone-300/50"></div>
+ ))}
+ </>
+ )}
+ </div>
+ <div className="absolute right-10 top-10 z-[100] block md:hidden">
+ {width <= 768 && <ProfileDrawer />}
+ </div>
+ </main>
+ {width <= 768 && <MemoryDrawer />}
+ </AnimatePresence>
+ </>
+ );
+}
+
+export function MemoryItem(
+ props: StoredContent & {
+ onDelete?: () => void;
+ removeFromSpace?: () => Promise<void>;
+ },
+) {
+ const { id, title, image, type, url, onDelete, removeFromSpace } = props;
+
+ const { deleteMemory } = useMemory();
+
+ const name = title
+ ? title.length > 20
+ ? title.slice(0, 20) + "..."
+ : title
+ : "Untitled Memory";
+
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+
+ const [moreDropdownOpen, setMoreDropdownOpen] = useState(false);
+
+ const touchEventProps = useTouchHold({
+ onHold() {
+ setMoreDropdownOpen(true);
+ },
+ });
+ return (
+ <Dialog
+ open={type === "note" ? isDialogOpen : false}
+ onOpenChange={setIsDialogOpen}
+ >
+ <DialogTrigger asChild>
+ <button
+ onClick={() => (type === "page" ? window.open(url) : null)}
+ data-space-text
+ className="relative flex h-min select-none flex-col items-center justify-center gap-2 text-center font-normal focus-visible:outline-none"
+ >
+ <div
+ {...touchEventProps}
+ className="has-[[data-space-text]:focus-visible]:ring-rgray-7 [&:has-[[data-space-text]:focus-visible]>[data-more-button]]:opacity-100 flex h-32 w-full items-center justify-center rounded-2xl border-2 border-black/20 p-2 pb-4 shadow-sm ring-transparent transition duration-150 ease-out hover:scale-105 has-[[data-space-text]:focus-visible]:outline-none has-[[data-space-text]:focus-visible]:ring-2 md:has-[[data-state='true']]:bg-transparent [&:hover>[data-more-button]]:opacity-100"
+ style={{
+ backgroundColor: `#${pseudoRandomizeColorWithName(name)}`,
+ }}
+ >
+ {type === "page" ? (
+ <PageMoreButton
+ isOpen={moreDropdownOpen}
+ setIsOpen={setMoreDropdownOpen}
+ removeFromSpace={removeFromSpace}
+ onDelete={() => {
+ deleteMemory(id);
+ onDelete?.();
+ }}
+ url={url}
+ />
+ ) : type === "note" ? (
+ <NoteMoreButton
+ isOpen={moreDropdownOpen}
+ setIsOpen={setMoreDropdownOpen}
+ removeFromSpace={removeFromSpace}
+ onEdit={() => setIsDialogOpen(true)}
+ onDelete={() => {
+ deleteMemory(id);
+ onDelete?.();
+ }}
+ />
+ ) : null}
+
+ <div className="flex h-24 w-24 items-center justify-center">
+ {type === "page" ? (
+ <img
+ className="h-16 w-16"
+ id={id.toString()}
+ src={image!}
+ onError={(e) => {
+ (e.target as HTMLImageElement).src =
+ "/icons/white_without_bg.png";
+ }}
+ />
+ ) : type === "note" ? (
+ <Text
+ onClick={() => setIsDialogOpen(true)}
+ className="h-16 w-16"
+ />
+ ) : (
+ <></>
+ )}
+ </div>
+ </div>
+ {name}
+ </button>
+ </DialogTrigger>
+ <DialogContent className="w-max max-w-[auto]">
+ <NoteEdit
+ onDelete={onDelete}
+ closeDialog={() => setIsDialogOpen(false)}
+ memory={props}
+ />
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+export function SpaceItem({
+ name,
+ id,
+ onDelete,
+ onClick,
+}: StoredSpace & { onDelete: () => void; onClick?: () => void }) {
+ const { cachedMemories } = useMemory();
+
+ const [itemRef, animateItem] = useAnimate();
+ const { width } = useViewport();
+
+ const [moreDropdownOpen, setMoreDropdownOpen] = useState(false);
+
+ const touchEventProps = useTouchHold({
+ onHold() {
+ setMoreDropdownOpen(true);
+ },
+ });
+
+ const spaceMemories = useMemo(() => {
+ return cachedMemories.filter((m) => m.space === id);
+ }, [cachedMemories]);
+
+ const _name = name.length > 20 ? name.slice(0, 20) + "..." : name;
+
+ return (
+ <button
+ onClick={onClick}
+ data-space-text
+ className="relative flex h-min select-none flex-col items-center justify-center gap-2 text-center font-normal focus-visible:outline-none"
+ >
+ <motion.div
+ ref={itemRef}
+ {...touchEventProps}
+ className="has-[[data-space-text]:focus-visible]:ring-rgray-7 [&:has-[[data-space-text]:focus-visible]>[data-more-button]]:opacity-100 flex h-32 w-full items-center justify-center rounded-2xl border-2 border-black/20 p-2 pb-4 shadow-sm ring-transparent transition duration-150 ease-out hover:scale-105 has-[[data-space-text]:focus-visible]:outline-none has-[[data-space-text]:focus-visible]:ring-2 md:has-[[data-state='true']]:bg-transparent [&:hover>[data-more-button]]:opacity-100"
+ style={{
+ backgroundColor: `#${pseudoRandomizeColorWithName(name)}`,
+ }}
+ >
+ <SpaceMoreButton
+ isOpen={moreDropdownOpen}
+ setIsOpen={setMoreDropdownOpen}
+ onEdit={onClick}
+ onDelete={onDelete}
+ />
+ {spaceMemories.length > 2 ? (
+ <MemoryWithImages3
+ onClick={onClick}
+ className="h-24 w-24"
+ id={id.toString()}
+ images={
+ spaceMemories
+ .map((c) => (c.type === "note" ? "/note.svg" : c.image))
+ .reverse() as string[]
+ }
+ />
+ ) : spaceMemories.length > 1 ? (
+ <MemoryWithImages2
+ onClick={onClick}
+ className="h-24 w-24"
+ id={id.toString()}
+ images={
+ spaceMemories
+ .map((c) => (c.type === "note" ? "/note.svg" : c.image))
+ .reverse() as string[]
+ }
+ />
+ ) : spaceMemories.length === 1 ? (
+ <MemoryWithImage
+ onClick={onClick}
+ className="h-24 w-24"
+ id={id.toString()}
+ image={
+ spaceMemories[0].type === "note"
+ ? "/note.svg"
+ : spaceMemories[0].image!
+ }
+ />
+ ) : (
+ <div
+ onClick={onClick}
+ className="flex items-center justify-center gap-2"
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ strokeWidth="1.5"
+ stroke="currentColor"
+ className="h-8 w-8"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
+ />
+ </svg>
+ <span className="text-stone-800/80">Empty Space</span>
+ </div>
+ )}
+ </motion.div>
+
+ {_name}
+ </button>
+ );
+}
+
+export function SpaceMoreButton({
+ onDelete,
+ isOpen,
+ setIsOpen,
+ onEdit,
+}: {
+ onDelete?: () => void;
+ isOpen?: boolean;
+ onEdit?: () => void;
+ setIsOpen?: (open: boolean) => void;
+}) {
+ return (
+ <DeleteConfirmation onDelete={onDelete} trigger={false}>
+ <DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
+ <DropdownMenuTrigger asChild>
+ <button
+ data-more-button
+ className="focus-visible:ring-rgray-7 absolute right-2 top-2 scale-0 rounded-md p-1 opacity-0 ring-transparent transition hover:bg-white focus-visible:bg-white focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 md:block md:scale-100 md:bg-transparent"
+ >
+ <MoreHorizontal className="h-5 w-5 text-black" />
+ </button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start">
+ <DropdownMenuItem onClick={onEdit}>
+ <Edit3 className="mr-2 h-4 w-4" strokeWidth={1.5} />
+ Edit
+ </DropdownMenuItem>
+ <DialogTrigger asChild>
+ <DropdownMenuItem className="focus:bg-red-100 focus:text-red-400">
+ <Trash2 className="mr-2 h-4 w-4" strokeWidth={1.5} />
+ Delete
+ </DropdownMenuItem>
+ </DialogTrigger>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </DeleteConfirmation>
+ );
+}
+
+export function PageMoreButton({
+ onDelete,
+ isOpen,
+ setIsOpen,
+ url,
+ removeFromSpace,
+}: {
+ onDelete?: () => void;
+ isOpen?: boolean;
+ url: string;
+ setIsOpen?: (open: boolean) => void;
+ removeFromSpace?: () => Promise<void>;
+}) {
+ return (
+ <DeleteConfirmation onDelete={onDelete} trigger={false}>
+ <DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
+ <DropdownMenuTrigger asChild>
+ <button
+ data-more-button
+ className="focus-visible:ring-rgray-7 absolute right-2 top-2 scale-0 rounded-md p-1 opacity-0 ring-transparent transition hover:bg-white focus-visible:bg-white focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 md:block md:scale-100 md:bg-transparent"
+ >
+ <MoreHorizontal className="h-5 w-5 text-black" />
+ </button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start">
+ <DropdownMenuItem onClick={() => window.open(url)}>
+ <ArrowUpRight
+ className="mr-2 h-4 w-4 scale-125"
+ strokeWidth={1.5}
+ />
+ Open
+ </DropdownMenuItem>
+ {removeFromSpace && (
+ <DropdownMenuItem onClick={removeFromSpace}>
+ <Minus className="mr-2 h-4 w-4" strokeWidth={1.5} />
+ Remove from space
+ </DropdownMenuItem>
+ )}
+ <DialogTrigger asChild>
+ <DropdownMenuItem className="focus:bg-red-100 focus:text-red-400">
+ <Trash2 className="mr-2 h-4 w-4" strokeWidth={1.5} />
+ Delete
+ </DropdownMenuItem>
+ </DialogTrigger>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </DeleteConfirmation>
+ );
+}
+
+export function NoteMoreButton({
+ onDelete,
+ isOpen,
+ setIsOpen,
+ onEdit,
+ removeFromSpace,
+}: {
+ onDelete?: () => void;
+ isOpen?: boolean;
+ onEdit?: () => void;
+ setIsOpen?: (open: boolean) => void;
+ removeFromSpace?: () => Promise<void>;
+}) {
+ return (
+ <DeleteConfirmation onDelete={onDelete} trigger={false}>
+ <DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
+ <DropdownMenuTrigger asChild>
+ <button
+ data-more-button
+ className="focus-visible:ring-rgray-7 absolute right-2 top-2 scale-0 rounded-md p-1 opacity-0 ring-transparent transition hover:bg-white focus-visible:bg-white focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 md:block md:scale-100 md:bg-transparent"
+ >
+ <MoreHorizontal className="h-5 w-5 text-black" />
+ </button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start">
+ <DropdownMenuItem onClick={onEdit}>
+ <Edit3 className="mr-2 h-4 w-4" strokeWidth={1.5} />
+ Edit
+ </DropdownMenuItem>
+ {removeFromSpace && (
+ <DropdownMenuItem onClick={removeFromSpace}>
+ <Minus className="mr-2 h-4 w-4" strokeWidth={1.5} />
+ Remove from space
+ </DropdownMenuItem>
+ )}
+ <DialogTrigger asChild>
+ <DropdownMenuItem className="focus:bg-red-100 focus:text-red-400">
+ <Trash2 className="mr-2 h-4 w-4" strokeWidth={1.5} />
+ Delete
+ </DropdownMenuItem>
+ </DialogTrigger>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </DeleteConfirmation>
+ );
+}
+
+export function AddMemoryModal({
+ type,
+ children,
+ defaultSpaces,
+ onAdd,
+ data,
+}: {
+ type: "page" | "note" | "space" | "existing-memory" | null;
+ children?: React.ReactNode | React.ReactNode[];
+ defaultSpaces?: number[];
+ data?: {
+ space?: {
+ title: string;
+ id: number;
+ };
+ fromSpaces?: number[];
+ notInSpaces?: number[];
+ };
+ onAdd?: (data?: StoredSpace | StoredContent | StoredContent[]) => void;
+}) {
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+
+ return (
+ <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
+ {children}
+ <DialogContent
+ onOpenAutoFocus={(e) => {
+ e.preventDefault();
+ const novel = document.querySelector('[contenteditable="true"]') as
+ | HTMLDivElement
+ | undefined;
+ if (novel) {
+ novel.autofocus = false;
+ novel.onfocus = () => {
+ (
+ document.querySelector("[data-modal-autofocus]") as
+ | HTMLInputElement
+ | undefined
+ )?.focus();
+ novel.onfocus = null;
+ };
+ }
+ (
+ document.querySelector("[data-modal-autofocus]") as
+ | HTMLInputElement
+ | undefined
+ )?.focus();
+ }}
+ className="w-max max-w-[auto]"
+ >
+ {type === "page" ? (
+ <AddMemoryPage
+ onAdd={onAdd}
+ defaultSpaces={defaultSpaces}
+ closeDialog={() => setIsDialogOpen(false)}
+ />
+ ) : type === "note" ? (
+ <NoteAddPage
+ onAdd={onAdd}
+ defaultSpaces={defaultSpaces}
+ closeDialog={() => setIsDialogOpen(false)}
+ />
+ ) : type === "space" ? (
+ <SpaceAddPage
+ onAdd={onAdd}
+ closeDialog={() => setIsDialogOpen(false)}
+ />
+ ) : type === "existing-memory" ? (
+ <AddExistingMemoryToSpace
+ onAdd={onAdd}
+ fromSpaces={data?.fromSpaces}
+ notInSpaces={data?.notInSpaces}
+ space={data!.space!}
+ closeDialog={() => setIsDialogOpen(false)}
+ />
+ ) : (
+ <></>
+ )}
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/components/Sidebar/SettingsTab.tsx b/apps/web/src/components/Sidebar/SettingsTab.tsx
new file mode 100644
index 00000000..31b8380d
--- /dev/null
+++ b/apps/web/src/components/Sidebar/SettingsTab.tsx
@@ -0,0 +1,99 @@
+import { Box, LogOut } from "lucide-react";
+import { signOut, useSession } from "next-auth/react";
+import { useEffect, useState } from "react";
+
+export function SettingsTab({ open }: { open: boolean }) {
+ const { data: session } = useSession();
+
+ const [tweetStat, setTweetStat] = useState<[number, number] | null>();
+ const [memoryStat, setMemoryStat] = useState<[number, number] | null>();
+
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ fetch("/api/getCount").then(async (resp) => {
+ const data = (await resp.json()) as any;
+ setTweetStat([data.tweetsCount, data.tweetsLimit]);
+ setMemoryStat([data.pageCount, data.pageLimit]);
+ setLoading(false);
+ });
+ }, [open]);
+
+ return (
+ <div className="flex h-full w-full flex-col items-start py-3 text-left font-normal text-black md:py-8">
+ <div className="w-full px-6">
+ <h1 className="w-full text-2xl font-medium">Settings</h1>
+ <div className="mt-5 grid w-full grid-cols-3 gap-1">
+ <img
+ className="rounded-full"
+ src={session?.user?.image ?? "/icons/white_without_bg.png"}
+ onError={(e) => {
+ (e.target as HTMLImageElement).src =
+ "/icons/white_without_bg.png";
+ }}
+ />
+ <div className="col-span-2 flex flex-col items-start justify-center">
+ <h1 className="text-xl font-medium">{session?.user?.name}</h1>
+ <span>{session?.user?.email}</span>
+ <button
+ onClick={() => signOut()}
+ className="bg-rgray-4 hover:bg-rgray-5 focus-visible:bg-rgray-5 focus-visible:ring-rgray-7 relative mt-auto flex items-center justify-center gap-2 rounded-md px-4 py-2 text-white ring-transparent transition focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-70"
+ >
+ <LogOut className="h-4 w-4" />
+ Logout
+ </button>
+ </div>
+ </div>
+ </div>
+ <div className="border-rgray-5 mt-auto w-full px-8 pt-8">
+ <h1 className="flex w-full items-center gap-2 text-xl">
+ <Box className="h-6 w-6" />
+ Storage
+ </h1>
+ {loading ? (
+ <div className="my-5 flex w-full flex-col items-center justify-center gap-5">
+ <div className="bg-rgray-5 h-6 w-full animate-pulse rounded-md text-lg"></div>
+ <div className="bg-rgray-5 h-6 w-full animate-pulse rounded-md text-lg"></div>
+ </div>
+ ) : (
+ <>
+ <div className="my-5">
+ <h2 className="text-md flex w-full items-center justify-between">
+ Memories
+ <div className="bg-rgray-4 flex rounded-md px-2 py-2 text-xs text-white/70">
+ {memoryStat?.join("/")}
+ </div>
+ </h2>
+ <div className="mt-2 h-5 w-full overflow-hidden rounded-full bg-stone-400">
+ <div
+ style={{
+ width: `${((memoryStat?.[0] ?? 0) / (memoryStat?.[1] ?? 100)) * 100}%`,
+ minWidth: memoryStat?.[0] ?? 0 > 0 ? "5%" : "0%",
+ }}
+ className="bg-rgray-5 h-full rounded-full"
+ />
+ </div>
+ </div>
+ <div className="my-5">
+ <h2 className="text-md flex w-full items-center justify-between">
+ Tweets
+ <div className="bg-rgray-4 flex rounded-md px-2 py-2 text-xs text-white/70">
+ {tweetStat?.join("/")}
+ </div>
+ </h2>
+ <div className="mt-2 h-5 w-full overflow-hidden rounded-full bg-stone-400">
+ <div
+ style={{
+ width: `${((tweetStat?.[0] ?? 0) / (tweetStat?.[1] ?? 100)) * 100}%`,
+ minWidth: tweetStat?.[0] ?? 0 > 0 ? "5%" : "0%",
+ }}
+ className="h-full rounded-full bg-white"
+ />
+ </div>
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/dev/SessionProviderWrapper.tsx b/apps/web/src/components/dev/SessionProviderWrapper.tsx
new file mode 100644
index 00000000..71f77886
--- /dev/null
+++ b/apps/web/src/components/dev/SessionProviderWrapper.tsx
@@ -0,0 +1,12 @@
+import { SessionProvider } from "next-auth/react";
+import React from "react";
+
+function SessionProviderWrapper({ children }: { children: React.ReactNode }) {
+ if (typeof window === "undefined") {
+ return <>{children}</>;
+ } else {
+ return <SessionProvider>{children}</SessionProvider>;
+ }
+}
+
+export default SessionProviderWrapper;
diff --git a/apps/web/src/components/dev/tailwindindicator.tsx b/apps/web/src/components/dev/tailwindindicator.tsx
new file mode 100644
index 00000000..fd70276d
--- /dev/null
+++ b/apps/web/src/components/dev/tailwindindicator.tsx
@@ -0,0 +1,16 @@
+export function TailwindIndicator() {
+ if (process.env.NODE_ENV === "production") return null;
+
+ return (
+ <div>
+ <div className="fixed bottom-1 left-1 z-[999] flex size-6 items-center justify-center rounded-full border-2 bg-white p-3 font-mono text-xs text-black">
+ <div className="block sm:hidden">xs</div>
+ <div className="hidden sm:block md:hidden">sm</div>
+ <div className="hidden md:block lg:hidden">md</div>
+ <div className="hidden lg:block xl:hidden">lg</div>
+ <div className="hidden xl:block 2xl:hidden">xl</div>
+ <div className="hidden 2xl:block">2xl</div>
+ </div>
+ </div>
+ );
+}