diff --git a/backend/prisma/migrations/20250409231956_add_image_url/migration.sql b/backend/prisma/migrations/20250409231956_add_image_url/migration.sql new file mode 100644 index 0000000..9b4f6f4 --- /dev/null +++ b/backend/prisma/migrations/20250409231956_add_image_url/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Article" ADD COLUMN "imageUrl" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a67e0ba..914cdb0 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -35,6 +35,7 @@ model Article { updatedAt DateTime @updatedAt categories String[] authors String[] + imageUrl String? Note Note[] @@index([url]) diff --git a/backend/src/articles/articles.controller.ts b/backend/src/articles/articles.controller.ts index 0bde02d..bfff736 100644 --- a/backend/src/articles/articles.controller.ts +++ b/backend/src/articles/articles.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { Controller, Get, Query, Param } from '@nestjs/common'; import { ArticlesService } from './articles.service'; @Controller('api/articles') @@ -20,4 +20,9 @@ export class ArticlesController { async getCount() { return { count: await this.articlesService.count() }; } + + @Get(':id') + async findOne(@Param('id') id: string) { + return this.articlesService.findOne(id); + } } diff --git a/backend/src/articles/articles.service.ts b/backend/src/articles/articles.service.ts index e48a9f7..ece3bdd 100644 --- a/backend/src/articles/articles.service.ts +++ b/backend/src/articles/articles.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; 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 { return this.prisma.article.count(); } diff --git a/backend/src/rss/rss.controller.ts b/backend/src/rss/rss.controller.ts index 0f4eda3..60618cf 100644 --- a/backend/src/rss/rss.controller.ts +++ b/backend/src/rss/rss.controller.ts @@ -6,7 +6,9 @@ export class RssController { constructor(private readonly rssService: RssService) {} @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); return { message: 'RSS feed processed and articles stored successfully.' }; } @@ -15,4 +17,4 @@ export class RssController { async getAllArticles() { return this.rssService.getAllArticles(); } -} \ No newline at end of file +} diff --git a/backend/src/rss/rss.service.ts b/backend/src/rss/rss.service.ts index 9989757..4080baf 100644 --- a/backend/src/rss/rss.service.ts +++ b/backend/src/rss/rss.service.ts @@ -5,25 +5,76 @@ import * as Parser from 'rss-parser'; @Injectable() export class RssService { constructor(private prisma: PrismaService) {} - feddUrl: string = 'https://www.rt.com/rss/'; - async fetchAndStoreArticles(feedUrl: string): Promise { - const parser = new Parser(); - const feed = await parser.parseURL(feedUrl); - for (const item of feed.items) { - await this.prisma.article.upsert({ - where: { url: item.link }, - update: {}, - create: { - title: item.title || 'Untitled', - content: item.contentSnippet || '', - source: feed.title || 'Unknown Source', - url: item.link || '', - publishedAt: item.isoDate ? new Date(item.isoDate) : new Date(), - categories: item.categories || [], - authors: item.creator ? [item.creator] : [], - }, - }); + private parser: Parser; + + onModuleInit() { + // Configure parser with custom fields + this.parser = new Parser({ + customFields: { + item: [ + ['content:encoded', 'contentEncoded'], + ['dc:creator', '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 { + 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(/]+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; } } diff --git a/frontend/src/api/articles.ts b/frontend/src/api/articles.ts index 7b152c0..5379edb 100644 --- a/frontend/src/api/articles.ts +++ b/frontend/src/api/articles.ts @@ -33,4 +33,12 @@ export async function getArticleCount(): Promise { } const data = await response.json(); return data.count; +} + +export async function fetchArticle(id: string): Promise
{ + const response = await fetch(`/api/articles/${id}`); + if (!response.ok) { + throw new Error('Failed to fetch article'); + } + return response.json(); } \ No newline at end of file diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 7409686..88a8cb1 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRoute } from './routes/__root' import { Route as RegisterImport } from './routes/register' +import { Route as NotebookImport } from './routes/notebook' import { Route as NotFoundImport } from './routes/notFound' import { Route as LoginImport } from './routes/login' import { Route as DashboardImport } from './routes/dashboard' @@ -29,6 +30,12 @@ const RegisterRoute = RegisterImport.update({ getParentRoute: () => rootRoute, } as any) +const NotebookRoute = NotebookImport.update({ + id: '/notebook', + path: '/notebook', + getParentRoute: () => rootRoute, +} as any) + const NotFoundRoute = NotFoundImport.update({ id: '/notFound', path: '/notFound', @@ -123,6 +130,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof NotFoundImport parentRoute: typeof rootRoute } + '/notebook': { + id: '/notebook' + path: '/notebook' + fullPath: '/notebook' + preLoaderRoute: typeof NotebookImport + parentRoute: typeof rootRoute + } '/register': { id: '/register' path: '/register' @@ -180,6 +194,7 @@ export interface FileRoutesByFullPath { '/dashboard': typeof DashboardRouteWithChildren '/login': typeof LoginRoute '/notFound': typeof NotFoundRoute + '/notebook': typeof NotebookRoute '/register': typeof RegisterRoute '/collections/[name]': typeof CollectionsnameRoute '/dashboard/': typeof DashboardIndexRoute @@ -191,6 +206,7 @@ export interface FileRoutesByTo { '/collections': typeof CollectionsRouteWithChildren '/login': typeof LoginRoute '/notFound': typeof NotFoundRoute + '/notebook': typeof NotebookRoute '/register': typeof RegisterRoute '/collections/[name]': typeof CollectionsnameRoute '/dashboard': typeof DashboardIndexRoute @@ -204,6 +220,7 @@ export interface FileRoutesById { '/dashboard': typeof DashboardRouteWithChildren '/login': typeof LoginRoute '/notFound': typeof NotFoundRoute + '/notebook': typeof NotebookRoute '/register': typeof RegisterRoute '/collections/[name]': typeof CollectionsnameRoute '/dashboard/': typeof DashboardIndexRoute @@ -218,6 +235,7 @@ export interface FileRouteTypes { | '/dashboard' | '/login' | '/notFound' + | '/notebook' | '/register' | '/collections/[name]' | '/dashboard/' @@ -228,6 +246,7 @@ export interface FileRouteTypes { | '/collections' | '/login' | '/notFound' + | '/notebook' | '/register' | '/collections/[name]' | '/dashboard' @@ -239,6 +258,7 @@ export interface FileRouteTypes { | '/dashboard' | '/login' | '/notFound' + | '/notebook' | '/register' | '/collections/[name]' | '/dashboard/' @@ -252,6 +272,7 @@ export interface RootRouteChildren { DashboardRoute: typeof DashboardRouteWithChildren LoginRoute: typeof LoginRoute NotFoundRoute: typeof NotFoundRoute + NotebookRoute: typeof NotebookRoute RegisterRoute: typeof RegisterRoute } @@ -262,6 +283,7 @@ const rootRouteChildren: RootRouteChildren = { DashboardRoute: DashboardRouteWithChildren, LoginRoute: LoginRoute, NotFoundRoute: NotFoundRoute, + NotebookRoute: NotebookRoute, RegisterRoute: RegisterRoute, } @@ -281,6 +303,7 @@ export const routeTree = rootRoute "/dashboard", "/login", "/notFound", + "/notebook", "/register" ] }, @@ -308,6 +331,9 @@ export const routeTree = rootRoute "/notFound": { "filePath": "notFound.tsx" }, + "/notebook": { + "filePath": "notebook.tsx" + }, "/register": { "filePath": "register.tsx" }, diff --git a/frontend/src/routes/articles.tsx b/frontend/src/routes/articles.tsx index 78c8bd6..c41ac89 100644 --- a/frontend/src/routes/articles.tsx +++ b/frontend/src/routes/articles.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { useInfiniteQuery } from '@tanstack/react-query' -import { fetchArticles } from '@/api/articles' +import { fetchArticles, PaginatedArticles } from '@/api/articles' import { Button } from '@/components/ui/button' import { addArticleToCollection, createCollection, fetchCollections } from '@/api/collections'; import { useUser } from '@/store/user'; @@ -61,15 +61,13 @@ function ArticlesPage() { fetchNextPage, hasNextPage, isFetchingNextPage - } = useInfiniteQuery({ - queryKey: ['articles'], - queryFn: async ({ pageParam = undefined }) => { - const response = await fetchArticles(pageParam); - return response; + } = useInfiniteQuery<{ pages: PaginatedArticles[] }>({ + queryKey: ['articles'] as const, + queryFn: async ({ pageParam }) => { + return fetchArticles(pageParam as string | undefined); }, getNextPageParam: (lastPage) => lastPage.nextCursor, initialPageParam: undefined as string | undefined, - staleTime: 1000 * 60 // Cache for 1 minute }); const user = useUser(); @@ -145,20 +143,30 @@ function ArticlesPage() {

{article.content?.substring(0, 120)}...

-
- {article.source} - - {new Date(article.publishedAt).toLocaleDateString()} - +

+ Source: {article.source} | Published: {new Date(article.publishedAt).toLocaleDateString()} +

+
+ +
-
))} diff --git a/frontend/src/routes/notebook.tsx b/frontend/src/routes/notebook.tsx new file mode 100644 index 0000000..2007fe3 --- /dev/null +++ b/frontend/src/routes/notebook.tsx @@ -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): { 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 ( +
+
+
+ ) + } + + if (isError) { + return ( +
+

Failed to load article

+ +
+ ) + } + + if (!article) { + return ( +
+

No Article Selected

+ +
+ ) + } + + 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 ( +
+
+

Article Notebook

+ +
+ +
+ {cells.map((cell, index) => ( +
+ {cell.source.map((line, i) => ( +
+ {line} +
+ ))} +
+ ))} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 1a894d9..91a4cd0 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -70,5 +70,8 @@ export default { }, }, }, - plugins: [require("tailwindcss-animate")], + plugins: [ + require("tailwindcss-animate"), + require("@tailwindcss/typography") + ], } \ No newline at end of file