feat(creator): 커뮤니티 게시글 상세를 연결한다
This commit is contained in:
@@ -7,30 +7,6 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
CC0710012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */; };
|
|
||||||
CC0710022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */; };
|
|
||||||
CC0710032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */; };
|
|
||||||
CC0710042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */; };
|
|
||||||
CC0710052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */; };
|
|
||||||
CC0710062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */; };
|
|
||||||
CC0710072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */; };
|
|
||||||
CC0710082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */; };
|
|
||||||
CC0720012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */; };
|
|
||||||
CC0720022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */; };
|
|
||||||
CC0720032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */; };
|
|
||||||
CC0720042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */; };
|
|
||||||
CC0720052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */; };
|
|
||||||
CC0720062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */; };
|
|
||||||
CC0720072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */; };
|
|
||||||
CC0720082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */; };
|
|
||||||
CC0730012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */; };
|
|
||||||
CC0730022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */; };
|
|
||||||
CC0730032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */; };
|
|
||||||
CC0730042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */; };
|
|
||||||
CC0730052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */; };
|
|
||||||
CC0730062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */; };
|
|
||||||
CC0730072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */; };
|
|
||||||
CC0730082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */; };
|
|
||||||
00C796627A6758D271215EC5 /* CreatorProfileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26881ED30292E28D2E5F84CD /* CreatorProfileItem.swift */; };
|
00C796627A6758D271215EC5 /* CreatorProfileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26881ED30292E28D2E5F84CD /* CreatorProfileItem.swift */; };
|
||||||
042BEE0C973700FB5F4540CD /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826D626B7714E5C28ED31074 /* I18n.swift */; };
|
042BEE0C973700FB5F4540CD /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826D626B7714E5C28ED31074 /* I18n.swift */; };
|
||||||
0470E68C6F302A131264E3AF /* ChannelDonationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF2BFD2E09F509C44C92214 /* ChannelDonationViewModel.swift */; };
|
0470E68C6F302A131264E3AF /* ChannelDonationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF2BFD2E09F509C44C92214 /* ChannelDonationViewModel.swift */; };
|
||||||
@@ -2043,6 +2019,51 @@
|
|||||||
CC0630082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */; };
|
CC0630082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */; };
|
||||||
CC0630092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */; };
|
CC0630092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */; };
|
||||||
CC06300A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */; };
|
CC06300A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */; };
|
||||||
|
CC0710012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */; };
|
||||||
|
CC0710022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */; };
|
||||||
|
CC0710032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */; };
|
||||||
|
CC0710042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */; };
|
||||||
|
CC0710052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */; };
|
||||||
|
CC0710062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */; };
|
||||||
|
CC0710072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */; };
|
||||||
|
CC0710082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */; };
|
||||||
|
CC0720012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */; };
|
||||||
|
CC0720022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */; };
|
||||||
|
CC0720032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */; };
|
||||||
|
CC0720042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */; };
|
||||||
|
CC0720052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */; };
|
||||||
|
CC0720062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */; };
|
||||||
|
CC0720072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */; };
|
||||||
|
CC0720082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */; };
|
||||||
|
CC0730012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */; };
|
||||||
|
CC0730022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */; };
|
||||||
|
CC0730032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */; };
|
||||||
|
CC0730042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */; };
|
||||||
|
CC0730052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */; };
|
||||||
|
CC0730062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */; };
|
||||||
|
CC0730072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */; };
|
||||||
|
CC0730082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */; };
|
||||||
|
CC0810012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift */; };
|
||||||
|
CC0810022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift */; };
|
||||||
|
CC0810032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift */; };
|
||||||
|
CC0810042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift */; };
|
||||||
|
CC0810052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift */; };
|
||||||
|
CC0810062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift */; };
|
||||||
|
CC0810072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift */; };
|
||||||
|
CC0820012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift */; };
|
||||||
|
CC0820022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift */; };
|
||||||
|
CC0820032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift */; };
|
||||||
|
CC0820042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift */; };
|
||||||
|
CC0820052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift */; };
|
||||||
|
CC0820062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift */; };
|
||||||
|
CC0820072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift */; };
|
||||||
|
CC0830012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift */; };
|
||||||
|
CC0830022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift */; };
|
||||||
|
CC0830032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift */; };
|
||||||
|
CC0830042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift */; };
|
||||||
|
CC0830052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift */; };
|
||||||
|
CC0830062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift */; };
|
||||||
|
CC0830072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0800072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift */; };
|
||||||
CD256952CC366FFB67557807 /* CreatorChannelFanTalkApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 002435A7145D59CCCA8F76D1 /* CreatorChannelFanTalkApi.swift */; };
|
CD256952CC366FFB67557807 /* CreatorChannelFanTalkApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 002435A7145D59CCCA8F76D1 /* CreatorChannelFanTalkApi.swift */; };
|
||||||
CE935959F7A2BF7301F517DB /* CreatorChannelFanTalkTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1C1B0C73E8D92112E47610 /* CreatorChannelFanTalkTabResponse.swift */; };
|
CE935959F7A2BF7301F517DB /* CreatorChannelFanTalkTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1C1B0C73E8D92112E47610 /* CreatorChannelFanTalkTabResponse.swift */; };
|
||||||
CE93595AF7A2BF7301F517DB /* CreatorChannelFanTalkWriteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C5D06F4F54C0ED7B7BD462 /* CreatorChannelFanTalkWriteViewModel.swift */; };
|
CE93595AF7A2BF7301F517DB /* CreatorChannelFanTalkWriteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C5D06F4F54C0ED7B7BD462 /* CreatorChannelFanTalkWriteViewModel.swift */; };
|
||||||
@@ -2073,14 +2094,6 @@
|
|||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailView.swift; sourceTree = "<group>"; };
|
|
||||||
CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailViewModel.swift; sourceTree = "<group>"; };
|
|
||||||
CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailContext.swift; sourceTree = "<group>"; };
|
|
||||||
CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailDisplayItem.swift; sourceTree = "<group>"; };
|
|
||||||
CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailTitleBar.swift; sourceTree = "<group>"; };
|
|
||||||
CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailFeedView.swift; sourceTree = "<group>"; };
|
|
||||||
CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailInputBar.swift; sourceTree = "<group>"; };
|
|
||||||
CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailActionPopup.swift; sourceTree = "<group>"; };
|
|
||||||
002435A7145D59CCCA8F76D1 /* CreatorChannelFanTalkApi.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFanTalkApi.swift; sourceTree = "<group>"; };
|
002435A7145D59CCCA8F76D1 /* CreatorChannelFanTalkApi.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFanTalkApi.swift; sourceTree = "<group>"; };
|
||||||
0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostChannelDonationRequest.swift; sourceTree = "<group>"; };
|
0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostChannelDonationRequest.swift; sourceTree = "<group>"; };
|
||||||
0EC05859619BDC03056911E7 /* MainTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = "<group>"; };
|
0EC05859619BDC03056911E7 /* MainTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = "<group>"; };
|
||||||
@@ -3006,6 +3019,21 @@
|
|||||||
CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityGridItem.swift; sourceTree = "<group>"; };
|
CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityGridItem.swift; sourceTree = "<group>"; };
|
||||||
CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityUploadButton.swift; sourceTree = "<group>"; };
|
CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityUploadButton.swift; sourceTree = "<group>"; };
|
||||||
CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityEmptyView.swift; sourceTree = "<group>"; };
|
CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityEmptyView.swift; sourceTree = "<group>"; };
|
||||||
|
CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailView.swift; sourceTree = "<group>"; };
|
||||||
|
CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailContext.swift; sourceTree = "<group>"; };
|
||||||
|
CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailDisplayItem.swift; sourceTree = "<group>"; };
|
||||||
|
CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailTitleBar.swift; sourceTree = "<group>"; };
|
||||||
|
CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailFeedView.swift; sourceTree = "<group>"; };
|
||||||
|
CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailInputBar.swift; sourceTree = "<group>"; };
|
||||||
|
CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelReplyDetailActionPopup.swift; sourceTree = "<group>"; };
|
||||||
|
CC0800012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityPostDetailResponse.swift; sourceTree = "<group>"; };
|
||||||
|
CC0800022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityPostDetailView.swift; sourceTree = "<group>"; };
|
||||||
|
CC0800032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityPostDetailViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
CC0800042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityPostDetailContentView.swift; sourceTree = "<group>"; };
|
||||||
|
CC0800052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityPostDetailCommentListView.swift; sourceTree = "<group>"; };
|
||||||
|
CC0800062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityPostDetailCommentRow.swift; sourceTree = "<group>"; };
|
||||||
|
CC0800072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityPostDetailInputBar.swift; sourceTree = "<group>"; };
|
||||||
CC73FFC54CB1D988053059D1 /* LanguageEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageEnvironment.swift; sourceTree = "<group>"; };
|
CC73FFC54CB1D988053059D1 /* LanguageEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageEnvironment.swift; sourceTree = "<group>"; };
|
||||||
CE82912F2F111593BE42E33C /* CommunityPostCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CommunityPostCard.swift; sourceTree = "<group>"; };
|
CE82912F2F111593BE42E33C /* CommunityPostCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CommunityPostCard.swift; sourceTree = "<group>"; };
|
||||||
D03CBA8005C81F7C31F59A89 /* Pods-SodaLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SodaLive.release.xcconfig"; path = "Target Support Files/Pods-SodaLive/Pods-SodaLive.release.xcconfig"; sourceTree = "<group>"; };
|
D03CBA8005C81F7C31F59A89 /* Pods-SodaLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SodaLive.release.xcconfig"; path = "Target Support Files/Pods-SodaLive/Pods-SodaLive.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
@@ -3097,37 +3125,6 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
CC0700092FF0700000A5BBA3 /* Models */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */,
|
|
||||||
CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */,
|
|
||||||
);
|
|
||||||
path = Models;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
CC07000A2FF0700000A5BBA3 /* Components */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */,
|
|
||||||
CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */,
|
|
||||||
CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */,
|
|
||||||
CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */,
|
|
||||||
);
|
|
||||||
path = Components;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
CC07000B2FF0700000A5BBA3 /* ReplyDetail */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
CC0700092FF0700000A5BBA3 /* Models */,
|
|
||||||
CC07000A2FF0700000A5BBA3 /* Components */,
|
|
||||||
CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */,
|
|
||||||
CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */,
|
|
||||||
);
|
|
||||||
path = ReplyDetail;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
038B15E425DE0639156ADBA9 /* Repository */ = {
|
038B15E425DE0639156ADBA9 /* Repository */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -6068,6 +6065,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
CC0600012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift */,
|
CC0600012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift */,
|
||||||
|
CC0800012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift */,
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -6099,12 +6097,65 @@
|
|||||||
CC06000B2FF0600000A5BBA3 /* Models */,
|
CC06000B2FF0600000A5BBA3 /* Models */,
|
||||||
CC06000C2FF0600000A5BBA3 /* Repository */,
|
CC06000C2FF0600000A5BBA3 /* Repository */,
|
||||||
CC06000D2FF0600000A5BBA3 /* Components */,
|
CC06000D2FF0600000A5BBA3 /* Components */,
|
||||||
|
CC0800092FF0800000A5BBA3 /* Detail */,
|
||||||
CC0600042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift */,
|
CC0600042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift */,
|
||||||
CC0600052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift */,
|
CC0600052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift */,
|
||||||
);
|
);
|
||||||
path = Community;
|
path = Community;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
CC0800082FF0800000A5BBA3 /* Components */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CC0800042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift */,
|
||||||
|
CC0800052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift */,
|
||||||
|
CC0800062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift */,
|
||||||
|
CC0800072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift */,
|
||||||
|
);
|
||||||
|
path = Components;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CC0800092FF0800000A5BBA3 /* Detail */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CC0800082FF0800000A5BBA3 /* Components */,
|
||||||
|
CC0800022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift */,
|
||||||
|
CC0800032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = Detail;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CC0700092FF0700000A5BBA3 /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CC0700032FF0700000A5BBA3 /* CreatorChannelReplyDetailContext.swift */,
|
||||||
|
CC0700042FF0700000A5BBA3 /* CreatorChannelReplyDetailDisplayItem.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CC07000A2FF0700000A5BBA3 /* Components */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CC0700052FF0700000A5BBA3 /* CreatorChannelReplyDetailTitleBar.swift */,
|
||||||
|
CC0700062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift */,
|
||||||
|
CC0700072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift */,
|
||||||
|
CC0700082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift */,
|
||||||
|
);
|
||||||
|
path = Components;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CC07000B2FF0700000A5BBA3 /* ReplyDetail */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CC0700092FF0700000A5BBA3 /* Models */,
|
||||||
|
CC07000A2FF0700000A5BBA3 /* Components */,
|
||||||
|
CC0700012FF0700000A5BBA3 /* CreatorChannelReplyDetailView.swift */,
|
||||||
|
CC0700022FF0700000A5BBA3 /* CreatorChannelReplyDetailViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = ReplyDetail;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
D37878F5929361B43B1E3C5C /* Card */ = {
|
D37878F5929361B43B1E3C5C /* Card */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -6556,6 +6607,13 @@
|
|||||||
CC0710062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */,
|
CC0710062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */,
|
||||||
CC0710072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */,
|
CC0710072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */,
|
||||||
CC0710082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */,
|
CC0710082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */,
|
||||||
|
CC0810012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift in Sources */,
|
||||||
|
CC0810022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift in Sources */,
|
||||||
|
CC0810032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift in Sources */,
|
||||||
|
CC0810042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift in Sources */,
|
||||||
|
CC0810052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift in Sources */,
|
||||||
|
CC0810062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift in Sources */,
|
||||||
|
CC0810072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift in Sources */,
|
||||||
CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */,
|
CC0101082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */,
|
||||||
CC0101112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
|
CC0101112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
|
||||||
CC0101122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
|
CC0101122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
|
||||||
@@ -7459,6 +7517,13 @@
|
|||||||
CC0720062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */,
|
CC0720062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */,
|
||||||
CC0720072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */,
|
CC0720072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */,
|
||||||
CC0720082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */,
|
CC0720082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */,
|
||||||
|
CC0820012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift in Sources */,
|
||||||
|
CC0820022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift in Sources */,
|
||||||
|
CC0820032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift in Sources */,
|
||||||
|
CC0820042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift in Sources */,
|
||||||
|
CC0820052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift in Sources */,
|
||||||
|
CC0820062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift in Sources */,
|
||||||
|
CC0820072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift in Sources */,
|
||||||
CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */,
|
CC0102082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */,
|
||||||
CC0102112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
|
CC0102112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
|
||||||
CC0102122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
|
CC0102122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
|
||||||
@@ -8390,6 +8455,13 @@
|
|||||||
CC0730062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */,
|
CC0730062FF0700000A5BBA3 /* CreatorChannelReplyDetailFeedView.swift in Sources */,
|
||||||
CC0730072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */,
|
CC0730072FF0700000A5BBA3 /* CreatorChannelReplyDetailInputBar.swift in Sources */,
|
||||||
CC0730082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */,
|
CC0730082FF0700000A5BBA3 /* CreatorChannelReplyDetailActionPopup.swift in Sources */,
|
||||||
|
CC0830012FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailResponse.swift in Sources */,
|
||||||
|
CC0830022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift in Sources */,
|
||||||
|
CC0830032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift in Sources */,
|
||||||
|
CC0830042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift in Sources */,
|
||||||
|
CC0830052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift in Sources */,
|
||||||
|
CC0830062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift in Sources */,
|
||||||
|
CC0830072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift in Sources */,
|
||||||
CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */,
|
CC0103082FF0100000A5BBA3 /* CreatorChannelView.swift in Sources */,
|
||||||
CC0103112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
|
CC0103112FF0100000A5BBA3 /* CreatorChannelTitleBar.swift in Sources */,
|
||||||
CC0103122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
|
CC0103122FF0100000A5BBA3 /* CreatorChannelHeaderSection.swift in Sources */,
|
||||||
|
|||||||
@@ -138,7 +138,13 @@ enum AppStep {
|
|||||||
|
|
||||||
case creatorChannelReplyDetail(
|
case creatorChannelReplyDetail(
|
||||||
context: CreatorChannelReplyDetailContext,
|
context: CreatorChannelReplyDetailContext,
|
||||||
onFanTalkRefresh: (() -> Void)? = nil
|
onFanTalkRefresh: (() -> Void)? = nil,
|
||||||
|
onCommunityRefresh: (() -> Void)? = nil
|
||||||
|
)
|
||||||
|
|
||||||
|
case creatorChannelCommunityPostDetail(
|
||||||
|
postId: Int,
|
||||||
|
onCommunityRefresh: (() -> Void)? = nil
|
||||||
)
|
)
|
||||||
|
|
||||||
case canCoupon(refresh: () -> Void)
|
case canCoupon(refresh: () -> Void)
|
||||||
|
|||||||
@@ -257,8 +257,15 @@ struct AppStepLayerView: View {
|
|||||||
case .creatorChannelFanTalkWrite(let creatorId, let mode, _, _, let onSuccess):
|
case .creatorChannelFanTalkWrite(let creatorId, let mode, _, _, let onSuccess):
|
||||||
CreatorChannelFanTalkWriteView(creatorId: creatorId, mode: mode, onSuccess: onSuccess)
|
CreatorChannelFanTalkWriteView(creatorId: creatorId, mode: mode, onSuccess: onSuccess)
|
||||||
|
|
||||||
case .creatorChannelReplyDetail(let context, let onFanTalkRefresh):
|
case .creatorChannelReplyDetail(let context, let onFanTalkRefresh, let onCommunityRefresh):
|
||||||
CreatorChannelReplyDetailView(context: context, onFanTalkRefresh: onFanTalkRefresh)
|
CreatorChannelReplyDetailView(
|
||||||
|
context: context,
|
||||||
|
onFanTalkRefresh: onFanTalkRefresh,
|
||||||
|
onCommunityRefresh: onCommunityRefresh
|
||||||
|
)
|
||||||
|
|
||||||
|
case .creatorChannelCommunityPostDetail(let postId, let onCommunityRefresh):
|
||||||
|
CreatorChannelCommunityPostDetailView(postId: postId, onCommunityRefresh: onCommunityRefresh)
|
||||||
|
|
||||||
case .canCoupon(let refresh):
|
case .canCoupon(let refresh):
|
||||||
CanCouponView(refresh: refresh)
|
CanCouponView(refresh: refresh)
|
||||||
|
|||||||
@@ -3422,6 +3422,7 @@ If you block this user, the following features will be restricted.
|
|||||||
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") }
|
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") }
|
||||||
static var listView: String { pick(ko: "리스트형", en: "List", ja: "リスト型") }
|
static var listView: String { pick(ko: "리스트형", en: "List", ja: "リスト型") }
|
||||||
static var thumbnailView: String { pick(ko: "썸네일형", en: "Thumbnail", ja: "サムネイル型") }
|
static var thumbnailView: String { pick(ko: "썸네일형", en: "Thumbnail", ja: "サムネイル型") }
|
||||||
|
static var commentPlaceholder: String { pick(ko: "댓글을 입력하세요", en: "Enter a comment", ja: "コメントを入力してください") }
|
||||||
static var communityEmpty: String {
|
static var communityEmpty: String {
|
||||||
pick(
|
pick(
|
||||||
ko: "크리에이터가 커뮤니티를 준비 중입니다.\n기대해 주세요!",
|
ko: "크리에이터가 커뮤니티를 준비 중입니다.\n기대해 주세요!",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ struct CreatorChannelCommunityGridItem: View {
|
|||||||
let post: CreatorChannelCommunityPostItem
|
let post: CreatorChannelCommunityPostItem
|
||||||
let isOwnPost: Bool
|
let isOwnPost: Bool
|
||||||
let onTapPurchase: () -> Void
|
let onTapPurchase: () -> Void
|
||||||
|
let onTapDetail: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { proxy in
|
GeometryReader { proxy in
|
||||||
@@ -14,20 +15,10 @@ struct CreatorChannelCommunityGridItem: View {
|
|||||||
.overlay(paidPriceOverlay)
|
.overlay(paidPriceOverlay)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
} else if let imageUrl = post.imageUrl, !imageUrl.isEmpty {
|
|
||||||
DownsampledKFImage(url: URL(string: imageUrl), size: CGSize(width: proxy.size.width, height: proxy.size.width))
|
|
||||||
.frame(width: proxy.size.width, height: proxy.size.width)
|
|
||||||
.aspectRatio(1, contentMode: .fill)
|
|
||||||
.clipped()
|
|
||||||
} else {
|
} else {
|
||||||
Color.gray900
|
tileContent(proxy: proxy)
|
||||||
|
.contentShape(Rectangle())
|
||||||
Text(fallbackText)
|
.onTapGesture(perform: onTapDetail)
|
||||||
.appFont(size: 12, weight: .medium)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.lineLimit(3)
|
|
||||||
.padding(SodaSpacing.s8)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: proxy.size.width, height: proxy.size.width)
|
.frame(width: proxy.size.width, height: proxy.size.width)
|
||||||
@@ -47,6 +38,27 @@ struct CreatorChannelCommunityGridItem: View {
|
|||||||
.aspectRatio(1, contentMode: .fit)
|
.aspectRatio(1, contentMode: .fit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func tileContent(proxy: GeometryProxy) -> some View {
|
||||||
|
if let imageUrl = post.imageUrl, !imageUrl.isEmpty {
|
||||||
|
DownsampledKFImage(url: URL(string: imageUrl), size: CGSize(width: proxy.size.width, height: proxy.size.width))
|
||||||
|
.frame(width: proxy.size.width, height: proxy.size.width)
|
||||||
|
.aspectRatio(1, contentMode: .fill)
|
||||||
|
.clipped()
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
Color.gray900
|
||||||
|
|
||||||
|
Text(fallbackText)
|
||||||
|
.appFont(size: 12, weight: .medium)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(3)
|
||||||
|
.padding(SodaSpacing.s8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var paidPriceOverlay: some View {
|
private var paidPriceOverlay: some View {
|
||||||
VStack(spacing: SodaSpacing.s4) {
|
VStack(spacing: SodaSpacing.s4) {
|
||||||
Image("ic_new_community_lock")
|
Image("ic_new_community_lock")
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ struct CreatorChannelCommunityListItem: View {
|
|||||||
let isOwnPost: Bool
|
let isOwnPost: Bool
|
||||||
let isOwnCreatorChannel: Bool
|
let isOwnCreatorChannel: Bool
|
||||||
let onTapLike: () -> Void
|
let onTapLike: () -> Void
|
||||||
let onTapComment: () -> Void
|
|
||||||
let onTapMore: () -> Void
|
let onTapMore: () -> Void
|
||||||
let onTapPurchase: () -> Void
|
let onTapPurchase: () -> Void
|
||||||
|
let onTapDetail: () -> Void
|
||||||
|
|
||||||
@State private var localIsLike: Bool
|
@State private var localIsLike: Bool
|
||||||
@State private var localLikeCount: Int
|
@State private var localLikeCount: Int
|
||||||
@@ -22,17 +22,17 @@ struct CreatorChannelCommunityListItem: View {
|
|||||||
isOwnPost: Bool,
|
isOwnPost: Bool,
|
||||||
isOwnCreatorChannel: Bool,
|
isOwnCreatorChannel: Bool,
|
||||||
onTapLike: @escaping () -> Void,
|
onTapLike: @escaping () -> Void,
|
||||||
onTapComment: @escaping () -> Void,
|
|
||||||
onTapMore: @escaping () -> Void,
|
onTapMore: @escaping () -> Void,
|
||||||
onTapPurchase: @escaping () -> Void
|
onTapPurchase: @escaping () -> Void,
|
||||||
|
onTapDetail: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
self.post = post
|
self.post = post
|
||||||
self.isOwnPost = isOwnPost
|
self.isOwnPost = isOwnPost
|
||||||
self.isOwnCreatorChannel = isOwnCreatorChannel
|
self.isOwnCreatorChannel = isOwnCreatorChannel
|
||||||
self.onTapLike = onTapLike
|
self.onTapLike = onTapLike
|
||||||
self.onTapComment = onTapComment
|
|
||||||
self.onTapMore = onTapMore
|
self.onTapMore = onTapMore
|
||||||
self.onTapPurchase = onTapPurchase
|
self.onTapPurchase = onTapPurchase
|
||||||
|
self.onTapDetail = onTapDetail
|
||||||
_localIsLike = State(initialValue: post.isLiked ?? false)
|
_localIsLike = State(initialValue: post.isLiked ?? false)
|
||||||
_localLikeCount = State(initialValue: post.likeCount)
|
_localLikeCount = State(initialValue: post.likeCount)
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,7 @@ struct CreatorChannelCommunityListItem: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
header
|
header
|
||||||
|
|
||||||
contentText
|
contentText
|
||||||
|
|
||||||
if isPaidLocked {
|
if isPaidLocked {
|
||||||
@@ -58,6 +59,8 @@ struct CreatorChannelCommunityListItem: View {
|
|||||||
.padding(SodaSpacing.s14)
|
.padding(SodaSpacing.s14)
|
||||||
.background(Color.gray900)
|
.background(Color.gray900)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
|
.onTapGesture(perform: onTapDetail)
|
||||||
.onChange(of: post.isLiked) { newValue in
|
.onChange(of: post.isLiked) { newValue in
|
||||||
localIsLike = newValue ?? false
|
localIsLike = newValue ?? false
|
||||||
}
|
}
|
||||||
@@ -81,21 +84,23 @@ struct CreatorChannelCommunityListItem: View {
|
|||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack(spacing: SodaSpacing.s8) {
|
HStack(spacing: SodaSpacing.s8) {
|
||||||
DownsampledKFImage(url: URL(string: post.creatorProfileUrl), size: CGSize(width: 42, height: 42))
|
HStack(spacing: SodaSpacing.s8) {
|
||||||
.background(Color.gray800)
|
DownsampledKFImage(url: URL(string: post.creatorProfileUrl), size: CGSize(width: 42, height: 42))
|
||||||
.clipShape(Circle())
|
.background(Color.gray800)
|
||||||
.frame(width: 42, height: 42)
|
.clipShape(Circle())
|
||||||
|
.frame(width: 42, height: 42)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(post.creatorNickname)
|
Text(post.creatorNickname)
|
||||||
.appFont(size: 14, weight: .medium)
|
.appFont(size: 14, weight: .medium)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
Text(post.relativeTimeText())
|
Text(post.relativeTimeText())
|
||||||
.appFont(size: 14, weight: .regular)
|
.appFont(size: 14, weight: .regular)
|
||||||
.foregroundColor(Color.gray500)
|
.foregroundColor(Color.gray500)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
@@ -235,18 +240,15 @@ struct CreatorChannelCommunityListItem: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if post.isCommentAvailable && !isPaidLocked {
|
if post.isCommentAvailable && !isPaidLocked {
|
||||||
Button(action: onTapComment) {
|
HStack(spacing: SodaSpacing.s4) {
|
||||||
HStack(spacing: SodaSpacing.s4) {
|
Image("ic_feed_community_reply")
|
||||||
Image("ic_feed_community_reply")
|
.resizable()
|
||||||
.resizable()
|
.frame(width: 18, height: 18)
|
||||||
.frame(width: 18, height: 18)
|
|
||||||
|
|
||||||
Text("\(post.commentCount)")
|
Text("\(post.commentCount)")
|
||||||
.appFont(size: 16, weight: .regular)
|
.appFont(size: 16, weight: .regular)
|
||||||
.foregroundColor(Color.gray400)
|
.foregroundColor(Color.gray400)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 24)
|
.frame(height: 24)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ struct CreatorChannelCommunityTabView: View {
|
|||||||
let creatorId: Int
|
let creatorId: Int
|
||||||
let isOwnCreatorChannel: Bool
|
let isOwnCreatorChannel: Bool
|
||||||
@ObservedObject var viewModel: CreatorChannelCommunityViewModel
|
@ObservedObject var viewModel: CreatorChannelCommunityViewModel
|
||||||
|
let onTapDetail: (CreatorChannelCommunityPostItem) -> Void
|
||||||
|
|
||||||
private let gridColumns = [
|
private let gridColumns = [
|
||||||
GridItem(.flexible(), spacing: 0),
|
GridItem(.flexible(), spacing: 0),
|
||||||
@@ -65,14 +66,14 @@ struct CreatorChannelCommunityTabView: View {
|
|||||||
onTapLike: {
|
onTapLike: {
|
||||||
viewModel.communityPostLike(postId: post.postId)
|
viewModel.communityPostLike(postId: post.postId)
|
||||||
},
|
},
|
||||||
onTapComment: {
|
|
||||||
viewModel.openCommentList(post: post)
|
|
||||||
},
|
|
||||||
onTapMore: {
|
onTapMore: {
|
||||||
viewModel.openReportMenu(post: post)
|
viewModel.openReportMenu(post: post)
|
||||||
},
|
},
|
||||||
onTapPurchase: {
|
onTapPurchase: {
|
||||||
viewModel.openPurchaseDialog(post: post)
|
viewModel.openPurchaseDialog(post: post)
|
||||||
|
},
|
||||||
|
onTapDetail: {
|
||||||
|
onTapDetail(post)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -96,6 +97,9 @@ struct CreatorChannelCommunityTabView: View {
|
|||||||
isOwnPost: isOwnPost,
|
isOwnPost: isOwnPost,
|
||||||
onTapPurchase: {
|
onTapPurchase: {
|
||||||
viewModel.openPurchaseDialog(post: post)
|
viewModel.openPurchaseDialog(post: post)
|
||||||
|
},
|
||||||
|
onTapDetail: {
|
||||||
|
onTapDetail(post)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CreatorChannelCommunityPostDetailCommentListView: View {
|
||||||
|
let creatorId: Int
|
||||||
|
let postId: Int
|
||||||
|
let commentCount: Int
|
||||||
|
let comments: [CreatorChannelCommunityCommentResponse]
|
||||||
|
let selectedComment: CreatorChannelCommunityCommentResponse?
|
||||||
|
let canEdit: (CreatorChannelCommunityCommentResponse) -> Bool
|
||||||
|
let canDelete: (CreatorChannelCommunityCommentResponse) -> Bool
|
||||||
|
let onAppearComment: (CreatorChannelCommunityCommentResponse) -> Void
|
||||||
|
let onTapReplyDetail: (CreatorChannelCommunityCommentResponse) -> Void
|
||||||
|
let onTapMore: (CreatorChannelCommunityCommentResponse) -> Void
|
||||||
|
let onTapEdit: (CreatorChannelCommunityCommentResponse) -> Void
|
||||||
|
let onTapDelete: (CreatorChannelCommunityCommentResponse) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s16) {
|
||||||
|
Text("댓글 \(commentCount)")
|
||||||
|
.appFont(size: 16, weight: .medium)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
|
||||||
|
LazyVStack(alignment: .leading, spacing: SodaSpacing.s20) {
|
||||||
|
ForEach(comments, id: \.commentId) { comment in
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
CreatorChannelCommunityPostDetailCommentRow(
|
||||||
|
comment: comment,
|
||||||
|
creatorId: creatorId,
|
||||||
|
onTapReplyDetail: { onTapReplyDetail(comment) },
|
||||||
|
onTapMore: { onTapMore(comment) }
|
||||||
|
)
|
||||||
|
|
||||||
|
if selectedComment?.commentId == comment.commentId {
|
||||||
|
CreatorChannelActionPopup(
|
||||||
|
canEdit: canEdit(comment),
|
||||||
|
canDelete: canDelete(comment),
|
||||||
|
onTapEdit: { onTapEdit(comment) },
|
||||||
|
onTapDelete: { onTapDelete(comment) }
|
||||||
|
)
|
||||||
|
.padding(.top, 28)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { onAppearComment(comment) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
}
|
||||||
|
.padding(.vertical, SodaSpacing.s20)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CreatorChannelCommunityPostDetailCommentRow: View {
|
||||||
|
let comment: CreatorChannelCommunityCommentResponse
|
||||||
|
let creatorId: Int
|
||||||
|
let onTapReplyDetail: () -> Void
|
||||||
|
let onTapMore: () -> Void
|
||||||
|
|
||||||
|
private var currentUserId: Int { UserDefaults.int(forKey: .userId) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTapReplyDetail) {
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
|
||||||
|
HStack(alignment: .top, spacing: SodaSpacing.s8) {
|
||||||
|
DownsampledKFImage(url: URL(string: comment.writerProfileImageUrl), size: CGSize(width: 42, height: 42))
|
||||||
|
.background(Color.gray800)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.frame(width: 42, height: 42)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
|
||||||
|
header
|
||||||
|
|
||||||
|
Text(comment.content)
|
||||||
|
.appFont(size: 16, weight: .regular)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let latestReply = comment.latestReply {
|
||||||
|
latestReplyCard(latestReply)
|
||||||
|
.padding(.leading, 50)
|
||||||
|
.background(alignment: .leading) {
|
||||||
|
CreatorChannelCommunityPostDetailReplyConnector()
|
||||||
|
.stroke(Color.gray800, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
|
||||||
|
.frame(width: 42)
|
||||||
|
.padding(.leading, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(spacing: SodaSpacing.s8) {
|
||||||
|
Text(comment.writerNickname)
|
||||||
|
.appFont(size: 14, weight: .medium)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(comment.relativeTimeText())
|
||||||
|
.appFont(size: 14, weight: .regular)
|
||||||
|
.foregroundColor(Color.gray500)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
if comment.isSecret {
|
||||||
|
Text(I18n.Explorer.secretComment)
|
||||||
|
.appFont(size: 11, weight: .medium)
|
||||||
|
.foregroundColor(Color.grayee)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.soda400.opacity(0.2))
|
||||||
|
.cornerRadius(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
if showsMoreButton {
|
||||||
|
Button(action: onTapMore) {
|
||||||
|
Image("ic_new_more")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func latestReplyCard(_ latestReply: CreatorChannelCommunityReplyResponse) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s8) {
|
||||||
|
HStack(spacing: SodaSpacing.s8) {
|
||||||
|
DownsampledKFImage(url: URL(string: latestReply.writerProfileImageUrl), size: CGSize(width: 20, height: 20))
|
||||||
|
.background(Color.gray800)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
|
||||||
|
Text(latestReply.writerNickname)
|
||||||
|
.appFont(size: 13, weight: .medium)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(latestReply.relativeTimeText())
|
||||||
|
.appFont(size: 14, weight: .regular)
|
||||||
|
.foregroundColor(Color.gray500)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(latestReply.content)
|
||||||
|
.appFont(size: 16, weight: .regular)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(SodaSpacing.s12)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.gray900)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var showsMoreButton: Bool {
|
||||||
|
comment.writerId == currentUserId || creatorId == currentUserId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CreatorChannelCommunityPostDetailReplyConnector: Shape {
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
let startX: CGFloat = 0
|
||||||
|
let endX: CGFloat = rect.width
|
||||||
|
let endY = rect.height - 8
|
||||||
|
let bendY = max(0, endY - 14)
|
||||||
|
|
||||||
|
path.move(to: CGPoint(x: startX, y: 0))
|
||||||
|
path.addLine(to: CGPoint(x: startX, y: bendY))
|
||||||
|
path.addQuadCurve(to: CGPoint(x: endX, y: endY), control: CGPoint(x: startX, y: endY))
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
import SDWebImageSwiftUI
|
||||||
|
|
||||||
|
struct CreatorChannelCommunityPostDetailContentView: View {
|
||||||
|
let detail: CreatorChannelCommunityPostDetailResponse
|
||||||
|
let onTapLike: () -> Void
|
||||||
|
let onTapPurchase: () -> Void
|
||||||
|
|
||||||
|
@StateObject private var playManager = CreatorCommunityMediaPlayerManager.shared
|
||||||
|
@StateObject private var contentPlayManager = ContentPlayManager.shared
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s16) {
|
||||||
|
if detail.isPinned {
|
||||||
|
pinnedLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
header
|
||||||
|
|
||||||
|
Text(detail.content)
|
||||||
|
.appFont(size: 16, weight: .regular)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
mediaContent
|
||||||
|
reactionBar
|
||||||
|
}
|
||||||
|
.padding(SodaSpacing.s14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pinnedLabel: some View {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Image("ic_pin")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
|
||||||
|
Text("Notice")
|
||||||
|
.appFont(size: 14, weight: .medium)
|
||||||
|
.foregroundColor(Color(hex: "73FF01"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(spacing: SodaSpacing.s8) {
|
||||||
|
DownsampledKFImage(url: URL(string: detail.creatorProfileUrl), size: CGSize(width: 42, height: 42))
|
||||||
|
.background(Color.gray800)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.frame(width: 42, height: 42)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(detail.creatorNickname)
|
||||||
|
.appFont(size: 14, weight: .medium)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(detail.relativeTimeText())
|
||||||
|
.appFont(size: 14, weight: .regular)
|
||||||
|
.foregroundColor(Color.gray500)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
if detail.price > 0 && detail.existOrdered && isOwnPost == false {
|
||||||
|
purchaseCompleteTag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var mediaContent: some View {
|
||||||
|
if isPaidLocked {
|
||||||
|
Button(action: onTapPurchase) {
|
||||||
|
RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)
|
||||||
|
.fill(Color.gray800)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 236)
|
||||||
|
.overlay(paidLockedOverlay)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
} else if let imageUrl = detail.imageUrl, imageUrl.isEmpty == false {
|
||||||
|
ZStack {
|
||||||
|
WebImage(url: URL(string: imageUrl))
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color.gray800)
|
||||||
|
|
||||||
|
audioPlayButton
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
|
} else if detail.audioUrl?.isEmpty == false {
|
||||||
|
RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)
|
||||||
|
.fill(Color.gray900)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 132)
|
||||||
|
.overlay(audioPlayButton)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var audioPlayButton: some View {
|
||||||
|
if let audioUrl = detail.audioUrl, audioUrl.isEmpty == false {
|
||||||
|
Button {
|
||||||
|
contentPlayManager.pauseAudio()
|
||||||
|
playManager.toggleContent(item: CreatorCommunityContentItem(contentId: detail.postId, url: audioUrl))
|
||||||
|
} label: {
|
||||||
|
Image(playManager.isPlaying && playManager.currentPlayingContentId == detail.postId ? "btn_audio_content_pause" : "btn_audio_content_play")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var paidLockedOverlay: some View {
|
||||||
|
VStack(spacing: SodaSpacing.s4) {
|
||||||
|
Image("ic_new_community_lock")
|
||||||
|
.resizable()
|
||||||
|
.renderingMode(.template)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
|
||||||
|
HStack(spacing: SodaSpacing.s6) {
|
||||||
|
Image("ic_bar_cash")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
|
||||||
|
Text("\(detail.price)")
|
||||||
|
.appFont(size: 16, weight: .medium)
|
||||||
|
.foregroundColor(Color.black)
|
||||||
|
}
|
||||||
|
.padding(SodaSpacing.s8)
|
||||||
|
.background(Color.white)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var purchaseCompleteTag: some View {
|
||||||
|
Text("구매완료")
|
||||||
|
.appFont(size: 12, weight: .medium)
|
||||||
|
.foregroundColor(Color.black)
|
||||||
|
.padding(.horizontal, SodaSpacing.s8)
|
||||||
|
.padding(.vertical, SodaSpacing.s4)
|
||||||
|
.background(Color.soda400)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var reactionBar: some View {
|
||||||
|
HStack(spacing: 15) {
|
||||||
|
if isPaidLocked == false {
|
||||||
|
Button(action: onTapLike) {
|
||||||
|
HStack(spacing: SodaSpacing.s4) {
|
||||||
|
Image(detail.isLiked ? "ic_feed_community_heart_fill" : "ic_feed_community_heart")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
|
||||||
|
Text("\(detail.likeCount)")
|
||||||
|
.appFont(size: 16, weight: .regular)
|
||||||
|
.foregroundColor(Color.gray500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if detail.isCommentAvailable {
|
||||||
|
HStack(spacing: SodaSpacing.s4) {
|
||||||
|
Image("ic_feed_community_reply")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
|
||||||
|
Text("\(detail.commentCount)")
|
||||||
|
.appFont(size: 16, weight: .regular)
|
||||||
|
.foregroundColor(Color.gray500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isOwnPost: Bool {
|
||||||
|
detail.creatorId == UserDefaults.int(forKey: .userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isPaidLocked: Bool {
|
||||||
|
detail.price > 0 && detail.existOrdered == false && isOwnPost == false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CreatorChannelCommunityPostDetailInputBar: View {
|
||||||
|
@Binding var text: String
|
||||||
|
@Binding var isSecret: Bool
|
||||||
|
let isShowSecret: Bool
|
||||||
|
let isSendEnabled: Bool
|
||||||
|
let isEditing: Bool
|
||||||
|
let onTapSend: () -> Void
|
||||||
|
let onTapCancelEditing: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray800)
|
||||||
|
.frame(height: 1)
|
||||||
|
|
||||||
|
if isShowSecret {
|
||||||
|
secretToggle
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .bottom, spacing: SodaSpacing.s8) {
|
||||||
|
if isEditing {
|
||||||
|
Button(action: onTapCancelEditing) {
|
||||||
|
Image("ic_close_white")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField(I18n.CreatorChannelCommunity.commentPlaceholder, text: $text, axis: .vertical)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
.disableAutocorrection(true)
|
||||||
|
.appFont(size: 15, weight: .regular)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.accentColor(Color.soda400)
|
||||||
|
.lineLimit(1...3)
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
.padding(.vertical, SodaSpacing.s8)
|
||||||
|
.frame(minHeight: 40)
|
||||||
|
.background(Color.gray900)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
guard isSendEnabled else { return }
|
||||||
|
onTapSend()
|
||||||
|
}) {
|
||||||
|
Image(isSendEnabled ? "ic_arrow_up_white" : "ic_arrow_up_gray")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(isSendEnabled ? Color.soda400 : Color.gray900)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isSendEnabled == false)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
.padding(.vertical, SodaSpacing.s8)
|
||||||
|
}
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var secretToggle: some View {
|
||||||
|
Button {
|
||||||
|
isSecret.toggle()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: SodaSpacing.s6) {
|
||||||
|
Image(isSecret ? "btn_square_select_checked" : "btn_square_select_normal")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
|
||||||
|
Text(I18n.Explorer.secretComment)
|
||||||
|
.appFont(size: 13, weight: .regular)
|
||||||
|
.foregroundColor(Color.gray500)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
.padding(.top, SodaSpacing.s8)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreatorChannelCommunityPostDetailInputBar_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
Group {
|
||||||
|
PreviewWrapper(
|
||||||
|
text: "Secret comment",
|
||||||
|
isSecret: true,
|
||||||
|
isShowSecret: true,
|
||||||
|
isSendEnabled: true,
|
||||||
|
isEditing: false
|
||||||
|
)
|
||||||
|
.padding(.vertical, SodaSpacing.s16)
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
.previewDisplayName("isShowSecret: true")
|
||||||
|
|
||||||
|
PreviewWrapper(
|
||||||
|
text: "Public comment",
|
||||||
|
isSecret: false,
|
||||||
|
isShowSecret: false,
|
||||||
|
isSendEnabled: true,
|
||||||
|
isEditing: false
|
||||||
|
)
|
||||||
|
.padding(.vertical, SodaSpacing.s16)
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
.previewDisplayName("isShowSecret: false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PreviewWrapper: View {
|
||||||
|
@State private var text: String
|
||||||
|
@State private var isSecret: Bool
|
||||||
|
let isShowSecret: Bool
|
||||||
|
let isSendEnabled: Bool
|
||||||
|
let isEditing: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
text: String,
|
||||||
|
isSecret: Bool,
|
||||||
|
isShowSecret: Bool,
|
||||||
|
isSendEnabled: Bool,
|
||||||
|
isEditing: Bool
|
||||||
|
) {
|
||||||
|
_text = State(initialValue: text)
|
||||||
|
_isSecret = State(initialValue: isSecret)
|
||||||
|
self.isShowSecret = isShowSecret
|
||||||
|
self.isSendEnabled = isSendEnabled
|
||||||
|
self.isEditing = isEditing
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
CreatorChannelCommunityPostDetailInputBar(
|
||||||
|
text: $text,
|
||||||
|
isSecret: $isSecret,
|
||||||
|
isShowSecret: isShowSecret,
|
||||||
|
isSendEnabled: isSendEnabled,
|
||||||
|
isEditing: isEditing,
|
||||||
|
onTapSend: {},
|
||||||
|
onTapCancelEditing: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CreatorChannelCommunityPostDetailView: View {
|
||||||
|
let postId: Int
|
||||||
|
let onCommunityRefresh: (() -> Void)?
|
||||||
|
|
||||||
|
@StateObject private var viewModel = CreatorChannelCommunityPostDetailViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
CreatorChannelReplyDetailTitleBar {
|
||||||
|
AppState.shared.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
if let detail = viewModel.detail {
|
||||||
|
CreatorChannelCommunityPostDetailContentView(
|
||||||
|
detail: detail,
|
||||||
|
onTapLike: viewModel.toggleLike,
|
||||||
|
onTapPurchase: showPurchaseError
|
||||||
|
)
|
||||||
|
|
||||||
|
if detail.isCommentAvailable {
|
||||||
|
CreatorChannelCommunityPostDetailCommentListView(
|
||||||
|
creatorId: detail.creatorId,
|
||||||
|
postId: detail.postId,
|
||||||
|
commentCount: detail.commentCount,
|
||||||
|
comments: viewModel.comments,
|
||||||
|
selectedComment: viewModel.selectedComment,
|
||||||
|
canEdit: viewModel.canEdit,
|
||||||
|
canDelete: viewModel.canDelete,
|
||||||
|
onAppearComment: viewModel.fetchNextCommentsIfNeeded,
|
||||||
|
onTapReplyDetail: showReplyDetail,
|
||||||
|
onTapMore: viewModel.selectComment,
|
||||||
|
onTapEdit: viewModel.beginEditing,
|
||||||
|
onTapDelete: viewModel.presentDeleteDialog
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: SodaSpacing.s20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.black.ignoresSafeArea())
|
||||||
|
|
||||||
|
if viewModel.isLoading && viewModel.hasLoaded == false {
|
||||||
|
LoadingView()
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.isShowDeleteDialog {
|
||||||
|
SodaV2ActionModal(
|
||||||
|
title: I18n.Common.commentDeleteTitle,
|
||||||
|
message: I18n.Common.confirmDeleteQuestion,
|
||||||
|
button1: SodaV2ActionModalButton(
|
||||||
|
label: I18n.Common.delete,
|
||||||
|
action: viewModel.confirmDelete
|
||||||
|
),
|
||||||
|
button2: SodaV2ActionModalButton(
|
||||||
|
label: I18n.Common.cancel,
|
||||||
|
action: dismissDeleteDialog
|
||||||
|
),
|
||||||
|
onDimmedTap: dismissDeleteDialog
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarBackButtonHidden(true)
|
||||||
|
.onAppear {
|
||||||
|
viewModel.configure(postId: postId, onCommunityRefresh: onCommunityRefresh)
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
if viewModel.detail?.isCommentAvailable == true {
|
||||||
|
CreatorChannelCommunityPostDetailInputBar(
|
||||||
|
text: $viewModel.commentText,
|
||||||
|
isSecret: $viewModel.isSecret,
|
||||||
|
isShowSecret: viewModel.isShowSecret,
|
||||||
|
isSendEnabled: viewModel.isCommentSendEnabled,
|
||||||
|
isEditing: viewModel.isEditing,
|
||||||
|
onTapSend: viewModel.sendComment,
|
||||||
|
onTapCancelEditing: viewModel.cancelEditing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissDeleteDialog() {
|
||||||
|
viewModel.isShowDeleteDialog = false
|
||||||
|
viewModel.deletingComment = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showReplyDetail(_ comment: CreatorChannelCommunityCommentResponse) {
|
||||||
|
guard let detail = viewModel.detail else { return }
|
||||||
|
AppState.shared.setAppStep(
|
||||||
|
step: .creatorChannelReplyDetail(
|
||||||
|
context: .communityPostDetail(
|
||||||
|
creatorId: detail.creatorId,
|
||||||
|
postId: detail.postId,
|
||||||
|
parentComment: comment
|
||||||
|
),
|
||||||
|
onCommunityRefresh: {
|
||||||
|
viewModel.fetchDetail(postId: postId)
|
||||||
|
onCommunityRefresh?()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showPurchaseError() {
|
||||||
|
viewModel.errorMessage = I18n.Common.commonError
|
||||||
|
viewModel.isShowPopup = true
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
import Moya
|
||||||
|
|
||||||
|
final class CreatorChannelCommunityPostDetailViewModel: ObservableObject {
|
||||||
|
private let repository = CreatorChannelCommunityRepository()
|
||||||
|
private let communityRepository = CreatorCommunityRepository()
|
||||||
|
private var subscription = Set<AnyCancellable>()
|
||||||
|
private var postId = 0
|
||||||
|
private var onCommunityRefresh: (() -> Void)?
|
||||||
|
private var latestRequestId = 0
|
||||||
|
|
||||||
|
@Published var detail: CreatorChannelCommunityPostDetailResponse?
|
||||||
|
@Published var comments = [CreatorChannelCommunityCommentResponse]()
|
||||||
|
@Published var commentText = ""
|
||||||
|
@Published var isSecret = false
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var isLoadingNextPage = false
|
||||||
|
@Published var hasLoaded = false
|
||||||
|
@Published var errorMessage = ""
|
||||||
|
@Published var isShowPopup = false
|
||||||
|
@Published var selectedComment: CreatorChannelCommunityCommentResponse?
|
||||||
|
@Published var editingComment: CreatorChannelCommunityCommentResponse?
|
||||||
|
@Published var deletingComment: CreatorChannelCommunityCommentResponse?
|
||||||
|
@Published var isShowDeleteDialog = false
|
||||||
|
@Published var page = 0
|
||||||
|
@Published var size = 20
|
||||||
|
@Published var hasNext = false
|
||||||
|
|
||||||
|
var isOwnPost: Bool {
|
||||||
|
detail?.creatorId == UserDefaults.int(forKey: .userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isShowSecret: Bool {
|
||||||
|
guard let detail else { return false }
|
||||||
|
return detail.price > 0 && detail.existOrdered && detail.creatorId != UserDefaults.int(forKey: .userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isCommentSendEnabled: Bool {
|
||||||
|
guard detail?.isCommentAvailable == true else { return false }
|
||||||
|
return commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false && isLoading == false
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEditing: Bool {
|
||||||
|
editingComment != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(postId: Int, onCommunityRefresh: (() -> Void)?) {
|
||||||
|
self.onCommunityRefresh = onCommunityRefresh
|
||||||
|
guard self.postId != postId || hasLoaded == false else { return }
|
||||||
|
|
||||||
|
self.postId = postId
|
||||||
|
fetchDetail(postId: postId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchDetail(postId: Int) {
|
||||||
|
self.postId = postId
|
||||||
|
latestRequestId += 1
|
||||||
|
let requestId = latestRequestId
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
repository.getCommunityPostDetail(postId: postId)
|
||||||
|
.sink { [weak self] result in
|
||||||
|
guard let self else { return }
|
||||||
|
guard requestId == self.latestRequestId else { return }
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .finished:
|
||||||
|
DEBUG_LOG("finish")
|
||||||
|
self.isLoading = false
|
||||||
|
case .failure(let error):
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.applyFailureState()
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
guard let self else { return }
|
||||||
|
guard requestId == self.latestRequestId else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder().decode(ApiResponse<CreatorChannelCommunityPostDetailResponse>.self, from: response.data)
|
||||||
|
if let data = decoded.data, decoded.success {
|
||||||
|
self.applyDetail(data)
|
||||||
|
} else {
|
||||||
|
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||||
|
self.isShowPopup = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
print(error)
|
||||||
|
self.errorMessage = I18n.Common.commonError
|
||||||
|
self.isShowPopup = true
|
||||||
|
}
|
||||||
|
|
||||||
|
self.isLoading = false
|
||||||
|
self.hasLoaded = true
|
||||||
|
}
|
||||||
|
.store(in: &subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchNextCommentsIfNeeded(currentComment: CreatorChannelCommunityCommentResponse) {
|
||||||
|
guard comments.last?.commentId == currentComment.commentId else { return }
|
||||||
|
guard detail?.isCommentAvailable == true else { return }
|
||||||
|
guard hasNext, isLoadingNextPage == false, isLoading == false else { return }
|
||||||
|
guard postId > 0 else { return }
|
||||||
|
|
||||||
|
let requestPage = page + 1
|
||||||
|
isLoadingNextPage = true
|
||||||
|
|
||||||
|
repository.getCommunityPostComments(postId: postId, page: requestPage, size: size)
|
||||||
|
.sink { [weak self] result in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .finished:
|
||||||
|
DEBUG_LOG("finish")
|
||||||
|
self.isLoadingNextPage = false
|
||||||
|
case .failure(let error):
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.applyFailureState()
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder().decode(ApiResponse<CreatorChannelCommunityCommentsResponse>.self, from: response.data)
|
||||||
|
if let data = decoded.data, decoded.success {
|
||||||
|
self.page = data.page
|
||||||
|
self.size = data.size
|
||||||
|
self.hasNext = data.hasNext
|
||||||
|
self.comments.append(contentsOf: data.comments)
|
||||||
|
} else {
|
||||||
|
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||||
|
self.isShowPopup = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.errorMessage = I18n.Common.commonError
|
||||||
|
self.isShowPopup = true
|
||||||
|
}
|
||||||
|
|
||||||
|
self.isLoadingNextPage = false
|
||||||
|
}
|
||||||
|
.store(in: &subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleLike() {
|
||||||
|
guard let detail, isLoading == false else { return }
|
||||||
|
|
||||||
|
let previousDetail = detail
|
||||||
|
let nextIsLiked = !detail.isLiked
|
||||||
|
self.detail = detail.updatingLike(isLiked: nextIsLiked)
|
||||||
|
|
||||||
|
communityRepository.communityPostLike(postId: detail.postId)
|
||||||
|
.sink { [weak self] result in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
if case .failure(let error) = result {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.detail = previousDetail
|
||||||
|
self.errorMessage = I18n.Common.commonError
|
||||||
|
self.isShowPopup = true
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
|
||||||
|
if decoded.success == false {
|
||||||
|
self.detail = previousDetail
|
||||||
|
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||||
|
self.isShowPopup = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.detail = previousDetail
|
||||||
|
self.errorMessage = I18n.Common.commonError
|
||||||
|
self.isShowPopup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendComment() {
|
||||||
|
guard isCommentSendEnabled else { return }
|
||||||
|
|
||||||
|
if let editingComment {
|
||||||
|
modify(comment: editingComment)
|
||||||
|
} else {
|
||||||
|
createComment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canEdit(_ comment: CreatorChannelCommunityCommentResponse) -> Bool {
|
||||||
|
comment.writerId == UserDefaults.int(forKey: .userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canDelete(_ comment: CreatorChannelCommunityCommentResponse) -> Bool {
|
||||||
|
canEdit(comment) || isOwnPost
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectComment(_ comment: CreatorChannelCommunityCommentResponse) {
|
||||||
|
selectedComment = selectedComment?.commentId == comment.commentId ? nil : comment
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSelectedComment() {
|
||||||
|
selectedComment = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginEditing(_ comment: CreatorChannelCommunityCommentResponse) {
|
||||||
|
guard canEdit(comment) else { return }
|
||||||
|
selectedComment = nil
|
||||||
|
editingComment = comment
|
||||||
|
commentText = comment.content
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelEditing() {
|
||||||
|
editingComment = nil
|
||||||
|
commentText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentDeleteDialog(_ comment: CreatorChannelCommunityCommentResponse) {
|
||||||
|
guard canDelete(comment) else { return }
|
||||||
|
selectedComment = nil
|
||||||
|
deletingComment = comment
|
||||||
|
isShowDeleteDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmDelete() {
|
||||||
|
guard let deletingComment, isLoading == false else { return }
|
||||||
|
isShowDeleteDialog = false
|
||||||
|
delete(comment: deletingComment)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyDetail(_ data: CreatorChannelCommunityPostDetailResponse) {
|
||||||
|
detail = data
|
||||||
|
page = data.comments.page
|
||||||
|
size = data.comments.size
|
||||||
|
hasNext = data.comments.hasNext
|
||||||
|
hasLoaded = true
|
||||||
|
|
||||||
|
if data.isCommentAvailable {
|
||||||
|
comments = data.comments.comments
|
||||||
|
} else {
|
||||||
|
comments = []
|
||||||
|
commentText = ""
|
||||||
|
isSecret = false
|
||||||
|
selectedComment = nil
|
||||||
|
editingComment = nil
|
||||||
|
deletingComment = nil
|
||||||
|
isShowDeleteDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createComment() {
|
||||||
|
guard postId > 0 else { return }
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
communityRepository.createCommunityPostComment(
|
||||||
|
comment: commentText.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
postId: postId,
|
||||||
|
parentId: nil,
|
||||||
|
isSecret: isShowSecret ? isSecret : false
|
||||||
|
)
|
||||||
|
.sink { [weak self] result in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
if case .failure(let error) = result {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.applyFailureState()
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
self?.handleCommentMutationResponse(response)
|
||||||
|
}
|
||||||
|
.store(in: &subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func modify(comment: CreatorChannelCommunityCommentResponse) {
|
||||||
|
let content = commentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard content.isEmpty == false, content != comment.content else {
|
||||||
|
errorMessage = I18n.Explorer.noChanges
|
||||||
|
isShowPopup = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
var request = ModifyCommunityPostCommentRequest(commentId: comment.commentId)
|
||||||
|
request.comment = content
|
||||||
|
communityRepository.modifyComment(request: request)
|
||||||
|
.sink { [weak self] result in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
if case .failure(let error) = result {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.applyFailureState()
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
self?.handleCommentMutationResponse(response)
|
||||||
|
}
|
||||||
|
.store(in: &subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func delete(comment: CreatorChannelCommunityCommentResponse) {
|
||||||
|
isLoading = true
|
||||||
|
var request = ModifyCommunityPostCommentRequest(commentId: comment.commentId)
|
||||||
|
request.isActive = false
|
||||||
|
communityRepository.modifyComment(request: request)
|
||||||
|
.sink { [weak self] result in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
if case .failure(let error) = result {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.applyFailureState()
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
self?.handleCommentMutationResponse(response)
|
||||||
|
}
|
||||||
|
.store(in: &subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleCommentMutationResponse(_ response: Moya.Response) {
|
||||||
|
isLoading = false
|
||||||
|
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
|
||||||
|
if decoded.success {
|
||||||
|
applyCommentMutationSuccessState()
|
||||||
|
onCommunityRefresh?()
|
||||||
|
fetchDetail(postId: postId)
|
||||||
|
} else {
|
||||||
|
errorMessage = decoded.message ?? I18n.Common.commonError
|
||||||
|
isShowPopup = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
errorMessage = I18n.Common.commonError
|
||||||
|
isShowPopup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyCommentMutationSuccessState() {
|
||||||
|
commentText = ""
|
||||||
|
isSecret = false
|
||||||
|
selectedComment = nil
|
||||||
|
editingComment = nil
|
||||||
|
deletingComment = nil
|
||||||
|
isShowDeleteDialog = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyFailureState() {
|
||||||
|
isLoading = false
|
||||||
|
isLoadingNextPage = false
|
||||||
|
hasLoaded = true
|
||||||
|
errorMessage = I18n.Common.commonError
|
||||||
|
isShowPopup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension CreatorChannelCommunityPostDetailResponse {
|
||||||
|
func updatingLike(isLiked: Bool) -> CreatorChannelCommunityPostDetailResponse {
|
||||||
|
let nextLikeCount = max(0, likeCount + (isLiked ? 1 : -1))
|
||||||
|
|
||||||
|
return CreatorChannelCommunityPostDetailResponse(
|
||||||
|
postId: postId,
|
||||||
|
creatorId: creatorId,
|
||||||
|
creatorNickname: creatorNickname,
|
||||||
|
creatorProfileUrl: creatorProfileUrl,
|
||||||
|
createdAtUtc: createdAtUtc,
|
||||||
|
content: content,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
audioUrl: audioUrl,
|
||||||
|
price: price,
|
||||||
|
isCommentAvailable: isCommentAvailable,
|
||||||
|
existOrdered: existOrdered,
|
||||||
|
likeCount: nextLikeCount,
|
||||||
|
commentCount: commentCount,
|
||||||
|
isPinned: isPinned,
|
||||||
|
isLiked: isLiked,
|
||||||
|
comments: comments
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct CreatorChannelCommunityPostDetailResponse: Decodable, Identifiable {
|
||||||
|
let postId: Int
|
||||||
|
let creatorId: Int
|
||||||
|
let creatorNickname: String
|
||||||
|
let creatorProfileUrl: String
|
||||||
|
let createdAtUtc: String
|
||||||
|
let content: String
|
||||||
|
let imageUrl: String?
|
||||||
|
let audioUrl: String?
|
||||||
|
let price: Int
|
||||||
|
let isCommentAvailable: Bool
|
||||||
|
let existOrdered: Bool
|
||||||
|
let likeCount: Int
|
||||||
|
let commentCount: Int
|
||||||
|
let isPinned: Bool
|
||||||
|
let isLiked: Bool
|
||||||
|
let comments: CreatorChannelCommunityCommentsResponse
|
||||||
|
|
||||||
|
var id: Int { postId }
|
||||||
|
|
||||||
|
func relativeTimeText(now: Date = Date()) -> String {
|
||||||
|
DateParser.relativeTimeText(fromUTC: createdAtUtc, fallback: createdAtUtc, now: now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreatorChannelCommunityCommentsResponse: Decodable {
|
||||||
|
let commentCount: Int
|
||||||
|
let comments: [CreatorChannelCommunityCommentResponse]
|
||||||
|
let page: Int
|
||||||
|
let size: Int
|
||||||
|
let hasNext: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreatorChannelCommunityCommentResponse: Decodable {
|
||||||
|
let commentId: Int
|
||||||
|
let writerId: Int
|
||||||
|
let writerProfileImageUrl: String
|
||||||
|
let writerNickname: String
|
||||||
|
let content: String
|
||||||
|
let createdAtUtc: String
|
||||||
|
let isSecret: Bool
|
||||||
|
let latestReply: CreatorChannelCommunityReplyResponse?
|
||||||
|
|
||||||
|
func relativeTimeText(now: Date = Date()) -> String {
|
||||||
|
DateParser.relativeTimeText(fromUTC: createdAtUtc, fallback: createdAtUtc, now: now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreatorChannelCommunityReplyResponse: Decodable {
|
||||||
|
let commentId: Int
|
||||||
|
let writerId: Int
|
||||||
|
let writerProfileImageUrl: String
|
||||||
|
let writerNickname: String
|
||||||
|
let content: String
|
||||||
|
let createdAtUtc: String
|
||||||
|
|
||||||
|
func relativeTimeText(now: Date = Date()) -> String {
|
||||||
|
DateParser.relativeTimeText(fromUTC: createdAtUtc, fallback: createdAtUtc, now: now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CreatorChannelCommunityRepliesResponse: Decodable {
|
||||||
|
let replyCount: Int
|
||||||
|
let replies: [CreatorChannelCommunityReplyResponse]
|
||||||
|
let page: Int
|
||||||
|
let size: Int
|
||||||
|
let hasNext: Bool
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import Moya
|
|||||||
|
|
||||||
enum CreatorChannelCommunityApi {
|
enum CreatorChannelCommunityApi {
|
||||||
case getCommunity(creatorId: Int, page: Int, size: Int)
|
case getCommunity(creatorId: Int, page: Int, size: Int)
|
||||||
|
case getCommunityPostDetail(postId: Int)
|
||||||
|
case getCommunityPostComments(postId: Int, page: Int, size: Int)
|
||||||
|
case getCommunityCommentReplies(commentId: Int, page: Int, size: Int)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CreatorChannelCommunityApi: TargetType {
|
extension CreatorChannelCommunityApi: TargetType {
|
||||||
@@ -15,12 +18,21 @@ extension CreatorChannelCommunityApi: TargetType {
|
|||||||
switch self {
|
switch self {
|
||||||
case .getCommunity(let creatorId, _, _):
|
case .getCommunity(let creatorId, _, _):
|
||||||
return "/api/v2/creator-channels/\(creatorId)/community"
|
return "/api/v2/creator-channels/\(creatorId)/community"
|
||||||
|
case .getCommunityPostDetail(let postId):
|
||||||
|
return "/api/v2/creator-channels/community-posts/\(postId)"
|
||||||
|
case .getCommunityPostComments(let postId, _, _):
|
||||||
|
return "/api/v2/creator-channels/community-posts/\(postId)/comments"
|
||||||
|
case .getCommunityCommentReplies(let commentId, _, _):
|
||||||
|
return "/api/v2/creator-channels/community-comments/\(commentId)/replies"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var method: Moya.Method {
|
var method: Moya.Method {
|
||||||
switch self {
|
switch self {
|
||||||
case .getCommunity:
|
case .getCommunity,
|
||||||
|
.getCommunityPostDetail,
|
||||||
|
.getCommunityPostComments,
|
||||||
|
.getCommunityCommentReplies:
|
||||||
return .get
|
return .get
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,6 +44,14 @@ extension CreatorChannelCommunityApi: TargetType {
|
|||||||
parameters: ["page": page, "size": size],
|
parameters: ["page": page, "size": size],
|
||||||
encoding: URLEncoding.queryString
|
encoding: URLEncoding.queryString
|
||||||
)
|
)
|
||||||
|
case .getCommunityPostDetail:
|
||||||
|
return .requestPlain
|
||||||
|
case .getCommunityPostComments(_, let page, let size),
|
||||||
|
.getCommunityCommentReplies(_, let page, let size):
|
||||||
|
return .requestParameters(
|
||||||
|
parameters: ["page": page, "size": size],
|
||||||
|
encoding: URLEncoding.queryString
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,4 +10,16 @@ final class CreatorChannelCommunityRepository {
|
|||||||
func getCommunity(creatorId: Int, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
|
func getCommunity(creatorId: Int, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
|
||||||
return api.requestPublisher(.getCommunity(creatorId: creatorId, page: page, size: size))
|
return api.requestPublisher(.getCommunity(creatorId: creatorId, page: page, size: size))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getCommunityPostDetail(postId: Int) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
return api.requestPublisher(.getCommunityPostDetail(postId: postId))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCommunityPostComments(postId: Int, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
return api.requestPublisher(.getCommunityPostComments(postId: postId, page: page, size: size))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCommunityCommentReplies(commentId: Int, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
return api.requestPublisher(.getCommunityCommentReplies(commentId: commentId, page: page, size: size))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,7 +303,8 @@ struct CreatorChannelView: View {
|
|||||||
CreatorChannelCommunityTabView(
|
CreatorChannelCommunityTabView(
|
||||||
creatorId: creatorId,
|
creatorId: creatorId,
|
||||||
isOwnCreatorChannel: isOwnCreatorChannel,
|
isOwnCreatorChannel: isOwnCreatorChannel,
|
||||||
viewModel: communityViewModel
|
viewModel: communityViewModel,
|
||||||
|
onTapDetail: showCommunityPostDetail
|
||||||
)
|
)
|
||||||
} else if viewModel.selectedTab != .home {
|
} else if viewModel.selectedTab != .home {
|
||||||
CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title)
|
CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title)
|
||||||
@@ -457,6 +458,17 @@ struct CreatorChannelView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func showCommunityPostDetail(_ post: CreatorChannelCommunityPostItem) {
|
||||||
|
AppState.shared.setAppStep(
|
||||||
|
step: .creatorChannelCommunityPostDetail(
|
||||||
|
postId: post.postId,
|
||||||
|
onCommunityRefresh: {
|
||||||
|
communityViewModel.fetchFirstPage(creatorId: creatorId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func showAudioContentUpload() {
|
private func showAudioContentUpload() {
|
||||||
isCreatorActionMenuPresented = false
|
isCreatorActionMenuPresented = false
|
||||||
AppState.shared.setAppStep(
|
AppState.shared.setAppStep(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
struct CreatorChannelReplyDetailView: View {
|
struct CreatorChannelReplyDetailView: View {
|
||||||
let context: CreatorChannelReplyDetailContext
|
let context: CreatorChannelReplyDetailContext
|
||||||
let onFanTalkRefresh: (() -> Void)?
|
let onFanTalkRefresh: (() -> Void)?
|
||||||
|
let onCommunityRefresh: (() -> Void)?
|
||||||
|
|
||||||
@StateObject private var viewModel = CreatorChannelReplyDetailViewModel()
|
@StateObject private var viewModel = CreatorChannelReplyDetailViewModel()
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ struct CreatorChannelReplyDetailView: View {
|
|||||||
}
|
}
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.configure(context: context, onFanTalkRefresh: onFanTalkRefresh)
|
viewModel.configure(context: context, onFanTalkRefresh: onFanTalkRefresh, onCommunityRefresh: onCommunityRefresh)
|
||||||
}
|
}
|
||||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1)
|
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import Moya
|
|||||||
|
|
||||||
final class CreatorChannelReplyDetailViewModel: ObservableObject {
|
final class CreatorChannelReplyDetailViewModel: ObservableObject {
|
||||||
private let communityRepository = CreatorCommunityRepository()
|
private let communityRepository = CreatorCommunityRepository()
|
||||||
|
private let v2CommunityRepository = CreatorChannelCommunityRepository()
|
||||||
private let explorerRepository = ExplorerRepository()
|
private let explorerRepository = ExplorerRepository()
|
||||||
private let fanTalkRepository = CreatorChannelFanTalkRepository()
|
private let fanTalkRepository = CreatorChannelFanTalkRepository()
|
||||||
private var subscription = Set<AnyCancellable>()
|
private var subscription = Set<AnyCancellable>()
|
||||||
private var context: CreatorChannelReplyDetailContext?
|
private var context: CreatorChannelReplyDetailContext?
|
||||||
private var onFanTalkRefresh: (() -> Void)?
|
private var onFanTalkRefresh: (() -> Void)?
|
||||||
private var communityReplyPage = 1
|
private var onCommunityRefresh: (() -> Void)?
|
||||||
|
private var communityReplyPage = 0
|
||||||
private let communityReplyPageSize = 20
|
private let communityReplyPageSize = 20
|
||||||
private var communityReplyTotalCount = 0
|
private var communityReplyTotalCount = 0
|
||||||
private var isCommunityReplyLast = false
|
private var isCommunityReplyLast = false
|
||||||
@@ -35,16 +37,20 @@ final class CreatorChannelReplyDetailViewModel: ObservableObject {
|
|||||||
editingItem != nil
|
editingItem != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func configure(context: CreatorChannelReplyDetailContext, onFanTalkRefresh: (() -> Void)?) {
|
func configure(context: CreatorChannelReplyDetailContext, onFanTalkRefresh: (() -> Void)?, onCommunityRefresh: (() -> Void)? = nil) {
|
||||||
guard self.context == nil else { return }
|
guard self.context == nil else { return }
|
||||||
|
|
||||||
self.context = context
|
self.context = context
|
||||||
self.onFanTalkRefresh = onFanTalkRefresh
|
self.onFanTalkRefresh = onFanTalkRefresh
|
||||||
|
self.onCommunityRefresh = onCommunityRefresh
|
||||||
|
|
||||||
switch context {
|
switch context {
|
||||||
case .community(_, _, let parentComment):
|
case .community(_, _, let parentComment):
|
||||||
parentItem = CreatorChannelReplyDetailDisplayItem(communityComment: parentComment)
|
parentItem = CreatorChannelReplyDetailDisplayItem(communityComment: parentComment)
|
||||||
fetchCommunityReplies(reset: true)
|
fetchCommunityReplies(reset: true)
|
||||||
|
case .communityPostDetail(let creatorId, _, let parentComment):
|
||||||
|
parentItem = CreatorChannelReplyDetailDisplayItem(communityPostDetail: parentComment, creatorId: creatorId)
|
||||||
|
fetchCommunityReplies(reset: true)
|
||||||
case .fanTalk(let creatorId, let parentFanTalk):
|
case .fanTalk(let creatorId, let parentFanTalk):
|
||||||
parentItem = CreatorChannelReplyDetailDisplayItem(fanTalk: parentFanTalk)
|
parentItem = CreatorChannelReplyDetailDisplayItem(fanTalk: parentFanTalk)
|
||||||
replies = parentFanTalk.creatorReplies.map {
|
replies = parentFanTalk.creatorReplies.map {
|
||||||
@@ -70,6 +76,9 @@ final class CreatorChannelReplyDetailViewModel: ObservableObject {
|
|||||||
if case .community(let creatorId, _, _) = context {
|
if case .community(let creatorId, _, _) = context {
|
||||||
return creatorId == UserDefaults.int(forKey: .userId)
|
return creatorId == UserDefaults.int(forKey: .userId)
|
||||||
}
|
}
|
||||||
|
if case .communityPostDetail(let creatorId, _, _) = context {
|
||||||
|
return creatorId == UserDefaults.int(forKey: .userId)
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
case .fanTalk:
|
case .fanTalk:
|
||||||
guard item.id > 0 else { return false }
|
guard item.id > 0 else { return false }
|
||||||
@@ -127,11 +136,11 @@ final class CreatorChannelReplyDetailViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fetchCommunityReplies(reset: Bool = false) {
|
func fetchCommunityReplies(reset: Bool = false) {
|
||||||
guard case .community(_, _, let parentComment) = context else { return }
|
guard isCommunityContext else { return }
|
||||||
guard isLoading == false, isLoadingNextPage == false else { return }
|
guard isLoading == false, isLoadingNextPage == false else { return }
|
||||||
|
|
||||||
if reset {
|
if reset {
|
||||||
communityReplyPage = 1
|
communityReplyPage = isCommunityPostDetailContext ? 0 : 1
|
||||||
communityReplyTotalCount = 0
|
communityReplyTotalCount = 0
|
||||||
isCommunityReplyLast = false
|
isCommunityReplyLast = false
|
||||||
} else if isCommunityReplyLast {
|
} else if isCommunityReplyLast {
|
||||||
@@ -145,7 +154,8 @@ final class CreatorChannelReplyDetailViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let requestPage = communityReplyPage
|
let requestPage = communityReplyPage
|
||||||
communityRepository.getCommentReplyList(commentId: parentComment.id, page: requestPage, size: communityReplyPageSize)
|
let publisher = communityRepliesPublisher(page: requestPage)
|
||||||
|
publisher
|
||||||
.sink { [weak self] result in
|
.sink { [weak self] result in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
if case .failure(let error) = result {
|
if case .failure(let error) = result {
|
||||||
@@ -157,27 +167,7 @@ final class CreatorChannelReplyDetailViewModel: ObservableObject {
|
|||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
self.isLoadingNextPage = false
|
self.isLoadingNextPage = false
|
||||||
|
|
||||||
do {
|
self.handleCommunityRepliesResponse(response, requestPage: requestPage, reset: reset)
|
||||||
let decoded = try JSONDecoder().decode(ApiResponse<GetCommunityPostCommentListResponse>.self, from: response.data)
|
|
||||||
if let data = decoded.data, decoded.success {
|
|
||||||
let newReplies = data.items.map { CreatorChannelReplyDetailDisplayItem(communityComment: $0) }
|
|
||||||
if reset || requestPage == 1 {
|
|
||||||
self.replies = newReplies
|
|
||||||
} else {
|
|
||||||
self.replies.append(contentsOf: newReplies)
|
|
||||||
}
|
|
||||||
self.communityReplyTotalCount = data.totalCount
|
|
||||||
self.communityReplyPage = requestPage + 1
|
|
||||||
self.isCommunityReplyLast = self.replies.count >= data.totalCount || newReplies.isEmpty
|
|
||||||
} else {
|
|
||||||
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
|
||||||
self.isShowPopup = true
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
ERROR_LOG(error.localizedDescription)
|
|
||||||
self.errorMessage = I18n.Common.commonError
|
|
||||||
self.isShowPopup = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.store(in: &subscription)
|
.store(in: &subscription)
|
||||||
}
|
}
|
||||||
@@ -199,6 +189,24 @@ final class CreatorChannelReplyDetailViewModel: ObservableObject {
|
|||||||
parentId: parentComment.id,
|
parentId: parentComment.id,
|
||||||
isSecret: parentComment.isSecret
|
isSecret: parentComment.isSecret
|
||||||
)
|
)
|
||||||
|
.sink { [weak self] result in
|
||||||
|
guard let self else { return }
|
||||||
|
if case .failure(let error) = result {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.applyFailureState()
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
self?.handleCommunityMutationResponse(response)
|
||||||
|
}
|
||||||
|
.store(in: &subscription)
|
||||||
|
case .communityPostDetail(_, let postId, let parentComment):
|
||||||
|
isLoading = true
|
||||||
|
communityRepository.createCommunityPostComment(
|
||||||
|
comment: content,
|
||||||
|
postId: postId,
|
||||||
|
parentId: parentComment.commentId,
|
||||||
|
isSecret: false
|
||||||
|
)
|
||||||
.sink { [weak self] result in
|
.sink { [weak self] result in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
if case .failure(let error) = result {
|
if case .failure(let error) = result {
|
||||||
@@ -350,6 +358,7 @@ final class CreatorChannelReplyDetailViewModel: ObservableObject {
|
|||||||
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
|
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
|
||||||
if decoded.success {
|
if decoded.success {
|
||||||
applyLocalSuccessState()
|
applyLocalSuccessState()
|
||||||
|
onCommunityRefresh?()
|
||||||
fetchCommunityReplies(reset: true)
|
fetchCommunityReplies(reset: true)
|
||||||
} else {
|
} else {
|
||||||
errorMessage = decoded.message ?? I18n.Common.commonError
|
errorMessage = decoded.message ?? I18n.Common.commonError
|
||||||
@@ -410,7 +419,93 @@ final class CreatorChannelReplyDetailViewModel: ObservableObject {
|
|||||||
|
|
||||||
private func applyFailureState() {
|
private func applyFailureState() {
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
isLoadingNextPage = false
|
||||||
errorMessage = I18n.Common.commonError
|
errorMessage = I18n.Common.commonError
|
||||||
isShowPopup = true
|
isShowPopup = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isCommunityContext: Bool {
|
||||||
|
if case .community = context { return true }
|
||||||
|
if case .communityPostDetail = context { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isCommunityPostDetailContext: Bool {
|
||||||
|
if case .communityPostDetail = context { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func communityRepliesPublisher(page: Int) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
switch context {
|
||||||
|
case .community(_, _, let parentComment):
|
||||||
|
return communityRepository.getCommentReplyList(commentId: parentComment.id, page: page, size: communityReplyPageSize)
|
||||||
|
case .communityPostDetail(_, _, let parentComment):
|
||||||
|
return v2CommunityRepository.getCommunityCommentReplies(commentId: parentComment.commentId, page: page, size: communityReplyPageSize)
|
||||||
|
default:
|
||||||
|
return Fail(error: MoyaError.underlying(NSError(domain: "CreatorChannelReplyDetail", code: -1), nil)).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleCommunityRepliesResponse(_ response: Moya.Response, requestPage: Int, reset: Bool) {
|
||||||
|
if isCommunityPostDetailContext {
|
||||||
|
handleCommunityPostDetailRepliesResponse(response, requestPage: requestPage, reset: reset)
|
||||||
|
} else {
|
||||||
|
handleLegacyCommunityRepliesResponse(response, requestPage: requestPage, reset: reset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleLegacyCommunityRepliesResponse(_ response: Moya.Response, requestPage: Int, reset: Bool) {
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder().decode(ApiResponse<GetCommunityPostCommentListResponse>.self, from: response.data)
|
||||||
|
if let data = decoded.data, decoded.success {
|
||||||
|
let newReplies = data.items.map { CreatorChannelReplyDetailDisplayItem(communityComment: $0) }
|
||||||
|
applyCommunityReplies(newReplies, requestPage: requestPage, reset: reset, totalCount: data.totalCount, hasNext: nil)
|
||||||
|
} else {
|
||||||
|
errorMessage = decoded.message ?? I18n.Common.commonError
|
||||||
|
isShowPopup = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
errorMessage = I18n.Common.commonError
|
||||||
|
isShowPopup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleCommunityPostDetailRepliesResponse(_ response: Moya.Response, requestPage: Int, reset: Bool) {
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder().decode(ApiResponse<CreatorChannelCommunityRepliesResponse>.self, from: response.data)
|
||||||
|
if let data = decoded.data, decoded.success {
|
||||||
|
let creatorId = contextCreatorId
|
||||||
|
let newReplies = data.replies.map { CreatorChannelReplyDetailDisplayItem(communityPostDetailReply: $0, creatorId: creatorId) }
|
||||||
|
applyCommunityReplies(newReplies, requestPage: requestPage, reset: reset, totalCount: data.replyCount, hasNext: data.hasNext)
|
||||||
|
} else {
|
||||||
|
errorMessage = decoded.message ?? I18n.Common.commonError
|
||||||
|
isShowPopup = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
errorMessage = I18n.Common.commonError
|
||||||
|
isShowPopup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var contextCreatorId: Int {
|
||||||
|
switch context {
|
||||||
|
case .community(let creatorId, _, _), .communityPostDetail(let creatorId, _, _), .fanTalk(let creatorId, _):
|
||||||
|
return creatorId
|
||||||
|
case .none:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyCommunityReplies(_ newReplies: [CreatorChannelReplyDetailDisplayItem], requestPage: Int, reset: Bool, totalCount: Int, hasNext: Bool?) {
|
||||||
|
if reset || requestPage == (isCommunityPostDetailContext ? 0 : 1) {
|
||||||
|
replies = newReplies
|
||||||
|
} else {
|
||||||
|
replies.append(contentsOf: newReplies)
|
||||||
|
}
|
||||||
|
communityReplyTotalCount = totalCount
|
||||||
|
communityReplyPage = requestPage + 1
|
||||||
|
isCommunityReplyLast = hasNext.map { !$0 } ?? (replies.count >= totalCount || newReplies.isEmpty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ enum CreatorChannelReplyDetailContext {
|
|||||||
postId: Int,
|
postId: Int,
|
||||||
parentComment: GetCommunityPostCommentListItem
|
parentComment: GetCommunityPostCommentListItem
|
||||||
)
|
)
|
||||||
|
case communityPostDetail(
|
||||||
|
creatorId: Int,
|
||||||
|
postId: Int,
|
||||||
|
parentComment: CreatorChannelCommunityCommentResponse
|
||||||
|
)
|
||||||
case fanTalk(
|
case fanTalk(
|
||||||
creatorId: Int,
|
creatorId: Int,
|
||||||
parentFanTalk: CreatorChannelFanTalkItemResponse
|
parentFanTalk: CreatorChannelFanTalkItemResponse
|
||||||
|
|||||||
@@ -74,6 +74,38 @@ extension CreatorChannelReplyDetailDisplayItem {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(communityPostDetail comment: CreatorChannelCommunityCommentResponse, creatorId: Int) {
|
||||||
|
let currentUserId = UserDefaults.int(forKey: .userId)
|
||||||
|
self.init(
|
||||||
|
id: comment.commentId,
|
||||||
|
source: .community,
|
||||||
|
writerId: comment.writerId,
|
||||||
|
nickname: comment.writerNickname,
|
||||||
|
profileImageUrl: comment.writerProfileImageUrl,
|
||||||
|
content: comment.content,
|
||||||
|
relativeTimeText: DateParser.relativeTimeText(fromUTC: comment.createdAtUtc, fallback: comment.createdAtUtc),
|
||||||
|
isSecret: comment.isSecret,
|
||||||
|
isMine: comment.writerId == currentUserId,
|
||||||
|
isCreatorReply: comment.writerId == creatorId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(communityPostDetailReply reply: CreatorChannelCommunityReplyResponse, creatorId: Int) {
|
||||||
|
let currentUserId = UserDefaults.int(forKey: .userId)
|
||||||
|
self.init(
|
||||||
|
id: reply.commentId,
|
||||||
|
source: .community,
|
||||||
|
writerId: reply.writerId,
|
||||||
|
nickname: reply.writerNickname,
|
||||||
|
profileImageUrl: reply.writerProfileImageUrl,
|
||||||
|
content: reply.content,
|
||||||
|
relativeTimeText: reply.relativeTimeText(),
|
||||||
|
isSecret: false,
|
||||||
|
isMine: reply.writerId == currentUserId,
|
||||||
|
isCreatorReply: reply.writerId == creatorId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
init(fanTalkReply: CreatorChannelFanTalkReplyResponse, creatorId: Int) {
|
init(fanTalkReply: CreatorChannelFanTalkReplyResponse, creatorId: Int) {
|
||||||
let currentUserId = UserDefaults.int(forKey: .userId)
|
let currentUserId = UserDefaults.int(forKey: .userId)
|
||||||
self.init(
|
self.init(
|
||||||
|
|||||||
379
docs/20260707_커뮤니티_게시글_상세/plan-task.md
Normal file
379
docs/20260707_커뮤니티_게시글_상세/plan-task.md
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
# 커뮤니티 게시글 상세 구현 계획
|
||||||
|
|
||||||
|
> **For agentic workers:** PRD를 기준으로 task 단위로 진행한다. 구현 중 요구사항 변경이 생기면 먼저 `docs/20260707_커뮤니티_게시글_상세/prd.md`를 보강하고 이 문서의 체크리스트를 갱신한다.
|
||||||
|
|
||||||
|
**Goal:** V2 커뮤니티 게시글 상세 화면을 추가하고, 상세/댓글/답글 조회 API와 기존 커뮤니티 mutation API를 연결한다.
|
||||||
|
|
||||||
|
**Architecture:** 상세/댓글/답글 조회는 `SodaLive/Sources/V2/CreatorChannel/Community/**`의 V2 API/Repository에 추가한다. 상세 화면 상태는 신규 `Community/Detail` ViewModel이 관리하고, 댓글 작성/수정/삭제/좋아요 mutation은 기존 `CreatorCommunityRepository`를 재사용한다. 댓글의 답글 상세 화면은 기존 `CreatorChannelReplyDetailView` 계열을 복사하지 않고 context/display adapter를 확장해 재사용한다.
|
||||||
|
|
||||||
|
**Tech Stack:** SwiftUI, Combine, Moya, Kingfisher/SDWebImageSwiftUI, 기존 `AppStep`/`AppState`, 기존 V2 design token(`Color`, `SodaSpacing`, `appFont`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기준 문서
|
||||||
|
|
||||||
|
- PRD: `docs/20260707_커뮤니티_게시글_상세/prd.md`
|
||||||
|
- 문서 정책: `docs/agent-guides/documentation-policy.md`
|
||||||
|
- 빌드/검증: `docs/agent-guides/build-test-verification.md`
|
||||||
|
- 커뮤니티 탭 PRD: `docs/20260705_크리에이터_채널_커뮤니티_탭/prd.md`
|
||||||
|
- 글 상세 - 답글 쓰기 PRD: `docs/20260707_글_상세_답글_쓰기/prd.md`
|
||||||
|
- Figma 댓글 가능 상세: `290:9243`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9243&m=dev`
|
||||||
|
- Figma 댓글 불가능 상세: `290:9266`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9266&m=dev`
|
||||||
|
|
||||||
|
## 구현 원칙
|
||||||
|
|
||||||
|
- 신규 상세 화면 파일은 `SodaLive/Sources/V2/CreatorChannel/Community/Detail/**` 아래에 둔다.
|
||||||
|
- 신규 조회 모델은 `SodaLive/Sources/V2/CreatorChannel/Community/Models/**` 아래에 둔다.
|
||||||
|
- 기존 `CreatorChannelCommunityApi.getCommunity`의 `page=0` 정책을 유지하고, 신규 V2 조회 API에도 `page - 1` 보정을 넣지 않는다.
|
||||||
|
- 댓글 작성/답글 작성/댓글 수정/댓글 삭제/좋아요 API는 기존 `CreatorCommunityRepository`를 사용한다.
|
||||||
|
- 댓글 불가능 게시글은 좋아요 UI는 유지하고, 댓글 icon/count, 댓글 목록, 하단 댓글 입력 바를 숨긴다.
|
||||||
|
- 댓글 입력창은 3줄까지 자동 확장하고 4줄 이상은 내부 스크롤로 처리한다.
|
||||||
|
- 전송 버튼 icon은 `ic_arrow_up_gray`, `ic_arrow_up_white`를 사용한다.
|
||||||
|
- 댓글 쓰기 UI에는 기존 비밀댓글 toggle을 추가하고, 기존 `isShowSecret` 노출 정책을 따른다.
|
||||||
|
- 답글 상세 UI는 기존 `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/**` 구현을 재사용한다.
|
||||||
|
|
||||||
|
## 주요 대상 파일
|
||||||
|
|
||||||
|
### 생성
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityPostDetailResponse.swift`
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift`
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift`
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift`
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentListView.swift`
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentRow.swift`
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailInputBar.swift`
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift`
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift`
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift`
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift`
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift`
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Models/CreatorChannelReplyDetailContext.swift`
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Models/CreatorChannelReplyDetailDisplayItem.swift`
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift`
|
||||||
|
- `SodaLive/Sources/App/AppStep.swift`
|
||||||
|
- `SodaLive/Sources/ContentView.swift`
|
||||||
|
- `SodaLive/Sources/I18n/I18n.swift`
|
||||||
|
- `SodaLive.xcodeproj/project.pbxproj`
|
||||||
|
|
||||||
|
### 확인/재사용
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift`
|
||||||
|
- `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift`
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailView.swift`
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/FanTalk/Components/CreatorChannelFanTalkListItem.swift`
|
||||||
|
- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityRepository.swift`
|
||||||
|
- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/Comment/ModifyCommunityPostCommentRequest.swift`
|
||||||
|
|
||||||
|
## TASK 체크리스트
|
||||||
|
|
||||||
|
### Phase 1: 조회 모델과 API
|
||||||
|
|
||||||
|
- [x] **Task 1.1: 커뮤니티 게시글 상세 응답 모델 생성**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityPostDetailResponse.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `CreatorChannelCommunityPostDetailResponse`를 `Decodable`, `Identifiable`로 정의한다.
|
||||||
|
- `CreatorChannelCommunityCommentsResponse`, `CreatorChannelCommunityCommentResponse`, `CreatorChannelCommunityReplyResponse`, `CreatorChannelCommunityRepliesResponse`를 정의한다.
|
||||||
|
- Kotlin `Long` 필드는 Swift `Int`로 선언한다.
|
||||||
|
- `CreatorChannelCommunityCommentResponse`와 `CreatorChannelCommunityReplyResponse`에는 `writerId: Int`를 포함한다.
|
||||||
|
- 각 시간 필드는 `DateParser.relativeTimeText(fromUTC:fallback:now:)`를 사용하는 helper를 제공한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "struct CreatorChannelCommunityPostDetailResponse|struct CreatorChannelCommunityCommentsResponse|struct CreatorChannelCommunityCommentResponse|writerId|struct CreatorChannelCommunityRepliesResponse|relativeTimeText" SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityPostDetailResponse.swift`
|
||||||
|
- 기대 결과: 상세/댓글/답글 모델과 `writerId`, 상대 시간 helper가 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 1.2: 커뮤니티 게시글 상세/댓글/답글 조회 API 추가**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `CreatorChannelCommunityApi`에 `getCommunityPostDetail(postId:)`, `getCommunityPostComments(postId:page:size:)`, `getCommunityCommentReplies(commentId:page:size:)` case를 추가한다.
|
||||||
|
- path는 각각 `/api/v2/creator-channels/community-posts/{postId}`, `/api/v2/creator-channels/community-posts/{postId}/comments`, `/api/v2/creator-channels/community-comments/{commentId}/replies`로 구성한다.
|
||||||
|
- 댓글/답글 query는 `page`, `size`를 그대로 전달하고 `page - 1` 보정을 하지 않는다.
|
||||||
|
- Repository에 동일 이름의 publisher 함수를 추가한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "getCommunityPostDetail|getCommunityPostComments|getCommunityCommentReplies|community-posts|community-comments|URLEncoding.queryString" SodaLive/Sources/V2/CreatorChannel/Community/Repository`
|
||||||
|
- 기대 결과: 신규 endpoint 3개와 query 전달이 확인된다.
|
||||||
|
|
||||||
|
### Phase 2: 라우팅과 목록 진입
|
||||||
|
|
||||||
|
- [x] **Task 2.1: 게시글 상세 route 추가**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/App/AppStep.swift`
|
||||||
|
- 수정: `SodaLive/Sources/ContentView.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `AppStep`에 `creatorChannelCommunityPostDetail(postId:onCommunityRefresh:)` case를 추가한다.
|
||||||
|
- `ContentView` switch에서 `CreatorChannelCommunityPostDetailView(postId:onCommunityRefresh:)`를 반환한다.
|
||||||
|
- `onCommunityRefresh`는 상세 mutation 성공 후 커뮤니티 탭 첫 페이지를 갱신하기 위한 optional callback으로 둔다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "creatorChannelCommunityPostDetail|CreatorChannelCommunityPostDetailView|onCommunityRefresh" SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift`
|
||||||
|
- 기대 결과: 새 route와 화면 연결이 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 2.2: 커뮤니티 리스트/그리드 item tap에서 상세 진입 연결**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 리스트 item과 그리드 item에 `onTapDetail` closure를 추가한다.
|
||||||
|
- item 전체 tap은 상세 route로 연결한다.
|
||||||
|
- 좋아요, 댓글, 더보기, 구매, 오디오 재생 버튼 tap은 상세 진입과 충돌하지 않게 button action으로 유지한다.
|
||||||
|
- 상세 route에는 `post.postId`와 `viewModel.fetchFirstPage(creatorId:)` callback을 전달한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "onTapDetail|creatorChannelCommunityPostDetail|fetchFirstPage\\(creatorId: creatorId\\)|contentShape|Button" SodaLive/Sources/V2/CreatorChannel/Community`
|
||||||
|
- 기대 결과: 리스트/그리드 item tap 상세 진입과 내부 버튼 action 분리가 확인된다.
|
||||||
|
|
||||||
|
### Phase 3: 상세 ViewModel과 mutation 재사용
|
||||||
|
|
||||||
|
- [x] **Task 3.1: 상세 ViewModel 기본 상태와 상세 조회 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 상태로 `detail`, `comments`, `commentText`, `isSecret`, `isLoading`, `isLoadingNextPage`, `hasLoaded`, `errorMessage`, `isShowPopup`, `selectedComment`, `editingComment`, `deletingComment`를 둔다.
|
||||||
|
- `fetchDetail(postId:)`에서 상세 API를 호출하고 게시글과 초기 댓글 목록을 구성한다.
|
||||||
|
- `isCommentAvailable == false`이면 댓글 입력/목록 상태를 비활성 상태로 유지한다.
|
||||||
|
- `isShowSecret` computed property는 기존 정책인 `price > 0 && existOrdered && creatorId != UserDefaults.int(forKey: .userId)`를 따른다.
|
||||||
|
- `isCommentSendEnabled`는 trim된 `commentText`가 비어 있지 않고 로딩 중이 아닐 때 true로 계산한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "final class CreatorChannelCommunityPostDetailViewModel|fetchDetail|detail|comments|commentText|isSecret|isShowSecret|isCommentSendEnabled|getCommunityPostDetail" SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift`
|
||||||
|
- 기대 결과: 상세 조회와 댓글 입력 상태가 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 3.2: 댓글 페이지네이션 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `fetchNextCommentsIfNeeded(currentComment:)`를 추가한다.
|
||||||
|
- 마지막 댓글 노출 시 `hasNext == true`이면 `page + 1`로 댓글 API를 호출한다.
|
||||||
|
- 중복 호출을 막기 위해 `isLoadingNextPage`와 `isLoading` guard를 둔다.
|
||||||
|
- 첫 페이지 재조회 시 기존 댓글 목록을 교체하고, 다음 페이지 성공 시 append한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "fetchNextCommentsIfNeeded|getCommunityPostComments|isLoadingNextPage|hasNext|page \\+ 1|append\\(contentsOf:" SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift`
|
||||||
|
- 기대 결과: 댓글 추가 조회와 append 로직이 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 3.3: 좋아요 mutation과 optimistic state 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `toggleLike()`에서 기존 `CreatorCommunityRepository.communityPostLike(postId:)`를 호출한다.
|
||||||
|
- tap 직후 `isLiked`와 `likeCount`를 local state에 반영한다.
|
||||||
|
- 실패 시 toast를 표시하고 tap 직전의 `isLiked`, `likeCount` 값으로 local state를 복구한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "toggleLike|communityPostLike|isLiked|likeCount|errorMessage|isShowPopup" SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift`
|
||||||
|
- 기대 결과: 기존 좋아요 API와 local state 반영이 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 3.4: 댓글 작성/수정/삭제 mutation 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 신규 댓글 작성은 `CreatorCommunityRepository.createCommunityPostComment(comment:postId:parentId:isSecret:)`를 사용하고 `parentId == nil`, `isSecret == 비밀댓글 toggle 값`으로 호출한다.
|
||||||
|
- 댓글 수정은 `ModifyCommunityPostCommentRequest(commentId:)`에 수정 content를 담아 `modifyComment(request:)`를 호출한다.
|
||||||
|
- 댓글 삭제는 `ModifyCommunityPostCommentRequest(commentId:)`에 `isActive = false`를 담아 `modifyComment(request:)`를 호출한다.
|
||||||
|
- 성공 후 입력값과 수정/삭제 선택 상태를 초기화하고 상세 또는 댓글 첫 페이지를 재조회한다.
|
||||||
|
- mutation 성공 시 `onCommunityRefresh` callback을 호출해 목록 갱신을 예약한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "createCommunityPostComment|parentId: nil|isSecret|ModifyCommunityPostCommentRequest|modifyComment|isActive = false|onCommunityRefresh|commentText = \"\"" SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift`
|
||||||
|
- 기대 결과: 기존 커뮤니티 댓글 mutation API 재사용이 확인된다.
|
||||||
|
|
||||||
|
### Phase 4: 상세 UI 컴포넌트
|
||||||
|
|
||||||
|
- [x] **Task 4.1: 상세 게시글 콘텐츠 컴포넌트 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- Figma 기준으로 작성자 프로필, 닉네임, 상대 시간, 본문, 이미지/잠금/오디오 영역, 좋아요/댓글 reaction을 표시한다.
|
||||||
|
- 유료 미구매 상태는 `price > 0 && existOrdered == false && isOwnPost == false`로 판정한다.
|
||||||
|
- 유료 미구매 상태에서는 `gray800` 계열 이미지 영역, 잠금 아이콘, 캐시 가격 capsule을 표시한다.
|
||||||
|
- 구매 완료 상태는 `구매완료` capsule tag를 표시한다.
|
||||||
|
- 접근 가능한 이미지/GIF는 기존 `WebImage` 또는 `DownsampledKFImage` 패턴을 따른다.
|
||||||
|
- `audioUrl`이 있으면 기존 `CreatorCommunityMediaPlayerManager`와 `ContentPlayManager` 흐름을 재사용한다.
|
||||||
|
- `isCommentAvailable == false`이면 댓글 icon/count만 숨기고 좋아요는 유지한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "CreatorChannelCommunityPostDetailContentView|ic_feed_community_heart_fill|ic_feed_community_heart|ic_feed_community_reply|ic_new_community_lock|ic_bar_cash|구매완료|CreatorCommunityMediaPlayerManager|isCommentAvailable" SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift`
|
||||||
|
- 기대 결과: 게시글 상세 표시, 좋아요, 댓글 숨김, 잠금/구매완료 UI가 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 4.2: 댓글 row와 최신 답글 카드 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentRow.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 댓글 row는 42pt 프로필, 닉네임, 상대 시간, 본문, 우측 더보기 버튼을 표시한다.
|
||||||
|
- `writerId == UserDefaults.int(forKey: .userId)`로 내 댓글 여부를 판정한다.
|
||||||
|
- `post.creatorId == UserDefaults.int(forKey: .userId)`로 현재 사용자가 해당 게시글을 쓴 크리에이터인지 판정한다.
|
||||||
|
- `latestReply != nil`이면 20pt 프로필, 닉네임, 상대 시간, 본문을 가진 `Color.gray900`, radius 14pt 답글 카드를 표시한다.
|
||||||
|
- 최신 답글이 있으면 기존 팬Talk/ReplyDetail connector와 같은 좌측 연결선을 표시한다.
|
||||||
|
- row tap은 답글 상세 진입 action을 호출한다.
|
||||||
|
- 더보기 버튼 tap은 popup/action 선택으로 연결하고 row tap과 충돌하지 않게 한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "CreatorChannelCommunityPostDetailCommentRow|latestReply|writerId|creatorId|UserDefaults.int|Color\\.gray900|RoundedRectangle|Shape|Path|onTapReplyDetail|onTapMore" SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentRow.swift`
|
||||||
|
- 기대 결과: 댓글 row, 최신 답글 카드, 연결선, action 분리가 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 4.3: 댓글 목록 컴포넌트 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentListView.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- section header는 `댓글 {commentCount}`를 표시한다.
|
||||||
|
- 댓글 목록은 `LazyVStack`으로 표시하고 마지막 댓글 `onAppear`에서 다음 페이지 조회를 요청한다.
|
||||||
|
- 댓글이 비어 있으면 header만 유지하거나 Figma 기준 빈 목록 영역을 유지한다.
|
||||||
|
- 선택된 댓글의 popup은 기존 `CreatorChannelActionPopup` 또는 `CreatorChannelReplyDetailActionPopup` 기반으로 수정/삭제 action을 표시한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "CreatorChannelCommunityPostDetailCommentListView|댓글|commentCount|LazyVStack|onAppear|fetchNext|CreatorChannelActionPopup|onTapEdit|onTapDelete" SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentListView.swift`
|
||||||
|
- 기대 결과: 댓글 목록, 페이지네이션 trigger, popup action 연결이 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 4.4: 하단 댓글 입력 바 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailInputBar.swift`
|
||||||
|
- 수정: `SodaLive/Sources/I18n/I18n.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- placeholder는 `댓글을 입력하세요`를 사용한다.
|
||||||
|
- 기존 비밀댓글 toggle UI를 추가하고 `isShowSecret`일 때만 표시한다.
|
||||||
|
- 입력창은 1줄에서 3줄까지 자동 확장한다.
|
||||||
|
- 4줄 이상은 3줄 높이로 유지하고 내부 vertical scroll을 허용한다.
|
||||||
|
- 전송 버튼은 비활성 상태에서 `ic_arrow_up_gray`/`gray900`, 활성 상태에서 `ic_arrow_up_white`/`soda400`를 사용한다.
|
||||||
|
- 수정 모드에서는 취소 버튼 또는 기존 입력 복귀 UX를 최소 범위로 제공한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "CreatorChannelCommunityPostDetailInputBar|댓글을 입력하세요|isShowSecret|isSecret|ic_arrow_up_gray|ic_arrow_up_white|Color\\.gray900|Color\\.soda400|axis: \\.vertical|lineLimit" SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailInputBar.swift SodaLive/Sources/I18n/I18n.swift`
|
||||||
|
- 기대 결과: placeholder, 비밀댓글 toggle, 3줄 확장, 전송 버튼 상태가 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 4.5: 상세 화면 조립**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `CreatorChannelReplyDetailTitleBar`를 재사용해 back-only title bar를 표시한다.
|
||||||
|
- `ScrollView` 안에 게시글 콘텐츠와 댓글 목록을 배치한다.
|
||||||
|
- `isCommentAvailable == true`일 때만 하단 입력 바를 `safeAreaInset(edge: .bottom)`으로 고정한다.
|
||||||
|
- loading, toast, 삭제 confirm modal을 연결한다.
|
||||||
|
- `onAppear`에서 `viewModel.fetchDetail(postId:)`를 호출한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "CreatorChannelCommunityPostDetailView|CreatorChannelReplyDetailTitleBar|CreatorChannelCommunityPostDetailContentView|CreatorChannelCommunityPostDetailCommentListView|CreatorChannelCommunityPostDetailInputBar|safeAreaInset|fetchDetail|SodaV2ActionModal|sodaToast" SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift`
|
||||||
|
- 기대 결과: 상세 화면 조립과 댓글 가능 여부별 하단 입력 바 조건이 확인된다.
|
||||||
|
|
||||||
|
### Phase 5: 답글 상세 재사용 확장
|
||||||
|
|
||||||
|
- [x] **Task 5.1: 신규 댓글 응답을 답글 상세 context로 전달할 수 있게 확장**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Models/CreatorChannelReplyDetailContext.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Models/CreatorChannelReplyDetailDisplayItem.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 기존 `.community(creatorId:postId:parentComment: GetCommunityPostCommentListItem)` 경로는 유지한다.
|
||||||
|
- 신규 상세 댓글 응답을 전달할 수 있는 context case 또는 adapter를 추가한다.
|
||||||
|
- `CreatorChannelCommunityCommentResponse`는 `commentId`, `writerId`, `writerNickname`, `writerProfileImageUrl`, `content`, `createdAtUtc`로 display item을 만든다.
|
||||||
|
- 신규 응답에는 `isSecret`이 없으므로 원댓글 표시용 `isSecret`은 false로 처리하고, 신규 댓글 입력의 비밀 여부는 상세 댓글 작성 UI에서만 다룬다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "CreatorChannelCommunityCommentResponse|communityPostDetail|commentId|writerId|writerProfileImageUrl|DateParser.relativeTimeText|isSecret: false" SodaLive/Sources/V2/CreatorChannel/ReplyDetail`
|
||||||
|
- 기대 결과: 신규 댓글 응답에서 답글 상세 display item을 만들 수 있다.
|
||||||
|
|
||||||
|
- [x] **Task 5.2: 답글 상세에서 신규 답글 조회 API 사용**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 신규 상세 댓글 context에서는 `CreatorChannelCommunityRepository.getCommunityCommentReplies(commentId:page:size:)`를 사용한다.
|
||||||
|
- 기존 V1 댓글 context는 기존 `CreatorCommunityRepository.getCommentReplyList(commentId:page:size:)` 경로를 유지한다.
|
||||||
|
- 신규 `CreatorChannelCommunityReplyResponse`를 `CreatorChannelReplyDetailDisplayItem`으로 변환한다.
|
||||||
|
- `hasNext`, `page`, `size`를 사용해 답글 페이지네이션을 처리한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "getCommunityCommentReplies|CreatorChannelCommunityRepliesResponse|CreatorChannelCommunityReplyResponse|hasNext|communityReplyPage|fetchNextCommunityRepliesIfNeeded" SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift`
|
||||||
|
- 기대 결과: 신규 답글 조회 API와 기존 V1 답글 조회 경로가 분리되어 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 5.3: 댓글 row tap에서 답글 상세 route 연결**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentRow.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailView.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailViewModel.swift`
|
||||||
|
- 수정: `SodaLive/Sources/App/AppStep.swift`
|
||||||
|
- 수정: `SodaLive/Sources/ContentView.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 댓글 row tap 시 `AppState.shared.setAppStep(step: .creatorChannelReplyDetail(...))`을 호출한다.
|
||||||
|
- context에는 `creatorId`, `postId`, 선택 댓글 응답을 전달한다.
|
||||||
|
- `CreatorChannelReplyDetailView`에서 커뮤니티 답글 mutation 성공 시 호출할 `onCommunityRefresh` callback을 추가하고, 게시글 상세 진입 시 `viewModel.fetchDetail(postId:)`를 전달한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "creatorChannelReplyDetail|CreatorChannelReplyDetailContext|CreatorChannelCommunityCommentResponse|onTapReplyDetail|onCommunityRefresh|postId|creatorId" SodaLive/Sources/V2/CreatorChannel/Community/Detail SodaLive/Sources/V2/CreatorChannel/ReplyDetail SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift`
|
||||||
|
- 기대 결과: 상세 댓글에서 기존 답글 상세 화면으로 진입하는 route가 확인된다.
|
||||||
|
|
||||||
|
### Phase 6: 권한, 문구, 프로젝트 등록
|
||||||
|
|
||||||
|
- [x] **Task 6.1: 댓글 popup 권한과 수정/삭제 모드 연결, 신고 차단 기록**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailCommentListView.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 내 댓글/답글이면 `수정하기`, `삭제하기`를 표시한다.
|
||||||
|
- 내가 해당 커뮤니티 게시글을 쓴 크리에이터이면 내 댓글/답글이 아니어도 `삭제하기`를 표시한다.
|
||||||
|
- `수정하기` tap 시 하단 입력창에 기존 댓글 내용을 채우고 send 시 수정 API를 호출한다.
|
||||||
|
- `삭제하기` tap 시 `SodaV2ActionModal`로 삭제 확인을 표시한다.
|
||||||
|
- [blocked] 신고는 기존 `ReportRequest`/`ReportType`에 커뮤니티 댓글 신고 식별자가 없고, 기존 `CreatorCommunityCommentItemView`도 신고 action을 노출하지 않아 연결하지 않는다. 백엔드/API 계약에 community comment report identifier가 추가된 뒤 별도 구현한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "writerId ==|creatorId ==|currentUserId|canEdit|canDelete|beginEditing|editingComment|deletingComment|SodaV2ActionModal|confirmDelete|CreatorChannelActionPopup" SodaLive/Sources/V2/CreatorChannel/Community/Detail`
|
||||||
|
- 기대 결과: 작성자 수정/삭제 권한, 게시글 크리에이터 삭제 권한, 수정 모드가 확인된다. 신고 action은 community comment report identifier API 계약 부재로 노출하지 않는다.
|
||||||
|
|
||||||
|
- [x] **Task 6.2: I18n 문구와 asset 참조 확인**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/I18n/I18n.swift`
|
||||||
|
- 확인: `SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_gray.imageset/Contents.json`
|
||||||
|
- 확인: `SodaLive/Resources/Assets.xcassets/v2/ic_arrow_up_white.imageset/Contents.json`
|
||||||
|
- 작업 내용:
|
||||||
|
- `I18n.CreatorChannelCommunity.commentPlaceholder` 또는 같은 범위의 문구로 `댓글을 입력하세요` ko/en/ja를 추가한다.
|
||||||
|
- 비밀댓글 toggle 문구는 기존 `I18n.Explorer.secretComment`를 재사용한다.
|
||||||
|
- `ic_arrow_up_gray`, `ic_arrow_up_white` asset이 존재하는지 확인하고 다른 arrow asset 이름을 참조하지 않는다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "commentPlaceholder|댓글을 입력하세요|secretComment|ic_arrow_up_gray|ic_arrow_up_white" SodaLive/Sources/I18n/I18n.swift SodaLive/Sources/V2/CreatorChannel/Community/Detail SodaLive/Resources/Assets.xcassets/v2`
|
||||||
|
- 기대 결과: 문구와 icon 참조가 확정된 asset 이름과 일치한다.
|
||||||
|
|
||||||
|
- [x] **Task 6.3: 프로젝트 파일 등록 확인**
|
||||||
|
- 대상 파일:
|
||||||
|
- 확인/수정: `SodaLive.xcodeproj/project.pbxproj`
|
||||||
|
- 작업 내용:
|
||||||
|
- 신규 Swift 파일이 Xcode target에 포함되는지 확인한다.
|
||||||
|
- 수동 등록이 필요한 경우 기존 V2 파일 등록 패턴을 따라 최소 변경으로 추가한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "CreatorChannelCommunityPostDetailView|CreatorChannelCommunityPostDetailViewModel|CreatorChannelCommunityPostDetailResponse|CreatorChannelCommunityPostDetailInputBar" SodaLive.xcodeproj/project.pbxproj`
|
||||||
|
- 기대 결과: 신규 Swift 파일의 project 등록이 확인된다.
|
||||||
|
|
||||||
|
### Phase 7: 최종 검증
|
||||||
|
|
||||||
|
- [x] **Task 7.1: 정적 문서/파일 검증**
|
||||||
|
- 대상 파일:
|
||||||
|
- 확인: `docs/20260707_커뮤니티_게시글_상세/prd.md`
|
||||||
|
- 확인: `docs/20260707_커뮤니티_게시글_상세/plan-task.md`
|
||||||
|
- 확인: 구현 변경 파일 전체
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `git diff --check -- docs/20260707_커뮤니티_게시글_상세 SodaLive/Sources/V2/CreatorChannel/Community SodaLive/Sources/V2/CreatorChannel/ReplyDetail SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift SodaLive/Sources/I18n/I18n.swift SodaLive.xcodeproj/project.pbxproj`
|
||||||
|
- 기대 결과: 공백 오류 없이 성공한다.
|
||||||
|
- 실행 명령: `plutil -lint SodaLive.xcodeproj/project.pbxproj`
|
||||||
|
- 기대 결과: `OK`가 출력된다.
|
||||||
|
|
||||||
|
- [x] **Task 7.2: 빌드 검증**
|
||||||
|
- 대상 파일:
|
||||||
|
- 확인: 전체 앱 target
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
|
||||||
|
- 기대 결과: Debug 빌드 성공.
|
||||||
|
- 참고: 테스트 번들 타깃이 확인되지 않는 저장소 상태이므로, 단위 테스트가 추가되지 않은 경우 빌드 검증을 우선 성공 기준으로 둔다.
|
||||||
|
|
||||||
|
- [ ] **Task 7.3: 수동 QA 시나리오 기록**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `docs/20260707_커뮤니티_게시글_상세/plan-task.md`
|
||||||
|
- 검증 기준:
|
||||||
|
- 댓글 가능 게시글 상세 진입 시 상세 API가 호출되고 게시글/댓글/입력 바가 표시된다.
|
||||||
|
- 댓글 불가능 게시글 상세 진입 시 좋아요는 표시되고 댓글 icon/count, 댓글 목록, 입력 바는 숨겨진다.
|
||||||
|
- 댓글 입력값이 없으면 `ic_arrow_up_gray`/`gray900`, 입력값이 있으면 `ic_arrow_up_white`/`soda400`가 표시된다.
|
||||||
|
- 4줄 이상 입력 시 입력창 높이가 3줄로 유지되고 내부 스크롤된다.
|
||||||
|
- 비밀댓글 toggle 노출 조건에서 toggle 값이 댓글 작성 API의 `isSecret`으로 전달된다.
|
||||||
|
- 댓글 row tap 시 기존 답글 상세 화면으로 진입하고 신규 답글 조회 API가 호출된다.
|
||||||
|
- 게시글을 쓴 크리에이터 계정에서는 내가 작성하지 않은 댓글/답글에도 `삭제하기`가 표시된다.
|
||||||
|
- 작성자도 게시글 크리에이터도 아닌 계정에서는 신고 action이 표시되지 않는다. [blocked] 기존 `ReportRequest`/`ReportType`에 community comment report identifier가 없어 백엔드/API 계약 확정 전까지 수동 QA 대상에서 제외한다.
|
||||||
|
- 하트 tap 시 기존 좋아요 API가 호출되고 icon/count가 갱신된다.
|
||||||
|
- 실행 후 이 문서의 `검증 기록` 섹션에 실행 일자, 명령, 결과를 누적한다.
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
- 2026-07-07: `docs/20260707_커뮤니티_게시글_상세/prd.md`, `docs/agent-guides/build-test-verification.md`, 기존 `docs/20260707_글_상세_답글_쓰기/plan-task.md`, `docs/20260705_크리에이터_채널_커뮤니티_탭/plan-task.md`를 확인해 구현 범위와 문서 형식을 정했다.
|
||||||
|
- 2026-07-07: 현재 코드 기준으로 `AppStep.creatorChannelReplyDetail`, `CreatorChannelReplyDetailView`, V2 커뮤니티 탭 파일, V2 커뮤니티 API/Repository, 답글 상세 display 모델이 이미 존재함을 확인하고, 게시글 상세는 신규 `Community/Detail` 하위 구현으로 계획했다.
|
||||||
|
- 2026-07-07: 기존 `CreatorCommunityCommentItemView`에서 게시글 크리에이터 또는 댓글 작성자이면 더보기 버튼을 표시하고, 수정은 작성자에게만, 삭제는 작성자 또는 게시글 크리에이터에게 허용하는 것을 확인했다. 계획 문서에 동일 권한 규칙을 반영했다.
|
||||||
|
- 2026-07-07: 사용자 확인 사항에 따라 신고 규칙도 기존 커뮤니티 댓글과 동일하게 검토 대상으로 두었으나, 구현 확인 결과 기존 `ReportRequest`/`ReportType`에 community comment report identifier가 없고 기존 `CreatorCommunityCommentItemView`도 신고 action을 노출하지 않아 계획 문서에 [blocked]로 정정했다.
|
||||||
|
- 2026-07-07: `plutil -lint SodaLive.xcodeproj/project.pbxproj` 실행 결과 `OK`를 확인했다.
|
||||||
|
- 2026-07-07: `git diff --check -- docs/20260707_커뮤니티_게시글_상세 SodaLive/Sources/V2/CreatorChannel/Community SodaLive/Sources/V2/CreatorChannel/ReplyDetail SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift SodaLive/Sources/I18n/I18n.swift SodaLive.xcodeproj/project.pbxproj` 실행 결과 공백 오류 없이 통과했다.
|
||||||
|
- 2026-07-07: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행 결과 `BUILD SUCCEEDED`를 확인했다.
|
||||||
|
- 2026-07-07: `rg "canReport|onTapReport|showReportError|CreatorChannelActionPopupWithReport|private struct CreatorChannelActionPopup|신고" SodaLive/Sources/V2/CreatorChannel/Community/Detail` 실행 결과 fake report 제거 후 출력이 없음을 확인했다.
|
||||||
|
- 2026-07-07: 커뮤니티 댓글 신고 흐름은 기존 `ReportRequest`/`ReportType`에 커뮤니티 댓글 신고 식별자가 없어 연결하지 못했다. 구현은 기존 `CreatorCommunityCommentItemView`의 수정/삭제 popup 동작과 동일하게 맞췄고, fake report action은 노출하지 않는다.
|
||||||
|
- 2026-07-07: 정적 검증과 Debug 빌드 검증은 완료했다. Task 7.3의 device/simulator/API-backed 환경 수동 runtime QA는 아직 수행하지 않았다.
|
||||||
289
docs/20260707_커뮤니티_게시글_상세/prd.md
Normal file
289
docs/20260707_커뮤니티_게시글_상세/prd.md
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
# PRD: 커뮤니티 게시글 상세
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
크리에이터 채널 커뮤니티 게시글을 터치했을 때 진입하는 V2 커뮤니티 게시글 상세 화면을 제공한다. 화면은 게시글 본문, 이미지/오디오/유료 잠금 상태, 좋아요, 댓글 목록, 최신 답글, 하단 고정 댓글 입력 바를 표시한다.
|
||||||
|
|
||||||
|
상세 API는 `GET /api/v2/creator-channels/community-posts/{postId}`를 사용한다. 댓글 추가 조회는 `GET /api/v2/creator-channels/community-posts/{postId}/comments`, 답글 추가 조회는 `GET /api/v2/creator-channels/community-comments/{commentId}/replies`를 사용한다. `page` 기본값은 `0`, `size` 기본값은 `20`이다.
|
||||||
|
|
||||||
|
Figma 참조:
|
||||||
|
- 댓글 가능 상세: `290:9243`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9243&m=dev`
|
||||||
|
- 댓글 불가능 상세: `290:9266`, `https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=290-9266&m=dev`
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 현재 V2 커뮤니티 탭은 게시글 목록을 제공하지만, 게시글 상세 API와 Figma 기준 상세 화면이 연결되어 있지 않다.
|
||||||
|
- 기존 커뮤니티 댓글 화면은 V1 sheet 기반이고, 이번 상세 화면의 하단 고정 입력 바와 댓글/최신 답글 노출 구조와 다르다.
|
||||||
|
- 팬Talk/댓글 답글 상세 UI가 이미 V2에 구현되어 있으므로 같은 UI를 복사할지 재사용할지 결정이 필요하다.
|
||||||
|
- 신규 상세/댓글/답글 조회 API는 `page=0` 기준인 반면 기존 V1 커뮤니티 API 일부는 내부에서 `page - 1`을 사용하므로 API 계층을 분리해야 한다.
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- 커뮤니티 목록 item tap 시 게시글 상세 화면으로 진입한다.
|
||||||
|
- 상세 진입 시 `GET /api/v2/creator-channels/community-posts/{postId}`를 호출한다.
|
||||||
|
- 상세 응답의 `comments`를 초기 댓글 목록으로 표시한다.
|
||||||
|
- 댓글 목록 추가 조회는 `GET /api/v2/creator-channels/community-posts/{postId}/comments?page={page}&size={size}`를 사용한다.
|
||||||
|
- 댓글의 답글 추가 조회는 `GET /api/v2/creator-channels/community-comments/{commentId}/replies?page={page}&size={size}`를 사용한다.
|
||||||
|
- 댓글 작성, 답글 작성, 댓글 수정, 댓글 삭제, 좋아요 API는 기존 커뮤니티 구현에서 사용하던 API를 그대로 사용한다.
|
||||||
|
- 댓글 popup menu는 기존 커뮤니티 댓글과 동일한 수정/삭제 흐름을 사용한다.
|
||||||
|
- 커뮤니티 댓글 신고는 [blocked] 상태로 구현 범위에서 제외한다. 현재 코드베이스에는 커뮤니티 댓글 신고용 `ReportRequest`/`ReportType` 식별자와 API가 없고, 기존 `CreatorCommunityCommentItemView`도 댓글 신고 UI를 노출하지 않는다.
|
||||||
|
- 댓글의 답글 상세 UI는 팬Talk 답글 상세와 동일하므로 `CreatorChannelReplyDetailView` 계열을 복사하지 않고 재사용/확장한다.
|
||||||
|
- `isCommentAvailable == false`이면 reaction bar의 댓글 icon/count, 댓글 목록, 하단 댓글 입력 바를 숨긴다.
|
||||||
|
- 하단 댓글 입력 바는 화면 하단에 고정하고 키보드 표시 시 키보드 위로 밀려 올라와야 한다.
|
||||||
|
- 댓글 입력창은 최대 3줄까지 자동 확장하고, 4줄 이상은 3줄 높이를 유지한 채 내부 vertical scroll로 처리한다.
|
||||||
|
- 좋아요 상태에 따라 `ic_feed_community_heart_fill` 또는 `ic_feed_community_heart`를 표시하고 tap 시 기존 좋아요 API를 호출한다.
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 커뮤니티 게시글 작성/수정 화면을 변경하지 않는다.
|
||||||
|
- 댓글/답글 작성, 수정, 삭제, 좋아요 mutation API를 신규 V2 endpoint로 교체하지 않는다.
|
||||||
|
- 기존 V1 커뮤니티 댓글 화면 전체를 삭제하거나 일괄 리팩터링하지 않는다.
|
||||||
|
- 팬Talk 답글 상세 UI를 별도 파일로 동일 복사하지 않는다.
|
||||||
|
- Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다.
|
||||||
|
- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다.
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 크리에이터의 커뮤니티 게시글을 상세로 읽고 좋아요를 누르려는 사용자
|
||||||
|
- 커뮤니티 게시글에 댓글을 작성하려는 사용자
|
||||||
|
- 커뮤니티 댓글에 답글을 확인하거나 작성하려는 사용자
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 사용자는 커뮤니티 목록에서 게시글을 터치해 상세 본문, 이미지, 좋아요 수, 댓글 수를 확인할 수 있다.
|
||||||
|
- 사용자는 댓글 입력창에 글자를 입력하면 전송 버튼이 활성화되는 것을 확인하고 댓글을 작성할 수 있다.
|
||||||
|
- 사용자는 댓글이 불가능한 게시글에서는 댓글 목록과 댓글 입력창이 보이지 않는 것을 확인할 수 있다.
|
||||||
|
- 사용자는 댓글에 최신 답글이 있으면 댓글 아래에서 답글 요약을 확인하고, 댓글을 터치해 답글 상세 화면으로 이동할 수 있다.
|
||||||
|
- 사용자는 게시글 하트를 터치해 좋아요 상태를 변경할 수 있다.
|
||||||
|
|
||||||
|
## 7. Core Requirements
|
||||||
|
|
||||||
|
### 7.1 API
|
||||||
|
신규 V2 조회 API는 `SodaLive/Sources/V2/CreatorChannel/Community/**` 하위 Repository/API에 추가한다. 기존 `CreatorCommunityApi`는 mutation 재사용 대상으로 유지하고, 신규 V2 조회 API에는 `page - 1` 보정을 적용하지 않는다.
|
||||||
|
|
||||||
|
상세:
|
||||||
|
- Method: `GET`
|
||||||
|
- Path: `/api/v2/creator-channels/community-posts/{postId}`
|
||||||
|
- Path parameter: `postId`
|
||||||
|
|
||||||
|
댓글:
|
||||||
|
- Method: `GET`
|
||||||
|
- Path: `/api/v2/creator-channels/community-posts/{postId}/comments`
|
||||||
|
- Path parameter: `postId`
|
||||||
|
- Query parameters: `page` 기본값 `0`, `size` 기본값 `20`
|
||||||
|
|
||||||
|
답글:
|
||||||
|
- Method: `GET`
|
||||||
|
- Path: `/api/v2/creator-channels/community-comments/{commentId}/replies`
|
||||||
|
- Path parameter: `commentId`
|
||||||
|
- Query parameters: `page` 기본값 `0`, `size` 기본값 `20`
|
||||||
|
|
||||||
|
응답 래퍼는 기존 V2 패턴대로 `ApiResponse<...>`로 디코딩한다. Kotlin `Long`은 기존 V2 관례대로 Swift `Int`로 선언한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class CreatorChannelCommunityPostDetailResponse(
|
||||||
|
val postId: Long,
|
||||||
|
val creatorId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileUrl: String,
|
||||||
|
val createdAtUtc: String,
|
||||||
|
val content: String,
|
||||||
|
val imageUrl: String?,
|
||||||
|
val audioUrl: String?,
|
||||||
|
val price: Int,
|
||||||
|
@JsonProperty("isCommentAvailable")
|
||||||
|
val isCommentAvailable: Boolean,
|
||||||
|
val existOrdered: Boolean,
|
||||||
|
val likeCount: Int,
|
||||||
|
val commentCount: Int,
|
||||||
|
@JsonProperty("isPinned")
|
||||||
|
val isPinned: Boolean,
|
||||||
|
@JsonProperty("isLiked")
|
||||||
|
val isLiked: Boolean,
|
||||||
|
val comments: CreatorChannelCommunityCommentsResponse
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelCommunityCommentsResponse(
|
||||||
|
val commentCount: Int,
|
||||||
|
val comments: List<CreatorChannelCommunityCommentResponse>,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelCommunityCommentResponse(
|
||||||
|
val commentId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerProfileImageUrl: String,
|
||||||
|
val writerNickname: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAtUtc: String,
|
||||||
|
val latestReply: CreatorChannelCommunityReplyResponse?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelCommunityReplyResponse(
|
||||||
|
val commentId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerProfileImageUrl: String,
|
||||||
|
val writerNickname: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAtUtc: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelCommunityRepliesResponse(
|
||||||
|
val replyCount: Int,
|
||||||
|
val replies: List<CreatorChannelCommunityReplyResponse>,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Entry and navigation
|
||||||
|
- `CreatorChannelCommunityTabView`의 리스트형/썸네일형 item tap에서 상세 화면으로 진입한다.
|
||||||
|
- 상세 route는 `AppStep`에 추가하고 `postId`와 필요한 refresh callback을 전달한다.
|
||||||
|
- 상세 화면에서 back tap 시 이전 화면으로 돌아간다.
|
||||||
|
- 상세에서 좋아요/댓글 작성/댓글 삭제 등으로 목록 갱신이 필요한 경우, 상세 종료 또는 mutation 성공 시 커뮤니티 탭 첫 페이지 갱신 callback을 호출한다.
|
||||||
|
|
||||||
|
### 7.3 Screen layout
|
||||||
|
- 배경은 `Color.black`을 사용한다.
|
||||||
|
- 상단 title bar는 Figma처럼 title text 없이 back chevron만 표시한다.
|
||||||
|
- 게시글 영역은 작성자 프로필, 닉네임, 상대 시간, 본문, 이미지/오디오/잠금 영역, 좋아요/댓글 count를 표시한다.
|
||||||
|
- `createdAtUtc`는 기존 `DateParser.relativeTimeText(fromUTC:fallback:now:)` 계열 helper로 상대 시간으로 표시한다.
|
||||||
|
- `price > 0 && existOrdered == false && isOwnPost == false`이면 유료 미구매 잠금 상태로 표시한다.
|
||||||
|
- 유료 미구매 잠금 상태는 Figma처럼 `gray800` 계열 이미지 영역, 잠금 아이콘, 캐시 가격 capsule을 표시한다.
|
||||||
|
- 구매 완료 상태는 Figma `290:9266`처럼 `구매완료` capsule tag를 표시한다.
|
||||||
|
- 이미지가 있고 접근 가능한 게시글이면 이미지 또는 GIF를 표시한다.
|
||||||
|
- `audioUrl`이 있으면 기존 커뮤니티 오디오 재생 흐름을 재사용해 이미지 중앙에 재생 버튼을 표시한다.
|
||||||
|
- `isPinned == true`이면 기존 커뮤니티 탭의 pinned 표시 규칙을 따른다.
|
||||||
|
|
||||||
|
### 7.4 Reaction
|
||||||
|
- `isLiked == true`이면 `ic_feed_community_heart_fill`을 표시한다.
|
||||||
|
- `isLiked == false`이면 `ic_feed_community_heart`를 표시한다.
|
||||||
|
- 하트 tap 시 기존 `CreatorCommunityRepository.communityPostLike(postId:)`를 호출한다.
|
||||||
|
- 좋아요 tap 직후 UI는 기존 `CreatorChannelCommunityListItem`처럼 local optimistic state로 반영하고, API 실패 시 toast를 표시한 뒤 tap 직전의 `isLiked`, `likeCount` 값으로 local state를 복구한다.
|
||||||
|
- `isCommentAvailable == true`이면 댓글 icon과 `commentCount`를 표시한다.
|
||||||
|
- `isCommentAvailable == false`이면 댓글 icon/count, 댓글 목록, 하단 입력 바를 모두 숨긴다.
|
||||||
|
|
||||||
|
### 7.5 Comments
|
||||||
|
- 상세 API 응답의 `comments.comments`를 초기 댓글 목록으로 표시한다.
|
||||||
|
- 댓글 section header는 Figma처럼 `댓글 {commentCount}` 형식으로 표시한다.
|
||||||
|
- 댓글 row는 작성자 프로필, 닉네임, 상대 시간, 본문, 우측 더보기 버튼을 표시한다.
|
||||||
|
- `writerId`로 내 댓글/답글 여부를 판정하고, 게시글의 `creatorId`로 현재 사용자가 해당 커뮤니티 게시글을 쓴 크리에이터인지 판정한다.
|
||||||
|
- 댓글/답글 popup menu는 기존 커뮤니티 댓글과 동일하게 내 댓글/답글이면 `수정하기`, `삭제하기`를 표시하고, 해당 게시글을 쓴 크리에이터이면 내가 쓴 댓글/답글이 아니어도 `삭제하기`를 표시한다.
|
||||||
|
- 커뮤니티 댓글 신고는 [blocked] 상태로 구현하지 않는다. 현재 코드베이스에는 커뮤니티 댓글 신고용 `ReportRequest`/`ReportType` 식별자와 API가 없고, 기존 `CreatorCommunityCommentItemView`도 댓글 신고 UI를 노출하지 않는다.
|
||||||
|
- `latestReply != nil`이면 댓글 아래에 최신 답글 카드와 좌측 연결선을 표시한다.
|
||||||
|
- 댓글 목록 하단 도달 시 `hasNext == true`이면 다음 page를 append한다.
|
||||||
|
- 첫 페이지 재조회 시 기존 댓글 목록을 교체한다.
|
||||||
|
- 댓글을 터치하면 답글 상세 화면으로 진입한다.
|
||||||
|
|
||||||
|
### 7.6 Reply detail reuse decision
|
||||||
|
- 댓글의 답글 상세 UI는 팬Talk 상세와 동일한 UI를 가진다.
|
||||||
|
- 별도 동일 복사는 하지 않는다.
|
||||||
|
- 기존 `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailView.swift`, `CreatorChannelReplyDetailFeedView.swift`, `CreatorChannelReplyDetailInputBar.swift`를 재사용한다.
|
||||||
|
- 신규 응답 모델이 기존 `GetCommunityPostCommentListItem`과 필드가 다르므로, 구현 시 `CreatorChannelReplyDetailDisplayItem` 생성용 adapter 또는 context 확장을 추가한다.
|
||||||
|
- 답글 목록 추가 조회는 신규 `GET /api/v2/creator-channels/community-comments/{commentId}/replies`를 우선 사용한다.
|
||||||
|
- 답글 작성/수정/삭제 mutation은 사용자 요구대로 기존 커뮤니티 댓글 API를 재사용한다.
|
||||||
|
|
||||||
|
### 7.7 Comment input bar
|
||||||
|
- `isCommentAvailable == true`일 때만 하단 고정 댓글 입력 바를 표시한다.
|
||||||
|
- placeholder는 사용자 요구사항 기준 `댓글을 입력하세요`를 표시한다.
|
||||||
|
- Figma에는 `댓글을 입력하세요.`처럼 마침표가 보이나, 구현 기준은 사용자 요구 문구를 우선한다.
|
||||||
|
- 댓글 쓰기 UI에는 기존 커뮤니티 댓글과 동일한 비밀댓글 toggle을 추가한다.
|
||||||
|
- 비밀댓글 toggle은 기존 커뮤니티 댓글 sheet의 `isShowSecret` 노출 정책을 따른다.
|
||||||
|
- 비밀댓글 toggle이 노출되지 않는 경우 `isSecret == false`로 댓글 작성 API를 호출한다.
|
||||||
|
- 입력값이 없거나 공백/줄바꿈만 있으면 전송 버튼을 비활성화한다.
|
||||||
|
- 입력값이 있으면 전송 버튼을 활성화한다.
|
||||||
|
- 입력창은 1줄에서 시작하고 3줄까지 자동 확장한다.
|
||||||
|
- 4줄 이상 입력 시 3줄 높이를 유지하고 입력창 내부에서 vertical scroll 처리한다.
|
||||||
|
- 하단 입력 바는 키보드가 표시되면 키보드 위로 올라와야 한다.
|
||||||
|
- 전송 중에는 중복 tap으로 같은 댓글이 여러 번 등록되지 않도록 한다.
|
||||||
|
- 댓글 작성 성공 후 입력값을 비우고 댓글 첫 페이지 또는 상세 데이터를 재조회한다.
|
||||||
|
|
||||||
|
전송 버튼 상태:
|
||||||
|
- 비활성화: icon `ic_arrow_up_gray`, 원형 배경, `gray900`
|
||||||
|
- 활성화: icon `ic_arrow_up_white`, 원형 배경, `soda400`
|
||||||
|
|
||||||
|
### 7.8 Comment popup menu and mutation reuse
|
||||||
|
- 댓글/답글 popup menu는 기존 커뮤니티 댓글과 동일한 메뉴 정책을 따른다.
|
||||||
|
- 기존 메뉴 정책은 `CreatorCommunityCommentItemView`와 동일하게 수정은 작성자에게만 허용하고, 삭제는 작성자 또는 게시글 크리에이터에게 허용한다.
|
||||||
|
- 커뮤니티 댓글 신고는 [blocked] 상태로 구현하지 않는다. `ReportRequest`/`ReportType`에 커뮤니티 댓글 신고 식별자가 추가되기 전까지 작성자도 게시글 크리에이터도 아닌 사용자에게도 `신고`를 제공하지 않는다.
|
||||||
|
- 댓글 작성은 기존 `CreatorCommunityRepository.createCommunityPostComment(comment:postId:parentId:isSecret:)`를 사용하고, 원댓글 작성 시 `parentId == nil`, `isSecret == 비밀댓글 toggle 값`으로 호출한다.
|
||||||
|
- 답글 작성은 같은 API에 `parentId == commentId`를 전달한다.
|
||||||
|
- 댓글/답글 수정과 삭제는 기존 `CreatorCommunityRepository.modifyComment(request:)`를 사용한다.
|
||||||
|
- 좋아요는 기존 `CreatorCommunityRepository.communityPostLike(postId:)`를 사용한다.
|
||||||
|
- 댓글 신고가 필요한 경우 `ReportRequest`/`ReportType`에 커뮤니티 댓글 신고 식별자가 추가된 뒤 별도 범위에서 연결한다.
|
||||||
|
|
||||||
|
### 7.9 Pagination and state
|
||||||
|
- 상세 첫 진입은 상세 API 1회로 게시글과 초기 댓글을 함께 구성한다.
|
||||||
|
- 댓글 추가 조회는 `comments.page + 1` 기준으로 호출한다.
|
||||||
|
- 답글 상세 추가 조회는 `replies.page + 1` 기준으로 호출한다.
|
||||||
|
- 조회 중 중복 호출을 막는다.
|
||||||
|
- `hasNext == false`이면 추가 조회하지 않는다.
|
||||||
|
- pull-to-refresh가 필요하면 상세 API를 다시 호출해 게시글과 댓글 첫 페이지를 동기화한다.
|
||||||
|
|
||||||
|
## 8. Reusable V2 Candidates
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift`
|
||||||
|
- 게시글 header, content, 유료 잠금 overlay, 오디오 재생, 좋아요/댓글 reaction 규칙을 상세 게시글 영역 구현 후보로 검토한다.
|
||||||
|
- 목록 카드 배경/radius는 상세 화면과 다르므로 전체 View 복사보다 내부 규칙을 추출하거나 참고하는 방향이 적합하다.
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift`
|
||||||
|
- 유료 잠금 가격 overlay와 pinned 표시 규칙 참고 후보이다.
|
||||||
|
- `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift`
|
||||||
|
- 홈/추천 카드의 좋아요 optimistic state, 잠금 overlay, 오디오 재생 흐름 참고 후보이다.
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/CreatorChannelReplyDetailView.swift`
|
||||||
|
- 댓글의 답글 상세 화면 재사용 대상이다.
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailFeedView.swift`
|
||||||
|
- 원댓글/답글 카드/좌측 연결선/더보기 popup 위치 계산을 재사용한다.
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailInputBar.swift`
|
||||||
|
- 답글 입력 바의 send 상태와 하단 고정 구조를 참고하되, 댓글 상세 입력 바는 3줄 자동 확장 요구가 있어 별도 확장 또는 공용화가 필요하다.
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/FanTalk/Components/CreatorChannelFanTalkListItem.swift`
|
||||||
|
- 댓글 row + latest reply 카드 + 좌측 연결선 구조가 Figma 댓글 row와 유사하다.
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/FanTalk/Components/CreatorChannelFanTalkActionPopup.swift`
|
||||||
|
- `CreatorChannelActionPopup`이 수정/삭제 popup 공용 기반으로 이미 사용 중이다.
|
||||||
|
- `SodaLive/Sources/V2/Component/Modal/SodaV2ActionModal.swift`
|
||||||
|
- 댓글/답글 삭제 확인 dialog 후보이다.
|
||||||
|
- `SodaLive/Sources/V2/CreatorChannel/ReplyDetail/Components/CreatorChannelReplyDetailTitleBar.swift`
|
||||||
|
- back-only title bar 재사용 후보이다.
|
||||||
|
|
||||||
|
## 9. Success Criteria
|
||||||
|
- 커뮤니티 게시글 item tap 시 상세 화면으로 진입한다.
|
||||||
|
- 상세 진입 시 `GET /api/v2/creator-channels/community-posts/{postId}`가 호출된다.
|
||||||
|
- 상세 API 응답으로 게시글 본문, 이미지/오디오/잠금 상태, 좋아요 수, 댓글 수, 초기 댓글 목록이 표시된다.
|
||||||
|
- 댓글 추가 로딩 시 `GET /api/v2/creator-channels/community-posts/{postId}/comments?page={page}&size=20`가 호출된다.
|
||||||
|
- 댓글의 답글 상세 추가 로딩 시 `GET /api/v2/creator-channels/community-comments/{commentId}/replies?page={page}&size=20`가 호출된다.
|
||||||
|
- `isCommentAvailable == false`이면 reaction bar의 댓글 icon/count, 댓글 section, 하단 댓글 입력 바가 표시되지 않는다.
|
||||||
|
- 댓글 쓰기 UI에는 기존 비밀댓글 toggle이 표시 조건에 맞게 제공된다.
|
||||||
|
- 입력값이 없으면 댓글 전송 버튼은 `gray900` 배경과 `ic_arrow_up_gray` icon으로 비활성화된다.
|
||||||
|
- 입력값이 있으면 댓글 전송 버튼은 `soda400` 배경과 `ic_arrow_up_white` icon으로 활성화된다.
|
||||||
|
- 댓글 입력창은 3줄까지 높이가 늘어나고, 4줄 이상은 3줄 높이에서 내부 스크롤된다.
|
||||||
|
- 키보드가 올라오면 하단 댓글 입력 바가 키보드 위로 올라온다.
|
||||||
|
- 댓글 작성 성공 후 입력값이 비워지고 댓글 목록이 갱신된다.
|
||||||
|
- 하트 tap 시 기존 좋아요 API가 호출되고 `isLiked` 상태에 맞는 heart icon이 표시된다.
|
||||||
|
- 댓글 popup menu는 기존 커뮤니티 댓글과 동일한 수정/삭제 흐름을 사용한다.
|
||||||
|
- 게시글을 쓴 크리에이터는 내가 작성하지 않은 댓글/답글에도 `삭제하기`를 사용할 수 있다.
|
||||||
|
- 커뮤니티 댓글 신고는 [blocked] 상태이며, 현재 코드베이스에 커뮤니티 댓글 신고용 `ReportRequest`/`ReportType` 식별자와 API가 없고 기존 `CreatorCommunityCommentItemView`도 신고를 노출하지 않으므로 이번 구현의 성공 기준에서 제외한다.
|
||||||
|
- 댓글의 답글 상세 UI는 팬Talk 상세와 동일한 기존 `CreatorChannelReplyDetailView` 계열을 재사용한다.
|
||||||
|
|
||||||
|
## 10. Technical Constraints
|
||||||
|
- 기능 변경은 `SodaLive/Sources/V2/**` 하위에서 우선 해결한다.
|
||||||
|
- 커뮤니티 게시글 상세 신규 View, ViewModel, 모델, 조회 Repository/API는 `SodaLive/Sources/V2/CreatorChannel/Community/**` 하위에 둔다.
|
||||||
|
- 댓글/답글 mutation은 기존 `SodaLive/Sources/Explorer/Profile/CreatorCommunity/**` repository를 호출할 수 있다.
|
||||||
|
- 신규 공용 컴포넌트가 필요하면 `SodaLive/Sources/V2/Component/**` 아래에 둔다.
|
||||||
|
- 특정 상세 화면에서만 쓰는 컴포넌트는 `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/**` 아래에 둔다.
|
||||||
|
- 신규 문구가 필요하면 `SodaLive/Sources/I18n/I18n.swift`에 ko/en/ja를 추가한다.
|
||||||
|
- 프로젝트 설정 변경은 신규 Swift 파일 target 등록이 필요한 경우에만 수행한다.
|
||||||
|
|
||||||
|
## 11. Assumptions
|
||||||
|
- 상세 API의 `postId`는 기존 좋아요/댓글 작성 mutation API가 요구하는 post id와 동일하다.
|
||||||
|
- 신규 답글 API의 `commentId`는 기존 `modifyComment`와 `createCommunityPostComment(parentId:)`에서 사용하는 comment id와 동일하다.
|
||||||
|
- `existOrdered == true`이면 유료 게시글의 구매 완료 또는 접근 가능 상태로 본다.
|
||||||
|
- 본인 게시글 판정은 기존 관례대로 `creatorId == UserDefaults.int(forKey: .userId)`를 우선 사용한다.
|
||||||
|
- 댓글 원문과 최신 답글은 서버 응답 문자열을 그대로 표시한다.
|
||||||
|
|
||||||
|
## 12. Open Questions
|
||||||
|
- 해당 없음
|
||||||
|
|
||||||
|
## 13. Verification Notes
|
||||||
|
- 2026-07-07: Figma `get_design_context`와 `get_screenshot`으로 `290:9243`, `290:9266`을 확인했다.
|
||||||
|
- 2026-07-07: `docs/agent-guides/documentation-policy.md`와 `docs/prd/sample-prd.md`를 확인해 신규 PRD 경로와 필수 섹션을 맞췄다.
|
||||||
|
- 2026-07-07: 기존 문서 `docs/20260705_크리에이터_채널_커뮤니티_탭/prd.md`, `docs/20260704_크리에이터_채널_팬Talk_탭/prd.md`, `docs/20260707_글_상세_답글_쓰기/prd.md`를 확인했다.
|
||||||
|
- 2026-07-07: 기존 V2 재사용 후보로 `CreatorChannelCommunityListItem`, `CreatorChannelCommunityGridItem`, `CommunityPostCard`, `CreatorChannelReplyDetailView`, `CreatorChannelReplyDetailFeedView`, `CreatorChannelReplyDetailInputBar`, `CreatorChannelFanTalkListItem`, `CreatorChannelActionPopup`, `SodaV2ActionModal`, `CreatorChannelReplyDetailTitleBar`를 확인했다.
|
||||||
|
- 2026-07-07: 기존 mutation 재사용 후보로 `CreatorCommunityRepository.communityPostLike`, `createCommunityPostComment`, `modifyComment`, `getCommunityPostDetail`, `getCommunityPostCommentList`, `getCommentReplyList`를 확인했다. 신규 V2 조회 API는 path/page 기준이 달라 V2 API에 별도 추가가 필요하다.
|
||||||
|
- 2026-07-07: 사용자 확인 사항을 반영해 댓글/답글 응답에 `writerId`를 추가하고, 댓글 쓰기 UI의 기존 비밀댓글 toggle 유지, 전송 버튼 icon의 `ic_arrow_up_gray`/`ic_arrow_up_white` 매핑, 댓글 불가능 게시글의 댓글 icon/count 숨김 정책을 확정했다.
|
||||||
|
- 2026-07-07: 기존 커뮤니티 댓글과 동일하게 수정은 작성자에게만, 삭제는 작성자 또는 게시글 크리에이터에게 제공하는 권한 규칙을 반영했다. 커뮤니티 댓글 신고는 기존 `ReportRequest`/`ReportType`에 커뮤니티 댓글 신고 식별자가 없고 기존 `CreatorCommunityCommentItemView`도 신고를 노출하지 않아 [blocked]로 정정했다.
|
||||||
Reference in New Issue
Block a user