diff --git a/backend/src/main/java/io/opspace/backend/controller/CaptureController.java b/backend/src/main/java/io/opspace/backend/controller/CaptureController.java index b370293..504d39b 100644 --- a/backend/src/main/java/io/opspace/backend/controller/CaptureController.java +++ b/backend/src/main/java/io/opspace/backend/controller/CaptureController.java @@ -42,9 +42,10 @@ public ResponseEntity createCapture(@Valid @RequestBody CreateCaptureRe public ResponseEntity> getCaptures( @RequestParam(required = false) Integer limit, @RequestParam(required = false) String cursor, - @RequestParam(required = false) Boolean converted + @RequestParam(required = false) Boolean converted, + @RequestParam(required = false) Boolean deleted ) { - CursorPage page = captureService.getCaptures(limit, cursor, converted); + CursorPage page = captureService.getCaptures(limit, cursor, converted, deleted); ResponseEntity.BodyBuilder response = ResponseEntity.ok(); if (page.nextCursor() != null) { response.header("X-Next-Cursor", page.nextCursor()); @@ -85,4 +86,10 @@ public ResponseEntity undoConversion(@PathVariable String captureId) { captureService.undoConversion(captureId); return ResponseEntity.noContent().build(); } + + @PostMapping("/{captureId}/restore") + public ResponseEntity restoreCapture(@PathVariable String captureId) { + captureService.restoreCapture(captureId); + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/io/opspace/backend/controller/ItemController.java b/backend/src/main/java/io/opspace/backend/controller/ItemController.java index f25ec18..ff4850e 100644 --- a/backend/src/main/java/io/opspace/backend/controller/ItemController.java +++ b/backend/src/main/java/io/opspace/backend/controller/ItemController.java @@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; @@ -37,9 +38,10 @@ public ResponseEntity> getItems( @RequestParam(required = false) Integer limit, @RequestParam(required = false) String cursor, @RequestParam(required = false) ItemStatus status, - @RequestParam(required = false) ItemType type + @RequestParam(required = false) ItemType type, + @RequestParam(required = false) Boolean deleted ) { - CursorPage page = itemService.getItems(limit, cursor, status, type); + CursorPage page = itemService.getItems(limit, cursor, status, type, deleted); ResponseEntity.BodyBuilder response = ResponseEntity.ok(); if (page.nextCursor() != null) { response.header("X-Next-Cursor", page.nextCursor()); @@ -74,4 +76,10 @@ public ResponseEntity deleteItem(@PathVariable String itemId) { itemService.deleteItem(itemId); return ResponseEntity.noContent().build(); } + + @PostMapping("/{itemId}/restore") + public ResponseEntity restoreItem(@PathVariable String itemId) { + itemService.restoreItem(itemId); + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/io/opspace/backend/domain/ActivityAction.java b/backend/src/main/java/io/opspace/backend/domain/ActivityAction.java index a3d612e..5c89a96 100644 --- a/backend/src/main/java/io/opspace/backend/domain/ActivityAction.java +++ b/backend/src/main/java/io/opspace/backend/domain/ActivityAction.java @@ -5,8 +5,10 @@ public enum ActivityAction { CAPTURE_UPDATED, CAPTURE_CONVERTED, CAPTURE_DELETED, + CAPTURE_RESTORED, ITEM_UPDATED, ITEM_STATUS_UPDATED, ITEM_DELETED, + ITEM_RESTORED, CONVERSION_UNDONE } diff --git a/backend/src/main/java/io/opspace/backend/service/CaptureService.java b/backend/src/main/java/io/opspace/backend/service/CaptureService.java index 8111be4..1dca569 100644 --- a/backend/src/main/java/io/opspace/backend/service/CaptureService.java +++ b/backend/src/main/java/io/opspace/backend/service/CaptureService.java @@ -59,9 +59,11 @@ public Capture createCapture(String rawContent) { return saved; } - public CursorPage getCaptures(Integer limit, String cursor, Boolean converted) { + public CursorPage getCaptures(Integer limit, String cursor, Boolean converted, Boolean deleted) { List filtered = captureRepository.findAllNewestFirst().stream() - .filter(capture -> capture.deletedAt() == null) + .filter(capture -> deleted == null + ? capture.deletedAt() == null + : deleted.equals(capture.deletedAt() != null)) .filter(capture -> converted == null || (converted ? capture.convertedItemId() != null : capture.convertedItemId() == null)) .sorted(Comparator.comparing(Capture::createdAt).reversed() @@ -263,6 +265,55 @@ public void undoConversion(String captureId) { ); } + @Transactional + public void restoreCapture(String captureId) { + Capture capture = findCaptureOrThrow(captureId); + if (capture.deletedAt() == null) { + throw new ConflictException("Capture is not deleted"); + } + + String now = Instant.now().toString(); + captureRepository.save(new Capture( + capture.rowId(), + capture.id(), + capture.content(), + capture.createdAt(), + capture.createdBy(), + now, + AuditContext.SYSTEM_ACTOR, + capture.convertedAt(), + capture.convertedItemId(), + null + )); + + if (capture.convertedItemId() != null) { + itemRepository.findById(capture.convertedItemId()).ifPresent(item -> { + if (item.deletedAt() != null) { + itemRepository.save(new Item( + item.rowId(), + item.id(), + item.type(), + item.status(), + item.content(), + item.createdAt(), + item.createdBy(), + now, + AuditContext.SYSTEM_ACTOR, + item.sourceCaptureId(), + null + )); + } + }); + } + + activityLogService.log( + ActivityAction.CAPTURE_RESTORED, + capture.id(), + capture.convertedItemId(), + null + ); + } + private String normalizeContent(String rawContent) { if (rawContent == null) { throw new BadRequestException("Capture content is required"); diff --git a/backend/src/main/java/io/opspace/backend/service/ItemService.java b/backend/src/main/java/io/opspace/backend/service/ItemService.java index 8311709..613d635 100644 --- a/backend/src/main/java/io/opspace/backend/service/ItemService.java +++ b/backend/src/main/java/io/opspace/backend/service/ItemService.java @@ -8,6 +8,7 @@ import io.opspace.backend.repository.CaptureRepository; import io.opspace.backend.repository.ItemRepository; import io.opspace.backend.shared.BadRequestException; +import io.opspace.backend.shared.ConflictException; import io.opspace.backend.shared.CursorPage; import io.opspace.backend.shared.CursorPagination; import io.opspace.backend.shared.NotFoundException; @@ -39,9 +40,11 @@ public ItemService( /** * Returns items sorted by newest first to match current UI expectation. */ - public CursorPage getItems(Integer limit, String cursor, ItemStatus status, ItemType type) { + public CursorPage getItems(Integer limit, String cursor, ItemStatus status, ItemType type, Boolean deleted) { List filtered = itemRepository.findAllNewestFirst().stream() - .filter(item -> item.deletedAt() == null) + .filter(item -> deleted == null + ? item.deletedAt() == null + : deleted.equals(item.deletedAt() != null)) .filter(item -> status == null || item.status() == status) .filter(item -> type == null || item.type() == type) .sorted(Comparator.comparing(Item::createdAt).reversed() @@ -175,6 +178,53 @@ public Item updateItem(String itemId, ItemStatus status, String rawContent) { return saved; } + @Transactional + public void restoreItem(String itemId) { + Item item = findItemOrThrow(itemId); + if (item.deletedAt() == null) { + throw new ConflictException("Item is not deleted"); + } + + String now = Instant.now().toString(); + itemRepository.save(new Item( + item.rowId(), + item.id(), + item.type(), + item.status(), + item.content(), + item.createdAt(), + item.createdBy(), + now, + AuditContext.SYSTEM_ACTOR, + item.sourceCaptureId(), + null + )); + + captureRepository.findById(item.sourceCaptureId()).ifPresent(capture -> { + if (capture.deletedAt() != null) { + captureRepository.save(new Capture( + capture.rowId(), + capture.id(), + capture.content(), + capture.createdAt(), + capture.createdBy(), + now, + AuditContext.SYSTEM_ACTOR, + capture.convertedAt(), + capture.convertedItemId(), + null + )); + } + }); + + activityLogService.log( + ActivityAction.ITEM_RESTORED, + item.sourceCaptureId(), + item.id(), + null + ); + } + private Item findItemOrThrow(String itemId) { return itemRepository.findById(itemId) .orElseThrow(() -> new NotFoundException("Item not found")); diff --git a/backend/src/test/java/io/opspace/backend/capture/CaptureControllerIntegrationTests.java b/backend/src/test/java/io/opspace/backend/capture/CaptureControllerIntegrationTests.java index 245f678..82374af 100644 --- a/backend/src/test/java/io/opspace/backend/capture/CaptureControllerIntegrationTests.java +++ b/backend/src/test/java/io/opspace/backend/capture/CaptureControllerIntegrationTests.java @@ -672,6 +672,167 @@ void deleteCaptureSoftDeletesLinkedItem() throws Exception { .andExpect(jsonPath("$.error").value("Item not found")); } + @Test + void getCapturesSupportsDeletedFilter() throws Exception { + String deletedCaptureId = createCaptureAndReturnId("Deleted capture"); + mockMvc.perform(delete("/api/v1/captures/{captureId}", deletedCaptureId)) + .andExpect(status().isNoContent()); + + String activeCaptureId = createCaptureAndReturnId("Active capture"); + + MvcResult deletedResult = mockMvc.perform(get("/api/v1/captures") + .param("deleted", "true")) + .andExpect(status().isOk()) + .andReturn(); + JsonNode deletedCaptures = objectMapper.readTree(deletedResult.getResponse().getContentAsString()); + assertEquals(1, deletedCaptures.size()); + assertEquals(deletedCaptureId, deletedCaptures.get(0).get("id").asText()); + + MvcResult activeResult = mockMvc.perform(get("/api/v1/captures") + .param("deleted", "false")) + .andExpect(status().isOk()) + .andReturn(); + JsonNode activeCaptures = objectMapper.readTree(activeResult.getResponse().getContentAsString()); + assertEquals(1, activeCaptures.size()); + assertEquals(activeCaptureId, activeCaptures.get(0).get("id").asText()); + } + + @Test + void getItemsSupportsDeletedFilter() throws Exception { + String deletedItemCaptureId = createCaptureAndReturnId("Deleted item capture"); + MvcResult deletedItemConversionResult = mockMvc.perform(post("/api/v1/captures/{captureId}/convert", + deletedItemCaptureId) + .contentType(APPLICATION_JSON) + .content(""" + {"type":"TASK"} + """)) + .andExpect(status().isCreated()) + .andReturn(); + String deletedItemId = objectMapper.readTree(deletedItemConversionResult.getResponse().getContentAsString()) + .get("id") + .asText(); + + mockMvc.perform(delete("/api/v1/captures/{captureId}", deletedItemCaptureId)) + .andExpect(status().isNoContent()); + + String activeItemCaptureId = createCaptureAndReturnId("Active item capture"); + MvcResult activeItemConversionResult = mockMvc.perform(post("/api/v1/captures/{captureId}/convert", + activeItemCaptureId) + .contentType(APPLICATION_JSON) + .content(""" + {"type":"TASK"} + """)) + .andExpect(status().isCreated()) + .andReturn(); + String activeItemId = objectMapper.readTree(activeItemConversionResult.getResponse().getContentAsString()) + .get("id") + .asText(); + + MvcResult deletedResult = mockMvc.perform(get("/api/v1/items") + .param("deleted", "true")) + .andExpect(status().isOk()) + .andReturn(); + JsonNode deletedItems = objectMapper.readTree(deletedResult.getResponse().getContentAsString()); + assertEquals(1, deletedItems.size()); + assertEquals(deletedItemId, deletedItems.get(0).get("id").asText()); + + MvcResult activeResult = mockMvc.perform(get("/api/v1/items") + .param("deleted", "false")) + .andExpect(status().isOk()) + .andReturn(); + JsonNode activeItems = objectMapper.readTree(activeResult.getResponse().getContentAsString()); + assertEquals(1, activeItems.size()); + assertEquals(activeItemId, activeItems.get(0).get("id").asText()); + + MvcResult defaultResult = mockMvc.perform(get("/api/v1/items")) + .andExpect(status().isOk()) + .andReturn(); + JsonNode defaultItems = objectMapper.readTree(defaultResult.getResponse().getContentAsString()); + assertEquals(1, defaultItems.size()); + assertEquals(activeItemId, defaultItems.get(0).get("id").asText()); + } + + @Test + void restoreCaptureBringsBackCaptureAndLinkedItem() throws Exception { + String captureId = createCaptureAndReturnId("Restore capture"); + MvcResult conversionResult = mockMvc.perform(post("/api/v1/captures/{captureId}/convert", captureId) + .contentType(APPLICATION_JSON) + .content(""" + {"type":"TASK"} + """)) + .andExpect(status().isCreated()) + .andReturn(); + String itemId = objectMapper.readTree(conversionResult.getResponse().getContentAsString()) + .get("id") + .asText(); + + mockMvc.perform(delete("/api/v1/captures/{captureId}", captureId)) + .andExpect(status().isNoContent()); + + mockMvc.perform(post("/api/v1/captures/{captureId}/restore", captureId)) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/api/v1/captures/{captureId}", captureId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(captureId)); + + mockMvc.perform(get("/api/v1/items/{itemId}", itemId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(itemId)); + } + + @Test + void restoreItemBringsBackItemAndSourceCapture() throws Exception { + String captureId = createCaptureAndReturnId("Restore item source"); + MvcResult conversionResult = mockMvc.perform(post("/api/v1/captures/{captureId}/convert", captureId) + .contentType(APPLICATION_JSON) + .content(""" + {"type":"NOTE"} + """)) + .andExpect(status().isCreated()) + .andReturn(); + String itemId = objectMapper.readTree(conversionResult.getResponse().getContentAsString()) + .get("id") + .asText(); + + mockMvc.perform(delete("/api/v1/items/{itemId}", itemId)) + .andExpect(status().isNoContent()); + + mockMvc.perform(post("/api/v1/items/{itemId}/restore", itemId)) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/api/v1/items/{itemId}", itemId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(itemId)); + + mockMvc.perform(get("/api/v1/captures/{captureId}", captureId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(captureId)); + } + + @Test + void restoreEndpointsReturnConflictWhenEntityNotDeleted() throws Exception { + String captureId = createCaptureAndReturnId("Active capture"); + MvcResult conversionResult = mockMvc.perform(post("/api/v1/captures/{captureId}/convert", captureId) + .contentType(APPLICATION_JSON) + .content(""" + {"type":"TASK"} + """)) + .andExpect(status().isCreated()) + .andReturn(); + String itemId = objectMapper.readTree(conversionResult.getResponse().getContentAsString()) + .get("id") + .asText(); + + mockMvc.perform(post("/api/v1/captures/{captureId}/restore", captureId)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error").value("Capture is not deleted")); + + mockMvc.perform(post("/api/v1/items/{itemId}/restore", itemId)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error").value("Item is not deleted")); + } + @Test void updateCaptureSyncsConvertedItemContent() throws Exception { String captureId = createCaptureAndReturnId("Old capture"); diff --git a/frontend/src/i18n/workspaceCopy.ts b/frontend/src/i18n/workspaceCopy.ts index f645865..2535356 100644 --- a/frontend/src/i18n/workspaceCopy.ts +++ b/frontend/src/i18n/workspaceCopy.ts @@ -46,10 +46,18 @@ type WorkspaceCopy = { timelineConverted: string loadMore: string loadingMore: string + recycleBinTitle: string + deletedCapturesTitle: string + deletedItemsTitle: string + emptyDeletedCaptures: string + emptyDeletedItems: string + restoreLabel: string + restoringLabel: string failedToLoad: string failedToCreate: string failedToConvert: string failedToUndo: string + failedToRestore: string failedToUndoMethodNotAllowed: string confirmConvert: string confirmDeleteCapture: string @@ -118,10 +126,18 @@ export const workspaceCopy: Record = { timelineConverted: 'Converted to item', loadMore: 'Load More', loadingMore: 'Loading...', + recycleBinTitle: 'Recycle Bin', + deletedCapturesTitle: 'Deleted Captures', + deletedItemsTitle: 'Deleted Items', + emptyDeletedCaptures: 'No deleted captures.', + emptyDeletedItems: 'No deleted items.', + restoreLabel: 'Restore', + restoringLabel: 'Restoring...', failedToLoad: 'Failed to load captures. Please try again.', failedToCreate: 'Failed to create capture. Please try again.', failedToConvert: 'Failed to convert capture. Please try again.', failedToUndo: 'Failed to undo conversion. Please try again.', + failedToRestore: 'Failed to restore. Please try again.', failedToUndoMethodNotAllowed: 'Undo is not available on the current backend. Please update/restart backend to a version that supports POST /api/v1/captures/{captureId}/undo.', confirmConvert: 'Convert this capture into an item?', @@ -189,10 +205,18 @@ export const workspaceCopy: Record = { timelineConverted: 'Đã chuyển thành item', loadMore: 'Tải thêm', loadingMore: 'Đang tải...', + recycleBinTitle: 'Thùng rác', + deletedCapturesTitle: 'Capture đã xóa', + deletedItemsTitle: 'Item đã xóa', + emptyDeletedCaptures: 'Không có capture đã xóa.', + emptyDeletedItems: 'Không có item đã xóa.', + restoreLabel: 'Khôi phục', + restoringLabel: 'Đang khôi phục...', failedToLoad: 'Không thể tải danh sách capture. Vui lòng thử lại.', failedToCreate: 'Không thể tạo capture. Vui lòng thử lại.', failedToConvert: 'Không thể chuyển capture. Vui lòng thử lại.', failedToUndo: 'Không thể hoàn tác chuyển đổi. Vui lòng thử lại.', + failedToRestore: 'Không thể khôi phục. Vui lòng thử lại.', failedToUndoMethodNotAllowed: 'Backend hiện tại chưa hỗ trợ Undo. Vui lòng cập nhật/khởi động lại backend có endpoint POST /api/v1/captures/{captureId}/undo.', confirmConvert: 'Bạn có chắc muốn chuyển capture này thành một item?', diff --git a/frontend/src/pages/WorkspacePage.css b/frontend/src/pages/WorkspacePage.css index 2634bec..7b5e4f9 100644 --- a/frontend/src/pages/WorkspacePage.css +++ b/frontend/src/pages/WorkspacePage.css @@ -581,6 +581,19 @@ margin-top: 12px; } +.workspace-page .recycle-bin { + margin-top: 16px; +} + +.workspace-page .recycle-bin-split { + gap: 14px; +} + +.workspace-page .recycle-bin h3 { + margin: 0 0 10px; + font-size: 15px; +} + .workspace-page .toolbar-chips { display: flex; gap: 6px; diff --git a/frontend/src/pages/WorkspacePage.tsx b/frontend/src/pages/WorkspacePage.tsx index 2c32920..20daad7 100644 --- a/frontend/src/pages/WorkspacePage.tsx +++ b/frontend/src/pages/WorkspacePage.tsx @@ -12,8 +12,11 @@ import { deleteCapture, deleteItem, getCapture, + getItem, listCaptures, listItems, + restoreCapture, + restoreItem, undoConversion, updateCapture, updateItem, @@ -52,6 +55,8 @@ function WorkspacePage() { const { locale, setLocale, theme, setTheme } = usePreferences() const [captures, setCaptures] = useState([]) const [items, setItems] = useState([]) + const [deletedCaptures, setDeletedCaptures] = useState([]) + const [deletedItems, setDeletedItems] = useState([]) const [isLoadingCaptures, setIsLoadingCaptures] = useState(true) const [isSubmittingCapture, setIsSubmittingCapture] = useState(false) const [convertingCaptureId, setConvertingCaptureId] = useState(null) @@ -74,7 +79,10 @@ function WorkspacePage() { const [deletingItemId, setDeletingItemId] = useState(null) const [editingCaptureId, setEditingCaptureId] = useState(null) const [editingItemId, setEditingItemId] = useState(null) + const [restoringCaptureId, setRestoringCaptureId] = useState(null) + const [restoringItemId, setRestoringItemId] = useState(null) const [dialogInput, setDialogInput] = useState('') + const [recycleBinError, setRecycleBinError] = useState(null) const [isPulseVisible, setIsPulseVisible] = useState(true) const [pulseView, setPulseView] = useState('board') @@ -139,6 +147,19 @@ function WorkspacePage() { setItems(itemPage.data) setCaptureCursor(capturePage.nextCursor) setItemCursor(itemPage.nextCursor) + + const [deletedCapturePage, deletedItemPage] = await Promise.allSettled([ + listCaptures({ limit: PAGE_SIZE, deleted: true }), + listItems({ limit: PAGE_SIZE, deleted: true }), + ]) + + if (deletedCapturePage.status === 'fulfilled') { + setDeletedCaptures(deletedCapturePage.value.data) + } + + if (deletedItemPage.status === 'fulfilled') { + setDeletedItems(deletedItemPage.value.data) + } } catch (error) { const message = error instanceof Error ? error.message : ui.failedToLoad setCaptureLoadError(message) @@ -183,6 +204,19 @@ function WorkspacePage() { } }, [convertedQuery, isSubmittingCapture]) + const refreshDeletedData = async () => { + try { + const [deletedCapturePage, deletedItemPage] = await Promise.all([ + listCaptures({ limit: PAGE_SIZE, deleted: true }), + listItems({ limit: PAGE_SIZE, deleted: true }), + ]) + setDeletedCaptures(deletedCapturePage.data) + setDeletedItems(deletedItemPage.data) + } catch { + // recycle bin refresh errors are non-blocking for primary workflow + } + } + const handleCreateCapture = async (captureContent: string) => { setIsSubmittingCapture(true) setCaptureSubmitError(null) @@ -268,6 +302,7 @@ function WorkspacePage() { if (focusedCapture?.id === captureId) { setFocusedCapture(null) } + await refreshDeletedData() } catch (error) { const message = error instanceof Error ? error.message : ui.failedToLoad setCaptureConvertError(message) @@ -283,6 +318,7 @@ function WorkspacePage() { await deleteItem(itemId) setItems((current) => current.filter((item) => item.id !== itemId)) setCaptures((current) => current.filter((capture) => capture.id !== sourceCaptureId)) + await refreshDeletedData() } catch (error) { const message = error instanceof Error ? error.message : ui.failedToUndo setItemActionError(message) @@ -332,6 +368,100 @@ function WorkspacePage() { } } + const performRestoreCapture = async (captureId: string) => { + setRestoringCaptureId(captureId) + setRecycleBinError(null) + try { + await restoreCapture(captureId) + const restoredCapture = await getCapture(captureId) + setDeletedCaptures((current) => current.filter((capture) => capture.id !== captureId)) + + const matchesCaptureFilter = + captureFilter === 'all' || + (captureFilter === 'converted' ? Boolean(restoredCapture.convertedItemId) : !restoredCapture.convertedItemId) + if (matchesCaptureFilter) { + setCaptures((current) => { + const exists = current.some((capture) => capture.id === captureId) + if (exists) { + return current.map((capture) => (capture.id === captureId ? restoredCapture : capture)) + } + return [restoredCapture, ...current] + }) + } + + if (restoredCapture.convertedItemId) { + const restoredItem = await getItem(restoredCapture.convertedItemId).catch(() => null) + if (restoredItem) { + setDeletedItems((current) => current.filter((item) => item.id !== restoredItem.id)) + if (matchesItemFilter(restoredItem)) { + setItems((current) => { + const exists = current.some((item) => item.id === restoredItem.id) + if (exists) { + return current.map((item) => (item.id === restoredItem.id ? restoredItem : item)) + } + return [restoredItem, ...current] + }) + } + } + } + + await refreshDeletedData() + } catch (error) { + const message = error instanceof Error ? error.message : ui.failedToRestore + setRecycleBinError(message) + } finally { + setRestoringCaptureId(null) + } + } + + const performRestoreItem = async (itemId: string, sourceCaptureId: string) => { + setRestoringItemId(itemId) + setRecycleBinError(null) + try { + await restoreItem(itemId) + const restoredItem = await getItem(itemId) + setDeletedItems((current) => current.filter((item) => item.id !== itemId)) + + if (matchesItemFilter(restoredItem)) { + setItems((current) => { + const exists = current.some((item) => item.id === restoredItem.id) + if (exists) { + return current.map((item) => (item.id === restoredItem.id ? restoredItem : item)) + } + return [restoredItem, ...current] + }) + } + + const restoredCapture = await getCapture(sourceCaptureId).catch(() => null) + if (restoredCapture) { + setDeletedCaptures((current) => current.filter((capture) => capture.id !== sourceCaptureId)) + const matchesCaptureFilter = + captureFilter === 'all' || + (captureFilter === 'converted' + ? Boolean(restoredCapture.convertedItemId) + : !restoredCapture.convertedItemId) + if (matchesCaptureFilter) { + setCaptures((current) => { + const exists = current.some((capture) => capture.id === sourceCaptureId) + if (exists) { + return current.map((capture) => + capture.id === sourceCaptureId ? restoredCapture : capture, + ) + } + return [restoredCapture, ...current] + }) + } + } + + await refreshDeletedData() + } catch (error) { + const message = error instanceof Error ? error.message : ui.failedToRestore + setRecycleBinError(message) + } finally { + setRestoringItemId(null) + } + } + const handleConfirmDialog = async () => { if (!pendingAction) { return @@ -842,6 +972,71 @@ function WorkspacePage() { /> {itemActionError ?

{itemActionError}

: null} + +
+
+

{ui.recycleBinTitle}

+
+
+
+

{ui.deletedCapturesTitle}

+ {deletedCaptures.length === 0 ?

{ui.emptyDeletedCaptures}

: null} +
    + {deletedCaptures.map((capture) => ( +
  • +

    {capture.content}

    +
    + {capture.deletedAt ? new Date(capture.deletedAt).toLocaleString(ui.localeCode) : ''} +
    + +
    +
    +
  • + ))} +
+
+ +
+

{ui.deletedItemsTitle}

+ {deletedItems.length === 0 ?

{ui.emptyDeletedItems}

: null} +
    + {deletedItems.map((item) => ( +
  • +

    {item.content}

    +
    +
    + {ui.typeLabels[item.type]} + {ui.statusLabels[item.status]} +
    +
    + +
    +
    +
  • + ))} +
+
+
+ {recycleBinError ?

{recycleBinError}

: null} +
{pendingAction ? (
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 17008aa..e3f4890 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -14,11 +14,13 @@ type CursorQuery = { export type CaptureListQuery = CursorQuery & { converted?: boolean + deleted?: boolean } export type ItemListQuery = CursorQuery & { status?: ItemStatus type?: ItemType + deleted?: boolean } type ApiErrorPayload = { @@ -80,6 +82,7 @@ export async function listCaptures(query: CaptureListQuery = {}): Promise(`${API_BASE}/captures${queryString}`) } @@ -99,6 +102,7 @@ export async function listItems(query: ItemListQuery = {}): Promise(`${API_BASE}/items${queryString}`) } @@ -108,6 +112,10 @@ export async function getItems(query: ItemListQuery = {}): Promise { return page.data } +export async function getItem(itemId: string): Promise { + return requestJson(`${API_BASE}/items/${itemId}`) +} + export async function convertCapture(captureId: string, type: ItemType): Promise { return requestJson(`${API_BASE}/captures/${captureId}/convert`, { method: 'POST', @@ -162,3 +170,19 @@ export async function undoConversion(captureId: string): Promise { throw new Error(message) } } + +export async function restoreCapture(captureId: string): Promise { + const response = await fetch(`${API_BASE}/captures/${captureId}/restore`, { method: 'POST' }) + if (!response.ok) { + const message = await parseError(response) + throw new Error(message) + } +} + +export async function restoreItem(itemId: string): Promise { + const response = await fetch(`${API_BASE}/items/${itemId}/restore`, { method: 'POST' }) + if (!response.ok) { + const message = await parseError(response) + throw new Error(message) + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 018989d..3559a36 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -12,6 +12,7 @@ export type Capture = { lastUpdatedBy: string convertedAt?: string convertedItemId?: string + deletedAt?: string } export type Item = { @@ -25,4 +26,5 @@ export type Item = { lastUpdatedAt: string lastUpdatedBy: string sourceCaptureId: string + deletedAt?: string }