diff --git a/Mark-In/Sources/App/DIContainer+.swift b/Mark-In/Sources/App/DIContainer+.swift index f73f900..d5a40e7 100644 --- a/Mark-In/Sources/App/DIContainer+.swift +++ b/Mark-In/Sources/App/DIContainer+.swift @@ -79,6 +79,15 @@ private extension DIContainer { linkRepository: resolve(), folderRepsoitory: resolve() ) + let deleteLinkUseCase: DeleteLinkUseCase = DeleteLinkUseCaseImpl( + authUserManager: resolve(), + linkRepository: resolve() + ) + let deleteFolderUseCase: DeleteFolderUseCase = DeleteFolderUseCaseImpl( + authUserManager: resolve(), + linkRepository: resolve(), + folderRepository: resolve() + ) register(fetchLinkListUseCase) register(fetchFolderListUseCase) @@ -87,5 +96,7 @@ private extension DIContainer { register(signInUseCase) register(signOutUseCase) register(withdrawalUseCase) + register(deleteLinkUseCase) + register(deleteFolderUseCase) } } diff --git a/Mark-In/Sources/Data/DTOs/FolderDTO.swift b/Mark-In/Sources/Data/DTOs/FolderDTO.swift index 9b2ce42..5aadbde 100644 --- a/Mark-In/Sources/Data/DTOs/FolderDTO.swift +++ b/Mark-In/Sources/Data/DTOs/FolderDTO.swift @@ -10,13 +10,19 @@ import Foundation struct FolderDTO: Codable { var id: String var name: String - var createdBy: Date + var createdAt: Date + + enum CodingKeys: String, CodingKey { + case id = "id" + case name = "name" + case createdAt = "createdAt" + } func toEntity() -> Folder { return Folder( id: self.id, name: self.name, - createdBy: self.createdBy + createdAt: self.createdAt ) } } diff --git a/Mark-In/Sources/Data/DTOs/WebLinkDTO.swift b/Mark-In/Sources/Data/DTOs/WebLinkDTO.swift index 087d46e..832d300 100644 --- a/Mark-In/Sources/Data/DTOs/WebLinkDTO.swift +++ b/Mark-In/Sources/Data/DTOs/WebLinkDTO.swift @@ -14,10 +14,22 @@ struct WebLinkDTO: Codable { var thumbnailUrl: String? var faviconUrl: String? var isPinned: Bool - var createdBy: Date + var createdAt: Date var lastAccessedAt: Date? var folderID: String? + enum CodingKeys: String, CodingKey { + case id = "id" + case url = "url" + case title = "title" + case thumbnailUrl = "thumbnailUrl" + case faviconUrl = "faviconUrl" + case isPinned = "isPinned" + case createdAt = "createdAt" + case lastAccessedAt = "lastAccessedAt" + case folderID = "folderID" + } + func toEntity() -> WebLink { WebLink( id: self.id, @@ -26,7 +38,7 @@ struct WebLinkDTO: Codable { thumbnailUrl: self.thumbnailUrl, faviconUrl: self.faviconUrl, isPinned: self.isPinned, - createdBy: self.createdBy, + createdAt: self.createdAt, lastAccessedAt: self.lastAccessedAt, folderID: self.folderID ) diff --git a/Mark-In/Sources/Data/FirestoreFieldKey.swift b/Mark-In/Sources/Data/FirestoreFieldKey.swift new file mode 100644 index 0000000..4c11eb7 --- /dev/null +++ b/Mark-In/Sources/Data/FirestoreFieldKey.swift @@ -0,0 +1,28 @@ +// +// FirestoreFieldKey.swift +// Mark-In +// +// Created by 이정동 on 6/4/25. +// + +import Foundation + +enum FirestoreFieldKey { + enum Link { + static let id = "id" + static let url = "url" + static let title = "title" + static let thumbnailUrl = "thumbnailUrl" + static let faviconUrl = "faviconUrl" + static let isPinned = "isPinned" + static let createdAt = "createdAt" + static let lastAccessedAt = "lastAccessedAt" + static let folderID = "folderID" + } + + enum Folder { + static let id = "id" + static let name = "name" + static let createdAt = "createdAt" + } +} diff --git a/Mark-In/Sources/Data/Repositories/FolderRepositoryImpl.swift b/Mark-In/Sources/Data/Repositories/FolderRepositoryImpl.swift index a5db1b4..705b912 100644 --- a/Mark-In/Sources/Data/Repositories/FolderRepositoryImpl.swift +++ b/Mark-In/Sources/Data/Repositories/FolderRepositoryImpl.swift @@ -11,7 +11,7 @@ import FirebaseFirestore struct FolderRepositoryImpl: FolderRepository { - typealias VoidCheckedContinuation = CheckedContinuation + typealias FolderFieldKey = FirestoreFieldKey.Folder private let db = Firestore.firestore() @@ -20,27 +20,24 @@ struct FolderRepositoryImpl: FolderRepository { let path = FirebaseEndpoint.FirestoreDB.folders(userID: userID).path let folderDocRef = db.collection(path).document() - /// 2. Firestore에 저장할 DTO 객체 생성 - let folderDTO = FolderDTO( + /// 2. 필드 값 생성 + let createdAt = Date() + + /// 3. Firestore에 추가 + try await folderDocRef.setData([ + FolderFieldKey.id: folderDocRef.documentID, + FolderFieldKey.name: folder.name, + FolderFieldKey.createdAt: createdAt + ]) + + /// 4. 생성된 데이터 반환 + let folderEntity = Folder( id: folderDocRef.documentID, name: folder.name, - createdBy: .now + createdAt: .now ) - /// 3. Firestore에 추가 - try await withCheckedThrowingContinuation { (continuation: VoidCheckedContinuation) in - do { - try folderDocRef.setData(from: folderDTO) { error in - if let error { continuation.resume(throwing: error) } - else { continuation.resume(returning: ()) } - } - } catch { - continuation.resume(throwing: error) - } - } - - /// 4. DocumentID가 업데이트 된 Folder Entity로 리턴 - return folderDTO.toEntity() + return folderEntity } func fetchAll(userID: String) async throws -> [Folder] { @@ -57,35 +54,8 @@ struct FolderRepositoryImpl: FolderRepository { } } - func update(userID: String, folder: Folder) async throws { - /// 1. Folder 문서 참조 생성 - guard let folderID = folder.id else { return } - let path = FirebaseEndpoint.FirestoreDB.folder(userID: userID, folderID: folderID).path - let folderDocRef = db.document(path) - - /// 2. Entity를 DTO로 변환 - let folderDTO = FolderDTO( - id: folderDocRef.documentID, - name: folder.name, - createdBy: folder.createdBy - ) - - /// 3. 업데이트 - try await withCheckedThrowingContinuation { (continuation: VoidCheckedContinuation) in - do { - try folderDocRef.setData(from: folderDTO) { error in - if let error { continuation.resume(throwing: error) } - else { continuation.resume(returning: ()) } - } - } catch { - continuation.resume(throwing: error) - } - } - } - - func delete(userID: String, folder: Folder) async throws { + func delete(userID: String, folderID: String) async throws { /// 1. Folder 문서 참조 생성 - guard let folderID = folder.id else { return } let path = FirebaseEndpoint.FirestoreDB.folder(userID: userID, folderID: folderID).path let folderDocRef = db.document(path) diff --git a/Mark-In/Sources/Data/Repositories/LinkRepositoryImpl.swift b/Mark-In/Sources/Data/Repositories/LinkRepositoryImpl.swift index f752726..8da3e35 100644 --- a/Mark-In/Sources/Data/Repositories/LinkRepositoryImpl.swift +++ b/Mark-In/Sources/Data/Repositories/LinkRepositoryImpl.swift @@ -14,7 +14,7 @@ import LinkMetadataKitInterface struct LinkRepositoryImpl: LinkRepository { - typealias VoidCheckedContinuation = CheckedContinuation + typealias LinkFieldKey = FirestoreFieldKey.Link private let db = Firestore.firestore() private let storage = Storage.storage().reference() @@ -40,33 +40,37 @@ struct LinkRepositoryImpl: LinkRepository { metadata: metadata ) - /// 4. Firestore에 저장할 DTO 객체 생성 - let linkDTO = WebLinkDTO( + /// 4. 필드 값 설정 + let title = link.title ?? metadata.title + let createdAt = Date() + + /// 5. Firestore에 추가 + try await linkDocRef.setData([ + LinkFieldKey.id: linkDocRef.documentID, + LinkFieldKey.url: link.url, + LinkFieldKey.title: title ?? NSNull(), + LinkFieldKey.thumbnailUrl: imageUrls.thumbnail ?? NSNull(), + LinkFieldKey.faviconUrl: imageUrls.favicon ?? NSNull(), + LinkFieldKey.isPinned: false, + LinkFieldKey.createdAt: createdAt, + LinkFieldKey.lastAccessedAt: NSNull(), + LinkFieldKey.folderID: link.folderID ?? NSNull() + ]) + + /// 6. 생성된 데이터 반환 + let linkEntity = WebLink( id: linkDocRef.documentID, url: link.url, - title: link.title ?? metadata.title, + title: title, thumbnailUrl: imageUrls.thumbnail, faviconUrl: imageUrls.favicon, isPinned: false, - createdBy: .now, + createdAt: createdAt, lastAccessedAt: nil, folderID: link.folderID ) - /// 5. Firestore에 추가 - try await withCheckedThrowingContinuation { (continuation: VoidCheckedContinuation) in - do { - try linkDocRef.setData(from: linkDTO) { error in - if let error { continuation.resume(throwing: error) } - else { continuation.resume(returning: ()) } - } - } catch { - continuation.resume(throwing: error) - } - } - - /// 6. 저장된 DTO 객체를 Entity로 변환 후 리턴 - return linkDTO.toEntity() + return linkEntity } func fetchAll(userID: String) async throws -> [WebLink] { @@ -83,40 +87,53 @@ struct LinkRepositoryImpl: LinkRepository { } } - func update(userID: String, link: WebLink) async throws { + + func moveLinkInFolder( + userID: String, + target linkID: String, + to folderID: String? + ) async throws { /// 1. Link 문서 참조 생성 - let path = FirebaseEndpoint.FirestoreDB.link(userID: userID, linkID: link.id).path + let path = FirebaseEndpoint.FirestoreDB.link(userID: userID, linkID: linkID).path let linkDocRef = db.document(path) - /// 2. Entity를 DTO로 변환 - let linkDTO = WebLinkDTO( - id: link.id, - url: link.url, - title: link.title, - thumbnailUrl: link.thumbnailUrl, - faviconUrl: link.faviconUrl, - isPinned: link.isPinned, - createdBy: link.createdBy, - lastAccessedAt: link.lastAccessedAt, - folderID: link.folderID - ) + /// 2. 문서 업데이트 + try await linkDocRef.updateData([ + LinkFieldKey.folderID: folderID ?? NSNull() + ]) + } + + func moveLinksInFolder( + userID: String, + fromFolderID: String?, + toFolderID: String? + ) async throws { + /// 1. Link 컬렉션 참조 생성 + let path = FirebaseEndpoint.FirestoreDB.links(userID: userID).path + let linkColRef = db.collection(path) + + /// 2. 조건에 해당하는 모든 문서 가져오기 + let querySnapshot = try await linkColRef + .whereField(LinkFieldKey.folderID, isEqualTo: fromFolderID ?? NSNull()) + .getDocuments() - /// 3. 업데이트 - try await withCheckedThrowingContinuation { (continuation: VoidCheckedContinuation) in - do { - try linkDocRef.setData(from: linkDTO) { error in - if let error { continuation.resume(throwing: error) } - else { continuation.resume(returning: ()) } + /// 3. 병렬 작업으로 문서 업데이트 + try await withThrowingTaskGroup(of: Void.self) { group in + querySnapshot.documents.forEach { document in + group.addTask { + try await document.reference.updateData([ + LinkFieldKey.folderID: toFolderID ?? NSNull() + ]) } - } catch { - continuation.resume(throwing: error) } + + try await group.waitForAll() } } - func delete(userID: String, link: WebLink) async throws { + func delete(userID: String, linkID: String) async throws { /// 1. Link 문서 참조 생성 - let path = FirebaseEndpoint.FirestoreDB.link(userID: userID, linkID: link.id).path + let path = FirebaseEndpoint.FirestoreDB.link(userID: userID, linkID: linkID).path let linkDocRef = db.document(path) /// 2. 이미지 데이터 삭제 @@ -126,6 +143,31 @@ struct LinkRepositoryImpl: LinkRepository { try await linkDocRef.delete() } + func deleteAllInFolder(userID: String, folderID: String?) async throws { + let path = FirebaseEndpoint.FirestoreDB.links(userID: userID).path + let querySnapshot = try await db.collection(path) + .whereField(LinkFieldKey.folderID, isEqualTo: folderID ?? NSNull()) + .getDocuments() + + /// 3. 병렬 작업으로 데이터 삭제 + try await withThrowingTaskGroup(of: Void.self) { group in + /// 모둔 문서에 순차 접근 + querySnapshot.documents.forEach { document in + /// Link 데이터 삭제 + group.addTask { + try await document.reference.delete() + } + + /// 이미지 데이터 삭제 + group.addTask { + try await deleteImageData(userID: userID, fileID: document.documentID) + } + } + + try await group.waitForAll() + } + } + func deleteAll(userID: String) async throws { /// 1. Links 컬렉션 참조 생성 let path = FirebaseEndpoint.FirestoreDB.links(userID: userID).path diff --git a/Mark-In/Sources/Domain/Entities/Folder.swift b/Mark-In/Sources/Domain/Entities/Folder.swift index 01a4cf3..8abc10e 100644 --- a/Mark-In/Sources/Domain/Entities/Folder.swift +++ b/Mark-In/Sources/Domain/Entities/Folder.swift @@ -10,5 +10,5 @@ import Foundation struct Folder: Equatable, Hashable { var id: String? var name: String - var createdBy: Date + var createdAt: Date } diff --git a/Mark-In/Sources/Domain/Entities/WebLink.swift b/Mark-In/Sources/Domain/Entities/WebLink.swift index 531f6f9..fe992ac 100644 --- a/Mark-In/Sources/Domain/Entities/WebLink.swift +++ b/Mark-In/Sources/Domain/Entities/WebLink.swift @@ -14,7 +14,7 @@ struct WebLink: Hashable { var thumbnailUrl: String? var faviconUrl: String? var isPinned: Bool - var createdBy: Date + var createdAt: Date var lastAccessedAt: Date? var folderID: String? } diff --git a/Mark-In/Sources/Domain/Interfaces/FolderRepository.swift b/Mark-In/Sources/Domain/Interfaces/FolderRepository.swift index c2152cd..2ec9b8a 100644 --- a/Mark-In/Sources/Domain/Interfaces/FolderRepository.swift +++ b/Mark-In/Sources/Domain/Interfaces/FolderRepository.swift @@ -10,7 +10,6 @@ import Foundation protocol FolderRepository { func create(userID: String, folder: WriteFolder) async throws -> Folder func fetchAll(userID: String) async throws -> [Folder] - func update(userID: String, folder: Folder) async throws - func delete(userID: String, folder: Folder) async throws + func delete(userID: String, folderID: String) async throws func deleteAll(userID: String) async throws } diff --git a/Mark-In/Sources/Domain/Interfaces/LinkRepository.swift b/Mark-In/Sources/Domain/Interfaces/LinkRepository.swift index 8238596..2e3ae77 100644 --- a/Mark-In/Sources/Domain/Interfaces/LinkRepository.swift +++ b/Mark-In/Sources/Domain/Interfaces/LinkRepository.swift @@ -9,8 +9,23 @@ import Foundation protocol LinkRepository { func create(userID: String, link: WriteLink) async throws -> WebLink + func fetchAll(userID: String) async throws -> [WebLink] - func update(userID: String, link: WebLink) async throws - func delete(userID: String, link: WebLink) async throws + + func moveLinkInFolder( + userID: String, + target linkID: String, + to folderID: String? + ) async throws + + func moveLinksInFolder( + userID: String, + fromFolderID: String?, + toFolderID: String? + ) async throws + + + func delete(userID: String, linkID: String) async throws + func deleteAllInFolder(userID: String, folderID: String?) async throws func deleteAll(userID: String) async throws } diff --git a/Mark-In/Sources/Domain/UseCases/Implements/SignInUseCaseImpl.swift b/Mark-In/Sources/Domain/UseCases/Implements/Auth/SignInUseCaseImpl.swift similarity index 100% rename from Mark-In/Sources/Domain/UseCases/Implements/SignInUseCaseImpl.swift rename to Mark-In/Sources/Domain/UseCases/Implements/Auth/SignInUseCaseImpl.swift diff --git a/Mark-In/Sources/Domain/UseCases/Implements/SignOutUseCaseImpl.swift b/Mark-In/Sources/Domain/UseCases/Implements/Auth/SignOutUseCaseImpl.swift similarity index 100% rename from Mark-In/Sources/Domain/UseCases/Implements/SignOutUseCaseImpl.swift rename to Mark-In/Sources/Domain/UseCases/Implements/Auth/SignOutUseCaseImpl.swift diff --git a/Mark-In/Sources/Domain/UseCases/Implements/WithdrawalUseCaseImpl.swift b/Mark-In/Sources/Domain/UseCases/Implements/Auth/WithdrawalUseCaseImpl.swift similarity index 100% rename from Mark-In/Sources/Domain/UseCases/Implements/WithdrawalUseCaseImpl.swift rename to Mark-In/Sources/Domain/UseCases/Implements/Auth/WithdrawalUseCaseImpl.swift diff --git a/Mark-In/Sources/Domain/UseCases/Implements/Folder/DeleteFolderUseCaseImpl.swift b/Mark-In/Sources/Domain/UseCases/Implements/Folder/DeleteFolderUseCaseImpl.swift new file mode 100644 index 0000000..9e5c402 --- /dev/null +++ b/Mark-In/Sources/Domain/UseCases/Implements/Folder/DeleteFolderUseCaseImpl.swift @@ -0,0 +1,50 @@ +// +// DeleteFolderUseCaseImpl.swift +// Mark-In +// +// Created by 이정동 on 6/3/25. +// + +import Foundation + +struct DeleteFolderUseCaseImpl: DeleteFolderUseCase { + + private let authUserManager: AuthUserManager + + private let linkRepository: LinkRepository + private let folderRepository: FolderRepository + + init( + authUserManager: AuthUserManager, + linkRepository: LinkRepository, + folderRepository: FolderRepository + ) { + self.authUserManager = authUserManager + self.linkRepository = linkRepository + self.folderRepository = folderRepository + } + + func execute(folderID: String?, includingChildren: Bool) async throws { + guard let userID = authUserManager.user?.id else { + throw AuthError.unauthenticated + } + + /// 폴더 하위 데이터까지 삭제 + if includingChildren { + try await linkRepository.deleteAllInFolder(userID: userID, folderID: folderID) + + /// 폴더 하위 데이터는 삭제하지 않음 -> 기본 폴더로 위치 변경시키기 + } else { + try await linkRepository.moveLinksInFolder( + userID: userID, + fromFolderID: folderID, + toFolderID: nil + ) + } + + /// 폴더ID가 존재할 경우 (= 기본 폴더가 아닌 경우) 폴더 삭제 + if let folderID { + try await folderRepository.delete(userID: userID, folderID: folderID) + } + } +} diff --git a/Mark-In/Sources/Domain/UseCases/Implements/FetchFolderListUseCaseImpl.swift b/Mark-In/Sources/Domain/UseCases/Implements/Folder/FetchFolderListUseCaseImpl.swift similarity index 100% rename from Mark-In/Sources/Domain/UseCases/Implements/FetchFolderListUseCaseImpl.swift rename to Mark-In/Sources/Domain/UseCases/Implements/Folder/FetchFolderListUseCaseImpl.swift diff --git a/Mark-In/Sources/Domain/UseCases/Implements/GenerateFolderUseCaseImpl.swift b/Mark-In/Sources/Domain/UseCases/Implements/Folder/GenerateFolderUseCaseImpl.swift similarity index 100% rename from Mark-In/Sources/Domain/UseCases/Implements/GenerateFolderUseCaseImpl.swift rename to Mark-In/Sources/Domain/UseCases/Implements/Folder/GenerateFolderUseCaseImpl.swift diff --git a/Mark-In/Sources/Domain/UseCases/Implements/Link/DeleteLinkUseCaseImpl.swift b/Mark-In/Sources/Domain/UseCases/Implements/Link/DeleteLinkUseCaseImpl.swift new file mode 100644 index 0000000..1251cf6 --- /dev/null +++ b/Mark-In/Sources/Domain/UseCases/Implements/Link/DeleteLinkUseCaseImpl.swift @@ -0,0 +1,27 @@ +// +// DeleteLinkUseCaseImpl.swift +// Mark-In +// +// Created by 이정동 on 6/3/25. +// + +import Foundation + +struct DeleteLinkUseCaseImpl: DeleteLinkUseCase { + + private let authUserManager: AuthUserManager + private let linkRepository: LinkRepository + + init(authUserManager: AuthUserManager, linkRepository: LinkRepository) { + self.authUserManager = authUserManager + self.linkRepository = linkRepository + } + + func execute(linkID: String) async throws { + guard let userID = authUserManager.user?.id else { + throw AuthError.unauthenticated + } + + try await linkRepository.delete(userID: userID, linkID: linkID) + } +} diff --git a/Mark-In/Sources/Domain/UseCases/Implements/FetchLinkListUseCaseImpl.swift b/Mark-In/Sources/Domain/UseCases/Implements/Link/FetchLinkListUseCaseImpl.swift similarity index 100% rename from Mark-In/Sources/Domain/UseCases/Implements/FetchLinkListUseCaseImpl.swift rename to Mark-In/Sources/Domain/UseCases/Implements/Link/FetchLinkListUseCaseImpl.swift diff --git a/Mark-In/Sources/Domain/UseCases/Implements/GenerateLinkUseCaseImpl.swift b/Mark-In/Sources/Domain/UseCases/Implements/Link/GenerateLinkUseCaseImpl.swift similarity index 100% rename from Mark-In/Sources/Domain/UseCases/Implements/GenerateLinkUseCaseImpl.swift rename to Mark-In/Sources/Domain/UseCases/Implements/Link/GenerateLinkUseCaseImpl.swift diff --git a/Mark-In/Sources/Domain/UseCases/Interfaces/SignInUseCase.swift b/Mark-In/Sources/Domain/UseCases/Interfaces/Auth/SignInUseCase.swift similarity index 100% rename from Mark-In/Sources/Domain/UseCases/Interfaces/SignInUseCase.swift rename to Mark-In/Sources/Domain/UseCases/Interfaces/Auth/SignInUseCase.swift diff --git a/Mark-In/Sources/Domain/UseCases/Interfaces/SignOutUseCase.swift b/Mark-In/Sources/Domain/UseCases/Interfaces/Auth/SignOutUseCase.swift similarity index 100% rename from Mark-In/Sources/Domain/UseCases/Interfaces/SignOutUseCase.swift rename to Mark-In/Sources/Domain/UseCases/Interfaces/Auth/SignOutUseCase.swift diff --git a/Mark-In/Sources/Domain/UseCases/Interfaces/WithdrawalUseCase.swift b/Mark-In/Sources/Domain/UseCases/Interfaces/Auth/WithdrawalUseCase.swift similarity index 100% rename from Mark-In/Sources/Domain/UseCases/Interfaces/WithdrawalUseCase.swift rename to Mark-In/Sources/Domain/UseCases/Interfaces/Auth/WithdrawalUseCase.swift diff --git a/Mark-In/Sources/Domain/UseCases/Interfaces/Folder/DeleteFolderUseCase.swift b/Mark-In/Sources/Domain/UseCases/Interfaces/Folder/DeleteFolderUseCase.swift new file mode 100644 index 0000000..497c786 --- /dev/null +++ b/Mark-In/Sources/Domain/UseCases/Interfaces/Folder/DeleteFolderUseCase.swift @@ -0,0 +1,12 @@ +// +// DeleteFolderUseCase.swift +// Mark-In +// +// Created by 이정동 on 6/3/25. +// + +import Foundation + +protocol DeleteFolderUseCase { + func execute(folderID: String?, includingChildren: Bool) async throws +} diff --git a/Mark-In/Sources/Domain/UseCases/Interfaces/FetchFolderListUseCase.swift b/Mark-In/Sources/Domain/UseCases/Interfaces/Folder/FetchFolderListUseCase.swift similarity index 100% rename from Mark-In/Sources/Domain/UseCases/Interfaces/FetchFolderListUseCase.swift rename to Mark-In/Sources/Domain/UseCases/Interfaces/Folder/FetchFolderListUseCase.swift diff --git a/Mark-In/Sources/Domain/UseCases/Interfaces/GenerateFolderUseCase.swift b/Mark-In/Sources/Domain/UseCases/Interfaces/Folder/GenerateFolderUseCase.swift similarity index 100% rename from Mark-In/Sources/Domain/UseCases/Interfaces/GenerateFolderUseCase.swift rename to Mark-In/Sources/Domain/UseCases/Interfaces/Folder/GenerateFolderUseCase.swift diff --git a/Mark-In/Sources/Domain/UseCases/Interfaces/Link/DeleteLinkUseCase.swift b/Mark-In/Sources/Domain/UseCases/Interfaces/Link/DeleteLinkUseCase.swift new file mode 100644 index 0000000..2dfd783 --- /dev/null +++ b/Mark-In/Sources/Domain/UseCases/Interfaces/Link/DeleteLinkUseCase.swift @@ -0,0 +1,12 @@ +// +// DeleteLinkUseCase.swift +// Mark-In +// +// Created by 이정동 on 6/3/25. +// + +import Foundation + +protocol DeleteLinkUseCase { + func execute(linkID: String) async throws +} diff --git a/Mark-In/Sources/Domain/UseCases/Interfaces/FetchLinkListUseCase.swift b/Mark-In/Sources/Domain/UseCases/Interfaces/Link/FetchLinkListUseCase.swift similarity index 100% rename from Mark-In/Sources/Domain/UseCases/Interfaces/FetchLinkListUseCase.swift rename to Mark-In/Sources/Domain/UseCases/Interfaces/Link/FetchLinkListUseCase.swift diff --git a/Mark-In/Sources/Domain/UseCases/Interfaces/GenerateLinkUseCase.swift b/Mark-In/Sources/Domain/UseCases/Interfaces/Link/GenerateLinkUseCase.swift similarity index 100% rename from Mark-In/Sources/Domain/UseCases/Interfaces/GenerateLinkUseCase.swift rename to Mark-In/Sources/Domain/UseCases/Interfaces/Link/GenerateLinkUseCase.swift diff --git a/Mark-In/Sources/Feature/AddFolder/AddFolderReducer.swift b/Mark-In/Sources/Feature/AddFolder/AddFolderReducer.swift index 2c90e9a..4ecdd43 100644 --- a/Mark-In/Sources/Feature/AddFolder/AddFolderReducer.swift +++ b/Mark-In/Sources/Feature/AddFolder/AddFolderReducer.swift @@ -18,7 +18,7 @@ struct AddFolderReducer: Reducer { } enum Action { - case didTapAddFolderButton(name: String) + case didTapAddFolderButton(WriteFolder) case didCompleteSave(Folder) case updateErrorState(Bool) } @@ -27,12 +27,11 @@ struct AddFolderReducer: Reducer { func reduce(into state: inout State, action: Action) -> Effect { switch action { - case .didTapAddFolderButton(let name): + case .didTapAddFolderButton(let writeFolder): state.isLoading = true return .run { do { - let writeFolder = WriteFolder(name: name) let result = try await self.generateFolderUseCase.execute(writeFolder: writeFolder) return .didCompleteSave(result) } catch { diff --git a/Mark-In/Sources/Feature/AddFolder/AddFolderView.swift b/Mark-In/Sources/Feature/AddFolder/AddFolderView.swift index 458de0c..c3d8837 100644 --- a/Mark-In/Sources/Feature/AddFolder/AddFolderView.swift +++ b/Mark-In/Sources/Feature/AddFolder/AddFolderView.swift @@ -16,7 +16,7 @@ struct AddFolderView: View { initialState: AddFolderReducer.State(), reducer: AddFolderReducer() ) - @State private var title: String = "" + @State private var name: String = "" private var isSaving: Bool { store.state.isLoading @@ -29,7 +29,7 @@ struct AddFolderView: View { Text("폴더를 추가:") .frame(maxWidth: .infinity, alignment: .leading) - TextField("", text: $title, prompt: Text("제목")) + TextField("", text: $name, prompt: Text("제목")) .textFieldStyle(.roundedBorder) .padding(.top, 14) .disabled(isSaving) @@ -55,10 +55,11 @@ struct AddFolderView: View { .stroke(.markBlack10, lineWidth: 0.5) } } - .disabled(title.isEmpty || isSaving) + .disabled(isSaving) Button { - store.send(.didTapAddFolderButton(name: title)) + let writeFolder = WriteFolder(name: name) + store.send(.didTapAddFolderButton(writeFolder)) } label: { Text("추가") .padding(.vertical, 4) @@ -67,7 +68,7 @@ struct AddFolderView: View { .background(.markPoint) .clipShape(RoundedRectangle(cornerRadius: 6)) } - .disabled(title.isEmpty || isSaving) + .disabled(name.isEmpty || isSaving) } .frame(maxWidth: .infinity, alignment: .trailing) .padding(.top, 18) diff --git a/Mark-In/Sources/Feature/AddLink/AddLinkView.swift b/Mark-In/Sources/Feature/AddLink/AddLinkView.swift index 80c5f8f..2a4b662 100644 --- a/Mark-In/Sources/Feature/AddLink/AddLinkView.swift +++ b/Mark-In/Sources/Feature/AddLink/AddLinkView.swift @@ -136,8 +136,8 @@ struct AddLinkView: View { #Preview { AddLinkView( folders: [ - .init(id: "", name: "기본폴더", createdBy: .now), - .init(id: "1", name: "폴더1", createdBy: .now), + .init(id: "", name: "기본폴더", createdAt: .now), + .init(id: "1", name: "폴더1", createdAt: .now), ] ) { print($0) diff --git a/Mark-In/Sources/Feature/Main/LinkListView.swift b/Mark-In/Sources/Feature/Main/LinkListView.swift index e94c70b..47c29ff 100644 --- a/Mark-In/Sources/Feature/Main/LinkListView.swift +++ b/Mark-In/Sources/Feature/Main/LinkListView.swift @@ -47,7 +47,10 @@ struct LinkListView: View { spacing: ViewConstants.spacing ) { ForEach(links, id: \.self) { link in - LinkCell(link: link) + LinkCell( + store: store, + link: link + ) } } .padding(20) @@ -76,6 +79,7 @@ struct LinkListView: View { private struct LinkCell: View { + let store: Store let link: WebLink var body: some View { @@ -115,7 +119,13 @@ private struct LinkCell: View { RoundedRectangle(cornerRadius: 6) .stroke(.markBlack20, lineWidth: 0.5) }) - + .contextMenu { + Button { + store.send(.deleteLinkButtonTapped(link: link)) + } label: { + Text("삭제") + } + } } private var headerTitle: some View { @@ -149,7 +159,7 @@ private struct LinkCell: View { .frame(width: 5, height: 5) } - Text(link.createdBy.description) + Text(link.createdAt.description) .font(.pretendard(size: 10, weight: .regular)) .foregroundStyle(.markBlack40) .lineLimit(1) @@ -158,7 +168,19 @@ private struct LinkCell: View { } #Preview { - LinkCell(link: .init(id: "", url: "www.naver.com", isPinned: true, createdBy: .now)) + let store: Store = .init( + initialState: MainReducer.State(), + reducer: MainReducer() + ) + LinkCell( + store: store, + link: .init( + id: "", + url: "www.naver.com", + isPinned: true, + createdAt: .now + ) + ) .frame(width: 210, height: 160) // LinkListView(viewModel: MainViewModel()) // .frame(width: 600, height: 600) diff --git a/Mark-In/Sources/Feature/Main/MainReducer.swift b/Mark-In/Sources/Feature/Main/MainReducer.swift index 57b4901..d289de2 100644 --- a/Mark-In/Sources/Feature/Main/MainReducer.swift +++ b/Mark-In/Sources/Feature/Main/MainReducer.swift @@ -33,6 +33,9 @@ struct MainReducer: Reducer { case didCreateLink(WebLink) case didCreateFolder(Folder) + case deleteLinkButtonTapped(link: WebLink) + case deleteFolderButtonTapped(folder: Folder, includingChildren: Bool) + case occuredError case empty @@ -40,6 +43,8 @@ struct MainReducer: Reducer { @Dependency private var fetchLinkListUseCase: FetchLinkListUseCase @Dependency private var fetchFolderListUseCase: FetchFolderListUseCase + @Dependency private var deleteLinkUseCase: DeleteLinkUseCase + @Dependency private var deleteFolderUseCase: DeleteFolderUseCase func reduce(into state: inout State, action: Action) -> Effect { switch action { @@ -59,10 +64,10 @@ struct MainReducer: Reducer { state.links = links - state.folderTabs.append(.folder(.init(id: nil, name: "기본폴더", createdBy: .now))) + state.folderTabs = [.folder(.init(id: nil, name: "기본폴더", createdAt: .now))] folders.forEach { state.folderTabs.append( - .folder(.init(id: $0.id, name: $0.name, createdBy: $0.createdBy)) + .folder(.init(id: $0.id, name: $0.name, createdAt: $0.createdAt)) ) } @@ -85,6 +90,28 @@ struct MainReducer: Reducer { state.folderTabs.append(.folder(folder)) return .none + case .deleteLinkButtonTapped(let link): + state.isLoading = true + return .run { + do { + try await self.deleteLinkUseCase.execute(linkID: link.id) + return .onAppear + } catch { + return .occuredError + } + } + + case let .deleteFolderButtonTapped(folder, includingChildren): + state.isLoading = true + return .run { + do { + try await self.deleteFolderUseCase.execute(folderID: folder.id, includingChildren: includingChildren) + return .onAppear + } catch { + return .occuredError + } + } + // TODO: 에러 처리 로직 추가 case .occuredError: return .none diff --git a/Mark-In/Sources/Feature/Main/SideBar.swift b/Mark-In/Sources/Feature/Main/SideBar.swift index 90a4ad5..87f8ee2 100644 --- a/Mark-In/Sources/Feature/Main/SideBar.swift +++ b/Mark-In/Sources/Feature/Main/SideBar.swift @@ -13,6 +13,9 @@ import ReducerKit struct SideBar: View { let store: Store + @State private var isPresentedDialog: Bool = false + @State private var deleteFolder: Folder? + var body: some View { VStack(alignment: .leading) { @@ -39,6 +42,17 @@ struct SideBar: View { NavigationLink(value: tab) { Label(tab.title, systemImage: tab.icon) } + .contextMenu { + if case .folder(let folder) = tab { + Button { + isPresentedDialog = true + deleteFolder = folder + } label: { + Text("삭제") + } + .disabled(folder.id == nil) + } + } } } } @@ -60,6 +74,36 @@ struct SideBar: View { .padding([.bottom, .leading], 10) } .buttonStyle(.plain) + .confirmationDialog( + "이 폴더를 삭제하시겠습니까?", + isPresented: $isPresentedDialog + ) { + Button(role: .destructive) { + store.send(.deleteFolderButtonTapped( + folder: deleteFolder!, + includingChildren: false + )) + } label: { + Text("폴더만 삭제") + } + + Button(role: .destructive) { + store.send(.deleteFolderButtonTapped( + folder: deleteFolder!, + includingChildren: true + )) + } label: { + Text("폴더와 링크 삭제") + } + + Button(role: .cancel) { + deleteFolder = nil + } label: { + Text("취소") + } + } message: { + Text("이 폴더를 삭제하면 하위 링크도 함께 삭제하거나, 그대로 유지할 수 있습니다.") + } } }