ADO2 콘텐츠, 상세 페이지 및 검색, 좋아요, 댓글, 공유하기 기능 추가
parent
f3195628d2
commit
0ea14da748
895
index.css
895
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;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js" crossorigin="anonymous"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:ital,wght@0,700;1,700&display=swap" rel="stylesheet">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>개인정보처리방침 - ADO2</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.8; color: #333; max-width: 800px; margin: 0 auto; padding: 40px 20px; }
|
||||
h1 { font-size: 28px; border-bottom: 2px solid #eee; padding-bottom: 12px; margin-bottom: 30px; }
|
||||
h2 { font-size: 19px; margin-top: 40px; color: #111; }
|
||||
p, li { font-size: 15px; color: #555; margin-bottom: 10px; }
|
||||
ul { padding-left: 20px; }
|
||||
.google-policy { background: #f0f7ff; border-left: 4px solid #4285f4; padding: 16px 20px; margin: 24px 0; border-radius: 4px; }
|
||||
.google-policy p { color: #1a1a2e; margin: 0; }
|
||||
.updated { color: #888; font-size: 14px; margin-bottom: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>개인정보처리방침 (Privacy Policy)</h1>
|
||||
<p class="updated"><strong>시행일:</strong> 2026년 5월 7일 | <strong>최종 수정일:</strong> 2026년 5월 27일</p>
|
||||
|
||||
<p>㈜에이아이오투오(이하 "회사")는 AI 마케팅 자동화 서비스 ADO2(이하 "서비스")를 제공함에 있어 사용자의 개인정보를 중요시하며, 「개인정보 보호법」 등 관련 법령을 성실히 준수합니다.</p>
|
||||
|
||||
<h2>1. 수집하는 개인정보 항목 및 수집 방법</h2>
|
||||
<p>회사는 서비스 제공을 위해 아래와 같은 개인정보를 수집합니다.</p>
|
||||
<ul>
|
||||
<li><strong>카카오 로그인을 통한 수집:</strong> 이름, 이메일 주소, 프로필 사진 — 서비스 회원 가입 및 로그인에 사용</li>
|
||||
<li><strong>Google 사용자 프로필 :</strong> YouTube 채널 연동 시 연결된 구글 계정의 이름 및 프로필 사진 확인 — 채널 소유자 식별 목적으로만 사용</li>
|
||||
<li><strong>YouTube Data API :</strong> 채널 정보, 동영상 목록, 재생목록 등 YouTube 계정 데이터 (읽기 전용)</li>
|
||||
<li><strong>YouTube Data API :</strong> AI가 생성한 콘텐츠를 YouTube에 업로드하기 위한 동영상 관리 권한</li>
|
||||
<li><strong>YouTube Analytics API :</strong> 채널 및 동영상의 조회수, 시청 시간 등 성과 지표 데이터 (읽기 전용)</li>
|
||||
<li><strong>서비스 이용 과정에서 자동 수집:</strong> 접속 IP, 쿠키, 서비스 이용 기록</li>
|
||||
</ul>
|
||||
|
||||
<h2>2. Google 사용자 데이터의 수집 및 이용 목적</h2>
|
||||
<p>회사가 접근하는 Google 사용자 데이터는 아래 목적으로만 사용되며, 명시된 목적 외에는 사용하지 않습니다.</p>
|
||||
<ul>
|
||||
<li><strong>Google 사용자 프로필:</strong> YouTube 채널 연동 과정에서 연결 대상 구글 계정을 식별하는 용도로만 사용하며, 서비스 로그인에는 사용되지 않습니다.</li>
|
||||
<li><strong>YouTube 계정 데이터 (읽기):</strong> 기존 채널 정보·동영상 현황을 분석하여 AI 콘텐츠 전략 수립에 활용합니다.</li>
|
||||
<li><strong>YouTube 동영상 업로드:</strong> AI가 생성한 영상을 사용자의 YouTube 채널에 업로드합니다. 업로드는 반드시 사용자의 명시적 요청에 의해서만 실행됩니다.</li>
|
||||
<li><strong>YouTube 분석 데이터:</strong> 채널 성과 지표를 분석하여 AI 마케팅 전략 수립 및 콘텐츠 최적화에 활용합니다.</li>
|
||||
</ul>
|
||||
|
||||
<div class="google-policy">
|
||||
<p><strong>[Google API 서비스 사용자 데이터 정책 준수]</strong><br><br>
|
||||
㈜에이아이오투오가 운영하는 ADO2 서비스가 Google API로부터 수신한 정보의 사용 및 타 앱으로의 전송은,
|
||||
<a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank">Google API 서비스 사용자 데이터 정책</a>의
|
||||
제한적 사용(Limited Use) 요건을 포함한 모든 정책을 엄격히 준수합니다.<br><br>
|
||||
특히, Google API로부터 수신한 데이터는 <strong>AI·ML 모델 학습에 사용되지 않으며</strong>, 사용자가 명시적으로 요청한 서비스 기능 제공 목적 외에는 사용·전송·공유되지 않습니다.</p>
|
||||
</div>
|
||||
|
||||
<h2>3. Google 사용자 데이터의 제3자 공유</h2>
|
||||
<p>회사는 Google API로부터 수신한 사용자 데이터를 아래 경우를 제외하고 어떠한 제3자에게도 판매하거나 공유하지 않습니다.</p>
|
||||
<ul>
|
||||
<li><strong>AI 콘텐츠 생성 처리:</strong> 서비스 제공에 필수적인 AI 생성 기능 수행을 위해 처리 서버로 전달될 수 있으며, 해당 처리는 서비스 제공 목적으로만 사용됩니다.</li>
|
||||
<li><strong>법적 요구:</strong> 관련 법령에 의거한 수사기관 등의 적법한 요청이 있는 경우</li>
|
||||
</ul>
|
||||
<p>Google 사용자 데이터는 광고, 마케팅, 프로파일링 목적으로 사용되지 않습니다.</p>
|
||||
|
||||
<h2>4. Google 사용자 데이터의 저장 및 보안</h2>
|
||||
<ul>
|
||||
<li>Google OAuth 액세스 토큰 및 리프레시 토큰은 암호화된 상태로 보관됩니다.</li>
|
||||
<li>Google API를 통해 읽어온 데이터는 서비스 기능 처리에 필요한 최소한의 시간 동안만 임시 보유하며, 처리 완료 후 삭제됩니다.</li>
|
||||
<li>서버와의 모든 통신은 TLS(HTTPS)를 통해 암호화됩니다.</li>
|
||||
<li>데이터 접근 권한은 서비스 운영에 필요한 최소 인원에게만 부여됩니다.</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. 개인정보의 보유 및 이용 기간 / 데이터 삭제</h2>
|
||||
<p>원칙적으로 회원 탈퇴 또는 개인정보 수집·이용 목적이 달성된 후에는 해당 정보를 지체 없이 파기합니다. 단, 관련 법령에 따라 보존이 필요한 경우 해당 기간 동안 보관합니다.</p>
|
||||
<ul>
|
||||
<li>전자상거래 관련 기록: 5년 (전자상거래 등에서의 소비자보호에 관한 법률)</li>
|
||||
<li>접속 로그 기록: 3개월 (통신비밀보호법)</li>
|
||||
<li>Google 연동 토큰: 연동 해제 또는 회원 탈퇴 즉시 삭제</li>
|
||||
</ul>
|
||||
<p><strong>데이터 삭제 요청 방법:</strong> 아래 이메일(<a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a>)로 요청하시면 <strong>영업일 기준 7일 이내</strong>에 처리 결과를 안내해 드립니다. Google 계정 연동 해제는 서비스 내 계정 설정에서 직접 처리하실 수 있으며, 해제 즉시 관련 토큰이 삭제됩니다.</p>
|
||||
|
||||
<h2>6. 정보주체의 권리 및 행사 방법</h2>
|
||||
<p>사용자는 언제든지 자신의 개인정보에 대한 열람, 수정, 삭제, 처리 정지를 요청할 수 있습니다. 서비스 내 계정 설정에서 직접 처리하거나 아래 문의처로 연락해 주시기 바랍니다.</p>
|
||||
<p>또한, <a href="https://myaccount.google.com/permissions" target="_blank">Google 계정 권한 관리 페이지</a>에서 ADO2 앱의 Google 데이터 접근 권한을 언제든지 직접 취소하실 수 있습니다.</p>
|
||||
|
||||
<h2>7. 개인정보 보호책임자 및 문의처</h2>
|
||||
<p>개인정보 보호와 관련된 불만 처리 및 피해 구제에 관한 사항은 아래로 문의해 주시기 바랍니다.</p>
|
||||
<ul>
|
||||
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
|
||||
<li><strong>서비스명:</strong> ADO2</li>
|
||||
<li><strong>이메일:</strong> <a href="mailto:o2oteam@o2o.kr">o2oteam@o2o.kr</a></li>
|
||||
<li><strong>웹사이트:</strong> https://demo.castad.net</li>
|
||||
</ul>
|
||||
|
||||
<p style="margin-top:40px; font-size:14px; color:#999;">본 방침은 2026년 5월 7일부터 시행됩니다. 최종 수정일: 2026년 5월 27일</p>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>서비스 약관 - ADO2</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.8; color: #333; max-width: 800px; margin: 0 auto; padding: 40px 20px; }
|
||||
h1 { font-size: 28px; border-bottom: 2px solid #eee; padding-bottom: 12px; margin-bottom: 30px; }
|
||||
h2 { font-size: 19px; margin-top: 40px; color: #111; }
|
||||
p, li { font-size: 15px; color: #555; margin-bottom: 10px; }
|
||||
ul { padding-left: 20px; }
|
||||
.updated { color: #888; font-size: 14px; margin-bottom: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>서비스 이용약관 (Terms of Service)</h1>
|
||||
<p class="updated"><strong>시행일:</strong> 2026년 5월 7일 | <strong>최종 수정일:</strong> 2026년 5월 21일</p>
|
||||
|
||||
<h2>제 1 조 (목적)</h2>
|
||||
<p>본 약관은 ㈜에이아이오투오(이하 "회사")가 제공하는 AI 마케팅 자동화 서비스 ADO2(이하 "서비스")의 이용과 관련하여, 회사와 이용자(이하 "회원") 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.</p>
|
||||
|
||||
<h2>제 2 조 (용어의 정의)</h2>
|
||||
<ul>
|
||||
<li>"서비스"라 함은 회사가 제공하는 ADO2 AI 마케팅 자동화 플랫폼 및 관련 제반 기능을 의미합니다.</li>
|
||||
<li>"회원"이라 함은 본 약관에 동의하고 회사와 이용계약을 체결하여 서비스를 이용하는 자를 말합니다.</li>
|
||||
<li>"콘텐츠"라 함은 서비스 내에서 AI가 생성하거나 회원이 등록하는 텍스트, 이미지, 음악, 영상 등 일체의 자료를 말합니다.</li>
|
||||
</ul>
|
||||
|
||||
<h2>제 3 조 (약관의 효력 및 변경)</h2>
|
||||
<p>회사는 본 약관의 내용을 서비스 화면에 게시하며, 관련 법령을 위배하지 않는 범위에서 약관을 개정할 수 있습니다. 약관이 변경되는 경우 시행일 7일 전부터 공지합니다.</p>
|
||||
|
||||
<h2>제 4 조 (서비스의 제공 및 변경)</h2>
|
||||
<p>회사는 AI 기반 마케팅 콘텐츠(가사, 이미지, 영상 등) 자동 생성, Google·YouTube 등 외부 플랫폼 연동, SNS 자동 배포 등의 서비스를 제공합니다. 운영상·기술상의 필요에 따라 서비스의 전부 또는 일부를 변경할 수 있습니다.</p>
|
||||
|
||||
<h2>제 5 조 (회원의 의무)</h2>
|
||||
<ul>
|
||||
<li>타인의 Google 계정 등 외부 서비스 계정을 무단으로 도용하여 서비스를 이용해서는 안 됩니다.</li>
|
||||
<li>스팸 발송, API 한도 고의 초과, 허위 정보 등록 등 비정상적인 방법으로 서비스를 이용해서는 안 됩니다.</li>
|
||||
<li>회사 또는 제3자의 지식재산권을 침해해서는 안 됩니다.</li>
|
||||
</ul>
|
||||
|
||||
<h2>제 6 조 (외부 API 연동 및 데이터 활용)</h2>
|
||||
<p>서비스는 Google, YouTube, Naver 등의 제3자 API를 활용하여 마케팅 자동화 기능을 제공합니다. 특히 YouTube 서비스 연동을 위해 YouTube Data API(<code>youtube.readonly</code>, <code>youtube.upload</code>) 및 YouTube Analytics API(<code>yt-analytics.readonly</code>)를 사용하며, 이를 통해 수집·처리되는 데이터는 <a href="/privacy.html">개인정보처리방침</a>에 따라 관리됩니다. 회원은 각 외부 서비스의 이용약관 및 정책을 준수할 의무가 있으며, 외부 API 제공사의 정책 변경으로 인한 서비스 제한에 대해 회사는 면책됩니다.</p>
|
||||
<p>YouTube API 서비스 이용과 관련하여 <a href="https://www.youtube.com/t/terms" target="_blank">YouTube 이용약관</a> 및 <a href="https://policies.google.com/privacy" target="_blank">Google 개인정보처리방침</a>이 함께 적용됩니다.</p>
|
||||
|
||||
<h2>제 7 조 (AI 생성 콘텐츠의 권리)</h2>
|
||||
<p>서비스 내에서 AI가 생성한 콘텐츠에 대한 권리 관계는 관련 법령 및 회사의 별도 정책에 따릅니다. 회원이 직접 입력한 정보(매장 URL, 상호명 등)를 기반으로 생성된 콘텐츠에 대한 책임은 회원에게 있습니다.</p>
|
||||
|
||||
<h2>제 8 조 (책임 제한)</h2>
|
||||
<p>회사는 천재지변, 외부 플랫폼(Google, YouTube, Naver 등)의 장애, 통신 장애 등 불가항력으로 서비스를 제공할 수 없는 경우 책임이 면제됩니다.</p>
|
||||
|
||||
<h2>제 9 조 (준거법 및 재판관할)</h2>
|
||||
<p>본 약관과 관련된 분쟁은 대한민국 법을 준거법으로 하며, 소송은 회사의 소재지를 관할하는 법원에 제소합니다.</p>
|
||||
|
||||
<h2>문의처</h2>
|
||||
<ul>
|
||||
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
|
||||
<li><strong>서비스명:</strong> ADO2</li>
|
||||
<li><strong>이메일:</strong> o2oteam@o2o.kr</li>
|
||||
<li><strong>웹사이트:</strong> https://demo.castad.net</li>
|
||||
</ul>
|
||||
|
||||
<p style="margin-top:40px; font-size:14px; color:#999;">본 약관은 2026년 5월 7일부터 시행됩니다.</p>
|
||||
</body>
|
||||
</html>
|
||||
16
src/App.tsx
16
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 <SocialConnectError />;
|
||||
}
|
||||
|
||||
// 영상 상세 페이지 (/video/{id}) — 인증 게이트는 VideoDetailPage 내부에서 처리
|
||||
const videoDetailMatch = pathname.match(/^\/video\/([^/]+)$/);
|
||||
if (videoDetailMatch) {
|
||||
return <VideoDetailPage videoId={videoDetailMatch[1]} />;
|
||||
}
|
||||
|
||||
|
||||
// 카카오 콜백 처리 중 로딩 화면 표시
|
||||
if (isProcessingCallback) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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<CitySelectModalProps> = ({ selected, onSelect, onClose }) => {
|
||||
const [activeRegion, setActiveRegion] = useState<string | null>(
|
||||
() => 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 (
|
||||
<div className="city-modal-backdrop" onClick={onClose}>
|
||||
<div className="city-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="city-modal-header">
|
||||
<span className="city-modal-title">
|
||||
{activeRegion ? (
|
||||
<>
|
||||
<button className="city-modal-back" onClick={() => setActiveRegion(null)}>←</button>
|
||||
{activeRegion}
|
||||
</>
|
||||
) : '지역 선택'}
|
||||
</span>
|
||||
<button className="city-modal-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{activeRegion === null ? (
|
||||
<div className="city-modal-grid">
|
||||
{REGIONS.map(r => (
|
||||
<button
|
||||
key={r.label}
|
||||
className={`city-modal-region-item ${r.cities.includes(selected) ? 'has-selected' : ''}`}
|
||||
onClick={() => setActiveRegion(r.label)}
|
||||
>
|
||||
<span>{r.label}</span>
|
||||
{r.cities.includes(selected) && <span className="city-modal-region-badge">{selected}</span>}
|
||||
<span className="city-modal-arrow">›</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="city-modal-item-wrap">
|
||||
{cities.map(city => (
|
||||
<button
|
||||
key={city}
|
||||
className={`city-modal-item ${selected === city ? 'active' : ''}`}
|
||||
onClick={() => handleCityClick(city)}
|
||||
>
|
||||
{city}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CitySelectModal;
|
||||
|
|
@ -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<LoginPromptModalProps> = ({ 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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(255, 255, 255, 0.25)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#01282A',
|
||||
border: '1px solid rgba(155, 202, 204, 0.2)',
|
||||
borderRadius: '16px',
|
||||
padding: '40px 32px',
|
||||
width: '360px',
|
||||
maxWidth: '90vw',
|
||||
textAlign: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
right: '16px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: '#9BCACC',
|
||||
padding: '4px',
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<line x1="4" y1="4" x2="16" y2="16"/>
|
||||
<line x1="16" y1="4" x2="4" y2="16"/>
|
||||
</svg>
|
||||
</button>
|
||||
<h2 style={{ color: '#FFFFFF', fontSize: '20px', fontWeight: 600, marginBottom: '12px' }}>
|
||||
{t('loginPrompt.title')}
|
||||
</h2>
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px',
|
||||
background: '#1A8F93',
|
||||
color: '#FFFFFF',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t('loginPrompt.loginBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPromptModal;
|
||||
|
|
@ -85,7 +85,7 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
|
|||
{ id: '대시보드', label: t('sidebar.dashboard'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> },
|
||||
{ id: '새 프로젝트 만들기', label: t('sidebar.newProject'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> },
|
||||
{ id: 'ADO2 콘텐츠', label: t('sidebar.ado2Contents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
|
||||
{ id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> },
|
||||
{ id: '내 콘텐츠', label: t('sidebar.myContents'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> },
|
||||
{ id: '콘텐츠 캘린더', label: t('contentCalendar.title'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> },
|
||||
{ id: '내 정보', label: t('sidebar.myInfo'), disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<VideoDetailContentProps> = ({ videoId, isModal = false, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const authed = isLoggedIn();
|
||||
|
||||
const [video, setVideo] = useState<VideoDetailItem | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<CommentItem[]>([]);
|
||||
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<HTMLTextAreaElement>(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<HTMLDivElement>(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<ReturnType<typeof setTimeout> | 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 <span style={{ color: '#6B9EA0', fontStyle: 'italic' }}>(삭제된 댓글입니다.)</span>;
|
||||
return content;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={isModal ? 'video-detail-modal-content' : 'video-detail-page-content'}>
|
||||
{/* 헤더 */}
|
||||
<div className="video-detail-header">
|
||||
{isModal ? (
|
||||
<button className="video-detail-close-btn" onClick={handleHeaderAction} aria-label="닫기">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<button className="video-detail-back-btn" onClick={handleHeaderAction}>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M13 4l-6 6 6 6"/>
|
||||
</svg>
|
||||
{t('sidebar.ado2Contents')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="ado2-contents-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>{t('ado2Contents.loading')}</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="ado2-contents-error"><p>{error}</p></div>
|
||||
) : video ? (
|
||||
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
|
||||
<video
|
||||
src={video.result_movie_url}
|
||||
controls
|
||||
autoPlay
|
||||
controlsList="nodownload"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="video-detail-player"
|
||||
onLoadedMetadata={(e) => {
|
||||
const v = e.currentTarget;
|
||||
setIsLandscape(v.videoWidth > v.videoHeight);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="video-detail-info">
|
||||
<h2 className="video-detail-store">{video.store_name}</h2>
|
||||
<p className="video-detail-date">{formatDate(video.created_at)}</p>
|
||||
|
||||
{/* 좋아요 + 링크 복사 */}
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className={`video-detail-like-btn ${isLiked ? 'liked' : ''}`}
|
||||
onClick={handleLike}
|
||||
disabled={false}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill={isLiked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||
</svg>
|
||||
{likeCount}
|
||||
</button>
|
||||
<div style={{ position: 'relative' }} ref={shareMenuRef}>
|
||||
<button
|
||||
className="video-detail-copy-btn"
|
||||
onClick={() => setShareMenuOpen(v => !v)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||
</svg>
|
||||
{copied ? '복사됨!' : '공유하기'}
|
||||
</button>
|
||||
{shareMenuOpen && (
|
||||
<div className="video-detail-share-menu">
|
||||
{/* 카카오톡 */}
|
||||
<button className="video-detail-share-item" onClick={handleKakaoShare}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#FEE500">
|
||||
<path d="M12 3C6.477 3 2 6.477 2 10.8c0 2.736 1.582 5.14 3.978 6.592-.175.598-.63 2.178-.723 2.514-.113.412.151.406.318.295.13-.087 2.07-1.403 2.909-1.97.487.068.986.104 1.518.104 5.523 0 10-3.477 10-7.8S17.523 3 12 3z"/>
|
||||
</svg>
|
||||
카카오톡
|
||||
</button>
|
||||
{/* 페이스북 */}
|
||||
<button className="video-detail-share-item" onClick={handleFacebookShare}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#1877F2">
|
||||
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.235 2.686.235v2.97h-1.513c-1.491 0-1.956.93-1.956 1.887v2.268h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/>
|
||||
</svg>
|
||||
페이스북
|
||||
</button>
|
||||
{/* X (트위터) */}
|
||||
<button className="video-detail-share-item" onClick={handleTwitterShare}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
X (트위터)
|
||||
</button>
|
||||
{/* URL 복사 */}
|
||||
<button className="video-detail-share-item" onClick={() => { handleCopyLink(); setShareMenuOpen(false); }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
{copied ? '복사됨!' : 'URL 복사'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 댓글 섹션 */}
|
||||
<div className="video-detail-comments">
|
||||
<div className="video-detail-comments-header">
|
||||
<h3 className="video-detail-comments-title">댓글</h3>
|
||||
<span className="video-detail-comments-count">{commentsTotal}</span>
|
||||
</div>
|
||||
|
||||
{/* 댓글 작성자 프로필 선택 */}
|
||||
{authed && (
|
||||
<div className="video-detail-comment-profile">
|
||||
<img
|
||||
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${commentAvatarSeed}`}
|
||||
alt="아바타 변경"
|
||||
className="video-detail-comment-avatar"
|
||||
onClick={handleChangeAvatar}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title="클릭하여 아바타 변경"
|
||||
/>
|
||||
<input
|
||||
className="video-detail-nickname-input"
|
||||
type="text"
|
||||
placeholder="작성자 이름"
|
||||
value={commentNickname}
|
||||
onChange={(e) => setCommentNickname(e.target.value)}
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="video-detail-comment-input-wrap">
|
||||
<textarea
|
||||
ref={commentTextareaRef}
|
||||
className="video-detail-comment-input"
|
||||
placeholder={authed ? '댓글을 입력하세요...' : '로그인 후 댓글을 작성할 수 있습니다'}
|
||||
maxLength={500}
|
||||
rows={1}
|
||||
value={commentInput}
|
||||
onChange={(e) => {
|
||||
setCommentInput(e.target.value);
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
onFocus={handleCommentFocus}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleCommentSubmit();
|
||||
}
|
||||
}}
|
||||
disabled={!authed}
|
||||
/>
|
||||
<button
|
||||
className="video-detail-comment-submit"
|
||||
onClick={handleCommentSubmit}
|
||||
disabled={!authed || !commentInput.trim() || commentSubmitting}
|
||||
>
|
||||
{commentSubmitting ? '작성 중' : '작성'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{comments.length === 0 && !commentsLoading ? (
|
||||
<p className="video-detail-comments-empty">아직 댓글이 없습니다.</p>
|
||||
) : (
|
||||
<ul className="video-detail-comment-list">
|
||||
{comments.map((c) => (
|
||||
<li key={c.id} className="video-detail-comment-item">
|
||||
<img
|
||||
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${c.id}`}
|
||||
alt="avatar"
|
||||
className="video-detail-comment-avatar"
|
||||
/>
|
||||
<div className="video-detail-comment-body">
|
||||
<span className="video-detail-comment-nickname">
|
||||
{c.nickname || '익명'}
|
||||
</span>
|
||||
<p className="video-detail-comment-text">
|
||||
{renderCommentContent(c.content, c.is_deleted)}
|
||||
</p>
|
||||
<div className="video-detail-comment-bottom">
|
||||
<span className="video-detail-comment-date">{formatCommentDate(c.created_at)}</span>
|
||||
{c.is_mine && !c.is_deleted && (
|
||||
<button
|
||||
className="video-detail-comment-delete"
|
||||
onClick={() => handleDeleteComment(c.id)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 대댓글 (읽기 전용) */}
|
||||
{c.replies && c.replies.length > 0 && (
|
||||
<ul className="video-detail-reply-list">
|
||||
{c.replies.map((r) => (
|
||||
<li key={r.id} className="video-detail-reply-item">
|
||||
<img
|
||||
src={`https://api.dicebear.com/9.x/pixel-art/svg?seed=${r.id}`}
|
||||
alt="avatar"
|
||||
className="video-detail-comment-avatar small"
|
||||
/>
|
||||
<div className="video-detail-comment-body">
|
||||
<span className="video-detail-comment-nickname">
|
||||
{r.nickname || '익명'}
|
||||
</span>
|
||||
<p className="video-detail-comment-text">
|
||||
{renderCommentContent(r.content, r.is_deleted)}
|
||||
</p>
|
||||
<div className="video-detail-comment-bottom">
|
||||
<span className="video-detail-comment-date">{formatCommentDate(r.created_at)}</span>
|
||||
{r.is_mine && !r.is_deleted && (
|
||||
<button
|
||||
className="video-detail-comment-delete"
|
||||
onClick={() => handleDeleteComment(r.id)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{commentsHasNext && (
|
||||
<button
|
||||
className="video-detail-comments-more"
|
||||
onClick={() => fetchComments(commentsPage + 1, true)}
|
||||
disabled={commentsLoading}
|
||||
>
|
||||
{commentsLoading ? '불러오는 중...' : '댓글 더 보기'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showLoginModal && (
|
||||
<LoginPromptModal onClose={() => setShowLoginModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoDetailContent;
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import VideoDetailContent from './VideoDetailContent';
|
||||
|
||||
interface VideoDetailModalProps {
|
||||
videoId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const VideoDetailModal: React.FC<VideoDetailModalProps> = ({ 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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.85)',
|
||||
zIndex: 2000,
|
||||
display: 'flex',
|
||||
overflowY: 'auto',
|
||||
padding: '24px 16px',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#01282A',
|
||||
borderRadius: '16px',
|
||||
width: '100%',
|
||||
maxWidth: '900px',
|
||||
margin: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VideoDetailContent videoId={videoId} isModal={true} onClose={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoDetailModal;
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import VideoDetailContent from './VideoDetailContent';
|
||||
|
||||
interface VideoDetailPageProps {
|
||||
videoId: string;
|
||||
}
|
||||
|
||||
const VideoDetailPage: React.FC<VideoDetailPageProps> = ({ videoId }) => {
|
||||
const handleBack = () => {
|
||||
localStorage.setItem('castad_active_item', 'ADO2 콘텐츠');
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="video-detail-page">
|
||||
<VideoDetailContent videoId={videoId} isModal={false} onClose={handleBack} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoDetailPage;
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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": "로그인 처리에 실패했습니다. 다시 시도해주세요.",
|
||||
|
|
|
|||
|
|
@ -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<HTMLVideoElement>(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 (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
className={className}
|
||||
muted
|
||||
playsInline
|
||||
preload="none"
|
||||
onCanPlay={handleCanPlay}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
);
|
||||
};
|
||||
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<ADO2ContentsPageProps> = ({ onBack, onNavigate }) => {
|
||||
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const authed = isLoggedIn();
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
||||
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(authed);
|
||||
const [error, setError] = useState<string | null>(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<number | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(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 (
|
||||
<div className="ado2-contents-page">
|
||||
{/* Header */}
|
||||
<div className="ado2-contents-header">
|
||||
<h1 className="ado2-contents-title">{t('ado2Contents.title')}</h1>
|
||||
<h1 className="ado2-contents-title">{t('sidebar.ado2Contents')}</h1>
|
||||
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
|
||||
</div>
|
||||
|
||||
{/* Content Grid */}
|
||||
<div className="ado2-contents-filters">
|
||||
<select
|
||||
className="ado2-filter-select"
|
||||
value={`${sortBy}__${order}`}
|
||||
onChange={(e) => {
|
||||
const [sb, ord] = e.target.value.split('__') as [typeof sortBy, typeof order];
|
||||
setSortBy(sb);
|
||||
setOrder(ord);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="created_at__desc">{t('ado2Contents.sortLatest')}</option>
|
||||
<option value="created_at__asc">{t('ado2Contents.sortOldest')}</option>
|
||||
<option value="like_count__desc">{t('ado2Contents.sortLikes')}</option>
|
||||
<option value="comment_count__desc">{t('ado2Contents.sortComments')}</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`ado2-region-pill ${region ? 'active' : ''}`}
|
||||
onClick={() => setShowCityModal(true)}
|
||||
>
|
||||
{region || t('ado2Contents.regionPlaceholder')}
|
||||
{region && (
|
||||
<span className="ado2-region-clear" onClick={(e) => { e.stopPropagation(); setRegion(''); setPage(1); }}>✕</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<form
|
||||
className="ado2-filter-search"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setStoreName(searchInput);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="ado2-filter-input"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder={t('ado2Contents.searchPlaceholder')}
|
||||
/>
|
||||
<button type="submit" className="ado2-filter-btn">
|
||||
{t('ado2Contents.searchBtn')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="ado2-contents-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
|
|
@ -239,82 +140,64 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack, onNavigate
|
|||
<>
|
||||
<div className="ado2-contents-grid">
|
||||
{videos.map((video) => (
|
||||
<div key={video.task_id} className="ado2-content-card">
|
||||
{/* Video Thumbnail */}
|
||||
<div className="content-card-thumbnail">
|
||||
{video.result_movie_url ? (
|
||||
<VideoPreviewCard
|
||||
<div
|
||||
key={video.video_id}
|
||||
className="ado2-content-card"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handleCardClick(video.video_id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCardClick(video.video_id)}
|
||||
>
|
||||
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
|
||||
{video.thumbnail_url ? (
|
||||
<img
|
||||
src={video.thumbnail_url}
|
||||
alt={video.store_name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : video.result_movie_url ? (
|
||||
<video
|
||||
src={video.result_movie_url}
|
||||
className="content-video-preview"
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
<div className="content-no-video">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
|
||||
<line x1="7" y1="2" x2="7" y2="22"/>
|
||||
<line x1="17" y1="2" x2="17" y2="22"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<line x1="2" y1="7" x2="7" y2="7"/>
|
||||
<line x1="2" y1="17" x2="7" y2="17"/>
|
||||
<line x1="17" y1="17" x2="22" y2="17"/>
|
||||
<line x1="17" y1="7" x2="22" y2="7"/>
|
||||
<div className="content-no-video" />
|
||||
)}
|
||||
{/* 호버 오버레이 */}
|
||||
<div className="ado2-gallery-overlay">
|
||||
<div className="ado2-gallery-play-btn">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="5,3 19,12 5,21"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Info */}
|
||||
<div className="content-card-info">
|
||||
<div className="content-card-text">
|
||||
<h3 className="content-card-title">
|
||||
{formatTitle(video.store_name, video.created_at)}
|
||||
</h3>
|
||||
<p className="content-card-date">
|
||||
{formatDate(video.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="content-card-actions">
|
||||
<button
|
||||
className="content-download-btn"
|
||||
onClick={() => handleUploadClick(video)}
|
||||
disabled={!video.result_movie_url}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
|
||||
<path d="M3 15v2h14v-2"/>
|
||||
</svg>
|
||||
<span>{t('ado2Contents.uploadToSocial')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="content-upload-btn"
|
||||
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
|
||||
disabled={!video.result_movie_url}
|
||||
title={t('ado2Contents.download')}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M10 3v10M10 13l-4-4M10 13l4-4"/>
|
||||
<path d="M3 15v2h14v-2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="content-delete-btn"
|
||||
onClick={() => handleDeleteClick(video.video_id)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
|
||||
<line x1="8" y1="8" x2="8" y2="14"/>
|
||||
<line x1="12" y1="8" x2="12" y2="14"/>
|
||||
</svg>
|
||||
</button>
|
||||
<h3 className="content-card-title">{video.store_name}</h3>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<p className="content-card-date">{formatDate(video.created_at)}</p>
|
||||
<span className="content-card-like">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||
</svg>
|
||||
{video.like_count ?? 0}
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: '6px' }}>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
{video.comment_count ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination - 항상 표시 */}
|
||||
<div className="ado2-contents-pagination">
|
||||
<button
|
||||
className="pagination-btn"
|
||||
|
|
@ -335,39 +218,24 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack, onNavigate
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
{deleteModalOpen && (
|
||||
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
|
||||
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<h2 className="delete-modal-title">{t('ado2Contents.deleteConfirmTitle')}</h2>
|
||||
<p className="delete-modal-description">{t('ado2Contents.deleteConfirmDesc')}</p>
|
||||
<div className="delete-modal-actions">
|
||||
<button
|
||||
className="delete-modal-btn cancel"
|
||||
onClick={handleDeleteCancel}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t('ado2Contents.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="delete-modal-btn confirm"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? t('ado2Contents.deleting') : t('ado2Contents.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!authed && (
|
||||
<LoginPromptModal onClose={() => { window.location.href = '/'; }} />
|
||||
)}
|
||||
|
||||
{/* 소셜 미디어 업로드 모달 */}
|
||||
<SocialPostingModal
|
||||
isOpen={uploadModalOpen}
|
||||
onClose={handleUploadModalClose}
|
||||
video={uploadTargetVideo}
|
||||
onGoToCalendar={onNavigate ? () => onNavigate('콘텐츠 캘린더') : undefined}
|
||||
/>
|
||||
{selectedVideoId !== null && (
|
||||
<VideoDetailModal
|
||||
videoId={String(selectedVideoId)}
|
||||
onClose={() => setSelectedVideoId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCityModal && (
|
||||
<CitySelectModal
|
||||
selected={region}
|
||||
onSelect={(city) => { setRegion(city); setPage(1); }}
|
||||
onClose={() => setShowCityModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -500,12 +500,10 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
return <p className="comp2-lyrics-text">{t('completion.noLyricsBGM')}</p>;
|
||||
}
|
||||
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<CompletionContentProps> = ({
|
|||
task_id: songTaskId || '',
|
||||
result_movie_url: videoUrl,
|
||||
created_at: new Date().toISOString(),
|
||||
like_count: 0,
|
||||
comment_count: 0,
|
||||
} : null}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<GenerationFlowProps> = ({
|
|||
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<GenerationFlowProps> = ({
|
|||
case '비즈니스 설정':
|
||||
return <BusinessSettingsContent />;
|
||||
case 'ADO2 콘텐츠':
|
||||
return <ADO2ContentsPage />;
|
||||
case '내 콘텐츠':
|
||||
return (
|
||||
<ADO2ContentsPage
|
||||
<MyContentsPage
|
||||
onBack={() => setActiveItem('새 프로젝트 만들기')}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
|
|
@ -544,12 +547,12 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
// 브랜드 분석(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 === '새 프로젝트 만들기') {
|
||||
|
|
|
|||
|
|
@ -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<HTMLVideoElement>(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 (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
className={className}
|
||||
muted
|
||||
playsInline
|
||||
preload="none"
|
||||
onCanPlay={handleCanPlay}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface MyContentsPageProps {
|
||||
onBack?: () => void;
|
||||
onNavigate?: (item: string) => void;
|
||||
}
|
||||
|
||||
const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate }) => {
|
||||
const { t } = useTranslation();
|
||||
const tutorial = useTutorial();
|
||||
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<number | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
const [uploadTargetVideo, setUploadTargetVideo] = useState<VideoListItem | null>(null);
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(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 (
|
||||
<div className="ado2-contents-page">
|
||||
{/* Header */}
|
||||
<div className="ado2-contents-header">
|
||||
<h1 className="ado2-contents-title">{t('sidebar.myContents')}</h1>
|
||||
<span className="ado2-contents-count">{t('ado2Contents.totalCount', { count: total })}</span>
|
||||
</div>
|
||||
|
||||
{/* Content Grid */}
|
||||
{loading ? (
|
||||
<div className="ado2-contents-loading">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>{t('ado2Contents.loading')}</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="ado2-contents-error">
|
||||
<p>{error}</p>
|
||||
<button onClick={fetchVideos} className="retry-btn">{t('ado2Contents.retry')}</button>
|
||||
</div>
|
||||
) : videos.length === 0 ? (
|
||||
<div className="ado2-contents-empty">
|
||||
<p>{t('ado2Contents.noContent')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ado2-contents-grid">
|
||||
{videos.map((video) => (
|
||||
<div key={video.task_id} className="ado2-content-card">
|
||||
{/* Video Thumbnail */}
|
||||
<div
|
||||
className="content-card-thumbnail"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setSelectedVideoId(video.video_id)}
|
||||
>
|
||||
{video.result_movie_url ? (
|
||||
<VideoPreviewCard
|
||||
src={video.result_movie_url}
|
||||
className="content-video-preview"
|
||||
/>
|
||||
) : (
|
||||
<div className="content-no-video">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
|
||||
<line x1="7" y1="2" x2="7" y2="22"/>
|
||||
<line x1="17" y1="2" x2="17" y2="22"/>
|
||||
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||
<line x1="2" y1="7" x2="7" y2="7"/>
|
||||
<line x1="2" y1="17" x2="7" y2="17"/>
|
||||
<line x1="17" y1="17" x2="22" y2="17"/>
|
||||
<line x1="17" y1="7" x2="22" y2="7"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card Info */}
|
||||
<div className="content-card-info">
|
||||
<div className="content-card-text">
|
||||
<h3 className="content-card-title">
|
||||
{formatTitle(video.store_name, video.created_at)}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<p className="content-card-date">
|
||||
{formatDate(video.created_at)}
|
||||
</p>
|
||||
<span className="content-card-like">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||
</svg>
|
||||
{video.like_count ?? 0}
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: '6px' }}>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
{video.comment_count ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="content-card-actions">
|
||||
<button
|
||||
className="content-download-btn"
|
||||
onClick={() => handleUploadClick(video)}
|
||||
disabled={!video.result_movie_url}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
|
||||
<path d="M3 15v2h14v-2"/>
|
||||
</svg>
|
||||
<span>{t('ado2Contents.uploadToSocial')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="content-upload-btn"
|
||||
onClick={() => handleDownload(video.result_movie_url, video.store_name)}
|
||||
disabled={!video.result_movie_url}
|
||||
title={t('ado2Contents.download')}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M10 3v10M10 13l-4-4M10 13l4-4"/>
|
||||
<path d="M3 15v2h14v-2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="content-delete-btn"
|
||||
onClick={() => handleDeleteClick(video.video_id)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 5h14M8 5V3h4v2M6 5v12h8V5"/>
|
||||
<line x1="8" y1="8" x2="8" y2="14"/>
|
||||
<line x1="12" y1="8" x2="12" y2="14"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="ado2-contents-pagination">
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
disabled={!hasPrev}
|
||||
>
|
||||
{t('ado2Contents.previous')}
|
||||
</button>
|
||||
<span className="pagination-info">{page} / {totalPages}</span>
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={!hasNext}
|
||||
>
|
||||
{t('ado2Contents.next')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 모달 */}
|
||||
{deleteModalOpen && (
|
||||
<div className="delete-modal-overlay" onClick={handleDeleteCancel}>
|
||||
<div className="delete-modal" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<h2 className="delete-modal-title">{t('ado2Contents.deleteConfirmTitle')}</h2>
|
||||
<p className="delete-modal-description">{t('ado2Contents.deleteConfirmDesc')}</p>
|
||||
<div className="delete-modal-actions">
|
||||
<button
|
||||
className="delete-modal-btn cancel"
|
||||
onClick={handleDeleteCancel}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t('ado2Contents.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="delete-modal-btn confirm"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? t('ado2Contents.deleting') : t('ado2Contents.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedVideoId !== null && (
|
||||
<VideoDetailModal
|
||||
videoId={String(selectedVideoId)}
|
||||
onClose={() => setSelectedVideoId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 소셜 미디어 업로드 모달 */}
|
||||
<SocialPostingModal
|
||||
isOpen={uploadModalOpen}
|
||||
onClose={handleUploadModalClose}
|
||||
video={uploadTargetVideo}
|
||||
onGoToCalendar={onNavigate ? () => onNavigate('콘텐츠 캘린더') : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyContentsPage;
|
||||
|
|
@ -389,7 +389,14 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
<>
|
||||
<main className="sound-studio-page">
|
||||
{audioUrl && (
|
||||
<audio ref={audioRef} src={audioUrl} preload="metadata" />
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
preload="metadata"
|
||||
onLoadedMetadata={() => {
|
||||
if (audioRef.current) setDuration(audioRef.current.duration);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
|
|
@ -606,12 +613,10 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
<div className="lyrics-display">
|
||||
{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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
}
|
||||
}
|
||||
|
||||
// 전체 사용자 영상 목록 조회 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<VideosListResponse> {
|
||||
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<VideoDetailItem> {
|
||||
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<CommentsResponse> {
|
||||
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<CommentItem> {
|
||||
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<void> {
|
||||
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<LikeToggleResponse> {
|
||||
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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue