diff --git a/.gitignore b/.gitignore index 3dba35c6..2d455eba 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ .pnp.js .yarn/install-state.gz - +src-tauri/ # testing /coverage diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..bd98ef09 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +.next/ +public/ +styles/ +*.md +out/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..88ebf1bf --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "printWidth": 100, + "trailingComma": "es5", + "tabWidth": 2, + "semi": false, + "singleQuote": true, + "jsxSingleQuote": true, + "maxLineLength": 80, + "endOfLine": "lf" +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..273ce37a --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +MIT License + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 6c0bba70..b185bb27 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,14 @@ -# TinyMind +## Cofe -TinyMind is a website that lets you write and sync your blog posts, short thoughts, and memos by signing in with GitHub. Here's how it works: +Cofe is designed to be a simple and easy-to-use blog and memo taking app, originally forked from [tinymind](https://github.com/mazzzystar/tinymind). -1. We create a public repo called "tinymind-blog" in your GitHub account. -2. When you write anything on our webpage, it automatically commits to your `yourname/tinymind-blog` repo. -3. This ensures a seamless way to create content and maintain data persistence. +![screnshot](https://github.com/metrue/cofe/blob/main/data/assets/images/Cofe-app.png?raw=true) -## Data Privacy & Permissions +### HOW TO RUN -We only have write access to your public repositories. Your privacy matters: +Register a new OAuth App on Github, and get the `GITHUB_ID` and `GITHUB_SECRET`, +then run the following command to start the blog: -- Content stored only in your GitHub repo -- No data kept on our servers -- You have full control through your GitHub account - -## TODO - -- [ ] Create a page to showcase all public writers using TinyMind (creator list) -- [ ] Implement shareable user main pages (like https://tinywind.me/mazzzystar) - -## Tech Stack - -Built with Next.js, React, TypeScript, NextAuth.js, and Tailwind CSS. - -## Contribute - -Contributions are welcome! Feel free to submit a Pull Request. - -## License - -[Your chosen license here] +```bash + GITHUB_USERNAME='metrue' GITHUB_ID='GITHUB_ID' GITHUB_SECRET='GITHUB_SECRET' NEXTAUTH_SECRET='NEXTAUTH_SECRET' npm run de +``` diff --git a/api/github/route.ts b/api/github/route.ts new file mode 100644 index 00000000..0f6b4f8d --- /dev/null +++ b/api/github/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createBlogPost, createMemo, deleteBlogPost, deleteMemo, updateBlogPost, updateMemo } from '@/lib/githubApi'; + +import { authOptions } from "@/lib/auth"; +import { createGitHubAPIClient } from '@/lib/client'; +import { getServerSession } from "next-auth/next"; + +export const dynamic = 'force-dynamic'; // Disable caching for this route +export const revalidate = 60; // Revalidate every 60 seconds + +// Add cache control headers +const headers = { + 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=30', + 'Content-Type': 'application/json', +}; + +export async function POST(request: NextRequest) { + try { + console.log('POST request received'); + const session = await getServerSession(authOptions); + + if (!session || !session.accessToken) { + console.log('No valid session found'); + return NextResponse.json({ error: 'Unauthorized' }, { status: 401, headers }); + } + + const { action, ...data } = await request.json(); + console.log('Action:', action); + console.log('Data:', JSON.stringify(data, null, 2)); + + switch (action) { + case 'createBlogPost': + await createBlogPost(data.title, data.content, session.accessToken); + return NextResponse.json({ message: 'Blog post created successfully' }, { headers }); + case 'updateBlogPost': + await updateBlogPost(data.id, data.title, data.content, session.accessToken); + return NextResponse.json({ message: 'Blog post updated successfully' }, { headers }); + case 'deleteBlogPost': + await deleteBlogPost(data.id, session.accessToken); + return NextResponse.json({ message: 'Blog post deleted successfully' }, { headers }); + case 'createMemo': + await createMemo(data.content, data.image, session.accessToken); + return NextResponse.json({ message: 'Memo created successfully' }, { headers }); + case 'updateMemo': + await updateMemo(data.id, data.content, session.accessToken); + return NextResponse.json({ message: 'Memo updated successfully' }, { headers }); + case 'deleteMemo': + await deleteMemo(data.id, session.accessToken); + return NextResponse.json({ message: 'Memo deleted successfully' }, { headers }); + default: + return NextResponse.json({ error: 'Invalid action' }, { status: 400, headers }); + } + } catch (error) { + console.error('Error in /api/github POST:', error); + if (error instanceof Error) { + console.error('Error stack:', error.stack); + return NextResponse.json({ error: error.message, stack: error.stack }, { status: 500, headers }); + } + return NextResponse.json({ error: 'An unexpected error occurred' }, { status: 500, headers }); + } +} + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session || !session.accessToken) { + console.log('No valid session found'); + return NextResponse.json({ error: 'Unauthorized' }, { status: 401, headers }); + } + + const { searchParams } = new URL(request.url); + const action = searchParams.get('action'); + const id = searchParams.get('id'); + + const client = createGitHubAPIClient(session.accessToken) + + switch (action) { + case 'getBlogPosts': + const posts = await client.getBlogPosts(); + return NextResponse.json(posts, { headers }); + case 'getBlogPost': + if (!id) { + return NextResponse.json({ error: 'Missing id parameter' }, { status: 400, headers }); + } + const post = await client.getBlogPost(`${id}.md`); + return NextResponse.json(post, { headers }); + case 'getMemos': + const memos = await client.getMemos() + return NextResponse.json(memos, { headers }); + default: + return NextResponse.json({ error: 'Invalid action' }, { status: 400, headers }); + } + } catch (error) { + console.error('Error in /api/github GET:', error); + if (error instanceof Error) { + return NextResponse.json({ error: error.message }, { status: 500, headers }); + } + return NextResponse.json({ error: 'An unexpected error occurred' }, { status: 500, headers }); + } +} diff --git a/app/api/github/route.ts b/app/api/github/route.ts deleted file mode 100644 index 545171da..00000000 --- a/app/api/github/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/lib/auth"; -import { createBlogPost, createThought, getBlogPosts, getThoughts } from '@/lib/githubApi'; - -export async function POST(request: NextRequest) { - try { - console.log('POST request received'); - const session = await getServerSession(authOptions); - - if (!session || !session.accessToken) { - console.log('No valid session found'); - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { action, ...data } = await request.json(); - console.log('Action:', action); - console.log('Data:', JSON.stringify(data, null, 2)); - - switch (action) { - case 'createBlogPost': - await createBlogPost(data.title, data.content, session.accessToken); - return NextResponse.json({ message: 'Blog post created successfully' }); - case 'createThought': - await createThought(data.content, data.image, session.accessToken); - return NextResponse.json({ message: 'Thought created successfully' }); - default: - return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); - } - } catch (error) { - console.error('Error in /api/github POST:', error); - if (error instanceof Error) { - console.error('Error stack:', error.stack); - return NextResponse.json({ error: error.message, stack: error.stack }, { status: 500 }); - } - return NextResponse.json({ error: 'An unexpected error occurred' }, { status: 500 }); - } -} - -export async function GET(request: NextRequest) { - try { - const session = await getServerSession(authOptions); - - if (!session || !session.accessToken) { - console.log('No valid session found'); - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { searchParams } = new URL(request.url); - const action = searchParams.get('action'); - - switch (action) { - case 'getBlogPosts': - const posts = await getBlogPosts(session.accessToken); - return NextResponse.json(posts); - case 'getThoughts': - const thoughts = await getThoughts(session.accessToken); - return NextResponse.json(thoughts); - default: - return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); - } - } catch (error) { - console.error('Error in /api/github GET:', error); - if (error instanceof Error) { - return NextResponse.json({ error: error.message }, { status: 500 }); - } - return NextResponse.json({ error: 'An unexpected error occurred' }, { status: 500 }); - } -} \ No newline at end of file diff --git a/app/blog/[id]/component.tsx b/app/blog/[id]/component.tsx new file mode 100644 index 00000000..34feabf1 --- /dev/null +++ b/app/blog/[id]/component.tsx @@ -0,0 +1,145 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { BlogPostContent } from '@/components/BlogPostContent' +import type { BlogPost } from '@/lib/types' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { AiOutlineEllipsis } from 'react-icons/ai' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { useSession } from 'next-auth/react' +import { useTranslations } from 'next-intl' +import { useToast } from '@/components/ui/use-toast' + +function removeFrontmatter(content: string): string { + const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/ + return content.replace(frontmatterRegex, '') +} + +function decodeContent(content: string): string { + try { + return decodeURIComponent(content) + } catch (error) { + console.error('Error decoding content:', error) + return content + } +} + +export const PostContainer = ({ post }: { post: BlogPost }) => { + const [isDeleting, setIsDeleting] = useState(false) + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const router = useRouter() + const { toast } = useToast() + // eslint-disable-next-line + const { data: session, status } = useSession() + + const t = useTranslations('HomePage') + + const decodedTitle = decodeContent(post.title) + const decodedContent = decodeContent(post.content) + const contentWithoutFrontmatter = removeFrontmatter(decodedContent) + + const handleDeleteBlogPost = async () => { + if (!session?.accessToken) { + console.error('No access token available') + return + } + + setIsDeleting(true) + try { + const response = await fetch('/api/github', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'deleteBlogPost', + id: post.id, + }), + }) + + if (!response.ok) { + throw new Error('Failed to delete blog post') + } + + toast({ + title: t('success'), + description: t('blogPostDeleted'), + duration: 3000, + }) + + setTimeout(() => { + router.push('/blog') + }, 500) + } catch (error) { + console.error('Error deleting blog post:', error) + toast({ + title: t('error'), + description: t('blogPostDeleteFailed'), + variant: 'destructive', + duration: 3000, + }) + } finally { + setIsDeleting(false) + setIsDeleteDialogOpen(false) + } + } + + const headerContent = ( + <> + + + + + + router.push(`/editor?type=blog&id=${post.id}`)}> + {t('edit')} + + setIsDeleteDialogOpen(true)}> + {t('delete')} + + + + + + + {t('confirmDelete')} + {t('undoAction')} + + + + + + + + + ) + + return ( + + ) +} diff --git a/app/blog/[id]/page.tsx b/app/blog/[id]/page.tsx index 6a74e442..7007e24d 100644 --- a/app/blog/[id]/page.tsx +++ b/app/blog/[id]/page.tsx @@ -1,63 +1,32 @@ -import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/lib/auth"; -import { getBlogPost } from "@/lib/githubApi"; -import Link from "next/link"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { format } from "date-fns"; +import { PostContainer } from './component' +import { getServerSession } from 'next-auth/next' +import { authOptions } from '@/lib/auth' +import { createGitHubAPIClient } from '@/lib/client' +import fs from 'fs' +import path from 'path' -function decodeTitle(title: string): string { - try { - return decodeURIComponent(title); - } catch { - return title; - } -} +export async function generateStaticParams() { + const blogDirectory = path.join(process.cwd(), 'data/blog') + const filenames = fs.readdirSync(blogDirectory) -export default async function BlogPost({ params }: { params: { id: string } }) { - const session = await getServerSession(authOptions); + // Generate params for each blog post based on filenames + const params = filenames.map((filename) => ({ + id: encodeURIComponent(filename.replace(/\.md$/, '')), // Ensure non-ASCII slugs are encoded + })) - if (!session) { - return ( - - -

