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

@@ -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"
}
}
}