콘텐츠 메인
- 홈 UI 페이지 생성
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_card_can_gray_32.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | |||||||
|  | { | ||||||
|  |   "images" : [ | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "1x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "2x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "filename" : "ic_card_can_gray_32.png", | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "3x" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "info" : { | ||||||
|  |     "author" : "xcode", | ||||||
|  |     "version" : 1 | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_card_can_gray_32.imageset/ic_card_can_gray_32.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 588 B | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_alarm.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | |||||||
|  | { | ||||||
|  |   "images" : [ | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "1x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "2x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "filename" : "ic_category_alarm.png", | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "3x" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "info" : { | ||||||
|  |     "author" : "xcode", | ||||||
|  |     "version" : 1 | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_alarm.imageset/ic_category_alarm.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.4 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_asmr.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | |||||||
|  | { | ||||||
|  |   "images" : [ | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "1x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "2x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "filename" : "ic_category_asmr.png", | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "3x" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "info" : { | ||||||
|  |     "author" : "xcode", | ||||||
|  |     "version" : 1 | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_asmr.imageset/ic_category_asmr.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 4.1 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_audio_book.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | |||||||
|  | { | ||||||
|  |   "images" : [ | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "1x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "2x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "filename" : "ic_category_audio_book.png", | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "3x" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "info" : { | ||||||
|  |     "author" : "xcode", | ||||||
|  |     "version" : 1 | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_audio_book.imageset/ic_category_audio_book.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.2 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_audio_toon.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | |||||||
|  | { | ||||||
|  |   "images" : [ | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "1x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "2x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "filename" : "ic_category_audio_toon.png", | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "3x" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "info" : { | ||||||
|  |     "author" : "xcode", | ||||||
|  |     "version" : 1 | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_audio_toon.imageset/ic_category_audio_toon.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.6 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_content.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | |||||||
|  | { | ||||||
|  |   "images" : [ | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "1x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "2x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "filename" : "ic_category_content.png", | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "3x" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "info" : { | ||||||
|  |     "author" : "xcode", | ||||||
|  |     "version" : 1 | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_content.imageset/ic_category_content.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.3 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_free.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | |||||||
|  | { | ||||||
|  |   "images" : [ | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "1x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "2x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "filename" : "ic_category_free.png", | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "3x" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "info" : { | ||||||
|  |     "author" : "xcode", | ||||||
|  |     "version" : 1 | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_free.imageset/ic_category_free.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.0 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_replay.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | |||||||
|  | { | ||||||
|  |   "images" : [ | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "1x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "2x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "filename" : "ic_category_replay.png", | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "3x" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "info" : { | ||||||
|  |     "author" : "xcode", | ||||||
|  |     "version" : 1 | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_replay.imageset/ic_category_replay.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.2 KiB | 
							
								
								
									
										21
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_series.imageset/Contents.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,21 @@ | |||||||
|  | { | ||||||
|  |   "images" : [ | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "1x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "2x" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "filename" : "ic_category_series.png", | ||||||
|  |       "idiom" : "universal", | ||||||
|  |       "scale" : "3x" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "info" : { | ||||||
|  |     "author" : "xcode", | ||||||
|  |     "version" : 1 | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										
											BIN
										
									
								
								SodaLive/Resources/Assets.xcassets/ic_category_series.imageset/ic_category_series.png
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.2 KiB | 
| @@ -38,6 +38,8 @@ enum ContentApi { | |||||||
|     case unpinContent(contentId: Int) |     case unpinContent(contentId: Int) | ||||||
|     case getAudioContentByTheme(themeId: Int, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int, sort: ContentAllByThemeViewModel.Sort) |     case getAudioContentByTheme(themeId: Int, isAdultContentVisible: Bool, contentType: ContentType, page: Int, size: Int, sort: ContentAllByThemeViewModel.Sort) | ||||||
|     case generateUrl(contentId: Int) |     case generateUrl(contentId: Int) | ||||||
|  |     case getContentMainHome | ||||||
|  |     case getPopularContentByCreator(creatorId: Int) | ||||||
| } | } | ||||||
|  |  | ||||||
| extension ContentApi: TargetType { | extension ContentApi: TargetType { | ||||||
| @@ -133,6 +135,12 @@ extension ContentApi: TargetType { | |||||||
|              |              | ||||||
|         case .generateUrl(let contentId): |         case .generateUrl(let contentId): | ||||||
|             return "/audio-content/\(contentId)/generate-url" |             return "/audio-content/\(contentId)/generate-url" | ||||||
|  |              | ||||||
|  |         case .getContentMainHome: | ||||||
|  |             return "/v2/audio-content/main/home" | ||||||
|  |              | ||||||
|  |         case .getPopularContentByCreator: | ||||||
|  |             return "/v2/audio-content/main/home/popular-content-by-creator" | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|      |      | ||||||
| @@ -147,6 +155,9 @@ extension ContentApi: TargetType { | |||||||
|         case .getMainBannerList, .getMainOrderList, .getNewContentUploadCreatorList, .getCurationList, .getAudioContentByTheme: |         case .getMainBannerList, .getMainOrderList, .getNewContentUploadCreatorList, .getCurationList, .getAudioContentByTheme: | ||||||
|             return .get |             return .get | ||||||
|              |              | ||||||
|  |         case .getContentMainHome, .getPopularContentByCreator: | ||||||
|  |             return .get | ||||||
|  |              | ||||||
|         case .likeContent, .modifyAudioContent, .modifyComment, .unpinContent: |         case .likeContent, .modifyAudioContent, .modifyComment, .unpinContent: | ||||||
|             return .put |             return .put | ||||||
|              |              | ||||||
| @@ -300,6 +311,13 @@ extension ContentApi: TargetType { | |||||||
|             ] as [String : Any] |             ] as [String : Any] | ||||||
|              |              | ||||||
|             return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) |             return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) | ||||||
|  |              | ||||||
|  |         case .getContentMainHome: | ||||||
|  |             return .requestPlain | ||||||
|  |              | ||||||
|  |         case .getPopularContentByCreator(let creatorId): | ||||||
|  |             let parameters = ["creatorId": creatorId] | ||||||
|  |             return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|      |      | ||||||
|   | |||||||
| @@ -8,7 +8,7 @@ | |||||||
| import Foundation | import Foundation | ||||||
|  |  | ||||||
| struct GetAudioContentMainResponse: Decodable { | struct GetAudioContentMainResponse: Decodable { | ||||||
|     let newContentUploadCreatorList: [GetNewContentUploadCreator] |     let newContentUploadCreatorList: [ContentCreatorResponse] | ||||||
|     let bannerList: [GetAudioContentBannerResponse] |     let bannerList: [GetAudioContentBannerResponse] | ||||||
|     let orderList: [GetAudioContentMainItem] |     let orderList: [GetAudioContentMainItem] | ||||||
|     let themeList: [String] |     let themeList: [String] | ||||||
| @@ -33,9 +33,10 @@ struct GetAudioContentRankingItem: Decodable { | |||||||
|     let duration: String |     let duration: String | ||||||
|     let creatorId: Int |     let creatorId: Int | ||||||
|     let creatorNickname: String |     let creatorNickname: String | ||||||
|  |     let creatorProfileImageUrl: String | ||||||
| } | } | ||||||
|  |  | ||||||
| struct GetNewContentUploadCreator: Decodable { | struct ContentCreatorResponse: Decodable { | ||||||
|     let creatorId: Int |     let creatorId: Int | ||||||
|     let creatorNickname: String |     let creatorNickname: String | ||||||
|     let creatorProfileImageUrl: String |     let creatorProfileImageUrl: String | ||||||
|   | |||||||
| @@ -0,0 +1,154 @@ | |||||||
|  | // | ||||||
|  | //  ContentMainTabRankContentView.swift | ||||||
|  | //  SodaLive | ||||||
|  | // | ||||||
|  | //  Created by klaus on 2/20/25. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | import Kingfisher | ||||||
|  |  | ||||||
|  | struct ContentMainTabRankContentView: View { | ||||||
|  |      | ||||||
|  |     let rows = [ | ||||||
|  |         GridItem(.fixed(60), alignment: .leading), | ||||||
|  |         GridItem(.fixed(60), alignment: .leading), | ||||||
|  |         GridItem(.fixed(60), alignment: .leading) | ||||||
|  |     ] | ||||||
|  |      | ||||||
|  |     let title: String | ||||||
|  |      | ||||||
|  |     let isMore: Bool | ||||||
|  |     let onClickMore: () -> Void | ||||||
|  |      | ||||||
|  |     let sortList: [String] | ||||||
|  |     let onClickSort: (String) -> Void | ||||||
|  |      | ||||||
|  |     let contentList: [GetAudioContentRankingItem] | ||||||
|  |      | ||||||
|  |     @State private var selectedSort = "" | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         VStack(spacing: 13.3) { | ||||||
|  |             HStack(spacing: 0) { | ||||||
|  |                 Text(title) | ||||||
|  |                     .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||||
|  |                     .foregroundColor(Color(hex: "eeeeee")) | ||||||
|  |                  | ||||||
|  |                 Spacer() | ||||||
|  |                  | ||||||
|  |                 if isMore { | ||||||
|  |                     Image("ic_forward") | ||||||
|  |                         .onTapGesture { onClickMore() } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             if !sortList.isEmpty { | ||||||
|  |                 ContentMainRankingSortView( | ||||||
|  |                     sorts: sortList, | ||||||
|  |                     selectSort: { | ||||||
|  |                         selectedSort = $0 | ||||||
|  |                         onClickSort($0) | ||||||
|  |                     }, | ||||||
|  |                     selectedSort: $selectedSort | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             ScrollView(.horizontal, showsIndicators: false) { | ||||||
|  |                 LazyHGrid(rows: rows, spacing: 13.3) { | ||||||
|  |                     ForEach(0..<contentList.count, id: \.self) { index in | ||||||
|  |                         let content = contentList[index] | ||||||
|  |                         HStack(spacing: 0) { | ||||||
|  |                             KFImage(URL(string: content.coverImageUrl)) | ||||||
|  |                                 .cancelOnDisappear(true) | ||||||
|  |                                 .downsampling( | ||||||
|  |                                     size: CGSize( | ||||||
|  |                                         width: 60, | ||||||
|  |                                         height: 60 | ||||||
|  |                                     ) | ||||||
|  |                                 ) | ||||||
|  |                                 .resizable() | ||||||
|  |                                 .frame(width: 60, height: 60) | ||||||
|  |                                 .cornerRadius(2.7) | ||||||
|  |                              | ||||||
|  |                             Text("\(index + 1)") | ||||||
|  |                                 .font(.custom(Font.bold.rawValue, size: 16.7)) | ||||||
|  |                                 .foregroundColor(.button) | ||||||
|  |                                 .padding(.horizontal, 12) | ||||||
|  |                              | ||||||
|  |                             VStack(alignment: .leading, spacing: 8) { | ||||||
|  |                                 Text(content.title) | ||||||
|  |                                     .lineLimit(2) | ||||||
|  |                                     .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||||
|  |                                     .foregroundColor(.grayd2) | ||||||
|  |                                  | ||||||
|  |                                 Text(content.creatorNickname) | ||||||
|  |                                     .font(.custom(Font.medium.rawValue, size: 11)) | ||||||
|  |                                     .foregroundColor(.gray77) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                         .frame(maxWidth: screenSize().width * 0.66, alignment: .leading) | ||||||
|  |                         .contentShape(Rectangle()) | ||||||
|  |                         .onTapGesture { | ||||||
|  |                             AppState | ||||||
|  |                                 .shared | ||||||
|  |                                 .setAppStep(step: .contentDetail(contentId: content.contentId)) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 .frame(height: 207) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         .onAppear { | ||||||
|  |             if !sortList.isEmpty { | ||||||
|  |                 selectedSort = sortList[0] | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #Preview { | ||||||
|  |     ContentMainTabRankContentView( | ||||||
|  |         title: "인기 단편", | ||||||
|  |         isMore: true, | ||||||
|  |         onClickMore: {}, | ||||||
|  |         sortList: ["매출", "댓글", "좋아요"], | ||||||
|  |         onClickSort: { _ in }, | ||||||
|  |         contentList: [ | ||||||
|  |             GetAudioContentRankingItem( | ||||||
|  |                 contentId: 1, | ||||||
|  |                 title: "안녕하세요 오늘은 커버곡을 들려드릴께요....안녕하세요 오늘은 커버곡을 들려드릴께요....", | ||||||
|  |                 coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||||
|  |                 themeStr: "커버곡", | ||||||
|  |                 price: 100, | ||||||
|  |                 duration: "00:30:20", | ||||||
|  |                 creatorId: 1, | ||||||
|  |                 creatorNickname: "유저1", | ||||||
|  |                 creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |             ), | ||||||
|  |             GetAudioContentRankingItem( | ||||||
|  |                 contentId: 2, | ||||||
|  |                 title: "안녕하세요 오늘은 커버곡을 들려드릴께요....", | ||||||
|  |                 coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||||
|  |                 themeStr: "커버곡", | ||||||
|  |                 price: 0, | ||||||
|  |                 duration: "00:30:20", | ||||||
|  |                 creatorId: 2, | ||||||
|  |                 creatorNickname: "유저2", | ||||||
|  |                 creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |             ), | ||||||
|  |             GetAudioContentRankingItem( | ||||||
|  |                 contentId: 3, | ||||||
|  |                 title: "안녕하세요 오늘은 커버곡을 들려드릴께요....안녕하세요 오늘은 커버곡을 들려드릴께요....", | ||||||
|  |                 coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||||
|  |                 themeStr: "커버곡", | ||||||
|  |                 price: 50, | ||||||
|  |                 duration: "00:30:20", | ||||||
|  |                 creatorId: 3, | ||||||
|  |                 creatorNickname: "유저3", | ||||||
|  |                 creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |             ) | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  | } | ||||||
							
								
								
									
										193
									
								
								SodaLive/Sources/Content/Main/V2/ContentByChannelView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,193 @@ | |||||||
|  | // | ||||||
|  | //  ContentByChannelView.swift | ||||||
|  | //  SodaLive | ||||||
|  | // | ||||||
|  | //  Created by klaus on 2/20/25. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  | import Kingfisher | ||||||
|  |  | ||||||
|  | struct ContentByChannelView: View { | ||||||
|  |      | ||||||
|  |     let title: String | ||||||
|  |     let creatorList: [ContentCreatorResponse] | ||||||
|  |     let contentList: [GetAudioContentRankingItem] | ||||||
|  |     let onClickCreator: (Int) -> Void | ||||||
|  |      | ||||||
|  |     @State private var selectedCreatorId = 0 | ||||||
|  |      | ||||||
|  |     let columns = [GridItem(.flexible()), GridItem(.flexible())] | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         VStack(alignment: .leading, spacing: 20) { | ||||||
|  |             Text(title) | ||||||
|  |                 .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||||
|  |                 .foregroundColor(Color(hex: "eeeeee")) | ||||||
|  |              | ||||||
|  |             ScrollView(.horizontal) { | ||||||
|  |                 HStack(spacing: 22) { | ||||||
|  |                     ForEach(0..<creatorList.count, id: \.self) { index in | ||||||
|  |                         let item = creatorList[index] | ||||||
|  |                          | ||||||
|  |                         ContentCreatorView( | ||||||
|  |                             isSelected: item.creatorId == selectedCreatorId, | ||||||
|  |                             item: item | ||||||
|  |                         ) | ||||||
|  |                         .onTapGesture { | ||||||
|  |                             let creatorId = item.creatorId | ||||||
|  |                             selectedCreatorId = creatorId | ||||||
|  |                             onClickCreator(creatorId) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             LazyVGrid(columns: columns, spacing: 13.3) { | ||||||
|  |                 ForEach(0..<contentList.count, id: \.self) { index in | ||||||
|  |                     let content = contentList[index] | ||||||
|  |                      | ||||||
|  |                     VStack(alignment: .leading, spacing: 8) { | ||||||
|  |                         ZStack(alignment:.bottom) { | ||||||
|  |                             GeometryReader { geometry in | ||||||
|  |                                 KFImage(URL(string: content.coverImageUrl)) | ||||||
|  |                                     .cancelOnDisappear(true) | ||||||
|  |                                     .resizable() | ||||||
|  |                                     .scaledToFill() | ||||||
|  |                                     .frame(width: geometry.size.width, height: geometry.size.width) | ||||||
|  |                                     .clipShape(RoundedRectangle(cornerRadius: 5.3)) | ||||||
|  |                                     .clipped() | ||||||
|  |                             } | ||||||
|  |                             .aspectRatio(1, contentMode: .fit) | ||||||
|  |                              | ||||||
|  |                             HStack(spacing: 0) { | ||||||
|  |                                 HStack(spacing: 2) { | ||||||
|  |                                     if content.price > 0 { | ||||||
|  |                                         Image("ic_card_can_gray_32") | ||||||
|  |                                     } | ||||||
|  |                                      | ||||||
|  |                                     Text(content.price > 0 ? "\(content.price)" : "무료") | ||||||
|  |                                         .font(.custom(Font.medium.rawValue, size: 12)) | ||||||
|  |                                         .foregroundColor(Color.white) | ||||||
|  |                                 } | ||||||
|  |                                 .padding(4) | ||||||
|  |                                 .background(Color.gray33.opacity(0.7)) | ||||||
|  |                                 .cornerRadius(10) | ||||||
|  |                                  | ||||||
|  |                                 Spacer() | ||||||
|  |                                  | ||||||
|  |                                 Text(content.duration) | ||||||
|  |                                     .font(.custom(Font.medium.rawValue, size: 12)) | ||||||
|  |                                     .foregroundColor(Color.white) | ||||||
|  |                                     .padding(4) | ||||||
|  |                                     .background(Color.gray33.opacity(0.7)) | ||||||
|  |                                     .cornerRadius(10) | ||||||
|  |                             } | ||||||
|  |                             .padding(.horizontal, 2.7) | ||||||
|  |                             .padding(.bottom, 2.7) | ||||||
|  |                         } | ||||||
|  |                          | ||||||
|  |                         Text(content.title) | ||||||
|  |                             .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||||
|  |                             .foregroundColor(.grayd2) | ||||||
|  |                             .lineLimit(1) | ||||||
|  |                          | ||||||
|  |                         HStack(spacing: 5.3) { | ||||||
|  |                             KFImage(URL(string: content.creatorProfileImageUrl)) | ||||||
|  |                                 .cancelOnDisappear(true) | ||||||
|  |                                 .downsampling(size: CGSize(width: 21, height: 21)) | ||||||
|  |                                 .resizable() | ||||||
|  |                                 .frame(width: 21, height: 21) | ||||||
|  |                                 .clipShape(Circle()) | ||||||
|  |                                 .clipped() | ||||||
|  |                              | ||||||
|  |                             Text(content.creatorNickname) | ||||||
|  |                                 .font(.custom(Font.medium.rawValue, size: 10)) | ||||||
|  |                                 .foregroundColor(.gray77) | ||||||
|  |                         } | ||||||
|  |                         .onTapGesture { | ||||||
|  |                             AppState | ||||||
|  |                                 .shared | ||||||
|  |                                 .setAppStep(step: .creatorDetail(userId: content.creatorId)) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     .onTapGesture { | ||||||
|  |                         AppState | ||||||
|  |                             .shared | ||||||
|  |                             .setAppStep(step: .contentDetail(contentId: content.contentId)) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |         } | ||||||
|  |         .onAppear { | ||||||
|  |             if !self.creatorList.isEmpty { | ||||||
|  |                 selectedCreatorId = creatorList[0].creatorId | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #Preview { | ||||||
|  |     ContentByChannelView( | ||||||
|  |         title: "채널별 인기 콘텐츠", | ||||||
|  |         creatorList: [ | ||||||
|  |             ContentCreatorResponse( | ||||||
|  |                 creatorId: 1, | ||||||
|  |                 creatorNickname: "유저1", | ||||||
|  |                 creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |             ), | ||||||
|  |             ContentCreatorResponse( | ||||||
|  |                 creatorId: 2, | ||||||
|  |                 creatorNickname: "유저2", | ||||||
|  |                 creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |             ) | ||||||
|  |         ], | ||||||
|  |         contentList: [ | ||||||
|  |             GetAudioContentRankingItem( | ||||||
|  |                 contentId: 1, | ||||||
|  |                 title: "안녕하세요 오늘은 커버곡을 들려드릴께요....안녕하세요 오늘은 커버곡을 들려드릴께요....", | ||||||
|  |                 coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||||
|  |                 themeStr: "커버곡", | ||||||
|  |                 price: 100, | ||||||
|  |                 duration: "00:30:20", | ||||||
|  |                 creatorId: 1, | ||||||
|  |                 creatorNickname: "유저1", | ||||||
|  |                 creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |             ), | ||||||
|  |             GetAudioContentRankingItem( | ||||||
|  |                 contentId: 2, | ||||||
|  |                 title: "안녕하세요 오늘은 커버곡을 들려드릴께요....", | ||||||
|  |                 coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||||
|  |                 themeStr: "커버곡", | ||||||
|  |                 price: 0, | ||||||
|  |                 duration: "00:30:20", | ||||||
|  |                 creatorId: 1, | ||||||
|  |                 creatorNickname: "유저1", | ||||||
|  |                 creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |             ), | ||||||
|  |             GetAudioContentRankingItem( | ||||||
|  |                 contentId: 3, | ||||||
|  |                 title: "안녕하세요 오늘은 커버곡을 들려드릴께요....안녕하세요 오늘은 커버곡을 들려드릴께요....", | ||||||
|  |                 coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||||
|  |                 themeStr: "커버곡", | ||||||
|  |                 price: 50, | ||||||
|  |                 duration: "00:30:20", | ||||||
|  |                 creatorId: 1, | ||||||
|  |                 creatorNickname: "유저1", | ||||||
|  |                 creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |             ), | ||||||
|  |             GetAudioContentRankingItem( | ||||||
|  |                 contentId: 4, | ||||||
|  |                 title: "안녕하세요 오늘은 커버곡을 들려드릴께요....안녕하세요 오늘은 커버곡을 들려드릴께요....", | ||||||
|  |                 coverImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||||
|  |                 themeStr: "커버곡", | ||||||
|  |                 price: 50, | ||||||
|  |                 duration: "00:30:20", | ||||||
|  |                 creatorId: 1, | ||||||
|  |                 creatorNickname: "유저1", | ||||||
|  |                 creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |             ) | ||||||
|  |         ] | ||||||
|  |     ) { _ in } | ||||||
|  | } | ||||||
							
								
								
									
										54
									
								
								SodaLive/Sources/Content/Main/V2/ContentCreatorView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,54 @@ | |||||||
|  | // | ||||||
|  | //  ContentCreatorView.swift | ||||||
|  | //  SodaLive | ||||||
|  | // | ||||||
|  | //  Created by klaus on 2/20/25. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  | import Kingfisher | ||||||
|  |  | ||||||
|  | struct ContentCreatorView: View { | ||||||
|  |      | ||||||
|  |     let isSelected: Bool | ||||||
|  |     let item: ContentCreatorResponse | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         VStack(spacing: 13.3) { | ||||||
|  |             KFImage(URL(string: item.creatorProfileImageUrl)) | ||||||
|  |                 .cancelOnDisappear(true) | ||||||
|  |                 .downsampling(size: CGSize(width: 60, height: 60)) | ||||||
|  |                 .resizable() | ||||||
|  |                 .frame(width: 60, height: 60) | ||||||
|  |                 .clipShape(Circle()) | ||||||
|  |                 .overlay( | ||||||
|  |                     Circle() | ||||||
|  |                         .strokeBorder(lineWidth: 3) | ||||||
|  |                         .foregroundColor( | ||||||
|  |                             .button | ||||||
|  |                                 .opacity(isSelected ? 1 : 0) | ||||||
|  |                         ) | ||||||
|  |                 ) | ||||||
|  |              | ||||||
|  |             Text(item.creatorNickname) | ||||||
|  |                 .font(.custom(Font.medium.rawValue, size: 11.3)) | ||||||
|  |                 .foregroundColor( | ||||||
|  |                     isSelected ? | ||||||
|  |                     Color.button : | ||||||
|  |                     Color.graybb | ||||||
|  |                          | ||||||
|  |                 ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #Preview { | ||||||
|  |     ContentCreatorView( | ||||||
|  |         isSelected: true, | ||||||
|  |         item: ContentCreatorResponse( | ||||||
|  |             creatorId: 1, | ||||||
|  |             creatorNickname: "유저1", | ||||||
|  |             creatorProfileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  | } | ||||||
							
								
								
									
										134
									
								
								SodaLive/Sources/Content/Main/V2/ContentMainBannerViewV2.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,134 @@ | |||||||
|  | // | ||||||
|  | //  ContentMainBannerViewV2.swift | ||||||
|  | //  SodaLive | ||||||
|  | // | ||||||
|  | //  Created by klaus on 2/20/25. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | import Kingfisher | ||||||
|  |  | ||||||
|  | struct ContentMainBannerViewV2: View { | ||||||
|  |      | ||||||
|  |     let bannerList: [GetAudioContentBannerResponse] | ||||||
|  |      | ||||||
|  |     @State var currentIndex = 0 | ||||||
|  |     @State var timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() | ||||||
|  |      | ||||||
|  |     @State var width: CGFloat = 0 | ||||||
|  |     @State var height: CGFloat = 0 | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         VStack(spacing: 0) { | ||||||
|  |             TabView(selection: $currentIndex) { | ||||||
|  |                 ForEach(0..<bannerList.count, id: \.self) { index in | ||||||
|  |                     let item = bannerList[index] | ||||||
|  |                     if let url = item.thumbnailImageUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { | ||||||
|  |                         ContentMainBannerImageView( | ||||||
|  |                             url: url, | ||||||
|  |                             width: width, | ||||||
|  |                             height: height, | ||||||
|  |                             item: item | ||||||
|  |                         ) | ||||||
|  |                     } else { | ||||||
|  |                         ContentMainBannerImageView( | ||||||
|  |                             url: item.thumbnailImageUrl, | ||||||
|  |                             width: width, | ||||||
|  |                             height: height, | ||||||
|  |                             item: item | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) | ||||||
|  |             .frame( | ||||||
|  |                 width: width, | ||||||
|  |                 height: height | ||||||
|  |             ) | ||||||
|  |              | ||||||
|  |             HStack(spacing: 4) { | ||||||
|  |                 ForEach(0..<bannerList.count, id: \.self) { index in | ||||||
|  |                     Capsule() | ||||||
|  |                         .foregroundColor( | ||||||
|  |                             index == currentIndex | ||||||
|  |                             ? .button | ||||||
|  |                             : .gray90 | ||||||
|  |                         ) | ||||||
|  |                         .frame( | ||||||
|  |                             width: index == currentIndex ? 18 : 6, | ||||||
|  |                             height: 6 | ||||||
|  |                         ) | ||||||
|  |                         .tag(index) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             .padding(.top, 13.3) | ||||||
|  |         } | ||||||
|  |         .frame(maxWidth: .infinity) | ||||||
|  |         .onAppear { | ||||||
|  |             width = screenSize().width - 26.7 | ||||||
|  |             height = width * 0.53 | ||||||
|  |             timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() | ||||||
|  |         } | ||||||
|  |         .onDisappear { | ||||||
|  |             timer.upstream.connect().cancel() | ||||||
|  |         } | ||||||
|  |         .onReceive(timer) { _ in | ||||||
|  |             DispatchQueue.main.async { | ||||||
|  |                 withAnimation { | ||||||
|  |                     if currentIndex == bannerList.count - 1 { | ||||||
|  |                         currentIndex = 0 | ||||||
|  |                     } else { | ||||||
|  |                         currentIndex += 1 | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #Preview { | ||||||
|  |     ContentMainBannerViewV2( | ||||||
|  |         bannerList: [ | ||||||
|  |             GetAudioContentBannerResponse( | ||||||
|  |                 type: .CREATOR, | ||||||
|  |                 thumbnailImageUrl: "https://test-cf.sodalive.net/audition/role/7/audition_role-dc4174e1-10b5-4a97-8379-c7a9336ab230-6691-1735908236571", | ||||||
|  |                 eventItem: nil, | ||||||
|  |                 creatorId: 2, | ||||||
|  |                 seriesId: nil, | ||||||
|  |                 link: nil | ||||||
|  |             ) | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | struct ContentMainBannerImageView: View { | ||||||
|  |     let url: String | ||||||
|  |     let width: CGFloat | ||||||
|  |     let height: CGFloat | ||||||
|  |     let item: GetAudioContentBannerResponse | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         KFImage(URL(string: url)) | ||||||
|  |             .cancelOnDisappear(true) | ||||||
|  |             .downsampling(size: CGSize(width: width, height: height)) | ||||||
|  |             .resizable() | ||||||
|  |             .scaledToFill() | ||||||
|  |             .frame(width: width, height: height) | ||||||
|  |             .cornerRadius(4.7) | ||||||
|  |             .onTapGesture { | ||||||
|  |                 switch item.type { | ||||||
|  |                 case .EVENT: | ||||||
|  |                     AppState.shared.setAppStep(step: .eventDetail(event: item.eventItem!)) | ||||||
|  |                 case .CREATOR: | ||||||
|  |                     AppState.shared.setAppStep(step: .creatorDetail(userId: item.creatorId!)) | ||||||
|  |                 case .SERIES: | ||||||
|  |                     AppState.shared.setAppStep(step: .seriesDetail(seriesId: item.seriesId!)) | ||||||
|  |                 case .LINK: | ||||||
|  |                     if let link = item.link, link.trimmingCharacters(in: .whitespaces).count > 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) { | ||||||
|  |                         UIApplication.shared.open(url) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,38 @@ | |||||||
|  | // | ||||||
|  | //  ContentMainTabCategoryView.swift | ||||||
|  | //  SodaLive | ||||||
|  | // | ||||||
|  | //  Created by klaus on 2/20/25. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | struct ContentMainTabCategoryView: View { | ||||||
|  |      | ||||||
|  |     let imageName: String | ||||||
|  |     let title: String | ||||||
|  |     let onClick: () -> Void | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         VStack(spacing: 5.3) { | ||||||
|  |             Image(imageName) | ||||||
|  |                 .resizable() | ||||||
|  |                 .frame(width: 43, height: 43) | ||||||
|  |              | ||||||
|  |             Text(title) | ||||||
|  |                 .font(.custom(Font.medium.rawValue, size: 12)) | ||||||
|  |                 .foregroundColor(.gray77) | ||||||
|  |         } | ||||||
|  |         .onTapGesture { | ||||||
|  |             onClick() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #Preview { | ||||||
|  |     ContentMainTabCategoryView( | ||||||
|  |         imageName: "ic_category_series", | ||||||
|  |         title: "시리즈", | ||||||
|  |         onClick: {} | ||||||
|  |     ) | ||||||
|  | } | ||||||
| @@ -0,0 +1,47 @@ | |||||||
|  | // | ||||||
|  | //  ContentMainTabHomeNoticeView.swift | ||||||
|  | //  SodaLive | ||||||
|  | // | ||||||
|  | //  Created by klaus on 2/20/25. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | struct ContentMainTabHomeNoticeView: View { | ||||||
|  |      | ||||||
|  |     let notice: NoticeItem | ||||||
|  |     let onClick: (NoticeItem) -> Void | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         HStack(spacing: 0) { | ||||||
|  |             Text(notice.title) | ||||||
|  |                 .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||||
|  |                 .foregroundColor(.white) | ||||||
|  |              | ||||||
|  |             Spacer() | ||||||
|  |              | ||||||
|  |             Text("자세히 >") | ||||||
|  |                 .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||||
|  |                 .foregroundColor(.white) | ||||||
|  |                 .onTapGesture { | ||||||
|  |                     onClick(notice) | ||||||
|  |                 } | ||||||
|  |         } | ||||||
|  |         .padding(.horizontal, 13.3) | ||||||
|  |         .padding(.vertical, 10) | ||||||
|  |         .background(Color.gray22) | ||||||
|  |         .cornerRadius(5.3) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #Preview { | ||||||
|  |     ContentMainTabHomeNoticeView( | ||||||
|  |         notice: NoticeItem( | ||||||
|  |             title: "[업데이트] 1.28.0 버전 업데이트", | ||||||
|  |             content: "test", | ||||||
|  |             date: "2025-02-07" | ||||||
|  |         ) | ||||||
|  |     ) { | ||||||
|  |         AppState.shared.setAppStep(step: .noticeDetail(notice: $0)) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,23 @@ | |||||||
|  | // | ||||||
|  | //  ContentMainTabHomeRepository.swift | ||||||
|  | //  SodaLive | ||||||
|  | // | ||||||
|  | //  Created by klaus on 2/20/25. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import Foundation | ||||||
|  | import CombineMoya | ||||||
|  | import Combine | ||||||
|  | import Moya | ||||||
|  |  | ||||||
|  | class ContentMainTabHomeRepository { | ||||||
|  |     private let api = MoyaProvider<ContentApi>() | ||||||
|  |      | ||||||
|  |     func getContentMainHome() -> AnyPublisher<Response, MoyaError> { | ||||||
|  |         return api.requestPublisher(.getContentMainHome) | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     func getPopularContentByCreator(creatorId: Int) -> AnyPublisher<Response, MoyaError> { | ||||||
|  |         return api.requestPublisher(.getPopularContentByCreator(creatorId: creatorId)) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,238 @@ | |||||||
|  | // | ||||||
|  | //  ContentMainTabHomeView.swift | ||||||
|  | //  SodaLive | ||||||
|  | // | ||||||
|  | //  Created by klaus on 2/20/25. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | struct ContentMainTabHomeView: View { | ||||||
|  |      | ||||||
|  |     @StateObject var viewModel = ContentMainTabHomeViewModel() | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         BaseView(isLoading: $viewModel.isLoading) { | ||||||
|  |             ScrollView(.vertical, showsIndicators: false) { | ||||||
|  |                 VStack(alignment: .leading, spacing: 0) { | ||||||
|  |                     HStack(spacing: 0) { | ||||||
|  |                         Text("콘텐츠 마켓") | ||||||
|  |                             .font(.custom(Font.bold.rawValue, size: 21.3)) | ||||||
|  |                             .foregroundColor(Color.button) | ||||||
|  |                          | ||||||
|  |                         Spacer() | ||||||
|  |                          | ||||||
|  |                         Image("ic_content_keep") | ||||||
|  |                             .onTapGesture { | ||||||
|  |                                 AppState.shared.setAppStep(step: .myBox(currentTab: .orderlist)) | ||||||
|  |                             } | ||||||
|  |                     } | ||||||
|  |                     .padding(.bottom, 26.7) | ||||||
|  |                     .padding(.horizontal, 13.3) | ||||||
|  |                      | ||||||
|  |                     if let notice = viewModel.noticeItem { | ||||||
|  |                         ContentMainTabHomeNoticeView(notice: notice) { | ||||||
|  |                             AppState.shared | ||||||
|  |                                 .setAppStep(step: .noticeDetail(notice: $0)) | ||||||
|  |                         } | ||||||
|  |                         .padding(.horizontal, 13.3) | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     if viewModel.bannerList.count > 0 { | ||||||
|  |                         ContentMainBannerViewV2(bannerList: viewModel.bannerList) | ||||||
|  |                             .padding(.top, 30) | ||||||
|  |                             .padding(.horizontal, 13.3) | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     HStack(spacing: 0) { | ||||||
|  |                         Image("ic_title_search_black") | ||||||
|  |                          | ||||||
|  |                         Text("채널명을 입력해 보세요") | ||||||
|  |                             .font(.custom(Font.medium.rawValue, size: 13.3)) | ||||||
|  |                             .foregroundColor(Color.gray55) | ||||||
|  |                             .keyboardType(.default) | ||||||
|  |                             .padding(.horizontal, 13.3) | ||||||
|  |                          | ||||||
|  |                         Spacer() | ||||||
|  |                     } | ||||||
|  |                     .padding(.horizontal, 21.3) | ||||||
|  |                     .frame(height: 50) | ||||||
|  |                     .frame(maxWidth: .infinity) | ||||||
|  |                     .background(Color.gray22) | ||||||
|  |                     .overlay( | ||||||
|  |                         RoundedRectangle(cornerRadius: 6.7) | ||||||
|  |                             .strokeBorder(lineWidth: 1) | ||||||
|  |                             .foregroundColor(Color.graybb) | ||||||
|  |                     ) | ||||||
|  |                     .padding(.top, 30) | ||||||
|  |                     .padding(.horizontal, 13.3) | ||||||
|  |                     .onTapGesture { | ||||||
|  |                         UserDefaults.set("", forKey: .searchChannel) | ||||||
|  |                         AppState.shared.setAppStep(step: .searchChannel) | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     VStack(spacing: 13.3) { | ||||||
|  |                         HStack(spacing: 0) { | ||||||
|  |                             ContentMainTabCategoryView( | ||||||
|  |                                 imageName: "ic_category_series", | ||||||
|  |                                 title: "시리즈", | ||||||
|  |                                 onClick: {} | ||||||
|  |                             ) | ||||||
|  |                             .frame(maxWidth: .infinity) | ||||||
|  |                              | ||||||
|  |                             ContentMainTabCategoryView( | ||||||
|  |                                 imageName: "ic_category_content", | ||||||
|  |                                 title: "단편", | ||||||
|  |                                 onClick: {} | ||||||
|  |                             ) | ||||||
|  |                             .frame(maxWidth: .infinity) | ||||||
|  |                              | ||||||
|  |                             ContentMainTabCategoryView( | ||||||
|  |                                 imageName: "ic_category_audio_book", | ||||||
|  |                                 title: "오디오북", | ||||||
|  |                                 onClick: {} | ||||||
|  |                             ) | ||||||
|  |                             .frame(maxWidth: .infinity) | ||||||
|  |                              | ||||||
|  |                             ContentMainTabCategoryView( | ||||||
|  |                                 imageName: "ic_category_alarm", | ||||||
|  |                                 title: "모닝콜", | ||||||
|  |                                 onClick: {} | ||||||
|  |                             ) | ||||||
|  |                             .frame(maxWidth: .infinity) | ||||||
|  |                         } | ||||||
|  |                          | ||||||
|  |                         HStack(spacing: 0) { | ||||||
|  |                             ContentMainTabCategoryView( | ||||||
|  |                                 imageName: "ic_category_asmr", | ||||||
|  |                                 title: "ASMR", | ||||||
|  |                                 onClick: {} | ||||||
|  |                             ) | ||||||
|  |                             .frame(maxWidth: .infinity) | ||||||
|  |                              | ||||||
|  |                             ContentMainTabCategoryView( | ||||||
|  |                                 imageName: "ic_category_replay", | ||||||
|  |                                 title: "다시듣기", | ||||||
|  |                                 onClick: {} | ||||||
|  |                             ) | ||||||
|  |                             .frame(maxWidth: .infinity) | ||||||
|  |                              | ||||||
|  |                             ContentMainTabCategoryView( | ||||||
|  |                                 imageName: "ic_category_audio_toon", | ||||||
|  |                                 title: "오디오툰", | ||||||
|  |                                 onClick: {} | ||||||
|  |                             ) | ||||||
|  |                             .frame(maxWidth: .infinity) | ||||||
|  |                              | ||||||
|  |                             ContentMainTabCategoryView( | ||||||
|  |                                 imageName: "ic_category_free", | ||||||
|  |                                 title: "무료", | ||||||
|  |                                 onClick: {} | ||||||
|  |                             ) | ||||||
|  |                             .frame(maxWidth: .infinity) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     .padding(.vertical, 13.3) | ||||||
|  |                     .background(Color.gray22) | ||||||
|  |                     .cornerRadius(5.3) | ||||||
|  |                     .padding(.top, 30) | ||||||
|  |                     .padding(.horizontal, 13.3) | ||||||
|  |                      | ||||||
|  |                     if let response = viewModel.rankCreatorResponse { | ||||||
|  |                         ContentMainTabHomeRankCreatorView(response: response) | ||||||
|  |                             .padding(.top, 30) | ||||||
|  |                             .padding(.horizontal, 13.3) | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     if !viewModel.rankSeriesList.isEmpty { | ||||||
|  |                         ContentMainTabHomeRankSeriesView(seriesList: viewModel.rankSeriesList) | ||||||
|  |                             .padding(.top, 30) | ||||||
|  |                             .padding(.horizontal, 13.3) | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     if !viewModel.rankSortTypeList.isEmpty { | ||||||
|  |                         ContentMainTabRankContentView( | ||||||
|  |                             title: "인기 단편", | ||||||
|  |                             isMore: true, | ||||||
|  |                             onClickMore: { | ||||||
|  |                                 AppState.shared.setAppStep(step: .contentRankingAll) | ||||||
|  |                             }, | ||||||
|  |                             sortList: !viewModel.rankSortTypeList.isEmpty ? | ||||||
|  |                             viewModel.rankSortTypeList : | ||||||
|  |                                 [], | ||||||
|  |                             onClickSort: { viewModel.getContentRanking(sort: $0) }, | ||||||
|  |                             contentList: viewModel.rankContentList | ||||||
|  |                         ) | ||||||
|  |                         .padding(.top, 30) | ||||||
|  |                         .padding(.horizontal, 13.3) | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     if viewModel.eventBannerList.count > 0 { | ||||||
|  |                         SectionEventBannerView(items: viewModel.eventBannerList) | ||||||
|  |                             .frame( | ||||||
|  |                                 width: viewModel.eventBannerList.count > 0 ? screenSize().width : 0, | ||||||
|  |                                 height: viewModel.eventBannerList.count > 0 ? screenSize().width * 300 / 1000 : 0, | ||||||
|  |                                 alignment: .center | ||||||
|  |                             ) | ||||||
|  |                             .padding(.top, 30) | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     if !viewModel.contentRankCreatorList.isEmpty { | ||||||
|  |                         ContentByChannelView( | ||||||
|  |                             title: "채널별 인기 콘텐츠", | ||||||
|  |                             creatorList: viewModel.contentRankCreatorList, | ||||||
|  |                             contentList: viewModel.salesCountRankContentList, | ||||||
|  |                             onClickCreator: { | ||||||
|  |                                 viewModel.getPopularContentByCreator(creatorId: $0) | ||||||
|  |                             } | ||||||
|  |                         ) | ||||||
|  |                         .padding(.top, 30) | ||||||
|  |                         .padding(.horizontal, 13.3) | ||||||
|  |                     } | ||||||
|  |                      | ||||||
|  |                     Text(""" | ||||||
|  | - 회사명 : 주식회사 소다라이브 | ||||||
|  |  | ||||||
|  | - 대표자 : 이재형 | ||||||
|  |  | ||||||
|  | - 주소 : 경기도 성남시 분당구 황새울로335번길 10, 5층 563A호 | ||||||
|  |  | ||||||
|  | - 사업자등록번호 : 870-81-03220 | ||||||
|  |  | ||||||
|  | - 통신판매업신고 : 제2024-성남분당B-1012호 | ||||||
|  |  | ||||||
|  | - 고객센터 : 02.2055.1477 (이용시간 10:00~19:00) | ||||||
|  |  | ||||||
|  | - 대표 이메일 : sodalive.official@gmail.com | ||||||
|  | """) | ||||||
|  |                     .font(.custom(Font.medium.rawValue, size: 11)) | ||||||
|  |                     .foregroundColor(Color.gray77) | ||||||
|  |                     .padding(.top, 30) | ||||||
|  |                     .padding(.horizontal, 13.3) | ||||||
|  |                 } | ||||||
|  |                 .onAppear { | ||||||
|  |                     viewModel.fetchData() | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) { | ||||||
|  |                 HStack { | ||||||
|  |                     Spacer() | ||||||
|  |                     Text(viewModel.errorMessage) | ||||||
|  |                         .padding(.vertical, 13.3) | ||||||
|  |                         .frame(width: screenSize().width - 66.7, alignment: .center) | ||||||
|  |                         .font(.custom(Font.medium.rawValue, size: 12)) | ||||||
|  |                         .background(Color.button) | ||||||
|  |                         .foregroundColor(Color.white) | ||||||
|  |                         .multilineTextAlignment(.leading) | ||||||
|  |                         .cornerRadius(20) | ||||||
|  |                         .padding(.bottom, 66.7) | ||||||
|  |                     Spacer() | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #Preview { | ||||||
|  |     ContentMainTabHomeView() | ||||||
|  | } | ||||||
| @@ -0,0 +1,152 @@ | |||||||
|  | // | ||||||
|  | //  ContentMainTabHomeViewModel.swift | ||||||
|  | //  SodaLive | ||||||
|  | // | ||||||
|  | //  Created by klaus on 2/20/25. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import Foundation | ||||||
|  | import Combine | ||||||
|  |  | ||||||
|  | final class ContentMainTabHomeViewModel: ObservableObject { | ||||||
|  |      | ||||||
|  |     private let repository = ContentMainTabHomeRepository() | ||||||
|  |     private let contentRepository = ContentRepository() | ||||||
|  |     private var subscription = Set<AnyCancellable>() | ||||||
|  |      | ||||||
|  |     @Published var errorMessage = "" | ||||||
|  |     @Published var isShowPopup = false | ||||||
|  |     @Published var isLoading = false | ||||||
|  |   | ||||||
|  |     @Published var noticeItem: NoticeItem? = nil | ||||||
|  |     @Published var bannerList = [GetAudioContentBannerResponse]() | ||||||
|  |     @Published var rankCreatorResponse: GetExplorerSectionResponse? = nil | ||||||
|  |     @Published var rankSeriesList = [SeriesListItem]() | ||||||
|  |     @Published var rankSortTypeList: [String] = [] | ||||||
|  |     @Published var rankContentList: [GetAudioContentRankingItem] = [] | ||||||
|  |     @Published var eventBannerList: [EventItem] = [] | ||||||
|  |     @Published var contentRankCreatorList: [ContentCreatorResponse] = [] | ||||||
|  |     @Published var salesCountRankContentList: [GetAudioContentRankingItem] = [] | ||||||
|  |      | ||||||
|  |     func fetchData() { | ||||||
|  |         isLoading = true | ||||||
|  |          | ||||||
|  |         repository.getContentMainHome() | ||||||
|  |             .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(ApiResponse<GetContentMainTabHomeResponse>.self, from: responseData) | ||||||
|  |                      | ||||||
|  |                     if let data = decoded.data, decoded.success { | ||||||
|  |                         self.noticeItem = data.latestNotice | ||||||
|  |                         self.bannerList = data.bannerList | ||||||
|  |                         self.rankCreatorResponse = data.rankCreatorList | ||||||
|  |                         self.rankSortTypeList = data.rankSortTypeList | ||||||
|  |                         self.rankContentList = data.rankContentList | ||||||
|  |                         self.eventBannerList = data.eventBannerList.eventList | ||||||
|  |                         self.contentRankCreatorList = data.contentRankCreatorList | ||||||
|  |                         self.salesCountRankContentList = data.salesCountRankContentList | ||||||
|  |                     } 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 getContentRanking(sort: String = "매출") { | ||||||
|  |         isLoading = true | ||||||
|  |         contentRepository.getContentRanking(page: 1, size: 12, sortType: sort) | ||||||
|  |             .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(ApiResponse<GetAudioContentRanking>.self, from: responseData) | ||||||
|  |                      | ||||||
|  |                     if let data = decoded.data, decoded.success { | ||||||
|  |                         self.rankContentList = data.items | ||||||
|  |                     } 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 getPopularContentByCreator(creatorId: Int) { | ||||||
|  |         isLoading = true | ||||||
|  |         repository.getPopularContentByCreator(creatorId: creatorId) | ||||||
|  |             .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(ApiResponse<[GetAudioContentRankingItem]>.self, from: responseData) | ||||||
|  |                      | ||||||
|  |                     if let data = decoded.data, decoded.success { | ||||||
|  |                         self.salesCountRankContentList = data | ||||||
|  |                     } 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) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,18 @@ | |||||||
|  | // | ||||||
|  | //  GetContentMainTabHomeResponse.swift | ||||||
|  | //  SodaLive | ||||||
|  | // | ||||||
|  | //  Created by klaus on 2/20/25. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | struct GetContentMainTabHomeResponse: Decodable { | ||||||
|  |     let latestNotice: NoticeItem? | ||||||
|  |     let bannerList: [GetAudioContentBannerResponse] | ||||||
|  |     let rankCreatorList: GetExplorerSectionResponse | ||||||
|  |     let rankSeriesList: [SeriesListItem] | ||||||
|  |     let rankSortTypeList: [String] | ||||||
|  |     let rankContentList: [GetAudioContentRankingItem] | ||||||
|  |     let eventBannerList: GetEventResponse | ||||||
|  |     let contentRankCreatorList: [ContentCreatorResponse] | ||||||
|  |     let salesCountRankContentList: [GetAudioContentRankingItem] | ||||||
|  | } | ||||||
| @@ -0,0 +1,170 @@ | |||||||
|  | // | ||||||
|  | //  ContentMainTabHomeRankCreatorView.swift | ||||||
|  | //  SodaLive | ||||||
|  | // | ||||||
|  | //  Created by klaus on 2/20/25. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  | import Kingfisher | ||||||
|  |  | ||||||
|  | struct ContentMainTabHomeRankCreatorView: View { | ||||||
|  |      | ||||||
|  |     let response: GetExplorerSectionResponse | ||||||
|  |      | ||||||
|  |     let rankingCrawns = ["ic_crown_1", "ic_crown_2", "ic_crown_3"] | ||||||
|  |     let rankingColors = [ | ||||||
|  |         [Color(hex: "ffdc00"), Color(hex: "ffb600")], | ||||||
|  |         [Color(hex: "ffffff"), Color(hex: "9f9f9f")], | ||||||
|  |         [Color(hex: "e6a77a"), Color(hex: "c67e4a")], | ||||||
|  |         [Color(hex: "ffffff").opacity(0), Color(hex: "ffffff").opacity(0)] | ||||||
|  |     ] | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         VStack(alignment: .leading, spacing: 0) { | ||||||
|  |             if let desc = response.desc { | ||||||
|  |                 VStack(spacing: 8) { | ||||||
|  |                     Text("\(desc)") | ||||||
|  |                         .font(.custom(Font.bold.rawValue, size: 14.7)) | ||||||
|  |                         .foregroundColor(Color.grayee) | ||||||
|  |                      | ||||||
|  |                     Text("※ 인기 순위는 매주 업데이트됩니다.") | ||||||
|  |                         .font(.custom(Font.light.rawValue, size: 13.3)) | ||||||
|  |                         .foregroundColor(Color.graybb) | ||||||
|  |                 } | ||||||
|  |                 .padding(.vertical, 8) | ||||||
|  |                 .frame(maxWidth: .infinity) | ||||||
|  |                 .background(Color.gray22) | ||||||
|  |                 .padding(.top, 13.3) | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             if let coloredTitle = response.coloredTitle, let color = response.color { | ||||||
|  |                 let titleArray = response.title.components(separatedBy: coloredTitle) | ||||||
|  |                 HStack(spacing: 0) { | ||||||
|  |                     Text(titleArray[0]) | ||||||
|  |                         .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||||
|  |                         .foregroundColor(Color.grayee) | ||||||
|  |                      | ||||||
|  |                     Text(coloredTitle) | ||||||
|  |                         .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||||
|  |                         .foregroundColor(Color(hex: color)) | ||||||
|  |                      | ||||||
|  |                     if titleArray.count > 1 { | ||||||
|  |                         Text(titleArray[1]) | ||||||
|  |                             .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||||
|  |                             .foregroundColor(Color.grayee) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 .padding(.top, 30) | ||||||
|  |             } else { | ||||||
|  |                 Text(response.title) | ||||||
|  |                     .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||||
|  |                     .foregroundColor(Color.grayee) | ||||||
|  |                     .padding(.top, 30) | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             ScrollView(.horizontal, showsIndicators: false) { | ||||||
|  |                 HStack(spacing: 13.3) { | ||||||
|  |                     ForEach(0..<response.creators.count, id: \.self) { index in | ||||||
|  |                         let creator = response.creators[index] | ||||||
|  |                         VStack(spacing: 0) { | ||||||
|  |                             if let _ = response.desc { | ||||||
|  |                                 ZStack { | ||||||
|  |                                     KFImage(URL(string: creator.profileImageUrl)) | ||||||
|  |                                         .cancelOnDisappear(true) | ||||||
|  |                                         .downsampling(size: CGSize(width: 90, height: 90)) | ||||||
|  |                                         .resizable() | ||||||
|  |                                         .clipShape(Circle()) | ||||||
|  |                                         .frame(width: 90, height: 90) | ||||||
|  |                                         .overlay( | ||||||
|  |                                             Circle() | ||||||
|  |                                                 .stroke( | ||||||
|  |                                                     AngularGradient(colors: rankingColors[index < 4 ? index : 3], center: .center), | ||||||
|  |                                                     lineWidth: 3 | ||||||
|  |                                                 ) | ||||||
|  |                                         ) | ||||||
|  |                                      | ||||||
|  |                                     if index < 3 { | ||||||
|  |                                         VStack(alignment: .trailing, spacing: 0) { | ||||||
|  |                                             Spacer() | ||||||
|  |                                              | ||||||
|  |                                             Image(rankingCrawns[index]) | ||||||
|  |                                                 .resizable() | ||||||
|  |                                                 .frame(width: 37, height: 37) | ||||||
|  |                                         } | ||||||
|  |                                         .frame(width: 93.3, height: 93.3, alignment: .trailing) | ||||||
|  |                                     } | ||||||
|  |                                 } | ||||||
|  |                                 .frame(width: 93.3, height: 93.3) | ||||||
|  |                             } else { | ||||||
|  |                                 KFImage(URL(string: creator.profileImageUrl)) | ||||||
|  |                                     .cancelOnDisappear(true) | ||||||
|  |                                     .downsampling(size: CGSize(width: 93, height: 93)) | ||||||
|  |                                     .resizable() | ||||||
|  |                                     .clipShape(Circle()) | ||||||
|  |                                     .frame(width: 93, height: 93) | ||||||
|  |                             } | ||||||
|  |                              | ||||||
|  |                             Text(creator.nickname) | ||||||
|  |                                 .font(.custom(Font.medium.rawValue, size: 11.3)) | ||||||
|  |                                 .foregroundColor(Color.grayee) | ||||||
|  |                                 .lineLimit(1) | ||||||
|  |                                 .frame(width: 93.3) | ||||||
|  |                                 .padding(.top, 13.3) | ||||||
|  |                              | ||||||
|  |                             Text(creator.tags) | ||||||
|  |                                 .font(.custom(Font.medium.rawValue, size: 10)) | ||||||
|  |                                 .foregroundColor(Color.button) | ||||||
|  |                                 .lineLimit(1) | ||||||
|  |                                 .frame(width: 93.3) | ||||||
|  |                                 .padding(.top, 3.3) | ||||||
|  |                         } | ||||||
|  |                         .contentShape(Rectangle()) | ||||||
|  |                         .onTapGesture { | ||||||
|  |                             AppState.shared | ||||||
|  |                                 .setAppStep(step: .creatorDetail(userId: creator.id)) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             .padding(.top, 13.3) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #Preview { | ||||||
|  |     ContentMainTabHomeRankCreatorView( | ||||||
|  |         response: GetExplorerSectionResponse( | ||||||
|  |             title: "인기 크리에이터", | ||||||
|  |             coloredTitle: "인기", | ||||||
|  |             color: "ff5c49", | ||||||
|  |             desc: "2025년 02월 10일 ~ 02월 16일", | ||||||
|  |             creators: [ | ||||||
|  |                 GetExplorerSectionCreatorResponse( | ||||||
|  |                     id: 1, | ||||||
|  |                     nickname: "User1", | ||||||
|  |                     tags: "", | ||||||
|  |                     profileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |                 ), | ||||||
|  |                 GetExplorerSectionCreatorResponse( | ||||||
|  |                     id: 2, | ||||||
|  |                     nickname: "User2", | ||||||
|  |                     tags: "", | ||||||
|  |                     profileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |                 ), | ||||||
|  |                 GetExplorerSectionCreatorResponse( | ||||||
|  |                     id: 3, | ||||||
|  |                     nickname: "User3", | ||||||
|  |                     tags: "", | ||||||
|  |                     profileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |                 ), | ||||||
|  |                 GetExplorerSectionCreatorResponse( | ||||||
|  |                     id: 4, | ||||||
|  |                     nickname: "User4", | ||||||
|  |                     tags: "", | ||||||
|  |                     profileImageUrl: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |                 ) | ||||||
|  |             ] | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  | } | ||||||
| @@ -0,0 +1,82 @@ | |||||||
|  | // | ||||||
|  | //  ContentMainTabHomeRankSeriesView.swift | ||||||
|  | //  SodaLive | ||||||
|  | // | ||||||
|  | //  Created by klaus on 2/20/25. | ||||||
|  | // | ||||||
|  |  | ||||||
|  | import SwiftUI | ||||||
|  |  | ||||||
|  | struct ContentMainTabHomeRankSeriesView: View { | ||||||
|  |      | ||||||
|  |     let seriesList: [SeriesListItem] | ||||||
|  |      | ||||||
|  |     var body: some View { | ||||||
|  |         VStack(alignment: .leading, spacing: 13.3) { | ||||||
|  |             Text("인기 시리즈") | ||||||
|  |                 .font(.custom(Font.bold.rawValue, size: 18.3)) | ||||||
|  |                 .foregroundColor(Color.grayee) | ||||||
|  |              | ||||||
|  |             ScrollView(.horizontal, showsIndicators: false) { | ||||||
|  |                 HStack(alignment: .top, spacing: 13.3) { | ||||||
|  |                     ForEach(0..<seriesList.count, id: \.self) { | ||||||
|  |                         let item = seriesList[$0] | ||||||
|  |                         SeriesListBigItemView(item: item, isVisibleCreator: true) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #Preview { | ||||||
|  |     ContentMainTabHomeRankSeriesView( | ||||||
|  |         seriesList: [ | ||||||
|  |             SeriesListItem( | ||||||
|  |                 seriesId: 1, | ||||||
|  |                 title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)", | ||||||
|  |                 coverImage: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||||
|  |                 publishedDaysOfWeek: "매주 수, 토요일", | ||||||
|  |                 isComplete: true, | ||||||
|  |                 creator: SeriesListItemCreator( | ||||||
|  |                     creatorId: 1, | ||||||
|  |                     nickname: "creator", | ||||||
|  |                     profileImage: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |                 ), | ||||||
|  |                 numberOfContent: 10, | ||||||
|  |                 isNew: true, | ||||||
|  |                 isPopular: true | ||||||
|  |             ), | ||||||
|  |             SeriesListItem( | ||||||
|  |                 seriesId: 2, | ||||||
|  |                 title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)", | ||||||
|  |                 coverImage: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||||
|  |                 publishedDaysOfWeek: "매주 수, 토요일", | ||||||
|  |                 isComplete: false, | ||||||
|  |                 creator: SeriesListItemCreator( | ||||||
|  |                     creatorId: 1, | ||||||
|  |                     nickname: "creator", | ||||||
|  |                     profileImage: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |                 ), | ||||||
|  |                 numberOfContent: 10, | ||||||
|  |                 isNew: false, | ||||||
|  |                 isPopular: true | ||||||
|  |             ), | ||||||
|  |             SeriesListItem( | ||||||
|  |                 seriesId: 1, | ||||||
|  |                 title: "제목, 관심사,프로필+방장, 참여인원(어딘가..)", | ||||||
|  |                 coverImage: "https://test-cf.sodalive.net/profile/default-profile.png", | ||||||
|  |                 publishedDaysOfWeek: "매주 수, 토요일", | ||||||
|  |                 isComplete: false, | ||||||
|  |                 creator: SeriesListItemCreator( | ||||||
|  |                     creatorId: 1, | ||||||
|  |                     nickname: "creator", | ||||||
|  |                     profileImage: "https://test-cf.sodalive.net/profile/default-profile.png" | ||||||
|  |                 ), | ||||||
|  |                 numberOfContent: 10, | ||||||
|  |                 isNew: true, | ||||||
|  |                 isPopular: false | ||||||
|  |             ) | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  | } | ||||||
| @@ -31,6 +31,7 @@ struct SectionEventBannerView: View { | |||||||
|                                     ) |                                     ) | ||||||
|                                 ) |                                 ) | ||||||
|                                 .resizable() |                                 .resizable() | ||||||
|  |                                 .scaledToFill() | ||||||
|                                 .frame(width: proxy.size.width, height: proxy.size.height, alignment: .center) |                                 .frame(width: proxy.size.width, height: proxy.size.height, alignment: .center) | ||||||
|                                 .tag(index) |                                 .tag(index) | ||||||
|                                 .onTapGesture { |                                 .onTapGesture { | ||||||
| @@ -50,6 +51,7 @@ struct SectionEventBannerView: View { | |||||||
|                                     ) |                                     ) | ||||||
|                                 ) |                                 ) | ||||||
|                                 .resizable() |                                 .resizable() | ||||||
|  |                                 .scaledToFill() | ||||||
|                                 .frame(width: proxy.size.width, height: proxy.size.height, alignment: .center) |                                 .frame(width: proxy.size.width, height: proxy.size.height, alignment: .center) | ||||||
|                                 .tag(index) |                                 .tag(index) | ||||||
|                                 .onTapGesture { |                                 .onTapGesture { | ||||||
|   | |||||||
| @@ -23,7 +23,7 @@ struct HomeView: View { | |||||||
|     private let liveView = LiveView() |     private let liveView = LiveView() | ||||||
|     private let audition = AuditionView() |     private let audition = AuditionView() | ||||||
|     private let messageView = MessageView() |     private let messageView = MessageView() | ||||||
|     private let contentView = ContentMainView() |     private let contentView = ContentMainTabHomeView() | ||||||
|      |      | ||||||
|     @State private var isShowPlayer = false |     @State private var isShowPlayer = false | ||||||
|      |      | ||||||
|   | |||||||
| @@ -62,7 +62,7 @@ struct SeriesListBigItemView: View { | |||||||
|             Text(item.title) |             Text(item.title) | ||||||
|                 .font(.custom(Font.medium.rawValue, size: 12)) |                 .font(.custom(Font.medium.rawValue, size: 12)) | ||||||
|                 .foregroundColor(Color.grayee) |                 .foregroundColor(Color.grayee) | ||||||
|                 .lineLimit(2) |                 .lineLimit(1) | ||||||
|              |              | ||||||
|             if isVisibleCreator { |             if isVisibleCreator { | ||||||
|                 HStack(spacing: 3) { |                 HStack(spacing: 3) { | ||||||
|   | |||||||
 Yu Sung
					Yu Sung