feat(creator): 커뮤니티 탭을 추가한다

This commit is contained in:
Yu Sung
2026-07-05 22:25:07 +09:00
parent 130b8eee40
commit f3b7d01a3f
20 changed files with 1706 additions and 2 deletions

View File

@@ -1875,6 +1875,36 @@
CC0530082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0500082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift */; }; CC0530082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0500082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift */; };
CC0530092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0500092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift */; }; CC0530092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0500092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift */; };
CC05300A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC05000A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift */; }; CC05300A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC05000A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift */; };
CC0610012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift */; };
CC0610022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift */; };
CC0610032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift */; };
CC0610042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift */; };
CC0610052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift */; };
CC0610062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift */; };
CC0610072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift */; };
CC0610082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */; };
CC0610092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */; };
CC06100A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */; };
CC0620012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift */; };
CC0620022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift */; };
CC0620032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift */; };
CC0620042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift */; };
CC0620052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift */; };
CC0620062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift */; };
CC0620072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift */; };
CC0620082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */; };
CC0620092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */; };
CC06200A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */; };
CC0630012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift */; };
CC0630022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift */; };
CC0630032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift */; };
CC0630042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift */; };
CC0630052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift */; };
CC0630062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift */; };
CC0630072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift */; };
CC0630082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */; };
CC0630092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */; };
CC06300A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.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 */; };
CFC28133D4D60FD1E436D97C /* CreatorChannelFanTalkTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7923ABAD25E9CC4840EC94 /* CreatorChannelFanTalkTabView.swift */; }; CFC28133D4D60FD1E436D97C /* CreatorChannelFanTalkTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7923ABAD25E9CC4840EC94 /* CreatorChannelFanTalkTabView.swift */; };
@@ -2779,6 +2809,16 @@
CC0500082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationEmptyView.swift; sourceTree = "<group>"; }; CC0500082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationEmptyView.swift; sourceTree = "<group>"; };
CC0500092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationFloatingButton.swift; sourceTree = "<group>"; }; CC0500092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationFloatingButton.swift; sourceTree = "<group>"; };
CC05000A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationCard.swift; sourceTree = "<group>"; }; CC05000A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationCard.swift; sourceTree = "<group>"; };
CC0600012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityTabResponse.swift; sourceTree = "<group>"; };
CC0600022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityApi.swift; sourceTree = "<group>"; };
CC0600032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityRepository.swift; sourceTree = "<group>"; };
CC0600042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityViewModel.swift; sourceTree = "<group>"; };
CC0600052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityTabView.swift; sourceTree = "<group>"; };
CC0600062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityViewModeBar.swift; sourceTree = "<group>"; };
CC0600072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityListItem.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>"; };
CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunityEmptyView.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>"; };
@@ -5430,6 +5470,7 @@
CC0300112FF0300000A5BBA3 /* Audio */, CC0300112FF0300000A5BBA3 /* Audio */,
CC0400112FF0400000A5BBA3 /* Series */, CC0400112FF0400000A5BBA3 /* Series */,
CC05001B2FF0500000A5BBA3 /* Donation */, CC05001B2FF0500000A5BBA3 /* Donation */,
CC06000E2FF0600000A5BBA3 /* Community */,
CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */, CC0100082FF0100000A5BBA3 /* CreatorChannelView.swift */,
CC0100092FF0100000A5BBA3 /* CreatorChannelViewModel.swift */, CC0100092FF0100000A5BBA3 /* CreatorChannelViewModel.swift */,
AA4588B91C0F73344C3DC4A4 /* FanTalk */, AA4588B91C0F73344C3DC4A4 /* FanTalk */,
@@ -5615,6 +5656,47 @@
path = Donation; path = Donation;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
CC06000B2FF0600000A5BBA3 /* Models */ = {
isa = PBXGroup;
children = (
CC0600012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift */,
);
path = Models;
sourceTree = "<group>";
};
CC06000C2FF0600000A5BBA3 /* Repository */ = {
isa = PBXGroup;
children = (
CC0600022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift */,
CC0600032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift */,
);
path = Repository;
sourceTree = "<group>";
};
CC06000D2FF0600000A5BBA3 /* Components */ = {
isa = PBXGroup;
children = (
CC0600062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift */,
CC0600072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift */,
CC0600082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift */,
CC0600092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift */,
CC06000A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift */,
);
path = Components;
sourceTree = "<group>";
};
CC06000E2FF0600000A5BBA3 /* Community */ = {
isa = PBXGroup;
children = (
CC06000B2FF0600000A5BBA3 /* Models */,
CC06000C2FF0600000A5BBA3 /* Repository */,
CC06000D2FF0600000A5BBA3 /* Components */,
CC0600042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift */,
CC0600052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift */,
);
path = Community;
sourceTree = "<group>";
};
D37878F5929361B43B1E3C5C /* Card */ = { D37878F5929361B43B1E3C5C /* Card */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -6099,6 +6181,16 @@
CC0510082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */, CC0510082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */,
CC0510092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */, CC0510092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */,
CC05100A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift in Sources */, CC05100A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift in Sources */,
CC0610012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift in Sources */,
CC0610022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift in Sources */,
CC0610032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift in Sources */,
CC0610042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift in Sources */,
CC0610052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift in Sources */,
CC0610062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift in Sources */,
CC0610072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift in Sources */,
CC0610082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift in Sources */,
CC0610092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */,
CC06100A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */,
409E35082B0759DE00CCB985 /* MoyaProviderExtension.swift in Sources */, 409E35082B0759DE00CCB985 /* MoyaProviderExtension.swift in Sources */,
40DDBD772B2B4FD700A2E8DA /* CreatorCommunityMoreItemView.swift in Sources */, 40DDBD772B2B4FD700A2E8DA /* CreatorCommunityMoreItemView.swift in Sources */,
400EA7742A89C42C004992CF /* StartLiveRequest.swift in Sources */, 400EA7742A89C42C004992CF /* StartLiveRequest.swift in Sources */,
@@ -6945,6 +7037,16 @@
CC0520082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */, CC0520082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */,
CC0520092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */, CC0520092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */,
CC05200A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift in Sources */, CC05200A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift in Sources */,
CC0620012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift in Sources */,
CC0620022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift in Sources */,
CC0620032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift in Sources */,
CC0620042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift in Sources */,
CC0620052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift in Sources */,
CC0620062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift in Sources */,
CC0620072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift in Sources */,
CC0620082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift in Sources */,
CC0620092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */,
CC06200A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */,
401B3F092FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */, 401B3F092FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */,
401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */, 401A3FAC2FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */, 401A3FAD2FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,
@@ -7817,6 +7919,16 @@
CC0530082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */, CC0530082FF0500000A5BBA3 /* CreatorChannelDonationEmptyView.swift in Sources */,
CC0530092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */, CC0530092FF0500000A5BBA3 /* CreatorChannelDonationFloatingButton.swift in Sources */,
CC05300A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift in Sources */, CC05300A2FF0500000A5BBA3 /* CreatorChannelDonationCard.swift in Sources */,
CC0630012FF0600000A5BBA3 /* CreatorChannelCommunityTabResponse.swift in Sources */,
CC0630022FF0600000A5BBA3 /* CreatorChannelCommunityApi.swift in Sources */,
CC0630032FF0600000A5BBA3 /* CreatorChannelCommunityRepository.swift in Sources */,
CC0630042FF0600000A5BBA3 /* CreatorChannelCommunityViewModel.swift in Sources */,
CC0630052FF0600000A5BBA3 /* CreatorChannelCommunityTabView.swift in Sources */,
CC0630062FF0600000A5BBA3 /* CreatorChannelCommunityViewModeBar.swift in Sources */,
CC0630072FF0600000A5BBA3 /* CreatorChannelCommunityListItem.swift in Sources */,
CC0630082FF0600000A5BBA3 /* CreatorChannelCommunityGridItem.swift in Sources */,
CC0630092FF0600000A5BBA3 /* CreatorChannelCommunityUploadButton.swift in Sources */,
CC06300A2FF0600000A5BBA3 /* CreatorChannelCommunityEmptyView.swift in Sources */,
401B3F082FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */, 401B3F082FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */,
401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */, 401A3FB02FEEAFA000A5BBA3 /* MainHomeView.swift in Sources */,
401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */, 401A3FB12FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift in Sources */,

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_grid.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 B

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "ic_new_list.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

View File

@@ -52,14 +52,14 @@ struct CreatorCommunityReportView: View {
Text(I18n.Common.cancel) Text(I18n.Common.cancel)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "9970ff")) .foregroundColor(Color.soda400)
.onTapGesture { .onTapGesture {
isShowing = false isShowing = false
} }
Text(I18n.Report.reportAction) Text(I18n.Report.reportAction)
.appFont(size: 13.3, weight: .medium) .appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "9970ff")) .foregroundColor(Color.soda400)
.onTapGesture { .onTapGesture {
if let selectedIndex = selectedIndex { if let selectedIndex = selectedIndex {
confirmAction(reasons[selectedIndex]) confirmAction(reasons[selectedIndex])

View File

@@ -3276,6 +3276,19 @@ If you block this user, the following features will be restricted.
} }
} }
enum CreatorChannelCommunity {
static var totalLabel: String { pick(ko: "전체", en: "Total", ja: "全体") }
static var listView: String { pick(ko: "리스트형", en: "List", ja: "リスト型") }
static var thumbnailView: String { pick(ko: "썸네일형", en: "Thumbnail", ja: "サムネイル型") }
static var communityEmpty: String {
pick(
ko: "크리에이터가 커뮤니티를 준비 중입니다.\n기대해 주세요!",
en: "The creator is preparing the community.\nPlease look forward to it!",
ja: "クリエイターがコミュニティを準備中です。\nお楽しみに!"
)
}
}
enum HomeRecommendation { enum HomeRecommendation {
static var activityLive: String { static var activityLive: String {
pick(ko: "라이브", en: "Live", ja: "ライブ") pick(ko: "라이브", en: "Live", ja: "ライブ")

View File

@@ -0,0 +1,16 @@
import SwiftUI
struct CreatorChannelCommunityEmptyView: View {
var body: some View {
VStack(spacing: 0) {
Text(I18n.CreatorChannelCommunity.communityEmpty)
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.gray500)
.multilineTextAlignment(.center)
.lineSpacing(SodaSpacing.s4)
}
.frame(maxWidth: .infinity)
.padding(.top, 316)
.padding(.bottom, 120)
}
}

View File

@@ -0,0 +1,82 @@
import SwiftUI
struct CreatorChannelCommunityGridItem: View {
let post: CreatorChannelCommunityPostItem
let isOwnPost: Bool
let onTapPurchase: () -> Void
var body: some View {
GeometryReader { proxy in
ZStack {
if isPaidLocked {
Button(action: onTapPurchase) {
Color.gray400
.overlay(paidPriceOverlay)
}
.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 {
Color.gray900
Text(fallbackText)
.appFont(size: 12, weight: .medium)
.foregroundColor(.white)
.multilineTextAlignment(.center)
.lineLimit(3)
.padding(SodaSpacing.s8)
}
}
.frame(width: proxy.size.width, height: proxy.size.width)
.clipped()
.overlay(alignment: .topTrailing) {
if post.isPinned {
Image("ic_pin")
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
.padding(6)
.shadow(color: .black.opacity(0.45), radius: 2, x: 0, y: 1)
.allowsHitTesting(false)
}
}
}
.aspectRatio(1, contentMode: .fit)
}
private var paidPriceOverlay: 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("\(post.price)")
.appFont(size: 16, weight: .medium)
.foregroundColor(.black)
}
.padding(SodaSpacing.s8)
.background(Color.white)
.clipShape(Capsule())
}
}
private var isPaidLocked: Bool {
post.price > 0 && post.existOrdered == false && isOwnPost == false
}
private var fallbackText: String {
let content = post.content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return "-" }
return String(content.prefix(18))
}
}

View File

@@ -0,0 +1,262 @@
import SwiftUI
import Kingfisher
import SDWebImageSwiftUI
struct CreatorChannelCommunityListItem: View {
let post: CreatorChannelCommunityPostItem
let isOwnPost: Bool
let isOwnCreatorChannel: Bool
let onTapLike: () -> Void
let onTapComment: () -> Void
let onTapMore: () -> Void
let onTapPurchase: () -> Void
@State private var localIsLike: Bool
@State private var localLikeCount: Int
@StateObject private var playManager = CreatorCommunityMediaPlayerManager.shared
@StateObject private var contentPlayManager = ContentPlayManager.shared
init(
post: CreatorChannelCommunityPostItem,
isOwnPost: Bool,
isOwnCreatorChannel: Bool,
onTapLike: @escaping () -> Void,
onTapComment: @escaping () -> Void,
onTapMore: @escaping () -> Void,
onTapPurchase: @escaping () -> Void
) {
self.post = post
self.isOwnPost = isOwnPost
self.isOwnCreatorChannel = isOwnCreatorChannel
self.onTapLike = onTapLike
self.onTapComment = onTapComment
self.onTapMore = onTapMore
self.onTapPurchase = onTapPurchase
_localIsLike = State(initialValue: post.isLiked ?? false)
_localLikeCount = State(initialValue: post.likeCount)
}
var body: some View {
VStack(alignment: .leading, spacing: SodaSpacing.s16) {
if post.isPinned {
pinnedLabel
}
header
contentText
if isPaidLocked {
paidLockedImage
} else if let imageUrl = post.imageUrl, !imageUrl.isEmpty {
imageContent(imageUrl: imageUrl)
}
reactionBar
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(SodaSpacing.s14)
.background(Color.gray900)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
.onChange(of: post.isLiked) { newValue in
localIsLike = newValue ?? false
}
.onChange(of: post.likeCount) { newValue in
localLikeCount = newValue
}
}
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: post.creatorProfileUrl), size: CGSize(width: 42, height: 42))
.background(Color.gray800)
.clipShape(Circle())
.frame(width: 42, height: 42)
VStack(alignment: .leading, spacing: 2) {
Text(post.creatorNickname)
.appFont(size: 14, weight: .medium)
.foregroundColor(.white)
.lineLimit(1)
Text(post.relativeTimeText())
.appFont(size: 14, weight: .regular)
.foregroundColor(Color.gray500)
.lineLimit(1)
}
Spacer(minLength: 0)
if shouldShowOwnerControls {
HStack(spacing: SodaSpacing.s4) {
if post.price > 0 {
priceTag
}
moreButton
}
} else if !isOwnPost {
moreButton
}
}
}
private var priceTag: some View {
HStack(spacing: 2) {
Image("ic_bar_cash")
.resizable()
.frame(width: 18, height: 18)
Text("\(post.price)")
.appFont(size: 12, weight: .regular)
.foregroundColor(.black)
}
.padding(.horizontal, SodaSpacing.s4)
.padding(.vertical, 2)
.background(Color.white)
.clipShape(Capsule())
}
private var moreButton: some View {
Button(action: onTapMore) {
Image("ic_seemore_vertical")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
}
private var contentText: some View {
Text(post.content)
.appFont(size: 16, weight: .regular)
.foregroundColor(.white)
.lineLimit(5)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func imageContent(imageUrl: String) -> some View {
ZStack {
WebImage(url: URL(string: imageUrl))
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
.background(Color.gray800)
if let audioUrl = post.audioUrl, !audioUrl.isEmpty {
audioPlayButton(audioUrl: audioUrl)
}
}
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
}
private var paidLockedImage: some View {
Button(action: onTapPurchase) {
RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)
.fill(Color.gray400)
.frame(maxWidth: .infinity)
.frame(height: 236)
.overlay(paidPriceOverlay)
}
.buttonStyle(.plain)
}
private var paidPriceOverlay: 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("\(post.price)")
.appFont(size: 16, weight: .medium)
.foregroundColor(.black)
}
.padding(SodaSpacing.s8)
.background(Color.white)
.clipShape(Capsule())
}
}
private func audioPlayButton(audioUrl: String) -> some View {
Button {
contentPlayManager.pauseAudio()
playManager.toggleContent(item: CreatorCommunityContentItem(contentId: post.postId, url: audioUrl))
} label: {
Image(playManager.isPlaying && playManager.currentPlayingContentId == post.postId ? "btn_audio_content_pause" : "btn_audio_content_play")
.resizable()
.frame(width: 48, height: 48)
}
.buttonStyle(.plain)
}
private var reactionBar: some View {
HStack(spacing: 15) {
if !isPaidLocked {
Button {
localIsLike.toggle()
localLikeCount += localIsLike ? 1 : -1
localLikeCount = max(0, localLikeCount)
onTapLike()
} label: {
HStack(spacing: SodaSpacing.s4) {
Image(localIsLike ? "ic_feed_community_heart_fill" : "ic_feed_community_heart")
.resizable()
.frame(width: 18, height: 18)
Text("\(localLikeCount)")
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.gray400)
}
}
.buttonStyle(.plain)
}
if post.isCommentAvailable && !isPaidLocked {
Button(action: onTapComment) {
HStack(spacing: SodaSpacing.s4) {
Image("ic_feed_community_reply")
.resizable()
.frame(width: 18, height: 18)
Text("\(post.commentCount)")
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.gray400)
}
}
.buttonStyle(.plain)
}
}
.frame(height: 24)
}
private var isPaidLocked: Bool {
post.price > 0 && post.existOrdered == false && isOwnPost == false
}
private var shouldShowOwnerControls: Bool {
isOwnCreatorChannel && isOwnPost
}
}

