check point, notebook basic structure defined
This commit is contained in:
parent
98c9328a3d
commit
5a01897a44
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Article" ADD COLUMN "imageUrl" TEXT;
|
||||||
@ -35,6 +35,7 @@ model Article {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
categories String[]
|
categories String[]
|
||||||
authors String[]
|
authors String[]
|
||||||
|
imageUrl String?
|
||||||
Note Note[]
|
Note Note[]
|
||||||
|
|
||||||
@@index([url])
|
@@index([url])
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Query } from '@nestjs/common';
|
import { Controller, Get, Query, Param } from '@nestjs/common';
|
||||||
import { ArticlesService } from './articles.service';
|
import { ArticlesService } from './articles.service';
|
||||||
|
|
||||||
@Controller('api/articles')
|
@Controller('api/articles')
|
||||||
@ -20,4 +20,9 @@ export class ArticlesController {
|
|||||||
async getCount() {
|
async getCount() {
|
||||||
return { count: await this.articlesService.count() };
|
return { count: await this.articlesService.count() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async findOne(@Param('id') id: string) {
|
||||||
|
return this.articlesService.findOne(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PaginatedArticles } from './types';
|
import { PaginatedArticles } from './types';
|
||||||
|
|
||||||
@ -46,6 +46,28 @@ export class ArticlesService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const article = await this.prisma.article.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
source: true,
|
||||||
|
url: true,
|
||||||
|
publishedAt: true,
|
||||||
|
categories: true,
|
||||||
|
authors: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
throw new NotFoundException(`Article with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return article;
|
||||||
|
}
|
||||||
|
|
||||||
async count(): Promise<number> {
|
async count(): Promise<number> {
|
||||||
return this.prisma.article.count();
|
return this.prisma.article.count();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,9 @@ export class RssController {
|
|||||||
constructor(private readonly rssService: RssService) {}
|
constructor(private readonly rssService: RssService) {}
|
||||||
|
|
||||||
@Post('fetch')
|
@Post('fetch')
|
||||||
async fetchAndStore(@Body('feedUrl') feedUrl: string): Promise<{ message: string }> {
|
async fetchAndStore(
|
||||||
|
@Body('feedUrl') feedUrl: string,
|
||||||
|
): Promise<{ message: string }> {
|
||||||
await this.rssService.fetchAndStoreArticles(feedUrl);
|
await this.rssService.fetchAndStoreArticles(feedUrl);
|
||||||
return { message: 'RSS feed processed and articles stored successfully.' };
|
return { message: 'RSS feed processed and articles stored successfully.' };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,25 +5,76 @@ import * as Parser from 'rss-parser';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class RssService {
|
export class RssService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
feddUrl: string = 'https://www.rt.com/rss/';
|
|
||||||
async fetchAndStoreArticles(feedUrl: string): Promise<void> {
|
|
||||||
const parser = new Parser();
|
|
||||||
const feed = await parser.parseURL(feedUrl);
|
|
||||||
|
|
||||||
for (const item of feed.items) {
|
private parser: Parser;
|
||||||
await this.prisma.article.upsert({
|
|
||||||
where: { url: item.link },
|
onModuleInit() {
|
||||||
update: {},
|
// Configure parser with custom fields
|
||||||
create: {
|
this.parser = new Parser({
|
||||||
title: item.title || 'Untitled',
|
customFields: {
|
||||||
content: item.contentSnippet || '',
|
item: [
|
||||||
source: feed.title || 'Unknown Source',
|
['content:encoded', 'contentEncoded'],
|
||||||
url: item.link || '',
|
['dc:creator', 'creator'],
|
||||||
publishedAt: item.isoDate ? new Date(item.isoDate) : new Date(),
|
],
|
||||||
categories: item.categories || [],
|
},
|
||||||
authors: item.creator ? [item.creator] : [],
|
});
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
private cleanHtml(html: string): string {
|
||||||
|
// Remove HTML tags and decode entities
|
||||||
|
return html
|
||||||
|
.replace(/<[^>]*>/g, '') // Remove HTML tags
|
||||||
|
.replace(/ /g, ' ') // Replace with space
|
||||||
|
.replace(/&/g, '&') // Replace & with &
|
||||||
|
.replace(/"/g, '"') // Replace " with "
|
||||||
|
.replace(/'/g, "'"); // Replace ' with '
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAndStoreArticles(feedUrl: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const feed = await this.parser.parseURL(feedUrl);
|
||||||
|
|
||||||
|
for (const item of feed.items) {
|
||||||
|
// Extract content from either contentEncoded or description
|
||||||
|
const rawContent = item.contentEncoded || item.content || item.description || '';
|
||||||
|
const cleanContent = this.cleanHtml(rawContent);
|
||||||
|
|
||||||
|
// Get the first image URL from content if available
|
||||||
|
const imageMatch = rawContent.match(/<img[^>]+src="([^">]+)"/);
|
||||||
|
const imageUrl = imageMatch ? imageMatch[1] : null;
|
||||||
|
|
||||||
|
// Extract categories and authors
|
||||||
|
const categories = Array.isArray(item.categories) ? item.categories : [];
|
||||||
|
const authors = item.creator ? [item.creator] : [];
|
||||||
|
|
||||||
|
await this.prisma.article.upsert({
|
||||||
|
where: { url: item.link || '' },
|
||||||
|
update: {
|
||||||
|
title: item.title || 'Untitled',
|
||||||
|
content: cleanContent,
|
||||||
|
source: feed.title || 'RT',
|
||||||
|
url: item.link || '',
|
||||||
|
publishedAt: item.isoDate ? new Date(item.isoDate) : new Date(),
|
||||||
|
categories,
|
||||||
|
authors,
|
||||||
|
imageUrl,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
title: item.title || 'Untitled',
|
||||||
|
content: cleanContent,
|
||||||
|
source: feed.title || 'RT',
|
||||||
|
url: item.link || '',
|
||||||
|
publishedAt: item.isoDate ? new Date(item.isoDate) : new Date(),
|
||||||
|
categories,
|
||||||
|
authors,
|
||||||
|
imageUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching RSS feed:', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,3 +34,11 @@ export async function getArticleCount(): Promise<number> {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.count;
|
return data.count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchArticle(id: string): Promise<Article> {
|
||||||
|
const response = await fetch(`/api/articles/${id}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch article');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import { Route as rootRoute } from './routes/__root'
|
import { Route as rootRoute } from './routes/__root'
|
||||||
import { Route as RegisterImport } from './routes/register'
|
import { Route as RegisterImport } from './routes/register'
|
||||||
|
import { Route as NotebookImport } from './routes/notebook'
|
||||||
import { Route as NotFoundImport } from './routes/notFound'
|
import { Route as NotFoundImport } from './routes/notFound'
|
||||||
import { Route as LoginImport } from './routes/login'
|
import { Route as LoginImport } from './routes/login'
|
||||||
import { Route as DashboardImport } from './routes/dashboard'
|
import { Route as DashboardImport } from './routes/dashboard'
|
||||||
@ -29,6 +30,12 @@ const RegisterRoute = RegisterImport.update({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
|
const NotebookRoute = NotebookImport.update({
|
||||||
|
id: '/notebook',
|
||||||
|
path: '/notebook',
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
const NotFoundRoute = NotFoundImport.update({
|
const NotFoundRoute = NotFoundImport.update({
|
||||||
id: '/notFound',
|
id: '/notFound',
|
||||||
path: '/notFound',
|
path: '/notFound',
|
||||||
@ -123,6 +130,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof NotFoundImport
|
preLoaderRoute: typeof NotFoundImport
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
}
|
}
|
||||||
|
'/notebook': {
|
||||||
|
id: '/notebook'
|
||||||
|
path: '/notebook'
|
||||||
|
fullPath: '/notebook'
|
||||||
|
preLoaderRoute: typeof NotebookImport
|
||||||
|
parentRoute: typeof rootRoute
|
||||||
|
}
|
||||||
'/register': {
|
'/register': {
|
||||||
id: '/register'
|
id: '/register'
|
||||||
path: '/register'
|
path: '/register'
|
||||||
@ -180,6 +194,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/dashboard': typeof DashboardRouteWithChildren
|
'/dashboard': typeof DashboardRouteWithChildren
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/notFound': typeof NotFoundRoute
|
'/notFound': typeof NotFoundRoute
|
||||||
|
'/notebook': typeof NotebookRoute
|
||||||
'/register': typeof RegisterRoute
|
'/register': typeof RegisterRoute
|
||||||
'/collections/[name]': typeof CollectionsnameRoute
|
'/collections/[name]': typeof CollectionsnameRoute
|
||||||
'/dashboard/': typeof DashboardIndexRoute
|
'/dashboard/': typeof DashboardIndexRoute
|
||||||
@ -191,6 +206,7 @@ export interface FileRoutesByTo {
|
|||||||
'/collections': typeof CollectionsRouteWithChildren
|
'/collections': typeof CollectionsRouteWithChildren
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/notFound': typeof NotFoundRoute
|
'/notFound': typeof NotFoundRoute
|
||||||
|
'/notebook': typeof NotebookRoute
|
||||||
'/register': typeof RegisterRoute
|
'/register': typeof RegisterRoute
|
||||||
'/collections/[name]': typeof CollectionsnameRoute
|
'/collections/[name]': typeof CollectionsnameRoute
|
||||||
'/dashboard': typeof DashboardIndexRoute
|
'/dashboard': typeof DashboardIndexRoute
|
||||||
@ -204,6 +220,7 @@ export interface FileRoutesById {
|
|||||||
'/dashboard': typeof DashboardRouteWithChildren
|
'/dashboard': typeof DashboardRouteWithChildren
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/notFound': typeof NotFoundRoute
|
'/notFound': typeof NotFoundRoute
|
||||||
|
'/notebook': typeof NotebookRoute
|
||||||
'/register': typeof RegisterRoute
|
'/register': typeof RegisterRoute
|
||||||
'/collections/[name]': typeof CollectionsnameRoute
|
'/collections/[name]': typeof CollectionsnameRoute
|
||||||
'/dashboard/': typeof DashboardIndexRoute
|
'/dashboard/': typeof DashboardIndexRoute
|
||||||
@ -218,6 +235,7 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/notFound'
|
| '/notFound'
|
||||||
|
| '/notebook'
|
||||||
| '/register'
|
| '/register'
|
||||||
| '/collections/[name]'
|
| '/collections/[name]'
|
||||||
| '/dashboard/'
|
| '/dashboard/'
|
||||||
@ -228,6 +246,7 @@ export interface FileRouteTypes {
|
|||||||
| '/collections'
|
| '/collections'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/notFound'
|
| '/notFound'
|
||||||
|
| '/notebook'
|
||||||
| '/register'
|
| '/register'
|
||||||
| '/collections/[name]'
|
| '/collections/[name]'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
@ -239,6 +258,7 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/notFound'
|
| '/notFound'
|
||||||
|
| '/notebook'
|
||||||
| '/register'
|
| '/register'
|
||||||
| '/collections/[name]'
|
| '/collections/[name]'
|
||||||
| '/dashboard/'
|
| '/dashboard/'
|
||||||
@ -252,6 +272,7 @@ export interface RootRouteChildren {
|
|||||||
DashboardRoute: typeof DashboardRouteWithChildren
|
DashboardRoute: typeof DashboardRouteWithChildren
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
NotFoundRoute: typeof NotFoundRoute
|
NotFoundRoute: typeof NotFoundRoute
|
||||||
|
NotebookRoute: typeof NotebookRoute
|
||||||
RegisterRoute: typeof RegisterRoute
|
RegisterRoute: typeof RegisterRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,6 +283,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
DashboardRoute: DashboardRouteWithChildren,
|
DashboardRoute: DashboardRouteWithChildren,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
NotFoundRoute: NotFoundRoute,
|
NotFoundRoute: NotFoundRoute,
|
||||||
|
NotebookRoute: NotebookRoute,
|
||||||
RegisterRoute: RegisterRoute,
|
RegisterRoute: RegisterRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -281,6 +303,7 @@ export const routeTree = rootRoute
|
|||||||
"/dashboard",
|
"/dashboard",
|
||||||
"/login",
|
"/login",
|
||||||
"/notFound",
|
"/notFound",
|
||||||
|
"/notebook",
|
||||||
"/register"
|
"/register"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -308,6 +331,9 @@ export const routeTree = rootRoute
|
|||||||
"/notFound": {
|
"/notFound": {
|
||||||
"filePath": "notFound.tsx"
|
"filePath": "notFound.tsx"
|
||||||
},
|
},
|
||||||
|
"/notebook": {
|
||||||
|
"filePath": "notebook.tsx"
|
||||||
|
},
|
||||||
"/register": {
|
"/register": {
|
||||||
"filePath": "register.tsx"
|
"filePath": "register.tsx"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||||
import { fetchArticles } from '@/api/articles'
|
import { fetchArticles, PaginatedArticles } from '@/api/articles'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { addArticleToCollection, createCollection, fetchCollections } from '@/api/collections';
|
import { addArticleToCollection, createCollection, fetchCollections } from '@/api/collections';
|
||||||
import { useUser } from '@/store/user';
|
import { useUser } from '@/store/user';
|
||||||
@ -61,15 +61,13 @@ function ArticlesPage() {
|
|||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage
|
isFetchingNextPage
|
||||||
} = useInfiniteQuery({
|
} = useInfiniteQuery<{ pages: PaginatedArticles[] }>({
|
||||||
queryKey: ['articles'],
|
queryKey: ['articles'] as const,
|
||||||
queryFn: async ({ pageParam = undefined }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
const response = await fetchArticles(pageParam);
|
return fetchArticles(pageParam as string | undefined);
|
||||||
return response;
|
|
||||||
},
|
},
|
||||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
staleTime: 1000 * 60 // Cache for 1 minute
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
@ -145,20 +143,30 @@ function ArticlesPage() {
|
|||||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-2">
|
<p className="text-muted-foreground text-sm mb-4 line-clamp-2">
|
||||||
{article.content?.substring(0, 120)}...
|
{article.content?.substring(0, 120)}...
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-between">
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
<span className="text-xs text-muted-foreground">{article.source}</span>
|
Source: {article.source} | Published: {new Date(article.publishedAt).toLocaleDateString()}
|
||||||
<span className="text-xs text-muted-foreground">
|
</p>
|
||||||
{new Date(article.publishedAt).toLocaleDateString()}
|
<div className="flex items-center justify-between gap-2">
|
||||||
</span>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => handleAddToCollection(article, user?.id, navigate)}
|
||||||
|
>
|
||||||
|
Add to Collection
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => navigate({
|
||||||
|
to: '/notebook',
|
||||||
|
search: { articleId: article.id }
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Open in Notebook
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="mt-4 w-full"
|
|
||||||
onClick={() => handleAddToCollection(article, user?.id, navigate)}
|
|
||||||
>
|
|
||||||
Add to Collection
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
132
frontend/src/routes/notebook.tsx
Normal file
132
frontend/src/routes/notebook.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { Article } from '@/types'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { fetchArticle } from '@/api/articles'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/notebook')({
|
||||||
|
component: NotebookPage,
|
||||||
|
validateSearch: (search: Record<string, unknown>): { articleId?: string; article?: Article } => {
|
||||||
|
return {
|
||||||
|
articleId: search.articleId as string,
|
||||||
|
article: search.article as Article
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function NotebookPage() {
|
||||||
|
const { articleId, article: preloadedArticle } = Route.useSearch()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { data: fetchedArticle, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['article', articleId],
|
||||||
|
queryFn: () => {
|
||||||
|
if (!articleId) throw new Error('No article ID provided');
|
||||||
|
return fetchArticle(articleId);
|
||||||
|
},
|
||||||
|
enabled: !!articleId && !preloadedArticle,
|
||||||
|
})
|
||||||
|
|
||||||
|
const article = preloadedArticle || fetchedArticle
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin w-8 h-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[50vh] space-y-4">
|
||||||
|
<h1 className="text-2xl font-semibold">Failed to load article</h1>
|
||||||
|
<Button onClick={() => navigate({ to: '/articles' })}>
|
||||||
|
Back to Articles
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[50vh] space-y-4">
|
||||||
|
<h1 className="text-2xl font-semibold">No Article Selected</h1>
|
||||||
|
<Button onClick={() => navigate({ to: '/articles' })}>
|
||||||
|
Browse Articles
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells = [
|
||||||
|
{
|
||||||
|
cell_type: 'markdown',
|
||||||
|
metadata: {
|
||||||
|
language: 'markdown'
|
||||||
|
},
|
||||||
|
source: [
|
||||||
|
`# ${article.title}`,
|
||||||
|
'',
|
||||||
|
`Source: ${article.source}`,
|
||||||
|
`Published: ${new Date(article.publishedAt).toLocaleString()}`,
|
||||||
|
article.authors.length > 0 ? `Authors: ${article.authors.join(', ')}` : '',
|
||||||
|
article.categories.length > 0 ? `Categories: ${article.categories.join(', ')}` : '',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
''
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cell_type: 'markdown',
|
||||||
|
metadata: {
|
||||||
|
language: 'markdown'
|
||||||
|
},
|
||||||
|
source: [article.content.split('\n').map(line => line.trim()).join('\n\n')]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cell_type: 'markdown',
|
||||||
|
metadata: {
|
||||||
|
language: 'markdown'
|
||||||
|
},
|
||||||
|
source: [
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'## Additional Information',
|
||||||
|
'',
|
||||||
|
`Original Article: [${article.url}](${article.url})`
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 max-w-4xl">
|
||||||
|
<div className="mb-6 flex justify-between items-center">
|
||||||
|
<h1 className="text-3xl font-bold">Article Notebook</h1>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate({ to: '/articles' })}
|
||||||
|
>
|
||||||
|
Back to Articles
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{cells.map((cell, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="prose prose-gray text-white dark:prose-invert max-w-none border rounded-lg p-8 bg-card"
|
||||||
|
>
|
||||||
|
{cell.source.map((line, i) => (
|
||||||
|
<div key={i} className="whitespace-pre-wrap">
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -70,5 +70,8 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [
|
||||||
|
require("tailwindcss-animate"),
|
||||||
|
require("@tailwindcss/typography")
|
||||||
|
],
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user