prototype working state

This commit is contained in:
dimitar 2025-04-09 23:25:31 +02:00
parent fdae53f82f
commit eada8b3eb1
86 changed files with 22240 additions and 0 deletions

55
.gitignore vendored Normal file
View File

@ -0,0 +1,55 @@
# Dependencies
node_modules
.pnp
.pnp.js
.yarn/install-state.gz
# Testing
coverage
.nyc_output
# Production
build/
dist/
dist-ssr/
*.local
# Environment files
.env
.env.*
!.env.example
# IDE
.idea/
.vscode/*
!.vscode/extensions.json
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# TypeScript
*.tsbuildinfo
# System Files
.DS_Store
Thumbs.db
# Prisma
prisma/*.db
prisma/migrations/*/
# Temporary files
*.tmp
.tmp/
temp/

3
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
# Keep environment variables out of version control
.env

4
backend/.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

98
backend/README.md Normal file
View File

@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

35
backend/eslint.config.mjs Normal file
View File

@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
ecmaVersion: 5,
sourceType: 'module',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn'
},
},
);

8
backend/nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

30
backend/nohup.out Normal file
View File

@ -0,0 +1,30 @@
> backend@0.0.1 start:dev
> nest start --watch
[11:59:27 PM] Starting compilation in watch mode...
[11:59:29 PM] Found 0 errors. Watching for file changes.
[Nest] 14734 - 04/07/2025, 11:59:29 PM  LOG [NestFactory] Starting Nest application...
[Nest] 14734 - 04/07/2025, 11:59:29 PM  LOG [InstanceLoader] PrismaModule dependencies initialized +10ms
[Nest] 14734 - 04/07/2025, 11:59:29 PM  LOG [InstanceLoader] AppModule dependencies initialized +1ms
[Nest] 14734 - 04/07/2025, 11:59:29 PM  LOG [InstanceLoader] ArticlesModule dependencies initialized +0ms
[Nest] 14734 - 04/07/2025, 11:59:29 PM  LOG [RoutesResolver] AppController {/}: +3ms
[Nest] 14734 - 04/07/2025, 11:59:29 PM  LOG [RouterExplorer] Mapped {/, GET} route +3ms
[Nest] 14734 - 04/07/2025, 11:59:29 PM  LOG [RoutesResolver] ArticlesController {/articles}: +0ms
[Nest] 14734 - 04/07/2025, 11:59:29 PM  LOG [RouterExplorer] Mapped {/articles, GET} route +1ms
[Nest] 14734 - 04/07/2025, 11:59:29 PM  LOG [NestApplication] Nest application successfully started +64ms
[12:17:20 AM] File change detected. Starting incremental compilation...
[12:17:20 AM] Found 0 errors. Watching for file changes.
[Nest] 25027 - 04/08/2025, 12:17:21 AM  LOG [NestFactory] Starting Nest application...
[Nest] 25027 - 04/08/2025, 12:17:21 AM  LOG [InstanceLoader] PrismaModule dependencies initialized +10ms
[Nest] 25027 - 04/08/2025, 12:17:21 AM  LOG [InstanceLoader] AppModule dependencies initialized +0ms
[Nest] 25027 - 04/08/2025, 12:17:21 AM  LOG [InstanceLoader] ArticlesModule dependencies initialized +0ms
[Nest] 25027 - 04/08/2025, 12:17:21 AM  LOG [RoutesResolver] AppController {/}: +4ms
[Nest] 25027 - 04/08/2025, 12:17:21 AM  LOG [RouterExplorer] Mapped {/, GET} route +2ms
[Nest] 25027 - 04/08/2025, 12:17:21 AM  LOG [RoutesResolver] ArticlesController {/articles}: +0ms
[Nest] 25027 - 04/08/2025, 12:17:21 AM  LOG [RouterExplorer] Mapped {/articles, GET} route +0ms
[Nest] 25027 - 04/08/2025, 12:17:21 AM  LOG [NestApplication] Nest application successfully started +116ms

12839
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

83
backend/package.json Normal file
View File

@ -0,0 +1,83 @@
{
"name": "backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^11.0.1",
"@prisma/client": "^6.6.0",
"@types/bcrypt": "^5.0.2",
"@types/passport-jwt": "^4.0.1",
"bcrypt": "^5.1.1",
"bullmq": "^5.47.2",
"passport-jwt": "^4.0.1",
"prisma": "^6.6.0",
"redis": "^4.7.0",
"reflect-metadata": "^0.2.2",
"rss-parser": "^3.13.0",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@ -0,0 +1,32 @@
-- CreateTable
CREATE TABLE "Article" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"source" TEXT NOT NULL,
"url" TEXT NOT NULL,
"publishedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"categories" TEXT[],
"authors" TEXT[],
CONSTRAINT "Article_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Note" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"articleId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Note_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Article_url_key" ON "Article"("url");
-- AddForeignKey
ALTER TABLE "Note" ADD CONSTRAINT "Note_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");

View File