View File

@@ -0,0 +1,32 @@
import SwiftUI
struct CreatorChannelCommunityUploadButton: View {
let action: () -> Void
var body: some View {
VStack {
Button(action: action) {
HStack(spacing: SodaSpacing.s6) {
Image("ic_new_upload_community_post")
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text(I18n.CreatorChannelHome.uploadCommunityPost)
.appFont(size: 16, weight: .bold)
.foregroundColor(.white)
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.frame(height: 52)
.padding(.horizontal, SodaSpacing.s14)
.background(Color.soda400)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
.padding(.top, SodaSpacing.s14)
.padding(.bottom, 34)
.background(Color.black)
}
}

View File

@@ -0,0 +1,59 @@
import SwiftUI
struct CreatorChannelCommunityViewModeBar: View {
let communityPostCount: Int
let viewMode: CreatorChannelCommunityViewMode
let onTapToggle: () -> Void
var body: some View {
HStack(alignment: .center, spacing: SodaSpacing.s8) {
HStack(spacing: SodaSpacing.s4) {
Text(I18n.CreatorChannelCommunity.totalLabel)
.appFont(size: 16, weight: .medium)
.foregroundColor(.white)
Text("\(communityPostCount)")
.appFont(size: 16, weight: .medium)
.foregroundColor(Color.gray500)
}
Spacer()
Button(action: onTapToggle) {
HStack(spacing: SodaSpacing.s4) {
Text(viewModeTitle)
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.gray400)
Image(viewModeIconName)
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, SodaSpacing.s14)
.frame(height: 52)
.background(Color.black)
}
private var viewModeTitle: String {
switch viewMode {
case .list:
return I18n.CreatorChannelCommunity.listView
case .thumbnail:
return I18n.CreatorChannelCommunity.thumbnailView
}
}
private var viewModeIconName: String {
switch viewMode {
case .list:
return "ic_new_list"
case .thumbnail:
return "ic_new_grid"
}
}
}

View File

@@ -0,0 +1,128 @@
import SwiftUI
struct CreatorChannelCommunityTabView: View {
let creatorId: Int
let isOwnCreatorChannel: Bool
@ObservedObject var viewModel: CreatorChannelCommunityViewModel
private let gridColumns = [
GridItem(.flexible(), spacing: 0),
GridItem(.flexible(), spacing: 0),
GridItem(.flexible(), spacing: 0)
]
var body: some View {
content
.background(Color.black)
.onAppear {
if viewModel.hasLoaded == false {
viewModel.fetchFirstPage(creatorId: creatorId)
}
}
.sheet(isPresented: $viewModel.isShowCommentListView) {
NavigationStack {
CreatorCommunityCommentListView(
isPresented: $viewModel.isShowCommentListView,
creatorId: creatorId,
postId: viewModel.selectedPostId,
isShowSecret: viewModel.isShowSecret
)
}
.toolbar(.hidden, for: .navigationBar)
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1)
}
private var content: some View {
VStack(spacing: 0) {
if isEmptyState {
CreatorChannelCommunityEmptyView()
} else {
CreatorChannelCommunityViewModeBar(
communityPostCount: viewModel.communityPostCount,
viewMode: viewModel.viewMode,
onTapToggle: viewModel.toggleViewMode
)
if viewModel.viewMode == .list {
listContent
} else {
gridContent
}
}
}
}
private var listContent: some View {
LazyVStack(spacing: SodaSpacing.s8) {
ForEach(viewModel.communityPosts) { post in
let isOwnPost = post.creatorId == UserDefaults.int(forKey: .userId)
CreatorChannelCommunityListItem(
post: post,
isOwnPost: isOwnPost,
isOwnCreatorChannel: isOwnCreatorChannel,
onTapLike: {
viewModel.communityPostLike(postId: post.postId)
},
onTapComment: {
viewModel.openCommentList(post: post)
},
onTapMore: {
viewModel.openReportMenu(post: post)
},
onTapPurchase: {
viewModel.openPurchaseDialog(post: post)
}
)
.onAppear {
viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: post)
}
}
loadingNextPageView
}
.padding(.horizontal, SodaSpacing.s14)
.padding(.bottom, contentBottomPadding)
}
private var gridContent: some View {
LazyVGrid(columns: gridColumns, spacing: 0) {
ForEach(viewModel.communityPosts) { post in
let isOwnPost = post.creatorId == UserDefaults.int(forKey: .userId)
CreatorChannelCommunityGridItem(
post: post,
isOwnPost: isOwnPost,
onTapPurchase: {
viewModel.openPurchaseDialog(post: post)
}
)
.onAppear {
viewModel.fetchNextPageIfNeeded(creatorId: creatorId, currentItem: post)
}
}
loadingNextPageView
}
.padding(.bottom, contentBottomPadding)
}
@ViewBuilder
private var loadingNextPageView: some View {
if viewModel.isLoadingNextPage {
ProgressView()
.padding(.vertical, SodaSpacing.s20)
}
}
private var isEmptyState: Bool {
guard viewModel.hasLoaded else { return false }
return viewModel.communityPostCount == 0
}
private var contentBottomPadding: CGFloat {
isOwnCreatorChannel ? 120 : SodaSpacing.s20
}
}

View File

@@ -0,0 +1,351 @@
import Foundation
import Combine
import Moya
enum CreatorChannelCommunityViewMode {
case list
case thumbnail
}
final class CreatorChannelCommunityViewModel: ObservableObject {
private let repository = CreatorChannelCommunityRepository()
private let communityRepository = CreatorCommunityRepository()
private let reportRepository = ReportRepository()
private var subscription = Set<AnyCancellable>()
private let pageSize = 20
private var latestRequestId = 0
@Published var isLoading = false
@Published var isLoadingNextPage = false
@Published var response: CreatorChannelCommunityTabResponse?
@Published var communityPosts = [CreatorChannelCommunityPostItem]()
@Published var viewMode: CreatorChannelCommunityViewMode = .list
@Published var page = 0
@Published var size = 20
@Published var hasNext = false
@Published var hasLoaded = false
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isShowCommentListView = false {
didSet {
if !isShowCommentListView {
selectedPostId = 0
isShowSecret = false
}
}
}
@Published var isShowReportMenu = false
@Published var isShowReportView = false {
didSet {
if !isShowReportView {
selectedPostId = 0
}
}
}
@Published var isShowDeleteConfirm = false {
didSet {
if !isShowDeleteConfirm {
selectedPostId = 0
}
}
}
@Published var isShowPostPurchaseView = false {
didSet {
if !isShowPostPurchaseView {
selectedPostId = 0
selectedPostPrice = 0
}
}
}
@Published var selectedPostId = 0
@Published var selectedPostPrice = 0
@Published var selectedPostIsPinned = false
@Published var isShowSecret = false
var communityPostCount: Int {
response?.communityPostCount ?? 0
}
func fetchFirstPage(creatorId: Int) {
fetchCommunity(creatorId: creatorId, page: 0, isNextPage: false)
}
func fetchNextPageIfNeeded(creatorId: Int, currentItem: CreatorChannelCommunityPostItem) {
guard communityPosts.last?.postId == currentItem.postId else { return }
guard hasNext, isLoadingNextPage == false, isLoading == false else { return }
fetchCommunity(creatorId: creatorId, page: page + 1, isNextPage: true)
}
func toggleViewMode() {
viewMode = viewMode == .list ? .thumbnail : .list
}
func isPaidLocked(_ post: CreatorChannelCommunityPostItem, isOwnPost: Bool) -> Bool {
post.price > 0 && post.existOrdered == false && isOwnPost == false
}
func openCommentList(post: CreatorChannelCommunityPostItem) {
selectedPostId = post.postId
isShowSecret = post.price > 0 && post.existOrdered && post.creatorId != UserDefaults.int(forKey: .userId)
isShowCommentListView = true
}
func openReportMenu(post: CreatorChannelCommunityPostItem) {
selectedPostId = post.postId
selectedPostPrice = post.price
selectedPostIsPinned = post.isPinned
isShowReportMenu = true
}
func openPurchaseDialog(post: CreatorChannelCommunityPostItem) {
selectedPostId = post.postId
selectedPostPrice = post.price
isShowPostPurchaseView = true
}
func communityPostLike(postId: Int) {
communityRepository.communityPostLike(postId: postId)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { _ in }
.store(in: &subscription)
}
func report(reason: String) {
guard selectedPostId > 0 else { return }
isLoading = true
let request = ReportRequest(type: .COMMUNITY_POST, reason: reason, communityPostId: selectedPostId)
reportRepository.report(request: request)
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.applyFailureState()
}
} receiveValue: { [weak self] response in
guard let self else { return }
self.isLoading = false
self.selectedPostId = 0
do {
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
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)
}
func deleteCommunityPost(creatorId: Int) {
guard selectedPostId > 0 else { return }
isLoading = true
let request = ModifyCommunityPostRequest(creatorCommunityId: selectedPostId, isActive: false)
var multipartData = [MultipartFormData]()
let encoder = JSONEncoder()
encoder.outputFormatting = .withoutEscapingSlashes
guard let jsonData = try? encoder.encode(request) else {
applyFailureState()
return
}
multipartData.append(MultipartFormData(provider: .data(jsonData), name: "request"))
communityRepository.modifyCommunityPost(parameters: multipartData)
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.applyFailureState()
}
} receiveValue: { [weak self] response in
guard let self else { return }
self.isLoading = false
self.selectedPostId = 0
do {
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
if decoded.success {
self.errorMessage = I18n.Explorer.deleted
self.isShowPopup = true
self.fetchFirstPage(creatorId: creatorId)
} 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)
}
func updateCommunityPostFixed(creatorId: Int) {
guard selectedPostId > 0 else { return }
isLoading = true
let request = UpdateCommunityPostFixedRequest(postId: selectedPostId, isFixed: !selectedPostIsPinned)
communityRepository.updateCommunityPostFixed(request: request)
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.applyFailureState()
}
} receiveValue: { [weak self] response in
guard let self else { return }
self.isLoading = false
do {
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
if decoded.success {
self.fetchFirstPage(creatorId: creatorId)
} 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)
}
func purchaseCommunityPost(creatorId: Int) {
guard selectedPostId > 0 else { return }
isLoading = true
communityRepository.purchaseCommunityPost(postId: selectedPostId)
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.applyFailureState()
}
} receiveValue: { [weak self] response in
guard let self else { return }
self.isLoading = false
self.selectedPostId = 0
do {
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data)
if decoded.success {
self.fetchFirstPage(creatorId: creatorId)
} else {
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
}
} catch {
ERROR_LOG(error.localizedDescription)
self.fetchFirstPage(creatorId: creatorId)
}
}
.store(in: &subscription)
}
private func fetchCommunity(creatorId: Int, page requestPage: Int, isNextPage: Bool) {
latestRequestId += 1
let requestId = latestRequestId
if isNextPage {
isLoadingNextPage = true
} else {
isLoading = true
}
repository.getCommunity(creatorId: creatorId, page: requestPage, size: pageSize)
.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
self.isLoadingNextPage = 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<CreatorChannelCommunityTabResponse>.self, from: response.data)
if let data = decoded.data, decoded.success {
self.applySuccessState(data, isNextPage: isNextPage)
self.hasLoaded = true
} 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.isLoading = false
self.isLoadingNextPage = false
}
.store(in: &subscription)
}
private func applySuccessState(_ data: CreatorChannelCommunityTabResponse, isNextPage: Bool) {
response = data
page = data.page
size = data.size
hasNext = data.hasNext
if isNextPage {
communityPosts.append(contentsOf: data.communityPosts)
} else {
communityPosts = data.communityPosts
}
}
private func applyFailureState() {
errorMessage = I18n.Common.commonError
isShowPopup = true
hasLoaded = true
isLoading = false
isLoadingNextPage = false
}
}

