Merge branch 'new-ui' of https://github.com/Dhravya/supermemory into new-ui

This commit is contained in:
Yash 2024-04-05 08:27:54 +00:00
commit 97988798bd
16 changed files with 189 additions and 327 deletions

View file

@ -17,8 +17,8 @@
"src/content.tsx"
],
"matches": [
"http://localhost:3000/*",
"https://anycontext.dhr.wtf/*",
"http://localhost:3000/",
"https://anycontext.dhr.wtf/",
"<all_urls>"
]
}

View file

@ -1,6 +1,9 @@
import { useEffect, useState } from 'react';
import { z } from 'zod';
import { userObj } from './types/zods';
import { getEnv } from './util';
const backendUrl = getEnv() === "development" ? "http://localhost:3000" : "https://supermemory.dhr.wtf";
function App() {
const [userData, setUserData] = useState<z.infer<typeof userObj> | null>(
@ -14,7 +17,7 @@ function App() {
if (loginButton) {
if (jwt) {
fetch('https://supermemory.dhr.wtf/api/me', {
fetch(`${backendUrl}/api/me`, {
headers: {
Authorization: `Bearer ${jwt}`,
},
@ -43,7 +46,7 @@ function App() {
<button
onClick={() =>
chrome.tabs.create({
url: 'https://supermemory.dhr.wtf/api/auth/signin',
url: `${backendUrl}/api/auth/signin`,
})
}
id="login"

View file

@ -1,3 +1,7 @@
import { getEnv } from "./util";
const backendUrl = getEnv() === "development" ? "http://localhost:3000" : "https://supermemory.dhr.wtf";
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.type === "getJwt") {
chrome.storage.local.get(["jwt"], ({ jwt }) => {
@ -17,7 +21,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
console.error("No JWT found");
return;
}
fetch("https://supermemory.dhr.wtf/api/store", {
fetch(`${backendUrl}/api/store`, {
method: "POST",
headers: {
"Authorization": `Bearer ${jwt}`,
@ -33,7 +37,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
const jwt = request.jwt;
(async () => {
await fetch("https://supermemory.dhr.wtf/api/ask", {
await fetch(`${backendUrl}/api/ask`, {
method: "POST",
headers: {
"Authorization": `Bearer ${jwt}`,

View file

@ -0,0 +1,13 @@
export const getEnv = () => {
// chrome.management.getSelf((self) => {
// if (self.installType === 'development') {
// return "development"
// }
// else {
// return "production"
// }
// })
// return null
return 'development'
}

View file

@ -24,23 +24,25 @@ CREATE TABLE `session` (
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `spaces` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text DEFAULT 'all' NOT NULL,
`description` text(255)
);
--> statement-breakpoint
CREATE TABLE `storedContent` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`content` text NOT NULL,
`title` text(255),
`description` text(255),
`url` text NOT NULL,
`space` text(255),
`space` text(255) DEFAULT 'all',
`savedAt` integer NOT NULL,
`baseUrl` text(255),
`image` text(255)
);
--> statement-breakpoint
CREATE TABLE `userStoredContent` (
`userId` text NOT NULL,
`contentId` integer NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`contentId`) REFERENCES `storedContent`(`id`) ON UPDATE no action ON DELETE no action
`image` text(255),
`user` text(255),
FOREIGN KEY (`space`) REFERENCES `spaces`(`name`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `user` (
@ -60,10 +62,9 @@ CREATE TABLE `verificationToken` (
--> statement-breakpoint
CREATE INDEX `account_userId_idx` ON `account` (`userId`);--> statement-breakpoint
CREATE INDEX `session_userId_idx` ON `session` (`userId`);--> statement-breakpoint
CREATE UNIQUE INDEX `storedContent_url_unique` ON `storedContent` (`url`);--> statement-breakpoint
CREATE INDEX `spaces_name_idx` ON `spaces` (`name`);--> statement-breakpoint
CREATE INDEX `storedContent_url_idx` ON `storedContent` (`url`);--> statement-breakpoint
CREATE INDEX `storedContent_savedAt_idx` ON `storedContent` (`savedAt`);--> statement-breakpoint
CREATE INDEX `storedContent_title_idx` ON `storedContent` (`title`);--> statement-breakpoint
CREATE INDEX `storedContent_space_idx` ON `storedContent` (`space`);--> statement-breakpoint
CREATE INDEX `userStoredContent_idx` ON `userStoredContent` (`userId`,`contentId`);--> statement-breakpoint
CREATE UNIQUE INDEX `unique_user_content` ON `userStoredContent` (`userId`,`contentId`);
CREATE INDEX `storedContent_user_idx` ON `storedContent` (`user`);

View file

@ -8,11 +8,7 @@ function MessagePoster({ jwt }: { jwt: string }) {
window.postMessage({ jwt }, '*');
}, [jwt]);
return (
<button onClick={() => window.postMessage({ jwt }, '*')}>
Send auth token to extension
</button>
);
return null;
}
export default MessagePoster;

View file

@ -1,6 +1,6 @@
import { db } from "@/server/db";
import { eq } from "drizzle-orm";
import { sessions, storedContent, userStoredContent, users } from "@/server/db/schema";
import { sessions, storedContent, users } from "@/server/db/schema";
import { type NextRequest, NextResponse } from "next/server";
import { env } from "@/env";
import { getMetaData } from "@/server/helpers";
@ -38,32 +38,19 @@ export async function POST(req: NextRequest) {
let id: number | undefined = undefined;
const storedCont = await db.select().from(storedContent).where(eq(storedContent.url, data.url)).limit(1)
const storedContentId = await db.insert(storedContent).values({
content: data.pageContent,
title: metadata.title,
description: metadata.description,
url: data.url,
baseUrl: metadata.baseUrl,
image: metadata.image,
savedAt: new Date(),
space: "all",
user: session.user.id
})
if (storedCont.length > 0) {
id = storedCont[0].id;
} else {
const storedContentId = await db.insert(storedContent).values({
content: data.pageContent,
title: metadata.title,
description: metadata.description,
url: data.url,
baseUrl: metadata.baseUrl,
image: metadata.image,
savedAt: new Date()
})
id = storedContentId.meta.last_row_id;
}
try {
await db.insert(userStoredContent).values({
userId: session.user.id,
contentId: id
});
} catch (e) {
console.log(e);
}
id = storedContentId.meta.last_row_id;
const res = await Promise.race([
fetch("https://cf-ai-backend.dhravya.workers.dev/add", {
@ -78,10 +65,6 @@ export async function POST(req: NextRequest) {
)
]) as Response
const _ = await res.text();
console.log(_)
if (res.status !== 200) {
return NextResponse.json({ message: "Error", error: "Error in CF function" }, { status: 500 });
}

View file

@ -1,17 +1,12 @@
import { CardContent, Card } from '@/components/ui/card';
import { db } from '@/server/db';
import {
sessions,
storedContent,
userStoredContent,
users,
} from '@/server/db/schema';
import { sessions, storedContent, users } from '@/server/db/schema';
import { eq, inArray } from 'drizzle-orm';
import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation';
import Image from 'next/image';
import QueryAI from '@/components/QueryAI';
import Sidebar from '@/components/Sidebar/index';
import Main from '@/components/Main';
import MessagePoster from './MessagePoster';
import { transformContent } from '../../types/memory';
export const runtime = 'edge';
@ -26,6 +21,13 @@ export default async function Home() {
return redirect('/api/auth/signin');
}
const selectedItem = cookies().get('selectedItem')?.value;
const setSelectedItem = async (selectedItem: string | null) => {
'use server';
cookies().set('selectedItem', selectedItem!);
};
const session = await db
.select()
.from(sessions)
@ -35,101 +37,28 @@ export default async function Home() {
return redirect('/api/auth/signin');
}
const userContent = await db
.select()
.from(userStoredContent)
.where(eq(userStoredContent.userId, session[0].userId));
const userData = await db
const [userData] = await db
.select()
.from(users)
.where(eq(users.id, session[0].userId))
.limit(1);
if (!userData || userData.length === 0) {
if (!userData) {
return redirect('/api/auth/signin');
}
const listOfContent =
userContent.map((content) => content.contentId).length > 0
? userContent.map((content) => content.contentId)
: [1];
const posts = await db
.select()
.from(storedContent)
.where(inArray(storedContent.id, listOfContent));
.where(eq(storedContent.user, userData.id));
const collectedSpaces = transformContent(posts);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-col items-center">
<div className="flex flex-col mt-16">
<div className="flex flex-col md:flex-row gap-4">
<Image
className="rounded-2xl"
src="/logo.png"
width={120}
height={120}
alt="logo"
/>
<div className="mt-4 text-gray-400 max-w-md">
<h1 className="text-xl font-bold text-white">SuperMemory</h1>
Remember that one thing you read a while ago? We got you covered.
Add the extension, click a button and I'll remember it for you.{' '}
<a
href="https://github.com/dhravyashah/anycontext"
target="_blank"
rel="noreferrer"
className="text-sky-500"
>
Get the Extension
</a>
</div>
</div>
</div>
<QueryAI />
<div className="flex w-screen">
<Sidebar selectChange={setSelectedItem} spaces={collectedSpaces} />
<Main sidebarOpen={selectedItem !== null} />
<MessagePoster jwt={token} />
{/* TODO: LABEL THE WEBSITES USING A CLASSIFICATION MODEL */}
{/* <nav className="flex space-x-2 my-4">
<Badge variant="secondary">Technology (2)</Badge>
<Badge variant="secondary">Business & Finance (1)</Badge>
<Badge variant="secondary">Education & Career (1)</Badge>
</nav> */}
<main className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-16">
{posts.reverse().map((post) => (
<a
href={post.url}
target="_blank"
rel="noreferrer"
className="hover:scale-105 ease-in-out transition-transform duration-300"
>
<Card className="w-full">
<img
alt="Not found"
className="w-full h-48 object-cover rounded-md"
height="200"
src={
post.image && post.image !== 'Image not found'
? post.image
: '/placeholder.svg'
}
style={{
aspectRatio: '300/200',
objectFit: 'cover',
}}
width="300"
/>
<CardContent>
<h3 className="text-lg font-semibold mt-4">{post.title}</h3>
<p className="text-sm text-gray-600">{post.baseUrl}</p>
<p className="text-sm">{post.description}</p>
</CardContent>
</Card>
</a>
))}
</main>
</div>
);
}

View file

@ -8,7 +8,7 @@ export default function Content() {
return (
<div className="flex w-screen">
<Sidebar onSelectChange={setSelectedItem} />
{/* <Sidebar selectChange={setSelectedItem} /> */}
<Main sidebarOpen={selectedItem !== null} />
</div>
);

View file

@ -1,10 +1,18 @@
import { MemoryProvider } from "@/contexts/MemoryContext";
import Content from "./content";
import Main from "@/components/Main";
import Sidebar from "@/components/Sidebar/index";
import { cookies } from "next/headers";
export default function Home() {
const selectedItem = cookies().get("selectedItem")?.value;
const setSelectedItem = async (selectedItem: string | null) => {
"use server";
cookies().set("selectedItem", selectedItem!);
};
return (
<MemoryProvider spaces={[]}>
<Content />
</MemoryProvider>
<div className="flex w-screen">
{/* <Sidebar selectChange={setSelectedItem} spaces={spaces} /> */}
<Main sidebarOpen={selectedItem !== null} />
</div>
);
}

View file

@ -1,24 +1,24 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useAutoAnimate } from '@formkit/auto-animate/react';
import {
MemoryWithImage,
MemoryWithImages3,
MemoryWithImages2,
} from "@/assets/MemoryWithImages";
import { type Space } from "../../../types/memory";
import { InputWithIcon } from "../ui/input";
} from '@/assets/MemoryWithImages';
import { type CollectedSpaces } from '../../../types/memory';
import { InputWithIcon } from '../ui/input';
import {
ArrowUpRight,
Edit3,
MoreHorizontal,
Search,
Trash2,
} from "lucide-react";
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
} from '../ui/dropdown-menu';
import {
animate,
AnimatePresence,
@ -26,105 +26,15 @@ import {
motion,
useAnimate,
Variant,
} from "framer-motion";
import { useRef, useState } from "react";
} from 'framer-motion';
import { useRef, useState } from 'react';
const spaces: Space[] = [
{
id: 1,
title: "Cool Tech",
description: "Really cool mind blowing tech",
content: [
{
id: 1,
title: "Perplexity",
description: "A good ui",
content: "",
image: "https://perplexity.ai/favicon.ico",
url: "https://perplexity.ai",
savedAt: new Date(),
baseUrl: "https://perplexity.ai",
space: "Cool tech",
},
{
id: 2,
title: "Pi.ai",
description: "A good ui",
content: "",
image: "https://pi.ai/pi-logo-192.png?v=2",
url: "https://pi.ai",
savedAt: new Date(),
baseUrl: "https://pi.ai",
space: "Cool tech",
},
{
id: 3,
title: "Visual Studio Code",
description: "A good ui",
content: "",
image: "https://code.visualstudio.com/favicon.ico",
url: "https://code.visualstudio.com",
savedAt: new Date(),
baseUrl: "https://code.visualstudio.com",
space: "Cool tech",
},
],
},
{
id: 2,
title: "Cool Courses",
description: "Amazng",
content: [
{
id: 1,
title: "Animation on the web",
description: "A good ui",
content: "",
image: "https://animations.dev/favicon.ico",
url: "https://animations.dev",
savedAt: new Date(),
baseUrl: "https://animations.dev",
space: "Cool courses",
},
{
id: 2,
title: "Tailwind Course",
description: "A good ui",
content: "",
image:
"https://tailwindcss.com/_next/static/media/tailwindcss-mark.3c5441fc7a190fb1800d4a5c7f07ba4b1345a9c8.svg",
url: "https://tailwindcss.com",
savedAt: new Date(),
baseUrl: "https://tailwindcss.com",
space: "Cool courses",
},
],
},
{
id: 3,
title: "Cool Libraries",
description: "Really cool mind blowing tech",
content: [
{
id: 1,
title: "Perplexity",
description: "A good ui",
content: "",
image: "https://yashverma.me/logo.jpg",
url: "https://perplexity.ai",
savedAt: new Date(),
baseUrl: "https://perplexity.ai",
space: "Cool libraries",
},
],
},
];
export function MemoriesBar() {
export function MemoriesBar({ spaces }: { spaces: CollectedSpaces[] }) {
const [parent, enableAnimations] = useAutoAnimate();
const [currentSpaces, setCurrentSpaces] = useState(spaces);
console.log("currentSpaces: ", currentSpaces);
console.log('currentSpaces: ', currentSpaces);
return (
<div className="text-rgray-11 flex w-full flex-col items-start py-8 text-left">
@ -157,8 +67,8 @@ export function MemoriesBar() {
const SpaceExitVariant: Variant = {
opacity: 0,
scale: 0,
borderRadius: "50%",
background: "var(--gray-1)",
borderRadius: '50%',
background: 'var(--gray-1)',
transition: {
duration: 0.2,
},
@ -170,7 +80,7 @@ export function SpaceItem({
content,
id,
onDelete,
}: Space & { onDelete: () => void }) {
}: CollectedSpaces & { onDelete: () => void }) {
const [itemRef, animateItem] = useAnimate();
return (
@ -184,22 +94,22 @@ export function SpaceItem({
<SpaceMoreButton
onDelete={() => {
if (!itemRef.current) return;
const trash = document.querySelector("#trash")! as HTMLDivElement;
const trashBin = document.querySelector("#trash-button")!;
const trash = document.querySelector('#trash')! as HTMLDivElement;
const trashBin = document.querySelector('#trash-button')!;
const trashRect = trashBin.getBoundingClientRect();
const scopeRect = itemRef.current.getBoundingClientRect();
const el = document.createElement("div");
el.style.position = "fixed";
el.style.top = "0";
el.style.left = "0";
el.style.width = "15px";
el.style.height = "15px";
el.style.backgroundColor = "var(--gray-7)";
el.style.zIndex = "60";
el.style.borderRadius = "50%";
el.style.transform = "scale(5)";
el.style.opacity = "0";
trash.dataset["open"] = "true";
const el = document.createElement('div');
el.style.position = 'fixed';
el.style.top = '0';
el.style.left = '0';
el.style.width = '15px';
el.style.height = '15px';
el.style.backgroundColor = 'var(--gray-7)';
el.style.zIndex = '60';
el.style.borderRadius = '50%';
el.style.transform = 'scale(5)';
el.style.opacity = '0';
trash.dataset['open'] = 'true';
const initial = {
x: scopeRect.left + scopeRect.width / 2,
y: scopeRect.top + scopeRect.height / 2,
@ -224,35 +134,35 @@ export function SpaceItem({
animateItem(itemRef.current, SpaceExitVariant, {
duration: 0.2,
}).then(() => {
itemRef.current.style.scale = "0";
itemRef.current.style.scale = '0';
onDelete();
});
document.body.appendChild(el);
el.animate(
{
transform: ["scale(5)", "scale(1)"],
transform: ['scale(5)', 'scale(1)'],
opacity: [0, 0.3, 1],
},
{
duration: 200,
easing: "cubic-bezier(0.64, 0.57, 0.67, 1.53)",
fill: "forwards",
easing: 'cubic-bezier(0.64, 0.57, 0.67, 1.53)',
fill: 'forwards',
},
);
el.animate(
{
offsetDistance: ["0%", "100%"],
offsetDistance: ['0%', '100%'],
},
{
duration: 2000,
easing: "cubic-bezier(0.64, 0.57, 0.67, 1.53)",
fill: "forwards",
easing: 'cubic-bezier(0.64, 0.57, 0.67, 1.53)',
fill: 'forwards',
delay: 200,
},
).onfinish = () => {
el.animate(
{ transform: "scale(0)", opacity: 0 },
{ duration: 200, fill: "forwards" },
{ transform: 'scale(0)', opacity: 0 },
{ duration: 200, fill: 'forwards' },
).onfinish = () => {
el.remove();
};

View file

@ -2,25 +2,18 @@
import { StoredContent } from "@/server/db/schema";
import { MemoryIcon } from "../../assets/Memories";
import { Trash2, User2 } from "lucide-react";
import React, { useEffect, useState } from "react";
import React, { ElementType, useEffect, useState } from "react";
import { MemoriesBar } from "./MemoriesBar";
import { AnimatePresence, motion } from "framer-motion";
import { Bin } from "@/assets/Bin";
import { CollectedSpaces } from "../../../types/memory";
export type MenuItem = {
icon: React.ReactNode | React.ReactNode[];
label: string;
content?: React.FC;
content?: React.ReactElement;
};
const menuItemsTop: Array<MenuItem> = [
{
icon: <MemoryIcon className="h-10 w-10" />,
label: "Memories",
content: MemoriesBar,
},
];
const menuItemsBottom: Array<MenuItem> = [
{
icon: <Trash2 strokeWidth={1.3} className="h-6 w-6" />,
@ -33,10 +26,19 @@ const menuItemsBottom: Array<MenuItem> = [
];
export default function Sidebar({
onSelectChange,
selectChange,
spaces,
}: {
onSelectChange?: (selectedItem: string | null) => void;
selectChange?: (selectedItem: string | null) => Promise<void>;
spaces: CollectedSpaces[];
}) {
const menuItemsTop: Array<MenuItem> = [
{
icon: <MemoryIcon className="h-10 w-10" />,
label: "Memories",
content: <MemoriesBar spaces={spaces} />,
},
];
const menuItems = [...menuItemsTop, ...menuItemsBottom];
const [selectedItem, setSelectedItem] = useState<string | null>(null);
@ -44,7 +46,7 @@ export default function Sidebar({
menuItems.find((i) => i.label === selectedItem)?.content ?? (() => <></>);
useEffect(() => {
void onSelectChange?.(selectedItem);
void selectChange?.(selectedItem);
}, [selectedItem]);
return (
@ -55,7 +57,7 @@ export default function Sidebar({
item={{
label: "Memories",
icon: <MemoryIcon className="h-10 w-10" />,
content: MemoriesBar,
content: <MemoriesBar spaces={spaces} />,
}}
selectedItem={selectedItem}
setSelectedItem={setSelectedItem}
@ -82,11 +84,11 @@ export default function Sidebar({
/>
</div>
<AnimatePresence>
{selectedItem && (
<SubSidebar>
<Subbar />
</SubSidebar>
)}
{/* @yxshv idk why this is giving typeerror
when used as <Subbar/> it says it's not valid element type
*/}
{/* @ts-ignore */}
{selectedItem && <SubSidebar>{Subbar}</SubSidebar>}
</AnimatePresence>
</div>
</>

View file

@ -0,0 +1,12 @@
import {
createSearchParamsCache,
parseAsInteger,
parseAsString
} from 'nuqs/server'
// Note: import from 'nuqs/server' to avoid the "use client" directive
export const searchParamsCache = createSearchParamsCache({
// List your search param keys and associated parsers here:
q: parseAsString.withDefault(''),
maxResults: parseAsInteger.withDefault(10)
})

View file

@ -6,15 +6,9 @@ import {
sqliteTableCreator,
text,
integer,
unique,
unique
} from "drizzle-orm/sqlite-core";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects.
*
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
*/
export const createTable = sqliteTableCreator((name) => `${name}`);
export const users = createTable("user", {
@ -84,27 +78,6 @@ export const verificationTokens = createTable(
}),
);
export const userStoredContent = createTable(
"userStoredContent",
{
userId: text("userId")
.notNull()
.references(() => users.id),
contentId: integer("contentId")
.notNull()
.references(() => storedContent.id),
},
(usc) => ({
userContentIdx: index("userStoredContent_idx").on(
usc.userId,
usc.contentId,
),
uniqueUserContent: unique("unique_user_content").on(
usc.userId,
usc.contentId,
),
}),
);
export const storedContent = createTable(
"storedContent",
@ -113,18 +86,32 @@ export const storedContent = createTable(
content: text("content").notNull(),
title: text("title", { length: 255 }),
description: text("description", { length: 255 }),
url: text("url").notNull().unique(),
space: text("space", { length: 255 }),
url: text("url").notNull(),
space: text("space", { length: 255 }).references(() => spaces.name).default('all'),
savedAt: int("savedAt", { mode: "timestamp" }).notNull(),
baseUrl: text("baseUrl", { length: 255 }),
image: text("image", { length: 255 }),
user: text("user", { length: 255 }).references(() => users.id),
},
(sc) => ({
urlIdx: index("storedContent_url_idx").on(sc.url),
savedAtIdx: index("storedContent_savedAt_idx").on(sc.savedAt),
titleInx: index("storedContent_title_idx").on(sc.title),
spaceIdx: index("storedContent_space_idx").on(sc.space),
userIdx: index("storedContent_user_idx").on(sc.user),
}),
);
export type StoredContent = typeof storedContent.$inferSelect;
export const spaces = createTable(
"spaces",
{
id: integer("id").notNull().primaryKey({ autoIncrement: true }),
name: text('name').notNull().default('all'),
description: text("description", { length: 255 }),
},
(space) => ({
nameIdx: index("spaces_name_idx").on(space.name),
}),
);
export type StoredContent = Omit<typeof storedContent.$inferSelect, 'user'>

View file

@ -1,6 +1,19 @@
import { StoredContent } from "@/server/db/schema";
import { StoredContent } from '@/server/db/schema';
export type Space = {
export const transformContent = (content: StoredContent[]): CollectedSpaces[] => {
const spaces = Array.from(new Set(content.map((c) => c.space)));
const spaceContent = spaces.map((space, i) => ({
title: space!,
id: i + 1,
description: '',
content: content.filter((c) => c.space === space),
}));
return spaceContent;
};
export type CollectedSpaces = {
id: number;
title: string;
description: string;

View file

@ -58,6 +58,7 @@
"html-metadata-parser": "^2.0.4",
"lucide-react": "^0.343.0",
"next-auth": "beta",
"nuqs": "^1.17.1",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"tailwindcss-animate": "^1.0.7"