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 6605d05..c756835 100644 --- a/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java +++ b/backend/src/main/java/com/thughari/jobtrackerpro/controller/CareerResourceController.java @@ -3,6 +3,7 @@ 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; @@ -13,6 +14,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.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -20,6 +22,7 @@ import org.springframework.web.multipart.MultipartFile; import java.util.UUID; +import java.util.List; @RestController @RequestMapping("/api/resources") @@ -34,9 +37,12 @@ public CareerResourceController(CareerResourceService careerResourceService) { @GetMapping public ResponseEntity getResources( @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size + @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, getAuthenticatedEmailOrNull())); + return ResponseEntity.ok(careerResourceService.getResourcePage(page, size, query, category, type, getAuthenticatedEmailOrNull())); } @PostMapping @@ -45,6 +51,19 @@ 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, 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/repo/CareerResourceRepository.java b/backend/src/main/java/com/thughari/jobtrackerpro/repo/CareerResourceRepository.java index ee4935c..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,16 +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 52f9e66..dd86431 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.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; @@ -13,15 +14,22 @@ 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; + @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; @@ -36,13 +44,21 @@ public CareerResourceService(CareerResourceRepository resourceRepository, } @Transactional(readOnly = true) - @Cacheable(value = "resourcePages", key = "{#page, #size, #viewerEmail == null ? 'anon' : #viewerEmail}") - public CareerResourcePageResponse getResourcePage(int page, int size, String viewerEmail) { + @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.findAllByOrderByCreatedAtDesc(pageable); + var resourcePage = resourceRepository.findAll(buildResourceFilter(normalizedQuery, normalizedCategory, normalizedType), pageable); var content = resourcePage.getContent() .stream() @@ -59,6 +75,62 @@ public CareerResourcePageResponse getResourcePage(int page, int size, String vie ); } + 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()); @@ -120,17 +192,79 @@ public void deleteResource(String email, java.util.UUID resourceId) { resourceRepository.delete(resource); } - @PostConstruct - public void seedStarterResources() { - if (resourceRepository.count() > 0) { - return; + @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) { diff --git a/frontend/src/app/components/resources/resources.component.html b/frontend/src/app/components/resources/resources.component.html index dae4378..1b2dd4d 100644 --- a/frontend/src/app/components/resources/resources.component.html +++ b/frontend/src/app/components/resources/resources.component.html @@ -91,6 +91,55 @@

Search & filter resources

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

Your shared resources

+

Edit or remove resources you have contributed.

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

+ } +
+ } + @if (!authService.isAuthenticated()) {
Want to contribute? Log in and add links or files. @@ -185,16 +234,9 @@

Add a resource

-
- - @for (categoryOption of categoryOptions(); track categoryOption) { - @if (categoryOption !== 'all') { - - } - } - @if (contributionMode === 'link') { Add a resource } + + @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.ts b/frontend/src/app/components/resources/resources.component.ts index 055e753..5e2851f 100644 --- a/frontend/src/app/components/resources/resources.component.ts +++ b/frontend/src/app/components/resources/resources.component.ts @@ -1,10 +1,10 @@ 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 { 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; @@ -41,9 +41,16 @@ export class ResourcesComponent { 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'); @@ -57,6 +64,11 @@ export class ResourcesComponent { selectedFile: File | null = null; selectedFileError = ''; + editTitle = ''; + editUrl = ''; + editCategory = ''; + editDescription = ''; + readonly categoryOptions = computed(() => { const categories = new Set(); for (const category of DEFAULT_RESOURCE_CATEGORIES) { @@ -68,46 +80,26 @@ export class ResourcesComponent { return ['all', ...Array.from(categories).sort((a, b) => a.localeCompare(b))]; }); - readonly filteredResources = computed(() => { - const normalizedQuery = this.searchQuery().trim().toLowerCase(); - const categoryFilter = this.selectedCategoryFilter(); - const typeFilter = this.selectedTypeFilter(); - - return this.resources().filter((resource) => { - const matchesQuery = - !normalizedQuery || - resource.title.toLowerCase().includes(normalizedQuery) || - resource.category.toLowerCase().includes(normalizedQuery) || - (resource.description || '').toLowerCase().includes(normalizedQuery) || - resource.submittedByName.toLowerCase().includes(normalizedQuery); - - const resourceCategory = resource.category?.trim() || 'General'; - const matchesCategory = - categoryFilter === 'all' || - resourceCategory.toLowerCase().includes(categoryFilter.toLowerCase()); - const matchesType = typeFilter === 'all' || resource.resourceType === typeFilter; - - return matchesQuery && matchesCategory && matchesType; - }); - }); - 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(); - for (const resource of this.filteredResources()) { + for (const resource of this.resources()) { const key = resource.category?.trim() || 'General'; const list = grouped.get(key) ?? []; list.push(resource); @@ -127,6 +119,9 @@ export class ResourcesComponent { .subscribe(() => this.syncRouteContext()); this.loadResources(true); + if (this.authService.isAuthenticated()) { + this.loadMyResources(); + } } private syncRouteContext() { @@ -144,6 +139,24 @@ export class ResourcesComponent { 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(reset = false) { if (reset) { this.currentPage.set(0); @@ -157,7 +170,7 @@ export class ResourcesComponent { try { const page = this.currentPage(); - const response = await this.resourceService.getResources(page, PAGE_SIZE, reset); + 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); @@ -170,6 +183,50 @@ export class ResourcesComponent { } } + @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(''); @@ -263,6 +320,9 @@ export class ResourcesComponent { 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 verify your details and try again.'); } finally { @@ -272,15 +332,69 @@ export class ResourcesComponent { 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 ''; diff --git a/frontend/src/app/services/resource.service.ts b/frontend/src/app/services/resource.service.ts index 669c764..b5ce733 100644 --- a/frontend/src/app/services/resource.service.ts +++ b/frontend/src/app/services/resource.service.ts @@ -34,6 +34,19 @@ 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; @@ -42,9 +55,12 @@ export class ResourceService { private apiUrl = `${this.API}/api/resources`; private pageCache = new Map(); - async getResources(page: number, size: number, forceRefresh = false) { + async getResources(page: number, size: number, filters: ResourceQueryFilters = {}, forceRefresh = false) { const cacheScope = this.authService.currentUser()?.email ?? 'anonymous'; - const key = `${cacheScope}:${page}:${size}`; + 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)!; @@ -54,7 +70,10 @@ export class ResourceService { this.http.get(this.apiUrl, { params: { page, - size + size, + ...(query ? { query } : {}), + ...(category ? { category } : {}), + ...(type ? { type } : {}) } }) ); @@ -99,4 +118,17 @@ export class ResourceService { 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; + } }