View File

@@ -0,0 +1,35 @@
import Foundation
struct CreatorChannelCommunityTabResponse: Decodable {
let communityPostCount: Int
let communityPosts: [CreatorChannelCommunityPostResponse]
let page: Int
let size: Int
let hasNext: Bool
struct CreatorChannelCommunityPostResponse: 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 likeCount: Int
let commentCount: Int
let isPinned: Bool
let existOrdered: Bool
let isLiked: Bool?
var id: Int { postId }
func relativeTimeText(now: Date = Date()) -> String {
DateParser.relativeTimeText(fromUTC: createdAtUtc, fallback: createdAtUtc, now: now)
}
}
}
typealias CreatorChannelCommunityPostItem = CreatorChannelCommunityTabResponse.CreatorChannelCommunityPostResponse

View File

@@ -0,0 +1,41 @@
import Foundation
import Moya
enum CreatorChannelCommunityApi {
case getCommunity(creatorId: Int, page: Int, size: Int)
}
extension CreatorChannelCommunityApi: TargetType {
var baseURL: URL {
return URL(string: BASE_URL)!
}
var path: String {
switch self {
case .getCommunity(let creatorId, _, _):
return "/api/v2/creator-channels/\(creatorId)/community"
}
}
var method: Moya.Method {
switch self {
case .getCommunity:
return .get
}
}
var task: Moya.Task {
switch self {
case .getCommunity(_, let page, let size):
return .requestParameters(
parameters: ["page": page, "size": size],
encoding: URLEncoding.queryString
)
}
}
var headers: [String: String]? {
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
}
}

View File

@@ -0,0 +1,13 @@
import Foundation
import Combine
import CombineMoya
import Moya
final class CreatorChannelCommunityRepository {
private let api = MoyaProvider<CreatorChannelCommunityApi>()
func getCommunity(creatorId: Int, page: Int, size: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getCommunity(creatorId: creatorId, page: page, size: size))
}
}

View File

