check point, notebook basic structure defined

This commit is contained in:
dimitar 2025-04-10 01:30:47 +02:00
parent 98c9328a3d
commit 5a01897a44
11 changed files with 303 additions and 43 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Article" ADD COLUMN "imageUrl" TEXT;

View File

@ -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])

View File

@ -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);
}
} }

View File

@ -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();
} }

View File

@ -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.' };
} }

View File

@ -5,26 +5,77 @@ 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(/&nbsp;/g, ' ') // Replace &nbsp; with space
.replace(/&amp;/g, '&') // Replace &amp; with &
.replace(/&quot;/g, '"') // Replace &quot; with "
.replace(/&#39;/g, "'"); // Replace &#39; 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;
}
} }
async getAllArticles() { async getAllArticles() {

View File

@ -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();
}

View File

@ -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"
}, },

View File

@ -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>
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="mt-4 w-full" className="flex-1"
onClick={() => handleAddToCollection(article, user?.id, navigate)} onClick={() => handleAddToCollection(article, user?.id, navigate)}
> >
Add to Collection Add to Collection
</Button> </Button>
<Button
variant="secondary"
size="sm"
className="flex-1"
onClick={() => navigate({
to: '/notebook',
search: { articleId: article.id }
})}
>
Open in Notebook
</Button>
</div>
</div> </div>
</div> </div>
))} ))}

View 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>
)
}

View File

@ -70,5 +70,8 @@ export default {
}, },
}, },
}, },
plugins: [require("tailwindcss-animate")], plugins: [
require("tailwindcss-animate"),
require("@tailwindcss/typography")
],
} }