@ -0,0 +1,56 @@
/*
Warnings:
- Added the required column `collectionId` to the `Note` table without a default value. This is not possible if the table is not empty.
- Added the required column `title` to the `Note` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "Note" DROP CONSTRAINT "Note_articleId_fkey";
-- AlterTable
ALTER TABLE "Article" ADD COLUMN "collectionId" TEXT;
-- AlterTable
ALTER TABLE "Note" ADD COLUMN "collectionId" TEXT NOT NULL,
ADD COLUMN "shared" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "sharedVia" TEXT,
ADD COLUMN "title" TEXT NOT NULL,
ALTER COLUMN "articleId" DROP NOT NULL;
-- CreateTable
CREATE TABLE "Collection" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Collection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "QuickNote" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"shared" BOOLEAN NOT NULL DEFAULT false,
"sharedVia" TEXT,
"collectionId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "QuickNote_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Article" ADD CONSTRAINT "Article_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuickNote" ADD CONSTRAINT "QuickNote_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Note" ADD CONSTRAINT "Note_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Note" ADD CONSTRAINT "Note_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,17 @@
/*
Warnings:
- Added the required column `userId` to the `Collection` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Collection" ADD COLUMN "userId" TEXT;
-- Update existing rows with a default user ID
UPDATE "Collection" SET "userId" = 'default-user-id' WHERE "userId" IS NULL;
-- AlterTable
ALTER TABLE "Collection" ALTER COLUMN "userId" SET NOT NULL;
-- AddForeignKey
ALTER TABLE "Collection" ADD CONSTRAINT "Collection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,45 @@
/*
Warnings:
- You are about to drop the column `collectionId` on the `Article` table. All the data in the column will be lost.
- Added the required column `userId` to the `QuickNote` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "Article" DROP CONSTRAINT "Article_collectionId_fkey";
-- AlterTable
ALTER TABLE "Article" DROP COLUMN "collectionId";
-- AlterTable
ALTER TABLE "Note" ADD COLUMN "userId" TEXT;
-- AlterTable
ALTER TABLE "QuickNote" ADD COLUMN "articleId" TEXT,
ADD COLUMN "userId" TEXT NOT NULL;
-- CreateTable
CREATE TABLE "_ArticleCollections" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_ArticleCollections_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE INDEX "_ArticleCollections_B_index" ON "_ArticleCollections"("B");
-- AddForeignKey
ALTER TABLE "QuickNote" ADD CONSTRAINT "QuickNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QuickNote" ADD CONSTRAINT "QuickNote_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Note" ADD CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArticleCollections" ADD CONSTRAINT "_ArticleCollections_A_fkey" FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArticleCollections" ADD CONSTRAINT "_ArticleCollections_B_fkey" FOREIGN KEY ("B") REFERENCES "Collection"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,14 @@
/*
Warnings:
- Made the column `userId` on table `Note` required. This step will fail if there are existing NULL values in that column.
*/
-- DropForeignKey
ALTER TABLE "Note" DROP CONSTRAINT "Note_userId_fkey";
-- AlterTable
ALTER TABLE "Note" ALTER COLUMN "userId" SET NOT NULL;
-- AddForeignKey
ALTER TABLE "Note" ADD CONSTRAINT "Note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -0,0 +1,80 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Collection {
id String @id @default(uuid())
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
articles Article[] @relation("ArticleCollections")
quickNotes QuickNote[] @relation("CollectionQuickNotes")
notes Note[]
userId String
user User @relation(fields: [userId], references: [id])
}
model Article {
id String @id @default(uuid())
title String
content String
source String
url String @unique
publishedAt DateTime
collections Collection[] @relation("ArticleCollections")
quickNotes QuickNote[] @relation("ArticleQuickNotes")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categories String[]
authors String[]
Note Note[]
}
model QuickNote {
id String @id @default(uuid())
title String
content String
shared Boolean @default(false)
sharedVia String?
collection Collection @relation("CollectionQuickNotes", fields: [collectionId], references: [id])
collectionId String
userId String
user User @relation(fields: [userId], references: [id])
article Article? @relation("ArticleQuickNotes", fields: [articleId], references: [id])
articleId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Note {
id String @id @default(uuid())
title String
content String
shared Boolean @default(false)
sharedVia String?
collection Collection @relation(fields: [collectionId], references: [id])
collectionId String
article Article? @relation(fields: [articleId], references: [id])
articleId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
User User? @relation(fields: [userId], references: [id])
userId String?
}
model User {
id String @id @default(uuid())
username String @unique
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
collections Collection[]
quickNotes QuickNote[]
notes Note[]
}

View File

@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

14
backend/src/app.module.ts Normal file
View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ArticlesModule } from './articles/articles.module';
import { AuthModule } from './auth/auth.module';
import { RssModule } from './rss/rss.module';
import { CollectionsModule } from './collections/collections.module';
@Module({
imports: [ArticlesModule, AuthModule, RssModule, CollectionsModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

View File

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { ArticlesService } from './articles.service';
@Controller('api/articles')
export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) {}
@Get()
async findAll() {
return this.articlesService.findAll();
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ArticlesController } from './articles.controller';
import { ArticlesService } from './articles.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [ArticlesController],
providers: [ArticlesService],
})
export class ArticlesModule {}

View File

@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class ArticlesService {
constructor(private prisma: PrismaService) {}
async findAll() {
return this.prisma.article.findMany({
orderBy: { publishedAt: 'desc' },
take: 100,
});
}
}

View File

@ -0,0 +1,25 @@
/* eslint-disable @typescript-eslint/require-await */
import { Body, Controller, Post, Request, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { User } from '@prisma/client';
type UserResponse = { access_token: string; user: Omit<User, 'password'> };
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@UseGuards(AuthGuard('local'))
@Post('login')
async login(@Request() req): Promise<UserResponse> {
return this.authService.login(req.user);
}
@Post('register')
async register(@Body() body: { username: string; password: string }): Promise<UserResponse> {
console.log('Registering user:', body.username);
const user = await this.authService.register(body.username, body.password);
return this.authService.login(user);
}
}

View File

@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'your-secret-key',
signOptions: { expiresIn: '24h' },
}),
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}

View File

