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..c756835 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,14 @@ 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())); } @PostMapping @@ -34,7 +51,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..421eb36 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,19 @@ import com.thughari.jobtrackerpro.entity.CareerResource; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +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); + 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..dd86431 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,270 @@ 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 java.util.List; +import jakarta.persistence.criteria.Predicate; + +import java.util.ArrayList; +import java.util.Locale; @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() + ); + } + + 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 +277,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 +320,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..b3a4adf 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,21 @@ describe('ResourcesComponent', () => { await TestBed.configureTestingModule({ imports: [ResourcesComponent], providers: [ + provideRouter([]), { provide: ResourceService, useValue: { - getResources: () => 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(), }, }, { diff --git a/frontend/src/app/components/resources/resources.component.ts b/frontend/src/app/components/resources/resources.component.ts index d2b3b93..5e2851f 100644 --- a/frontend/src/app/components/resources/resources.component.ts +++ b/frontend/src/app/components/resources/resources.component.ts @@ -1,11 +1,27 @@ 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; +const DEFAULT_RESOURCE_CATEGORIES = [ + 'DSA', + 'Resume', + 'System Design', + 'Interview Prep', + 'Job Boards', + 'Roadmaps', + 'Mock Interviews', + 'Portfolio' +]; + +type ResourceTypeFilter = 'all' | 'LINK' | 'FILE'; + @Component({ selector: 'app-resources', standalone: true, @@ -16,17 +32,69 @@ 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'); title = ''; url = ''; category = ''; description = ''; + contributionMode: 'link' | 'file' = 'link'; + selectedFile: File | null = null; + selectedFileError = ''; + + editTitle = ''; + editUrl = ''; + editCategory = ''; + editDescription = ''; + + readonly categoryOptions = computed(() => { + const categories = new Set(); + for (const category of DEFAULT_RESOURCE_CATEGORIES) { + categories.add(category); + } + for (const resource of this.resources()) { + categories.add(resource.category?.trim() || 'General'); + } + return ['all', ...Array.from(categories).sort((a, b) => a.localeCompare(b))]; + }); + + 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 +113,297 @@ export class ResourcesComponent { }); constructor() { - this.loadResources(); + this.syncRouteContext(); + this.router.events + .pipe(filter((event) => event instanceof NavigationEnd)) + .subscribe(() => this.syncRouteContext()); + + this.loadResources(true); + if (this.authService.isAuthenticated()) { + this.loadMyResources(); + } + } + + private syncRouteContext() { + this.isInAppRoute.set(this.router.url.startsWith('/app/')); + } + + 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); + 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.'); + } 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.'); + } 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..b5ce733 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,101 @@ 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(); + + 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}`; - async getResources() { - return await firstValueFrom(this.http.get(this.apiUrl)); + 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 } : {}) + } + }) + ); + + this.pageCache.set(key, data); + return data; + } + + invalidateCache() { + this.pageCache.clear(); } 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; } }