diff --git a/index.css b/index.css index 1a289c6..581818c 100644 --- a/index.css +++ b/index.css @@ -8889,7 +8889,38 @@ display: flex; align-items: baseline; gap: 16px; - margin-bottom: 32px; + margin-bottom: 16px; +} + +.ado2-sort-pills { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.ado2-sort-pill { + padding: 6px 14px; + border-radius: 20px; + border: 1px solid rgba(155, 202, 204, 0.3); + background: transparent; + color: #9BCACC; + font-size: 13px; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; + white-space: nowrap; +} + +.ado2-sort-pill:hover { + background: rgba(155, 202, 204, 0.1); + color: #fff; +} + +.ado2-sort-pill.active { + background: #1A8F93; + border-color: #1A8F93; + color: #fff; + font-weight: 600; } .ado2-contents-title { @@ -8905,6 +8936,285 @@ color: #9BCACC; } +.ado2-contents-filters { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.ado2-filter-select { + height: 38px; + padding: 0 12px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.15); + background: rgba(255,255,255,0.07); + color: var(--color-text-white); + font-size: 14px; + cursor: pointer; + outline: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23aaa' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 30px; + min-width: 110px; +} + +.ado2-filter-select:focus { + border-color: rgba(155,202,204,0.5); +} + +.ado2-filter-select option { + background: #1e2a2b; + color: var(--color-text-white); +} + +.ado2-filter-search { + display: flex; + gap: 8px; + flex: 1; + max-width: 360px; +} + +.ado2-filter-input { + flex: 1; + height: 38px; + padding: 0 12px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.15); + background: rgba(255,255,255,0.07); + color: var(--color-text-white); + font-size: 14px; + outline: none; +} + +.ado2-filter-input::placeholder { + color: rgba(255,255,255,0.35); +} + +.ado2-filter-input:focus { + border-color: rgba(155,202,204,0.5); +} + +.ado2-filter-btn { + height: 38px; + padding: 0 16px; + border-radius: 8px; + border: none; + background: #9BCACC; + color: #1a2a2b; + font-size: 14px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +.ado2-filter-btn:hover { + background: #7db8bb; +} + +.ado2-order-btn { + height: 38px; + width: 38px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.15); + background: rgba(255,255,255,0.07); + color: var(--color-text-white); + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.ado2-order-btn:hover { + background: rgba(155,202,204,0.2); + border-color: rgba(155,202,204,0.5); +} + +.ado2-region-pill { + display: inline-flex; + align-items: center; + gap: 6px; + height: 38px; + padding: 0 16px; + border-radius: 999px; + border: 1.5px solid rgba(255,255,255,0.25); + background: transparent; + color: rgba(255,255,255,0.5); + font-size: 14px; + cursor: pointer; + white-space: nowrap; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} + +.ado2-region-pill:hover { + border-color: rgba(155,202,204,0.6); + color: var(--color-text-white); +} + +.ado2-region-pill.active { + border-color: #9BCACC; + color: #9BCACC; + background: rgba(155,202,204,0.08); +} + +.ado2-region-clear { + font-size: 12px; + color: rgba(155,202,204,0.7); + padding: 1px 3px; + border-radius: 4px; + line-height: 1; +} + +.ado2-region-clear:hover { + color: #9BCACC; + background: rgba(155,202,204,0.15); +} + +.city-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.city-modal { + background: #1a2a2b; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 16px; + width: 375px; + max-width: 92vw; + display: flex; + flex-direction: column; +} + +.city-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 20px 14px; + border-bottom: 1px solid rgba(255,255,255,0.08); + flex-shrink: 0; +} + +.city-modal-title { + font-size: 16px; + font-weight: 600; + color: var(--color-text-white); + display: flex; + align-items: center; + gap: 8px; +} + +.city-modal-back { + background: none; + border: none; + color: rgba(255,255,255,0.6); + font-size: 18px; + cursor: pointer; + padding: 0 4px; + line-height: 1; +} + +.city-modal-back:hover { + color: var(--color-text-white); +} + +.city-modal-close { + background: none; + border: none; + color: rgba(255,255,255,0.5); + font-size: 16px; + cursor: pointer; + padding: 4px 6px; + border-radius: 6px; +} + +.city-modal-close:hover { + color: var(--color-text-white); + background: rgba(255,255,255,0.08); +} + +.city-modal-grid { + display: flex; + flex-direction: column; + padding: 8px 0; +} + +.city-modal-region-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 14px 20px; + background: none; + border: none; + border-bottom: 1px solid rgba(255,255,255,0.05); + color: var(--color-text-white); + font-size: 15px; + cursor: pointer; + text-align: left; +} + +.city-modal-region-item:hover { + background: rgba(255,255,255,0.05); +} + +.city-modal-region-item.has-selected { + color: #9BCACC; +} + +.city-modal-region-badge { + font-size: 12px; + background: rgba(155,202,204,0.2); + color: #9BCACC; + border-radius: 10px; + padding: 2px 8px; +} + +.city-modal-arrow { + margin-left: auto; + color: rgba(255,255,255,0.3); + font-size: 18px; +} + +.city-modal-item-wrap { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px 16px 20px; +} + +.city-modal-item { + padding: 7px 16px; + border-radius: 20px; + border: 1px solid rgba(255,255,255,0.15); + background: rgba(255,255,255,0.05); + color: var(--color-text-white); + font-size: 13px; + cursor: pointer; + white-space: nowrap; +} + +.city-modal-item:hover { + background: rgba(155,202,204,0.15); + border-color: rgba(155,202,204,0.4); +} + +.city-modal-item.active { + background: #9BCACC; + border-color: #9BCACC; + color: #1a2a2b; + font-weight: 600; +} + .ado2-contents-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); @@ -8942,6 +9252,46 @@ justify-content: center; } +/* ADO2 갤러리 호버 오버레이 */ +.ado2-gallery-thumbnail-wrap { + position: relative; +} + +.ado2-gallery-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0); + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; + pointer-events: none; +} + +.ado2-gallery-thumbnail-wrap:hover .ado2-gallery-overlay { + background: rgba(0, 0, 0, 0.45); +} + +.ado2-gallery-play-btn { + width: 56px; + height: 56px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.9); + color: #01282A; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transform: scale(0.8); + transition: opacity 0.2s ease, transform 0.2s ease; + padding-left: 4px; +} + +.ado2-gallery-thumbnail-wrap:hover .ado2-gallery-play-btn { + opacity: 1; + transform: scale(1); +} + .content-card-info { padding: 16px; display: flex; @@ -8966,6 +9316,19 @@ text-overflow: ellipsis; } +.content-card-like { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + font-size: 15px; + font-weight: 500; + color: #9BCACC; + white-space: nowrap; + flex-shrink: 0; + margin-left: 8px; +} + .content-card-date { font-size: 14px; font-weight: 400; @@ -9106,6 +9469,536 @@ cursor: not-allowed; } +/* ── VideoDetailPage / VideoDetailModal ─────────────────────────── */ + +/* 모달 내부 콘텐츠 (풀페이지의 video-detail-page와 동일 스타일, 배경 제외) */ +.video-detail-modal-content { + color: #fff; + padding: 24px 32px; +} + +.video-detail-page-content { + color: #fff; + padding: 0; + flex: 1; + display: flex; + flex-direction: column; +} + +/* 모달 닫기 버튼 */ +.video-detail-close-btn { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: #9BCACC; + cursor: pointer; + padding: 4px; + margin-left: auto; + transition: color 0.2s; +} + +.video-detail-close-btn:hover { + color: #fff; +} + +.video-detail-page { + min-height: 100vh; + background: #01282A; + color: #fff; + padding: 24px 32px; + max-width: 900px; + margin: 0 auto; + display: flex; + flex-direction: column; +} + +.video-detail-header { + margin-bottom: 24px; +} + +.video-detail-back-btn { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + color: #9BCACC; + font-size: 14px; + cursor: pointer; + padding: 4px 0; + transition: color 0.2s; +} + +.video-detail-back-btn:hover { + color: #fff; +} + +.video-detail-content { + display: flex; + gap: 32px; + flex-wrap: wrap; + align-items: center; + flex: 1; + margin: auto 0; +} + +/* 가로 영상: 영상 위, 정보 아래 */ +.video-detail-content.landscape { + flex-direction: column; +} + +.video-detail-player { + display: block; + width: 100%; + height: auto; + max-width: 450px; + border-radius: 12px; + flex-shrink: 0; +} + +.video-detail-content.landscape .video-detail-player { + max-width: 100%; + width: 100%; +} + +.video-detail-info { + flex: 1 1 220px; + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 8px; + align-self: flex-start; +} + +.video-detail-content.landscape .video-detail-info { + flex: 1 1 auto; + width: 100%; + align-self: stretch; + padding-top: 0; +} + +.video-detail-store { + font-size: 22px; + font-weight: 600; + margin: 0; + line-height: 1.3; +} + +.video-detail-date { + font-size: 14px; + color: #9BCACC; + margin: 0; +} + +.video-detail-copy-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 16px; + background: #034A4D; + border: 1px solid #6AB0B3; + border-radius: 8px; + color: #9BCACC; + font-size: 14px; + cursor: pointer; + transition: background 0.2s, color 0.2s; + width: fit-content; +} + +.video-detail-copy-btn:hover { + background: #024648; + color: #fff; +} + +.video-detail-copy-btn.copied { + background: #1A8F93; + border-color: #1A8F93; + color: #fff; +} + +.video-detail-share-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 100; + background: #012023; + border: 1px solid #2A6669; + border-radius: 10px; + padding: 6px; + min-width: 160px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + gap: 2px; +} + +.video-detail-share-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + background: transparent; + border: none; + border-radius: 7px; + color: #C5E8EA; + font-size: 14px; + cursor: pointer; + transition: background 0.15s; + width: 100%; + text-align: left; +} + +.video-detail-share-item:hover { + background: #023E42; + color: #fff; +} + +/* 비로그인 플레이스홀더 */ +.video-detail-placeholder { + flex: 1 1 320px; + max-width: 480px; + aspect-ratio: 9/16; + max-height: 80vh; + background: #034A4D; + border-radius: 12px; + filter: blur(4px); + opacity: 0.4; +} + +.video-detail-placeholder-text { + height: 24px; + background: #034A4D; + border-radius: 6px; + opacity: 0.4; + filter: blur(4px); +} + +.video-detail-placeholder-text.short { + width: 60%; + height: 16px; +} + +/* ── 댓글 섹션 ─────────────────────────────── */ + +.video-detail-comments { + border-top: 1px solid rgba(155, 202, 204, 0.2); + padding-top: 20px; + margin-top: 8px; +} + +.video-detail-comments-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + +.video-detail-comments-count { + font-size: 14px; + color: #9BCACC; + font-weight: 500; +} + +/* 댓글 작성자 프로필 */ +.video-detail-comment-profile { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.video-detail-comment-profile-avatar-wrap { + position: relative; + flex-shrink: 0; +} + +.video-detail-avatar-change-btn { + position: absolute; + bottom: -4px; + right: -4px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #1A8F93; + border: none; + color: #fff; + font-size: 11px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.video-detail-nickname-input { + flex: 0 0 auto; + width: 200px; + background: #034A4D; + border: 1px solid rgba(155, 202, 204, 0.25); + border-radius: 8px; + padding: 8px 12px; + color: #fff; + font-size: 13px; + outline: none; +} + +.video-detail-nickname-input::placeholder { + color: #6B9EA0; +} + +.video-detail-nickname-input:focus { + border-color: #9BCACC; +} + +/* 댓글 닉네임 */ +.video-detail-comment-nickname { + font-size: 13px; + font-weight: 600; + color: #9BCACC; + display: block; + margin-bottom: 2px; +} + +/* 날짜 + 삭제 버튼 한 줄 */ +.video-detail-comment-bottom { + display: flex; + align-items: center; + gap: 10px; + margin-top: 4px; +} + +.video-detail-comments-title { + font-size: 17px; + font-weight: 600; + margin: 0; +} + +.video-detail-comment-input-wrap { + display: flex; + gap: 8px; + margin-bottom: 24px; +} + +.video-detail-comment-input { + flex: 1; + background: #034A4D; + border: 1px solid rgba(155, 202, 204, 0.25); + border-radius: 8px; + padding: 12px 16px; + color: #fff; + font-size: 14px; + outline: none; + transition: border-color 0.2s; + resize: none; + overflow: hidden; + line-height: 1.5; + min-height: 44px; + font-family: inherit; +} + +.video-detail-comment-input::placeholder { + color: #6B9EA0; +} + +.video-detail-comment-input:focus { + border-color: #9BCACC; +} + +.video-detail-comment-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.video-detail-comment-submit { + padding: 12px 20px; + background: #1A8F93; + border: none; + border-radius: 8px; + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + white-space: nowrap; +} + +.video-detail-comment-submit:hover:not(:disabled) { + background: #158489; +} + +.video-detail-comment-submit:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.video-detail-comments-empty { + color: #6B9EA0; + font-size: 14px; + margin: 0; +} + +.video-detail-comment-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 20px; +} + +.video-detail-comment-item { + display: flex; + gap: 12px; +} + +.video-detail-comment-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + background: #034A4D; +} + +.video-detail-comment-avatar.small { + width: 28px; + height: 28px; +} + +.video-detail-comment-body { + flex: 1; +} + +.video-detail-comment-meta { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 4px; +} + +.video-detail-comment-name { + font-size: 14px; + font-weight: 600; +} + +.video-detail-comment-date { + font-size: 12px; + color: #6B9EA0; +} + +.video-detail-comment-text { + font-size: 14px; + color: #C8E6E8; + margin: 0; + line-height: 1.5; + word-break: break-all; + overflow-wrap: break-word; +} + +.video-detail-comment-delete { + background: none; + border: none; + color: #6B9EA0; + font-size: 12px; + cursor: pointer; + padding: 0; + transition: color 0.2s; +} + +.video-detail-comment-delete:hover { + color: #ff6b6b; +} + +.video-detail-reply-list { + list-style: none; + padding: 0; + margin: 8px 0 0 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.video-detail-reply-item { + display: flex; + gap: 10px; + padding-left: 4px; + border-left: 2px solid rgba(155, 202, 204, 0.2); +} + +.video-detail-comments-more { + width: 100%; + margin-top: 16px; + padding: 10px; + background: transparent; + border: 1px solid rgba(155, 202, 204, 0.25); + border-radius: 8px; + color: #9BCACC; + font-size: 14px; + cursor: pointer; + transition: background 0.2s; +} + +.video-detail-comments-more:hover:not(:disabled) { + background: rgba(155, 202, 204, 0.08); +} + +.video-detail-comments-more:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 좋아요 버튼 */ +.video-detail-like-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 16px; + background: #034A4D; + border: 1px solid rgba(155, 202, 204, 0.3); + border-radius: 8px; + color: #9BCACC; + font-size: 14px; + cursor: pointer; + transition: background 0.2s, color 0.2s, border-color 0.2s; +} + +.video-detail-like-btn:hover:not(:disabled) { + background: #024648; + color: #fff; +} + +.video-detail-like-btn.liked { + background: rgba(255, 100, 100, 0.15); + border-color: rgba(255, 100, 100, 0.4); + color: #ff6b6b; +} + +.video-detail-like-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +@media (max-width: 600px) { + .video-detail-page { + padding: 16px; + } + .video-detail-content { + flex-direction: column; + } + .video-detail-player { + max-width: 100%; + width: 100%; + } + .video-detail-info { + flex: 0 0 auto; + width: 100%; + align-self: auto; + padding-top: 0; + } +} + .pagination-info { color: #9BCACC; font-size: 14px; diff --git a/index.html b/index.html index c93a28e..3c2e33b 100755 --- a/index.html +++ b/index.html @@ -36,6 +36,7 @@ } } + diff --git a/public/privacy.html b/public/privacy.html new file mode 100644 index 0000000..09fb86a --- /dev/null +++ b/public/privacy.html @@ -0,0 +1,92 @@ + + + + + + 개인정보처리방침 - ADO2 + + + +

개인정보처리방침 (Privacy Policy)

+

시행일: 2026년 5월 7일  |  최종 수정일: 2026년 5월 27일

+ +

㈜에이아이오투오(이하 "회사")는 AI 마케팅 자동화 서비스 ADO2(이하 "서비스")를 제공함에 있어 사용자의 개인정보를 중요시하며, 「개인정보 보호법」 등 관련 법령을 성실히 준수합니다.

+ +

1. 수집하는 개인정보 항목 및 수집 방법

+

회사는 서비스 제공을 위해 아래와 같은 개인정보를 수집합니다.

+ + +

2. Google 사용자 데이터의 수집 및 이용 목적

+

회사가 접근하는 Google 사용자 데이터는 아래 목적으로만 사용되며, 명시된 목적 외에는 사용하지 않습니다.

+ + +
+

[Google API 서비스 사용자 데이터 정책 준수]

+ ㈜에이아이오투오가 운영하는 ADO2 서비스가 Google API로부터 수신한 정보의 사용 및 타 앱으로의 전송은, + Google API 서비스 사용자 데이터 정책의 + 제한적 사용(Limited Use) 요건을 포함한 모든 정책을 엄격히 준수합니다.

+ 특히, Google API로부터 수신한 데이터는 AI·ML 모델 학습에 사용되지 않으며, 사용자가 명시적으로 요청한 서비스 기능 제공 목적 외에는 사용·전송·공유되지 않습니다.

+
+ +

3. Google 사용자 데이터의 제3자 공유

+

회사는 Google API로부터 수신한 사용자 데이터를 아래 경우를 제외하고 어떠한 제3자에게도 판매하거나 공유하지 않습니다.

+ +

Google 사용자 데이터는 광고, 마케팅, 프로파일링 목적으로 사용되지 않습니다.

+ +

4. Google 사용자 데이터의 저장 및 보안

+ + +

5. 개인정보의 보유 및 이용 기간 / 데이터 삭제

+

원칙적으로 회원 탈퇴 또는 개인정보 수집·이용 목적이 달성된 후에는 해당 정보를 지체 없이 파기합니다. 단, 관련 법령에 따라 보존이 필요한 경우 해당 기간 동안 보관합니다.

+ +

데이터 삭제 요청 방법: 아래 이메일(o2oteam@o2o.kr)로 요청하시면 영업일 기준 7일 이내에 처리 결과를 안내해 드립니다. Google 계정 연동 해제는 서비스 내 계정 설정에서 직접 처리하실 수 있으며, 해제 즉시 관련 토큰이 삭제됩니다.

+ +

6. 정보주체의 권리 및 행사 방법

+

사용자는 언제든지 자신의 개인정보에 대한 열람, 수정, 삭제, 처리 정지를 요청할 수 있습니다. 서비스 내 계정 설정에서 직접 처리하거나 아래 문의처로 연락해 주시기 바랍니다.

+

또한, Google 계정 권한 관리 페이지에서 ADO2 앱의 Google 데이터 접근 권한을 언제든지 직접 취소하실 수 있습니다.

+ +

7. 개인정보 보호책임자 및 문의처

+

개인정보 보호와 관련된 불만 처리 및 피해 구제에 관한 사항은 아래로 문의해 주시기 바랍니다.

+ + +

본 방침은 2026년 5월 7일부터 시행됩니다. 최종 수정일: 2026년 5월 27일

+ + \ No newline at end of file diff --git a/public/terms.html b/public/terms.html new file mode 100644 index 0000000..8941c00 --- /dev/null +++ b/public/terms.html @@ -0,0 +1,66 @@ + + + + + + 서비스 약관 - ADO2 + + + +

서비스 이용약관 (Terms of Service)

+

시행일: 2026년 5월 7일  |  최종 수정일: 2026년 5월 21일

+ +

제 1 조 (목적)

+

본 약관은 ㈜에이아이오투오(이하 "회사")가 제공하는 AI 마케팅 자동화 서비스 ADO2(이하 "서비스")의 이용과 관련하여, 회사와 이용자(이하 "회원") 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.

+ +

제 2 조 (용어의 정의)

+ + +

제 3 조 (약관의 효력 및 변경)

+

회사는 본 약관의 내용을 서비스 화면에 게시하며, 관련 법령을 위배하지 않는 범위에서 약관을 개정할 수 있습니다. 약관이 변경되는 경우 시행일 7일 전부터 공지합니다.

+ +

제 4 조 (서비스의 제공 및 변경)

+

회사는 AI 기반 마케팅 콘텐츠(가사, 이미지, 영상 등) 자동 생성, Google·YouTube 등 외부 플랫폼 연동, SNS 자동 배포 등의 서비스를 제공합니다. 운영상·기술상의 필요에 따라 서비스의 전부 또는 일부를 변경할 수 있습니다.

+ +

제 5 조 (회원의 의무)

+ + +

제 6 조 (외부 API 연동 및 데이터 활용)

+

서비스는 Google, YouTube, Naver 등의 제3자 API를 활용하여 마케팅 자동화 기능을 제공합니다. 특히 YouTube 서비스 연동을 위해 YouTube Data API(youtube.readonly, youtube.upload) 및 YouTube Analytics API(yt-analytics.readonly)를 사용하며, 이를 통해 수집·처리되는 데이터는 개인정보처리방침에 따라 관리됩니다. 회원은 각 외부 서비스의 이용약관 및 정책을 준수할 의무가 있으며, 외부 API 제공사의 정책 변경으로 인한 서비스 제한에 대해 회사는 면책됩니다.

+

YouTube API 서비스 이용과 관련하여 YouTube 이용약관Google 개인정보처리방침이 함께 적용됩니다.

+ +

제 7 조 (AI 생성 콘텐츠의 권리)

+

서비스 내에서 AI가 생성한 콘텐츠에 대한 권리 관계는 관련 법령 및 회사의 별도 정책에 따릅니다. 회원이 직접 입력한 정보(매장 URL, 상호명 등)를 기반으로 생성된 콘텐츠에 대한 책임은 회원에게 있습니다.

+ +

제 8 조 (책임 제한)

+

회사는 천재지변, 외부 플랫폼(Google, YouTube, Naver 등)의 장애, 통신 장애 등 불가항력으로 서비스를 제공할 수 없는 경우 책임이 면제됩니다.

+ +

제 9 조 (준거법 및 재판관할)

+

본 약관과 관련된 분쟁은 대한민국 법을 준거법으로 하며, 소송은 회사의 소재지를 관할하는 법원에 제소합니다.

+ +

문의처

+ + +

본 약관은 2026년 5월 7일부터 시행됩니다.

+ + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 30953e8..15d8c60 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,9 @@ import GenerationFlow from './pages/Dashboard/GenerationFlow'; import SocialConnectSuccess from './pages/Social/SocialConnectSuccess'; import SocialConnectError from './pages/Social/SocialConnectError'; import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback'; +import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage'; +import VideoDetailPage from './components/VideoDetailPage'; +import LoginPromptModal from './components/LoginPromptModal'; import { crawlUrl, autocomplete, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api'; import { saveSearchHistory } from './components/SearchHistory/useSearchHistory'; import { CrawlingResponse } from './types/api'; @@ -144,6 +147,12 @@ const App: React.FC = () => { localStorage.removeItem('castad_wizard_step'); localStorage.removeItem('castad_active_item'); } + const redirectPath = sessionStorage.getItem('castad_login_redirect'); + sessionStorage.removeItem('castad_login_redirect'); + if (redirectPath && redirectPath !== '/') { + window.location.href = redirectPath; + return; + } setInitialTab('새 프로젝트 만들기'); setViewMode('generation_flow'); } catch (err) { @@ -387,6 +396,13 @@ const App: React.FC = () => { return ; } + // 영상 상세 페이지 (/video/{id}) — 인증 게이트는 VideoDetailPage 내부에서 처리 + const videoDetailMatch = pathname.match(/^\/video\/([^/]+)$/); + if (videoDetailMatch) { + return ; + } + + // 카카오 콜백 처리 중 로딩 화면 표시 if (isProcessingCallback) { return ( diff --git a/src/components/CitySelectModal.tsx b/src/components/CitySelectModal.tsx new file mode 100644 index 0000000..de42fba --- /dev/null +++ b/src/components/CitySelectModal.tsx @@ -0,0 +1,145 @@ +import React, { useState, useEffect } from 'react'; + +const REGIONS: { label: string; cities: string[] }[] = [ + { + label: '특별시 / 광역시', + cities: ['서울시', '부산시', '대구시', '인천시', '광주시', '대전시', '울산시', '세종시'], + }, + { + label: '경기도', + cities: [ + '수원시', '성남시', '고양시', '용인시', '부천시', '안산시', '안양시', '남양주시', + '화성시', '평택시', '의정부시', '시흥시', '파주시', '김포시', '광주시', '광명시', + '군포시', '하남시', '오산시', '이천시', '안성시', '구리시', '양주시', '포천시', + '여주시', '동두천시', '과천시', '가평군', '양평군', '연천군', + ], + }, + { + label: '강원도', + cities: [ + '춘천시', '원주시', '강릉시', '동해시', '태백시', '속초시', '삼척시', + '홍천군', '횡성군', '영월군', '평창군', '정선군', '철원군', '화천군', + '양구군', '인제군', '고성군', '양양군', + ], + }, + { + label: '충청북도', + cities: [ + '청주시', '충주시', '제천시', + '보은군', '옥천군', '영동군', '증평군', '진천군', '괴산군', '음성군', '단양군', + ], + }, + { + label: '충청남도', + cities: [ + '천안시', '공주시', '보령시', '아산시', '서산시', '논산시', '계룡시', '당진시', + '금산군', '부여군', '서천군', '청양군', '홍성군', '예산군', '태안군', + ], + }, + { + label: '전라북도', + cities: [ + '전주시', '군산시', '익산시', '정읍시', '남원시', '김제시', + '완주군', '진안군', '무주군', '장수군', '임실군', '순창군', '고창군', '부안군', + ], + }, + { + label: '전라남도', + cities: [ + '목포시', '여수시', '순천시', '나주시', '광양시', + '담양군', '곡성군', '구례군', '고흥군', '보성군', '화순군', '장흥군', '강진군', + '해남군', '영암군', '무안군', '함평군', '영광군', '장성군', '완도군', '진도군', '신안군', + ], + }, + { + label: '경상북도', + cities: [ + '포항시', '경주시', '김천시', '안동시', '구미시', '영주시', '영천시', '상주시', '문경시', '경산시', + '의성군', '청송군', '영양군', '영덕군', '청도군', '고령군', '성주군', '칠곡군', + '예천군', '봉화군', '울진군', '울릉군', + ], + }, + { + label: '경상남도', + cities: [ + '창원시', '진주시', '통영시', '사천시', '김해시', '밀양시', '거제시', '양산시', + '의령군', '함안군', '창녕군', '고성군', '남해군', '하동군', '산청군', '함양군', '거창군', '합천군', + ], + }, + { + label: '제주도', + cities: ['제주시', '서귀포시'], + }, +]; + +interface CitySelectModalProps { + selected: string; + onSelect: (city: string) => void; + onClose: () => void; +} + +const CitySelectModal: React.FC = ({ selected, onSelect, onClose }) => { + const [activeRegion, setActiveRegion] = useState( + () => REGIONS.find(r => r.cities.includes(selected))?.label ?? null + ); + + useEffect(() => { + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = ''; }; + }, []); + + const cities = REGIONS.find(r => r.label === activeRegion)?.cities ?? []; + + const handleCityClick = (city: string) => { + onSelect(city === selected ? '' : city); + onClose(); + }; + + return ( +
+
e.stopPropagation()}> +
+ + {activeRegion ? ( + <> + + {activeRegion} + + ) : '지역 선택'} + + +
+ + {activeRegion === null ? ( +
+ {REGIONS.map(r => ( + + ))} +
+ ) : ( +
+ {cities.map(city => ( + + ))} +
+ )} +
+
+ ); +}; + +export default CitySelectModal; diff --git a/src/components/LoginPromptModal.tsx b/src/components/LoginPromptModal.tsx new file mode 100644 index 0000000..3a1ccbd --- /dev/null +++ b/src/components/LoginPromptModal.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { getKakaoLoginUrl } from '../utils/api'; + +interface LoginPromptModalProps { + onClose: () => void; +} + +const LoginPromptModal: React.FC = ({ onClose }) => { + const { t } = useTranslation(); + + const handleLogin = async () => { + try { + sessionStorage.setItem('castad_login_redirect', window.location.pathname); + const response = await getKakaoLoginUrl(); + window.location.href = response.auth_url; + } catch (err) { + console.error('Failed to get Kakao login URL:', err); + } + }; + + return ( +
+
e.stopPropagation()} + > + +

+ {t('loginPrompt.title')} +

+ + +
+
+ ); +}; + +export default LoginPromptModal; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 0a2ec5a..1a1f0e1 100755 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -85,7 +85,7 @@ const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userI { id: '대시보드', label: t('sidebar.dashboard'), disabled: false, icon: }, { id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: }, { id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: }, - { id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: true, icon: }, + { id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: false, icon: }, { id: '콘텐츠 캘린더', label: t('contentCalendar.title'), disabled: false, icon: }, { id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: }, ]; diff --git a/src/components/Tutorial/TutorialOverlay.tsx b/src/components/Tutorial/TutorialOverlay.tsx index 7c4770c..7f76c93 100644 --- a/src/components/Tutorial/TutorialOverlay.tsx +++ b/src/components/Tutorial/TutorialOverlay.tsx @@ -27,7 +27,11 @@ interface TutorialOverlayProps { const PADDING = 8; function getTargetRect(selector: string): Rect | null { - const el = document.querySelector(selector); + const els = Array.from(document.querySelectorAll(selector)); + const el = els.find(e => { + const r = (e as HTMLElement).getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }) ?? els[0]; if (!el) return null; const r = el.getBoundingClientRect(); return { top: r.top, left: r.left, width: r.width, height: r.height, bottom: r.bottom }; diff --git a/src/components/Tutorial/tutorialSteps.ts b/src/components/Tutorial/tutorialSteps.ts index c993419..98c10dc 100644 --- a/src/components/Tutorial/tutorialSteps.ts +++ b/src/components/Tutorial/tutorialSteps.ts @@ -50,7 +50,7 @@ export const tutorialSteps: TutorialStepDef[] = [ targetSelector: '.hero-dropdown-trigger', titleKey: 'tutorial.landing.dropdown.title', descriptionKey: 'tutorial.landing.dropdown.desc', - position: 'left', + position: 'top', clickToAdvance: false, noSpotlight: true, variant: 'bubble', @@ -59,7 +59,7 @@ export const tutorialSteps: TutorialStepDef[] = [ targetSelector: '.hero-input-wrapper', titleKey: 'tutorial.landing.field.title', descriptionKey: 'tutorial.landing.field.desc', - position: 'right', + position: 'top', clickToAdvance: false, noSpotlight: true, variant: 'bubble', @@ -68,7 +68,7 @@ export const tutorialSteps: TutorialStepDef[] = [ targetSelector: '.hero-button', titleKey: 'tutorial.landing.button.title', descriptionKey: 'tutorial.landing.button.desc', - position: 'right', + position: 'bottom', clickToAdvance: true, noSpotlight: true, variant: 'bubble', @@ -86,17 +86,17 @@ export const tutorialSteps: TutorialStepDef[] = [ clickToAdvance: false, }, { - targetSelector: '.asset-upload-zone', + targetSelector: '.asset-upload-zone, .asset-mobile-upload-btn', titleKey: 'tutorial.asset.upload.title', descriptionKey: 'tutorial.asset.upload.desc', - position: 'left', + position: 'bottom', clickToAdvance: false, }, { targetSelector: '.asset-ratio-section', titleKey: 'tutorial.asset.ratio.title', descriptionKey: 'tutorial.asset.ratio.desc', - position: 'left', + position: 'top', clickToAdvance: false, }, { @@ -129,7 +129,7 @@ export const tutorialSteps: TutorialStepDef[] = [ targetSelector: '.btn-generate-sound', titleKey: 'tutorial.sound.generate.title', descriptionKey: 'tutorial.sound.generate.desc', - position: 'right', + position: 'top', clickToAdvance: true, }, ], @@ -141,14 +141,14 @@ export const tutorialSteps: TutorialStepDef[] = [ targetSelector: '.lyrics-display', titleKey: 'tutorial.sound.lyrics.title', descriptionKey: 'tutorial.sound.lyrics.desc', - position: 'left', + position: 'top', clickToAdvance: false, }, { targetSelector: '.status-message-new', titleKey: 'tutorial.sound.lyricsWait.title', descriptionKey: 'tutorial.sound.lyricsWait.desc', - position: 'right', + position: 'top', clickToAdvance: false, }, ], @@ -160,7 +160,7 @@ export const tutorialSteps: TutorialStepDef[] = [ targetSelector: '.audio-player', titleKey: 'tutorial.sound.audioPlayer.title', descriptionKey: 'tutorial.sound.audioPlayer.desc', - position: 'left', + position: 'bottom', clickToAdvance: false, }, { @@ -220,21 +220,21 @@ export const tutorialSteps: TutorialStepDef[] = [ targetSelector: '.content-upload-btn', titleKey: 'tutorial.ado2.download.title', descriptionKey: 'tutorial.ado2.download.desc', - position: 'right', + position: 'top', clickToAdvance: false, }, { targetSelector: '.content-delete-btn', titleKey: 'tutorial.ado2.delete.title', descriptionKey: 'tutorial.ado2.delete.desc', - position: 'right', + position: 'top', clickToAdvance: false, }, { targetSelector: '.content-download-btn', titleKey: 'tutorial.ado2.upload.title', descriptionKey: 'tutorial.ado2.upload.desc', - position: 'right', + position: 'top', clickToAdvance: true, }, ], @@ -252,7 +252,7 @@ export const tutorialSteps: TutorialStepDef[] = [ targetSelector: '.comp2-video-section', titleKey: 'tutorial.completion.generating.title', descriptionKey: 'tutorial.completion.generating.desc', - position: 'right', + position: 'top', } ] }, @@ -263,7 +263,7 @@ export const tutorialSteps: TutorialStepDef[] = [ targetSelector: '.comp2-video-section', titleKey: 'tutorial.completion.completion.title', descriptionKey: 'tutorial.completion.completion.desc', - position: 'right', + position: 'top', clickToAdvance: false, }, { @@ -326,7 +326,7 @@ export const tutorialSteps: TutorialStepDef[] = [ targetSelector: '.yoy-chart-card', titleKey: 'tutorial.dashboard.chart.title', descriptionKey: 'tutorial.dashboard.chart.desc', - position: 'right', + position: 'top', }, { targetSelector: '.tutorial-center-anchor', diff --git a/src/components/VideoDetailContent.tsx b/src/components/VideoDetailContent.tsx new file mode 100644 index 0000000..1f3617a --- /dev/null +++ b/src/components/VideoDetailContent.tsx @@ -0,0 +1,484 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + getVideoById, + getVideoComments, + postVideoComment, + deleteComment, + toggleVideoLike, + isLoggedIn, +} from '../utils/api'; +import { VideoDetailItem, CommentItem } from '../types/api'; +import LoginPromptModal from './LoginPromptModal'; + +interface VideoDetailContentProps { + videoId: string; + isModal?: boolean; + onClose?: () => void; +} + +const VideoDetailContent: React.FC = ({ videoId, isModal = false, onClose }) => { + const { t } = useTranslation(); + const authed = isLoggedIn(); + + const [video, setVideo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [likeCount, setLikeCount] = useState(0); + const [isLiked, setIsLiked] = useState(false); + + const [copied, setCopied] = useState(false); + const [shareMenuOpen, setShareMenuOpen] = useState(false); + const [isLandscape, setIsLandscape] = useState(false); + const [showLoginModal, setShowLoginModal] = useState(false); + + const [comments, setComments] = useState([]); + const [commentsTotal, setCommentsTotal] = useState(0); + const [commentsPage, setCommentsPage] = useState(1); + const [commentsHasNext, setCommentsHasNext] = useState(false); + const [commentsLoading, setCommentsLoading] = useState(false); + const [commentInput, setCommentInput] = useState(''); + const [commentSubmitting, setCommentSubmitting] = useState(false); + const commentTextareaRef = useRef(null); + + const [commentNickname, setCommentNickname] = useState(''); + const [commentAvatarSeedIdx, setCommentAvatarSeedIdx] = useState(0); + + // 고정 seed 목록: 브라우저가 캐싱하여 중복 요청 없음 + const AVATAR_SEEDS = ['42', '77', '123', '256', '512', '888', '1024', '2048', '3141', '9999']; + const commentAvatarSeed = AVATAR_SEEDS[commentAvatarSeedIdx % AVATAR_SEEDS.length]; + + const handleChangeAvatar = useCallback(() => { + setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length); + }, []); + + const fetchComments = useCallback(async (page: number, append = false) => { + setCommentsLoading(true); + try { + const res = await getVideoComments(videoId, page, 20); + const sorted = [...res.items].sort( + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ); + setComments(prev => append ? [...prev, ...sorted] : sorted); + setCommentsTotal(res.total); + setCommentsHasNext(res.has_next); + setCommentsPage(page); + } catch (err) { + console.error('Failed to fetch comments:', err); + } finally { + setCommentsLoading(false); + } + }, [videoId]); + + useEffect(() => { + const fetchVideo = async () => { + setLoading(true); + setError(null); + try { + const data = await getVideoById(videoId); + setVideo(data); + setLikeCount(data.like_count); + setIsLiked(data.is_liked_by_me); + } catch (err) { + console.error('Failed to fetch video:', err); + setError(t('ado2Contents.loadFailed')); + } finally { + setLoading(false); + } + }; + + fetchVideo(); + fetchComments(1); + }, [videoId, fetchComments]); + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`; + }; + + const formatCommentDate = (dateString: string) => { + const date = new Date(dateString); + return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`; + }; + + const shareUrl = `${window.location.origin}/video/${videoId}`; + + const handleCopyLink = async () => { + try { + await navigator.clipboard.writeText(shareUrl); + } catch { + // clipboard API 미지원 환경에서는 무시 + } + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleKakaoShare = () => { + const kakao = window.Kakao; + if (kakao?.Share) { + kakao.Share.sendDefault({ + objectType: 'feed', + content: { + title: video?.store_name ?? 'ADO2 영상', + description: `${video?.region ?? ''} · ADO2 AI 마케팅 영상`, + imageUrl: 'https://demo.castad.net/favicon_48.svg', + link: { mobileWebUrl: shareUrl, webUrl: shareUrl }, + }, + buttons: [{ title: '영상 보기', link: { mobileWebUrl: shareUrl, webUrl: shareUrl } }], + }); + } else if (navigator.share) { + navigator.share({ url: shareUrl }).catch(() => {}); + } else { + handleCopyLink(); + } + setShareMenuOpen(false); + }; + + const handleFacebookShare = () => { + window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600'); + setShareMenuOpen(false); + }; + + const handleTwitterShare = () => { + window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}`, '_blank', 'noopener,width=600,height=600'); + setShareMenuOpen(false); + }; + + const shareMenuRef = React.useRef(null); + + useEffect(() => { + if (!shareMenuOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (shareMenuRef.current && !shareMenuRef.current.contains(e.target as Node)) { + setShareMenuOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [shareMenuOpen]); + + const likeDebounceRef = React.useRef | null>(null); + + const handleLike = () => { + if (!authed) { setShowLoginModal(true); return; } + + // 1. UI 즉시 업데이트 (Optimistic) + setIsLiked(prev => !prev); + setLikeCount(prev => isLiked ? prev - 1 : prev + 1); + + // 2. 기존 debounce 타이머 취소 후 재설정 + if (likeDebounceRef.current) clearTimeout(likeDebounceRef.current); + likeDebounceRef.current = setTimeout(async () => { + const prevLiked = isLiked; + const prevCount = likeCount; + try { + await toggleVideoLike(videoId); + } catch (err) { + // 3. 실패 시 롤백 + console.error('Failed to toggle like:', err); + setIsLiked(prevLiked); + setLikeCount(prevCount); + } + }, 500); + }; + + const handleHeaderAction = () => { + if (!isModal && !authed) { setShowLoginModal(true); return; } + onClose?.(); + }; + + const handleCommentFocus = () => { + if (!authed) setShowLoginModal(true); + }; + + const handleCommentSubmit = async () => { + if (!authed) { setShowLoginModal(true); return; } + if (!commentInput.trim() || commentSubmitting) return; + setCommentSubmitting(true); + try { + await postVideoComment(videoId, commentInput.trim(), commentNickname); + setCommentInput(''); + if (commentTextareaRef.current) { + commentTextareaRef.current.style.height = 'auto'; + } + setCommentNickname(''); + setCommentAvatarSeedIdx(prev => (prev + 1) % AVATAR_SEEDS.length); + await fetchComments(1); + } catch (err) { + console.error('Failed to post comment:', err); + } finally { + setCommentSubmitting(false); + } + }; + + const handleDeleteComment = async (commentId: number) => { + try { + await deleteComment(commentId); + await fetchComments(commentsPage); + } catch (err) { + console.error('Failed to delete comment:', err); + } + }; + + const renderCommentContent = (content: string | null, isDeleted: boolean) => { + if (isDeleted) return (삭제된 댓글입니다.); + return content; + }; + + return ( +
+ {/* 헤더 */} +
+ {isModal ? ( + + ) : ( + + )} +
+ + {loading ? ( +
+
+

{t('ado2Contents.loading')}

+
+ ) : error ? ( +

{error}

+ ) : video ? ( +
+