@ -0,0 +1,56 @@
import { Injectable, ConflictException } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { User } from '@prisma/client';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
type UserWithoutPassword = Omit<User, 'password'>;
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService,
) {}
async validateUser(
username: string,
password: string,
): Promise<UserWithoutPassword | null> {
const user = await this.usersService.findOne(username);
if (user && (await bcrypt.compare(password, user.password))) {
const { password: _, ...result } = user;
return result;
}
return null;
}
async login(user: UserWithoutPassword) {
const payload = { username: user.username, sub: user.id };
return {
access_token: this.jwtService.sign(payload),
user,
};
}
async register(
username: string,
password: string,
): Promise<UserWithoutPassword> {
// Check if user exists
const existingUser = await this.usersService.findOne(username);
if (existingUser) {
throw new ConflictException('Username already exists');
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
const user = await this.usersService.create(username, hashedPassword);
// Return user without password
const { password: _, ...result } = user;
return result;
}
}

View File

@ -0,0 +1,4 @@
export * from './jwt-auth.guard';
export * from './jwt.strategy';
export * from './auth.service';
export * from './auth.controller';

View File

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@ -0,0 +1,26 @@
import { ExtractJwt, Strategy, StrategyOptions } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
interface JwtPayload {
sub: string;
username: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
const jwtExtractor = ExtractJwt.fromAuthHeaderAsBearerToken();
const options: StrategyOptions = {
jwtFromRequest: jwtExtractor,
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'your-secret-key',
};
super(options);
}
validate(payload: JwtPayload): { id: string; username: string } {
return { id: payload.sub, username: payload.username };
}
}

View File

@ -0,0 +1,19 @@
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super(); // Configure the strategy (e.g., usernameField: 'email') if needed
}
async validate(username: string, password: string): Promise<any> {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}

View File

@ -0,0 +1,136 @@
import {
Controller,
Get,
Post,
Body,
Param,
UseGuards,
Request,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { JwtAuthGuard } from '../auth';
import { Article, Collection, QuickNote } from '@prisma/client';
interface RequestWithUser extends Request {
user: {
id: string;
username: string;
};
}
type CollectionWithRelations = Collection & {
articles: Article[];
quickNotes: QuickNote[];
notes: any[];
};
interface CreateCollectionDto {
name: string;
description?: string;
}
interface CreateArticleDto {
title: string;
content: string;
source: string;
url: string;
publishedAt: Date;
}
interface CreateQuickNoteDto {
title: string;
content: string;
shared?: boolean;
sharedVia?: string;
}
@Controller('api/collections')
@UseGuards(JwtAuthGuard)
export class CollectionsController {
constructor(private readonly prisma: PrismaService) {}
@Get()
async getAllCollections(
@Request() req: RequestWithUser,
): Promise<CollectionWithRelations[]> {
return await this.prisma.collection.findMany({
where: {
userId: req.user.id,
},
include: {
articles: true,
quickNotes: true,
notes: true,
},
});
}
@Post()
async createCollection(
@Body() data: CreateCollectionDto,
@Request() req: RequestWithUser,
): Promise<CollectionWithRelations> {
return await this.prisma.collection.create({
data: {
...data,
userId: req.user.id,
},
include: {
articles: true,
quickNotes: true,
notes: true,
},
});
}
@Post(':id/add-article')
async addArticleToCollection(
@Param('id') id: string,
@Body() articleData: CreateArticleDto,
): Promise<Article> {
const existingArticle = await this.prisma.article.findUnique({
where: { url: articleData.url },
});
if (existingArticle) {
await this.prisma.collection.update({
where: { id },
data: {
articles: {
connect: { id: existingArticle.id },
},
},
});
return existingArticle;
}
return await this.prisma.article.create({
data: {
...articleData,
collections: {
connect: { id },
},
},
});
}
@Post(':id/quick-notes')
async addQuickNoteToCollection(
@Param('id') id: string,
@Body() quickNoteData: CreateQuickNoteDto,
@Request() req: RequestWithUser,
): Promise<QuickNote> {
return await this.prisma.quickNote.create({
data: {
...quickNoteData,
shared: quickNoteData.shared ?? false,
collection: {
connect: { id },
},
user: {
connect: { id: req.user.id },
},
},
});
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { CollectionsController } from './collections.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PrismaModule, AuthModule],
controllers: [CollectionsController],
})
export class CollectionsModule {}

11
backend/src/main.ts Normal file
View File

@ -0,0 +1,11 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap().catch((err) => {
console.error('Error during application bootstrap:', err);
});

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -0,0 +1,20 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
constructor() {
super();
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@ -0,0 +1,18 @@
import { Controller, Post, Body, Get } from '@nestjs/common';
import { RssService } from './rss.service';
@Controller('rss')
export class RssController {
constructor(private readonly rssService: RssService) {}
@Post('fetch')
async fetchAndStore(@Body('feedUrl') feedUrl: string): Promise<{ message: string }> {
await this.rssService.fetchAndStoreArticles(feedUrl);
return { message: 'RSS feed processed and articles stored successfully.' };
}
@Get('articles')
async getAllArticles() {
return this.rssService.getAllArticles();
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { RssService } from './rss.service';
import { RssController } from './rss.controller';
@Module({
imports: [PrismaModule],
providers: [RssService],
controllers: [RssController],
exports: [RssService],
})
export class RssModule {}

View File

@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
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<void> {
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] : [],
},
});
}
}
async getAllArticles() {
return this.prisma.article.findMany({
orderBy: { publishedAt: 'desc' },
});
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { User } from '@prisma/client';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
async findOne(username: string): Promise<User | null> {
return await this.prisma.user.findUnique({
where: { username },
});
}
async create(username: string, hashedPassword: string): Promise<User> {
return await this.prisma.user.create({
data: {
username,
password: hashedPassword,
},
});
}
}

