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. 수집하는 개인정보 항목 및 수집 방법
+ 회사는 서비스 제공을 위해 아래와 같은 개인정보를 수집합니다.
+
+ 카카오 로그인을 통한 수집: 이름, 이메일 주소, 프로필 사진 — 서비스 회원 가입 및 로그인에 사용
+ Google 사용자 프로필 : YouTube 채널 연동 시 연결된 구글 계정의 이름 및 프로필 사진 확인 — 채널 소유자 식별 목적으로만 사용
+ YouTube Data API : 채널 정보, 동영상 목록, 재생목록 등 YouTube 계정 데이터 (읽기 전용)
+ YouTube Data API : AI가 생성한 콘텐츠를 YouTube에 업로드하기 위한 동영상 관리 권한
+ YouTube Analytics API : 채널 및 동영상의 조회수, 시청 시간 등 성과 지표 데이터 (읽기 전용)
+ 서비스 이용 과정에서 자동 수집: 접속 IP, 쿠키, 서비스 이용 기록
+
+
+ 2. Google 사용자 데이터의 수집 및 이용 목적
+ 회사가 접근하는 Google 사용자 데이터는 아래 목적으로만 사용되며, 명시된 목적 외에는 사용하지 않습니다.
+
+ Google 사용자 프로필: YouTube 채널 연동 과정에서 연결 대상 구글 계정을 식별하는 용도로만 사용하며, 서비스 로그인에는 사용되지 않습니다.
+ YouTube 계정 데이터 (읽기): 기존 채널 정보·동영상 현황을 분석하여 AI 콘텐츠 전략 수립에 활용합니다.
+ YouTube 동영상 업로드: AI가 생성한 영상을 사용자의 YouTube 채널에 업로드합니다. 업로드는 반드시 사용자의 명시적 요청에 의해서만 실행됩니다.
+ YouTube 분석 데이터: 채널 성과 지표를 분석하여 AI 마케팅 전략 수립 및 콘텐츠 최적화에 활용합니다.
+
+
+
+
[Google API 서비스 사용자 데이터 정책 준수]
+ ㈜에이아이오투오가 운영하는 ADO2 서비스가 Google API로부터 수신한 정보의 사용 및 타 앱으로의 전송은,
+ Google API 서비스 사용자 데이터 정책 의
+ 제한적 사용(Limited Use) 요건을 포함한 모든 정책을 엄격히 준수합니다.
+ 특히, Google API로부터 수신한 데이터는 AI·ML 모델 학습에 사용되지 않으며 , 사용자가 명시적으로 요청한 서비스 기능 제공 목적 외에는 사용·전송·공유되지 않습니다.
+
+
+ 3. Google 사용자 데이터의 제3자 공유
+ 회사는 Google API로부터 수신한 사용자 데이터를 아래 경우를 제외하고 어떠한 제3자에게도 판매하거나 공유하지 않습니다.
+
+ AI 콘텐츠 생성 처리: 서비스 제공에 필수적인 AI 생성 기능 수행을 위해 처리 서버로 전달될 수 있으며, 해당 처리는 서비스 제공 목적으로만 사용됩니다.
+ 법적 요구: 관련 법령에 의거한 수사기관 등의 적법한 요청이 있는 경우
+
+ Google 사용자 데이터는 광고, 마케팅, 프로파일링 목적으로 사용되지 않습니다.
+
+ 4. Google 사용자 데이터의 저장 및 보안
+
+ Google OAuth 액세스 토큰 및 리프레시 토큰은 암호화된 상태로 보관됩니다.
+ Google API를 통해 읽어온 데이터는 서비스 기능 처리에 필요한 최소한의 시간 동안만 임시 보유하며, 처리 완료 후 삭제됩니다.
+ 서버와의 모든 통신은 TLS(HTTPS)를 통해 암호화됩니다.
+ 데이터 접근 권한은 서비스 운영에 필요한 최소 인원에게만 부여됩니다.
+
+
+ 5. 개인정보의 보유 및 이용 기간 / 데이터 삭제
+ 원칙적으로 회원 탈퇴 또는 개인정보 수집·이용 목적이 달성된 후에는 해당 정보를 지체 없이 파기합니다. 단, 관련 법령에 따라 보존이 필요한 경우 해당 기간 동안 보관합니다.
+
+ 전자상거래 관련 기록: 5년 (전자상거래 등에서의 소비자보호에 관한 법률)
+ 접속 로그 기록: 3개월 (통신비밀보호법)
+ Google 연동 토큰: 연동 해제 또는 회원 탈퇴 즉시 삭제
+
+ 데이터 삭제 요청 방법: 아래 이메일(o2oteam@o2o.kr )로 요청하시면 영업일 기준 7일 이내 에 처리 결과를 안내해 드립니다. Google 계정 연동 해제는 서비스 내 계정 설정에서 직접 처리하실 수 있으며, 해제 즉시 관련 토큰이 삭제됩니다.
+
+ 6. 정보주체의 권리 및 행사 방법
+ 사용자는 언제든지 자신의 개인정보에 대한 열람, 수정, 삭제, 처리 정지를 요청할 수 있습니다. 서비스 내 계정 설정에서 직접 처리하거나 아래 문의처로 연락해 주시기 바랍니다.
+ 또한, Google 계정 권한 관리 페이지 에서 ADO2 앱의 Google 데이터 접근 권한을 언제든지 직접 취소하실 수 있습니다.
+
+ 7. 개인정보 보호책임자 및 문의처
+ 개인정보 보호와 관련된 불만 처리 및 피해 구제에 관한 사항은 아래로 문의해 주시기 바랍니다.
+
+ 회사명: ㈜에이아이오투오
+ 서비스명: ADO2
+ 이메일: o2oteam@o2o.kr
+ 웹사이트: https://demo.castad.net
+
+
+ 본 방침은 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 조 (용어의 정의)
+
+ "서비스"라 함은 회사가 제공하는 ADO2 AI 마케팅 자동화 플랫폼 및 관련 제반 기능을 의미합니다.
+ "회원"이라 함은 본 약관에 동의하고 회사와 이용계약을 체결하여 서비스를 이용하는 자를 말합니다.
+ "콘텐츠"라 함은 서비스 내에서 AI가 생성하거나 회원이 등록하는 텍스트, 이미지, 음악, 영상 등 일체의 자료를 말합니다.
+
+
+ 제 3 조 (약관의 효력 및 변경)
+ 회사는 본 약관의 내용을 서비스 화면에 게시하며, 관련 법령을 위배하지 않는 범위에서 약관을 개정할 수 있습니다. 약관이 변경되는 경우 시행일 7일 전부터 공지합니다.
+
+ 제 4 조 (서비스의 제공 및 변경)
+ 회사는 AI 기반 마케팅 콘텐츠(가사, 이미지, 영상 등) 자동 생성, Google·YouTube 등 외부 플랫폼 연동, SNS 자동 배포 등의 서비스를 제공합니다. 운영상·기술상의 필요에 따라 서비스의 전부 또는 일부를 변경할 수 있습니다.
+
+ 제 5 조 (회원의 의무)
+
+ 타인의 Google 계정 등 외부 서비스 계정을 무단으로 도용하여 서비스를 이용해서는 안 됩니다.
+ 스팸 발송, API 한도 고의 초과, 허위 정보 등록 등 비정상적인 방법으로 서비스를 이용해서는 안 됩니다.
+ 회사 또는 제3자의 지식재산권을 침해해서는 안 됩니다.
+
+
+ 제 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 조 (준거법 및 재판관할)
+ 본 약관과 관련된 분쟁은 대한민국 법을 준거법으로 하며, 소송은 회사의 소재지를 관할하는 법원에 제소합니다.
+
+ 문의처
+
+ 회사명: ㈜에이아이오투오
+ 서비스명: ADO2
+ 이메일: o2oteam@o2o.kr
+ 웹사이트: https://demo.castad.net
+
+
+ 본 약관은 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 ? (
+ <>
+ setActiveRegion(null)}>←
+ {activeRegion}
+ >
+ ) : '지역 선택'}
+
+ ✕
+
+
+ {activeRegion === null ? (
+
+ {REGIONS.map(r => (
+ setActiveRegion(r.label)}
+ >
+ {r.label}
+ {r.cities.includes(selected) && {selected} }
+ ›
+
+ ))}
+
+ ) : (
+
+ {cities.map(city => (
+ handleCityClick(city)}
+ >
+ {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')}
+
+
+
+ {t('loginPrompt.loginBtn')}
+
+
+
+ );
+};
+
+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 ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+ {t('sidebar.ado2Contents')}
+
+ )}
+
+
+ {loading ? (
+
+
+
{t('ado2Contents.loading')}
+
+ ) : error ? (
+
+ ) : video ? (
+
+
e.preventDefault()}
+ className="video-detail-player"
+ onLoadedMetadata={(e) => {
+ const v = e.currentTarget;
+ setIsLandscape(v.videoWidth > v.videoHeight);
+ }}
+ />
+
+
+
{video.store_name}
+
{formatDate(video.created_at)}
+
+ {/* 좋아요 + 링크 복사 */}
+
+
+
+
+
+ {likeCount}
+
+
+
setShareMenuOpen(v => !v)}
+ >
+
+
+
+
+ {copied ? '복사됨!' : '공유하기'}
+
+ {shareMenuOpen && (
+
+ {/* 카카오톡 */}
+
+
+
+
+ 카카오톡
+
+ {/* 페이스북 */}
+
+
+
+
+ 페이스북
+
+ {/* X (트위터) */}
+
+
+
+
+ X (트위터)
+
+ {/* URL 복사 */}
+
{ handleCopyLink(); setShareMenuOpen(false); }}>
+
+
+
+
+ {copied ? '복사됨!' : 'URL 복사'}
+
+
+ )}
+
+
+
+ {/* 댓글 섹션 */}
+
+
+
댓글
+ {commentsTotal}
+
+
+ {/* 댓글 작성자 프로필 선택 */}
+ {authed && (
+
+
+
setCommentNickname(e.target.value)}
+ maxLength={20}
+ />
+
+ )}
+
+
+
+
+ {comments.length === 0 && !commentsLoading ? (
+
아직 댓글이 없습니다.
+ ) : (
+
+ {comments.map((c) => (
+
+
+
+
+ {c.nickname || '익명'}
+
+
+ {renderCommentContent(c.content, c.is_deleted)}
+
+
+ {formatCommentDate(c.created_at)}
+ {c.is_mine && !c.is_deleted && (
+ handleDeleteComment(c.id)}
+ >
+ 삭제
+
+ )}
+
+
+ {/* 대댓글 (읽기 전용) */}
+ {c.replies && c.replies.length > 0 && (
+
+ {c.replies.map((r) => (
+
+
+
+
+ {r.nickname || '익명'}
+
+
+ {renderCommentContent(r.content, r.is_deleted)}
+
+
+ {formatCommentDate(r.created_at)}
+ {r.is_mine && !r.is_deleted && (
+ handleDeleteComment(r.id)}
+ >
+ 삭제
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+
+ ))}
+
+ )}
+
+ {commentsHasNext && (
+
fetchComments(commentsPage + 1, true)}
+ disabled={commentsLoading}
+ >
+ {commentsLoading ? '불러오는 중...' : '댓글 더 보기'}
+
+ )}
+
+
+
+ ) : null}
+
+ {showLoginModal && (
+
setShowLoginModal(false)} />
+ )}
+
+ );
+};
+
+export default VideoDetailContent;
diff --git a/src/components/VideoDetailModal.tsx b/src/components/VideoDetailModal.tsx
new file mode 100644
index 0000000..4e3d23b
--- /dev/null
+++ b/src/components/VideoDetailModal.tsx
@@ -0,0 +1,56 @@
+import React, { useEffect } from 'react';
+import VideoDetailContent from './VideoDetailContent';
+
+interface VideoDetailModalProps {
+ videoId: string;
+ onClose: () => void;
+}
+
+const VideoDetailModal: React.FC = ({ videoId, onClose }) => {
+ useEffect(() => {
+ // 모달 열릴 때 배경 스크롤 잠금
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ document.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ document.body.style.overflow = prev;
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [onClose]);
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+
+ );
+};
+
+export default VideoDetailModal;
diff --git a/src/components/VideoDetailPage.tsx b/src/components/VideoDetailPage.tsx
new file mode 100644
index 0000000..b98f810
--- /dev/null
+++ b/src/components/VideoDetailPage.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import VideoDetailContent from './VideoDetailContent';
+
+interface VideoDetailPageProps {
+ videoId: string;
+}
+
+const VideoDetailPage: React.FC = ({ videoId }) => {
+ const handleBack = () => {
+ localStorage.setItem('castad_active_item', 'ADO2 콘텐츠');
+ window.location.href = '/';
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default VideoDetailPage;
diff --git a/src/index.tsx b/src/index.tsx
index 7152cf1..b070285 100755
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -4,6 +4,17 @@ import ReactDOM from 'react-dom/client';
import './i18n';
import App from './App';
+declare global {
+ interface Window {
+ Kakao: any;
+ }
+}
+
+const kakaoKey = import.meta.env.VITE_KAKAO_JS_KEY as string | undefined;
+if (kakaoKey && window.Kakao && !window.Kakao.isInitialized()) {
+ window.Kakao.init(kakaoKey);
+}
+
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
diff --git a/src/locales/en.json b/src/locales/en.json
index 37c540e..f3cdc1e 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -471,7 +471,14 @@
"delete": "Delete",
"previous": "Previous",
"next": "Next",
- "uploadToSocial": "Upload to social media"
+ "uploadToSocial": "Upload to social media",
+ "sortLatest": "Latest",
+ "sortOldest": "Oldest",
+ "sortLikes": "Most Liked",
+ "sortComments": "Most Commented",
+ "regionPlaceholder": "Select Region",
+ "searchPlaceholder": "Search by name",
+ "searchBtn": "Search"
},
"businessSettings": {
"title": "Business Settings",
@@ -546,6 +553,10 @@
"scheduledCount": "Sched {{count}}",
"failedCount": "Fail {{count}}"
},
+ "loginPrompt": {
+ "title": "Login Required",
+ "loginBtn": "Login with Kakao"
+ },
"app": {
"loginProcessing": "Processing login...",
"loginFailed": "Login processing failed. Please try again.",
diff --git a/src/locales/ko.json b/src/locales/ko.json
index c8abca8..9def045 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -471,7 +471,14 @@
"delete": "삭제",
"previous": "이전",
"next": "다음",
- "uploadToSocial": "소셜 미디어에 업로드"
+ "uploadToSocial": "소셜 미디어에 업로드",
+ "sortLatest": "최신순",
+ "sortOldest": "오래된순",
+ "sortLikes": "좋아요 많은순",
+ "sortComments": "댓글 많은순",
+ "regionPlaceholder": "지역 선택",
+ "searchPlaceholder": "업체명 검색",
+ "searchBtn": "검색"
},
"businessSettings": {
"title": "비즈니스 설정",
@@ -546,6 +553,10 @@
"scheduledCount": "예약 {{count}}",
"failedCount": "실패 {{count}}"
},
+ "loginPrompt": {
+ "title": "로그인이 필요합니다",
+ "loginBtn": "카카오로 로그인"
+ },
"app": {
"loginProcessing": "로그인 처리 중...",
"loginFailed": "로그인 처리에 실패했습니다. 다시 시도해주세요.",
diff --git a/src/pages/Dashboard/ADO2ContentsPage.tsx b/src/pages/Dashboard/ADO2ContentsPage.tsx
index 06e57cd..f1307b9 100644
--- a/src/pages/Dashboard/ADO2ContentsPage.tsx
+++ b/src/pages/Dashboard/ADO2ContentsPage.tsx
@@ -1,226 +1,127 @@
-
-import React, { useState, useEffect, useRef, useCallback } from 'react';
+import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
-import { getVideosList, deleteVideo } from '../../utils/api';
+import { getAllVideos, isLoggedIn } from '../../utils/api';
import { VideoListItem } from '../../types/api';
-import SocialPostingModal from '../../components/SocialPostingModal';
-
-const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
- const videoRef = useRef(null);
- const isPlayingRef = useRef(false);
- const pendingPlayRef = useRef(false);
-
- useEffect(() => {
- const video = videoRef.current;
- if (!video) return;
-
- const observer = new IntersectionObserver(
- ([entry]) => {
- if (entry.isIntersecting) {
- video.preload = 'auto';
- } else {
- video.preload = 'none';
- }
- },
- { threshold: 0.1 }
- );
-
- observer.observe(video);
- return () => observer.disconnect();
- }, []);
-
- const safePlay = useCallback((video: HTMLVideoElement) => {
- const playPromise = video.play();
- if (playPromise !== undefined) {
- playPromise
- .then(() => { isPlayingRef.current = true; })
- .catch((error) => {
- if (error.name !== 'AbortError') console.error(error);
- });
- }
- }, []);
-
- const handleCanPlay = useCallback(() => {
- if (!pendingPlayRef.current || !videoRef.current) return;
- pendingPlayRef.current = false;
- safePlay(videoRef.current);
- }, [safePlay]);
-
- const handleMouseEnter = useCallback(() => {
- const video = videoRef.current;
- if (!video) return;
- if (video.readyState >= 3) {
- safePlay(video);
- } else {
- pendingPlayRef.current = true;
- }
- }, [safePlay]);
-
- const handleMouseLeave = useCallback(() => {
- const video = videoRef.current;
- if (!video) return;
- pendingPlayRef.current = false;
- if (isPlayingRef.current) {
- video.pause();
- video.currentTime = 0;
- isPlayingRef.current = false;
- }
- }, []);
-
- return (
-
- );
-};
+import LoginPromptModal from '../../components/LoginPromptModal';
+import VideoDetailModal from '../../components/VideoDetailModal';
+import CitySelectModal from '../../components/CitySelectModal';
interface ADO2ContentsPageProps {
- onBack: () => void;
- onNavigate?: (item: string) => void;
+ onBack?: () => void;
}
-const ADO2ContentsPage: React.FC = ({ onBack, onNavigate }) => {
+const ADO2ContentsPage: React.FC = () => {
const { t } = useTranslation();
+ const authed = isLoggedIn();
+ const [selectedVideoId, setSelectedVideoId] = useState(null);
const [videos, setVideos] = useState([]);
const [total, setTotal] = useState(0);
- const [loading, setLoading] = useState(true);
+ const [loading, setLoading] = useState(authed);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [hasNext, setHasNext] = useState(false);
const [hasPrev, setHasPrev] = useState(false);
const [totalPages, setTotalPages] = useState(1);
- const [deleteModalOpen, setDeleteModalOpen] = useState(false);
- const [deleteTargetId, setDeleteTargetId] = useState(null);
- const [isDeleting, setIsDeleting] = useState(false);
- const [uploadModalOpen, setUploadModalOpen] = useState(false);
- const [uploadTargetVideo, setUploadTargetVideo] = useState(null);
- const pageSize = 12;
+ const pageSize = 20;
+ const [sortBy, setSortBy] = useState<'created_at' | 'like_count' | 'comment_count'>('created_at');
+ const [order, setOrder] = useState<'desc' | 'asc'>('desc');
+ const [searchInput, setSearchInput] = useState('');
+ const [storeName, setStoreName] = useState('');
+ const [region, setRegion] = useState('');
+ const [showCityModal, setShowCityModal] = useState(false);
useEffect(() => {
+ if (!authed) return;
fetchVideos();
- }, [page]);
+ }, [page, sortBy, order, storeName, region]);
const fetchVideos = async () => {
setLoading(true);
setError(null);
try {
- const response = await getVideosList(page, pageSize);
- console.log('[ADO2] API response:', response);
- console.log('[ADO2] First video item:', response.items[0]);
- // result_movie_url이 있는 비디오만 필터링 (빈/더미 데이터 제외)
- const validVideos = response.items.filter(video => video.result_movie_url && video.result_movie_url.trim() !== '');
- setVideos(validVideos);
+ const response = await getAllVideos(page, pageSize, sortBy, storeName, order, region);
+ setVideos(response.items);
setTotal(response.total);
setTotalPages(response.total_pages);
setHasNext(response.has_next);
setHasPrev(response.has_prev);
} catch (err) {
- console.error('Failed to fetch videos:', err);
+ console.error('Failed to fetch all videos:', err);
setError(t('ado2Contents.loadFailed'));
} finally {
setLoading(false);
}
};
+ const handleCardClick = (videoId: number) => {
+ setSelectedVideoId(videoId);
+ };
+
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
- const hours = String(date.getHours()).padStart(2, '0');
- const minutes = String(date.getMinutes()).padStart(2, '0');
- return `${year}.${month}.${day}・${hours}:${minutes}`;
- };
-
- const formatTitle = (storeName: string, dateString: string) => {
- const date = new Date(dateString);
- const month = String(date.getMonth() + 1).padStart(2, '0');
- const day = String(date.getDate()).padStart(2, '0');
- const hours = String(date.getHours()).padStart(2, '0');
- const minutes = String(date.getMinutes()).padStart(2, '0');
- return `${storeName} ${month}/${day} ${hours}:${minutes}`;
- };
-
- const handleDownload = async (videoUrl: string, storeName: string) => {
- try {
- const response = await fetch(videoUrl);
- const blob = await response.blob();
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `${storeName}.mp4`;
- document.body.appendChild(a);
- a.click();
- window.URL.revokeObjectURL(url);
- document.body.removeChild(a);
- } catch (err) {
- console.error('Download failed:', err);
- alert(t('ado2Contents.downloadFailed'));
- }
- };
-
- const handleDeleteClick = (videoId: number) => {
- setDeleteTargetId(videoId);
- setDeleteModalOpen(true);
- };
-
- const handleUploadClick = (video: VideoListItem) => {
- console.log('[ADO2] Upload clicked - video object:', video);
- console.log('[ADO2] video.video_id:', video.video_id);
- setUploadTargetVideo(video);
- setUploadModalOpen(true);
- };
-
- const handleUploadModalClose = () => {
- setUploadModalOpen(false);
- setUploadTargetVideo(null);
- };
-
- const handleDeleteCancel = () => {
- setDeleteModalOpen(false);
- setDeleteTargetId(null);
- };
-
- const handleDeleteConfirm = async () => {
- if (!deleteTargetId) return;
- setIsDeleting(true);
- try {
- await deleteVideo(deleteTargetId);
-
- // 삭제 성공 시 로컬 상태에서 즉시 제거 (UI 즉시 반영)
- // fetchVideos()를 호출하지 않음 - 서버 캐시 또는 동기화 지연으로 인해
- // 삭제된 항목이 다시 나타날 수 있기 때문
- setVideos(prev => prev.filter(video => video.video_id !== deleteTargetId));
- setTotal(prev => Math.max(0, prev - 1));
-
- setDeleteModalOpen(false);
- setDeleteTargetId(null);
- } catch (err) {
- console.error('Delete failed:', err);
- alert(t('ado2Contents.deleteFailed'));
- } finally {
- setIsDeleting(false);
- }
+ return `${year}.${month}.${day}`;
};
return (
- {/* Header */}
-
{t('ado2Contents.title')}
+ {t('sidebar.ado2Contents')}
{t('ado2Contents.totalCount', { count: total })}
- {/* Content Grid */}
+
+ {
+ const [sb, ord] = e.target.value.split('__') as [typeof sortBy, typeof order];
+ setSortBy(sb);
+ setOrder(ord);
+ setPage(1);
+ }}
+ >
+ {t('ado2Contents.sortLatest')}
+ {t('ado2Contents.sortOldest')}
+ {t('ado2Contents.sortLikes')}
+ {t('ado2Contents.sortComments')}
+
+
+ setShowCityModal(true)}
+ >
+ {region || t('ado2Contents.regionPlaceholder')}
+ {region && (
+ { e.stopPropagation(); setRegion(''); setPage(1); }}>✕
+ )}
+
+
+
+
+
{loading ? (
@@ -239,82 +140,64 @@ const ADO2ContentsPage: React.FC
= ({ onBack, onNavigate
<>
{videos.map((video) => (
-
- {/* Video Thumbnail */}
-
- {video.result_movie_url ? (
-
handleCardClick(video.video_id)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => e.key === 'Enter' && handleCardClick(video.video_id)}
+ >
+
+ {video.thumbnail_url ? (
+
+ ) : video.result_movie_url ? (
+
) : (
-
-
-
-
-
-
-
-
-
-
+
+ )}
+ {/* 호버 오버레이 */}
+
-
- {/* Card Info */}
-
- {formatTitle(video.store_name, video.created_at)}
-
-
- {formatDate(video.created_at)}
-
-
-
- {/* Action Buttons */}
-
-
handleUploadClick(video)}
- disabled={!video.result_movie_url}
- >
-
-
-
-
- {t('ado2Contents.uploadToSocial')}
-
-
handleDownload(video.result_movie_url, video.store_name)}
- disabled={!video.result_movie_url}
- title={t('ado2Contents.download')}
- >
-
-
-
-
-
-
handleDeleteClick(video.video_id)}
- >
-
-
-
-
-
-
+
{video.store_name}
+
+
{formatDate(video.created_at)}
+
+
+
+
+ {video.like_count ?? 0}
+
+
+
+ {video.comment_count ?? 0}
+
+
))}
- {/* Pagination - 항상 표시 */}
= ({ onBack, onNavigate
>
)}
- {/* 삭제 확인 모달 */}
- {deleteModalOpen && (
-
-
e.stopPropagation()}>
-
{t('ado2Contents.deleteConfirmTitle')}
-
{t('ado2Contents.deleteConfirmDesc')}
-
-
- {t('ado2Contents.cancel')}
-
-
- {isDeleting ? t('ado2Contents.deleting') : t('ado2Contents.delete')}
-
-
-
-
+ {!authed && (
+ { window.location.href = '/'; }} />
)}
- {/* 소셜 미디어 업로드 모달 */}
- onNavigate('콘텐츠 캘린더') : undefined}
- />
+ {selectedVideoId !== null && (
+ setSelectedVideoId(null)}
+ />
+ )}
+
+ {showCityModal && (
+ { setRegion(city); setPage(1); }}
+ onClose={() => setShowCityModal(false)}
+ />
+ )}
);
};
diff --git a/src/pages/Dashboard/CompletionContent.tsx b/src/pages/Dashboard/CompletionContent.tsx
index 4f8f0ce..01d2467 100755
--- a/src/pages/Dashboard/CompletionContent.tsx
+++ b/src/pages/Dashboard/CompletionContent.tsx
@@ -500,12 +500,10 @@ const CompletionContent: React.FC
= ({
return {t('completion.noLyricsBGM')}
;
}
const lines = songCompletionData.lyrics.split('\n').filter((l: string) => l.trim());
- const intro = lines.slice(0, 1);
const outro = lines.slice(-1);
- const body = lines.slice(1, -1);
+ const body = lines.slice(0, -1);
const half = Math.ceil(body.length / 2);
const sections = [
- { tag: '[Intro]', lines: intro },
{ tag: '[Verse]', lines: body.slice(0, half) },
{ tag: '[Chorus]', lines: body.slice(half) },
{ tag: '[Outro]', lines: outro },
@@ -557,6 +555,8 @@ const CompletionContent: React.FC = ({
task_id: songTaskId || '',
result_movie_url: videoUrl,
created_at: new Date().toISOString(),
+ like_count: 0,
+ comment_count: 0,
} : null}
/>
diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx
index 5eb79c0..762fb03 100755
--- a/src/pages/Dashboard/GenerationFlow.tsx
+++ b/src/pages/Dashboard/GenerationFlow.tsx
@@ -9,6 +9,7 @@ import DashboardContent from './DashboardContent';
import BusinessSettingsContent from './BusinessSettingsContent';
import UrlInputContent from './UrlInputContent';
import ADO2ContentsPage from './ADO2ContentsPage';
+import MyContentsPage from './MyContentsPage';
import MyInfoContent from './MyInfoContent';
import ContentCalendarContent from './ContentCalendarContent';
import LoadingSection from '../Analysis/LoadingSection';
@@ -364,7 +365,7 @@ const GenerationFlow: React.FC = ({
if (tutorial.isActive) tutorial.skipTutorial();
if (activeItem === '내 정보' && !tutorial.hasSeen(TUTORIAL_KEYS.MY_INFO)) {
tutorial.startTutorial(TUTORIAL_KEYS.MY_INFO);
- } else if (activeItem === 'ADO2 콘텐츠' && !tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
+ } else if (activeItem === '내 콘텐츠' && !tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
} else if (activeItem === '대시보드' && !tutorial.hasSeen(TUTORIAL_KEYS.DASHBOARD)) {
tutorial.startTutorial(TUTORIAL_KEYS.DASHBOARD);
@@ -509,8 +510,10 @@ const GenerationFlow: React.FC = ({
case '비즈니스 설정':
return ;
case 'ADO2 콘텐츠':
+ return ;
+ case '내 콘텐츠':
return (
- setActiveItem('새 프로젝트 만들기')}
onNavigate={handleNavigate}
/>
@@ -544,12 +547,12 @@ const GenerationFlow: React.FC = ({
// 브랜드 분석(0)일 때는 전체 페이지 스크롤
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0), ADO2 콘텐츠, 내 정보
- const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
+ const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || activeItem === 'ADO2 콘텐츠' || activeItem === '내 콘텐츠' || activeItem === '내 정보' || activeItem === '콘텐츠 캘린더' || isBrandAnalysis;
// 현재 화면에 맞는 튜토리얼 키 반환 (없으면 null)
const getCurrentTutorialKey = (): string | null => {
if (activeItem === '내 정보') return TUTORIAL_KEYS.MY_INFO;
- if (activeItem === 'ADO2 콘텐츠') return TUTORIAL_KEYS.ADO2_CONTENTS;
+ if (activeItem === '내 콘텐츠') return TUTORIAL_KEYS.ADO2_CONTENTS;
if (activeItem === '대시보드') return TUTORIAL_KEYS.DASHBOARD;
if (activeItem === '콘텐츠 캘린더') return TUTORIAL_KEYS.CONTENT_CALENDAR;
if (activeItem === '새 프로젝트 만들기') {
diff --git a/src/pages/Dashboard/MyContentsPage.tsx b/src/pages/Dashboard/MyContentsPage.tsx
new file mode 100644
index 0000000..aa06855
--- /dev/null
+++ b/src/pages/Dashboard/MyContentsPage.tsx
@@ -0,0 +1,399 @@
+
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { getVideosList, deleteVideo } from '../../utils/api';
+import { VideoListItem } from '../../types/api';
+import SocialPostingModal from '../../components/SocialPostingModal';
+import VideoDetailModal from '../../components/VideoDetailModal';
+import { useTutorial } from '../../components/Tutorial/useTutorial';
+import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
+
+const VideoPreviewCard: React.FC<{ src: string; className?: string }> = ({ src, className }) => {
+ const videoRef = useRef(null);
+ const isPlayingRef = useRef(false);
+ const pendingPlayRef = useRef(false);
+
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ video.preload = 'auto';
+ } else {
+ video.preload = 'none';
+ }
+ },
+ { threshold: 0.1 }
+ );
+
+ observer.observe(video);
+ return () => observer.disconnect();
+ }, []);
+
+ const safePlay = useCallback((video: HTMLVideoElement) => {
+ const playPromise = video.play();
+ if (playPromise !== undefined) {
+ playPromise
+ .then(() => { isPlayingRef.current = true; })
+ .catch((error) => {
+ if (error.name !== 'AbortError') console.error(error);
+ });
+ }
+ }, []);
+
+ const handleCanPlay = useCallback(() => {
+ if (!pendingPlayRef.current || !videoRef.current) return;
+ pendingPlayRef.current = false;
+ safePlay(videoRef.current);
+ }, [safePlay]);
+
+ const handleMouseEnter = useCallback(() => {
+ const video = videoRef.current;
+ if (!video) return;
+ if (video.readyState >= 3) {
+ safePlay(video);
+ } else {
+ pendingPlayRef.current = true;
+ }
+ }, [safePlay]);
+
+ const handleMouseLeave = useCallback(() => {
+ const video = videoRef.current;
+ if (!video) return;
+ pendingPlayRef.current = false;
+ if (isPlayingRef.current) {
+ video.pause();
+ video.currentTime = 0;
+ isPlayingRef.current = false;
+ }
+ }, []);
+
+ return (
+
+ );
+};
+
+interface MyContentsPageProps {
+ onBack?: () => void;
+ onNavigate?: (item: string) => void;
+}
+
+const MyContentsPage: React.FC = ({ onNavigate }) => {
+ const { t } = useTranslation();
+ const tutorial = useTutorial();
+ const [videos, setVideos] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [page, setPage] = useState(1);
+ const [hasNext, setHasNext] = useState(false);
+ const [hasPrev, setHasPrev] = useState(false);
+ const [totalPages, setTotalPages] = useState(1);
+ const [deleteModalOpen, setDeleteModalOpen] = useState(false);
+ const [deleteTargetId, setDeleteTargetId] = useState(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [uploadModalOpen, setUploadModalOpen] = useState(false);
+ const [uploadTargetVideo, setUploadTargetVideo] = useState(null);
+ const [selectedVideoId, setSelectedVideoId] = useState(null);
+
+ const pageSize = 12;
+
+ useEffect(() => {
+ fetchVideos();
+ }, [page]);
+
+ useEffect(() => {
+ if (!tutorial.hasSeen(TUTORIAL_KEYS.ADO2_CONTENTS)) {
+ tutorial.startTutorial(TUTORIAL_KEYS.ADO2_CONTENTS);
+ }
+ }, []);
+
+ const fetchVideos = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await getVideosList(page, pageSize);
+ const validVideos = response.items.filter(video => video.result_movie_url && video.result_movie_url.trim() !== '');
+ setVideos(validVideos);
+ setTotal(response.total);
+ setTotalPages(response.total_pages);
+ setHasNext(response.has_next);
+ setHasPrev(response.has_prev);
+ } catch (err) {
+ console.error('Failed to fetch videos:', err);
+ setError(t('ado2Contents.loadFailed'));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ return `${year}.${month}.${day}・${hours}:${minutes}`;
+ };
+
+ const formatTitle = (storeName: string, dateString: string) => {
+ const date = new Date(dateString);
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ return `${storeName} ${month}/${day} ${hours}:${minutes}`;
+ };
+
+ const handleDownload = async (videoUrl: string, storeName: string) => {
+ try {
+ const response = await fetch(videoUrl);
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${storeName}.mp4`;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+ } catch (err) {
+ console.error('Download failed:', err);
+ alert(t('ado2Contents.downloadFailed'));
+ }
+ };
+
+ const handleDeleteClick = (videoId: number) => {
+ setDeleteTargetId(videoId);
+ setDeleteModalOpen(true);
+ };
+
+ const handleUploadClick = (video: VideoListItem) => {
+ setUploadTargetVideo(video);
+ setUploadModalOpen(true);
+ };
+
+ const handleUploadModalClose = () => {
+ setUploadModalOpen(false);
+ setUploadTargetVideo(null);
+ };
+
+ const handleDeleteCancel = () => {
+ setDeleteModalOpen(false);
+ setDeleteTargetId(null);
+ };
+
+ const handleDeleteConfirm = async () => {
+ if (!deleteTargetId) return;
+ setIsDeleting(true);
+ try {
+ await deleteVideo(deleteTargetId);
+ setVideos(prev => prev.filter(video => video.video_id !== deleteTargetId));
+ setTotal(prev => Math.max(0, prev - 1));
+ setDeleteModalOpen(false);
+ setDeleteTargetId(null);
+ } catch (err) {
+ console.error('Delete failed:', err);
+ alert(t('ado2Contents.deleteFailed'));
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
{t('sidebar.myContents')}
+ {t('ado2Contents.totalCount', { count: total })}
+
+
+ {/* Content Grid */}
+ {loading ? (
+
+
+
{t('ado2Contents.loading')}
+
+ ) : error ? (
+
+
{error}
+
{t('ado2Contents.retry')}
+
+ ) : videos.length === 0 ? (
+
+
{t('ado2Contents.noContent')}
+
+ ) : (
+ <>
+
+ {videos.map((video) => (
+
+ {/* Video Thumbnail */}
+
setSelectedVideoId(video.video_id)}
+ >
+ {video.result_movie_url ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ {/* Card Info */}
+
+
+
+ {formatTitle(video.store_name, video.created_at)}
+
+
+
+ {formatDate(video.created_at)}
+
+
+
+
+
+ {video.like_count ?? 0}
+
+
+
+ {video.comment_count ?? 0}
+
+
+
+
+ {/* Action Buttons */}
+
+
handleUploadClick(video)}
+ disabled={!video.result_movie_url}
+ >
+
+
+
+
+ {t('ado2Contents.uploadToSocial')}
+
+
handleDownload(video.result_movie_url, video.store_name)}
+ disabled={!video.result_movie_url}
+ title={t('ado2Contents.download')}
+ >
+
+
+
+
+
+
handleDeleteClick(video.video_id)}
+ >
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {/* Pagination */}
+
+ setPage(p => p - 1)}
+ disabled={!hasPrev}
+ >
+ {t('ado2Contents.previous')}
+
+ {page} / {totalPages}
+ setPage(p => p + 1)}
+ disabled={!hasNext}
+ >
+ {t('ado2Contents.next')}
+
+
+ >
+ )}
+
+ {/* 삭제 확인 모달 */}
+ {deleteModalOpen && (
+
+
e.stopPropagation()}>
+
{t('ado2Contents.deleteConfirmTitle')}
+
{t('ado2Contents.deleteConfirmDesc')}
+
+
+ {t('ado2Contents.cancel')}
+
+
+ {isDeleting ? t('ado2Contents.deleting') : t('ado2Contents.delete')}
+
+
+
+
+ )}
+
+ {selectedVideoId !== null && (
+
setSelectedVideoId(null)}
+ />
+ )}
+
+ {/* 소셜 미디어 업로드 모달 */}
+ onNavigate('콘텐츠 캘린더') : undefined}
+ />
+
+ );
+};
+
+export default MyContentsPage;
diff --git a/src/pages/Dashboard/SoundStudioContent.tsx b/src/pages/Dashboard/SoundStudioContent.tsx
index 1d6688e..b4aa424 100755
--- a/src/pages/Dashboard/SoundStudioContent.tsx
+++ b/src/pages/Dashboard/SoundStudioContent.tsx
@@ -389,7 +389,14 @@ const SoundStudioContent: React.FC = ({
<>
{audioUrl && (
-
+ {
+ if (audioRef.current) setDuration(audioRef.current.duration);
+ }}
+ />
)}
{/* Header */}
@@ -606,12 +613,10 @@ const SoundStudioContent: React.FC = ({
{lyrics ? (() => {
const lines = lyrics.split('\n').filter(l => l.trim());
- const intro = lines.slice(0, 1);
const outro = lines.slice(-1);
- const body = lines.slice(1, -1);
+ const body = lines.slice(0, -1);
const half = Math.ceil(body.length / 2);
const sections = [
- { tag: '[Intro]', lines: intro },
{ tag: '[Verse]', lines: body.slice(0, half) },
{ tag: '[Chorus]', lines: body.slice(half) },
{ tag: '[Outro]', lines: outro },
diff --git a/src/types/api.ts b/src/types/api.ts
index f3493d0..c3373e6 100644
--- a/src/types/api.ts
+++ b/src/types/api.ts
@@ -267,14 +267,29 @@ export interface UserCreditsResponse {
credits: number;
}
-// 비디오 목록 아이템
+// 비디오 목록 아이템 (갤러리용)
export interface VideoListItem {
video_id: number;
store_name: string;
region: string;
task_id: string;
result_movie_url: string;
+ thumbnail_url?: string;
created_at: string;
+ like_count: number;
+ comment_count: number;
+ is_liked_by_me?: boolean;
+}
+
+// 비디오 상세 아이템
+export interface VideoDetailItem {
+ video_id: number;
+ result_movie_url: string;
+ store_name: string;
+ region: string;
+ created_at: string;
+ like_count: number;
+ is_liked_by_me: boolean;
}
// 비디오 목록 응답
@@ -288,6 +303,45 @@ export interface VideosListResponse {
has_prev: boolean;
}
+// 댓글 대댓글
+export interface CommentReply {
+ id: number;
+ nickname: string;
+ content: string | null;
+ is_deleted: boolean;
+ is_mine: boolean;
+ created_at: string;
+}
+
+// 댓글 아이템
+export interface CommentItem {
+ id: number;
+ nickname: string;
+ content: string | null;
+ is_deleted: boolean;
+ is_mine: boolean;
+ created_at: string;
+ replies: CommentReply[];
+}
+
+// 댓글 목록 응답
+export interface CommentsResponse {
+ items: CommentItem[];
+ total: number;
+ page: number;
+ page_size: number;
+ total_pages: number;
+ has_next: boolean;
+ has_prev: boolean;
+}
+
+// 좋아요 토글 응답
+export interface LikeToggleResponse {
+ video_id: number;
+ is_liked: boolean;
+ like_count: number;
+}
+
// ============================================
// Social OAuth Types
// ============================================
diff --git a/src/utils/api.ts b/src/utils/api.ts
index e133efd..d9925cf 100644
--- a/src/utils/api.ts
+++ b/src/utils/api.ts
@@ -30,6 +30,10 @@ import {
YTAutoSeoRequest,
YTAutoSeoResponse,
UserCreditsResponse,
+ VideoDetailItem,
+ CommentsResponse,
+ CommentItem,
+ LikeToggleResponse,
} from '../types/api';
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
@@ -373,6 +377,99 @@ export async function deleteVideo(videoId: number): Promise {
}
}
+// 전체 사용자 영상 목록 조회 API (ADO2 콘텐츠 갤러리용)
+export async function getAllVideos(
+ page: number = 1,
+ pageSize: number = 20,
+ sortBy: 'created_at' | 'like_count' | 'comment_count' = 'created_at',
+ storeName: string = '',
+ order: 'desc' | 'asc' = 'desc',
+ region: string = '',
+): Promise {
+ const params = new URLSearchParams({
+ page: String(page),
+ page_size: String(pageSize),
+ sort_by: sortBy,
+ order,
+ });
+ if (storeName.trim()) params.set('store_name', storeName.trim());
+ if (region.trim()) params.set('region', region.trim());
+ const response = await authenticatedFetch(`${API_URL}/video/all?${params.toString()}`, {
+ method: 'GET',
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ return response.json();
+}
+
+// 단일 영상 상세 조회 API
+export async function getVideoById(videoId: string): Promise {
+ const response = await authenticatedFetch(`${API_URL}/video/${videoId}`, {
+ method: 'GET',
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ return response.json();
+}
+
+// 댓글 목록 조회
+export async function getVideoComments(videoId: string, page: number = 1, pageSize: number = 20): Promise {
+ const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}?page=${page}&page_size=${pageSize}`, {
+ method: 'GET',
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ return response.json();
+}
+
+// 댓글 작성
+export async function postVideoComment(videoId: string, content: string, nickname?: string, parentId?: number): Promise {
+ const response = await authenticatedFetch(`${API_URL}/comment/video/${videoId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content, nickname: nickname || '익명', parent_id: parentId ?? null }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ return response.json();
+}
+
+// 댓글 삭제
+export async function deleteComment(commentId: number): Promise {
+ const response = await authenticatedFetch(`${API_URL}/comment/${commentId}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+}
+
+// 좋아요 토글
+export async function toggleVideoLike(videoId: string): Promise {
+ const response = await authenticatedFetch(`${API_URL}/video/${videoId}/like`, {
+ method: 'POST',
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ return response.json();
+}
+
// 이미지 업로드 API (multipart/form-data)
// 타임아웃: 5분 (많은 이미지 업로드 시 시간이 오래 걸릴 수 있음)
const IMAGE_UPLOAD_TIMEOUT = 5 * 60 * 1000;