diff --git a/apps/api/src/main/java/com/mally/api/pastebin/PastebinController.java b/apps/api/src/main/java/com/mally/api/pastebin/PastebinController.java index 126cb25..ff47183 100644 --- a/apps/api/src/main/java/com/mally/api/pastebin/PastebinController.java +++ b/apps/api/src/main/java/com/mally/api/pastebin/PastebinController.java @@ -3,14 +3,15 @@ import com.mally.api.auth.AuthenticationManager; import com.mally.api.auth.UserJwt; import com.mally.api.pastebin.dtos.CreatePasteDTO; -import com.mally.api.pastebin.dtos.SearchPastesDTO; import com.mally.api.pastebin.entities.Paste; import com.mally.api.pastebin.services.PastebinService; import com.mally.api.shared.rest.dtos.ApiResponseDTO; import jakarta.validation.Valid; import lombok.AllArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -23,11 +24,22 @@ public class PastebinController { private final PastebinService pastebinService; - @GetMapping("/search") - public ResponseEntity> search(@RequestParam String query, Pageable pageable) { - var userId = AuthenticationManager.getAuthenticatedUser().map(UserJwt::getId).orElse(null); + @GetMapping("/") + public ResponseEntity> findAll( + @RequestParam(required = false) String search, + @RequestParam(defaultValue = "0") int pageNumber, + @RequestParam(defaultValue = "10") int pageSize, + @RequestParam(defaultValue = "createdAt") String sortBy, + @RequestParam(defaultValue = "DESC") String orderBy + ) { + var userId = AuthenticationManager.getAuthenticatedUser().map(UserJwt::getId).orElseThrow(); + var result = pastebinService.findAll( + userId, + search, + PageRequest.of(pageNumber, pageSize, Sort.by(orderBy.equalsIgnoreCase("ASC") ? Sort.Direction.ASC : Sort.Direction.DESC, sortBy)) + ); - return ResponseEntity.ok().body(pastebinService.search(query, userId, pageable)); + return ResponseEntity.ok(result); } @GetMapping("/paste/{slug}") @@ -50,4 +62,18 @@ public ResponseEntity paste(@Valid @RequestBody CreatePasteDTO d .ok() .body(ApiResponseDTO.success("Paste created successfully.", paste)); } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + pastebinService.delete(id); + + return ResponseEntity.ok(ApiResponseDTO.success("Paste deleted successfully.", null)); + } + + @DeleteMapping("bulk") + public ResponseEntity bulkDelete(@RequestParam List id) { + pastebinService.deleteMany(id); + + return ResponseEntity.ok(ApiResponseDTO.success("Pastes deleted successfully.", null)); + } } diff --git a/apps/api/src/main/java/com/mally/api/pastebin/services/PastebinService.java b/apps/api/src/main/java/com/mally/api/pastebin/services/PastebinService.java index 5707d58..eac3de0 100644 --- a/apps/api/src/main/java/com/mally/api/pastebin/services/PastebinService.java +++ b/apps/api/src/main/java/com/mally/api/pastebin/services/PastebinService.java @@ -56,13 +56,25 @@ public void deleteExpiredPastes() { pastebinRepository.deleteExpiredPastes(ZonedDateTime.now()); } - public Page search(String searchQuery, String userId, Pageable pageable) { - List searchFields = List.of("slug", "text", "syntax", "createdAt", "expiresAt"); + public Long getStats(String userId) { + return pastebinRepository.countByUserId(userId); + } + + public Page findAll(String userId, String search, Pageable pageable) { + if (search != null && !search.isEmpty()) { + List searchFields = List.of("slug", "text", "syntax"); + + return PaginationUtils.paginateSearch(entityManager, Paste.class, searchFields, search, userId, pageable); + } - return PaginationUtils.paginateSearch(entityManager, Paste.class, searchFields, searchQuery, userId, pageable); + return pastebinRepository.findAllByUserId(userId, pageable); } - public Long getStats(String userId) { - return pastebinRepository.countByUserId(userId); + public void delete(Long id) { + pastebinRepository.deleteById(id); + } + + public void deleteMany(List ids) { + pastebinRepository.deleteAllById(ids); } } diff --git a/apps/ui/src/app/dashboard/dashboard-routing.module.ts b/apps/ui/src/app/dashboard/dashboard-routing.module.ts index 4c0f547..3d55d8c 100644 --- a/apps/ui/src/app/dashboard/dashboard-routing.module.ts +++ b/apps/ui/src/app/dashboard/dashboard-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; import { DashboardIndexComponent } from './pages/index/dashboard-index.component'; import { RouterModule, Routes } from '@angular/router'; import { UrlsComponent } from './pages/urls/urls.component'; +import { PastesComponent } from './pages/pastes/pastes.component'; const routes: Routes = [ { @@ -12,6 +13,10 @@ const routes: Routes = [ path: 'short-urls', component: UrlsComponent, }, + { + path: 'pastes', + component: PastesComponent, + }, ]; @NgModule({ diff --git a/apps/ui/src/app/dashboard/dashboard.module.ts b/apps/ui/src/app/dashboard/dashboard.module.ts index ec65fea..f9b237b 100644 --- a/apps/ui/src/app/dashboard/dashboard.module.ts +++ b/apps/ui/src/app/dashboard/dashboard.module.ts @@ -15,9 +15,15 @@ import { ConfirmationService } from 'primeng/api'; import { StatsService } from '../stats/services/stats.service'; import { SkeletonModule } from 'primeng/skeleton'; import { StatsCardComponent } from './pages/index/stats-card/stats-card.component'; +import { PastesComponent } from './pages/pastes/pastes.component'; @NgModule({ - declarations: [DashboardIndexComponent, UrlsComponent, StatsCardComponent], + declarations: [ + DashboardIndexComponent, + UrlsComponent, + StatsCardComponent, + PastesComponent, + ], imports: [ CommonModule, DashboardRoutingModule, diff --git a/apps/ui/src/app/dashboard/pages/index/dashboard-index.component.html b/apps/ui/src/app/dashboard/pages/index/dashboard-index.component.html index ac453da..0def531 100644 --- a/apps/ui/src/app/dashboard/pages/index/dashboard-index.component.html +++ b/apps/ui/src/app/dashboard/pages/index/dashboard-index.component.html @@ -2,8 +2,8 @@
- - + +
diff --git a/apps/ui/src/app/dashboard/pages/pastes/pastes.component.html b/apps/ui/src/app/dashboard/pages/pastes/pastes.component.html new file mode 100644 index 0000000..b42b6e4 --- /dev/null +++ b/apps/ui/src/app/dashboard/pages/pastes/pastes.component.html @@ -0,0 +1,94 @@ + + + +

Pastes

+ + + +
+ + + + +
+ +
+
+
+ + + + + + + URL + + + Syntax + + + Text + + + Created at + + + Expires in + + + + + + + + + + + {{ window.location.hostname + '/p/' + paste.slug }} + + + + {{ paste.syntax }} + + + + {{ paste.text }} + + + {{ paste.createdAt | date: 'short' }} + + + {{ pasteExpiresIn(paste.expiresAt, 'humanized') }} + + + + + + + +
diff --git a/apps/ui/src/app/dashboard/pages/pastes/pastes.component.scss b/apps/ui/src/app/dashboard/pages/pastes/pastes.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/apps/ui/src/app/dashboard/pages/pastes/pastes.component.ts b/apps/ui/src/app/dashboard/pages/pastes/pastes.component.ts new file mode 100644 index 0000000..84bfd65 --- /dev/null +++ b/apps/ui/src/app/dashboard/pages/pastes/pastes.component.ts @@ -0,0 +1,166 @@ +import { Component } from '@angular/core'; +import { PaginationParams } from '../../../url-shortener/services/url-shortener.service'; +import { Url } from '../../../shared/interfaces/url'; +import { HttpErrorResponse } from '@angular/common/http'; +import { + ConfirmationService, + MenuItem, + MenuItemCommandEvent, +} from 'primeng/api'; +import dayjs from 'dayjs'; +import { Menu } from 'primeng/menu'; +import { ToastService } from '../../../shared/services/toast/toast.service'; +import { Page } from '../../../shared/interfaces/http'; +import { TableLazyLoadEvent } from 'primeng/table'; +import { + Paste, + PastebinService, +} from '../../../pastebin/services/pastebin.service'; + +@Component({ + selector: 'app-pastes', + templateUrl: 'pastes.component.html', + styleUrl: 'pastes.component.scss', +}) +export class PastesComponent { + data?: Page; + selectedUrls: Url[] = []; + optionsItems: MenuItem[] = [ + { + label: 'Options', + items: [ + { + label: 'Delete', + icon: 'ti ti-trash', + command: (event: MenuItemCommandEvent) => { + const id = event.item?.['data']['id']; + + if (!id) return; + + this.confirmationService.confirm({ + target: event.originalEvent?.target as EventTarget, + message: + 'Are you sure you want to delete this Paste?', + acceptIcon: 'none', + rejectIcon: 'none', + rejectButtonStyleClass: 'p-button-text', + header: 'Confirmation', + icon: 'ti ti-alert-triangle', + accept: () => this.delete(id), + }); + }, + }, + ], + }, + ]; + + protected readonly window = window; + private lastLazyLoadEvent!: TableLazyLoadEvent; + private searchQuery?: string; + protected loadingData: boolean = false; + + constructor( + private readonly pastebinService: PastebinService, + private readonly confirmationService: ConfirmationService, + private readonly toastService: ToastService, + ) {} + + onLoad(event: TableLazyLoadEvent) { + this.lastLazyLoadEvent = event; + + const sortBy = event.sortField as string | undefined; + const orderBy = event.sortOrder === 1 ? 'ASC' : 'DESC'; + const search = this.searchQuery; + const pageNumber = + event.first === 0 || event.first == undefined + ? 0 + : event.first / (event.rows == undefined ? 1 : event.rows); + + this.fetchUrls({ + sortBy, + orderBy, + search, + pageNumber: pageNumber.toString(), + }); + } + + fetchUrls(params?: PaginationParams) { + this.loadingData = true; + this.pastebinService.findAll(params).subscribe({ + next: (data) => { + this.data = data; + this.loadingData = false; + }, + error: (error: HttpErrorResponse) => { + this.toastService.error(error.error.message); + this.loadingData = false; + }, + }); + } + + pasteExpiresIn(expiresAt: string, format: 'humanized' | 'long') { + if (format === 'humanized') { + const expiration = dayjs(expiresAt); + if (expiration < dayjs()) { + return 'Expired'; + } else { + return expiration.fromNow(); + } + } else { + return dayjs(expiresAt).format('MMMM D, YYYY h:mm A'); + } + } + + delete(id: number) { + this.pastebinService.delete(id).subscribe({ + next: () => { + this.toastService.success('Paste deleted successfully'); + this.fetchUrls(); + }, + error: (error: HttpErrorResponse) => { + this.toastService.error(error.error.message); + }, + }); + } + + deleteMany() { + const ids = this.selectedUrls.map((url) => url.id); + + this.confirmationService.confirm({ + message: 'Are you sure you want to delete these Pastes?', + acceptIcon: 'none', + rejectIcon: 'none', + rejectButtonStyleClass: 'p-button-text', + header: 'Confirmation', + icon: 'ti ti-alert-triangle', + accept: () => { + this.pastebinService.deleteMany(ids).subscribe({ + next: () => { + this.toastService.success( + 'Pastes deleted successfully', + ); + this.fetchUrls(); + }, + error: (error: HttpErrorResponse) => { + this.toastService.error(error.error.message); + }, + }); + }, + }); + } + + search(event: Event) { + this.searchQuery = (event.target as HTMLInputElement).value; + + this.onLoad(this.lastLazyLoadEvent); + } + + openOptionsMenu(menu: Menu, event: MouseEvent, url: Url) { + const options = this.optionsItems.at(0); + + options?.items?.forEach((item) => { + item['data'] = url; + }); + menu.toggle(event); + } +} diff --git a/apps/ui/src/app/dashboard/pages/urls/urls.component.html b/apps/ui/src/app/dashboard/pages/urls/urls.component.html index 7fc7cf0..93c67f4 100644 --- a/apps/ui/src/app/dashboard/pages/urls/urls.component.html +++ b/apps/ui/src/app/dashboard/pages/urls/urls.component.html @@ -7,7 +7,7 @@ #dt [value]="data?.content ?? []" [totalRecords]="data?.totalElements ?? 0" - [rows]="data?.size" + [rows]="data?.numberOfElements" [lazy]="true" (onLazyLoad)="onLoad($event)" [loading]="loadingData" diff --git a/apps/ui/src/app/pastebin/services/pastebin.service.ts b/apps/ui/src/app/pastebin/services/pastebin.service.ts index b7dc398..c7973d4 100644 --- a/apps/ui/src/app/pastebin/services/pastebin.service.ts +++ b/apps/ui/src/app/pastebin/services/pastebin.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@angular/core'; -import { environment } from '../../../environment/environment'; -import { HttpClient } from '@angular/common/http'; -import { ApiResponse } from '../../shared/interfaces/http'; +import { ApiResponse, Page } from '../../shared/interfaces/http'; import { HttpService } from '../../shared/services/http/http.service'; +import { ObjectUtils } from '../../shared/utils/object'; +import { PaginationParams } from '../../url-shortener/services/url-shortener.service'; export type Paste = { id: number; @@ -20,6 +20,7 @@ export type PasteRequest = { }; export type PasteResponse = ApiResponse; +export type PastesReponse = Page; @Injectable({ providedIn: 'root', @@ -29,6 +30,14 @@ export class PastebinService { constructor(private readonly httpService: HttpService) {} + findAll(options: PaginationParams = {}) { + const params = ObjectUtils.filterDefinedValues(options); + + return this.httpService.get(this.BASE_PATH + '/', { + params, + }); + } + get(slug: string) { return this.httpService.get( this.BASE_PATH + '/paste/' + slug, @@ -41,4 +50,16 @@ export class PastebinService { data, ); } + + delete(id: number) { + return this.httpService.delete(this.BASE_PATH + '/' + id); + } + + deleteMany(ids: number[]) { + return this.httpService.delete(this.BASE_PATH + '/bulk', { + params: { + id: ids.map((id) => id.toString()), + }, + }); + } } diff --git a/apps/ui/src/app/shared/layouts/dashboard-layout/dashboard-layout.component.ts b/apps/ui/src/app/shared/layouts/dashboard-layout/dashboard-layout.component.ts index ee4b006..c741d10 100644 --- a/apps/ui/src/app/shared/layouts/dashboard-layout/dashboard-layout.component.ts +++ b/apps/ui/src/app/shared/layouts/dashboard-layout/dashboard-layout.component.ts @@ -36,6 +36,12 @@ export class DashboardLayoutComponent { path: '/dashboard/short-urls', new: false, }, + { + label: 'Pastes', + icon: 'ti ti-clipboard', + path: '/dashboard/pastes', + new: false, + }, ], }, ];