View File

@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

View File

@ -0,0 +1,36 @@
import { Test, TestingModule } from '@nestjs/testing';
import * as request from 'supertest';
import { INestApplication } from '@nestjs/common';
import { AppModule } from '../src/app.module';
describe('AuthController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it('/auth/login (POST) - success', () => {
return request(app.getHttpServer())
.post('/auth/login')
.send({ username: 'test', password: 'test' })
.expect(201)
.expect({ userId: 1, username: 'test' });
});
it('/auth/login (POST) - failure', () => {
return request(app.getHttpServer())
.post('/auth/login')
.send({ username: 'wrong', password: 'wrong' })
.expect(401);
});
});

View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

21
backend/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

54
frontend/README.md Normal file
View File

@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

21
frontend/components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

28
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6050
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
frontend/package.json Normal file
View File

@ -0,0 +1,55 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-hover-card": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-menubar": "^1.1.6",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@tanstack/react-query": "^5.72.0",
"@tanstack/react-router": "^1.115.2",
"@tanstack/router-vite-plugin": "^1.115.2",
"@tanstack/store": "^0.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.487.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.2.0",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.2.5"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@tailwindcss/typography": "^0.5.16",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.18",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"vite": "^6.2.0"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
'tailwindcss': {},
'autoprefixer': {},
}
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

22
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,22 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const queryClient = new QueryClient()
const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
export default App

View File

@ -0,0 +1,17 @@
import { Article } from '@/types'
export async function fetchArticles(): Promise<Article[]> {
const response = await fetch('/api/articles')
if (!response.ok) {
throw new Error('Failed to fetch articles')
}
return response.json()
}
export async function fetchAllArticles(): Promise<Article[]> {
const response = await fetch('/api/articles');
if (!response.ok) {
throw new Error('Failed to fetch articles');
}
return response.json();
}

View File

