From 0b91c896189500c56b093b982b8fce21f257173d Mon Sep 17 00:00:00 2001 From: Hari Date: Mon, 16 Feb 2026 20:28:45 +0530 Subject: [PATCH 1/8] Build community resources with backend APIs and uploads --- .../jobtrackerpro/config/SecurityConfig.java | 2 + .../controller/CareerResourceController.java | 40 ++++++ .../jobtrackerpro/dto/CareerResourceDTO.java | 17 +++ .../dto/CreateCareerResourceRequest.java | 11 ++ .../jobtrackerpro/entity/CareerResource.java | 52 ++++++++ .../repo/CareerResourceRepository.java | 13 ++ .../service/CareerResourceService.java | 115 ++++++++++++++++++ frontend/src/app/app.routes.ts | 6 + .../components/landing/landing.component.html | 8 +- .../resources/resources.component.css | 3 + .../resources/resources.component.html | 101 +++++++++++++++ .../resources/resources.component.spec.ts | 39 ++++++ .../resources/resources.component.ts | 95 +++++++++++++++ frontend/src/app/services/resource.service.ts | 36 ++++++ 14 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/dto/CreateCareerResourceRequest.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java create mode 100644 frontend/src/app/components/resources/resources.component.css create mode 100644 frontend/src/app/components/resources/resources.component.html create mode 100644 frontend/src/app/components/resources/resources.component.spec.ts create mode 100644 frontend/src/app/components/resources/resources.component.ts create mode 100644 frontend/src/app/services/resource.service.ts diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/config/SecurityConfig.java b/backend/src/main/java/com/thughari/jobtrackerpro/config/SecurityConfig.java index 7eea4fb..d8cba27 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/config/SecurityConfig.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/config/SecurityConfig.java @@ -8,6 +8,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -56,6 +57,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.GET, "/api/resources", "/api/resources/**").permitAll() .requestMatchers(publicEndpoints).permitAll() .anyRequest().authenticated() ) diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java b/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java new file mode 100644 index 0000000..b40c592 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java @@ -0,0 +1,40 @@ +package com.thughari.jobtrackerpro.controller; + +import com.thughari.jobtrackerpro.dto.CareerResourceDTO; +import com.thughari.jobtrackerpro.dto.CreateCareerResourceRequest; +import com.thughari.jobtrackerpro.service.CareerResourceService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/resources") +public class CareerResourceController { + + private final CareerResourceService careerResourceService; + + public CareerResourceController(CareerResourceService careerResourceService) { + this.careerResourceService = careerResourceService; + } + + @GetMapping + public ResponseEntity> getResources() { + return ResponseEntity.ok(careerResourceService.getAllResources()); + } + + @PostMapping + public ResponseEntity addResource(@RequestBody CreateCareerResourceRequest request) { + String email = getAuthenticatedEmail(); + return ResponseEntity.ok(careerResourceService.createResource(email, request)); + } + + private String getAuthenticatedEmail() { + return ((String) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).toLowerCase(); + } +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java new file mode 100644 index 0000000..f29ed91 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java @@ -0,0 +1,17 @@ +package com.thughari.jobtrackerpro.dto; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +public class CareerResourceDTO { + private UUID id; + private String title; + private String url; + private String category; + private String description; + private String submittedByName; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/CreateCareerResourceRequest.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CreateCareerResourceRequest.java new file mode 100644 index 0000000..3dfd817 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CreateCareerResourceRequest.java @@ -0,0 +1,11 @@ +package com.thughari.jobtrackerpro.dto; + +import lombok.Data; + +@Data +public class CreateCareerResourceRequest { + private String title; + private String url; + private String category; + private String description; +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java b/backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java new file mode 100644 index 0000000..5e65f36 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java @@ -0,0 +1,52 @@ +package com.thughari.jobtrackerpro.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@Entity +@Table(name = "career_resources") +public class CareerResource { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(columnDefinition = "uuid") + private UUID id; + + @Column(nullable = false, length = 180) + private String title; + + @Column(nullable = false, length = 2048) + private String url; + + @Column(nullable = false, length = 80) + private String category; + + @Column(length = 1200) + private String description; + + @Column(nullable = false) + private String submittedByEmail; + + @Column(nullable = false) + private String submittedByName; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @PrePersist + public void prePersist() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java b/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java new file mode 100644 index 0000000..828835e --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java @@ -0,0 +1,13 @@ +package com.thughari.jobtrackerpro.repo; + +import com.thughari.jobtrackerpro.entity.CareerResource; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface CareerResourceRepository extends JpaRepository { + List findAllByOrderByCreatedAtDesc(); + + boolean existsByUrl(String url); +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java new file mode 100644 index 0000000..669e826 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java @@ -0,0 +1,115 @@ +package com.thughari.jobtrackerpro.service; + +import com.thughari.jobtrackerpro.dto.CareerResourceDTO; +import com.thughari.jobtrackerpro.dto.CreateCareerResourceRequest; +import com.thughari.jobtrackerpro.entity.CareerResource; +import com.thughari.jobtrackerpro.entity.User; +import com.thughari.jobtrackerpro.repo.CareerResourceRepository; +import com.thughari.jobtrackerpro.repo.UserRepository; +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional +public class CareerResourceService { + + private final CareerResourceRepository resourceRepository; + private final UserRepository userRepository; + + public CareerResourceService(CareerResourceRepository resourceRepository, UserRepository userRepository) { + this.resourceRepository = resourceRepository; + this.userRepository = userRepository; + } + + @Transactional(readOnly = true) + public List getAllResources() { + return resourceRepository.findAllByOrderByCreatedAtDesc() + .stream() + .map(this::toDTO) + .toList(); + } + + public CareerResourceDTO createResource(String email, CreateCareerResourceRequest request) { + String normalizedUrl = normalizeUrl(request.getUrl()); + validatePayload(request, normalizedUrl); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + CareerResource resource = new CareerResource(); + resource.setTitle(request.getTitle().trim()); + resource.setUrl(normalizedUrl); + resource.setCategory(request.getCategory().trim()); + resource.setDescription(request.getDescription() == null ? null : request.getDescription().trim()); + resource.setSubmittedByEmail(user.getEmail()); + resource.setSubmittedByName(user.getName() == null || user.getName().isBlank() ? user.getEmail() : user.getName()); + + return toDTO(resourceRepository.save(resource)); + } + + @PostConstruct + public void seedStarterResources() { + if (resourceRepository.count() > 0) { + return; + } + + addSeedResource("Career Preparation Notes", "https://docs.google.com/document/d/1-25JrPUai6P7pjKk1g7mELpI0YUINS_NpjUk7lsMfyg/edit?tab=t.0#heading=h.kf8l3f8jftc2", "Guides & Study Docs"); + addSeedResource("Main Career Resources Folder", "https://drive.google.com/drive/folders/1ISp9GBv7ih1blEQOPYplD_idG1j0ibGq?usp=sharing", "Drive Folders & File Packs"); + addSeedResource("DSA Folder", "https://drive.google.com/drive/folders/1ei52Zc_cQe0rJK404M56BmEisUjnF6kN?usp=drive_link", "Drive Folders & File Packs"); + addSeedResource("21 Days React Study Plan", "https://thecodedose.notion.site/21-Days-React-Study-Plan-1988ff023cae48459bae8cb20cb75a67", "Structured Learning"); + addSeedResource("Opportunity Tracker Sheet", "https://docs.google.com/spreadsheets/d/1KBFiqJTaFY1164XtglKvn2vAofScCfGlkY-n54D2d14/edit?gid=584790886#gid=584790886", "Trackers & Opportunity Sheets"); + } + + private void addSeedResource(String title, String url, String category) { + if (resourceRepository.existsByUrl(url)) { + return; + } + + CareerResource resource = new CareerResource(); + resource.setTitle(title); + resource.setUrl(url); + resource.setCategory(category); + resource.setDescription("Seeded starter resource"); + resource.setSubmittedByEmail("system@jobtrackerpro.local"); + resource.setSubmittedByName("JobTrackerPro"); + resourceRepository.save(resource); + } + + private void validatePayload(CreateCareerResourceRequest request, String normalizedUrl) { + if (request.getTitle() == null || request.getTitle().isBlank()) { + throw new IllegalArgumentException("Title is required"); + } + if (request.getCategory() == null || request.getCategory().isBlank()) { + throw new IllegalArgumentException("Category is required"); + } + if (normalizedUrl == null || normalizedUrl.isBlank()) { + throw new IllegalArgumentException("Valid URL is required"); + } + } + + private String normalizeUrl(String url) { + if (url == null) { + return null; + } + String trimmed = url.trim(); + if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { + return "https://" + trimmed; + } + return trimmed; + } + + private CareerResourceDTO toDTO(CareerResource resource) { + CareerResourceDTO dto = new CareerResourceDTO(); + dto.setId(resource.getId()); + dto.setTitle(resource.getTitle()); + dto.setUrl(resource.getUrl()); + dto.setCategory(resource.getCategory()); + dto.setDescription(resource.getDescription()); + dto.setSubmittedByName(resource.getSubmittedByName()); + dto.setCreatedAt(resource.getCreatedAt()); + return dto; + } +} diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 63a3add..2485a5c 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -12,6 +12,7 @@ import { guestGuard } from './core/guards/guest.guard'; import { AboutComponent } from './components/about/about.component'; import { ResetPasswordComponent } from './components/auth/reset-password/reset-password.component'; import { PrivacyComponent } from './components/privacy/privacy.component'; +import { ResourcesComponent } from './components/resources/resources.component'; export const routes: Routes = [ { @@ -29,6 +30,11 @@ export const routes: Routes = [ component: PrivacyComponent, title: 'Privacy Policy' }, + { + path: 'resources', + component: ResourcesComponent, + title: 'Career Resources - JobTrackerPro' + }, { path: 'reset-password', component: ResetPasswordComponent diff --git a/frontend/src/app/components/landing/landing.component.html b/frontend/src/app/components/landing/landing.component.html index 0fa42c9..beeeddb 100644 --- a/frontend/src/app/components/landing/landing.component.html +++ b/frontend/src/app/components/landing/landing.component.html @@ -10,6 +10,7 @@ @@ -142,7 +147,8 @@

Zero Latency

diff --git a/frontend/src/app/components/resources/resources.component.css b/frontend/src/app/components/resources/resources.component.css new file mode 100644 index 0000000..5d4e87f --- /dev/null +++ b/frontend/src/app/components/resources/resources.component.css @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/frontend/src/app/components/resources/resources.component.html b/frontend/src/app/components/resources/resources.component.html new file mode 100644 index 0000000..a78f40e --- /dev/null +++ b/frontend/src/app/components/resources/resources.component.html @@ -0,0 +1,101 @@ +
+ + +
+
+

Community Career Resource Mine

+

Backend-powered resources for everyone

+

Resources are now served from Postgres through the API, and logged-in users can contribute links to make this page community friendly.