Please sign in to view this post

- - Sign in - -
-
- ); - } + return params +} - const post = await getBlogPost(params.id, session.accessToken as string); +export default async function Page({ params }: { params: { id: string } }) { + const session = await getServerSession(authOptions) + const username = process.env.GITHUB_USERNAME ?? '' + const client = createGitHubAPIClient(session?.accessToken ?? '') + const posts = await client.getBlogPosts(username) + const post = posts.find((p) => p.id === decodeURIComponent(params.id)) if (!post) { - return ( - - -

Post not found

-
-
- ); + return
Post not found
} - const decodedTitle = decodeTitle(post.title); - const formattedDate = format(new Date(post.date), "MMMM d, yyyy"); - - return ( - - - {decodedTitle} -

{formattedDate}

-
- -
-
-
- - - ); + return } diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 787ede2e..24da0ee9 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -1,18 +1,18 @@ -import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/lib/auth"; import BlogList from "@/components/BlogList"; -import { getBlogPosts } from "@/lib/githubApi"; -import GitHubSignInButton from "@/components/GitHubSignInButton"; +import { authOptions } from "@/lib/auth"; +import { createGitHubAPIClient } from '@/lib/client' +import { getServerSession } from "next-auth/next"; + +export const revalidate = 60; export default async function BlogPage() { const session = await getServerSession(authOptions); - if (!session || !session.accessToken) { - return ; - } + const client = createGitHubAPIClient(session?.accessToken ?? ''); + const username = process.env.GITHUB_USERNAME ?? ''; try { - const posts = await getBlogPosts(session.accessToken); + const posts = await client.getBlogPosts(username ?? ''); return ; } catch (error) { console.error("Error fetching blog posts:", error); diff --git a/app/editor/page.tsx b/app/editor/page.tsx index 78718117..21d5b519 100644 --- a/app/editor/page.tsx +++ b/app/editor/page.tsx @@ -1,14 +1,27 @@ -import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/lib/auth"; import EditorComponent from "@/components/Editor"; import GitHubSignInButton from "@/components/GitHubSignInButton"; +import { authOptions } from "@/lib/auth"; +import { getServerSession } from "next-auth/next"; -export default async function EditorPage() { +export default async function EditorPage({ + searchParams, +}: { + searchParams: { type?: string }; +}) { const session = await getServerSession(authOptions); + const defaultType = searchParams.type === "blog" ? "blog" : "memo"; if (!session) { - return ; + + const username = process.env.GITHUB_USERNAME; + if (!username) { + return ; + } } - return ; + return ( +
+ +
+ ); } diff --git a/app/favicon.ico b/app/favicon.ico index 9778fdeb..f53dec7a 100644 Binary files a/app/favicon.ico and b/app/favicon.ico differ diff --git a/app/fonts/GeistMonoVF.woff b/app/fonts/GeistMonoVF.woff deleted file mode 100644 index f2ae185c..00000000 Binary files a/app/fonts/GeistMonoVF.woff and /dev/null differ diff --git a/app/fonts/GeistVF.woff b/app/fonts/GeistVF.woff deleted file mode 100644 index 1b62daac..00000000 Binary files a/app/fonts/GeistVF.woff and /dev/null differ diff --git a/app/globals.css b/app/globals.css index 46947be4..f16fbd4c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,66 +1,71 @@ @tailwind base; @tailwind components; @tailwind utilities; + @layer base { :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --radius: 0.5rem + --background: #fdfdfd; + --foreground: #303030; + --card: #fdfdfd; + --card-foreground: #303030; + --popover: #fdfdfd; + --popover-foreground: #303030; + --primary: #2357cd; + --primary-foreground: #fdfdfd; + --secondary: #f5f5f5; + --secondary-foreground: #404040; + --muted: #f5f5f5; + --muted-foreground: #707070; + --accent: #f5f5f5; + --accent-foreground: #404040; + --destructive: #652222; + --destructive-foreground: #fdfdfd; + --border: #ccc; + --input: #ccc; + --ring: #2357cd; + --radius: 0.4rem; } + .dark { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55% + --background: #151615; + --foreground: #dbdfdf; + --card: #151615; + --card-foreground: #dbdfdf; + --popover: #151615; + --popover-foreground: #dbdfdf; + --primary: #6eb8ff; + --primary-foreground: #151615; + --secondary: #3a3b3b; + --secondary-foreground: #bfc5c5; + --muted: #3a3b3b; + --muted-foreground: #b4b6b8; + --accent: #3a3b3b; + --accent-foreground: #bfc5c5; + --destructive: #652222; + --destructive-foreground: #dbdfdf; + --border: #414141; + --input: #414141; + --ring: #6eb8ff; } } + @layer base { * { @apply border-border; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; } + body { @apply bg-background text-foreground; + line-height: 1.62em; + margin: 0; + font-size: 18px; + word-wrap: break-word; } -} \ No newline at end of file + + a { + @apply text-primary; + text-underline-offset: 3px; + text-decoration-thickness: 0.5px; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 06b34d7d..cb203dfc 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,58 +1,110 @@ -import type { Metadata } from "next"; -import { Inter } from "next/font/google"; -import "./globals.css"; -import Header from "@/components/Header"; -import { SessionProvider } from "../components/SessionProvider"; -import Link from "next/link"; -import { FiPlus } from "react-icons/fi"; -import { Button } from "@/components/ui/button"; -import Script from "next/script"; -import Footer from "@/components/Footer"; - -const inter = Inter({ subsets: ["latin"] }); - -export const metadata: Metadata = { - title: "TinyMind - Write and sync your blog & memo data with GitHub", - description: - "Create a GitHub account and write blogs, thoughts, and notes using this website. Your data will be automatically saved in a GitHub repository, which means your data will never be lost as long as GitHub exists.p", -}; - -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { +import './globals.css' + +import { getLocale, getMessages, getTranslations } from 'next-intl/server' + +import CreateButton from '@/components/CreateButton' +import Head from 'next/head' +import Header from '@/components/Header' +import { Memo } from '@/lib/types' +import type { Metadata } from 'next' +import { NextIntlClientProvider } from 'next-intl' +import Script from 'next/script' +import { SessionProvider } from '../components/SessionProvider' +import { Toaster } from '@/components/ui/toaster' +import { authOptions } from '@/lib/auth' +import { createGitHubAPIClient } from '@/lib/client' +import { getIconUrls } from '@/lib/githubApi' +import { getServerSession } from 'next-auth/next' +import { gowun_wodum } from '@/components/ui/font' + +export async function generateMetadata(): Promise { + const t = await getTranslations('metadata') + const session = await getServerSession(authOptions) + + const title = + t('title') || + 'Cofe - Write and sync your blog posts & memos with one-click GitHub sign-in' + const description = + t('description') || + 'Write and preserve your blogs, memos, and notes effortlessly. Sign in with GitHub to automatically sync your content to your own repository, ensuring your ideas are safely stored as long as GitHub exists.' + + const { iconPath } = await getIconPaths(session?.accessToken) + + return { + title, + description, + manifest: '/manifest.json', + openGraph: { + title, + description, + images: [{ url: iconPath, width: 512, height: 512, alt: 'App Logo' }], + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title, + description, + images: [iconPath], + }, + } +} + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const locale = await getLocale() + const messages = await getMessages() + const session = await getServerSession(authOptions) + const username = process.env.GITHUB_USERNAME ?? '' + + const memos = await createGitHubAPIClient(session?.accessToken || '').getMemos(username ?? '') + let latestMemo: Memo | undefined + if (memos.length > 0) { + latestMemo = memos.sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + )[0] + } + + const { iconPath } = await getIconPaths(session?.accessToken) + return ( - - - - - - -
-
{children}
-