@ -0,0 +1,43 @@
import { Collection } from '@/types';
import { userStore } from '@/store/user';
const getAuthHeaders = () => {
const token = userStore.state.token;
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
};
export async function fetchCollections(): Promise<Collection[]> {
const response = await fetch('/api/collections', {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error('Failed to fetch collections');
}
return response.json();
}
export async function createCollection(data: { name: string; description?: string; userId: string }): Promise<Collection> {
const response = await fetch('/api/collections', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to create collection');
}
return response.json();
}
export async function addArticleToCollection(collectionId: string, articleData: { title: string; content: string; source: string; url: string; publishedAt: Date }): Promise<void> {
const response = await fetch(`/api/collections/${collectionId}/add-article`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(articleData),
});
if (!response.ok) {
throw new Error('Failed to add article to collection');
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,173 @@
import { useRouter } from '@tanstack/react-router';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { useUser, useIsAuthenticated, userActions } from '@/store/user';
import {
Sheet,
SheetContent,
SheetTrigger,
SheetTitle,
SheetDescription,
SheetHeader
} from '@/components/ui/sheet';
export function Navbar() {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const user = useUser();
const isAuthenticated = useIsAuthenticated();
const closeSheet = () => setIsOpen(false);
const navigateTo = (path: string) => {
router.navigate({ to: path });
closeSheet();
};
const handleLogout = () => {
userActions.logout();
navigateTo('/');
};
const handleLoginSuccess = () => {
navigateTo('/dashboard');
};
const authButtons = isAuthenticated ? (
<>
<div className="text-sm text-muted-foreground mr-4">
Welcome, {user?.username}!
</div>
<Button
variant="outline"
size="sm"
onClick={handleLogout}
>
Logout
</Button>
</>
) : (
<>
<Button
variant="default"
size="sm"
onClick={handleLoginSuccess}
>
Login
</Button>
</>
);
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center">
<div className="flex flex-1 items-center justify-between">
{/* Logo/Brand */}
<div
onClick={() => navigateTo('/')}
className="flex items-center space-x-2 cursor-pointer"
>
<span className="font-bold text-xl">NewsApp</span>
</div>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-6 ml-6 flex-1">
<div
onClick={() => navigateTo('/')}
className="transition-colors hover:text-foreground/80 text-foreground/60 cursor-pointer"
>
Home
</div>
<div
onClick={() => navigateTo('/articles')}
className="transition-colors hover:text-foreground/80 text-foreground/60 cursor-pointer"
>
Articles
</div>
{isAuthenticated && (
<div
onClick={() => navigateTo('/dashboard')}
className="transition-colors hover:text-foreground/80 text-foreground/60 cursor-pointer"
>
Dashboard
</div>
)}
</nav>
{/* Actions */}
<div className="hidden md:flex items-center space-x-2 ml-auto">
{authButtons}
</div>
{/* Mobile Menu Trigger */}
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger asChild className="md:hidden">
<Button variant="outline" size="icon" className="h-8 w-8 px-0">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="6" y2="6" />
<line x1="4" x2="20" y1="18" y2="18" />
</svg>
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[240px] sm:w-[300px] pr-0">
<SheetHeader>
<SheetTitle>Navigation Menu</SheetTitle>
<SheetDescription>
Access all pages of the News App.
</SheetDescription>
</SheetHeader>
<nav className="flex flex-col gap-4 mt-6">
<div
onClick={() => navigateTo('/')}
className="px-2 py-1 text-foreground/70 hover:text-foreground cursor-pointer"
>
Home
</div>
<div
onClick={() => navigateTo('/articles')}
className="px-2 py-1 text-foreground/70 hover:text-foreground cursor-pointer"
>
Articles
</div>
{isAuthenticated ? (
<>
<div className="px-2 py-1 text-foreground/70">
Welcome, {user?.username}!
</div>
<Button
variant="outline"
className="w-full"
onClick={handleLogout}
>
Logout
</Button>
</>
) : (
<Button
variant="default"
className="w-full"
onClick={() => navigateTo('/login')}
>
Login
</Button>
)}
</nav>
</SheetContent>
</Sheet>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,14 @@
import { useStore } from '@tanstack/react-store';
import { Navigate } from '@tanstack/react-router';
import { userStore } from '@/store/user';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useStore(userStore, (state) => state.isAuthenticated);
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
return <>{children}</>;
}

View File

@ -0,0 +1,51 @@
import { Link } from '@tanstack/react-router';
import { ArchiveIcon, BookmarkIcon, NewspaperIcon, SettingsIcon } from 'lucide-react';
export function Sidebar() {
return (
<div className="w-64 h-full bg-sidebar border-r border-border">
<nav className="p-4 space-y-2">
<Link
to="/dashboard/articles"
className="flex items-center space-x-3 px-3 py-2 rounded-lg hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
activeProps={{ className: 'bg-accent text-foreground' }}
>
<NewspaperIcon className="w-5 h-5" />
<span>Articles</span>
</Link>
<Link
to="/dashboard/archive"
className="flex items-center space-x-3 px-3 py-2 rounded-lg hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
activeProps={{ className: 'bg-accent text-foreground' }}
>
<ArchiveIcon className="w-5 h-5" />
<span>Archive</span>
</Link>
<Link
to="/dashboard/notes"
className="flex items-center space-x-3 px-3 py-2 rounded-lg hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
activeProps={{ className: 'bg-accent text-foreground' }}
>
<BookmarkIcon className="w-5 h-5" />
<span>Notes</span>
</Link>
<Link
to="/collections"
className="flex items-center space-x-3 px-3 py-2 rounded-lg hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
activeProps={{ className: 'bg-accent text-foreground' }}
>
<BookmarkIcon className="w-5 h-5" />
<span>Collections</span>
</Link>
<Link
to="/dashboard/settings"
className="flex items-center space-x-3 px-3 py-2 rounded-lg hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
activeProps={{ className: 'bg-accent text-foreground' }}
>
<SettingsIcon className="w-5 h-5" />
<span>Settings</span>
</Link>
</nav>
</div>
);
}

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,137 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

193
frontend/src/index.css Normal file
View File

@ -0,0 +1,193 @@
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@tailwind base;
@tailwind components;
@tailwind utilities;
/* @import "taiwindcss" */
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--font-sans: system-ui, Avenir, Helvetica, Arial, sans-serif;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
padding: 0;
min-height: 100vh;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,287 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as RegisterImport } from './routes/register'
import { Route as NotFoundImport } from './routes/notFound'
import { Route as LoginImport } from './routes/login'
import { Route as DashboardImport } from './routes/dashboard'
import { Route as CollectionsImport } from './routes/collections'
import { Route as ArticlesImport } from './routes/articles'
import { Route as IndexImport } from './routes/index'
import { Route as CollectionsnameImport } from './routes/collections/[name]'
// Create/Update Routes
const RegisterRoute = RegisterImport.update({
id: '/register',
path: '/register',
getParentRoute: () => rootRoute,
} as any)
const NotFoundRoute = NotFoundImport.update({
id: '/notFound',
path: '/notFound',
getParentRoute: () => rootRoute,
} as any)
const LoginRoute = LoginImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRoute,
} as any)
const DashboardRoute = DashboardImport.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => rootRoute,
} as any)
const CollectionsRoute = CollectionsImport.update({
id: '/collections',
path: '/collections',
getParentRoute: () => rootRoute,
} as any)
const ArticlesRoute = ArticlesImport.update({
id: '/articles',
path: '/articles',
getParentRoute: () => rootRoute,
} as any)
const IndexRoute = IndexImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRoute,
} as any)
const CollectionsnameRoute = CollectionsnameImport.update({
id: '/[name]',
path: '/[name]',
getParentRoute: () => CollectionsRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/articles': {
id: '/articles'
path: '/articles'
fullPath: '/articles'
preLoaderRoute: typeof ArticlesImport
parentRoute: typeof rootRoute
}
'/collections': {
id: '/collections'
path: '/collections'
fullPath: '/collections'
preLoaderRoute: typeof CollectionsImport
parentRoute: typeof rootRoute
}
'/dashboard': {
id: '/dashboard'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof DashboardImport
parentRoute: typeof rootRoute
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginImport
parentRoute: typeof rootRoute
}
'/notFound': {
id: '/notFound'
path: '/notFound'
fullPath: '/notFound'
preLoaderRoute: typeof NotFoundImport
parentRoute: typeof rootRoute
}
'/register': {
id: '/register'
path: '/register'
fullPath: '/register'
preLoaderRoute: typeof RegisterImport
parentRoute: typeof rootRoute
}
'/collections/[name]': {
id: '/collections/[name]'
path: '/[name]'
fullPath: '/collections/[name]'
preLoaderRoute: typeof CollectionsnameImport
parentRoute: typeof CollectionsImport
}
}
}
// Create and export the route tree
interface CollectionsRouteChildren {
CollectionsnameRoute: typeof CollectionsnameRoute
}
const CollectionsRouteChildren: CollectionsRouteChildren = {
CollectionsnameRoute: CollectionsnameRoute,
}
const CollectionsRouteWithChildren = CollectionsRoute._addFileChildren(
CollectionsRouteChildren,
)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/articles': typeof ArticlesRoute
'/collections': typeof CollectionsRouteWithChildren
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/notFound': typeof NotFoundRoute
'/register': typeof RegisterRoute
'/collections/[name]': typeof CollectionsnameRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/articles': typeof ArticlesRoute
'/collections': typeof CollectionsRouteWithChildren
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/notFound': typeof NotFoundRoute
'/register': typeof RegisterRoute
'/collections/[name]': typeof CollectionsnameRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexRoute
'/articles': typeof ArticlesRoute
'/collections': typeof CollectionsRouteWithChildren
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/notFound': typeof NotFoundRoute
'/register': typeof RegisterRoute
'/collections/[name]': typeof CollectionsnameRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/articles'
| '/collections'
| '/dashboard'
| '/login'
| '/notFound'
| '/register'
| '/collections/[name]'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/articles'
| '/collections'
| '/dashboard'
| '/login'
| '/notFound'
| '/register'
| '/collections/[name]'
id:
| '__root__'
| '/'
| '/articles'
| '/collections'
| '/dashboard'
| '/login'
| '/notFound'
| '/register'
| '/collections/[name]'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ArticlesRoute: typeof ArticlesRoute
CollectionsRoute: typeof CollectionsRouteWithChildren
DashboardRoute: typeof DashboardRoute
LoginRoute: typeof LoginRoute
NotFoundRoute: typeof NotFoundRoute
RegisterRoute: typeof RegisterRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ArticlesRoute: ArticlesRoute,
CollectionsRoute: CollectionsRouteWithChildren,
DashboardRoute: DashboardRoute,
LoginRoute: LoginRoute,
NotFoundRoute: NotFoundRoute,
RegisterRoute: RegisterRoute,
}
export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* ROUTE_MANIFEST_START
{
"routes": {
"__root__": {
"filePath": "__root.tsx",
"children": [
"/",
"/articles",
"/collections",
"/dashboard",
"/login",
"/notFound",
"/register"
]
},
"/": {
"filePath": "index.tsx"
},
"/articles": {
"filePath": "articles.tsx"
},
"/collections": {
"filePath": "collections.tsx",
"children": [
"/collections/[name]"
]
},
"/dashboard": {
"filePath": "dashboard.tsx"
},
"/login": {
"filePath": "login.tsx"
},
"/notFound": {
"filePath": "notFound.tsx"
},
"/register": {
"filePath": "register.tsx"
},
"/collections/[name]": {
"filePath": "collections/[name].tsx",
"parent": "/collections"
}
}
}
ROUTE_MANIFEST_END */