@@ -9,6 +9,7 @@ struct CreatorChannelView: View {
@StateObject private var channelDonationViewModel = ChannelDonationViewModel() @StateObject private var channelDonationViewModel = ChannelDonationViewModel()
@StateObject private var donationViewModel = CreatorChannelDonationViewModel() @StateObject private var donationViewModel = CreatorChannelDonationViewModel()
@StateObject private var fanTalkViewModel = CreatorChannelFanTalkViewModel() @StateObject private var fanTalkViewModel = CreatorChannelFanTalkViewModel()
@StateObject private var communityViewModel = CreatorChannelCommunityViewModel()
@StateObject private var liveViewModel = LiveViewModel() @StateObject private var liveViewModel = LiveViewModel()
@StateObject private var mypageViewModel = MyPageViewModel() @StateObject private var mypageViewModel = MyPageViewModel()
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token) @AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
@@ -142,6 +143,15 @@ struct CreatorChannelView: View {
.padding(.bottom, SodaSpacing.s14) .padding(.bottom, SodaSpacing.s14)
} }
if isOwnCreatorChannel && viewModel.selectedTab == .community && !viewModel.isLoading {
VStack {
Spacer()
CreatorChannelCommunityUploadButton(action: showCommunityWrite)
}
.ignoresSafeArea(.container, edges: .bottom)
}
if isShowAuthConfirmView { if isShowAuthConfirmView {
authConfirmDialog authConfirmDialog
} }
@@ -168,6 +178,8 @@ struct CreatorChannelView: View {
} }
) )
} }
communityOverlay(proxy: proxy)
} }
} }
.navigationBarBackButtonHidden(true) .navigationBarBackButtonHidden(true)
@@ -281,6 +293,12 @@ struct CreatorChannelView: View {
viewModel: fanTalkViewModel, viewModel: fanTalkViewModel,
onTapWrite: showFanTalkWriteView onTapWrite: showFanTalkWriteView
) )
} else if viewModel.selectedTab == .community {
CreatorChannelCommunityTabView(
creatorId: creatorId,
isOwnCreatorChannel: isOwnCreatorChannel,
viewModel: communityViewModel
)
} else if viewModel.selectedTab != .home { } else if viewModel.selectedTab != .home {
CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title) CreatorChannelPlaceholderTabView(title: viewModel.selectedTab.title)
} else if let response = viewModel.response { } else if let response = viewModel.response {
@@ -404,6 +422,7 @@ struct CreatorChannelView: View {
AppState.shared.setAppStep( AppState.shared.setAppStep(
step: .creatorCommunityWrite(onSuccess: { step: .creatorCommunityWrite(onSuccess: {
viewModel.fetchHome(creatorId: creatorId) viewModel.fetchHome(creatorId: creatorId)
communityViewModel.fetchFirstPage(creatorId: creatorId)
}) })
) )
} }
@@ -494,6 +513,92 @@ struct CreatorChannelView: View {
isShowChannelDonationDialog = true isShowChannelDonationDialog = true
} }
@ViewBuilder
private func communityOverlay(proxy: GeometryProxy) -> some View {
if viewModel.selectedTab == .community {
if communityViewModel.isShowReportMenu {
VStack(spacing: 0) {
CreatorCommunityMenuView(
isShowing: $communityViewModel.isShowReportMenu,
isShowCreatorMenu: isOwnCreatorChannel,
isFixed: communityViewModel.selectedPostIsPinned,
fixedAction: {
communityViewModel.updateCommunityPostFixed(creatorId: creatorId)
},
modifyAction: showCommunityModify,
deleteAction: {
if isOwnCreatorChannel {
communityViewModel.isShowDeleteConfirm = true
}
},
reportAction: {
communityViewModel.isShowReportView = true
}
)
if proxy.safeAreaInsets.bottom > 0 {
Rectangle()
.foregroundColor(Color(hex: "222222"))
.frame(width: proxy.size.width, height: 15.3)
}
}
.ignoresSafeArea()
}
if communityViewModel.isShowReportView {
CreatorCommunityReportView(isShowing: $communityViewModel.isShowReportView) { reason in
communityViewModel.report(reason: reason)
}
}
if communityViewModel.isShowDeleteConfirm {
SodaDialog(
title: I18n.Common.postDeleteTitle,
desc: I18n.Common.confirmDeleteQuestion,
confirmButtonTitle: I18n.Common.delete,
confirmButtonAction: {
communityViewModel.deleteCommunityPost(creatorId: creatorId)
communityViewModel.isShowDeleteConfirm = false
},
cancelButtonTitle: I18n.Common.cancel,
cancelButtonAction: {
communityViewModel.isShowDeleteConfirm = false
},
textAlignment: .center
)
}
if communityViewModel.isShowPostPurchaseView {
CommunityPostPurchaseDialog(
isShowing: $communityViewModel.isShowPostPurchaseView,
can: communityViewModel.selectedPostPrice,
confirmAction: {
communityViewModel.purchaseCommunityPost(creatorId: creatorId)
}
)
}
if communityViewModel.isLoading {
LoadingView()
}
}
}
private func showCommunityModify() {
let postId = communityViewModel.selectedPostId
guard postId > 0 else { return }
AppState.shared.setAppStep(
step: .creatorCommunityModify(
postId: postId,
onSuccess: {
communityViewModel.fetchFirstPage(creatorId: creatorId)
}
)
)
communityViewModel.selectedPostId = 0
}
private func showFanTalkWriteView() {} private func showFanTalkWriteView() {}
} }

View File