+
+ + @if (authService.isAuthenticated()) { +
+

Share a resource

+

Contribute helpful links so the whole community benefits.

+ +
+ + +
+ + + + + +
+ + @if (saveMessage()) { +

{{ saveMessage() }}

+ } +
+
+ } @else { +
+ Want to contribute? Log in and add your own career resources. +
+ } + + @if (errorMessage()) { +

{{ errorMessage() }}

+ } + + @if (isLoading()) { +

Loading resources...

+ } @else { +
+ @for (section of groupedResources(); track section.category) { + + } +
+ } +
+
diff --git a/frontend/src/app/components/resources/resources.component.spec.ts b/frontend/src/app/components/resources/resources.component.spec.ts new file mode 100644 index 0000000..00f8105 --- /dev/null +++ b/frontend/src/app/components/resources/resources.component.spec.ts @@ -0,0 +1,39 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ResourcesComponent } from './resources.component'; +import { ResourceService } from '../../services/resource.service'; +import { AuthService } from '../../services/auth.service'; + +describe('ResourcesComponent', () => { + let component: ResourcesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResourcesComponent], + providers: [ + { + provide: ResourceService, + useValue: { + getResources: () => Promise.resolve([]), + createResource: () => Promise.resolve({}), + }, + }, + { + provide: AuthService, + useValue: { + isAuthenticated: () => false, + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ResourcesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/resources/resources.component.ts b/frontend/src/app/components/resources/resources.component.ts new file mode 100644 index 0000000..d2b3b93 --- /dev/null +++ b/frontend/src/app/components/resources/resources.component.ts @@ -0,0 +1,95 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { AuthService } from '../../services/auth.service'; +import { CareerResource, ResourceService } from '../../services/resource.service'; +import { LogoComponent } from '../ui/logo/logo.component'; + +@Component({ + selector: 'app-resources', + standalone: true, + imports: [CommonModule, RouterLink, LogoComponent, FormsModule], + templateUrl: './resources.component.html', + styleUrl: './resources.component.css' +}) +export class ResourcesComponent { + authService = inject(AuthService); + private resourceService = inject(ResourceService); + + readonly resources = signal([]); + readonly isLoading = signal(true); + readonly errorMessage = signal(''); + readonly saveMessage = signal(''); + readonly isSaving = signal(false); + + title = ''; + url = ''; + category = ''; + description = ''; + + readonly groupedResources = computed(() => { + const grouped = new Map(); + + for (const resource of this.resources()) { + const key = resource.category?.trim() || 'General'; + const list = grouped.get(key) ?? []; + list.push(resource); + grouped.set(key, list); + } + + return Array.from(grouped.entries()).map(([category, links]) => ({ + category, + links + })); + }); + + constructor() { + this.loadResources(); + } + + async loadResources() { + this.isLoading.set(true); + this.errorMessage.set(''); + + try { + const data = await this.resourceService.getResources(); + this.resources.set(data); + } catch { + this.errorMessage.set('Could not load community resources right now. Please try again.'); + } finally { + this.isLoading.set(false); + } + } + + async addResource() { + this.saveMessage.set(''); + + if (!this.title.trim() || !this.url.trim() || !this.category.trim()) { + this.saveMessage.set('Title, URL, and category are required.'); + return; + } + + this.isSaving.set(true); + + try { + const created = await this.resourceService.createResource({ + title: this.title, + url: this.url, + category: this.category, + description: this.description + }); + + this.resources.set([created, ...this.resources()]); + this.title = ''; + this.url = ''; + this.category = ''; + this.description = ''; + this.saveMessage.set('Resource added. Thanks for contributing!'); + } catch { + this.saveMessage.set('Could not add the resource. Please check the link and try again.'); + } finally { + this.isSaving.set(false); + } + } +} diff --git a/frontend/src/app/services/resource.service.ts b/frontend/src/app/services/resource.service.ts new file mode 100644 index 0000000..eb99db8 --- /dev/null +++ b/frontend/src/app/services/resource.service.ts @@ -0,0 +1,36 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import { environment } from '../../environments/environment'; + +export interface CareerResource { + id: string; + title: string; + url: string; + category: string; + description?: string; + submittedByName: string; + createdAt: string; +} + +export interface CreateResourcePayload { + title: string; + url: string; + category: string; + description?: string; +} + +@Injectable({ providedIn: 'root' }) +export class ResourceService { + private readonly API = environment.apiBaseUrl; + private http = inject(HttpClient); + private apiUrl = `${this.API}/api/resources`; + + async getResources() { + return await firstValueFrom(this.http.get(this.apiUrl)); + } + + async createResource(payload: CreateResourcePayload) { + return await firstValueFrom(this.http.post(this.apiUrl, payload)); + } +} From 6e1f11206c1663e337f8208eb9914d69877d835e Mon Sep 17 00:00:00 2001 From: Hari Date: Mon, 16 Feb 2026 21:08:03 +0530 Subject: [PATCH 2/8] Add in-app community resources with file uploads and owner delete --- .../jobtrackerpro/config/SecurityConfig.java | 2 + .../controller/CareerResourceController.java | 64 +++++++ .../jobtrackerpro/dto/CareerResourceDTO.java | 19 +++ .../jobtrackerpro/entity/CareerResource.java | 55 ++++++ .../interfaces/StorageService.java | 1 + .../repo/CareerResourceRepository.java | 13 ++ .../service/CareerResourceService.java | 159 +++++++++++++++++ .../service/CloudStorageService.java | 109 +++++++++--- .../service/mock/LocalStorageService.java | 42 ++++- frontend/src/app/app.component.html | 7 + frontend/src/app/app.routes.ts | 7 + .../components/landing/landing.component.html | 8 +- .../resources/resources.component.css | 3 + .../resources/resources.component.html | 112 ++++++++++++ .../resources/resources.component.spec.ts | 41 +++++ .../resources/resources.component.ts | 161 ++++++++++++++++++ frontend/src/app/services/resource.service.ts | 59 +++++++ 17 files changed, 832 insertions(+), 30 deletions(-) create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java create mode 100644 frontend/src/app/components/resources/resources.component.css create mode 100644 frontend/src/app/components/resources/resources.component.html create mode 100644 frontend/src/app/components/resources/resources.component.spec.ts create mode 100644 frontend/src/app/components/resources/resources.component.ts create mode 100644 frontend/src/app/services/resource.service.ts diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/config/SecurityConfig.java b/backend/src/main/java/com/thughari/jobtrackerpro/config/SecurityConfig.java index 7eea4fb..e3fbe6c 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/config/SecurityConfig.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/config/SecurityConfig.java @@ -8,6 +8,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -56,6 +57,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.GET, "/api/resources", "/api/resources/**", "/api/storage/files/**").permitAll() .requestMatchers(publicEndpoints).permitAll() .anyRequest().authenticated() ) diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java b/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java new file mode 100644 index 0000000..73184ea --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java @@ -0,0 +1,64 @@ +package com.thughari.jobtrackerpro.controller; + +import com.thughari.jobtrackerpro.dto.CareerResourceDTO; +import com.thughari.jobtrackerpro.service.CareerResourceService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/resources") +public class CareerResourceController { + + private final CareerResourceService careerResourceService; + + public CareerResourceController(CareerResourceService careerResourceService) { + this.careerResourceService = careerResourceService; + } + + @GetMapping + public ResponseEntity> getResources() { + return ResponseEntity.ok(careerResourceService.getAllResources()); + } + + @PostMapping + public ResponseEntity addResource( + @RequestParam String title, + @RequestParam(required = false) String url, + @RequestParam String category, + @RequestParam(required = false) String description, + @RequestParam(required = false) MultipartFile file + ) { + String email = getAuthenticatedEmail(); + + return ResponseEntity.ok(careerResourceService.createResource( + email, + title, + url, + category, + description, + file + )); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteResource(@PathVariable UUID id) { + String email = getAuthenticatedEmail(); + careerResourceService.deleteResource(id, email); + return ResponseEntity.noContent().build(); + } + + private String getAuthenticatedEmail() { + return ((String) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).toLowerCase(); + } +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java new file mode 100644 index 0000000..a0b669b --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java @@ -0,0 +1,19 @@ +package com.thughari.jobtrackerpro.dto; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +public class CareerResourceDTO { + private UUID id; + private String title; + private String url; + private String fileUrl; + private String category; + private String description; + private String submittedByName; + private String submittedByEmail; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java b/backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java new file mode 100644 index 0000000..455acbf --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java @@ -0,0 +1,55 @@ +package com.thughari.jobtrackerpro.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@Entity +@Table(name = "career_resources") +public class CareerResource { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(columnDefinition = "uuid") + private UUID id; + + @Column(nullable = false, length = 180) + private String title; + + @Column(length = 2048) + private String url; + + @Column(length = 2048) + private String fileUrl; + + @Column(nullable = false, length = 80) + private String category; + + @Column(length = 1200) + private String description; + + @Column(nullable = false) + private String submittedByEmail; + + @Column(nullable = false) + private String submittedByName; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @PrePersist + public void prePersist() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/interfaces/StorageService.java b/backend/src/main/java/com/thughari/jobtrackerpro/interfaces/StorageService.java index 0610324..25ebe87 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/interfaces/StorageService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/interfaces/StorageService.java @@ -6,6 +6,7 @@ public interface StorageService { String uploadFile(MultipartFile file, String userId); String uploadFromUrl(String externalUrl, String userId); + String uploadResourceFile(MultipartFile file, String userId); void deleteFile(String fileUrl); } diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java b/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java new file mode 100644 index 0000000..828835e --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java @@ -0,0 +1,13 @@ +package com.thughari.jobtrackerpro.repo; + +import com.thughari.jobtrackerpro.entity.CareerResource; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface CareerResourceRepository extends JpaRepository { + List findAllByOrderByCreatedAtDesc(); + + boolean existsByUrl(String url); +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java new file mode 100644 index 0000000..e8de6f7 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java @@ -0,0 +1,159 @@ +package com.thughari.jobtrackerpro.service; + +import com.thughari.jobtrackerpro.dto.CareerResourceDTO; +import com.thughari.jobtrackerpro.entity.CareerResource; +import com.thughari.jobtrackerpro.entity.User; +import com.thughari.jobtrackerpro.repo.CareerResourceRepository; +import com.thughari.jobtrackerpro.repo.UserRepository; +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.thughari.jobtrackerpro.interfaces.StorageService; + +import java.util.List; +import java.util.UUID; + +@Service +@Transactional +public class CareerResourceService { + + private final CareerResourceRepository resourceRepository; + private final UserRepository userRepository; + private final StorageService storageService; + + public CareerResourceService( + CareerResourceRepository resourceRepository, + UserRepository userRepository, + StorageService storageService + ) { + this.resourceRepository = resourceRepository; + this.userRepository = userRepository; + this.storageService = storageService; + } + + @Transactional(readOnly = true) + public List getAllResources() { + return resourceRepository.findAllByOrderByCreatedAtDesc() + .stream() + .map(this::toDTO) + .toList(); + } + + public CareerResourceDTO createResource( + String email, + String title, + String url, + String category, + String description, + MultipartFile file + ) { + String normalizedUrl = normalizeUrl(url); + validatePayload(title, category, normalizedUrl, file); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + String uploadedFileUrl = null; + if (file != null && !file.isEmpty()) { + uploadedFileUrl = storageService.uploadResourceFile(file, user.getId().toString()); + } + + CareerResource resource = new CareerResource(); + resource.setTitle(title.trim()); + resource.setUrl(normalizedUrl); + resource.setFileUrl(uploadedFileUrl); + resource.setCategory(category.trim()); + resource.setDescription(description == null ? null : description.trim()); + resource.setSubmittedByEmail(user.getEmail()); + resource.setSubmittedByName(user.getName() == null || user.getName().isBlank() ? user.getEmail() : user.getName()); + + return toDTO(resourceRepository.save(resource)); + } + + public void deleteResource(UUID resourceId, String requesterEmail) { + CareerResource resource = resourceRepository.findById(resourceId) + .orElseThrow(() -> new IllegalArgumentException("Resource not found")); + + if (!resource.getSubmittedByEmail().equalsIgnoreCase(requesterEmail)) { + throw new IllegalArgumentException("You can remove only your own resources"); + } + + if (resource.getFileUrl() != null && !resource.getFileUrl().isBlank()) { + storageService.deleteFile(resource.getFileUrl()); + } + + resourceRepository.delete(resource); + } + + @PostConstruct + public void seedStarterResources() { + if (resourceRepository.count() > 0) { + return; + } + + addSeedResource("Career Preparation Notes", "https://docs.google.com/document/d/1-25JrPUai6P7pjKk1g7mELpI0YUINS_NpjUk7lsMfyg/edit?tab=t.0#heading=h.kf8l3f8jftc2", "Guides & Study Docs"); + addSeedResource("Main Career Resources Folder", "https://drive.google.com/drive/folders/1ISp9GBv7ih1blEQOPYplD_idG1j0ibGq?usp=sharing", "Drive Folders & File Packs"); + addSeedResource("DSA Folder", "https://drive.google.com/drive/folders/1ei52Zc_cQe0rJK404M56BmEisUjnF6kN?usp=drive_link", "Drive Folders & File Packs"); + addSeedResource("21 Days React Study Plan", "https://thecodedose.notion.site/21-Days-React-Study-Plan-1988ff023cae48459bae8cb20cb75a67", "Structured Learning"); + addSeedResource("Opportunity Tracker Sheet", "https://docs.google.com/spreadsheets/d/1KBFiqJTaFY1164XtglKvn2vAofScCfGlkY-n54D2d14/edit?gid=584790886#gid=584790886", "Trackers & Opportunity Sheets"); + } + + private void addSeedResource(String title, String url, String category) { + if (resourceRepository.existsByUrl(url)) { + return; + } + + CareerResource resource = new CareerResource(); + resource.setTitle(title); + resource.setUrl(url); + resource.setCategory(category); + resource.setDescription("Seeded starter resource"); + resource.setSubmittedByEmail("system@jobtrackerpro.local"); + resource.setSubmittedByName("JobTrackerPro"); + resourceRepository.save(resource); + } + + private void validatePayload(String title, String category, String normalizedUrl, MultipartFile file) { + if (title == null || title.isBlank()) { + throw new IllegalArgumentException("Title is required"); + } + if (category == null || category.isBlank()) { + throw new IllegalArgumentException("Category is required"); + } + + boolean hasUrl = normalizedUrl != null && !normalizedUrl.isBlank(); + boolean hasFile = file != null && !file.isEmpty(); + + if (!hasUrl && !hasFile) { + throw new IllegalArgumentException("Provide either a URL or a file"); + } + } + + private String normalizeUrl(String url) { + if (url == null || url.trim().isBlank()) { + return null; + } + + String trimmed = url.trim(); + if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { + return "https://" + trimmed; + } + return trimmed; + } + + private CareerResourceDTO toDTO(CareerResource resource) { + CareerResourceDTO dto = new CareerResourceDTO(); + dto.setId(resource.getId()); + dto.setTitle(resource.getTitle()); + dto.setUrl(resource.getUrl()); + dto.setFileUrl(resource.getFileUrl()); + dto.setCategory(resource.getCategory()); + dto.setDescription(resource.getDescription()); + dto.setSubmittedByName(resource.getSubmittedByName()); + dto.setSubmittedByEmail(resource.getSubmittedByEmail()); + dto.setCreatedAt(resource.getCreatedAt()); + return dto; + } +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/CloudStorageService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/CloudStorageService.java index 080ea4b..f119d48 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/CloudStorageService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/CloudStorageService.java @@ -28,8 +28,9 @@ public class CloudStorageService implements StorageService { private final S3Client s3Client; - - private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; + + private static final long MAX_IMAGE_FILE_SIZE = 5 * 1024 * 1024; + private static final long MAX_RESOURCE_FILE_SIZE = 10 * 1024 * 1024; @Value("${cloudflare.r2.bucket}") private String bucketName; @@ -42,19 +43,19 @@ public CloudStorageService(S3Client s3Client) { } public String uploadFile(MultipartFile file, String userId) { - - String contentType = file.getContentType(); + + String contentType = file.getContentType(); if (!isValidImageContent(contentType)) { throw new InvalidImageException("Invalid file type. Only JPG, PNG, GIF, WEBP are allowed."); } - - if (file.getSize() > MAX_FILE_SIZE) { - throw new MaxUploadSizeExceededException(0); + + if (file.getSize() > MAX_IMAGE_FILE_SIZE) { + throw new MaxUploadSizeExceededException(MAX_IMAGE_FILE_SIZE); } try { String extension = getExtensionFromContentType(file.getContentType()); String fileName = userId + "-" + System.currentTimeMillis() + extension; - + PutObjectRequest putObj = PutObjectRequest.builder() .bucket(bucketName) .key(fileName) @@ -65,13 +66,43 @@ public String uploadFile(MultipartFile file, String userId) { return publicUrl + "/" + fileName; } catch (Exception e) { - log.error("Failed to upload to R2: " + e.getLocalizedMessage()); + log.error("Failed to upload to R2: " + e.getLocalizedMessage()); throw new RuntimeException("Failed to upload to R2", e); } } + @Override + public String uploadResourceFile(MultipartFile file, String userId) { + String contentType = file.getContentType(); + if (!isValidResourceFileType(contentType, file.getOriginalFilename())) { + throw new IllegalArgumentException("Invalid file type. Only PDF, DOC, DOCX and TXT are allowed."); + } + + if (file.getSize() > MAX_RESOURCE_FILE_SIZE) { + throw new MaxUploadSizeExceededException(MAX_RESOURCE_FILE_SIZE); + } + + try { + String extension = getExtensionFromFilename(file.getOriginalFilename()); + String fileName = "resources/" + userId + "-" + System.currentTimeMillis() + extension; + + PutObjectRequest putObj = PutObjectRequest.builder() + .bucket(bucketName) + .key(fileName) + .contentType(contentType) + .build(); + + s3Client.putObject(putObj, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + return publicUrl + "/" + fileName; + } catch (Exception e) { + log.error("Failed to upload resource file to R2", e); + throw new RuntimeException("Failed to upload resource file", e); + } + } + public String uploadFromUrl(String externalUrl, String userId) { - if (externalUrl == null || !externalUrl.startsWith("http")) { + if (externalUrl == null || !externalUrl.startsWith("http")) { throw new InvalidImageException("Invalid URL format"); } try { @@ -89,18 +120,18 @@ public String uploadFromUrl(String externalUrl, String userId) { client.send(request, HttpResponse.BodyHandlers.ofByteArray()); if (response.statusCode() != 200) { - log.error("Failed to download image."); - throw new ResourceNotFoundException("provided url is not accessible"); + log.error("Failed to download image."); + throw new ResourceNotFoundException("provided url is not accessible"); } String contentType = response.headers().firstValue("Content-Type").orElse(""); - + if (!isValidImageContent(contentType)) { throw new InvalidImageException("URL does not point to a valid image (Type: " + contentType + ")"); } - + response.headers().firstValue("Content-Length").ifPresent(len -> { - if (Long.parseLong(len) > MAX_FILE_SIZE) { + if (Long.parseLong(len) > MAX_IMAGE_FILE_SIZE) { throw new IllegalArgumentException("Image at URL is too large"); } }); @@ -123,19 +154,18 @@ public String uploadFromUrl(String externalUrl, String userId) { } catch (InvalidImageException | IllegalArgumentException | ResourceNotFoundException e) { throw e; - } - catch (Exception e) { + } catch (Exception e) { log.error("Failed to upload image From Url to R2", e); throw new InvalidImageException("unable to update image"); } } - + public void deleteFile(String fileUrl) { if (fileUrl == null || fileUrl.isEmpty()) { return; } if (!fileUrl.startsWith(publicUrl)) { - return; + return; } try { @@ -145,7 +175,7 @@ public void deleteFile(String fileUrl) { .bucket(bucketName) .key(key) .build()); - + log.info("Deleted old image from R2: {}", key); } catch (Exception e) { @@ -153,10 +183,9 @@ public void deleteFile(String fileUrl) { } } - private String getExtensionFromContentType(String contentType) { if (contentType == null) return ".jpg"; - + return switch (contentType.toLowerCase()) { case "image/png" -> ".png"; case "image/jpeg", "image/jpg" -> ".jpg"; @@ -165,12 +194,38 @@ private String getExtensionFromContentType(String contentType) { default -> ".jpg"; }; } - + + private String getExtensionFromFilename(String filename) { + if (filename == null || !filename.contains(".")) { + return ".pdf"; + } + return filename.substring(filename.lastIndexOf('.')).toLowerCase(); + } + private boolean isValidImageContent(String contentType) { if (contentType == null) return false; return contentType.equals("image/jpeg") || - contentType.equals("image/png") || - contentType.equals("image/gif") || - contentType.equals("image/webp"); + contentType.equals("image/png") || + contentType.equals("image/gif") || + contentType.equals("image/webp"); + } + + private boolean isValidResourceFileType(String contentType, String filename) { + String ext = getExtensionFromFilename(filename); + boolean extAllowed = ext.equals(".pdf") || ext.equals(".doc") || ext.equals(".docx") || ext.equals(".txt"); + + if (!extAllowed) { + return false; + } + + if (contentType == null || contentType.isBlank()) { + return true; + } + + return contentType.equals("application/pdf") || + contentType.equals("application/msword") || + contentType.equals("application/vnd.openxmlformats-officedocument.wordprocessingml.document") || + contentType.equals("text/plain") || + contentType.equals("application/octet-stream"); } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/mock/LocalStorageService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/mock/LocalStorageService.java index a8acb10..c83dac4 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/mock/LocalStorageService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/mock/LocalStorageService.java @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.multipart.MultipartFile; import java.io.File; @@ -18,6 +19,8 @@ public class LocalStorageService implements StorageService { @Value("${app.base-url}") private String baseUrl; + private static final long MAX_RESOURCE_FILE_SIZE = 10 * 1024 * 1024; + private final String UPLOAD_DIR = "uploads"; public LocalStorageService() { @@ -31,13 +34,37 @@ public String uploadFile(MultipartFile file, String userId) { String fileName = userId + "-" + System.currentTimeMillis() + ".jpg"; Path path = Paths.get(UPLOAD_DIR, fileName); Files.write(path, file.getBytes()); - + return baseUrl + "/api/storage/files/" + fileName; } catch (Exception e) { throw new RuntimeException("Local upload failed", e); } } + @Override + public String uploadResourceFile(MultipartFile file, String userId) { + try { + if (file.getSize() > MAX_RESOURCE_FILE_SIZE) { + throw new MaxUploadSizeExceededException(MAX_RESOURCE_FILE_SIZE); + } + + String extension = getExtensionFromFilename(file.getOriginalFilename()); + if (!isAllowedResourceExtension(extension)) { + throw new IllegalArgumentException("Invalid file type. Only PDF, DOC, DOCX and TXT are allowed."); + } + + String fileName = "resources-" + userId + "-" + System.currentTimeMillis() + extension; + Path path = Paths.get(UPLOAD_DIR, fileName); + Files.write(path, file.getBytes()); + + return baseUrl + "/api/storage/files/" + fileName; + } catch (IllegalArgumentException | MaxUploadSizeExceededException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Local resource upload failed", e); + } + } + @Override public String uploadFromUrl(String externalUrl, String userId) { return externalUrl; @@ -53,4 +80,15 @@ public void deleteFile(String fileUrl) { System.err.println("Could not delete local file: " + e.getMessage()); } } -} \ No newline at end of file + + private boolean isAllowedResourceExtension(String extension) { + return ".pdf".equals(extension) || ".doc".equals(extension) || ".docx".equals(extension) || ".txt".equals(extension); + } + + private String getExtensionFromFilename(String fileName) { + if (fileName == null || !fileName.contains(".")) { + return ".pdf"; + } + return fileName.substring(fileName.lastIndexOf('.')).toLowerCase(); + } +} diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index eb21e9e..687297f 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -15,6 +15,7 @@ + @if (saveMessage()) { +

{{ saveMessage() }}

+ } + + + } @else { +
+ Want to contribute? Log in and add links/files. +
+ } + + @if (errorMessage()) { +

{{ errorMessage() }}

+ } + + @if (isLoading()) { +

Loading resources...

+ } @else { +
+ @for (section of groupedResources(); track section.category) { +
+
+

{{ section.category }}

+

{{ section.links.length }} resources

+
+ +
    + @for (link of section.links; track link.id) { +
  • +
    +
    +

    {{ link.title }}

    +

    Shared by {{ link.submittedByName }}

    +
    + + {{ getLinkLabel(link) }} + +
    + + @if (isOwner(link)) { + + } +
  • + } +
+
+ } +
+ } + + diff --git a/frontend/src/app/components/resources/resources.component.spec.ts b/frontend/src/app/components/resources/resources.component.spec.ts new file mode 100644 index 0000000..5762377 --- /dev/null +++ b/frontend/src/app/components/resources/resources.component.spec.ts @@ -0,0 +1,41 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ResourcesComponent } from './resources.component'; +import { ResourceService } from '../../services/resource.service'; +import { AuthService } from '../../services/auth.service'; + +describe('ResourcesComponent', () => { + let component: ResourcesComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResourcesComponent], + providers: [ + { + provide: ResourceService, + useValue: { + getResources: () => Promise.resolve([]), + createResource: () => Promise.resolve({}), + deleteResource: () => Promise.resolve(), + }, + }, + { + provide: AuthService, + useValue: { + isAuthenticated: () => false, + currentUser: () => null, + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ResourcesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/resources/resources.component.ts b/frontend/src/app/components/resources/resources.component.ts new file mode 100644 index 0000000..cd31b1e --- /dev/null +++ b/frontend/src/app/components/resources/resources.component.ts @@ -0,0 +1,161 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { AuthService } from '../../services/auth.service'; +import { CareerResource, ResourceService } from '../../services/resource.service'; +import { LogoComponent } from '../ui/logo/logo.component'; + +@Component({ + selector: 'app-resources', + standalone: true, + imports: [CommonModule, RouterLink, LogoComponent, FormsModule], + templateUrl: './resources.component.html', + styleUrl: './resources.component.css' +}) +export class ResourcesComponent { + authService = inject(AuthService); + private resourceService = inject(ResourceService); + + readonly resources = signal([]); + readonly isLoading = signal(true); + readonly errorMessage = signal(''); + readonly saveMessage = signal(''); + readonly isSaving = signal(false); + readonly deletingResourceId = signal(null); + + title = ''; + url = ''; + category = ''; + description = ''; + selectedFile: File | null = null; + + readonly groupedResources = computed(() => { + const grouped = new Map(); + + for (const resource of this.resources()) { + const key = resource.category?.trim() || 'General'; + const list = grouped.get(key) ?? []; + list.push(resource); + grouped.set(key, list); + } + + return Array.from(grouped.entries()).map(([category, links]) => ({ + category, + links + })); + }); + + constructor() { + this.loadResources(); + } + + isOwner(resource: CareerResource) { + const userEmail = this.authService.currentUser()?.email?.toLowerCase(); + return !!userEmail && userEmail === resource.submittedByEmail.toLowerCase(); + } + + getPrimaryLink(resource: CareerResource) { + return resource.url || resource.fileUrl || '#'; + } + + getLinkLabel(resource: CareerResource) { + return resource.fileUrl && !resource.url ? 'Open file' : 'Open link'; + } + + onFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0] || null; + + if (!file) { + this.selectedFile = null; + return; + } + + if (file.size > 10 * 1024 * 1024) { + this.saveMessage.set('File is too large. Max size is 10MB.'); + this.selectedFile = null; + input.value = ''; + return; + } + + this.selectedFile = file; + } + + async loadResources() { + this.isLoading.set(true); + this.errorMessage.set(''); + + try { + const data = await this.resourceService.getResources(); + this.resources.set(data); + } catch { + this.errorMessage.set('Could not load community resources right now. Please try again.'); + } finally { + this.isLoading.set(false); + } + } + + async addResource() { + this.saveMessage.set(''); + + if (!this.title.trim() || !this.category.trim()) { + this.saveMessage.set('Title and category are required.'); + return; + } + + if (!this.url.trim() && !this.selectedFile) { + this.saveMessage.set('Provide either a URL or upload a file.'); + return; + } + + this.isSaving.set(true); + + try { + const created = await this.resourceService.createResource({ + title: this.title, + url: this.url, + category: this.category, + description: this.description, + file: this.selectedFile + }); + + this.resources.set([created, ...this.resources()]); + this.title = ''; + this.url = ''; + this.category = ''; + this.description = ''; + this.selectedFile = null; + + const fileInput = document.getElementById('resource-file-input') as HTMLInputElement | null; + if (fileInput) { + fileInput.value = ''; + } + + this.saveMessage.set('Resource added. Thanks for contributing!'); + } catch { + this.saveMessage.set('Could not add the resource. Please check inputs and try again.'); + } finally { + this.isSaving.set(false); + } + } + + async deleteResource(resource: CareerResource) { + if (!this.isOwner(resource)) { + return; + } + + this.deletingResourceId.set(resource.id); + this.saveMessage.set(''); + + try { + await this.resourceService.deleteResource(resource.id); + this.resources.set(this.resources().filter((item) => item.id !== resource.id)); + this.saveMessage.set('Resource removed successfully.'); + } catch { + this.saveMessage.set('Could not remove resource. Please try again.'); + } finally { + this.deletingResourceId.set(null); + } + } +} diff --git a/frontend/src/app/services/resource.service.ts b/frontend/src/app/services/resource.service.ts new file mode 100644 index 0000000..f792fe3 --- /dev/null +++ b/frontend/src/app/services/resource.service.ts @@ -0,0 +1,59 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import { environment } from '../../environments/environment'; + +export interface CareerResource { + id: string; + title: string; + url?: string; + fileUrl?: string; + category: string; + description?: string; + submittedByName: string; + submittedByEmail: string; + createdAt: string; +} + +export interface CreateResourcePayload { + title: string; + category: string; + url?: string; + description?: string; + file?: File | null; +} + +@Injectable({ providedIn: 'root' }) +export class ResourceService { + private readonly API = environment.apiBaseUrl; + private http = inject(HttpClient); + private apiUrl = `${this.API}/api/resources`; + + async getResources() { + return await firstValueFrom(this.http.get(this.apiUrl)); + } + + async createResource(payload: CreateResourcePayload) { + const formData = new FormData(); + formData.append('title', payload.title); + formData.append('category', payload.category); + + if (payload.url) { + formData.append('url', payload.url); + } + + if (payload.description) { + formData.append('description', payload.description); + } + + if (payload.file) { + formData.append('file', payload.file); + } + + return await firstValueFrom(this.http.post(this.apiUrl, formData)); + } + + async deleteResource(id: string) { + return await firstValueFrom(this.http.delete(`${this.apiUrl}/${id}`)); + } +} From 2a1dc04532777c98002bfcd847fac3d537380ca2 Mon Sep 17 00:00:00 2001 From: Hari Date: Mon, 16 Feb 2026 21:25:47 +0530 Subject: [PATCH 3/8] Hide duplicate resources top nav inside app layout --- .../src/app/components/resources/resources.component.html | 3 +++ .../src/app/components/resources/resources.component.ts | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/resources/resources.component.html b/frontend/src/app/components/resources/resources.component.html index f35df95..77b4fd7 100644 --- a/frontend/src/app/components/resources/resources.component.html +++ b/frontend/src/app/components/resources/resources.component.html @@ -1,4 +1,5 @@
+ @if (showStandaloneNav()) { + } +
diff --git a/frontend/src/app/components/resources/resources.component.ts b/frontend/src/app/components/resources/resources.component.ts index cd31b1e..06e4255 100644 --- a/frontend/src/app/components/resources/resources.component.ts +++ b/frontend/src/app/components/resources/resources.component.ts @@ -1,7 +1,7 @@ import { CommonModule } from '@angular/common'; import { Component, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { RouterLink } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { AuthService } from '../../services/auth.service'; import { CareerResource, ResourceService } from '../../services/resource.service'; import { LogoComponent } from '../ui/logo/logo.component'; @@ -16,6 +16,7 @@ import { LogoComponent } from '../ui/logo/logo.component'; export class ResourcesComponent { authService = inject(AuthService); private resourceService = inject(ResourceService); + private router = inject(Router); readonly resources = signal([]); readonly isLoading = signal(true); @@ -50,6 +51,11 @@ export class ResourcesComponent { this.loadResources(); } + + showStandaloneNav() { + return !this.router.url.startsWith('/app'); + } + isOwner(resource: CareerResource) { const userEmail = this.authService.currentUser()?.email?.toLowerCase(); return !!userEmail && userEmail === resource.submittedByEmail.toLowerCase(); From cae158ac839dbf667fa99efb7229ff802071c1ce Mon Sep 17 00:00:00 2001 From: Hari Date: Mon, 16 Feb 2026 21:45:41 +0530 Subject: [PATCH 4/8] Add resource search/filter and owner edit workflow --- .../controller/CareerResourceController.java | 25 ++++ .../service/CareerResourceService.java | 80 ++++++++-- .../resources/resources.component.html | 111 ++++++++++---- .../resources/resources.component.spec.ts | 1 + .../resources/resources.component.ts | 141 +++++++++++++++++- frontend/src/app/services/resource.service.ts | 34 ++++- 6 files changed, 343 insertions(+), 49 deletions(-) diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java b/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java index 73184ea..7fdbf53 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -51,6 +52,30 @@ public ResponseEntity addResource( )); } + @PutMapping("/{id}") + public ResponseEntity updateResource( + @PathVariable UUID id, + @RequestParam String title, + @RequestParam(required = false) String url, + @RequestParam String category, + @RequestParam(required = false) String description, + @RequestParam(defaultValue = "false") boolean removeFile, + @RequestParam(required = false) MultipartFile file + ) { + String email = getAuthenticatedEmail(); + + return ResponseEntity.ok(careerResourceService.updateResource( + id, + email, + title, + url, + category, + description, + removeFile, + file + )); + } + @DeleteMapping("/{id}") public ResponseEntity deleteResource(@PathVariable UUID id) { String email = getAuthenticatedEmail(); diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java index e8de6f7..21fbdd3 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java @@ -3,6 +3,7 @@ import com.thughari.jobtrackerpro.dto.CareerResourceDTO; import com.thughari.jobtrackerpro.entity.CareerResource; import com.thughari.jobtrackerpro.entity.User; +import com.thughari.jobtrackerpro.interfaces.StorageService; import com.thughari.jobtrackerpro.repo.CareerResourceRepository; import com.thughari.jobtrackerpro.repo.UserRepository; import jakarta.annotation.PostConstruct; @@ -10,8 +11,6 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import com.thughari.jobtrackerpro.interfaces.StorageService; - import java.util.List; import java.util.UUID; @@ -50,7 +49,7 @@ public CareerResourceDTO createResource( MultipartFile file ) { String normalizedUrl = normalizeUrl(url); - validatePayload(title, category, normalizedUrl, file); + validateBasicFields(title, category); User user = userRepository.findByEmail(email) .orElseThrow(() -> new IllegalArgumentException("User not found")); @@ -60,26 +59,74 @@ public CareerResourceDTO createResource( uploadedFileUrl = storageService.uploadResourceFile(file, user.getId().toString()); } + ensureLinkOrFileExists(normalizedUrl, uploadedFileUrl); + CareerResource resource = new CareerResource(); resource.setTitle(title.trim()); resource.setUrl(normalizedUrl); resource.setFileUrl(uploadedFileUrl); resource.setCategory(category.trim()); - resource.setDescription(description == null ? null : description.trim()); + resource.setDescription(normalizeOptional(description)); resource.setSubmittedByEmail(user.getEmail()); resource.setSubmittedByName(user.getName() == null || user.getName().isBlank() ? user.getEmail() : user.getName()); return toDTO(resourceRepository.save(resource)); } - public void deleteResource(UUID resourceId, String requesterEmail) { + public CareerResourceDTO updateResource( + UUID resourceId, + String requesterEmail, + String title, + String url, + String category, + String description, + boolean removeFile, + MultipartFile file + ) { CareerResource resource = resourceRepository.findById(resourceId) .orElseThrow(() -> new IllegalArgumentException("Resource not found")); - if (!resource.getSubmittedByEmail().equalsIgnoreCase(requesterEmail)) { - throw new IllegalArgumentException("You can remove only your own resources"); + enforceOwner(resource, requesterEmail); + + validateBasicFields(title, category); + String normalizedUrl = normalizeUrl(url); + + String oldFileUrl = resource.getFileUrl(); + String nextFileUrl = oldFileUrl; + + if (removeFile) { + nextFileUrl = null; } + if (file != null && !file.isEmpty()) { + User user = userRepository.findByEmail(requesterEmail) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + nextFileUrl = storageService.uploadResourceFile(file, user.getId().toString()); + } + + ensureLinkOrFileExists(normalizedUrl, nextFileUrl); + + resource.setTitle(title.trim()); + resource.setUrl(normalizedUrl); + resource.setCategory(category.trim()); + resource.setDescription(normalizeOptional(description)); + resource.setFileUrl(nextFileUrl); + + CareerResource saved = resourceRepository.save(resource); + + if (oldFileUrl != null && !oldFileUrl.isBlank() && !oldFileUrl.equals(nextFileUrl)) { + storageService.deleteFile(oldFileUrl); + } + + return toDTO(saved); + } + + public void deleteResource(UUID resourceId, String requesterEmail) { + CareerResource resource = resourceRepository.findById(resourceId) + .orElseThrow(() -> new IllegalArgumentException("Resource not found")); + + enforceOwner(resource, requesterEmail); + if (resource.getFileUrl() != null && !resource.getFileUrl().isBlank()) { storageService.deleteFile(resource.getFileUrl()); } @@ -115,22 +162,37 @@ private void addSeedResource(String title, String url, String category) { resourceRepository.save(resource); } - private void validatePayload(String title, String category, String normalizedUrl, MultipartFile file) { + private void enforceOwner(CareerResource resource, String requesterEmail) { + if (!resource.getSubmittedByEmail().equalsIgnoreCase(requesterEmail)) { + throw new IllegalArgumentException("You can modify only your own resources"); + } + } + + private void validateBasicFields(String title, String category) { if (title == null || title.isBlank()) { throw new IllegalArgumentException("Title is required"); } if (category == null || category.isBlank()) { throw new IllegalArgumentException("Category is required"); } + } + private void ensureLinkOrFileExists(String normalizedUrl, String fileUrl) { boolean hasUrl = normalizedUrl != null && !normalizedUrl.isBlank(); - boolean hasFile = file != null && !file.isEmpty(); + boolean hasFile = fileUrl != null && !fileUrl.isBlank(); if (!hasUrl && !hasFile) { throw new IllegalArgumentException("Provide either a URL or a file"); } } + private String normalizeOptional(String value) { + if (value == null || value.trim().isBlank()) { + return null; + } + return value.trim(); + } + private String normalizeUrl(String url) { if (url == null || url.trim().isBlank()) { return null; diff --git a/frontend/src/app/components/resources/resources.component.html b/frontend/src/app/components/resources/resources.component.html index 77b4fd7..bf5c9d2 100644 --- a/frontend/src/app/components/resources/resources.component.html +++ b/frontend/src/app/components/resources/resources.component.html @@ -1,40 +1,55 @@
@if (showStandaloneNav()) { - } -

Community Career Resource Mine

Clean, mobile-first resource sharing

-

Browse resources from backend storage and contribute links or files (PDF/DOC/TXT up to 10MB).

+

Search resources, filter by category, and manage your own uploads in one place.

+
+
+ + +
+ Showing {{ filteredResources().length }} result(s) +
+
+
+ @if (authService.isAuthenticated()) {

Share a resource

@@ -99,10 +114,50 @@

{{ section.category }}

@if (isOwner(link)) { - +
+ + +
+ } + + @if (editingResourceId === link.id) { +
+ + + + + @if (link.fileUrl) { + + } + + + + +
+ + +
+
} } diff --git a/frontend/src/app/components/resources/resources.component.spec.ts b/frontend/src/app/components/resources/resources.component.spec.ts index 5762377..e5e9bbf 100644 --- a/frontend/src/app/components/resources/resources.component.spec.ts +++ b/frontend/src/app/components/resources/resources.component.spec.ts @@ -18,6 +18,7 @@ describe('ResourcesComponent', () => { getResources: () => Promise.resolve([]), createResource: () => Promise.resolve({}), deleteResource: () => Promise.resolve(), + updateResource: () => Promise.resolve({}), }, }, { diff --git a/frontend/src/app/components/resources/resources.component.ts b/frontend/src/app/components/resources/resources.component.ts index 06e4255..2a7997a 100644 --- a/frontend/src/app/components/resources/resources.component.ts +++ b/frontend/src/app/components/resources/resources.component.ts @@ -24,6 +24,7 @@ export class ResourcesComponent { readonly saveMessage = signal(''); readonly isSaving = signal(false); readonly deletingResourceId = signal(null); + readonly updatingResourceId = signal(null); title = ''; url = ''; @@ -31,10 +32,54 @@ export class ResourcesComponent { description = ''; selectedFile: File | null = null; + searchTerm = ''; + selectedCategoryFilter = 'All'; + + editingResourceId: string | null = null; + editTitle = ''; + editUrl = ''; + editCategory = ''; + editDescription = ''; + editFile: File | null = null; + removeExistingFile = false; + + readonly categoryOptions = computed(() => { + const categories = new Set(); + for (const item of this.resources()) { + categories.add(item.category?.trim() || 'General'); + } + return ['All', ...Array.from(categories).sort((a, b) => a.localeCompare(b))]; + }); + + readonly filteredResources = computed(() => { + const search = this.searchTerm.trim().toLowerCase(); + + return this.resources().filter((resource) => { + const matchesCategory = this.selectedCategoryFilter === 'All' || resource.category === this.selectedCategoryFilter; + + if (!matchesCategory) { + return false; + } + + if (!search) { + return true; + } + + const haystack = [ + resource.title, + resource.category, + resource.description || '', + resource.submittedByName + ].join(' ').toLowerCase(); + + return haystack.includes(search); + }); + }); + readonly groupedResources = computed(() => { const grouped = new Map(); - for (const resource of this.resources()) { + for (const resource of this.filteredResources()) { const key = resource.category?.trim() || 'General'; const list = grouped.get(key) ?? []; list.push(resource); @@ -51,7 +96,6 @@ export class ResourcesComponent { this.loadResources(); } - showStandaloneNav() { return !this.router.url.startsWith('/app'); } @@ -73,19 +117,26 @@ export class ResourcesComponent { const input = event.target as HTMLInputElement; const file = input.files?.[0] || null; - if (!file) { + if (!this.isValidUpload(file)) { + input.value = ''; this.selectedFile = null; return; } - if (file.size > 10 * 1024 * 1024) { - this.saveMessage.set('File is too large. Max size is 10MB.'); - this.selectedFile = null; + this.selectedFile = file; + } + + onEditFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0] || null; + + if (!this.isValidUpload(file)) { input.value = ''; + this.editFile = null; return; } - this.selectedFile = file; + this.editFile = file; } async loadResources() { @@ -146,6 +197,69 @@ export class ResourcesComponent { } } + startEditing(resource: CareerResource) { + this.editingResourceId = resource.id; + this.editTitle = resource.title; + this.editUrl = resource.url || ''; + this.editCategory = resource.category; + this.editDescription = resource.description || ''; + this.editFile = null; + this.removeExistingFile = false; + this.saveMessage.set(''); + } + + cancelEditing() { + this.editingResourceId = null; + this.editTitle = ''; + this.editUrl = ''; + this.editCategory = ''; + this.editDescription = ''; + this.editFile = null; + this.removeExistingFile = false; + } + + async saveEdit(resource: CareerResource) { + if (!this.isOwner(resource)) { + return; + } + + if (!this.editTitle.trim() || !this.editCategory.trim()) { + this.saveMessage.set('Title and category are required for update.'); + return; + } + + const effectiveHasFile = !!this.editFile || (!!resource.fileUrl && !this.removeExistingFile); + if (!this.editUrl.trim() && !effectiveHasFile) { + this.saveMessage.set('Keep URL or file. A resource needs at least one.'); + return; + } + + this.updatingResourceId.set(resource.id); + this.saveMessage.set(''); + + try { + const updated = await this.resourceService.updateResource({ + id: resource.id, + title: this.editTitle, + url: this.editUrl, + category: this.editCategory, + description: this.editDescription, + file: this.editFile, + removeFile: this.removeExistingFile + }); + + this.resources.set( + this.resources().map((item) => (item.id === updated.id ? updated : item)) + ); + this.cancelEditing(); + this.saveMessage.set('Resource updated successfully.'); + } catch { + this.saveMessage.set('Could not update resource. Please try again.'); + } finally { + this.updatingResourceId.set(null); + } + } + async deleteResource(resource: CareerResource) { if (!this.isOwner(resource)) { return; @@ -164,4 +278,17 @@ export class ResourcesComponent { this.deletingResourceId.set(null); } } + + private isValidUpload(file: File | null) { + if (!file) { + return true; + } + + if (file.size > 10 * 1024 * 1024) { + this.saveMessage.set('File is too large. Max size is 10MB.'); + return false; + } + + return true; + } } diff --git a/frontend/src/app/services/resource.service.ts b/frontend/src/app/services/resource.service.ts index f792fe3..b1d9a52 100644 --- a/frontend/src/app/services/resource.service.ts +++ b/frontend/src/app/services/resource.service.ts @@ -23,6 +23,16 @@ export interface CreateResourcePayload { file?: File | null; } +export interface UpdateResourcePayload { + id: string; + title: string; + category: string; + url?: string; + description?: string; + file?: File | null; + removeFile?: boolean; +} + @Injectable({ providedIn: 'root' }) export class ResourceService { private readonly API = environment.apiBaseUrl; @@ -34,6 +44,24 @@ export class ResourceService { } async createResource(payload: CreateResourcePayload) { + const formData = this.toFormData(payload); + return await firstValueFrom(this.http.post(this.apiUrl, formData)); + } + + async updateResource(payload: UpdateResourcePayload) { + const formData = this.toFormData(payload); + if (payload.removeFile) { + formData.append('removeFile', 'true'); + } + + return await firstValueFrom(this.http.put(`${this.apiUrl}/${payload.id}`, formData)); + } + + async deleteResource(id: string) { + return await firstValueFrom(this.http.delete(`${this.apiUrl}/${id}`)); + } + + private toFormData(payload: CreateResourcePayload | UpdateResourcePayload) { const formData = new FormData(); formData.append('title', payload.title); formData.append('category', payload.category); @@ -50,10 +78,6 @@ export class ResourceService { formData.append('file', payload.file); } - return await firstValueFrom(this.http.post(this.apiUrl, formData)); - } - - async deleteResource(id: string) { - return await firstValueFrom(this.http.delete(`${this.apiUrl}/${id}`)); + return formData; } } From d3a6f67f9f7592d363982c85cbede30094ba8739 Mon Sep 17 00:00:00 2001 From: Hari Date: Mon, 16 Feb 2026 21:52:56 +0530 Subject: [PATCH 5/8] Collapse add resource form and fix reactive search filtering --- .../resources/resources.component.html | 59 +++++++++++-------- .../resources/resources.component.ts | 24 ++++++-- 2 files changed, 54 insertions(+), 29 deletions(-) diff --git a/frontend/src/app/components/resources/resources.component.html b/frontend/src/app/components/resources/resources.component.html index bf5c9d2..a22808e 100644 --- a/frontend/src/app/components/resources/resources.component.html +++ b/frontend/src/app/components/resources/resources.component.html @@ -36,9 +36,9 @@

- - - - - - - -
- -
- - @if (saveMessage()) { -

{{ saveMessage() }}

- }
+ + @if (showAddForm()) { +
+ + + + + + + +
+ +
+
+ } + + @if (saveMessage()) { +

{{ saveMessage() }}

+ } } @else {
diff --git a/frontend/src/app/components/resources/resources.component.ts b/frontend/src/app/components/resources/resources.component.ts index 2a7997a..3b0bc50 100644 --- a/frontend/src/app/components/resources/resources.component.ts +++ b/frontend/src/app/components/resources/resources.component.ts @@ -32,8 +32,9 @@ export class ResourcesComponent { description = ''; selectedFile: File | null = null; - searchTerm = ''; - selectedCategoryFilter = 'All'; + readonly searchTerm = signal(''); + readonly selectedCategoryFilter = signal('All'); + readonly showAddForm = signal(false); editingResourceId: string | null = null; editTitle = ''; @@ -52,10 +53,11 @@ export class ResourcesComponent { }); readonly filteredResources = computed(() => { - const search = this.searchTerm.trim().toLowerCase(); + const search = this.searchTerm().trim().toLowerCase(); return this.resources().filter((resource) => { - const matchesCategory = this.selectedCategoryFilter === 'All' || resource.category === this.selectedCategoryFilter; + const selectedCategory = this.selectedCategoryFilter(); + const matchesCategory = selectedCategory === 'All' || resource.category === selectedCategory; if (!matchesCategory) { return false; @@ -92,6 +94,19 @@ export class ResourcesComponent { })); }); + + onSearchTermChange(value: string) { + this.searchTerm.set(value); + } + + onCategoryFilterChange(value: string) { + this.selectedCategoryFilter.set(value); + } + + toggleAddForm() { + this.showAddForm.update((value) => !value); + } + constructor() { this.loadResources(); } @@ -190,6 +205,7 @@ export class ResourcesComponent { } this.saveMessage.set('Resource added. Thanks for contributing!'); + this.showAddForm.set(false); } catch { this.saveMessage.set('Could not add the resource. Please check inputs and try again.'); } finally { From 7474a7c8bc7f6d3a2d9f7bca8721af50ea697e88 Mon Sep 17 00:00:00 2001 From: Hari Date: Tue, 17 Feb 2026 00:45:15 +0530 Subject: [PATCH 6/8] Use backend-driven category options for resource filters --- .../controller/CareerResourceController.java | 72 +++- .../jobtrackerpro/dto/CareerResourceDTO.java | 4 + .../dto/CareerResourcePageResponse.java | 17 + .../dto/UpdateCareerResourceRequest.java | 11 + .../jobtrackerpro/entity/CareerResource.java | 8 + .../repo/CareerResourceRepository.java | 13 +- .../service/CareerResourceService.java | 271 +++++++++++-- .../resources/resources.component.html | 355 ++++++++++++++---- .../resources/resources.component.spec.ts | 16 +- .../resources/resources.component.ts | 343 ++++++++++++++++- frontend/src/app/services/resource.service.ts | 117 +++++- 11 files changed, 1117 insertions(+), 110 deletions(-) create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourcePageResponse.java create mode 100644 backend/src/main/java/com/thughari/jobtrackerpro/dto/UpdateCareerResourceRequest.java diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java b/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java index b40c592..f83327e 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java @@ -1,16 +1,27 @@ package com.thughari.jobtrackerpro.controller; import com.thughari.jobtrackerpro.dto.CareerResourceDTO; +import com.thughari.jobtrackerpro.dto.CareerResourcePageResponse; import com.thughari.jobtrackerpro.dto.CreateCareerResourceRequest; +import com.thughari.jobtrackerpro.dto.UpdateCareerResourceRequest; import com.thughari.jobtrackerpro.service.CareerResourceService; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import java.util.UUID; import java.util.List; @RestController @@ -24,8 +35,20 @@ public CareerResourceController(CareerResourceService careerResourceService) { } @GetMapping - public ResponseEntity> getResources() { - return ResponseEntity.ok(careerResourceService.getAllResources()); + public ResponseEntity getResources( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String query, + @RequestParam(required = false) String category, + @RequestParam(required = false) String type + ) { + return ResponseEntity.ok(careerResourceService.getResourcePage(page, size, query, category, type, getAuthenticatedEmailOrNull())); + } + + + @GetMapping("/categories") + public ResponseEntity> getCategories() { + return ResponseEntity.ok(careerResourceService.getAllCategories()); } @PostMapping @@ -34,7 +57,52 @@ public ResponseEntity addResource(@RequestBody CreateCareerRe return ResponseEntity.ok(careerResourceService.createResource(email, request)); } + @GetMapping("/mine") + public ResponseEntity> getMyResources() { + String email = getAuthenticatedEmail(); + return ResponseEntity.ok(careerResourceService.getMyResources(email)); + } + + @PutMapping("/{id}") + public ResponseEntity updateResource(@PathVariable UUID id, + @RequestBody UpdateCareerResourceRequest request) { + String email = getAuthenticatedEmail(); + return ResponseEntity.ok(careerResourceService.updateResource(email, id, request)); + } + + @PostMapping(path = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadResource( + @RequestParam String title, + @RequestParam String category, + @RequestParam(required = false) String description, + @RequestParam MultipartFile file + ) { + String email = getAuthenticatedEmail(); + return ResponseEntity.ok(careerResourceService.createResourceFromFile(email, title, category, description, file)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteResource(@PathVariable UUID id) { + String email = getAuthenticatedEmail(); + careerResourceService.deleteResource(email, id); + return ResponseEntity.noContent().build(); + } + private String getAuthenticatedEmail() { return ((String) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).toLowerCase(); } + + private String getAuthenticatedEmailOrNull() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated() || authentication instanceof AnonymousAuthenticationToken) { + return null; + } + + Object principal = authentication.getPrincipal(); + if (principal instanceof String email && !"anonymousUser".equalsIgnoreCase(email)) { + return email.toLowerCase(); + } + + return null; + } } diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java index f29ed91..91147a1 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourceDTO.java @@ -12,6 +12,10 @@ public class CareerResourceDTO { private String url; private String category; private String description; + private String resourceType; + private String originalFileName; + private Long fileSizeBytes; + private boolean ownedByCurrentUser; private String submittedByName; private LocalDateTime createdAt; } diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourcePageResponse.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourcePageResponse.java new file mode 100644 index 0000000..ea235ba --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/CareerResourcePageResponse.java @@ -0,0 +1,17 @@ +package com.thughari.jobtrackerpro.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; + +@Data +@AllArgsConstructor +public class CareerResourcePageResponse { + private List content; + private int page; + private int size; + private long totalElements; + private int totalPages; + private boolean hasNext; +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/dto/UpdateCareerResourceRequest.java b/backend/src/main/java/com/thughari/jobtrackerpro/dto/UpdateCareerResourceRequest.java new file mode 100644 index 0000000..dd71b87 --- /dev/null +++ b/backend/src/main/java/com/thughari/jobtrackerpro/dto/UpdateCareerResourceRequest.java @@ -0,0 +1,11 @@ +package com.thughari.jobtrackerpro.dto; + +import lombok.Data; + +@Data +public class UpdateCareerResourceRequest { + private String title; + private String url; + private String category; + private String description; +} diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java b/backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java index 5e65f36..0472131 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/entity/CareerResource.java @@ -28,12 +28,20 @@ public class CareerResource { @Column(nullable = false, length = 2048) private String url; + @Column(nullable = false, length = 16) + private String resourceType = "LINK"; + @Column(nullable = false, length = 80) private String category; @Column(length = 1200) private String description; + @Column(length = 255) + private String originalFileName; + + private Long fileSizeBytes; + @Column(nullable = false) private String submittedByEmail; diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java b/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java index 828835e..1d26336 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java @@ -2,12 +2,23 @@ import com.thughari.jobtrackerpro.entity.CareerResource; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.util.List; import java.util.UUID; -public interface CareerResourceRepository extends JpaRepository { +public interface CareerResourceRepository extends JpaRepository, JpaSpecificationExecutor { List findAllByOrderByCreatedAtDesc(); + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + List findAllBySubmittedByEmailOrderByCreatedAtDesc(String email); + + @Query("select distinct r.category from CareerResource r where r.category is not null and trim(r.category) <> '' order by r.category asc") + List findDistinctCategories(); + boolean existsByUrl(String url); } diff --git a/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java b/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java index 669e826..e9297a6 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/service/CareerResourceService.java @@ -1,66 +1,276 @@ package com.thughari.jobtrackerpro.service; import com.thughari.jobtrackerpro.dto.CareerResourceDTO; +import com.thughari.jobtrackerpro.dto.CareerResourcePageResponse; import com.thughari.jobtrackerpro.dto.CreateCareerResourceRequest; +import com.thughari.jobtrackerpro.dto.UpdateCareerResourceRequest; import com.thughari.jobtrackerpro.entity.CareerResource; import com.thughari.jobtrackerpro.entity.User; +import com.thughari.jobtrackerpro.interfaces.StorageService; import com.thughari.jobtrackerpro.repo.CareerResourceRepository; import com.thughari.jobtrackerpro.repo.UserRepository; import jakarta.annotation.PostConstruct; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import jakarta.persistence.criteria.Predicate; + +import java.util.ArrayList; +import java.util.Locale; import java.util.List; @Service @Transactional public class CareerResourceService { + private static final int MAX_PAGE_SIZE = 50; + private static final int DEMO_MIN_RESOURCE_COUNT = 60; + private final CareerResourceRepository resourceRepository; private final UserRepository userRepository; + private final StorageService storageService; - public CareerResourceService(CareerResourceRepository resourceRepository, UserRepository userRepository) { + public CareerResourceService(CareerResourceRepository resourceRepository, + UserRepository userRepository, + StorageService storageService) { this.resourceRepository = resourceRepository; this.userRepository = userRepository; + this.storageService = storageService; } @Transactional(readOnly = true) - public List getAllResources() { - return resourceRepository.findAllByOrderByCreatedAtDesc() + @Cacheable(value = "resourcePages", key = "{#page, #size, #query == null ? '' : #query, #category == null ? '' : #category, #type == null ? '' : #type, #viewerEmail == null ? 'anon' : #viewerEmail}") + public CareerResourcePageResponse getResourcePage(int page, + int size, + String query, + String category, + String type, + String viewerEmail) { + int sanitizedPage = Math.max(0, page); + int sanitizedSize = Math.max(1, Math.min(size, MAX_PAGE_SIZE)); + String normalizedQuery = normalizeFilter(query); + String normalizedCategory = normalizeFilter(category); + String normalizedType = normalizeType(type); + + var pageable = PageRequest.of(sanitizedPage, sanitizedSize, Sort.by(Sort.Direction.DESC, "createdAt")); + var resourcePage = resourceRepository.findAll(buildResourceFilter(normalizedQuery, normalizedCategory, normalizedType), pageable); + + var content = resourcePage.getContent() .stream() - .map(this::toDTO) + .map(resource -> toDTO(resource, viewerEmail)) .toList(); + + return new CareerResourcePageResponse( + content, + resourcePage.getNumber(), + resourcePage.getSize(), + resourcePage.getTotalElements(), + resourcePage.getTotalPages(), + resourcePage.hasNext() + ); + } + + @Transactional(readOnly = true) + public List getAllCategories() { + return resourceRepository.findDistinctCategories(); + } + + private Specification buildResourceFilter(String query, String category, String type) { + return (root, criteriaQuery, criteriaBuilder) -> { + var predicates = new ArrayList(); + + if (query != null) { + String likeQuery = "%" + query.toLowerCase(Locale.ROOT) + "%"; + predicates.add(criteriaBuilder.or( + criteriaBuilder.like(criteriaBuilder.lower(root.get("title")), likeQuery), + criteriaBuilder.like(criteriaBuilder.lower(root.get("category")), likeQuery), + criteriaBuilder.like(criteriaBuilder.lower(root.get("description")), likeQuery), + criteriaBuilder.like(criteriaBuilder.lower(root.get("submittedByName")), likeQuery) + )); + } + + if (category != null) { + String likeCategory = "%" + category.toLowerCase(Locale.ROOT) + "%"; + predicates.add(criteriaBuilder.like(criteriaBuilder.lower(root.get("category")), likeCategory)); + } + + if (type != null) { + predicates.add(criteriaBuilder.equal(criteriaBuilder.upper(root.get("resourceType")), type)); + } + + return predicates.isEmpty() + ? criteriaBuilder.conjunction() + : criteriaBuilder.and(predicates.toArray(new Predicate[0])); + }; + } + + private String normalizeFilter(String value) { + if (value == null) { + return null; + } + + String trimmed = value.trim(); + if (trimmed.isEmpty() || "all".equalsIgnoreCase(trimmed)) { + return null; + } + + return trimmed; } + private String normalizeType(String value) { + String normalized = normalizeFilter(value); + if (normalized == null) { + return null; + } + + String upper = normalized.toUpperCase(Locale.ROOT); + if (!upper.equals("LINK") && !upper.equals("FILE")) { + return null; + } + + return upper; + } + + @CacheEvict(value = "resourcePages", allEntries = true) public CareerResourceDTO createResource(String email, CreateCareerResourceRequest request) { String normalizedUrl = normalizeUrl(request.getUrl()); - validatePayload(request, normalizedUrl); + validateLinkPayload(request, normalizedUrl); - User user = userRepository.findByEmail(email) - .orElseThrow(() -> new IllegalArgumentException("User not found")); + User user = getUser(email); CareerResource resource = new CareerResource(); resource.setTitle(request.getTitle().trim()); resource.setUrl(normalizedUrl); resource.setCategory(request.getCategory().trim()); resource.setDescription(request.getDescription() == null ? null : request.getDescription().trim()); - resource.setSubmittedByEmail(user.getEmail()); - resource.setSubmittedByName(user.getName() == null || user.getName().isBlank() ? user.getEmail() : user.getName()); + resource.setResourceType("LINK"); + applySubmitter(resource, user); - return toDTO(resourceRepository.save(resource)); + return toDTO(resourceRepository.save(resource), email); } - @PostConstruct - public void seedStarterResources() { - if (resourceRepository.count() > 0) { - return; + @CacheEvict(value = "resourcePages", allEntries = true) + public CareerResourceDTO createResourceFromFile(String email, + String title, + String category, + String description, + MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("Please choose a file to upload"); + } + validateCommonFields(title, category); + + User user = getUser(email); + String fileUrl = storageService.uploadResourceFile(file, user.getId().toString()); + + CareerResource resource = new CareerResource(); + resource.setTitle(title.trim()); + resource.setCategory(category.trim()); + resource.setDescription(description == null ? null : description.trim()); + resource.setUrl(fileUrl); + resource.setResourceType("FILE"); + resource.setOriginalFileName(file.getOriginalFilename()); + resource.setFileSizeBytes(file.getSize()); + applySubmitter(resource, user); + + return toDTO(resourceRepository.save(resource), email); + } + + @CacheEvict(value = "resourcePages", allEntries = true) + public void deleteResource(String email, java.util.UUID resourceId) { + CareerResource resource = resourceRepository.findById(resourceId) + .orElseThrow(() -> new IllegalArgumentException("Resource not found")); + + if (!resource.getSubmittedByEmail().equalsIgnoreCase(email)) { + throw new IllegalArgumentException("You can only delete resources you added"); + } + + if ("FILE".equalsIgnoreCase(resource.getResourceType())) { + storageService.deleteFile(resource.getUrl()); + } + + resourceRepository.delete(resource); + } + + @Transactional(readOnly = true) + public java.util.List getMyResources(String email) { + return resourceRepository.findAllBySubmittedByEmailOrderByCreatedAtDesc(email) + .stream() + .map(resource -> toDTO(resource, email)) + .toList(); + } + + @CacheEvict(value = "resourcePages", allEntries = true) + public CareerResourceDTO updateResource(String email, java.util.UUID resourceId, UpdateCareerResourceRequest request) { + CareerResource resource = resourceRepository.findById(resourceId) + .orElseThrow(() -> new IllegalArgumentException("Resource not found")); + + if (!resource.getSubmittedByEmail().equalsIgnoreCase(email)) { + throw new IllegalArgumentException("You can only edit resources you added"); } + validateCommonFields(request.getTitle(), request.getCategory()); + + resource.setTitle(request.getTitle().trim()); + resource.setCategory(request.getCategory().trim()); + resource.setDescription(request.getDescription() == null ? null : request.getDescription().trim()); + + if ("LINK".equalsIgnoreCase(resource.getResourceType())) { + String normalizedUrl = normalizeUrl(request.getUrl()); + if (normalizedUrl == null || normalizedUrl.isBlank()) { + throw new IllegalArgumentException("Valid URL is required for link resources"); + } + resource.setUrl(normalizedUrl); + } + + return toDTO(resourceRepository.save(resource), email); + } + + @PostConstruct + public void seedStarterResources() { addSeedResource("Career Preparation Notes", "https://docs.google.com/document/d/1-25JrPUai6P7pjKk1g7mELpI0YUINS_NpjUk7lsMfyg/edit?tab=t.0#heading=h.kf8l3f8jftc2", "Guides & Study Docs"); addSeedResource("Main Career Resources Folder", "https://drive.google.com/drive/folders/1ISp9GBv7ih1blEQOPYplD_idG1j0ibGq?usp=sharing", "Drive Folders & File Packs"); addSeedResource("DSA Folder", "https://drive.google.com/drive/folders/1ei52Zc_cQe0rJK404M56BmEisUjnF6kN?usp=drive_link", "Drive Folders & File Packs"); addSeedResource("21 Days React Study Plan", "https://thecodedose.notion.site/21-Days-React-Study-Plan-1988ff023cae48459bae8cb20cb75a67", "Structured Learning"); addSeedResource("Opportunity Tracker Sheet", "https://docs.google.com/spreadsheets/d/1KBFiqJTaFY1164XtglKvn2vAofScCfGlkY-n54D2d14/edit?gid=584790886#gid=584790886", "Trackers & Opportunity Sheets"); + + seedDemoVolumeResources(); + } + + private void seedDemoVolumeResources() { + long currentCount = resourceRepository.count(); + if (currentCount >= DEMO_MIN_RESOURCE_COUNT) { + return; + } + + String[][] templates = { + {"React Interview Drill", "https://example.com/resources/react-interview-drill", "Interview Prep"}, + {"DSA Patterns Workbook", "https://example.com/resources/dsa-patterns-workbook", "DSA"}, + {"System Design Primer", "https://example.com/resources/system-design-primer", "System Design"}, + {"Resume Bullet Bank", "https://example.com/resources/resume-bullet-bank", "Resume"}, + {"Backend Fundamentals Roadmap", "https://example.com/resources/backend-fundamentals-roadmap", "Roadmaps"}, + {"Frontend Fundamentals Roadmap", "https://example.com/resources/frontend-fundamentals-roadmap", "Roadmaps"}, + {"Mock Interview Checklist", "https://example.com/resources/mock-interview-checklist", "Mock Interviews"}, + {"Job Board Tracker", "https://example.com/resources/job-board-tracker", "Job Boards"}, + {"Behavioral Question Matrix", "https://example.com/resources/behavioral-question-matrix", "Interview Prep"}, + {"Portfolio Project Ideas", "https://example.com/resources/portfolio-project-ideas", "Portfolio"} + }; + + long resourcesToAdd = DEMO_MIN_RESOURCE_COUNT - currentCount; + for (int i = 0; i < resourcesToAdd; i++) { + String[] template = templates[i % templates.length]; + String title = template[0] + " #" + (i + 1); + String url = template[1] + "?v=" + (i + 1); + String category = template[2]; + + addSeedResource(title, url, category); + } } private void addSeedResource(String title, String url, String category) { @@ -73,21 +283,36 @@ private void addSeedResource(String title, String url, String category) { resource.setUrl(url); resource.setCategory(category); resource.setDescription("Seeded starter resource"); + resource.setResourceType("LINK"); resource.setSubmittedByEmail("system@jobtrackerpro.local"); resource.setSubmittedByName("JobTrackerPro"); resourceRepository.save(resource); } - private void validatePayload(CreateCareerResourceRequest request, String normalizedUrl) { - if (request.getTitle() == null || request.getTitle().isBlank()) { + private User getUser(String email) { + return userRepository.findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + } + + private void applySubmitter(CareerResource resource, User user) { + resource.setSubmittedByEmail(user.getEmail()); + resource.setSubmittedByName(user.getName() == null || user.getName().isBlank() ? user.getEmail() : user.getName()); + } + + private void validateLinkPayload(CreateCareerResourceRequest request, String normalizedUrl) { + validateCommonFields(request.getTitle(), request.getCategory()); + if (normalizedUrl == null || normalizedUrl.isBlank()) { + throw new IllegalArgumentException("Valid URL is required"); + } + } + + private void validateCommonFields(String title, String category) { + if (title == null || title.isBlank()) { throw new IllegalArgumentException("Title is required"); } - if (request.getCategory() == null || request.getCategory().isBlank()) { + if (category == null || category.isBlank()) { throw new IllegalArgumentException("Category is required"); } - if (normalizedUrl == null || normalizedUrl.isBlank()) { - throw new IllegalArgumentException("Valid URL is required"); - } } private String normalizeUrl(String url) { @@ -101,13 +326,17 @@ private String normalizeUrl(String url) { return trimmed; } - private CareerResourceDTO toDTO(CareerResource resource) { + private CareerResourceDTO toDTO(CareerResource resource, String viewerEmail) { CareerResourceDTO dto = new CareerResourceDTO(); dto.setId(resource.getId()); dto.setTitle(resource.getTitle()); dto.setUrl(resource.getUrl()); dto.setCategory(resource.getCategory()); dto.setDescription(resource.getDescription()); + dto.setResourceType(resource.getResourceType()); + dto.setOriginalFileName(resource.getOriginalFileName()); + dto.setFileSizeBytes(resource.getFileSizeBytes()); + dto.setOwnedByCurrentUser(viewerEmail != null && resource.getSubmittedByEmail().equalsIgnoreCase(viewerEmail)); dto.setSubmittedByName(resource.getSubmittedByName()); dto.setCreatedAt(resource.getCreatedAt()); return dto; diff --git a/frontend/src/app/components/resources/resources.component.html b/frontend/src/app/components/resources/resources.component.html index a78f40e..728a724 100644 --- a/frontend/src/app/components/resources/resources.component.html +++ b/frontend/src/app/components/resources/resources.component.html @@ -1,68 +1,146 @@ -
- + + } -
-
-

Community Career Resource Mine

-

Backend-powered resources for everyone

-

Resources are now served from Postgres through the API, and logged-in users can contribute links to make this page community friendly.

+
+
+

Community Career Resource Mine

+

Smart, cached resources built for scale

+

Resources are cached and paginated so the app stays fast even with large catalogs. Logged-in users can share links, upload documents, and remove their own contributions.

- @if (authService.isAuthenticated()) { -
-

Share a resource

-

Contribute helpful links so the whole community benefits.

- -
- - +
+
+
+

Search & filter resources

+

Find links and files quickly by keyword, category, or type.

- + @if (authService.isAuthenticated()) { + + } +
- +
+ -
-
+ + @if (saveMessage()) { +

{{ saveMessage() }}

+ } +
+ + @if (authService.isAuthenticated()) { +
+
+
+

Your shared resources

+

Edit or remove resources you have contributed.

+
+ - @if (saveMessage()) { -

{{ saveMessage() }}

- }
+ + @if (isLoadingMyResources()) { +

Loading your resources...

+ } @else if (!myResources().length) { +

You have not shared any resources yet.

+ } @else { +
    + @for (resource of myResources(); track resource.id) { +
  • +
    +

    {{ resource.title }}

    +

    {{ resource.category }} · {{ resource.resourceType === 'FILE' ? 'File' : 'Link' }}

    +
    +
    + + +
    +
  • + } +
+ } + + @if (editMessage()) { +

{{ editMessage() }}

+ }
- } @else { -
- Want to contribute? Log in and add your own career resources. + } + + @if (!authService.isAuthenticated()) { +
+ Want to contribute? Log in and add links or files.
} @@ -72,30 +150,177 @@

Share a resource

@if (isLoading()) {

Loading resources...

+ } @else if (!groupedResources().length) { +
+ No resources matched your search/filter. Try adjusting the filters. +
} @else { -
+
@for (section of groupedResources(); track section.category) { -
+ + @if (hasNext()) { +
+ +
+ } }
+ + @if (showAddResourceModal()) { +
+
+
+
+

Add a resource

+

Share a helpful link or upload a file (max 10MB).

+
+ +
+ +
+ + +
+ +
+ + +
+ + @if (contributionMode === 'link') { + + } @else { + + } + + + +
+ + +
+
+
+ } + + @if (editingResource()) { +
+
+
+
+

Edit resource

+

Update your resource details.

+
+ +
+ +
+ + +
+ + @if (editingResource()?.resourceType === 'LINK') { + + } @else { +

File URL cannot be edited for uploaded resources. You can edit title/category/description.

+ } + + + +
+ + +
+
+
+ } + + @for (categoryOption of categoryOptions(); track categoryOption) { + @if (categoryOption !== 'all') { + + } + } + +
diff --git a/frontend/src/app/components/resources/resources.component.spec.ts b/frontend/src/app/components/resources/resources.component.spec.ts index 00f8105..10d1039 100644 --- a/frontend/src/app/components/resources/resources.component.spec.ts +++ b/frontend/src/app/components/resources/resources.component.spec.ts @@ -3,6 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ResourcesComponent } from './resources.component'; import { ResourceService } from '../../services/resource.service'; import { AuthService } from '../../services/auth.service'; +import { provideRouter } from '@angular/router'; describe('ResourcesComponent', () => { let component: ResourcesComponent; @@ -12,11 +13,24 @@ describe('ResourcesComponent', () => { await TestBed.configureTestingModule({ imports: [ResourcesComponent], providers: [ + provideRouter([]), { provide: ResourceService, useValue: { - getResources: () => Promise.resolve([]), + getResourceCategories: () => Promise.resolve([]), + getResources: () => Promise.resolve({ + content: [], + page: 0, + size: 20, + totalElements: 0, + totalPages: 0, + hasNext: false, + }), createResource: () => Promise.resolve({}), + uploadResourceFile: () => Promise.resolve({}), + deleteResource: () => Promise.resolve(), + getMyResources: () => Promise.resolve([]), + updateResource: () => Promise.resolve({}), }, }, { diff --git a/frontend/src/app/components/resources/resources.component.ts b/frontend/src/app/components/resources/resources.component.ts index d2b3b93..52c259a 100644 --- a/frontend/src/app/components/resources/resources.component.ts +++ b/frontend/src/app/components/resources/resources.component.ts @@ -1,11 +1,16 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, inject, signal } from '@angular/core'; +import { Component, HostListener, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { RouterLink } from '@angular/router'; +import { NavigationEnd, Router, RouterLink } from '@angular/router'; +import { filter } from 'rxjs'; import { AuthService } from '../../services/auth.service'; -import { CareerResource, ResourceService } from '../../services/resource.service'; +import { CareerResource, ResourceQueryFilters, ResourceService } from '../../services/resource.service'; import { LogoComponent } from '../ui/logo/logo.component'; +const PAGE_SIZE = 20; +const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; +type ResourceTypeFilter = 'all' | 'LINK' | 'FILE'; + @Component({ selector: 'app-resources', standalone: true, @@ -16,17 +21,61 @@ import { LogoComponent } from '../ui/logo/logo.component'; export class ResourcesComponent { authService = inject(AuthService); private resourceService = inject(ResourceService); + private router = inject(Router); readonly resources = signal([]); readonly isLoading = signal(true); + readonly isLoadingMore = signal(false); + readonly hasNext = signal(false); + readonly currentPage = signal(0); readonly errorMessage = signal(''); readonly saveMessage = signal(''); + readonly editMessage = signal(''); readonly isSaving = signal(false); + readonly isUpdating = signal(false); + readonly isInAppRoute = signal(false); + readonly showAddResourceModal = signal(false); + readonly isLoadingMyResources = signal(false); + readonly myResources = signal([]); + readonly editingResource = signal(null); + private searchDebounceRef: ReturnType | null = null; + private scrollLoadDebounceRef: ReturnType | null = null; + + readonly searchQuery = signal(''); + readonly selectedCategoryFilter = signal('all'); + readonly selectedTypeFilter = signal('all'); + readonly backendCategories = signal([]); title = ''; url = ''; category = ''; description = ''; + contributionMode: 'link' | 'file' = 'link'; + selectedFile: File | null = null; + selectedFileError = ''; + + editTitle = ''; + editUrl = ''; + editCategory = ''; + editDescription = ''; + + readonly categoryOptions = computed(() => ['all', ...this.backendCategories()]); + + onSearchQueryChange(value: string) { + this.searchQuery.set(value); + this.scheduleFilterReload(); + } + + onCategoryFilterChange(value: string) { + const normalized = value.trim(); + this.selectedCategoryFilter.set(normalized ? normalized : 'all'); + this.loadResources(true); + } + + onTypeFilterChange(value: ResourceTypeFilter) { + this.selectedTypeFilter.set(value); + this.loadResources(true); + } readonly groupedResources = computed(() => { const grouped = new Map(); @@ -45,51 +94,311 @@ export class ResourcesComponent { }); constructor() { - this.loadResources(); + this.syncRouteContext(); + this.router.events + .pipe(filter((event) => event instanceof NavigationEnd)) + .subscribe(() => this.syncRouteContext()); + + this.loadCategoryOptions(); + this.loadResources(true); + if (this.authService.isAuthenticated()) { + this.loadMyResources(); + } + } + + private syncRouteContext() { + this.isInAppRoute.set(this.router.url.startsWith('/app/')); + } + + + async loadCategoryOptions(forceRefresh = false) { + try { + const categories = await this.resourceService.getResourceCategories(forceRefresh); + this.backendCategories.set(categories); + } catch { + this.backendCategories.set([]); + } + } + + openAddResourceModal() { + this.saveMessage.set(''); + this.showAddResourceModal.set(true); + } + + closeAddResourceModal() { + this.showAddResourceModal.set(false); + this.selectedFile = null; + this.selectedFileError = ''; + } + + async loadMyResources() { + if (!this.authService.isAuthenticated()) { + this.myResources.set([]); + return; + } + + this.isLoadingMyResources.set(true); + this.editMessage.set(''); + try { + const mine = await this.resourceService.getMyResources(); + this.myResources.set(mine); + } catch { + this.editMessage.set('Could not load your shared resources right now.'); + } finally { + this.isLoadingMyResources.set(false); + } } - async loadResources() { - this.isLoading.set(true); + async loadResources(reset = false) { + if (reset) { + this.currentPage.set(0); + this.resources.set([]); + this.isLoading.set(true); + } else { + this.isLoadingMore.set(true); + } + this.errorMessage.set(''); try { - const data = await this.resourceService.getResources(); - this.resources.set(data); + const page = this.currentPage(); + const response = await this.resourceService.getResources(page, PAGE_SIZE, this.currentFilters(), reset); + const merged = reset ? response.content : [...this.resources(), ...response.content]; + this.resources.set(merged); + this.hasNext.set(response.hasNext); + this.currentPage.set(page + 1); } catch { this.errorMessage.set('Could not load community resources right now. Please try again.'); } finally { this.isLoading.set(false); + this.isLoadingMore.set(false); + } + } + + @HostListener('window:scroll') + onWindowScroll() { + if (!this.hasNext() || this.isLoading() || this.isLoadingMore() || this.showAddResourceModal()) { + return; + } + + const scrollTop = window.scrollY || document.documentElement.scrollTop || 0; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; + const fullHeight = document.documentElement.scrollHeight || document.body.scrollHeight || 0; + const remaining = fullHeight - (scrollTop + viewportHeight); + + if (remaining > 220) { + return; + } + + if (this.scrollLoadDebounceRef) { + return; + } + + this.scrollLoadDebounceRef = setTimeout(() => { + this.scrollLoadDebounceRef = null; + }, 300); + + this.loadResources(); + } + + private scheduleFilterReload() { + if (this.searchDebounceRef) { + clearTimeout(this.searchDebounceRef); + } + + this.searchDebounceRef = setTimeout(() => { + this.loadResources(true); + }, 250); + } + + private currentFilters(): ResourceQueryFilters { + return { + query: this.searchQuery(), + category: this.selectedCategoryFilter(), + type: this.selectedTypeFilter() + }; + } + + onModeChange(mode: 'link' | 'file') { + this.contributionMode = mode; + this.saveMessage.set(''); + this.selectedFile = null; + this.selectedFileError = ''; + } + + onFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0] ?? null; + this.selectedFileError = ''; + + if (!file) { + this.selectedFile = null; + return; + } + + const allowedTypes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/plain', + 'application/octet-stream' + ]; + + const validExtension = /\.(pdf|doc|docx|txt)$/i.test(file.name); + const validType = allowedTypes.includes(file.type) || file.type === ''; + + if (!validExtension || !validType) { + this.selectedFile = null; + this.selectedFileError = 'Only PDF, DOC, DOCX, and TXT files are allowed.'; + input.value = ''; + return; + } + + if (file.size > MAX_FILE_SIZE_BYTES) { + this.selectedFile = null; + this.selectedFileError = 'File size must be 10MB or less.'; + input.value = ''; + return; } + + this.selectedFile = file; } async addResource() { this.saveMessage.set(''); - if (!this.title.trim() || !this.url.trim() || !this.category.trim()) { - this.saveMessage.set('Title, URL, and category are required.'); + if (!this.title.trim() || !this.category.trim()) { + this.saveMessage.set('Title and category are required.'); + return; + } + + if (this.contributionMode === 'link' && !this.url.trim()) { + this.saveMessage.set('Resource URL is required for link contributions.'); + return; + } + + if (this.contributionMode === 'file' && !this.selectedFile) { + this.saveMessage.set('Please choose a file before uploading.'); return; } this.isSaving.set(true); try { - const created = await this.resourceService.createResource({ - title: this.title, - url: this.url, - category: this.category, - description: this.description - }); + let created: CareerResource; + + if (this.contributionMode === 'file' && this.selectedFile) { + created = await this.resourceService.uploadResourceFile({ + title: this.title, + category: this.category, + description: this.description, + file: this.selectedFile + }); + } else { + created = await this.resourceService.createResource({ + title: this.title, + url: this.url, + category: this.category, + description: this.description + }); + } this.resources.set([created, ...this.resources()]); + this.hasNext.set(true); this.title = ''; this.url = ''; this.category = ''; this.description = ''; + this.selectedFile = null; this.saveMessage.set('Resource added. Thanks for contributing!'); + this.showAddResourceModal.set(false); + await this.loadCategoryOptions(true); + if (created.ownedByCurrentUser) { + this.myResources.set([created, ...this.myResources()]); + } } catch { - this.saveMessage.set('Could not add the resource. Please check the link and try again.'); + this.saveMessage.set('Could not add the resource. Please verify your details and try again.'); } finally { this.isSaving.set(false); } } + + async deleteResource(resource: CareerResource) { + this.saveMessage.set(''); + this.editMessage.set(''); + try { + await this.resourceService.deleteResource(resource.id); + this.resources.set(this.resources().filter(item => item.id !== resource.id)); + this.myResources.set(this.myResources().filter(item => item.id !== resource.id)); + this.saveMessage.set('Resource removed successfully.'); + await this.loadCategoryOptions(true); + } catch { + this.saveMessage.set('Could not remove this resource right now.'); + } + } + + startEditResource(resource: CareerResource) { + this.editingResource.set(resource); + this.editTitle = resource.title; + this.editUrl = resource.resourceType === 'LINK' ? resource.url : ''; + this.editCategory = resource.category; + this.editDescription = resource.description ?? ''; + this.editMessage.set(''); + } + + cancelEditResource() { + this.editingResource.set(null); + this.editMessage.set(''); + } + + async saveResourceEdit() { + const target = this.editingResource(); + if (!target) { + return; + } + + if (!this.editTitle.trim() || !this.editCategory.trim()) { + this.editMessage.set('Title and category are required.'); + return; + } + + if (target.resourceType === 'LINK' && !this.editUrl.trim()) { + this.editMessage.set('URL is required for link resources.'); + return; + } + + this.isUpdating.set(true); + this.editMessage.set(''); + + try { + const updated = await this.resourceService.updateResource(target.id, { + title: this.editTitle, + url: target.resourceType === 'LINK' ? this.editUrl : undefined, + category: this.editCategory, + description: this.editDescription + }); + + this.myResources.set(this.myResources().map(item => item.id === updated.id ? updated : item)); + this.resources.set(this.resources().map(item => item.id === updated.id ? updated : item)); + this.editingResource.set(null); + this.saveMessage.set('Resource updated successfully.'); + await this.loadCategoryOptions(true); + } catch { + this.editMessage.set('Could not update this resource right now.'); + } finally { + this.isUpdating.set(false); + } + } + + formatFileSize(size?: number) { + if (!size) { + return ''; + } + + if (size < 1024 * 1024) { + return `${Math.ceil(size / 1024)} KB`; + } + + return `${(size / (1024 * 1024)).toFixed(1)} MB`; + } } diff --git a/frontend/src/app/services/resource.service.ts b/frontend/src/app/services/resource.service.ts index eb99db8..c797792 100644 --- a/frontend/src/app/services/resource.service.ts +++ b/frontend/src/app/services/resource.service.ts @@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { environment } from '../../environments/environment'; +import { AuthService } from './auth.service'; export interface CareerResource { id: string; @@ -9,10 +10,23 @@ export interface CareerResource { url: string; category: string; description?: string; + resourceType: 'LINK' | 'FILE'; + originalFileName?: string; + fileSizeBytes?: number; + ownedByCurrentUser: boolean; submittedByName: string; createdAt: string; } +export interface CareerResourcePage { + content: CareerResource[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + hasNext: boolean; +} + export interface CreateResourcePayload { title: string; url: string; @@ -20,17 +34,114 @@ export interface CreateResourcePayload { description?: string; } +export interface UpdateResourcePayload { + title: string; + url?: string; + category: string; + description?: string; +} + +export interface ResourceQueryFilters { + query?: string; + category?: string; + type?: 'all' | 'LINK' | 'FILE'; +} + @Injectable({ providedIn: 'root' }) export class ResourceService { private readonly API = environment.apiBaseUrl; private http = inject(HttpClient); + private authService = inject(AuthService); private apiUrl = `${this.API}/api/resources`; + private pageCache = new Map(); + private categoryCache: string[] | null = null; + + async getResources(page: number, size: number, filters: ResourceQueryFilters = {}, forceRefresh = false) { + const cacheScope = this.authService.currentUser()?.email ?? 'anonymous'; + const query = filters.query?.trim() ?? ''; + const category = filters.category?.trim() ?? ''; + const type = filters.type && filters.type !== 'all' ? filters.type : ''; + const key = `${cacheScope}:${page}:${size}:${query}:${category}:${type}`; + + if (!forceRefresh && this.pageCache.has(key)) { + return this.pageCache.get(key)!; + } + + const data = await firstValueFrom( + this.http.get(this.apiUrl, { + params: { + page, + size, + ...(query ? { query } : {}), + ...(category ? { category } : {}), + ...(type ? { type } : {}) + } + }) + ); - async getResources() { - return await firstValueFrom(this.http.get(this.apiUrl)); + this.pageCache.set(key, data); + return data; + } + + invalidateCache() { + this.pageCache.clear(); + this.categoryCache = null; + } + + + async getResourceCategories(forceRefresh = false) { + if (!forceRefresh && this.categoryCache) { + return this.categoryCache; + } + + const categories = await firstValueFrom(this.http.get(`${this.apiUrl}/categories`)); + this.categoryCache = categories; + return categories; } async createResource(payload: CreateResourcePayload) { - return await firstValueFrom(this.http.post(this.apiUrl, payload)); + const created = await firstValueFrom(this.http.post(this.apiUrl, payload)); + this.invalidateCache(); + return created; + } + + async uploadResourceFile(payload: { + title: string; + category: string; + description?: string; + file: File; + }) { + const formData = new FormData(); + formData.append('title', payload.title); + formData.append('category', payload.category); + if (payload.description?.trim()) { + formData.append('description', payload.description.trim()); + } + formData.append('file', payload.file); + + const created = await firstValueFrom( + this.http.post(`${this.apiUrl}/upload`, formData) + ); + + this.invalidateCache(); + return created; + } + + async deleteResource(resourceId: string) { + await firstValueFrom(this.http.delete(`${this.apiUrl}/${resourceId}`)); + this.invalidateCache(); + } + + async getMyResources() { + return await firstValueFrom(this.http.get(`${this.apiUrl}/mine`)); + } + + async updateResource(resourceId: string, payload: UpdateResourcePayload) { + const updated = await firstValueFrom( + this.http.put(`${this.apiUrl}/${resourceId}`, payload) + ); + + this.invalidateCache(); + return updated; } } From 758352899a81c8fe1076d57535b1990b22e8eb63 Mon Sep 17 00:00:00 2001 From: Hari Date: Tue, 17 Feb 2026 00:53:50 +0530 Subject: [PATCH 7/8] Improve mobile category filter UX with searchable input --- .../resources/resources.component.html | 21 +++++++++++++------ .../resources/resources.component.ts | 13 +++++++++--- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/components/resources/resources.component.html b/frontend/src/app/components/resources/resources.component.html index 728a724..500300f 100644 --- a/frontend/src/app/components/resources/resources.component.html +++ b/frontend/src/app/components/resources/resources.component.html @@ -64,13 +64,22 @@

Search & filter Filter by category
- + + @if (selectedCategoryFilter()) { + + }
+ + @for (categoryOption of categoryOptions(); track categoryOption) { + + } +

@if (saveMessage()) {