View File

@ -0,0 +1,17 @@
import { createRootRoute, Outlet } from '@tanstack/react-router';
import NotFound from './notFound';
import { Navbar } from '../components/Navbar';
export const Route = createRootRoute({
component: () => (
<div className="min-h-screen bg-background text-foreground antialiased">
<Navbar />
<div className="container py-6 px-4 md:px-6">
<Outlet />
</div>
</div>
),
notFoundComponent: NotFound
})
export default Route;

View File

@ -0,0 +1,137 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { fetchArticles } from '@/api/articles'
import { Button } from '@/components/ui/button'
import { addArticleToCollection, createCollection, fetchCollections } from '@/api/collections';
import { useUser } from '@/store/user';
import { useNavigate } from '@tanstack/react-router';
import { Article } from '@/types';
export const Route = createFileRoute('/articles')({
component: ArticlesPage
})
async function handleAddToCollection(
article: Article,
userId: string | undefined,
navigate: ReturnType<typeof useNavigate>
) {
if (!userId) {
alert('Please login to add articles to collections');
navigate({ to: '/login' });
return;
}
const collectionName = prompt('Enter Collection Name:');
if (!collectionName) return;
try {
// Fetch existing collections
const collections = await fetchCollections();
let collection = collections.find((col) => col.name === collectionName);
// If collection does not exist, create it
if (!collection) {
collection = await createCollection({ name: collectionName, userId });
alert(`Collection '${collectionName}' created successfully!`);
}
// Add article to the collection
await addArticleToCollection(collection.id, {
title: article.title,
content: article.content,
source: article.source,
url: article.url,
publishedAt: new Date(article.publishedAt)
});
alert(`Article added to collection '${collectionName}' successfully!`);
} catch (error: unknown) {
console.error('Failed to add article to collection:', error);
alert('Failed to add article to collection. Please try again.');
}
}
function ArticlesPage() {
const { data: articles, isLoading, isError, error } = useQuery({
queryKey: ['articles'],
queryFn: async () => {
try {
const response = await fetchArticles();
console.log('Articles API response:', response);
return response;
} catch (err) {
console.error('Error fetching articles:', err);
throw err;
}
},
staleTime: 1000 * 60 // Cache for 1 minute
});
const user = useUser();
const navigate = useNavigate();
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="p-6 bg-red-50 border border-red-200 rounded-lg">
<h2 className="text-xl font-semibold text-red-800">Error</h2>
<p className="text-red-600">Failed to load articles: {error?.message || 'Unknown error'}</p>
<Button variant="outline" className="mt-4" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">News Articles</h1>
<p className="text-muted-foreground mt-2">Browse the latest news from various sources</p>
</div>
{articles && articles.length > 0 ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{articles.map(article => (
<div
key={article.id}
className="rounded-lg border bg-card shadow-sm overflow-hidden hover:shadow-md transition-shadow"
>
<div className="p-6">
<h2 className="text-xl font-semibold mb-2">{article.title}</h2>
<p className="text-muted-foreground text-sm mb-4 line-clamp-2">
{article.content?.substring(0, 120)}...
</p>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{article.source}</span>
<span className="text-xs text-muted-foreground">
{new Date(article.publishedAt).toLocaleDateString()}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleAddToCollection(article, user?.id, navigate)}
>
Add to Collection
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center p-12 border rounded-lg bg-background">
<h3 className="text-lg font-medium">No articles found</h3>
<p className="text-muted-foreground mt-1">Check back later for new content</p>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,84 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchCollections, createCollection } from '@/api/collections';
import { Button } from '@/components/ui/button';
import { useState } from 'react';
import { createFileRoute } from '@tanstack/react-router';
import { useUser, useIsAuthenticated } from '@/store/user';
import { useNavigate } from '@tanstack/react-router';
import { ProtectedRoute } from '@/components/ProtectedRoute';
function CollectionsPage() {
const queryClient = useQueryClient();
const user = useUser();
const navigate = useNavigate();
const { data: collections, isLoading, isError } = useQuery({
queryKey: ['collections'],
queryFn: fetchCollections,
});
const mutation = useMutation({
mutationFn: (data: { name: string; description: string; userId: string }) => createCollection(data),
onSuccess: () => queryClient.invalidateQueries(['collections']),
});
const [newCollection, setNewCollection] = useState({ name: '', description: '' });
const handleCreateCollection = () => {
if (!user?.id) {
alert('Please login to create collections');
navigate({ to: '/login' });
return;
}
mutation.mutate({ ...newCollection, userId: user.id });
setNewCollection({ name: '', description: '' });
};
if (isLoading) return <div>Loading collections...</div>;
if (isError) return <div>Failed to load collections. Please try again later.</div>;
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Collections</h1>
<div className="space-y-4">
<input
type="text"
placeholder="Collection Name"
value={newCollection.name}
onChange={(e) => setNewCollection({ ...newCollection, name: e.target.value })}
className="border p-2 rounded w-full"
/>
<textarea
placeholder="Description"
value={newCollection.description}
onChange={(e) => setNewCollection({ ...newCollection, description: e.target.value })}
className="border p-2 rounded w-full"
/>
<Button onClick={handleCreateCollection}>Create Collection</Button>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{collections?.map((collection) => (
<div key={collection.id} className="border p-4 rounded-lg">
<h2 className="text-xl font-semibold">{collection.name}</h2>
<p className="text-sm text-muted-foreground">{collection.description}</p>
<div className="mt-4 space-y-2">
<p className="text-sm">Articles: {collection.articles.length}</p>
<p className="text-sm">Quick Notes: {collection.quickNotes.length}</p>
<p className="text-sm">Notes: {collection.notes.length}</p>
</div>
</div>
))}
</div>
</div>
);
}
export default CollectionsPage;
export const Route = createFileRoute('/collections')({
component: CollectionsPage,
})

View File

@ -0,0 +1,47 @@
import { useParams } from '@tanstack/react-router';
function CollectionDetailsPage() {
const { name } = useParams();
const { data: collections, isLoading, isError } = useQuery(['collections'], fetchCollections);
if (isLoading) return <div>Loading collection...</div>;
if (isError) return <div>Failed to load collection. Please try again later.</div>;
const collection = collections?.find((col) => col.name === name);
if (!collection) return <div>Collection not found.</div>;
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">{collection.name}</h1>
<p className="text-muted-foreground">{collection.description}</p>
<div className="space-y-4">
<h2 className="text-2xl font-semibold">Articles</h2>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{collection.articles.map((article) => (
<div key={article.id} className="border p-4 rounded-lg">
<h3 className="text-xl font-semibold">{article.title}</h3>
<p className="text-sm text-muted-foreground">
{article.content.substring(0, 100)}...
</p>
<a href={article.url} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
Read more
</a>
</div>
))}
</div>
</div>
</div>
);
}
export default CollectionDetailsPage;import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/collections/[name]')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/collections/[name]"!</div>
}