@@ -0,0 +1,221 @@
# 크리에이터 채널 커뮤니티 탭 구현 계획
## 기준 문서
- PRD: `docs/20260705_크리에이터_채널_커뮤니티_탭/prd.md`
- 기존 오디오 탭 PRD: `docs/20260704_크리에이터_채널_오디오_탭/prd.md`
- 기존 팬Talk 탭 PRD: `docs/20260704_크리에이터_채널_팬Talk_탭/prd.md`
- 기존 커뮤니티 그리드/리스트 전환 계획: `docs/plan-task/20260305_크리에이터커뮤니티전체보기그리드리스트전환구현.md`
- 코드 스타일: `docs/agent-guides/code-style.md`
- 빌드/검증: `docs/agent-guides/build-test-verification.md`
- Figma 전체 리스트형: `290:9061`, `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-9061&m=dev`
- Figma 전체 썸네일형: `290:9073`, `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-9073&m=dev`
- Figma 전체 리스트형 + 본인 채널: `665:19021`, `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=665-19021&m=dev`
- Figma 전체 썸네일형 + 본인 채널: Figma 설명 기준, 썸네일 UI는 `290:9073`과 동일하고 하단 고정 `커뮤니티 글 올리기` 버튼을 표시한다.
- Figma 유료 미구매 게시글: `290:9066`, `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-9066&m=dev`
- Figma 유료 금액 우측 상단 표시: `665:19024`, `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=665-19024&m=dev`
- Figma Empty: `290:8994`, `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-8994&m=dev`
## 구현 원칙
- 기존 `CreatorChannelView`의 공통 shell, header, sticky tab-bar, title bar 동작은 유지한다.
- `CreatorChannelTab.community` 콘텐츠만 신규 `CreatorChannelCommunityTabView`로 교체한다.
- 커뮤니티 탭 API는 V2 신규 endpoint를 사용한다.
- 좋아요, 댓글 sheet, 구매 dialog, 신고, 수정, 삭제, 고정, 오디오 재생은 기존 커뮤니티 구현 흐름을 가능한 한 재사용한다.
- 리스트형/썸네일형 토글은 API 재호출 없이 현재 로드된 목록만 재배치한다.
- 게시글 상세 화면은 후속 범위이므로 item tap 상세 이동은 연결하지 않는다.
- GIF는 정지 이미지로 변환하지 않고 재생 가능한 이미지 컴포넌트를 사용한다.
## 주요 대상 파일
### 생성
- `SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityTabResponse.swift`
- `SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift`
- `SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift`
- `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift`
- `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift`
- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityViewModeBar.swift`
- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift`
- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift`
- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityUploadButton.swift`
- `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityEmptyView.swift`
### 수정
- `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
- `SodaLive/Sources/I18n/I18n.swift`
- 필요 시 `SodaLive.xcodeproj/project.pbxproj`
### 확인/재사용
- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift`
- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllItemView.swift`
- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllViewModel.swift`
- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/Player/CreatorCommunityMediaPlayerManager.swift`
- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityRepository.swift`
- `SodaLive/Sources/Dialog/CommunityPostPurchaseDialog.swift`
## TASK 체크리스트
### Phase 1: 문서와 API 모델
- [x] **Task 1.1: PRD와 계획 문서 생성**
- 대상 파일:
- 생성: `docs/20260705_크리에이터_채널_커뮤니티_탭/prd.md`
- 생성: `docs/20260705_크리에이터_채널_커뮤니티_탭/plan-task.md`
- 검증 기준:
- 실행 명령: `rg "290:9061|290:9073|665:19021|290:9066|665:19024|290:8994|CreatorChannelCommunityTabResponse" docs/20260705_크리에이터_채널_커뮤니티_탭`
- 기대 결과: 구현할 UI의 Figma URL과 응답 모델 요구사항이 문서에 기록된다.
- [x] **Task 1.2: 커뮤니티 탭 Response model 생성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityTabResponse.swift`
- 작업 내용:
- `CreatorChannelCommunityTabResponse``CreatorChannelCommunityPostResponse``Decodable`로 생성한다.
- Kotlin `Long` 필드는 Swift `Int`로 선언한다.
- `creatorProfileUrl`, `existOrdered`, `isPinned`를 포함한다.
- 검증 기준:
- 실행 명령: `rg "struct CreatorChannelCommunityTabResponse|communityPostCount|communityPosts|hasNext|struct CreatorChannelCommunityPostResponse|creatorProfileUrl|existOrdered|isPinned" SodaLive/Sources/V2/CreatorChannel/Community/Models/CreatorChannelCommunityTabResponse.swift`
### Phase 2: API, Repository, ViewModel
- [x] **Task 2.1: 커뮤니티 탭 API endpoint 생성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift`
- 작업 내용:
- `CreatorChannelCommunityApi.getCommunity(creatorId:page:size:)`를 만든다.
- path는 `/api/v2/creator-channels/{creatorId}/community`로 구성한다.
- query는 `page`, `size``URLEncoding.queryString`으로 전송한다.
- 검증 기준:
- 실행 명령: `rg "CreatorChannelCommunityApi|getCommunity|/api/v2/creator-channels/|/community|page|size|URLEncoding.queryString" SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityApi.swift`
- [x] **Task 2.2: 커뮤니티 탭 Repository 생성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift`
- 작업 내용:
- `MoyaProvider<CreatorChannelCommunityApi>`를 사용하는 `getCommunity(...)` publisher를 제공한다.
- 검증 기준:
- 실행 명령: `rg "CreatorChannelCommunityRepository|MoyaProvider<CreatorChannelCommunityApi>|AnyPublisher<Response, MoyaError>|getCommunity" SodaLive/Sources/V2/CreatorChannel/Community/Repository/CreatorChannelCommunityRepository.swift`
- [x] **Task 2.3: 커뮤니티 탭 ViewModel 생성**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift`
- 작업 내용:
- 상태는 loading, response, `communityPosts`, view mode, page, size, hasNext, hasLoaded, error toast, 선택 post id/price/action 상태를 둔다.
- 첫 페이지 조회, 다음 페이지 조회, 새로고침, view mode 토글을 제공한다.
- `communityPostCount`는 sort-bar 표시용 computed property로 제공한다.
- 유료 미구매 판정 helper를 제공한다.
- 검증 기준:
- 실행 명령: `rg "CreatorChannelCommunityViewModel|fetchFirstPage|fetchNextPageIfNeeded|toggleViewMode|communityPostCount|hasNext|page|size|isPaidLocked" SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift`
### Phase 3: 리스트형/썸네일형 UI
- [x] **Task 3.1: 리스트형/썸네일형 토글 bar 구현**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityViewModeBar.swift`
- 수정: `SodaLive/Sources/I18n/I18n.swift`
- 작업 내용:
- 좌측에는 `전체 {communityPostCount}`를 표시한다.
- 우측 버튼은 리스트형/썸네일형 문구와 `ic_new_list` / `ic_new_grid` 아이콘을 표시한다.
- 정렬 popup이나 `ContentSort`는 사용하지 않는다.
- 검증 기준:
- 실행 명령: `rg "CreatorChannelCommunityViewModeBar|communityPostCount|ic_new_list|ic_new_grid|listView|thumbnailView" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityViewModeBar.swift SodaLive/Sources/I18n/I18n.swift`
- [x] **Task 3.2: 리스트형 item 구현**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift`
- 작업 내용:
- Figma `290:9061`, `665:19021`, `290:9066`, `665:19024` 기준으로 작성자, 시간, 본문, 이미지/GIF, 오디오 재생 버튼, 가격, 더보기, 좋아요, 댓글 표시를 구성한다.
- `isCommentAvailable == false`이면 댓글 영역을 숨긴다.
- 유료 미구매 상태이면 회색 `RoundedRectangle`과 가격 UI를 표시하고 오디오 재생 버튼을 숨긴다.
- `imageUrl == nil`이면 유/무료 관계없이 이미지 영역을 표시하지 않는다.
- GIF 재생은 `WebImage` 등 기존 재생 가능한 이미지 컴포넌트를 우선 사용한다.
- 검증 기준:
- 실행 명령: `rg "CreatorChannelCommunityListItem|creatorProfileUrl|isCommentAvailable|RoundedRectangle|audioUrl|CommunityPostPurchase|isPinned|existOrdered" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift`
- [x] **Task 3.3: 썸네일형 grid item 구현**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift`
- 작업 내용:
- Figma `290:9073` 기준 3열 grid item을 만든다.
- 이미지/GIF가 있으면 정방형으로 표시한다.
- `imageUrl == nil`이면 유/무료 관계없이 기존 커뮤니티 전체보기 fallback처럼 글 일부를 표시한다.
- 유료 미구매 상태의 가격 UI는 중앙형으로 표시한다.
- 고정 표시는 별도로 분리한다.
- 검증 기준:
- 실행 명령: `rg "CreatorChannelCommunityGridItem|aspectRatio\\(1|fallbackText|isPaidLocked|isPinned|WebImage|ic_pin" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityGridItem.swift`
- [x] **Task 3.4: Empty view 구현**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityEmptyView.swift`
- 수정: `SodaLive/Sources/I18n/I18n.swift`
- 작업 내용:
- `communityPostCount == 0`일 때 Figma `290:8994` 기준 empty view를 만든다.
- 검증 기준:
- 실행 명령: `rg "CreatorChannelCommunityEmptyView|communityEmpty|290:8994" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityEmptyView.swift SodaLive/Sources/I18n/I18n.swift docs/20260705_크리에이터_채널_커뮤니티_탭`
### Phase 4: 액션 재사용과 본인 채널 CTA
- [x] **Task 4.1: 커뮤니티 탭 화면 조립**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift`
- 작업 내용:
- 첫 진입 시 `fetchFirstPage(creatorId:)`를 호출한다.
- view mode에 따라 list 또는 grid를 표시한다.
- 마지막 item 노출 시 `fetchNextPageIfNeeded`로 append한다.
- 댓글 sheet, 구매 dialog, 신고 view, 삭제 confirm dialog, 더보기 menu 상태를 연결한다.
- 검증 기준:
- 실행 명령: `rg "CreatorChannelCommunityTabView|CreatorChannelCommunityViewModeBar|CreatorChannelCommunityListItem|CreatorChannelCommunityGridItem|fetchFirstPage|fetchNextPageIfNeeded|CommunityPostPurchaseDialog|CreatorCommunityCommentListView" SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityTabView.swift`
- [x] **Task 4.2: 기존 커뮤니티 mutation/action 흐름 재사용**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/CreatorChannel/Community/CreatorChannelCommunityViewModel.swift`
- 확인: `SodaLive/Sources/Explorer/Profile/CreatorCommunity/CreatorCommunityRepository.swift`
- 확인: `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllViewModel.swift`
- 작업 내용:
- 좋아요, 구매, 신고, 삭제, 고정 API 호출은 기존 repository와 request 모델을 재사용한다.
- 성공 후 첫 페이지를 다시 조회한다.
- 수정은 기존 `AppState.shared.setAppStep(step: .creatorCommunityModify(...))`로 이동한다.
- 검증 기준:
- 실행 명령: `rg "communityPostLike|purchaseCommunityPost|ReportType.COMMUNITY_POST|deleteCommunityPost|updateCommunityPostFixed|creatorCommunityModify" SodaLive/Sources/V2/CreatorChannel/Community SodaLive/Sources/Explorer/Profile/CreatorCommunity`
- [x] **Task 4.3: 본인 채널 업로드 버튼 구현**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityUploadButton.swift`
- 작업 내용:
- 본인 채널 하단 고정 `커뮤니티 글 올리기` 버튼을 만든다.
- 버튼 문구는 기존 다국어 문구를 우선 재사용한다.
- 검증 기준:
- 실행 명령: `rg "CreatorChannelCommunityUploadButton|uploadCommunityPost|frame\\(maxWidth: \\.infinity|Capsule" SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityUploadButton.swift`
### Phase 5: CreatorChannelView 연결과 검증
- [x] **Task 5.1: 커뮤니티 탭 콘텐츠와 CTA 연결**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
- 작업 내용:
- `CreatorChannelTab.community` 임시 화면을 `CreatorChannelCommunityTabView`로 교체한다.
- 본인 채널이고 커뮤니티 탭이면 `CreatorChannelCommunityUploadButton`을 하단 고정으로 표시한다.
- 작성 성공 후 홈과 커뮤니티 탭 첫 페이지를 갱신할 수 있도록 closure를 연결한다.
- 검증 기준:
- 실행 명령: `rg "CreatorChannelCommunityTabView|CreatorChannelCommunityUploadButton|viewModel\\.selectedTab == \\.community|showCommunityWrite" SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
- [x] **Task 5.2: 프로젝트 파일 등록과 정적 검증**
- 대상 파일:
- 확인/수정: `SodaLive.xcodeproj/project.pbxproj`
- 검증 기준:
- 실행 명령: `git diff --check -- docs/20260705_크리에이터_채널_커뮤니티_탭 SodaLive/Sources/V2/CreatorChannel SodaLive/Sources/I18n/I18n.swift SodaLive.xcodeproj/project.pbxproj`
- 기대 결과: 공백 오류 없이 성공한다.
- 실행 명령: `plutil -lint SodaLive.xcodeproj/project.pbxproj`
- 기대 결과: `OK`가 출력된다.
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 기대 결과: 빌드 성공.
## 검증 기록
- 2026-07-05: `docs/agent-guides/documentation-policy.md`를 확인해 신규 문서 경로를 `docs/20260705_크리에이터_채널_커뮤니티_탭/prd.md`, `docs/20260705_크리에이터_채널_커뮤니티_탭/plan-task.md`로 결정했다.
- 2026-07-05: 기존 `docs/20260704_크리에이터_채널_오디오_탭`, `docs/20260704_크리에이터_채널_팬Talk_탭`, `docs/plan-task/20260305_크리에이터커뮤니티전체보기그리드리스트전환구현.md`를 확인해 문서 형식과 구현 계획 단위를 맞췄다.
- 2026-07-05: `CreatorChannelView`, 기존 `CreatorCommunityAllView`, `CreatorCommunityAllItemView`, `CreatorCommunityAllViewModel`, `GetCommunityPostListResponse`를 확인해 V2 커뮤니티 탭 전용 신규 파일과 기존 커뮤니티 액션 재사용 범위를 정했다.
- 2026-07-05: 사용자 추가 확인으로 Empty Figma `290:8994`, 썸네일형 유료 미구매 가격 UI 중앙형, `imageUrl == nil`일 때 유/무료 관계없이 이미지 미표시 규칙을 반영했다.
- 2026-07-05: `CreatorChannelCommunityTabResponse`, `CreatorChannelCommunityApi`, `CreatorChannelCommunityRepository`, `CreatorChannelCommunityViewModel`을 생성하고 V2 커뮤니티 탭 목록 조회, 페이지네이션, 리스트/썸네일 전환, 기존 커뮤니티 mutation/action 재사용 흐름을 연결했다.
- 2026-07-05: `CreatorChannelCommunityTabView`와 하위 컴포넌트 5개를 생성해 리스트형/썸네일형/Empty/본인 채널 업로드 CTA를 연결하고, `CreatorChannelView``.community` 탭 임시 화면을 신규 탭으로 교체했다.
- 2026-07-05: `I18n.CreatorChannelCommunity` 문구를 추가하고, `ic_new_grid`, `ic_new_list` 신규 asset을 사용하는 view mode toggle을 구현했다.
- 2026-07-05: `git diff --check`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`로 정적 검증과 Debug 빌드를 확인했다.

View File

@@ -0,0 +1,192 @@
# PRD: 크리에이터 채널 커뮤니티 탭
## 1. Overview
크리에이터 채널 공통 shell의 `커뮤니티` 탭에서 크리에이터의 커뮤니티 게시글을 리스트형 또는 썸네일형으로 제공한다. 상단 title bar, header, sticky tab-bar는 기존 크리에이터 채널 홈/라이브/오디오/시리즈/팬Talk/후원 구현을 재사용하고, tab-bar 아래 콘텐츠만 커뮤니티 탭 전용 API 응답으로 구성한다.
API는 `GET /api/v2/creator-channels/{creatorId}/community`를 사용한다. `creatorId`는 path variable이며, query parameter는 `page`, `size`를 사용한다. 기본값은 `page=0`, `size=20`이다.
Figma 참조:
- 전체 리스트형: `290:9061`, `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-9061&m=dev`
- 전체 썸네일형: `290:9073`, `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-9073&m=dev`
- 전체 리스트형 + 본인 채널: `665:19021`, `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=665-19021&m=dev`
- 전체 썸네일형 + 본인 채널: Figma 설명 기준, 썸네일 UI는 `290:9073`과 동일하고 하단 고정 `커뮤니티 글 올리기` 버튼을 표시한다.
- 유료이고 구매하지 않은 게시글: `290:9066`, `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-9066&m=dev`
- 유료 금액 우측 상단 표시: `665:19024`, `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=665-19024&m=dev`
- Empty: `290:8994`, `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-8994&m=dev`
## 2. Problem
- 현재 `CreatorChannelTab.community`는 임시 화면으로 표시되어 V2 크리에이터 채널 안에서 커뮤니티 게시글 목록을 확인할 수 없다.
- 기존 `CreatorCommunityAllView`는 구 프로필 커뮤니티 전체보기 화면이고, V2 채널 탭의 sticky header, tab-bar, sort-bar 구조와 직접 맞지 않는다.
- 커뮤니티 탭은 오디오 탭의 정렬 선택과 달리 리스트형/썸네일형 보기 전환 버튼이 필요하다.
- 유료 미구매 게시글, 댓글 비활성 게시글, 본인 채널 게시글의 표시 조건이 기존 전체보기와 일부 달라 V2 탭 기준으로 명확히 분리해야 한다.
## 3. Goals
- `CreatorChannelTab.community` 선택 시 커뮤니티 탭 API를 호출하고 응답 데이터로 화면을 구성한다.
- 기본 query는 `page=0`, `size=20`이다.
- sort-bar 좌측에는 `전체 {communityPostCount}`를 표시한다.
- sort-bar 우측은 정렬이 아니라 리스트형/썸네일형 토글 버튼으로 제공한다.
- 기본 보기 방식은 리스트형이고, 기본 아이콘은 `ic_new_list`를 사용한다.
- 토글 후 썸네일형에서는 `ic_new_grid`를 사용한다.
- 리스트형/썸네일형 표시 문구는 `I18n`에 ko/en/ja 다국어로 추가한다.
- 리스트형은 기존 커뮤니티 리스트 카드의 정보 구조를 따르되 V2 채널 탭 디자인에 맞춰 작성한다.
- 썸네일형은 3열 grid로 표시하고, 이미지가 있으면 이미지 또는 GIF를 재생 가능하게 표시한다.
- 썸네일형에서 `imageUrl == nil`이면 유/무료 관계없이 이미지를 표시하지 않고 기존 크리에이터 커뮤니티 페이지의 fallback 규칙을 참고해 글 일부를 표시한다.
- 유료 미구매 게시글은 이미지 영역에 회색 `RoundedRectangle`을 표시하고, 오디오 재생 버튼은 표시하지 않는다.
- 본인 또는 구매한 유저는 이미지와 중앙 오디오 재생 버튼을 기존 커뮤니티 페이지와 동일한 방식으로 표시한다.
- 댓글을 사용할 수 없는 게시글은 댓글 아이콘과 댓글 개수를 숨긴다.
- 본인 채널에 본인이 쓴 커뮤니티 게시글에서만 상단 더보기와 가격 표시를 표시한다.
- 본인 채널이면 하단 고정 `커뮤니티 글 올리기` 버튼을 표시한다.
- 게시글 우측 더보기 액션은 기존 커뮤니티 전체보기의 수정/삭제/신고/고정 액션 흐름을 재사용한다.
## 4. Non-Goals
- 크리에이터 채널 공통 shell, header, sticky tab-bar 동작을 다시 설계하지 않는다.
- 커뮤니티 게시글 상세 화면은 이번 범위에서 구현하지 않는다.
- 커뮤니티 글쓰기/수정 화면 자체를 새로 구현하지 않고 기존 `creatorCommunityWrite` / `creatorCommunityModify` navigation step을 재사용한다.
- 커뮤니티 댓글 리스트, 댓글 작성, 신고, 삭제, 구매 mutation API를 새로 만들지 않고 기존 커뮤니티 구현의 흐름을 재사용한다.
- 오디오 탭의 정렬 popup을 커뮤니티 탭에 표시하지 않는다.
- Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다.
- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다.
## 5. Core Requirements
### 5.1 API
- Method: `GET`
- Path: `/api/v2/creator-channels/{creatorId}/community`
- Path parameter:
- `creatorId`
- Query parameters:
- `page`: 기본값 `0`
- `size`: 기본값 `20`
- 응답 래퍼는 기존 패턴대로 `ApiResponse<CreatorChannelCommunityTabResponse>`로 디코딩한다.
```kotlin
data class CreatorChannelCommunityTabResponse(
val communityPostCount: Int,
val communityPosts: List<CreatorChannelCommunityPostResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
data class CreatorChannelCommunityPostResponse(
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 likeCount: Int,
val commentCount: Int,
@JsonProperty("isPinned")
val isPinned: Boolean,
val existOrdered: Boolean
)
```
- Kotlin `Long`은 기존 V2 모델 관례대로 Swift `Int`로 선언한다.
- `creatorProfileUrl`, `existOrdered`는 서버 응답에 추가되는 필드로 포함한다.
- `createdAtUtc`는 기존 `DateParser.relativeTimeText(fromUTC:fallback:now:)` 계열 helper를 사용해 상대 시간으로 표시한다.
### 5.2 View mode toggle
- 기본 보기 방식은 리스트형이다.
- sort-bar 우측 버튼을 터치할 때마다 리스트형과 썸네일형을 토글한다.
- 리스트형 상태에서는 버튼에 `ic_new_list`와 리스트형 문구를 표시한다.
- 썸네일형 상태에서는 버튼에 `ic_new_grid`와 썸네일형 문구를 표시한다.
- 리스트형/썸네일형 문구는 `I18n.CreatorChannelCommunity` 또는 같은 범위의 신규 enum에 ko/en/ja로 추가한다.
- 보기 방식 전환은 현재까지 로드된 `communityPosts`를 그대로 사용하고 API를 다시 호출하지 않는다.
### 5.3 List layout
- 리스트형은 Figma `290:9061``665:19021`을 기준으로 표시한다.
- 작성자 프로필 이미지, 닉네임, 상대 시간, 본문, 이미지/오디오 영역, 좋아요 수, 댓글 수를 표시한다.
- `isPinned == true`이면 게시글에 고정 표시를 제공한다.
- `price > 0 && existOrdered == false && isOwnPost == false`이면 유료 미구매 상태로 표시한다.
- 유료 미구매 상태에서는 이미지 URL도 `nil`로 내려올 수 있으므로 이미지 영역에 회색 `RoundedRectangle`을 표시한다.
- 유료 미구매 상태에서는 오디오 재생 버튼을 표시하지 않는다.
- 본인 또는 구매한 유저는 이미지가 있으면 이미지 또는 GIF를 표시하고, `audioUrl != nil`이면 이미지 중앙에 재생 버튼을 표시한다.
- GIF URL은 정지 이미지로 변환하지 않고 재생 가능한 이미지 컴포넌트를 사용한다.
- `imageUrl == nil`이면 유/무료 관계없이 이미지 영역을 표시하지 않는다.
- `isCommentAvailable == false`이면 댓글 아이콘과 댓글 개수를 숨긴다.
### 5.4 Thumbnail layout
- 썸네일형은 Figma `290:9073` 기준 3열 grid로 표시한다.
- 이미지 또는 GIF가 있으면 정방형 썸네일에 표시한다.
- 이미지가 없으면 유/무료 관계없이 기존 `CreatorCommunityAllGridItemView`의 fallback 규칙을 참고해 글 일부를 표시한다.
- 유료 미구매 게시글은 회색 배경과 중앙형 가격 UI를 표시한다.
- `isPinned == true`이면 썸네일 우측 상단에 고정 표시를 제공한다.
- grid item tap은 후속 상세 화면 구현 전까지 상세 이동을 연결하지 않는다.
### 5.5 Paid post display
- 유료 미구매 판정은 `price > 0 && existOrdered == false && isOwnPost == false`이다.
- 리스트형 중앙 가격 표시는 Figma `290:9066`을 따른다.
- 우측 상단 가격 표시는 Figma `665:19024`를 따른다.
- 썸네일형 유료 미구매 가격 UI는 중앙형을 사용한다.
- 상단 더보기와 가격 표시는 본인 채널에 본인이 쓴 커뮤니티 게시글에서만 표시한다.
- 유료 미구매 게시글의 구매 dialog는 기존 `CommunityPostPurchaseDialog`를 재사용한다.
- 구매 성공 후 현재 페이지 범위를 첫 페이지부터 다시 조회하거나 해당 item을 갱신한다.
### 5.6 Actions
- 좋아요는 기존 `CreatorCommunityRepository.communityPostLike(postId:)` 흐름을 재사용한다.
- 댓글 목록은 기존 `CreatorCommunityCommentListView`를 재사용하되, `isCommentAvailable == false`이면 진입 UI를 표시하지 않는다.
- 더보기 메뉴는 기존 `CreatorCommunityMenuView` 또는 V2 anchored popup 패턴 중 구현 시점에 더 작은 변경으로 맞는 방식을 사용한다.
- 본인 채널의 내 게시글에서 더보기 메뉴는 기존과 동일하게 고정/수정/삭제 액션을 제공한다.
- 내 게시글이 아니면 신고 액션을 기존 `CreatorCommunityReportView``ReportType.COMMUNITY_POST` 흐름으로 처리한다.
- 수정 성공, 삭제 성공, 고정 상태 변경 성공 후 커뮤니티 탭 첫 페이지를 다시 조회한다.
### 5.7 Own channel CTA
- 본인 채널이고 `selectedTab == .community`이면 하단 고정 `커뮤니티 글 올리기` 버튼을 표시한다.
- 버튼 문구는 기존 `I18n.CreatorChannelHome.uploadCommunityPost` 또는 동일 의미의 기존 다국어 문구를 우선 재사용한다.
- 버튼 tap은 기존 `showCommunityWrite()` 흐름을 재사용한다.
- 글 작성 성공 후 홈 정보와 커뮤니티 탭 목록을 갱신한다.
### 5.8 Pagination and state
- 첫 진입 시 `page=0`, `size=20`으로 조회한다.
- 다음 페이지 로딩 중 중복 호출을 막는다.
- 마지막 item이 화면에 나타났고 `hasNext == true`이면 `page + 1`을 조회한다.
- 다음 페이지 성공 시 기존 `communityPosts` 뒤에 append한다.
- 첫 페이지 재조회 시 기존 목록을 교체한다.
- Empty 상태는 `communityPostCount == 0` 기준으로 판정하고 Figma `290:8994`를 따른다.
## 6. Success Criteria
- 커뮤니티 탭 진입 시 `GET /api/v2/creator-channels/{creatorId}/community?page=0&size=20`가 호출된다.
- count bar에는 `전체 {communityPostCount}`가 표시된다.
- 우측 버튼은 정렬이 아니라 리스트형/썸네일형 토글로 동작한다.
- 기본값은 리스트형이며 `ic_new_list` 상태로 표시된다.
- 토글 시 썸네일형으로 바뀌고 `ic_new_grid` 상태로 표시된다.
- 리스트형/썸네일형 문구는 ko/en/ja 다국어로 제공된다.
- 유료 미구매 게시글은 회색 `RoundedRectangle` 이미지 영역과 가격 UI를 표시하고 오디오 재생 버튼은 표시하지 않는다.
- 본인 또는 구매한 유저의 게시글은 이미지/GIF와 중앙 오디오 재생 버튼을 표시한다.
- `isCommentAvailable == false`이면 댓글 아이콘과 댓글 개수가 표시되지 않는다.
- 본인 채널의 내 게시글에서만 상단 더보기와 가격 표시가 노출된다.
- 본인 채널이면 리스트형/썸네일형 모두에서 하단 고정 `커뮤니티 글 올리기` 버튼이 표시된다.
- 게시글 더보기 액션은 기존 커뮤니티 페이지와 동일한 수정/삭제/신고/고정 흐름을 사용한다.
- `communityPostCount == 0`이면 커뮤니티 empty 상태가 표시된다.
- 목록 하단 도달 시 `hasNext == true`이면 다음 페이지가 append된다.
## 7. Technical Constraints
- 기능 변경은 `SodaLive/Sources/V2/CreatorChannel/**` 하위에서 해결한다.
- 커뮤니티 탭 전용 View, ViewModel, Repository, API, 모델은 `SodaLive/Sources/V2/CreatorChannel/Community/**` 아래에 둔다.
- 기존 구 커뮤니티 전체보기 파일은 필요한 재사용 지점만 참고하고, V2 탭 구현을 위해 직접 대규모 수정하지 않는다.
- 기존 커뮤니티 mutation, 댓글, 구매, 신고, 오디오 플레이어 흐름은 가능한 한 재사용한다.
- 신규 문구는 `SodaLive/Sources/I18n/I18n.swift`에 ko/en/ja를 추가한다.
- 프로젝트 설정 변경은 신규 Swift 파일 target 등록이 필요한 경우에만 수행한다.
## 8. Assumptions
- `creatorProfileUrl``existOrdered`는 커뮤니티 탭 API 응답에 포함된다.
- 현재 로그인 사용자의 id는 기존 관례대로 `UserDefaults.int(forKey: .userId)`로 확인한다.
- `isOwnPost``post.creatorId == UserDefaults.int(forKey: .userId)`로 판정한다.
- 커뮤니티 게시글 상세 화면은 후속 범위이므로 grid/list item tap 상세 이동은 이번 작업에서 연결하지 않는다.
- 본인 채널 여부는 `CreatorChannelView``isOwnCreatorChannel` 값을 커뮤니티 탭에 주입한다.
## 9. Open Questions
- 해당 없음
## 10. Verification Notes
- 2026-07-05: 사용자 제공 API, 응답 모델, Figma URL, 본인 채널/유료/댓글/토글 요구사항을 기준으로 PRD를 작성했다.
- 2026-07-05: `docs/agent-guides/documentation-policy.md`, 기존 크리에이터 채널 오디오/팬Talk 문서, `CreatorChannelView`, 기존 `CreatorCommunityAllView`, `CreatorCommunityAllItemView`, `CreatorCommunityAllViewModel`을 확인해 문서 범위를 정했다.
- 2026-07-05: 사용자 추가 확인으로 Empty Figma `290:8994`, 썸네일형 유료 미구매 가격 UI 중앙형, `imageUrl == nil`일 때 유/무료 관계없이 이미지 미표시 규칙을 반영했다.