feat(i18n): 콘텐츠 모듈 그룹2 하드코딩 문구를 I18n 키로 통일한다

This commit is contained in:
Yu Sung
2026-04-01 16:08:26 +09:00
parent 49e2487617
commit a90996603b
11 changed files with 184 additions and 80 deletions

View File

@@ -25,7 +25,7 @@ struct ContentListView: View {
.resizable()
.frame(width: 20, height: 20)
Text("콘텐츠 전체보기")
Text(I18n.Content.List.title)
.appFont(size: 18.3, weight: .bold)
.foregroundColor(Color(hex: "eeeeee"))
}
@@ -46,7 +46,7 @@ struct ContentListView: View {
}
if userId == UserDefaults.int(forKey: .userId) {
Text("새로운 콘텐츠 등록하기")
Text(I18n.Content.List.createNewContentAction)
.appFont(size: 15, weight: .bold)
.foregroundColor(Color(hex: "eeeeee"))
.padding(.vertical, 17)
@@ -61,7 +61,7 @@ struct ContentListView: View {
HStack(spacing: 13.3) {
Spacer()
Text("최신순")
Text(I18n.Content.Sort.newest)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(
Color(hex: "e2e2e2")
@@ -73,7 +73,7 @@ struct ContentListView: View {
}
}
Text("높은 가격순")
Text(I18n.Content.Sort.priceHigh)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(
Color(hex: "e2e2e2")
@@ -85,7 +85,7 @@ struct ContentListView: View {
}
}
Text("낮은 가격순")
Text(I18n.Content.Sort.priceLow)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(
Color(hex: "e2e2e2")
@@ -103,7 +103,7 @@ struct ContentListView: View {
.padding(.top, 13.3)
HStack(spacing: 0) {
Text("전체")
Text(I18n.Content.Count.totalPrefix)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2"))
@@ -112,7 +112,7 @@ struct ContentListView: View {
.foregroundColor(Color(hex: "ff5c49"))
.padding(.leading, 8)
Text("")
Text(I18n.Content.Count.countUnit)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2"))
.padding(.leading, 2)

View File

@@ -200,7 +200,7 @@ extension ContentPlayManager {
}
private func showError() {
self.errorMessage = "오류가 발생했습니다. 다시 시도해 주세요."
self.errorMessage = I18n.Content.Playback.playFailed
self.isShowPopup = true
self.resetAudioData()
}

View File

@@ -28,7 +28,7 @@ struct ContentCreateSelectThemeView: View {
VStack(spacing: 0) {
HStack(alignment: .top, spacing: 0) {
Text("테마 선택")
Text(I18n.CreateContent.selectTheme)
.appFont(size: 18.3, weight: .bold)
.foregroundColor(.white)

View File

@@ -44,13 +44,13 @@ final class ContentCreateSelectThemeViewModel: ObservableObject {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.errorMessage = I18n.Common.commonError
}
self.isShowPopup = true
}
} catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
}

View File

@@ -36,11 +36,11 @@ struct ContentCreateView: View {
GeometryReader { proxy in
ZStack {
VStack(spacing: 0) {
DetailNavigationBar(title: String(localized: "콘텐츠 등록"))
DetailNavigationBar(title: I18n.CreateContent.registerTitle)
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
Text("썸네일")
Text(I18n.CreateContent.thumbnail)
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -73,7 +73,7 @@ struct ContentCreateView: View {
.frame(alignment: .bottomTrailing)
.onTapGesture { isShowPhotoPicker = true }
Text("등록")
Text(I18n.CreateContent.registerSectionTitle)
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -103,12 +103,12 @@ struct ContentCreateView: View {
.padding(.top, 26.7)
VStack(spacing: 0) {
Text("제목")
Text(I18n.CreateContent.titleLabel)
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading)
TextField("제목을 입력하세요", text: $viewModel.title)
TextField(I18n.CreateContent.titlePlaceholder, text: $viewModel.title)
.autocapitalization(.none)
.disableAutocorrection(true)
.appFont(size: 13.3, weight: .medium)
@@ -121,16 +121,16 @@ struct ContentCreateView: View {
.padding(.top, 13.3)
HStack(spacing: 0) {
Text("내용")
Text(I18n.CreateContent.contentLabel)
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
Spacer()
Text("\(viewModel.detail.count)")
Text(I18n.CreateContent.characterCount(viewModel.detail.count))
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.mainRed)
Text(" / 최대 500자")
Text(I18n.CreateContent.max500CharactersSuffix)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.gray77)
}
@@ -146,7 +146,7 @@ struct ContentCreateView: View {
.cornerRadius(6.7)
.padding(.top, 13.3)
Text("테마")
Text(I18n.CreateContent.themeLabel)
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -188,13 +188,13 @@ struct ContentCreateView: View {
hideKeyboard()
}
Text("태그")
Text(I18n.CreateContent.tagLabel)
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 26.7)
TextField("예: #연애 #커버곡", text: $viewModel.hashtags)
TextField(I18n.CreateContent.tagPlaceholderExample, text: $viewModel.hashtags)
.autocapitalization(.none)
.disableAutocorrection(true)
.appFont(size: 13.3, weight: .medium)
@@ -215,7 +215,7 @@ struct ContentCreateView: View {
.padding(.top, 26.7)
VStack(spacing: 13.3) {
Text("가격 설정")
Text(I18n.CreateContent.priceSettingsTitle)
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -236,7 +236,7 @@ struct ContentCreateView: View {
if !viewModel.isFree {
VStack(spacing: 13.3) {
Text("소장 설정")
Text(I18n.CreateContent.ownershipSettingsTitle)
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -264,13 +264,13 @@ struct ContentCreateView: View {
.padding(.top, 13.3)
VStack(spacing: 0) {
Text(viewModel.purchaseOption == .RENT_ONLY ? "대여 가격" : "소장 가격")
Text(viewModel.purchaseOption == .RENT_ONLY ? I18n.CreateContent.rentPriceLabel : I18n.CreateContent.purchasePriceLabel)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayd2)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 0) {
TextField("가격을 입력하세요(5캔 이상)", text: $viewModel.priceString)
TextField(I18n.CreateContent.priceInputPlaceholder, text: $viewModel.priceString)
.autocapitalization(.none)
.disableAutocorrection(true)
.appFont(size: 14.7, weight: .bold)
@@ -281,7 +281,7 @@ struct ContentCreateView: View {
Spacer()
Text("")
Text(I18n.CreateContent.canUnit)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.gray77)
}
@@ -296,18 +296,18 @@ struct ContentCreateView: View {
.frame(height: 1)
.padding(.top, 11)
Text("※ 이용기간 대여 (5일) | 소장 (서비스종료시까지)")
Text(I18n.CreateContent.rentalPeriodNotice)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.gray77)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 13.3)
Text("※ 대여가격은 소장가격의 70%로 자동 반영")
Text(I18n.CreateContent.rentalPriceAutoNotice)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.gray77)
.frame(maxWidth: .infinity, alignment: .leading)
Text("※ 콘텐츠의 최소금액은 5캔 입니다")
Text(I18n.CreateContent.minimumPriceNotice)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.gray77)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -316,7 +316,7 @@ struct ContentCreateView: View {
if viewModel.price > 0 && viewModel.purchaseOption != .RENT_ONLY {
VStack(spacing: 13.3) {
Text("한정판 설정")
Text(I18n.CreateContent.limitedEditionSettingsTitle)
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -336,7 +336,7 @@ struct ContentCreateView: View {
}
if viewModel.isLimited {
TextField("한정판 개수를 입력하세요", text: $viewModel.limitedString)
TextField(I18n.CreateContent.limitedCountPlaceholder, text: $viewModel.limitedString)
.autocapitalization(.none)
.disableAutocorrection(true)
.appFont(size: 14.7, weight: .bold)
@@ -353,7 +353,7 @@ struct ContentCreateView: View {
}
VStack(spacing: 13.3) {
Text("포인트 사용")
Text(I18n.CreateContent.pointUsageTitle)
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -375,7 +375,7 @@ struct ContentCreateView: View {
.padding(.top, 26.7)
VStack(spacing: 13.3) {
Text("미리듣기")
Text(I18n.CreateContent.previewTitle)
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -398,19 +398,19 @@ struct ContentCreateView: View {
if viewModel.isGeneratePreview {
VStack(spacing: 10) {
Text("미리듣기 시간 설정")
Text(I18n.CreateContent.previewTimeSettingsTitle)
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading)
Text("미리듣기 시간을 직접 설정하지 않으면 콘텐츠 앞부분 15초가 자동으로 설정됩니다. 미리듣기의 시간제한은 없습니다.")
Text(I18n.CreateContent.previewTimeGuide)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.gray77)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 13.3) {
VStack(spacing: 5.3) {
Text("시작 시간")
Text(I18n.CreateContent.previewStartTimeLabel)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayd2)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -429,7 +429,7 @@ struct ContentCreateView: View {
}
VStack(spacing: 5.3) {
Text("종료 시간")
Text(I18n.CreateContent.previewEndTimeLabel)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayd2)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -458,7 +458,7 @@ struct ContentCreateView: View {
if shouldShowAdultSetting {
VStack(spacing: 13.3) {
Text("연령 제한")
Text(I18n.CreateContent.ageRestrictionTitle)
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -477,7 +477,7 @@ struct ContentCreateView: View {
}
}
Text("성인콘텐츠를 전체관람가로 등록할 시 발생하는 법적 책임은 회사와 상관없이 콘텐츠를 등록한 본인에게 있습니다.\n콘텐츠 내용은 물론 제목도 19금 여부를 체크해 주시기 바랍니다.")
Text(I18n.CreateContent.adultLegalNotice)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.mainRed3)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -488,7 +488,7 @@ struct ContentCreateView: View {
}
VStack(spacing: 13.3) {
Text("댓글 가능 여부")
Text(I18n.CreateContent.commentAvailabilityTitle)
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -533,7 +533,7 @@ struct ContentCreateView: View {
if viewModel.isActiveReservation {
HStack(spacing: 13.3) {
VStack(alignment: .leading, spacing: 6.7) {
Text("예약 날짜")
Text(I18n.CreateContent.reservationDateLabel)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee)
@@ -554,7 +554,7 @@ struct ContentCreateView: View {
}
VStack(alignment: .leading, spacing: 6.7) {
Text("예약 시간")
Text(I18n.CreateContent.reservationTimeLabel)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color.grayee)
@@ -586,7 +586,7 @@ struct ContentCreateView: View {
VStack(spacing: 0) {
HStack(alignment: .top, spacing: 0) {
Text("등록")
Text(I18n.CreateContent.registerButton)
.appFont(size: 18.3, weight: .bold)
.foregroundColor(Color.white)
.frame(height: 50)

View File

@@ -154,7 +154,7 @@ final class ContentCreateViewModel: ObservableObject {
mimeType: "image/*")
)
} else {
errorMessage = "커버이미지를 업로드 하지 못했습니다.\n다시 선택해 주세요"
errorMessage = I18n.CreateContent.coverImageUploadFailed
isShowPopup = true
isLoading = false
return
@@ -176,19 +176,19 @@ final class ContentCreateViewModel: ObservableObject {
)
)
} else {
errorMessage = "콘텐츠 파일을 업로드 하지 못했습니다.\n다시 선택해 주세요"
errorMessage = I18n.CreateContent.contentFileUploadFailed
isShowPopup = true
isLoading = false
return
}
} else {
errorMessage = "콘텐츠 파일을 업로드 하지 못했습니다.\n다시 선택해 주세요"
errorMessage = I18n.CreateContent.contentFileUploadFailed
isShowPopup = true
isLoading = false
return
}
} else {
errorMessage = "콘텐츠 파일을 업로드 하지 못했습니다.\n다시 선택해 주세요"
errorMessage = I18n.CreateContent.contentFileUploadFailed
isShowPopup = true
isLoading = false
return
@@ -219,19 +219,19 @@ final class ContentCreateViewModel: ObservableObject {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.errorMessage = I18n.Common.commonError
}
self.isShowPopup = true
}
} catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
}
.store(in: &subscription)
} else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
self.isLoading = false
}
@@ -240,37 +240,37 @@ final class ContentCreateViewModel: ObservableObject {
private func validateData() -> Bool {
if title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
errorMessage = "제목을 입력해 주세요."
errorMessage = I18n.CreateContent.titleRequired
isShowPopup = true
return false
}
if detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || detail.count < 5 {
errorMessage = "내용을 5자 이상 입력해 주세요."
errorMessage = I18n.CreateContent.detailMinLengthRequired
isShowPopup = true
return false
}
if theme == nil {
errorMessage = "테마를 선택해 주세요."
errorMessage = I18n.CreateContent.themeRequired
isShowPopup = true
return false
}
if coverImage == nil {
errorMessage = "커버이미지를 선택해 주세요."
errorMessage = I18n.CreateContent.coverImageRequired
isShowPopup = true
return false
}
if selectedFileUrl == nil {
errorMessage = "오디오 콘텐츠를 선택해 주세요."
errorMessage = I18n.CreateContent.audioContentRequired
isShowPopup = true
return false
}
if !isFree && price < 5 {
errorMessage = "콘텐츠의 최소금액은 5캔 입니다."
errorMessage = I18n.CreateContent.minimumPriceRequired
isShowPopup = true
return false
}
@@ -278,14 +278,14 @@ final class ContentCreateViewModel: ObservableObject {
if previewStartTime.count > 0 && previewEndTime.count > 0 {
let startTimeArray = previewStartTime.split(separator: ":")
if startTimeArray.count != 3 {
errorMessage = "미리 듣기 시간 형식은 00:30:00 과 같아야 합니다"
errorMessage = I18n.CreateContent.previewTimeFormatInvalid
isShowPopup = true
return false
}
for time in startTimeArray {
if time.count != 2 {
errorMessage = "미리 듣기 시간 형식은 00:30:00 과 같아야 합니다"
errorMessage = I18n.CreateContent.previewTimeFormatInvalid
isShowPopup = true
return false
}
@@ -293,14 +293,14 @@ final class ContentCreateViewModel: ObservableObject {
let endTimeArray = previewStartTime.split(separator: ":")
if endTimeArray.count != 3 {
errorMessage = "미리 듣기 시간 형식은 00:30:00 과 같아야 합니다"
errorMessage = I18n.CreateContent.previewTimeFormatInvalid
isShowPopup = true
return false
}
for time in endTimeArray {
if time.count != 2 {
errorMessage = "미리 듣기 시간 형식은 00:30:00 과 같아야 합니다"
errorMessage = I18n.CreateContent.previewTimeFormatInvalid
isShowPopup = true
return false
}
@@ -308,13 +308,13 @@ final class ContentCreateViewModel: ObservableObject {
let timeDifference = timeDifference(startTime: previewStartTime, endTime: previewEndTime)
if timeDifference < 15.0 {
errorMessage = "미리 듣기의 최소 시간은 15초 입니다"
errorMessage = I18n.CreateContent.previewMinimumDurationError
isShowPopup = true
return false
}
} else {
if previewStartTime.count > 0 || previewEndTime.count > 0 {
errorMessage = "미리 듣기 시작 시간과 종료 시간 둘 다 입력을 하거나 둘 다 입력 하지 않아야 합니다."
errorMessage = I18n.CreateContent.previewStartEndBothOrNone
isShowPopup = true
return false
}

View File

@@ -38,7 +38,7 @@ struct QuarterTimePickerView: View {
}
Button(action: { self.isShowing = false }) {
Text("확인")
Text(I18n.Common.confirm)
.appFont(size: 16)
.foregroundColor(Color(hex: "eeeeee"))
.padding(.vertical, 10)

View File

@@ -28,7 +28,7 @@ struct SelectDatePicker: View {
.frame(width: proxy.size.width)
Button(action: { self.isShowing = false }) {
Text("확인")
Text(I18n.Common.confirm)
.appFont(size: 16)
.foregroundColor(Color(hex: "eeeeee"))
.padding(.vertical, 10)

View File

@@ -30,7 +30,7 @@ struct ContentCurationView: View {
HStack(spacing: 13.3) {
Spacer()
Text("최신순")
Text(I18n.Content.Sort.newest)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(
Color(hex: "e2e2e2")
@@ -42,7 +42,7 @@ struct ContentCurationView: View {
}
}
Text("높은 가격순")
Text(I18n.Content.Sort.priceHigh)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(
Color(hex: "e2e2e2")
@@ -54,7 +54,7 @@ struct ContentCurationView: View {
}
}
Text("낮은 가격순")
Text(I18n.Content.Sort.priceLow)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(
Color(hex: "e2e2e2")
@@ -72,7 +72,7 @@ struct ContentCurationView: View {
.padding(.top, 13.3)
HStack(spacing: 0) {
Text("전체")
Text(I18n.Content.Count.totalPrefix)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2"))
@@ -81,7 +81,7 @@ struct ContentCurationView: View {
.foregroundColor(Color(hex: "ff5c49"))
.padding(.leading, 8)
Text("")
Text(I18n.Content.Count.countUnit)
.appFont(size: 13.3, weight: .medium)
.foregroundColor(Color(hex: "e2e2e2"))
.padding(.leading, 2)

View File

@@ -436,6 +436,16 @@ enum I18n {
}
enum Content {
enum List {
static var title: String {
pick(ko: "콘텐츠 전체보기", en: "All content", ja: "コンテンツ一覧")
}
static var createNewContentAction: String {
pick(ko: "새로운 콘텐츠 등록하기", en: "Register new content", ja: "新しいコンテンツを登録する")
}
}
enum All {
static var title: String {
pick(ko: "콘텐츠 전체", en: "All content", ja: "コンテンツ全体")
@@ -523,6 +533,16 @@ enum I18n {
pick(ko: "Sold Out", en: "Sold out", ja: "売り切れ")
}
}
enum Playback {
static var playFailed: String {
pick(
ko: "오류가 발생했습니다. 다시 시도해 주세요.",
en: "An error occurred. Please try again.",
ja: "エラーが発生しました。もう一度お試しください。"
)
}
}
}
enum CharacterDetailGallery {
@@ -2319,6 +2339,52 @@ enum I18n {
static var uploadContentDescriptionHint: String { pick(ko: "내용을 입력하세요", en: "Enter the details.", ja: "内容を入力してください") }
static var uploadTitle: String { pick(ko: "콘텐츠 업로드", en: "Content upload", ja: "コンテンツ投稿") }
static var uploadAction: String { pick(ko: "콘텐츠 업로드", en: "Upload content", ja: "コンテンツを投稿") }
static var registerTitle: String { pick(ko: "콘텐츠 등록", en: "Register content", ja: "コンテンツ登録") }
static var thumbnail: String { pick(ko: "썸네일", en: "Thumbnail", ja: "サムネイル") }
static var registerSectionTitle: String { pick(ko: "등록", en: "Upload", ja: "登録") }
static var titleLabel: String { pick(ko: "제목", en: "Title", ja: "タイトル") }
static var titlePlaceholder: String { pick(ko: "제목을 입력하세요", en: "Enter a title", ja: "タイトルを入力してください") }
static var contentLabel: String { pick(ko: "내용", en: "Details", ja: "内容") }
static func characterCount(_ count: Int) -> String { pick(ko: "\(count)", en: "\(count) chars", ja: "\(count)文字") }
static var max500CharactersSuffix: String { pick(ko: " / 최대 500자", en: " / Max 500 chars", ja: " / 最大500文字") }
static var themeLabel: String { pick(ko: "테마", en: "Theme", ja: "テーマ") }
static var tagLabel: String { pick(ko: "태그", en: "Tags", ja: "タグ") }
static var tagPlaceholderExample: String { pick(ko: "예: #연애 #커버곡", en: "Ex: #romance #cover", ja: "例: #恋愛 #カバー曲") }
static var priceSettingsTitle: String { pick(ko: "가격 설정", en: "Price settings", ja: "価格設定") }
static var ownershipSettingsTitle: String { pick(ko: "소장 설정", en: "Ownership settings", ja: "購入設定") }
static var rentPriceLabel: String { pick(ko: "대여 가격", en: "Rent price", ja: "レンタル価格") }
static var purchasePriceLabel: String { pick(ko: "소장 가격", en: "Purchase price", ja: "購入価格") }
static var priceInputPlaceholder: String { pick(ko: "가격을 입력하세요(5캔 이상)", en: "Enter price (5+ cans)", ja: "価格を入力してください5can以上") }
static var canUnit: String { pick(ko: "", en: "cans", ja: "can") }
static var rentalPeriodNotice: String { pick(ko: "※ 이용기간 대여 (5일) | 소장 (서비스종료시까지)", en: "※ Rental period (5 days) | Purchase (until service end)", ja: "※ 利用期間 レンタル5日購入サービス終了時まで") }
static var rentalPriceAutoNotice: String { pick(ko: "※ 대여가격은 소장가격의 70%로 자동 반영", en: "※ Rent price is automatically set to 70% of purchase price", ja: "※ レンタル価格は購入価格の70%に自動反映されます") }
static var minimumPriceNotice: String { pick(ko: "※ 콘텐츠의 최소금액은 5캔 입니다", en: "※ Minimum content price is 5 cans", ja: "※ コンテンツの最低価格は5canです") }
static var limitedEditionSettingsTitle: String { pick(ko: "한정판 설정", en: "Limited edition settings", ja: "限定版設定") }
static var limitedCountPlaceholder: String { pick(ko: "한정판 개수를 입력하세요", en: "Enter limited edition quantity", ja: "限定版の数量を入力してください") }
static var pointUsageTitle: String { pick(ko: "포인트 사용", en: "Point usage", ja: "ポイント使用") }
static var previewTitle: String { pick(ko: "미리듣기", en: "Preview", ja: "試聴") }
static var previewTimeSettingsTitle: String { pick(ko: "미리듣기 시간 설정", en: "Preview time settings", ja: "試聴時間設定") }
static var previewTimeGuide: String { pick(ko: "미리듣기 시간을 직접 설정하지 않으면 콘텐츠 앞부분 15초가 자동으로 설정됩니다. 미리듣기의 시간제한은 없습니다.", en: "If you do not set preview time manually, the first 15 seconds are set automatically. There is no limit on preview duration.", ja: "試聴時間を直接設定しない場合、コンテンツ冒頭15秒が自動設定されます。試聴時間に上限はありません。") }
static var previewStartTimeLabel: String { pick(ko: "시작 시간", en: "Start time", ja: "開始時間") }
static var previewEndTimeLabel: String { pick(ko: "종료 시간", en: "End time", ja: "終了時間") }
static var ageRestrictionTitle: String { pick(ko: "연령 제한", en: "Age restriction", ja: "年齢制限") }
static var adultLegalNotice: String { pick(ko: "성인콘텐츠를 전체관람가로 등록할 시 발생하는 법적 책임은 회사와 상관없이 콘텐츠를 등록한 본인에게 있습니다.\n콘텐츠 내용은 물론 제목도 19금 여부를 체크해 주시기 바랍니다.", en: "If adult content is registered as suitable for all ages, legal responsibility lies with the person who registered the content, not the company.\nPlease check whether both the content and title are age-restricted.", ja: "成人向けコンテンツを全年齢向けとして登録した場合に発生する法的責任は、会社ではなく登録者本人にあります。\nコンテンツ内容だけでなくタイトルの19禁該当可否も確認してください。") }
static var commentAvailabilityTitle: String { pick(ko: "댓글 가능 여부", en: "Comment availability", ja: "コメント可否") }
static var reservationDateLabel: String { pick(ko: "예약 날짜", en: "Reservation date", ja: "予約日") }
static var reservationTimeLabel: String { pick(ko: "예약 시간", en: "Reservation time", ja: "予約時間") }
static var registerButton: String { pick(ko: "등록", en: "Register", ja: "登録") }
static var coverImageUploadFailed: String { pick(ko: "커버이미지를 업로드 하지 못했습니다.\n다시 선택해 주세요", en: "Failed to upload cover image.\nPlease select again.", ja: "カバー画像をアップロードできませんでした。\nもう一度選択してください。") }
static var contentFileUploadFailed: String { pick(ko: "콘텐츠 파일을 업로드 하지 못했습니다.\n다시 선택해 주세요", en: "Failed to upload content file.\nPlease select again.", ja: "コンテンツファイルをアップロードできませんでした。\nもう一度選択してください。") }
static var titleRequired: String { pick(ko: "제목을 입력해 주세요.", en: "Please enter a title.", ja: "タイトルを入力してください。") }
static var detailMinLengthRequired: String { pick(ko: "내용을 5자 이상 입력해 주세요.", en: "Please enter at least 5 characters for details.", ja: "内容を5文字以上入力してください。") }
static var themeRequired: String { pick(ko: "테마를 선택해 주세요.", en: "Please select a theme.", ja: "テーマを選択してください。") }
static var coverImageRequired: String { pick(ko: "커버이미지를 선택해 주세요.", en: "Please select a cover image.", ja: "カバー画像を選択してください。") }
static var audioContentRequired: String { pick(ko: "오디오 콘텐츠를 선택해 주세요.", en: "Please select audio content.", ja: "オーディオコンテンツを選択してください。") }
static var minimumPriceRequired: String { pick(ko: "콘텐츠의 최소금액은 5캔 입니다.", en: "Minimum content price is 5 cans.", ja: "コンテンツの最低価格は5canです。") }
static var previewTimeFormatInvalid: String { pick(ko: "미리 듣기 시간 형식은 00:30:00 과 같아야 합니다", en: "Preview time format must be like 00:30:00", ja: "試聴時間の形式は00:30:00のようである必要があります") }
static var previewMinimumDurationError: String { pick(ko: "미리 듣기의 최소 시간은 15초 입니다", en: "Minimum preview duration is 15 seconds", ja: "試聴の最小時間は15秒です") }
static var previewStartEndBothOrNone: String { pick(ko: "미리 듣기 시작 시간과 종료 시간 둘 다 입력을 하거나 둘 다 입력 하지 않아야 합니다.", en: "Enter both preview start and end times, or leave both empty.", ja: "試聴の開始時間と終了時間は両方入力するか、両方未入力にしてください。") }
static var uploadDescription: String {
pick(
ko: "등록한 콘텐츠가 업로드 중입니다.\n콘텐츠 등록이 완료되면 알림을 보내드립니다.\n이 페이지를 나가도 콘텐츠는 자동으로 등록됩니다.",

View File

@@ -153,16 +153,16 @@
- [x] `SodaLive/Sources/Content/ContentListItemView.swift`
#### Group 2 (11-20)
- [ ] `SodaLive/Sources/Content/ContentListView.swift`
- [ ] `SodaLive/Sources/Content/ContentPlayManager.swift`
- [ ] `SodaLive/Sources/Content/ContentRepository.swift`
- [ ] `SodaLive/Sources/Content/Create/ContentCreateSelectThemeView.swift`
- [ ] `SodaLive/Sources/Content/Create/ContentCreateSelectThemeViewModel.swift`
- [ ] `SodaLive/Sources/Content/Create/ContentCreateView.swift`
- [ ] `SodaLive/Sources/Content/Create/ContentCreateViewModel.swift`
- [ ] `SodaLive/Sources/Content/Create/QuarterTimePickerView.swift`
- [ ] `SodaLive/Sources/Content/Create/SelectDatePicker.swift`
- [ ] `SodaLive/Sources/Content/Curation/ContentCurationView.swift`
- [x] `SodaLive/Sources/Content/ContentListView.swift`
- [x] `SodaLive/Sources/Content/ContentPlayManager.swift`
- [x] `SodaLive/Sources/Content/ContentRepository.swift`
- [x] `SodaLive/Sources/Content/Create/ContentCreateSelectThemeView.swift`
- [x] `SodaLive/Sources/Content/Create/ContentCreateSelectThemeViewModel.swift`
- [x] `SodaLive/Sources/Content/Create/ContentCreateView.swift`
- [x] `SodaLive/Sources/Content/Create/ContentCreateViewModel.swift`
- [x] `SodaLive/Sources/Content/Create/QuarterTimePickerView.swift`
- [x] `SodaLive/Sources/Content/Create/SelectDatePicker.swift`
- [x] `SodaLive/Sources/Content/Curation/ContentCurationView.swift`
#### Group 3 (21-30)
- [ ] `SodaLive/Sources/Content/Curation/ContentCurationViewModel.swift`
@@ -1012,3 +1012,41 @@
- LSP 진단: SourceKit 단독 해석 환경에서 외부 모듈/프로젝트 심볼 미해결 오류(`Kingfisher`, `BaseView`, `I18n` 등)가 보고되나, 동일 변경셋은 `xcodebuild` 실컴파일 통과로 검증 완료.
- 빌드 검증: `SodaLive`, `SodaLive-dev` Debug 빌드 모두 성공(`** BUILD SUCCEEDED **`).
- 테스트 검증: 두 스킴 모두 `Scheme ... is not currently configured for the test action.`로 test action 미구성 확인(코드 실패 아님, 스킴 제약).
### 22차 구현 (Content 모듈 Group 2, 10개 파일 처리, 2026-04-01)
- 무엇/왜/어떻게:
- 무엇: `변경 대상 파일 전체 목록``Content` Group 2(11~20, 10개 파일)를 전수 점검하고, 런타임 사용자 노출 하드코딩 문구를 `I18n.*` 참조로 전환했다.
- 왜: 콘텐츠 목록/큐레이션/생성 플로우 구간에서 하드코딩 문자열이 남아 있어 `I18n.swift` 단일 접근 원칙과 불일치했기 때문이다.
- 어떻게: Group 2 대상 파일을 병렬 탐색한 뒤(`explore`/`librarian`), `grep`/`ast_grep_search`/`lsp_diagnostics`로 치환 대상을 확정하고 `I18n.Content.*`, `I18n.CreateContent.*` 키를 추가·연결했다. 동적 글자수 표기는 함수형 키(`characterCount(_:)`)로 처리했다.
- 실행 명령/도구:
- `task(subagent_type="explore", ...)` x2 (`bg_70fab19d`, `bg_9fe99782`)
- `task(subagent_type="librarian", ...)` x2 (`bg_934b15f1`, `bg_faea5e5d`)
- `background_output(task_id=...)` x4 (위 4개 task 결과 수집)
- `grep("\"[^\"]*[가-힣][^\"]*\"", include=Group2 대상 파일)`
- `grep("String\\(localized:|NSLocalizedString\\(|LocalizedStringKey\\(", include=Group2 대상 파일)`
- `lsp_diagnostics(filePath=변경 파일 전체)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- `I18n.swift` 확장:
- `I18n.Content.List.*`(목록 섹션/필터/정렬 라벨)
- `I18n.Content.Playback.playFailed`
- `I18n.CreateContent.*`(테마 선택/입력 폼/알림/검증 문구)
- `I18n.CreateContent.characterCount(_ count: Int)`
- 치환 완료 파일(실치환 9개):
- `ContentListView.swift`, `ContentPlayManager.swift`
- `ContentCreateSelectThemeView.swift`, `ContentCreateSelectThemeViewModel.swift`
- `ContentCreateView.swift`, `ContentCreateViewModel.swift`
- `QuarterTimePickerView.swift`, `SelectDatePicker.swift`
- `ContentCurationView.swift`
- 점검만 수행(실치환 없음, 체크 완료 1개):
- `ContentRepository.swift` (사용자 노출 문구 없음; API 정렬 기본 파라미터 `"매출"`만 존재)
- Group 2 체크박스 10개 `- [x]` 완료 반영.
- Group 2 재탐지 결과, 남은 한글 리터럴은 `ContentPlayManager.swift`의 디버그 `print` 로그 3건 및 `ContentRepository.swift`의 API 파라미터 기본값 1건(비노출)만 확인.
- 직접 로컬라이제이션 API(`String(localized:)`, `NSLocalizedString`, `LocalizedStringKey`)는 Group 2 대상 파일에서 0건으로 확인.
- 빌드 검증: `SodaLive`, `SodaLive-dev` Debug 빌드 모두 성공(`** BUILD SUCCEEDED **`).
- 테스트 검증: 두 스킴 모두 `Scheme ... is not currently configured for the test action.`로 test action 미구성 확인(코드 실패 아님, 스킴 제약).
- 수동 QA(문구 경로 수동 점검): Group 2 변경 파일에서 사용자 노출 텍스트가 `I18n.*` 경유인지 라인 단위 검토 완료. 비노출 문자열(디버그 로그/API 파라미터)은 예외로 문서화했다.
- LSP 진단 참고: SourceKit 단독 해석 환경에서 외부 모듈/프로젝트 심볼(`Kingfisher`, `ObjectBox`, `I18n`, `AppState` 등) 미해결 오류가 보고되나, 동일 변경셋은 `xcodebuild` 실컴파일 통과로 검증했다.