View File

@ -0,0 +1,22 @@
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { Sidebar } from '@/components/Sidebar';
import { ProtectedRoute } from '@/components/ProtectedRoute';
export function Dashboard() {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 p-4">
<Outlet />
</main>
</div>
);
}
export const Route = createFileRoute('/dashboard')({
component: () => (
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
),
});

View File

@ -0,0 +1,73 @@
import { createFileRoute } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
import { useRouter } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query';
import { fetchAllArticles } from '@/api/articles';
export const Route = createFileRoute('/')({
component: IndexPage,
})
function IndexPage() {
const router = useRouter();
const navigateTo = (path: string) => {
router.navigate({ to: path });
};
const { data: articles, isLoading, isError } = useQuery({
queryKey: ['allArticles'],
queryFn: fetchAllArticles,
});
if (isLoading) {
return <div>Loading articles...</div>;
}
if (isError) {
return <div>Failed to load articles. Please try again later.</div>;
}
return (
<div className="space-y-8">
<div className="py-12 md:py-16 lg:py-20">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center space-y-4 text-center">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
Welcome to News App
</h1>
<p className="mx-auto max-w-[700px] text-muted-foreground md:text-xl">
Your source for up-to-date articles and news from around the world
</p>
</div>
<div className="space-x-4">
<Button onClick={() => navigateTo('/articles')}>
Browse Articles
</Button>
<Button variant="outline" onClick={() => navigateTo('/login')}>
Login
</Button>
</div>
</div>
</div>
</div>
<div className="container px-4 md:px-6">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{articles?.map((article) => (
<div key={article.id} className="rounded-lg border bg-card p-6 text-card-foreground shadow">
<h3 className="text-lg font-semibold">{article.title}</h3>
<p className="text-sm text-muted-foreground">
{article.content.substring(0, 100)}...
</p>
<a href={article.url} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
Read more
</a>
</div>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,106 @@
import { createFileRoute } from '@tanstack/react-router';
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
import { userActions } from '@/store/user';
export const Route = createFileRoute('/login')({
component: Login,
});
function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
const response = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (response.ok) {
const { user, access_token } = await response.json();
userActions.setUserAndToken(user, access_token);
navigate({ to: '/dashboard' }); // Redirect to dashboard after successful login
} else {
setError('Invalid username or password');
}
} catch {
setError('An error occurred. Please try again later.');
}
};
return (
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">Login</h1>
<p className="text-sm text-muted-foreground">
Enter your credentials to sign in to your account
</p>
</div>
<div className="grid gap-6">
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-4 p-3 text-sm text-white bg-destructive rounded-md">
{error}
</div>
)}
<div className="grid gap-4">
<div className="grid gap-2">
<label htmlFor="username" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
required
/>
</div>
<div className="grid gap-2">
<label htmlFor="password" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
required
/>
</div>
<Button type="submit" className="w-full">
Sign In
</Button>
</div>
</form>
<div className="text-center text-sm">
Don't have an account?{' '}
<a
href="/register"
onClick={(e) => {
e.preventDefault();
navigate({ to: '/register' });
}}
className="underline hover:text-primary"
>
Register
</a>
</div>
</div>
</div>
);
}
export default Login;

