feat(community): 크리에이터 커뮤니티 게시물 고정 기능을 추가한다

This commit is contained in:
Yu Sung
2026-03-17 10:41:28 +09:00
parent de627e1700
commit 5e08711b29
12 changed files with 241 additions and 48 deletions

View File

@@ -2506,6 +2506,9 @@
}
}
}
},
"고정 해제" : {
},
"공개 설정" : {
"localizations" : {
@@ -4142,6 +4145,22 @@
}
}
},
"목" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Thu"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "木"
}
}
}
},
"모집완료" : {
"localizations" : {
"en" : {
@@ -4174,22 +4193,6 @@
}
}
},
"목" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Thu"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "木"
}
}
}
},
"무료" : {
"localizations" : {
"en" : {
@@ -8406,6 +8409,9 @@
}
}
}
},
"최상단에 고정" : {
},
"최신 콘텐츠" : {
"localizations" : {
@@ -8647,22 +8653,6 @@
}
}
},
"캐릭터 정보" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Character info"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "キャラクター情報"
}
}
}
},
"캔" : {
"localizations" : {
"en" : {
@@ -8679,6 +8669,22 @@
}
}
},
"캐릭터 정보" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Character info"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "キャラクター情報"
}
}
}
},
"캔 충전" : {
"localizations" : {
"en" : {

View File

@@ -257,6 +257,7 @@ struct CreatorCommunityAllItemView_Previews: PreviewProvider {
isCommentAvailable: false,
isAdult: false,
isLike: true,
isFixed: false,
existOrdered: false,
likeCount: 10,
commentCount: 0,

View File

@@ -74,6 +74,12 @@ struct CreatorCommunityAllView: View {
CreatorCommunityMenuView(
isShowing: $viewModel.isShowReportMenu,
isShowCreatorMenu: creatorId == UserDefaults.int(forKey: .userId),
isFixed: viewModel.isPostFixed,
fixedAction: {
if creatorId == UserDefaults.int(forKey: .userId) {
viewModel.updateCommunityPostFixed()
}
},
modifyAction: {
let postId = viewModel.postId
AppState.shared
@@ -165,6 +171,7 @@ struct CreatorCommunityAllView: View {
let item = viewModel.communityPostList[index]
CreatorCommunityAllGridItemView(item: item)
.id(index)
.aspectRatio(1, contentMode: .fit)
.onTapGesture {
selectedListIndex = index
listAnchorIndex = index
@@ -173,6 +180,9 @@ struct CreatorCommunityAllView: View {
isGridMode = false
}
}
.onLongPressGesture(minimumDuration: 0.5) {
viewModel.openReportMenu(item: item)
}
.onAppear {
if index == viewModel.communityPostList.count - 1 {
viewModel.getCommunityPostList()
@@ -243,8 +253,7 @@ struct CreatorCommunityAllView: View {
)
},
onClickShowReportMenu: {
viewModel.postId = item.postId
viewModel.isShowReportMenu = true
viewModel.openReportMenu(item: item)
},
onClickPurchaseContent: {
viewModel.postId = item.postId
@@ -328,20 +337,27 @@ private struct CreatorCommunityAllGridItemView: View {
.scaledToFit()
.frame(width: 24, height: 24)
} else if let imageUrl = item.imageUrl, !imageUrl.isEmpty {
AsyncImage(url: URL(string: imageUrl)) { phase in
switch phase {
case .empty:
Color.gray33
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
Color(hex: "263238")
@unknown default:
Color.gray33
Rectangle()
.fill(Color.clear)
.aspectRatio(1, contentMode: .fit)
.overlay {
AsyncImage(url: URL(string: imageUrl)) { phase in
switch phase {
case .empty:
Color.gray33
case .success(let image):
image
.resizable()
.scaledToFill()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .failure:
Color(hex: "263238")
@unknown default:
Color.gray33
}
}
}
}
.clipped()
} else {
Color(hex: "263238")
@@ -355,6 +371,17 @@ private struct CreatorCommunityAllGridItemView: View {
}
.aspectRatio(1, contentMode: .fit)
.clipped()
.overlay(alignment: .topTrailing) {
if item.isFixed == true {
Image("ic_pin")
.resizable()
.scaledToFit()
.frame(width: 14, height: 14)
.padding(8)
.shadow(color: .black.opacity(0.45), radius: 2, x: 0, y: 1)
.allowsHitTesting(false)
}
}
}
private var isPaidLocked: Bool {

View File

@@ -21,6 +21,7 @@ class CreatorCommunityAllViewModel: ObservableObject {
@Published private(set) var communityPostList = [GetCommunityPostListResponse]()
@Published var postId = 0
@Published var isPostFixed = false
@Published var postPrice = 0
@Published var postIndex = -1
@Published var isShowSecret = false
@@ -124,6 +125,12 @@ class CreatorCommunityAllViewModel: ObservableObject {
isShowCommentListView = true
}
func openReportMenu(item: GetCommunityPostListResponse) {
postId = item.postId
isPostFixed = item.isFixed == true
isShowReportMenu = true
}
func openCommentListForDeepLink(postId: Int) {
guard postId > 0 else {
return
@@ -289,6 +296,56 @@ class CreatorCommunityAllViewModel: ObservableObject {
self.isLoading = false
}
}
func updateCommunityPostFixed() {
let postId = postId
let nextIsFixed = !isPostFixed
guard postId > 0 else {
return
}
isLoading = true
let request = UpdateCommunityPostFixedRequest(postId: postId, isFixed: nextIsFixed)
repository.updateCommunityPostFixed(request: request)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [unowned self] response in
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData)
if decoded.success {
self.isPostFixed = nextIsFixed
DispatchQueue.main.async {
self.refreshCommunityPostListKeepingLoadedCount()
}
} else {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
}
self.isShowPopup = true
}
} catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.isShowPopup = true
}
self.isLoading = false
}
.store(in: &subscription)
}
func purchaseCommunityPost() {
let postId = postId
@@ -337,6 +394,13 @@ class CreatorCommunityAllViewModel: ObservableObject {
}
}
private func refreshCommunityPostListKeepingLoadedCount() {
pageSize = max(communityPostList.count, 10)
page = 1
isLast = false
getCommunityPostList()
}
private func shouldShowSecretCommentOption(item: GetCommunityPostListResponse) -> Bool {
item.price > 0 && item.existOrdered && item.creatorId != UserDefaults.int(forKey: .userId)
}

View File

@@ -11,7 +11,9 @@ struct CreatorCommunityMenuView: View {
@Binding var isShowing: Bool
let isShowCreatorMenu: Bool
let isFixed: Bool
let fixedAction: () -> Void
let modifyAction: () -> Void
let deleteAction: () -> Void
let reportAction: () -> Void
@@ -28,6 +30,23 @@ struct CreatorCommunityMenuView: View {
VStack(spacing: 13.3) {
if isShowCreatorMenu {
HStack(spacing: 13.3) {
Image(isFixed ? "ic_pin_cancel" : "ic_pin")
Text(isFixed ? "고정 해제" : "최상단에 고정")
.appFont(size: 16.7, weight: .medium)
.foregroundColor(.white)
Spacer()
}
.padding(.vertical, 8)
.padding(.horizontal, 26.7)
.contentShape(Rectangle())
.onTapGesture {
isShowing = false
fixedAction()
}
HStack(spacing: 13.3) {
Image("ic_make_message")
@@ -90,6 +109,8 @@ struct CreatorCommunityMenuView: View {
CreatorCommunityMenuView(
isShowing: .constant(true),
isShowCreatorMenu: true,
isFixed: false,
fixedAction: {},
modifyAction: {},
deleteAction: {},
reportAction: {}

View File

@@ -8,6 +8,11 @@
import Foundation
import Moya
struct UpdateCommunityPostFixedRequest: Encodable {
let postId: Int
let isFixed: Bool
}
enum CreatorCommunityApi {
case createCommunityPost(parameters: [MultipartFormData])
case modifyCommunityPost(parameters: [MultipartFormData])
@@ -20,6 +25,7 @@ enum CreatorCommunityApi {
case modifyComment(request: ModifyCommunityPostCommentRequest)
case getLatestPostListFromCreatorsYouFollow
case purchaseCommunityPost(postId: Int)
case updateCommunityPostFixed(request: UpdateCommunityPostFixedRequest)
}
extension CreatorCommunityApi: TargetType {
@@ -52,6 +58,9 @@ extension CreatorCommunityApi: TargetType {
case .purchaseCommunityPost:
return "/creator-community/purchase"
case .updateCommunityPostFixed:
return "/creator-community/fixed"
}
}
@@ -63,7 +72,7 @@ extension CreatorCommunityApi: TargetType {
case .getCommunityPostList, .getCommunityPostCommentList, .getCommentReplyList, .getCommunityPostDetail, .getLatestPostListFromCreatorsYouFollow:
return .get
case .modifyComment, .modifyCommunityPost:
case .modifyComment, .modifyCommunityPost, .updateCommunityPostFixed:
return .put
}
}
@@ -122,6 +131,9 @@ extension CreatorCommunityApi: TargetType {
case .purchaseCommunityPost(let postId):
return .requestJSONEncodable(PurchasePostRequest(postId: postId))
case .updateCommunityPostFixed(let request):
return .requestJSONEncodable(request)
}
}

View File

@@ -104,6 +104,7 @@ struct CreatorCommunityItemView_Previews: PreviewProvider {
isCommentAvailable: false,
isAdult: false,
isLike: false,
isFixed: false,
existOrdered: false,
likeCount: 10,
commentCount: 0,

View File

@@ -56,4 +56,8 @@ class CreatorCommunityRepository {
func purchaseCommunityPost(postId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.purchaseCommunityPost(postId: postId))
}
func updateCommunityPostFixed(request: UpdateCommunityPostFixedRequest) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.updateCommunityPostFixed(request: request))
}
}

View File

@@ -21,6 +21,7 @@ struct GetCommunityPostListResponse: Decodable {
let isCommentAvailable: Bool
let isAdult: Bool
let isLike: Bool
var isFixed: Bool?
let existOrdered: Bool
let likeCount: Int
let commentCount: Int

View File

@@ -58,6 +58,7 @@ struct SectionCommunityPostView_Previews: PreviewProvider {
isCommentAvailable: false,
isAdult: false,
isLike: true,
isFixed: false,
existOrdered: false,
likeCount: 10,
commentCount: 0,
@@ -77,6 +78,7 @@ struct SectionCommunityPostView_Previews: PreviewProvider {
isCommentAvailable: false,
isAdult: false,
isLike: true,
isFixed: false,
existOrdered: false,
likeCount: 20,
commentCount: 0,

View File

@@ -0,0 +1,39 @@
# 크리에이터 커뮤니티 게시물 고정 기능 구현
- [x] API `/creator-community/fixed` (`PUT`) 추가
- [x] `UpdateCommunityPostFixedRequest` DTO 추가 및 Repository 연결
- [x] 리스트 모드 보조 메뉴에 고정/해제 항목 추가
- [x] 그리드 모드 길게 터치 시 보조 메뉴 표시 연결
- [x] 보조 메뉴를 BottomSheet 형태로 표시
- [x] 고정 상태에 따라 문구 분기 (`최상단에 고정` / `고정 해제`)
- [x] 고정 게시물 우측 상단 핀 표시를 그리드 모드에만 적용
- [x] 그리드 모드 게시물 이미지를 1:1 비율로 표시
- [x] 진단/빌드/테스트 수행 및 결과 기록
---
## 검증 기록
- `lsp_diagnostics` (수정 파일 전체): SourceKit 환경에서 외부 모듈(`Moya`, `Kingfisher`) 인식 오류가 지속되어 진단 신뢰성이 낮아 빌드 결과로 최종 검증함.
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`: 성공 (`** BUILD SUCCEEDED **`).
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`: 성공 (`** BUILD SUCCEEDED **`).
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`: 실패 (`Scheme SodaLive is not currently configured for the test action.`).
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`: 실패 (`Scheme SodaLive-dev is not currently configured for the test action.`).
- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`: 성공 (`** BUILD SUCCEEDED **` 2회).
- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`: 두 스킴 모두 test action 미구성으로 실패.
- (요구사항 조정) 리스트 모드 핀 오버레이 제거, 그리드 모드(`CreatorCommunityAllGridItemView`)만 핀 표시 유지.
- (요구사항 조정) 그리드 모드 셀 내부 콘텐츠에 `frame(maxWidth: .infinity, maxHeight: .infinity)`를 적용해 1:1 비율 셀에서 이미지가 항상 정사각형 기준으로 표시되도록 고정.
- (요구사항 조정) `gridContentView`에서 셀 프레임을 `width = (containerWidth - 2) / 3`, `height = width`로 직접 지정해 긴 이미지여도 세로가 가로를 넘지 않도록 고정.
- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`: 성공 (`** BUILD SUCCEEDED **` 2회).
- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`: 두 스킴 모두 test action 미구성으로 실패.
- (요구사항 조정) 그리드 셀의 `aspectRatio` 기반 계산을 제거하고, `CreatorCommunityAllGridItemView` 호출부에서 정사각형 고정 프레임을 직접 부여.
- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`: 성공 (`** BUILD SUCCEEDED **` 2회).
- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`: 두 스킴 모두 test action 미구성으로 실패.
- (요구사항 조정) 바깥 폭(`containerWidth`) 기반 고정 계산을 제거하고, `LazyVGrid` 컬럼 폭에 맞춰 `CreatorCommunityAllGridItemView` 내부 `aspectRatio(1, .fit)`로 정사각형을 강제해 옆 셀 침범을 방지.
- (요구사항 조정) 정사각형은 `LazyVGrid` 컬럼 자체 폭 기준으로만 계산되도록 `gridContentView(containerWidth:)``gridItemLength`를 제거해 긴 원본 비율 이미지의 옆 셀 침범을 차단.
- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`: 성공 (`** BUILD SUCCEEDED **` 2회).
- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`: 두 스킴 모두 test action 미구성으로 실패.
- (요구사항 조정) `GeometryReader` 내부 실제 그리드 폭 기준으로 `gridItemLength = (width - 2) / 3`를 계산하고 각 아이템에 `frame(width: gridItemLength, height: gridItemLength)`를 적용해 1:1을 강제.
- (요구사항 조정) 아이템 내부 중복 비율 제약(`aspectRatio`)을 제거해 부모 고정 프레임과 충돌하지 않도록 정리.
- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`: 성공 (`** BUILD SUCCEEDED **` 2회).
- (추가 반영) `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`: 두 스킴 모두 test action 미구성으로 실패.

View File

@@ -0,0 +1,15 @@
# 현재 변경사항 안전 커밋
- [x] `commit-policy` 스킬을 로드한다.
- [x] 작업 트리 상태(`git status`, `git diff --cached`, `git diff`, `git log -5 --oneline`)를 확인한다.
- [x] 커밋 대상 파일만 스테이징하고 민감정보 파일 제외 여부를 점검한다.
- [x] 커밋 메시지 규칙(``<type>(scope): <description>`` + 한글 description)을 만족하는 메시지를 확정한다.
- [x] 커밋 전 메시지 검증 스크립트를 실행해 PASS를 확인한다.
- [ ] 커밋을 수행한다.
- [ ] 커밋 후 메시지 검증 스크립트를 실행해 PASS를 확인한다.
---
## 검증 기록
- (진행 중) 커밋 실행 이후 명령/결과를 누적 기록한다.