View File

@ -0,0 +1,26 @@
import { createFileRoute } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
import { Link } from '@tanstack/react-router';
const NotFound = () => {
return (
<div className="flex flex-col items-center justify-center py-20">
<h1 className="text-5xl font-bold mb-4">404</h1>
<p className="text-xl mb-8 text-muted-foreground">Page not found</p>
<p className="text-center text-muted-foreground max-w-md mb-8">
The page you are looking for doesn't exist or has been moved.
</p>
<Button asChild>
<Link to="/">
Return Home
</Link>
</Button>
</div>
);
};
export const Route = createFileRoute('/notFound')({
component: NotFound,
});
export default NotFound;

View File

@ -0,0 +1,127 @@
import { createFileRoute } from '@tanstack/react-router';
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { Button } from '@/components/ui/button';
import { userActions } from '@/store/user';
function Register() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
try {
const response = await fetch('/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (response.ok) {
const { user, access_token } = await response.json();
userActions.setUserAndToken(user, access_token);
navigate({ to: '/dashboard' });
} else {
const errorData = await response.json();
setError(errorData.message || 'Registration failed');
}
} catch {
setError('An error occurred. Please try again later.');
}
};
return (
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">Create an Account</h1>
<p className="text-sm text-muted-foreground">
Enter your details to create your account
</p>
</div>
<div className="grid gap-6">
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-4 p-3 text-sm text-white bg-destructive rounded-md">
{error}
</div>
)}
<div className="grid gap-4">
<div className="grid gap-2">
<label htmlFor="username" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
required
/>
</div>
<div className="grid gap-2">
<label htmlFor="password" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
required
/>
</div>
<div className="grid gap-2">
<label htmlFor="confirmPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
required
/>
</div>
<Button type="submit" className="w-full">
Register
</Button>
</div>
</form>
<div className="text-center text-sm">
Already have an account?{' '}
<a
href="/login"
onClick={(e) => {
e.preventDefault();
navigate({ to: '/login' });
}}
className="underline hover:text-primary"
>
Sign in
</a>
</div>
</div>
</div>
);
}
export const Route = createFileRoute('/register')({
component: Register,
})
export default Register;

View File

@ -0,0 +1,43 @@
import { Store } from '@tanstack/store'
import { useStore } from '@tanstack/react-store'
export interface User {
id: string
username: string
createdAt: string
updatedAt: string
}
interface UserState {
user: User | null
isAuthenticated: boolean
token: string | null
}
const initialState: UserState = {
user: null,
isAuthenticated: false,
token: null,
}
export const userStore = new Store<UserState>(initialState)
// Store actions
export const userActions = {
setUserAndToken: (user: User | null, token: string | null) => {
userStore.setState((state) => ({
...state,
user,
token,
isAuthenticated: !!user && !!token,
}))
},
logout: () => {
userStore.setState(() => initialState)
},
}
// React hooks for accessing store state
export const useUser = () => useStore(userStore, (state) => state.user)
export const useIsAuthenticated = () => useStore(userStore, (state) => state.isAuthenticated)
export const useToken = () => useStore(userStore, (state) => state.token)

35
frontend/src/types.ts Normal file
View File

@ -0,0 +1,35 @@
export interface Article {
id: string
title: string
content: string
source: string
url: string
publishedAt: string
categories: string[]
authors: string[]
}
export interface Collection {
id: string;
name: string;
description?: string;
articles: Article[];
quickNotes: QuickNote[];
notes: Note[];
}
export interface QuickNote {
id: string;
title: string;
content: string;
shared: boolean;
sharedVia?: string;
}
export interface Note {
id: string;
title: string;
content: string;
shared: boolean;
sharedVia?: string;
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,74 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

13
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"files": [],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

28
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { TanStackRouterVite } from '@tanstack/router-vite-plugin'
import path from 'path'
export default defineConfig({
plugins: [
react(),
TanStackRouterVite()
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
},
'/auth': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
})