e&&(t=e),t}function a(t){document.adoptedStyleSheets=[...document.adoptedStyleSheets,t]}function l(t,n){const e=t[3];return[(1-e)*n[0]+e*t[0],(1-e)*n[1]+e*t[1],(1-e)*n[2]+e*t[2],e+n[3]*(1-e)]}function c([t,n,e]){const o=Math.max(t,n,e),i=Math.min(t,n,e),r=o-i;let s;return s=i===o?0:t===o?(1/6*(n-e)/r+1)%1:n===o?1/6*(e-t)/r+1/3:1/6*(t-n)/r+2/3,s}function d([t,n,e]){return.2126*(t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4))+.7152*(n<=.04045?n/12.92:Math.pow((n+.055)/1.055,2.4))+.0722*(e<=.04045?e/12.92:Math.pow((e+.055)/1.055,2.4))}const h=.027,p=.1,u=5e-4;function m([t,n,e]){return.2126729*Math.pow(t,2.4)+.7151522*Math.pow(n,2.4)+.072175*Math.pow(e,2.4)}function g(t,n){return function(t,n){if(t=f(t),n=f(n),Math.abs(t-n)t?(e=1.14*(Math.pow(n,.56)-Math.pow(t,.57)),e=e -.1?0:e+h);return 100*e}(m(l(t,n)),m(n))}function f(t){return t>.022?t:t+Math.pow(.022-t,1.414)}const x=[[12,-1,-1,-1,-1,100,90,80,-1,-1],[14,-1,-1,-1,100,90,80,60,60,-1],[16,-1,-1,100,90,80,60,55,50,50],[18,-1,-1,90,80,60,55,50,40,40],[24,-1,100,80,60,55,50,40,38,35],[30,-1,90,70,55,50,40,38,35,40],[36,-1,80,60,50,40,38,35,30,25],[48,100,70,55,40,38,35,30,25,20],[60,90,60,50,38,35,30,25,20,20],[72,80,55,40,35,30,25,20,20,20],[96,70,50,35,30,25,20,20,20,20],[120,60,40,30,25,20,20,20,20,20]];x.reverse();const b={aa:3,aaa:4.5},y={aa:4.5,aaa:7};function v(t,n){return function(t,n){const e=72*parseFloat(t.replace("px",""))/96;return(isNaN(Number(n))?["bold","bolder"].includes(n):Number(n)>=600)?e>=14:e>=18}(t,n)?b:y}function w(t,n,e,o=1){e?.color&&(t.save(),t.translate(.5,.5),t.lineWidth=o,"dashed"===e.pattern&&t.setLineDash([3,3]),"dotted"===e.pattern&&t.setLineDash([2,2]),t.strokeStyle=e.color,t.stroke(n),t.restore())}function A(t,n,e,o,i){i&&(t.save(),i.fillColor&&(t.fillStyle=i.fillColor,t.fill(n)),i.hatchColor&&F(t,n,e,10,i.hatchColor,o,!1),t.restore())}function M(t,n,e){let o=0;function i(i){const r=[];for(let s=0;sparseInt(t,16)/255))}function B(t,n){if("rgb"===n){const[n,e,o,i]=t;return`rgb(${(255*n).toFixed()} ${(255*e).toFixed()} ${(255*o).toFixed()}${1===i?"":" / "+Math.round(100*i)/100})`}if("hsl"===n){const[n,e,o,i]=function([t,n,e,o]){const i=Math.max(t,n,e),r=Math.min(t,n,e),s=i-r,a=i+r,l=.5*a;let d;return d=0===l||1===l?0:l<=.5?s/a:s/(2-a),[c([t,n,e]),d,l,o]}(t);return`hsl(${Math.round(360*n)}deg ${Math.round(100*e)} ${Math.round(100*o)}${1===i?"":" / "+Math.round(100*(i??1))/100})`}if("hwb"===n){const[n,e,o,i]=function([t,n,e,o]){const i=c([t,n,e]),r=Math.max(t,n,e);return[i,Math.min(t,n,e),1-r,o]}(t);return`hwb(${Math.round(360*n)}deg ${Math.round(100*e)} ${Math.round(100*o)}${1===i?"":" / "+Math.round(100*(i??1))/100})`}throw new Error("NOT_REACHED")}function H(t,n,e,o,i,r,s){t.save();const a=M(n,r,s);return e&&(t.fillStyle=e,t.fill(a)),o&&("dashed"===i&&t.setLineDash([3,3]),"dotted"===i&&t.setLineDash([2,2]),t.lineWidth=2,t.strokeStyle=o,t.stroke(a)),t.restore(),a}const X=20,W="#1A73E8";function I(t,n,o,i,r,s,a=new DOMMatrix){const l=`grid-${r.gridLayerCounter++}-labels`;let c=document.getElementById(l);if(!c){const t=document.getElementById("grid-label-container");if(!t)throw new Error("#grid-label-container is not found");c=e(t,"div"),c.id=l}const d=t.gridHighlightConfig?.rowLineColor?t.gridHighlightConfig.rowLineColor:W,h=et(d);c.style.setProperty("--row-label-color",d),c.style.setProperty("--row-label-text-color",h);const p=t.gridHighlightConfig?.columnLineColor?t.gridHighlightConfig.columnLineColor:W,u=et(p);c.style.setProperty("--column-label-color",p),c.style.setProperty("--column-label-text-color",u),c.innerText="";const m=e(c,"div","area-names"),g=e(c,"div","line-names"),f=e(c,"div","line-numbers"),x=e(c,"div","track-sizes"),b=function(t,n){const e=Math.round(n.maxX-n.minX),o=Math.round(n.maxY-n.minY),i={rows:{positive:{positions:[],hasFirst:!1,hasLast:!1},negative:{positions:[],hasFirst:!1,hasLast:!1}},columns:{positive:{positions:[],hasFirst:!1,hasLast:!1},negative:{positions:[],hasFirst:!1,hasLast:!1}},bounds:{minX:Math.round(n.minX),maxX:Math.round(n.maxX),minY:Math.round(n.minY),maxY:Math.round(n.maxY),allPoints:n.allPoints,width:e,height:o}};if(t.gridHighlightConfig?.showLineNames){const n=T(t.rowLineNameOffsets||[]),e={positions:n.positions,names:n.names,hasFirst:!!n.positions.length&&R(n.positions).y===i.bounds.minY,hasLast:!!n.positions.length&&D(n.positions).y===i.bounds.maxY};i.rows.positive=e;const o=T(t.columnLineNameOffsets||[]),r={positions:o.positions,names:o.names,hasFirst:!!o.positions.length&&R(o.positions).x===i.bounds.minX,hasLast:!!o.positions.length&&D(o.positions).x===i.bounds.maxX};i.columns.positive=r}else{const n=({x:t,y:n})=>({x:Math.round(t),y:Math.round(n)});t.positiveRowLineNumberPositions&&(i.rows.positive={positions:t.positiveRowLineNumberPositions.map(n),hasFirst:Math.round(R(t.positiveRowLineNumberPositions).y)===i.bounds.minY,hasLast:Math.round(D(t.positiveRowLineNumberPositions).y)===i.bounds.maxY}),t.negativeRowLineNumberPositions&&(i.rows.negative={positions:t.negativeRowLineNumberPositions.map(n),hasFirst:Math.round(R(t.negativeRowLineNumberPositions).y)===i.bounds.minY,hasLast:Math.round(D(t.negativeRowLineNumberPositions).y)===i.bounds.maxY}),t.positiveColumnLineNumberPositions&&(i.columns.positive={positions:t.positiveColumnLineNumberPositions.map(n),hasFirst:Math.round(R(t.positiveColumnLineNumberPositions).x)===i.bounds.minX,hasLast:Math.round(D(t.positiveColumnLineNumberPositions).x)===i.bounds.maxX}),t.negativeColumnLineNumberPositions&&(i.columns.negative={positions:t.negativeColumnLineNumberPositions.map(n),hasFirst:Math.round(R(t.negativeColumnLineNumberPositions).x)===i.bounds.minX,hasLast:Math.round(D(t.negativeColumnLineNumberPositions).x)===i.bounds.maxX})}return i}(t,n);t.gridHighlightConfig?.showLineNames?function(t,n,e,o,i=new DOMMatrix,r="horizontal-tb"){for(const[s,a]of n.columns.positive.positions.entries()){j(N(t,Q(n.columns.positive.names[s]),"column"),S(a,i),n,r,e,o)}for(const[s,a]of n.rows.positive.positions.entries()){Z(N(t,Q(n.rows.positive.names[s]),"row"),S(a,i),n,r,e,o)}}(g,b,i,s,a,t.writingMode):function(t,n,e,o,i=new DOMMatrix,r="horizontal-tb"){if(!n.columns.positive.names)for(const[s,a]of U(n.columns.positive.positions,"x")){j(N(t,(s+1).toString(),"column"),S(a,i),n,r,e,o)}if(!n.rows.positive.names)for(const[s,a]of U(n.rows.positive.positions,"y")){Z(N(t,(s+1).toString(),"row"),S(a,i),n,r,e,o)}for(const[s,a]of U(n.columns.negative.positions,"x")){q(N(t,(-1*n.columns.negative.positions.length+s).toString(),"column"),S(a,i),n,r,e,o)}for(const[s,a]of U(n.rows.negative.positions,"y")){V(N(t,(-1*n.rows.negative.positions.length+s).toString(),"row"),S(a,i),n,r,e,o)}}(f,b,i,s,a,t.writingMode),function(t,n,e=new DOMMatrix,o="horizontal-tb"){for(const{name:i,bounds:r}of n){const n=N(t,i,"row"),{width:s,height:a}=_(n,o),l=S("vertical-rl"===o||"sideways-rl"===o?r.allPoints[3]:"sideways-lr"===o?r.allPoints[1]:r.allPoints[0],e),c=r.allPoints[1].x20,l=!s&&Math.abs(t[t.length-1][n]-i[n])>20;(r||s||a&&l)&&(yield[o,i],e=i)}}const D=t=>t[t.length-1],R=t=>t[0];function T(t){const n=[],e=[];for(const{name:o,x:i,y:r}of t){const t=Math.round(i),s=Math.round(r),a=n.findIndex((({x:n,y:e})=>n===t&&e===s));a>-1?e[a].push(o):(n.push({x:t,y:s}),e.push([o]))}return{positions:n,names:e}}function O(t,n,e,o,i,r=new DOMMatrix,s="horizontal-tb"){const{main:a,cross:l}=K(s),{crossSize:c}=G(s,o);for(const{x:o,y:d,computedSize:h,authoredSize:p}of n){const n=S({x:o,y:d},r),u=h.toFixed(2),m=N(t,`${p?p+"·":""}${`${u.endsWith(".00")?u.slice(0,-3):u}px`}`,e),g=_(m,s);let f=n[a]-g.mainSizet));else{const e=t.match(/[0-9.]+/g);if(!e)return null;n=e.slice(0,3).map((t=>parseInt(t,10)/255))}return n.length?d(n)>.2?"#121212":"white":null}function ot(t){return t.startsWith("horizontal")}function it(t){return"vertical-rl"===t||"sideways-rl"===t}function rt(t,n,e,o,i,r,s){const a=C(),l=M(t.gridBorder,a,r);n.save(),function(t,n,e){if(ot(t))return;const o=n.allPoints[0],i=n.allPoints[1],r=n.allPoints[3];e.translate(o.x,o.y),("vertical-rl"===t||"sideways-rl"===t)&&(e.rotate(90*Math.PI/180),e.translate(0,-1*(r.y-o.y)));"vertical-lr"===t&&(e.rotate(90*Math.PI/180),e.scale(1,-1));"sideways-lr"===t&&(e.rotate(-90*Math.PI/180),e.translate(-1*(i.x-o.x),0));e.translate(-1*o.x,-1*o.y)}(t.writingMode,a,n),t.gridHighlightConfig.gridBackgroundColor&&(n.fillStyle=t.gridHighlightConfig.gridBackgroundColor,n.fill(l)),t.gridHighlightConfig.gridBorderColor&&(n.save(),n.translate(.5,.5),n.lineWidth=0,t.gridHighlightConfig.gridBorderDash&&n.setLineDash([3,3]),n.strokeStyle=t.gridHighlightConfig.gridBorderColor,n.stroke(l),n.restore());const c=st(n,t,"row",r),d=st(n,t,"column",r);lt(n,t.rowGaps,t.gridHighlightConfig.rowGapColor,t.gridHighlightConfig.rowHatchColor,t.rotationAngle,r,!0),lt(n,t.columnGaps,t.gridHighlightConfig.columnGapColor,t.gridHighlightConfig.columnHatchColor,t.rotationAngle,r,!1);const h=function(t,n,e,o){if(!n||!Object.keys(n).length)return[];t.save(),e&&(t.strokeStyle=e);t.lineWidth=2;const i=[];for(const e in n){const r=n[e],s=C(),a=M(r,s,o);t.stroke(a),i.push({name:e,bounds:s})}return t.restore(),i}(n,t.areaNames,t.gridHighlightConfig.areaBorderColor,r),p=n.getTransform();p.scaleSelf(1/e),n.restore(),t.gridHighlightConfig.showGridExtensionLines&&(c&&at(n,c,t.gridHighlightConfig.rowLineColor,t.gridHighlightConfig.rowLineDash,p,o,i),d&&at(n,d,t.gridHighlightConfig.columnLineColor,t.gridHighlightConfig.columnLineDash,p,o,i)),I(t,a,h,{canvasWidth:o,canvasHeight:i},s,r,p)}function st(t,n,e,o){const i=n[`${e}s`],r=n.gridHighlightConfig[`${e}LineColor`],s=n.gridHighlightConfig[`${e}LineDash`];if(!r)return null;const a=C(),l=M(i,a,o);return t.save(),t.translate(.5,.5),s&&t.setLineDash([3,3]),t.lineWidth=0,t.strokeStyle=r,t.save(),t.stroke(l),t.restore(),t.restore(),a}function at(t,n,e,o,i,r,s){t.save(),t.strokeStyle=e,t.lineWidth=1,t.translate(.5,.5),o&&t.setLineDash([3,3]);for(let e=0;e \');\n}\n\n.element-layout-type.flex {\n background-image: url(\'data:image/svg+xml, \');\n}\n\n.element-description {\n flex: 1 1;\n font-weight: bold;\n word-wrap: break-word;\n word-break: break-all;\n}\n\n.dimensions {\n color: var(--sys-color-outline);\n text-align: right;\n margin-left: 10px;\n}\n\n.material-node-width {\n margin-right: 2px;\n}\n\n.material-node-height {\n margin-left: 2px;\n}\n\n.material-tag-name {\n /* Keep this in sync with inspectorCommon.css (--override-dom-tag-name-color) */\n color: rgb(136 18 128);\n}\n\n.material-class-name,\n.material-node-id {\n /* Keep this in sync with inspectorCommon.css (.webkit-html-attribute-value) */\n color: rgb(26 26 166);\n}\n\n.contrast-text {\n width: 16px;\n height: 16px;\n text-align: center;\n line-height: 16px;\n margin-right: 8px;\n border: 1px solid rgb(0 0 0 / 10%);\n padding: 0 1px;\n}\n\n.a11y-icon-not-ok {\n background-image: url(\'data:image/svg+xml, \');\n}\n\n.a11y-icon-warning {\n background-image: url(\'data:image/svg+xml, \');\n}\n\n.a11y-icon-ok {\n background-image: url(\'data:image/svg+xml, \');\n}\n\n@media (forced-colors: active) {\n :root,\n body {\n background-color: transparent;\n forced-color-adjust: none;\n }\n\n .tooltip-content {\n border-color: Highlight;\n background-color: canvas;\n forced-color-adjust: none;\n }\n\n .tooltip-content::after {\n background-color: Highlight;\n }\n\n .color-swatch-inner,\n .contrast-text,\n .separator {\n border-color: Highlight;\n }\n\n .section-name {\n color: Highlight;\n }\n\n .dimensions,\n .element-info-name,\n .element-info-value-color,\n .element-info-value-contrast,\n .element-info-value-icon,\n .element-info-value-text,\n .material-tag-name,\n .material-class-name,\n .material-node-id {\n color: canvastext;\n }\n}\n');function pt(t,n,e,o){const{baseSize:i,isHorizontalFlow:r}=t,s=vt(n),a=r?{p1:s.p1,p2:Lt(s.p1,s.p2,i),p3:Lt(s.p4,s.p3,i),p4:s.p4}:{p1:s.p1,p2:s.p2,p3:Lt(s.p2,s.p3,i),p4:Lt(s.p1,s.p4,i)};!function(t,n,e,o,i){const r=t.flexItemHighlightConfig,s=C(),a=M((c=e,["M",c.p1.x,c.p1.y,"L",c.p2.x,c.p2.y,"L",c.p3.x,c.p3.y,"L",c.p4.x,c.p4.y,"Z"]),s,i),l=Math.atan2(n.p4.y-n.p1.y,n.p4.x-n.p1.x)+45*Math.PI/180;var c;A(o,a,s,l,r.baseSizeBox),w(o,a,r.baseSizeBorder)}(t,s,a,e,o),function(t,n,e,o,i){const{isHorizontalFlow:r}=t,s=t.flexItemHighlightConfig;if(!s.flexibilityArrow)return;const a=r?{x:(e.p2.x+e.p3.x)/2,y:(e.p2.y+e.p3.y)/2}:{x:(e.p4.x+e.p3.x)/2,y:(e.p4.y+e.p3.y)/2},l=r?{x:(n.p2.x+n.p3.x)/2,y:(n.p2.y+n.p3.y)/2}:{x:(n.p4.x+n.p3.x)/2,y:(n.p4.y+n.p3.y)/2};if(l.x===a.x&&l.y===a.y)return;const c=yt([a,l]);if(w(o,M(c,C(),i),s.flexibilityArrow,1),!s.flexibilityArrow.color)return;const d=M(["M",l.x-5,l.y-5,"L",l.x,l.y,"L",l.x-5,l.y+5],C(),i),h=Math.atan2(l.y-a.y,l.x-a.x);o.save(),o.translate(l.x+.5,l.y+.5),o.rotate(h),o.translate(-l.x-.5,-l.y-.5),w(o,d,s.flexibilityArrow,1),o.restore()}(t,s,a,e,o)}function ut(t,n,e){const o=t.flexContainerHighlightConfig,i=C(),r=M(t.containerBorder,i,e),{isHorizontalFlow:s,isReverse:a,lines:l}=t;if(w(n,r,o.containerBorder),!l?.length)return;const c=function(t,n,e,o){const i=vt(t),r=[];for(const t of n){if(!t.length)continue;let s=vt(t[0].itemBorder);const a=[];for(const{itemBorder:n}of t){const t=vt(n);s=s?wt(s,t,e,o):t,a.push(t)}const l=1===n.length?i:At(s,i,e),c=a.map((t=>At(t,l,!e)));r.push({quad:l,items:a,extendedItems:c})}return r}(t.containerBorder,l,s,a);!function(t,n,e,o,i){const r=t.flexContainerHighlightConfig,s=o.map(((t,n)=>{const e=o[n+1]?.quad;return{path:i?xt(t.quad,e):bt(t.quad,e),items:t.extendedItems.map(((n,e)=>{const o=t.extendedItems[e+1]&&t.extendedItems[e+1];return i?bt(n,o):xt(n,o)}))}})),a=s.length>1;for(const{path:t,items:o}of s){for(const t of o)w(n,M(t,C(),e),r.itemSeparator);a&&w(n,M(t,C(),e),r.lineSeparator)}}(t,n,e,c,s),function(t,n,e,o,i){const{isHorizontalFlow:r}=t,{mainDistributedSpace:s,crossDistributedSpace:a,rowGapSpace:l,columnGapSpace:c}=t.flexContainerHighlightConfig,d=r?c:l,h=r?l:c,p=s&&Boolean(s.fillColor||s.hatchColor),u=i.length>1&&a&&Boolean(a.fillColor||a.hatchColor),m=d&&Boolean(d.fillColor||d.hatchColor),g=i.length>1&&h&&Boolean(h.fillColor||h.hatchColor),f=s&&a&&d&&h&&s.fillColor===a.fillColor&&s.hatchColor===a.hatchColor&&s.fillColor===d.fillColor&&s.hatchColor===d.hatchColor&&s.fillColor===h.fillColor&&s.hatchColor===h.hatchColor,x=vt(o);if(f){return void gt(x,i.map((t=>t.extendedItems)).flat().map((t=>t)),s,n,e)}const b=function(t,n){const{crossGap:e,mainGap:o,isHorizontalFlow:i,isReverse:r}=t,s=[],a=[];if(e&&n.length>1)for(let t=0,o=t+1;tt.quad)),...g?b.crossGaps:[]],a,n,e)}if(p)for(const[t,o]of i.entries()){const i=[...o.extendedItems,...m?b.mainGaps[t]:[]];gt(o.quad,i,s,n,e)}if(g)for(const t of b.crossGaps)gt(t,[],h,n,e);if(m)for(const t of b.mainGaps)for(const o of t)gt(o,[],d,n,e)}(t,n,e,t.containerBorder,c),function(t,n,e,o,i){o.forEach((({quad:o,items:r},s)=>{!function(t,n,e,o,i,r){const{alignItemsStyle:s,isHorizontalFlow:a}=t,{crossAlignment:l}=t.flexContainerHighlightConfig;if(!l?.color)return;const c=[];switch(s){case"flex-start":c.push([a?o.p1:o.p4,a?o.p2:o.p1]);break;case"flex-end":c.push([a?o.p3:o.p2,a?o.p4:o.p3]);break;case"center":a?(c.push([{x:(o.p1.x+o.p4.x)/2,y:(o.p1.y+o.p4.y)/2},{x:(o.p2.x+o.p3.x)/2,y:(o.p2.y+o.p3.y)/2}]),c.push([{x:(o.p2.x+o.p3.x)/2,y:(o.p2.y+o.p3.y)/2},{x:(o.p1.x+o.p4.x)/2,y:(o.p1.y+o.p4.y)/2}])):(c.push([{x:(o.p1.x+o.p2.x)/2,y:(o.p1.y+o.p2.y)/2},{x:(o.p3.x+o.p4.x)/2,y:(o.p3.y+o.p4.y)/2}]),c.push([{x:(o.p3.x+o.p4.x)/2,y:(o.p3.y+o.p4.y)/2},{x:(o.p1.x+o.p2.x)/2,y:(o.p1.y+o.p2.y)/2}]));break;case"stretch":case"normal":c.push([a?o.p1:o.p4,a?o.p2:o.p1]),c.push([a?o.p3:o.p2,a?o.p4:o.p3]);break;case"baseline":if(a){const t=i[0],n=Mt([t.p1,t.p2],[o.p2,o.p3]),e=Mt([t.p1,t.p2],[o.p1,o.p4]),s=r[0],a=Math.atan2(t.p4.y-t.p1.y,t.p4.x-t.p1.x);c.push([{x:n.x+s*Math.cos(a),y:n.y+s*Math.sin(a)},{x:e.x+s*Math.cos(a),y:e.y+s*Math.sin(a)}])}}for(const o of c){w(n,M(yt(o),C(),e),l,2),mt(t,n,e,o[0],o[1])}}(t,n,e,o,r,i[s])}))}(t,n,e,c,l.map((t=>t.map((t=>t.baseline)))))}function mt(t,n,e,o,i){const{crossAlignment:r}=t.flexContainerHighlightConfig;if(!r?.color)return;const s=Math.atan2(i.y-o.y,i.x-o.x),a={x:-2*Math.cos(s-.5*Math.PI)+(o.x+i.x)/2,y:-2*Math.sin(s-.5*Math.PI)+(o.y+i.y)/2},l=M(["M",a.x,a.y,"L",a.x+5.5,a.y+6,"L",a.x+2.5,a.y+6,"L",a.x+2.5,a.y+6+5,"L",a.x-2.5,a.y+6+5,"L",a.x-2.5,a.y+6,"L",a.x-5.5,a.y+6,"Z"],C(),e);n.save(),n.translate(a.x,a.y),n.rotate(s),n.translate(-a.x,-a.y),n.fillStyle=r.color,n.fill(l),n.lineWidth=1,n.strokeStyle="white",n.stroke(l),n.restore()}function gt(t,n,e,o,i){if(e){if(e.fillColor){const r=Y(t,n,C(),i);o.fillStyle=e.fillColor,o.fill(r)}if(e.hatchColor){const r=180*Math.atan2(t.p2.y-t.p1.y,t.p2.x-t.p1.x)/Math.PI,s=C();F(o,Y(t,n,s,i),s,10,e.hatchColor,r,!1)}}}function ft(t,n,e,o,i){i&&([t,n]=[n,t]);const r=o?Math.atan2(t.p4.y-t.p1.y,t.p4.x-t.p1.x):Math.atan2(t.p2.y-t.p1.y,t.p2.x-t.p1.x),s=St(o?t.p4:t.p2,n.p1),a=s/2-e/2,l=s/2+e/2;return o?{p1:{x:Math.round(t.p4.x+a*Math.cos(r)),y:Math.round(t.p4.y+a*Math.sin(r))},p2:{x:Math.round(t.p3.x+a*Math.cos(r)),y:Math.round(t.p3.y+a*Math.sin(r))},p3:{x:Math.round(t.p3.x+l*Math.cos(r)),y:Math.round(t.p3.y+l*Math.sin(r))},p4:{x:Math.round(t.p4.x+l*Math.cos(r)),y:Math.round(t.p4.y+l*Math.sin(r))}}:{p1:{x:Math.round(t.p2.x+a*Math.cos(r)),y:Math.round(t.p2.y+a*Math.sin(r))},p2:{x:Math.round(t.p2.x+l*Math.cos(r)),y:Math.round(t.p2.y+l*Math.sin(r))},p3:{x:Math.round(t.p3.x+l*Math.cos(r)),y:Math.round(t.p3.y+l*Math.sin(r))},p4:{x:Math.round(t.p3.x+a*Math.cos(r)),y:Math.round(t.p3.y+a*Math.sin(r))}}}function xt(t,n){const e=n&&t.p4.y===n.p1.y,o=["M",t.p1.x,t.p1.y,"L",t.p2.x,t.p2.y];return e?o:[...o,"M",t.p3.x,t.p3.y,"L",t.p4.x,t.p4.y]}function bt(t,n){const e=n&&t.p2.x===n.p1.x,o=["M",t.p1.x,t.p1.y,"L",t.p4.x,t.p4.y];return e?o:[...o,"M",t.p3.x,t.p3.y,"L",t.p2.x,t.p2.y]}function yt(t){return["M",t[0].x,t[0].y,"L",t[1].x,t[1].y]}function vt(t){return{p1:{x:t[1],y:t[2]},p2:{x:t[4],y:t[5]},p3:{x:t[7],y:t[8]},p4:{x:t[10],y:t[11]}}}function wt(t,n,e,o){o&&([t,n]=[n,t]);const i=e?[t.p1,t.p4]:[t.p1,t.p2],r=e?[n.p2,n.p3]:[n.p4,n.p3],s=e?[t.p1,t.p2]:[t.p1,t.p4],a=e?[t.p4,t.p3]:[t.p2,t.p3],l=e?[n.p1,n.p2]:[n.p1,n.p4],c=e?[n.p4,n.p3]:[n.p2,n.p3];let d,h,p,u;return e?(d=Mt(i,l),Ct(i,d)&&(d=t.p1),h=Mt(r,s),Ct(r,h)&&(h=n.p2),p=Mt(r,a),Ct(r,p)&&(p=n.p3),u=Mt(i,c),Ct(i,u)&&(u=t.p4)):(d=Mt(i,l),Ct(i,d)&&(d=t.p1),h=Mt(i,c),Ct(i,h)&&(h=t.p2),p=Mt(r,a),Ct(r,p)&&(p=n.p3),u=Mt(r,s),Ct(r,u)&&(u=n.p4)),{p1:d,p2:h,p3:p,p4:u}}function At(t,n,e){return{p1:e?Mt([n.p1,n.p4],[t.p1,t.p2]):Mt([n.p1,n.p2],[t.p1,t.p4]),p2:e?Mt([n.p2,n.p3],[t.p1,t.p2]):Mt([n.p1,n.p2],[t.p2,t.p3]),p3:e?Mt([n.p2,n.p3],[t.p3,t.p4]):Mt([n.p3,n.p4],[t.p2,t.p3]),p4:e?Mt([n.p1,n.p4],[t.p3,t.p4]):Mt([n.p3,n.p4],[t.p1,t.p4])}}function Mt([t,n],[e,o]){const i=((t.x*n.y-t.y*n.x)*(e.x-o.x)-(t.x-n.x)*(e.x*o.y-e.y*o.x))/((t.x-n.x)*(e.y-o.y)-(t.y-n.y)*(e.x-o.x)),r=((t.x*n.y-t.y*n.x)*(e.y-o.y)-(t.y-n.y)*(e.x*o.y-e.y*o.x))/((t.x-n.x)*(e.y-o.y)-(t.y-n.y)*(e.x-o.x));return{x:Object.is(i,-0)?0:i,y:Object.is(r,-0)?0:r}}function Ct([t,n],e){return!(t.xn.x))&&(!(t.x>n.x&&(e.x>t.x||e.xn.y))&&(!(t.y>n.y&&(e.y>t.y||e.y{t.stopPropagation(),t.preventDefault(),this.originX=void 0,this.originY=void 0,this.document.body.style.cursor="default",this.document.body.removeEventListener("mousemove",e),this.document.body.addEventListener("mousemove",this.boundMousemove)};this.document.body.addEventListener("mouseup",o,{once:!0}),window.addEventListener("mouseout",o,{once:!0}),this.document.body.addEventListener("mousemove",e)}onDrag(t,n){if(!this.originX&&!this.originY)return;let e,o;if(this.originX){const t=this.originX.coord-n.clientX;e=Math.round(this.originX.value-t)}if(this.originY){const t=this.originY.coord-n.clientY;o=Math.round(this.originY.value-t)}t.update({width:e,height:o})}}function Pt(t,n){return"start"===n?{x:(t.minX+t.maxX)/2,y:t.minY}:"center"===n?{x:(t.minX+t.maxX)/2,y:(t.minY+t.maxY)/2}:"end"===n?{x:(t.minX+t.maxX)/2,y:t.maxY}:void 0}function Ft(t,n,e){let o=0,i=!0;n.x===e.minX?(o=-.5*Math.PI,i=!1):n.x===e.maxX?(o=.5*Math.PI,i=!1):n.y===e.minY?(o=0,i=!1):n.y===e.maxY&&(o=Math.PI,i=!1);const r=o+(i?2*Math.PI:Math.PI);t.save(),t.beginPath(),t.lineWidth=5,t.strokeStyle="white",t.arc(n.x,n.y,6,o,r),t.stroke(),t.fillStyle="#4585f6",t.arc(n.x,n.y,4,o,r),t.fill(),t.restore()}function Yt(t,n,e){!function(t,n,e){H(n,t.paddingBox,t.scrollPaddingColor,void 0,void 0,C(),e),n.save(),n.globalCompositeOperation="destination-out",H(n,t.snapport,"white",void 0,void 0,C(),e),n.restore()}(t,n,e);const o=function(t,n,e){const o=[];for(const i of t.snapAreas){const r=C();H(n,i.path,t.scrollMarginColor,t.snapAreaBorder.color,t.snapAreaBorder.pattern,r,e),n.save(),n.globalCompositeOperation="destination-out",H(n,i.borderBox,"white",void 0,void 0,C(),e),n.restore(),o.push(r)}return o}(t,n,e);!function(t,n,e){H(n,t.snapport,void 0,t.snapportBorder.color,void 0,C(),e)}(t,n,e),function(t,n,e){for(let r=0;r{const o=n.isPointInDraggablePath(t,e);if(o)return{type:o.type,initialWidth:o.initialWidth,initialHeight:o.initialHeight,id:o.highlightIndex,update:({width:t,height:n})=>{window.InspectorOverlayHost.send({highlightType:"isolatedElement",highlightIndex:o.highlightIndex,newWidth:`${t}px`,newHeight:`${n}px`,resizerType:o.type})}}}})),this.dragHandler.install()),this.context.save();const{widthPath:e,heightPath:o,bidirectionPath:i,currentWidth:r,currentHeight:s,highlightIndex:a}=function(t,n,e,o,i){const{currentX:r,currentY:s,currentWidth:a,currentHeight:l,highlightIndex:c}=t;n.save(),n.fillStyle=t.isolationModeHighlightConfig.maskColor,n.fillRect(0,0,e,o),n.clearRect(r,s,a,l),n.restore();const d=C(),h=M(t.widthResizerBorder,d,i);A(n,h,d,0,{fillColor:t.isolationModeHighlightConfig.resizerColor});const p=M(t.heightResizerBorder,d,i);A(n,p,d,0,{fillColor:t.isolationModeHighlightConfig.resizerColor});const u=M(t.bidirectionResizerBorder,d,i);return A(n,u,d,0,{fillColor:t.isolationModeHighlightConfig.resizerColor}),{widthPath:h,heightPath:p,bidirectionPath:u,currentWidth:a,currentHeight:l,highlightIndex:c}}(t,this.context,this.canvasWidth,this.canvasHeight,this.emulationScaleFactor);this.draggableBorders.set(a,{widthPath:e,heightPath:o,bidirectionPath:i,highlightIndex:a,initialWidth:r,initialHeight:s}),this.context.restore()}isPointInDraggablePath(t,n){for(const{widthPath:e,heightPath:o,bidirectionPath:i,highlightIndex:r,initialWidth:s,initialHeight:a}of this.draggableBorders.values()){if(this.context.isPointInPath(e,t,n))return{type:"width",highlightIndex:r,initialWidth:s};if(this.context.isPointInPath(o,t,n))return{type:"height",highlightIndex:r,initialHeight:a};if(this.context.isPointInPath(i,t,n))return{type:"bidirection",highlightIndex:r,initialWidth:s,initialHeight:a}}}}function Bt(t){return 0===t[3]}const Ht="rgba(0,0,0,0.2)",Xt="rgba(0,0,0,0.7)",Wt="rgba(255, 255, 255, 0.8)";const It="rgba(128, 128, 128, 0.3)";const Ut=new CSSStyleSheet;Ut.replaceSync('/*\n * Copyright 2021 The Chromium Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style license that can be\n * found in the LICENSE file.\n */\n\nbody {\n background-color: rgb(0 0 0 / 31%);\n}\n\n.controls-line {\n display: flex;\n justify-content: center;\n margin: 10px 0;\n}\n\n.message-box {\n padding: 2px 4px;\n display: flex;\n align-items: center;\n cursor: default;\n overflow: hidden;\n}\n\n#paused-in-debugger {\n white-space: nowrap;\n text-overflow: ellipsis;\n overflow: hidden;\n}\n\n.controls-line > * {\n background-color: rgb(255 255 194);\n border: 1px solid rgb(202 202 202);\n height: 22px;\n box-sizing: border-box;\n}\n\n.controls-line .button {\n width: 26px;\n margin-left: -1px;\n margin-right: 0;\n padding: 0;\n flex-shrink: 0;\n flex-grow: 0;\n cursor: pointer;\n}\n\n.controls-line .button .glyph {\n width: 100%;\n height: 100%;\n background-color: rgb(0 0 0 / 75%);\n opacity: 80%;\n mask-repeat: no-repeat;\n mask-position: center;\n position: relative;\n}\n\n.controls-line .button:active .glyph {\n top: 1px;\n left: 1px;\n}\n\n#resume-button .glyph {\n mask-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA0AAAAKCAYAAABv7tTEAAAAAXNSR0IArs4c6QAAAFJJREFUKM+10bEJgGAMBeEPbR3BLRzEVdzEVRzELRzBVohVwEJ+iODBlQfhBeJhsmHU4C0KnFjQV6J0x1SNAhdWDJUoPTB3PvLLeaUhypM3n3sD/qc7lDrdpIEAAAAASUVORK5CYII=");\n mask-size: 13px 10px;\n background-color: rgb(66 129 235);\n}\n\n#step-over-button .glyph {\n mask-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAKCAYAAAC5Sw6hAAAAAXNSR0IArs4c6QAAAOFJREFUKM+N0j8rhXEUB/DPcxW35CqhvIBrtqibkklhV8qkTHe4ZbdblcXgPVhuMdqUTUl5A2KRRCF5LGc4PT1P7qnfcr5/zu/8KdTHLFaxjHnc4RZXKI0QYxjgLQTVd42l/0wmg5iFX3iq5H6w22RS4DyRH7CB8cAXcBTGJT6xUmd0mEwuMdFQcA3fwXvGTAan8BrgPabTL9fRRyfx91PRMwyjGwcJ2EyCfsrfpPw2Pipz24NT/MZciiQYVshzOKnZ5Hturxt3k2MnCpS4SPkeHpPR8Sh3tYgttBoW9II2/AHiaEqvD2Fc0wAAAABJRU5ErkJggg==");\n mask-size: 18px 10px;\n}\n');const Dt=new CSSStyleSheet;Dt.replaceSync("/*\n * Copyright 2021 The Chromium Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style license that can be\n * found in the LICENSE file.\n */\n\nbody {\n cursor: crosshair;\n}\n\n#zone {\n background-color: #0003;\n border: 1px solid #fffd;\n display: none;\n position: absolute;\n}\n");let Rt=null,Tt=null;function Ot(){if(!Rt)throw new Error("Error calculating currentRect: no anchor was defined.");if(!Tt)throw new Error("Error calculating currentRect: no position was defined.");return{x:Math.min(Rt.x,Tt.x),y:Math.min(Rt.y,Tt.y),width:Math.abs(Rt.x-Tt.x),height:Math.abs(Rt.y-Tt.y)}}function Qt(){Rt=null,Tt=null}const Nt=new CSSStyleSheet;Nt.replaceSync("/*\n * Copyright 2021 The Chromium Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style license that can be\n * found in the LICENSE file.\n */\n\n:root {\n --border-radius: 4px;\n}\n\n.source-order-label-container {\n display: block;\n min-width: 20px;\n position: absolute;\n text-align: center;\n align-items: center;\n background-color: #fff;\n font-family: Menlo, Consolas, monospace;\n font-size: 12px;\n font-weight: bold;\n padding: 2px;\n border: 1.5px solid;\n}\n\n.top-corner {\n border-bottom-right-radius: var(--border-radius);\n}\n\n.bottom-corner {\n border-top-right-radius: var(--border-radius);\n}\n\n.above-element {\n border-top-right-radius: var(--border-radius);\n border-top-left-radius: var(--border-radius);\n}\n\n.below-element {\n border-bottom-right-radius: var(--border-radius);\n border-bottom-left-radius: var(--border-radius);\n}\n\n.above-element-wider {\n border-top-right-radius: var(--border-radius);\n border-top-left-radius: var(--border-radius);\n border-bottom-right-radius: var(--border-radius);\n}\n\n.below-element-wider {\n border-bottom-right-radius: var(--border-radius);\n border-bottom-left-radius: var(--border-radius);\n border-top-right-radius: var(--border-radius);\n}\n\n.bottom-corner-wider {\n border-top-right-radius: var(--border-radius);\n border-bottom-right-radius: var(--border-radius);\n}\n\n.bottom-corner-taller {\n border-top-right-radius: var(--border-radius);\n border-top-left-radius: var(--border-radius);\n}\n\n.bottom-corner-wider-taller {\n border-top-left-radius: var(--border-radius);\n border-top-right-radius: var(--border-radius);\n border-bottom-right-radius: var(--border-radius);\n}\n");const Jt=300,Kt={topCorner:"top-corner",aboveElement:"above-element",belowElement:"below-element",aboveElementWider:"above-element-wider",belowElementWider:"below-element-wider",bottomCornerWider:"bottom-corner-wider",bottomCornerTaller:"bottom-corner-taller",bottomCornerWiderTaller:"bottom-corner-wider-taller"};function Gt(t){return t%1?t.toFixed(2):String(t)}const Zt=new CSSStyleSheet;Zt.replaceSync('/*\n * Copyright 2023 The Chromium Authors. All rights reserved.\n * Use of this source code is governed by a BSD-style license that can be\n * found in the LICENSE file.\n */\n\n:root {\n --wco-theme-color: #121212;\n --wco-icon-color: #fff;\n}\n\n.image-group {\n display: flex;\n background-color: var(--wco-theme-color);\n align-items: center;\n}\n\n.image-group-left {\n float: left;\n justify-content: flex-start;\n gap: 4px;\n padding-left: 12px;\n}\n\n.image-group-right {\n float: right;\n justify-content: flex-end;\n gap: 2px;\n padding-right: 17px;\n}\n\n.windows-right-image-group {\n width: 238px;\n height: 33px;\n}\n\n.linux-right-image-group {\n width: 196px;\n height: 34px;\n}\n\n.mac-left-image-group {\n width: 74px;\n height: 40px;\n}\n\n.mac-right-image-group {\n width: 100px;\n height: 40px;\n}\n\n.image {\n width: 33px;\n height: 33px;\n background-color: var(--wco-icon-color);\n}\n\n#mac-chevron,\n#mac-ellipsis {\n width: 40px;\n height: 40px;\n background-color: var(--wco-icon-color);\n}\n\n#close {\n mask-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACQAAAAfCAYAAACPvW/2AAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV9TpSJVBytIcchQneyiIuJUqlgEC6Wt0KqDyaUfQpOGJMXFUXAtOPixWHVwcdbVwVUQBD9AXF2cFF2kxP8lhRYxHhz34929x907QGhUmGp2xQBVs4x0Ii7m8iti4BUBhDGEfsxKzNSTmYUsPMfXPXx8vYvyLO9zf44+pWAywCcSx5huWMTrxNObls55nzjEypJCfE48btAFiR+5Lrv8xrnksMAzQ0Y2PUccIhZLHSx3MCsbKvEUcURRNcoXci4rnLc4q5Uaa92TvzBY0JYzXKc5ggQWkUQKImTUsIEKLERp1Ugxkab9uIc/7PhT5JLJtQFGjnlUoUJy/OB/8Ltbszg54SYF40D3i21/jAKBXaBZt+3vY9tungD+Z+BKa/urDWDmk/R6W4scAQPbwMV1W5P3gMsdYPhJlwzJkfw0hWIReD+jb8oDg7dA76rbW2sfpw9AlrpaugEODoGxEmWveby7p7O3f8+0+vsB9f9y2zZ6P+8AAAAGYktHRAD/AP8A/6C9p5MAAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfnBxsWBAcQDgJxAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAAAPlJREFUWMPtlTFOxDAQRd8k5gYb+i1W6Si4/ym22wpatJHogWQo+JaMlAjZgS3QPClSIk/s55mxDEEQBME3rPYHdzcgAbOZLRsxnWLezcxr5u8aNpGAR2Bw935FpgfuFZNqJ28RmoFnYAQOpZTeB409KbZ6t3U1NlvcfVK5R4lMGs4yF2DaKumvCklqdverPkdl2oCTZK5mNt+kqTf65UFznYGXVpnWHlrblAGuZxdpZ3YGleksmdPXkDeXLO2UyQ2c+2kpGr1JKjXIdMChlMkLF6dtLDK1/HWGeuC4dpp0+rLUEXgF3m5xddwBHz9cHb1inCAIguAf8QkteHDWohPAIAAAAABJRU5ErkJggg==");\n}\n\n#maximize {\n mask-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACQAAAAfCAYAAACPvW/2AAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV9TpSJVBytIcchQneyiIuJUqlgEC6Wt0KqDyaUfQpOGJMXFUXAtOPixWHVwcdbVwVUQBD9AXF2cFF2kxP8lhRYxHhz34929x907QGhUmGp2xQBVs4x0Ii7m8iti4BUBhDGEfsxKzNSTmYUsPMfXPXx8vYvyLO9zf44+pWAywCcSx5huWMTrxNObls55nzjEypJCfE48btAFiR+5Lrv8xrnksMAzQ0Y2PUccIhZLHSx3MCsbKvEUcURRNcoXci4rnLc4q5Uaa92TvzBY0JYzXKc5ggQWkUQKImTUsIEKLERp1Ugxkab9uIc/7PhT5JLJtQFGjnlUoUJy/OB/8Ltbszg54SYF40D3i21/jAKBXaBZt+3vY9tungD+Z+BKa/urDWDmk/R6W4scAQPbwMV1W5P3gMsdYPhJlwzJkfw0hWIReD+jb8oDg7dA76rbW2sfpw9AlrpaugEODoGxEmWveby7p7O3f8+0+vsB9f9y2zZ6P+8AAAAGYktHRAD/AP8A/6C9p5MAAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfnBxsWBACOapfSAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAAAGJJREFUWMPt07sNgDAMhOEzQhkHxqHK0GEcmp8NiGTJQHFf64culiKZmdm/RWYI2CVtk7YzIsYrrwA60B7qDeiZ3Uv6tBFXplYWqIoDOdBngWbfPrt3Tc4NSQcw6zEzM6t2A1K/HsQFSWEQAAAAAElFTkSuQmCC");\n}\n\n#minimize {\n mask-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACQAAAAfCAYAAACPvW/2AAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV9TpSJVBytIcchQneyiIuJUqlgEC6Wt0KqDyaUfQpOGJMXFUXAtOPixWHVwcdbVwVUQBD9AXF2cFF2kxP8lhRYxHhz34929x907QGhUmGp2xQBVs4x0Ii7m8iti4BUBhDGEfsxKzNSTmYUsPMfXPXx8vYvyLO9zf44+pWAywCcSx5huWMTrxNObls55nzjEypJCfE48btAFiR+5Lrv8xrnksMAzQ0Y2PUccIhZLHSx3MCsbKvEUcURRNcoXci4rnLc4q5Uaa92TvzBY0JYzXKc5ggQWkUQKImTUsIEKLERp1Ugxkab9uIc/7PhT5JLJtQFGjnlUoUJy/OB/8Ltbszg54SYF40D3i21/jAKBXaBZt+3vY9tungD+Z+BKa/urDWDmk/R6W4scAQPbwMV1W5P3gMsdYPhJlwzJkfw0hWIReD+jb8oDg7dA76rbW2sfpw9AlrpaugEODoGxEmWveby7p7O3f8+0+vsB9f9y2zZ6P+8AAAAGYktHRAD/AP8A/6C9p5MAAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfnBxsWAzIJ/FCVAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAAADNJREFUWMPt0LERACEMA0E5/+ZolO4+NiUwEDK78SlRAgC8rW5G3T2SfJvsr6rpYgCAMwvylgUCKbPyMgAAAABJRU5ErkJggg==");\n}\n\n#mac-ellipsis,\n#ellipsis {\n mask-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACQAAAAfCAYAAACPvW/2AAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV9TpSJVBytIcchQneyiIuJUqlgEC6Wt0KqDyaUfQpOGJMXFUXAtOPixWHVwcdbVwVUQBD9AXF2cFF2kxP8lhRYxHhz34929x907QGhUmGp2xQBVs4x0Ii7m8iti4BUBhDGEfsxKzNSTmYUsPMfXPXx8vYvyLO9zf44+pWAywCcSx5huWMTrxNObls55nzjEypJCfE48btAFiR+5Lrv8xrnksMAzQ0Y2PUccIhZLHSx3MCsbKvEUcURRNcoXci4rnLc4q5Uaa92TvzBY0JYzXKc5ggQWkUQKImTUsIEKLERp1Ugxkab9uIc/7PhT5JLJtQFGjnlUoUJy/OB/8Ltbszg54SYF40D3i21/jAKBXaBZt+3vY9tungD+Z+BKa/urDWDmk/R6W4scAQPbwMV1W5P3gMsdYPhJlwzJkfw0hWIReD+jb8oDg7dA76rbW2sfpw9AlrpaugEODoGxEmWveby7p7O3f8+0+vsB9f9y2zZ6P+8AAAAGYktHRAD/AP8A/6C9p5MAAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfnBxsTEiHYUPCwAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAAAEBJREFUWMPt0aENACAQBMENEnqj/24OjwISAmJHrrrPgyRJeitJS9JO2oqyOwboQE9Sd9qVQb++rM5XrzZJkiQY1Fw4YEmaUfMAAAAASUVORK5CYII=");\n}\n\n#mac-chevron,\n#chevron {\n mask-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACQAAAAfCAYAAACPvW/2AAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV9TpSJVBytIcchQneyiIuJUqlgEC6Wt0KqDyaUfQpOGJMXFUXAtOPixWHVwcdbVwVUQBD9AXF2cFF2kxP8lhRYxHhz34929x907QGhUmGp2xQBVs4x0Ii7m8iti4BUBhDGEfsxKzNSTmYUsPMfXPXx8vYvyLO9zf44+pWAywCcSx5huWMTrxNObls55nzjEypJCfE48btAFiR+5Lrv8xrnksMAzQ0Y2PUccIhZLHSx3MCsbKvEUcURRNcoXci4rnLc4q5Uaa92TvzBY0JYzXKc5ggQWkUQKImTUsIEKLERp1Ugxkab9uIc/7PhT5JLJtQFGjnlUoUJy/OB/8Ltbszg54SYF40D3i21/jAKBXaBZt+3vY9tungD+Z+BKa/urDWDmk/R6W4scAQPbwMV1W5P3gMsdYPhJlwzJkfw0hWIReD+jb8oDg7dA76rbW2sfpw9AlrpaugEODoGxEmWveby7p7O3f8+0+vsB9f9y2zZ6P+8AAAAGYktHRAD/AP8A/6C9p5MAAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfnBxsTEjCy4NBCAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAAAKBJREFUWMPtk7sKAkEMRU/WRrtFv0hZ9J9VXD9psdFCiM0IFruYeZT3VDMh4R4yDAghhBBZWLTR3U9AB4xm9grOrIED8Dazc2Smy5BfATtgSEERmQHYpllaC92ACeiBo7tvAjI98ADuzZ9sIehiZs/cnmZC/wJrZYqEloIBr5UpFpqRmlL5e75Gf2IzoRkpajbTROhHap+uY+lmhBBCiEI+sBxN3vpZhO0AAAAASUVORK5CYII=");\n}\n\n#mac-close,\n#mac-minimize,\n#mac-maximize {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n}\n\n#mac-close {\n background-color: #ff5f57;\n}\n\n#mac-minimize {\n background-color: #ffbd2e;\n}\n\n#mac-maximize {\n background-color: #28c941;\n}\n');function Vt(t){t.classList.add("hidden")}function jt(t){t.classList.remove("hidden")}function qt(t,n,e){const o=function(t){const n=i("div");for(const e of t){const t=i("div");t.id=e,t.classList.add("image"),n.append(t)}return n}(e);return o.classList.add("image-group"),o.classList.add(`image-group-${n}`),o.classList.add(`${t}-${n}-image-group`),o.classList.add("hidden"),o}a(t);const $t=new CSSStyleSheet;$t.replaceSync('\n/* Grid row and column labels */\n.grid-label-content {\n position: absolute;\n -webkit-user-select: none;\n padding: 2px;\n font-family: Menlo, monospace;\n font-size: 10px;\n min-width: 17px;\n min-height: 15px;\n border-radius: 2px;\n box-sizing: border-box;\n z-index: 1;\n background-clip: padding-box;\n pointer-events: none;\n text-align: center;\n display: flex;\n justify-content: center;\n align-items: center;\n}\n\n.grid-label-content[data-direction=row] {\n background-color: var(--row-label-color, #1A73E8);\n color: var(--row-label-text-color, #121212);\n}\n\n.grid-label-content[data-direction=column] {\n background-color: var(--column-label-color, #1A73E8);\n color: var(--column-label-text-color,#121212);\n}\n\n.line-names ul,\n.line-names .line-name {\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.line-names .line-name {\n max-width: 100px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.line-names .grid-label-content,\n.line-numbers .grid-label-content,\n.track-sizes .grid-label-content {\n border: 1px solid white;\n --inner-corner-avoid-distance: 15px;\n}\n\n.grid-label-content.top-left.inner-shared-corner,\n.grid-label-content.top-right.inner-shared-corner {\n transform: translateY(var(--inner-corner-avoid-distance));\n}\n\n.grid-label-content.bottom-left.inner-shared-corner,\n.grid-label-content.bottom-right.inner-shared-corner {\n transform: translateY(calc(var(--inner-corner-avoid-distance) * -1));\n}\n\n.grid-label-content.left-top.inner-shared-corner,\n.grid-label-content.left-bottom.inner-shared-corner {\n transform: translateX(var(--inner-corner-avoid-distance));\n}\n\n.grid-label-content.right-top.inner-shared-corner,\n.grid-label-content.right-bottom.inner-shared-corner {\n transform: translateX(calc(var(--inner-corner-avoid-distance) * -1));\n}\n\n.line-names .grid-label-content::before,\n.line-numbers .grid-label-content::before,\n.track-sizes .grid-label-content::before {\n position: absolute;\n z-index: 1;\n pointer-events: none;\n content: "";\n width: 3px;\n height: 3px;\n border: 1px solid white;\n border-width: 0 1px 1px 0;\n}\n\n.line-names .grid-label-content[data-direction=row]::before,\n.line-numbers .grid-label-content[data-direction=row]::before,\n.track-sizes .grid-label-content[data-direction=row]::before {\n background: var(--row-label-color, #1A73E8);\n}\n\n.line-names .grid-label-content[data-direction=column]::before,\n.line-numbers .grid-label-content[data-direction=column]::before,\n.track-sizes .grid-label-content[data-direction=column]::before {\n background: var(--column-label-color, #1A73E8);\n}\n\n.grid-label-content.bottom-mid::before {\n transform: translateY(-1px) rotate(45deg);\n top: 100%;\n}\n\n.grid-label-content.top-mid::before {\n transform: translateY(-3px) rotate(-135deg);\n top: 0%;\n}\n\n.grid-label-content.left-mid::before {\n transform: translateX(-3px) rotate(135deg);\n left: 0%\n}\n\n.grid-label-content.right-mid::before {\n transform: translateX(3px) rotate(-45deg);\n right: 0%;\n}\n\n.grid-label-content.right-top::before {\n transform: translateX(3px) translateY(-1px) rotate(-90deg) skewY(30deg);\n right: 0%;\n top: 0%;\n}\n\n.grid-label-content.right-bottom::before {\n transform: translateX(3px) translateY(-3px) skewX(30deg);\n right: 0%;\n top: 100%;\n}\n\n.grid-label-content.bottom-right::before {\n transform: translateX(1px) translateY(-1px) skewY(30deg);\n right: 0%;\n top: 100%;\n}\n\n.grid-label-content.bottom-left::before {\n transform: translateX(-1px) translateY(-1px) rotate(90deg) skewX(30deg);\n left: 0%;\n top: 100%;\n}\n\n.grid-label-content.left-top::before {\n transform: translateX(-3px) translateY(-1px) rotate(180deg) skewX(30deg);\n left: 0%;\n top: 0%;\n}\n\n.grid-label-content.left-bottom::before {\n transform: translateX(-3px) translateY(-3px) rotate(90deg) skewY(30deg);\n left: 0%;\n top: 100%;\n}\n\n.grid-label-content.top-right::before {\n transform: translateX(1px) translateY(-3px) rotate(-90deg) skewX(30deg);\n right: 0%;\n top: 0%;\n}\n\n.grid-label-content.top-left::before {\n transform: translateX(-1px) translateY(-3px) rotate(180deg) skewY(30deg);\n left: 0%;\n top: 0%;\n}\n\n@media (forced-colors: active) {\n .grid-label-content {\n border-color: Highlight;\n background-color: Canvas;\n color: Text;\n forced-color-adjust: none;\n }\n .grid-label-content::before {\n background-color: Canvas;\n border-color: Highlight;\n }\n}');const _t=new class extends n{tooltip;persistentOverlay;gridLabelState={gridLayerCounter:0};reset(t){super.reset(t),this.tooltip.innerHTML="",this.gridLabelState.gridLayerCounter=0,this.persistentOverlay&&this.persistentOverlay.reset(t)}install(){this.document.body.classList.add("fill");const t=this.document.createElement("canvas");t.id="canvas",t.classList.add("fill"),this.document.body.append(t);const n=this.document.createElement("div");n.id="tooltip-container",this.document.body.append(n),this.tooltip=n,this.persistentOverlay=new zt(this.window),this.persistentOverlay.renderGridMarkup(),this.persistentOverlay.setCanvas(t),this.setCanvas(t),super.install()}uninstall(){this.document.body.classList.remove("fill"),this.document.body.innerHTML="",super.uninstall()}drawHighlight(t){this.context.save();const n=C();let a=null,c=null;for(let e=t.paths.slice();e.length;){const t=e.pop();t&&(this.context.save(),H(this.context,t.path,t.fillColor,t.outlineColor,void 0,n,this.emulationScaleFactor),e.length&&(this.context.globalCompositeOperation="destination-out",H(this.context,e[e.length-1].path,"red",void 0,void 0,n,this.emulationScaleFactor)),this.context.restore(),"content"===t.name&&(a=t.path),"border"===t.name&&(c=t.path))}this.context.restore(),this.context.save();const h=Boolean(t.paths.length&&t.showRulers&&n.minX<20&&n.maxX+20=t)for(const[t,e]of[900,800,700,600,500,400,300,200,100].entries())if(o>=e){const e=n[n.length-1-t];return-1===e?null:e}return null}(n.fontSize,n.fontWeight);a.textContent=String(Math.floor(100*t)/100)+"%",e(r,"div",null===s||Math.abs(t)S&&tL;let F=E-C;F=s(F,A,c-b-A);let Y=a.minY-w-y,H=!0;Y<0?(Y=Math.min(h-y,a.maxY+w),H=!1):a.minY>h&&(Y=h-w-y);const X=F>=a.minX&&F+b<=a.maxX&&Y>=a.minY&&Y+y<=a.maxY,W=Fa.minX&&Ya.minY;if(W&&!X)return void(m.style.display="none");if(m.style.top=Y+"px",m.style.left=F+"px",m.style.setProperty("--arrow-visibility",P||X?"hidden":"visible"),P)return;m.style.setProperty("--arrow",H?"var(--arrow-down)":"var(--arrow-up)"),m.style.setProperty("--shadow-direction",H?"var(--shadow-up)":"var(--shadow-down)"),m.style.setProperty("--arrow-top",(H?y-1:-w)+"px"),m.style.setProperty("--arrow-left",E-F+"px")}(t.elementInfo,t.colorFormat,n,this.canvasWidth,this.canvasHeight)),t.gridInfo)for(const n of t.gridInfo)rt(n,this.context,this.deviceScaleFactor,this.canvasWidth,this.canvasHeight,this.emulationScaleFactor,this.gridLabelState);if(t.flexInfo)for(const n of t.flexInfo)ut(n,this.context,this.emulationScaleFactor);if(t.containerQueryInfo)for(const n of t.containerQueryInfo)ht(n,this.context,this.emulationScaleFactor);const u=t.flexInfo?.length&&t.flexInfo.some((t=>Object.keys(t.flexContainerHighlightConfig).length>0));if(t.flexItemInfo&&!u)for(const n of t.flexItemInfo){const t="content"===n.boxSizing?a:c;t&&pt(n,t,this.context,this.emulationScaleFactor)}return this.context.restore(),{bounds:n}}drawGridHighlight(t){this.persistentOverlay&&this.persistentOverlay.drawGridHighlight(t)}drawFlexContainerHighlight(t){this.persistentOverlay&&this.persistentOverlay.drawFlexContainerHighlight(t)}drawScrollSnapHighlight(t){this.persistentOverlay?.drawScrollSnapHighlight(t)}drawContainerQueryHighlight(t){this.persistentOverlay?.drawContainerQueryHighlight(t)}drawIsolatedElementHighlight(t){this.persistentOverlay?.drawIsolatedElementHighlight(t)}drawAxis(t,n,e){t.save();const o=this.pageZoomFactor*this.pageScaleFactor*this.emulationScaleFactor,i=this.scrollX*this.pageScaleFactor,r=this.scrollY*this.pageScaleFactor;function s(t){return Math.round(t*o)}function a(t){return Math.round(t/o)}const l=this.canvasWidth/o,c=this.canvasHeight/o,d=50;t.save(),t.fillStyle=Wt,e?t.fillRect(0,s(c)-15,s(l),s(c)):t.fillRect(0,0,s(l),15),t.globalCompositeOperation="destination-out",t.fillStyle="red",n?t.fillRect(s(l)-15,0,s(l),s(c)):t.fillRect(0,0,15,s(c)),t.restore(),t.fillStyle=Wt,n?t.fillRect(s(l)-15,0,s(l),s(c)):t.fillRect(0,0,15,s(c)),t.lineWidth=1,t.strokeStyle=Xt,t.fillStyle=Xt;{t.save(),t.translate(-i,.5-r);const o=c+a(r);for(let e=100;ethis.window.InspectorOverlayHost.send("resume"))),r.addEventListener("click",(()=>this.window.InspectorOverlayHost.send("stepOver"))),super.install()}uninstall(){this.document.body.innerHTML="",this.document.removeEventListener("keydown",this.onKeyDown),super.uninstall()}drawPausedInDebuggerMessage(t){this.container.textContent=t}}(window,Ut),on=new class extends n{zone;constructor(t,n=[]){super(t,n),this.onMouseDown=this.onMouseDown.bind(this),this.onMouseUp=this.onMouseUp.bind(this),this.onMouseMove=this.onMouseMove.bind(this),this.onKeyDown=this.onKeyDown.bind(this)}install(){const t=this.document.documentElement;t.addEventListener("mousedown",this.onMouseDown,!0),t.addEventListener("mouseup",this.onMouseUp,!0),t.addEventListener("mousemove",this.onMouseMove,!0),t.addEventListener("keydown",this.onKeyDown,!0);const n=this.document.createElement("div");n.id="zone",this.document.body.append(n),this.zone=n,super.install()}uninstall(){this.document.body.innerHTML="";const t=this.document.documentElement;t.removeEventListener("mousedown",this.onMouseDown,!0),t.removeEventListener("mouseup",this.onMouseUp,!0),t.removeEventListener("mousemove",this.onMouseMove,!0),t.removeEventListener("keydown",this.onKeyDown,!0),super.uninstall()}onMouseDown(t){Rt={x:t.pageX,y:t.pageY},Tt=Rt,this.updateZone(),t.stopPropagation(),t.preventDefault()}onMouseUp(t){if(Rt&&Tt){const t=Ot();t.width>=5&&t.height>=5&&this.window.InspectorOverlayHost.send(t)}Qt(),this.updateZone(),t.stopPropagation(),t.preventDefault()}onMouseMove(t){Rt&&1===t.buttons?Tt={x:t.pageX,y:t.pageY}:Rt=null,this.updateZone(),t.stopPropagation(),t.preventDefault()}onKeyDown(t){Rt&&"Escape"===t.key&&(Qt(),this.updateZone(),t.stopPropagation(),t.preventDefault())}updateZone(){const t=this.zone;if(!Tt||!Rt)return void(t.style.display="none");t.style.display="block";const n=Ot();t.style.left=n.x+"px",t.style.top=n.y+"px",t.style.width=n.width+"px",t.style.height=n.height+"px"}}(window,Dt),rn=new class extends n{sourceOrderContainer;reset(t){super.reset(t),this.sourceOrderContainer.textContent=""}install(){this.document.body.classList.add("fill");const t=this.document.createElement("canvas");t.id="canvas",t.classList.add("fill"),this.document.body.append(t);const n=this.document.createElement("div");n.id="source-order-container",this.document.body.append(n),this.sourceOrderContainer=n,this.setCanvas(t),super.install()}uninstall(){this.document.body.classList.remove("fill"),this.document.body.innerHTML="",super.uninstall()}drawSourceOrder(t){const n=t.sourceOrder||0,e=t.paths.slice().pop();if(!e)throw new Error("No path provided");this.context.save();const o=C(),i=e.outlineColor;return this.context.save(),function(t,n,e,o,i,r){t.save();const s=M(n,i,r);e&&(t.strokeStyle=e,t.lineWidth=2,o||t.setLineDash([3,3]),t.stroke(s));t.restore()}(this.context,e.path,i,Boolean(n),o,this.emulationScaleFactor),this.context.restore(),this.context.save(),Boolean(n)&&this.drawSourceOrderLabel(n,i,o),this.context.restore(),{bounds:o}}drawSourceOrderLabel(t,n,o){const i=this.sourceOrderContainer,r=i.children,s=e(i,"div","source-order-label-container");s.style.color=n,s.textContent=String(t);const a=s.offsetHeight,l=function(t,n,e,o,i){let r;const s=t.minX+e>t.maxX,a=t.minY+n>t.maxY;if(!s&&!a||o.length>=Jt)return Kt.topCorner;let l=!1;for(let i=0;i=s.top,c=t.minY<=s.top+s.height&&t.minY>=s.top,d=t.minX>=s.left&&t.minX<=s.left+s.width,h=t.minX+e>=s.left&&t.minX+e<=s.left+s.width;if((d||h)&&(a||c)){l=!0;break}}t.minY-n>0&&!l?(r=Kt.aboveElement,s&&(r=Kt.aboveElementWider)):t.maxY+n{const n=t[0];if("setOverlay"===n){const n=t[1];cn&&cn.uninstall(),cn=ln[n],cn.setPlatform(dn),cn.installed||cn.install()}else"setPlatform"===n?dn=t[1]:"drawingFinished"===n||cn.dispatch(t)}}();
diff --git a/backend/app/infra/crawling_refactor/browsers/chrome-linux64/rpm.deps b/backend/app/infra/crawling_refactor/browsers/chrome-linux64/rpm.deps
new file mode 100644
index 0000000..21f405e
--- /dev/null
+++ b/backend/app/infra/crawling_refactor/browsers/chrome-linux64/rpm.deps
@@ -0,0 +1,91 @@
+ca-certificates
+ld-linux-x86-64.so.2()(64bit)
+ld-linux-x86-64.so.2(GLIBC_2.2.5)(64bit)
+ld-linux-x86-64.so.2(GLIBC_2.3)(64bit)
+libX11.so.6()(64bit)
+libXcomposite.so.1()(64bit)
+libXdamage.so.1()(64bit)
+libXext.so.6()(64bit)
+libXfixes.so.3()(64bit)
+libXrandr.so.2()(64bit)
+libasound.so.2()(64bit)
+libasound.so.2(ALSA_0.9)(64bit)
+libasound.so.2(ALSA_0.9.0rc4)(64bit)
+libatk-1.0.so.0()(64bit)
+libatk-bridge-2.0.so.0()(64bit)
+libatspi.so.0()(64bit)
+libc.so.6()(64bit)
+libc.so.6(GLIBC_2.10)(64bit)
+libc.so.6(GLIBC_2.11)(64bit)
+libc.so.6(GLIBC_2.14)(64bit)
+libc.so.6(GLIBC_2.15)(64bit)
+libc.so.6(GLIBC_2.16)(64bit)
+libc.so.6(GLIBC_2.17)(64bit)
+libc.so.6(GLIBC_2.18)(64bit)
+libc.so.6(GLIBC_2.2.5)(64bit)
+libc.so.6(GLIBC_2.25)(64bit)
+libc.so.6(GLIBC_2.3)(64bit)
+libc.so.6(GLIBC_2.3.2)(64bit)
+libc.so.6(GLIBC_2.3.3)(64bit)
+libc.so.6(GLIBC_2.3.4)(64bit)
+libc.so.6(GLIBC_2.4)(64bit)
+libc.so.6(GLIBC_2.6)(64bit)
+libc.so.6(GLIBC_2.7)(64bit)
+libc.so.6(GLIBC_2.8)(64bit)
+libc.so.6(GLIBC_2.9)(64bit)
+libcairo.so.2()(64bit)
+libcups.so.2()(64bit)
+libcurl.so.4()(64bit)
+libdbus-1.so.3()(64bit)
+libdbus-1.so.3(LIBDBUS_1_3)(64bit)
+libdl.so.2()(64bit)
+libdl.so.2(GLIBC_2.2.5)(64bit)
+liberation-fonts
+libexpat.so.1()(64bit)
+libgbm.so.1()(64bit)
+libgcc_s.so.1()(64bit)
+libgcc_s.so.1(GCC_3.0)(64bit)
+libgcc_s.so.1(GCC_3.3)(64bit)
+libgcc_s.so.1(GCC_4.0.0)(64bit)
+libgio-2.0.so.0()(64bit)
+libglib-2.0.so.0()(64bit)
+libgobject-2.0.so.0()(64bit)
+libgtk-3.so.0()(64bit)
+libm.so.6()(64bit)
+libm.so.6(GLIBC_2.2.5)(64bit)
+libnspr4.so()(64bit)
+libnss3.so()(64bit)
+libnss3.so(NSS_3.11)(64bit)
+libnss3.so(NSS_3.12)(64bit)
+libnss3.so(NSS_3.12.1)(64bit)
+libnss3.so(NSS_3.2)(64bit)
+libnss3.so(NSS_3.22)(64bit)
+libnss3.so(NSS_3.3)(64bit)
+libnss3.so(NSS_3.30)(64bit)
+libnss3.so(NSS_3.39)(64bit)
+libnss3.so(NSS_3.4)(64bit)
+libnss3.so(NSS_3.5)(64bit)
+libnss3.so(NSS_3.6)(64bit)
+libnss3.so(NSS_3.9.2)(64bit)
+libnssutil3.so()(64bit)
+libnssutil3.so(NSSUTIL_3.12.3)(64bit)
+libpango-1.0.so.0()(64bit)
+libpthread.so.0()(64bit)
+libpthread.so.0(GLIBC_2.12)(64bit)
+libpthread.so.0(GLIBC_2.2.5)(64bit)
+libpthread.so.0(GLIBC_2.3.2)(64bit)
+libpthread.so.0(GLIBC_2.3.3)(64bit)
+libpthread.so.0(GLIBC_2.3.4)(64bit)
+libsmime3.so()(64bit)
+libsmime3.so(NSS_3.10)(64bit)
+libsmime3.so(NSS_3.2)(64bit)
+libudev.so.1()(64bit)
+libudev.so.1(LIBUDEV_183)(64bit)
+libvulkan.so.1()(64bit)
+libxcb.so.1()(64bit)
+libxkbcommon.so.0()(64bit)
+libxkbcommon.so.0(V_0.5.0)(64bit)
+rpmlib(FileDigests) <= 4.6.0-1
+rtld(GNU_HASH)
+wget
+xdg-utils
diff --git a/backend/app/infra/crawling_refactor/browsers/chrome-linux64/v8_context_snapshot.bin b/backend/app/infra/crawling_refactor/browsers/chrome-linux64/v8_context_snapshot.bin
new file mode 100644
index 0000000..67d8d28
Binary files /dev/null and b/backend/app/infra/crawling_refactor/browsers/chrome-linux64/v8_context_snapshot.bin differ
diff --git a/backend/app/infra/crawling_refactor/browsers/chrome-linux64/vk_swiftshader_icd.json b/backend/app/infra/crawling_refactor/browsers/chrome-linux64/vk_swiftshader_icd.json
new file mode 100644
index 0000000..28be1f3
--- /dev/null
+++ b/backend/app/infra/crawling_refactor/browsers/chrome-linux64/vk_swiftshader_icd.json
@@ -0,0 +1 @@
+{"file_format_version": "1.0.0", "ICD": {"library_path": "./libvk_swiftshader.so", "api_version": "1.0.5"}}
\ No newline at end of file
diff --git a/backend/app/infra/crawling_refactor/browsers/chrome-linux64/xdg-mime b/backend/app/infra/crawling_refactor/browsers/chrome-linux64/xdg-mime
new file mode 100644
index 0000000..e0d9799
--- /dev/null
+++ b/backend/app/infra/crawling_refactor/browsers/chrome-linux64/xdg-mime
@@ -0,0 +1,1402 @@
+#!/bin/sh
+#---------------------------------------------
+# xdg-mime
+#
+# Utility script to manipulate MIME related information
+# on XDG compliant systems.
+#
+# Refer to the usage() function below for usage.
+#
+# Copyright 2009-2010, Fathi Boudra
+# Copyright 2009-2010, Rex Dieter
+# Copyright 2006, Kevin Krammer
+# Copyright 2006, Jeremy White
+#
+# LICENSE:
+#
+# Permission is hereby granted, free of charge, to any person obtaining a
+# copy of this software and associated documentation files (the "Software"),
+# to deal in the Software without restriction, including without limitation
+# the rights to use, copy, modify, merge, publish, distribute, sublicense,
+# and/or sell copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included
+# in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+#
+#---------------------------------------------
+
+manualpage()
+{
+cat << _MANUALPAGE
+Name
+
+xdg-mime - command line tool for querying information about file type handling
+and adding descriptions for new file types
+
+Synopsis
+
+xdg-mime query { filetype | default } ...
+
+xdg-mime default application mimetype(s)
+
+xdg-mime install [--mode mode] [--novendor] mimetypes-file
+
+xdg-mime uninstall [--mode mode] mimetypes-file
+
+xdg-mime { --help | --manual | --version }
+
+Description
+
+The xdg-mime program can be used to query information about file types and to
+add descriptions for new file types.
+
+Commands
+
+query
+
+ Returns information related to file types.
+
+ The query option is for use inside a desktop session only. It is not
+ recommended to use xdg-mime query as root.
+
+ The following queries are supported:
+
+ query filetype FILE: Returns the file type of FILE in the form of a MIME
+ type.
+
+ query default mimetype: Returns the default application that the desktop
+ environment uses for opening files of type mimetype. The default
+ application is identified by its *.desktop file.
+
+default
+
+ Ask the desktop environment to make application the default application for
+ opening files of type mimetype. An application can be made the default for
+ several file types by specifying multiple mimetypes.
+
+ application is the desktop file id of the application and has the form
+ vendor-name.desktop application must already be installed in the desktop
+ menu before it can be made the default handler. The aplication's desktop
+ file must list support for all the MIME types that it wishes to be the
+ default handler for.
+
+ Requests to make an application a default handler may be subject to system
+ policy or approval by the end-user. xdg-mime query can be used to verify
+ whether an application is the actual default handler for a specific file
+ type.
+
+ The default option is for use inside a desktop session only. It is not
+ recommended to use xdg-mime default as root.
+
+install
+ Adds the file type descriptions provided in mimetypes-file to the desktop
+ environment. mimetypes-file must be a XML file that follows the
+ freedesktop.org Shared MIME-info Database specification and that has a
+ mime-info element as its document root. For each new file type one or more
+ icons with name type-subtype must be installed with the xdg-icon-resource
+ command in the mimetypes context. For example the filetype application/
+ vnd.oasis.opendocument.text requires an icon named
+ application-vnd.oasis.opendocument.text to be installed (unless the file
+ type recommends another icon name).
+uninstall
+ Removes the file type descriptions provided in mimetypes-file and
+ previously added with xdg-mime install from the desktop environment.
+ mimetypes-file must be a XML file that follows the freedesktop.org Shared
+ MIME-info Database specification and that has a mime-info element as its
+ document root.
+
+Options
+
+--mode mode
+
+ mode can be user or system. In user mode the file is (un)installed for the
+ current user only. In system mode the file is (un)installed for all users
+ on the system. Usually only root is allowed to install in system mode.
+
+ The default is to use system mode when called by root and to use user mode
+ when called by a non-root user.
+
+--novendor
+
+ Normally, xdg-mime checks to ensure that the mimetypes-file to be installed
+ has a proper vendor prefix. This option can be used to disable that check.
+
+ A vendor prefix consists of alpha characters ([a-zA-Z]) and is terminated
+ with a dash ("-"). Companies and organizations are encouraged to use a word
+ or phrase, preferably the organizations name, for which they hold a
+ trademark as their vendor prefix. The purpose of the vendor prefix is to
+ prevent name conflicts.
+
+--help
+ Show command synopsis.
+--manual
+ Show this manualpage.
+--version
+ Show the xdg-utils version information.
+
+Environment Variables
+
+xdg-mime honours the following environment variables:
+
+XDG_UTILS_DEBUG_LEVEL
+ Setting this environment variable to a non-zero numerical value makes
+ xdg-mime do more verbose reporting on stderr. Setting a higher value
+ increases the verbosity.
+XDG_UTILS_INSTALL_MODE
+ This environment variable can be used by the user or administrator to
+ override the installation mode. Valid values are user and system.
+
+Exit Codes
+
+An exit code of 0 indicates success while a non-zero exit code indicates
+failure. The following failure codes can be returned:
+
+1
+ Error in command line syntax.
+2
+ One of the files passed on the command line did not exist.
+3
+ A required tool could not be found.
+4
+ The action failed.
+5
+ No permission to read one of the files passed on the command line.
+
+See Also
+
+xdg-icon-resource(1), xdg-desktop-menu(1)
+
+Examples
+
+xdg-mime query filetype /tmp/foobar.png
+
+Prints the MIME type of the file /tmp/foobar.png, in this case image/png
+
+xdg-mime query default image/png
+
+Prints the .desktop filename of the application which is registered to open PNG
+files.
+
+xdg-mime install shinythings-shiny.xml
+
+Adds a file type description for "shiny"-files. "shinythings-" is used as the
+vendor prefix. The file type description could look as folows.
+
+shinythings-shiny.xml:
+
+
+
+
+ Shiny new file type
+
+
+
+
+
+An icon for this new file type must also be installed, for example with:
+
+xdg-icon-resource install --context mimetypes --size 64 shiny-file-icon.png text-x-shiny
+
+_MANUALPAGE
+}
+
+usage()
+{
+cat << _USAGE
+xdg-mime - command line tool for querying information about file type handling
+and adding descriptions for new file types
+
+Synopsis
+
+xdg-mime query { filetype | default } ...
+
+xdg-mime default application mimetype(s)
+
+xdg-mime install [--mode mode] [--novendor] mimetypes-file
+
+xdg-mime uninstall [--mode mode] mimetypes-file
+
+xdg-mime { --help | --manual | --version }
+
+_USAGE
+}
+
+#@xdg-utils-common@
+
+#----------------------------------------------------------------------------
+# Common utility functions included in all XDG wrapper scripts
+#----------------------------------------------------------------------------
+
+DEBUG()
+{
+ [ -z "${XDG_UTILS_DEBUG_LEVEL}" ] && return 0;
+ [ ${XDG_UTILS_DEBUG_LEVEL} -lt $1 ] && return 0;
+ shift
+ echo "$@" >&2
+}
+
+# This handles backslashes but not quote marks.
+first_word()
+{
+ read first rest
+ echo "$first"
+}
+
+#-------------------------------------------------------------
+# map a binary to a .desktop file
+binary_to_desktop_file()
+{
+ search="${XDG_DATA_HOME:-$HOME/.local/share}:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
+ binary="`which "$1"`"
+ binary="`readlink -f "$binary"`"
+ base="`basename "$binary"`"
+ IFS=:
+ for dir in $search; do
+ unset IFS
+ [ "$dir" ] || continue
+ [ -d "$dir/applications" -o -d "$dir/applnk" ] || continue
+ for file in "$dir"/applications/*.desktop "$dir"/applications/*/*.desktop "$dir"/applnk/*.desktop "$dir"/applnk/*/*.desktop; do
+ [ -r "$file" ] || continue
+ # Check to make sure it's worth the processing.
+ grep -q "^Exec.*$base" "$file" || continue
+ # Make sure it's a visible desktop file (e.g. not "preferred-web-browser.desktop").
+ grep -Eq "^(NoDisplay|Hidden)=true" "$file" && continue
+ command="`grep -E "^Exec(\[[^]=]*])?=" "$file" | cut -d= -f 2- | first_word`"
+ command="`which "$command"`"
+ if [ x"`readlink -f "$command"`" = x"$binary" ]; then
+ # Fix any double slashes that got added path composition
+ echo "$file" | sed -e 's,//*,/,g'
+ return
+ fi
+ done
+ done
+}
+
+#-------------------------------------------------------------
+# map a .desktop file to a binary
+## FIXME: handle vendor dir case
+desktop_file_to_binary()
+{
+ search="${XDG_DATA_HOME:-$HOME/.local/share}:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
+ desktop="`basename "$1"`"
+ IFS=:
+ for dir in $search; do
+ unset IFS
+ [ "$dir" -a -d "$dir/applications" ] || continue
+ file="$dir/applications/$desktop"
+ [ -r "$file" ] || continue
+ # Remove any arguments (%F, %f, %U, %u, etc.).
+ command="`grep -E "^Exec(\[[^]=]*])?=" "$file" | cut -d= -f 2- | first_word`"
+ command="`which "$command"`"
+ readlink -f "$command"
+ return
+ done
+}
+
+#-------------------------------------------------------------
+# Exit script on successfully completing the desired operation
+
+exit_success()
+{
+ if [ $# -gt 0 ]; then
+ echo "$@"
+ echo
+ fi
+
+ exit 0
+}
+
+
+#-----------------------------------------
+# Exit script on malformed arguments, not enough arguments
+# or missing required option.
+# prints usage information
+
+exit_failure_syntax()
+{
+ if [ $# -gt 0 ]; then
+ echo "xdg-mime: $@" >&2
+ echo "Try 'xdg-mime --help' for more information." >&2
+ else
+ usage
+ echo "Use 'man xdg-mime' or 'xdg-mime --manual' for additional info."
+ fi
+
+ exit 1
+}
+
+#-------------------------------------------------------------
+# Exit script on missing file specified on command line
+
+exit_failure_file_missing()
+{
+ if [ $# -gt 0 ]; then
+ echo "xdg-mime: $@" >&2
+ fi
+
+ exit 2
+}
+
+#-------------------------------------------------------------
+# Exit script on failure to locate necessary tool applications
+
+exit_failure_operation_impossible()
+{
+ if [ $# -gt 0 ]; then
+ echo "xdg-mime: $@" >&2
+ fi
+
+ exit 3
+}
+
+#-------------------------------------------------------------
+# Exit script on failure returned by a tool application
+
+exit_failure_operation_failed()
+{
+ if [ $# -gt 0 ]; then
+ echo "xdg-mime: $@" >&2
+ fi
+
+ exit 4
+}
+
+#------------------------------------------------------------
+# Exit script on insufficient permission to read a specified file
+
+exit_failure_file_permission_read()
+{
+ if [ $# -gt 0 ]; then
+ echo "xdg-mime: $@" >&2
+ fi
+
+ exit 5
+}
+
+#------------------------------------------------------------
+# Exit script on insufficient permission to write a specified file
+
+exit_failure_file_permission_write()
+{
+ if [ $# -gt 0 ]; then
+ echo "xdg-mime: $@" >&2
+ fi
+
+ exit 6
+}
+
+check_input_file()
+{
+ if [ ! -e "$1" ]; then
+ exit_failure_file_missing "file '$1' does not exist"
+ fi
+ if [ ! -r "$1" ]; then
+ exit_failure_file_permission_read "no permission to read file '$1'"
+ fi
+}
+
+check_vendor_prefix()
+{
+ file_label="$2"
+ [ -n "$file_label" ] || file_label="filename"
+ file=`basename "$1"`
+ case "$file" in
+ [a-zA-Z]*-*)
+ return
+ ;;
+ esac
+
+ echo "xdg-mime: $file_label '$file' does not have a proper vendor prefix" >&2
+ echo 'A vendor prefix consists of alpha characters ([a-zA-Z]) and is terminated' >&2
+ echo 'with a dash ("-"). An example '"$file_label"' is '"'example-$file'" >&2
+ echo "Use --novendor to override or 'xdg-mime --manual' for additional info." >&2
+ exit 1
+}
+
+check_output_file()
+{
+ # if the file exists, check if it is writeable
+ # if it does not exists, check if we are allowed to write on the directory
+ if [ -e "$1" ]; then
+ if [ ! -w "$1" ]; then
+ exit_failure_file_permission_write "no permission to write to file '$1'"
+ fi
+ else
+ DIR=`dirname "$1"`
+ if [ ! -w "$DIR" -o ! -x "$DIR" ]; then
+ exit_failure_file_permission_write "no permission to create file '$1'"
+ fi
+ fi
+}
+
+#----------------------------------------
+# Checks for shared commands, e.g. --help
+
+check_common_commands()
+{
+ while [ $# -gt 0 ] ; do
+ parm="$1"
+ shift
+
+ case "$parm" in
+ --help)
+ usage
+ echo "Use 'man xdg-mime' or 'xdg-mime --manual' for additional info."
+ exit_success
+ ;;
+
+ --manual)
+ manualpage
+ exit_success
+ ;;
+
+ --version)
+ echo "xdg-mime 1.1.0 rc1"
+ exit_success
+ ;;
+ esac
+ done
+}
+
+check_common_commands "$@"
+
+[ -z "${XDG_UTILS_DEBUG_LEVEL}" ] && unset XDG_UTILS_DEBUG_LEVEL;
+if [ ${XDG_UTILS_DEBUG_LEVEL-0} -lt 1 ]; then
+ # Be silent
+ xdg_redirect_output=" > /dev/null 2> /dev/null"
+else
+ # All output to stderr
+ xdg_redirect_output=" >&2"
+fi
+
+#--------------------------------------
+# Checks for known desktop environments
+# set variable DE to the desktop environments name, lowercase
+
+detectDE()
+{
+ # see https://bugs.freedesktop.org/show_bug.cgi?id=34164
+ unset GREP_OPTIONS
+
+ if [ -n "${XDG_CURRENT_DESKTOP}" ]; then
+ case "${XDG_CURRENT_DESKTOP}" in
+ GNOME)
+ DE=gnome;
+ ;;
+ KDE)
+ DE=kde;
+ ;;
+ LXDE)
+ DE=lxde;
+ ;;
+ XFCE)
+ DE=xfce
+ esac
+ fi
+
+ if [ x"$DE" = x"" ]; then
+ # classic fallbacks
+ if [ x"$KDE_FULL_SESSION" = x"true" ]; then DE=kde;
+ elif [ x"$GNOME_DESKTOP_SESSION_ID" != x"" ]; then DE=gnome;
+ elif `dbus-send --print-reply --dest=org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus.GetNameOwner string:org.gnome.SessionManager > /dev/null 2>&1` ; then DE=gnome;
+ elif xprop -root _DT_SAVE_MODE 2> /dev/null | grep ' = \"xfce4\"$' >/dev/null 2>&1; then DE=xfce;
+ elif xprop -root 2> /dev/null | grep -i '^xfce_desktop_window' >/dev/null 2>&1; then DE=xfce
+ fi
+ fi
+
+ if [ x"$DE" = x"" ]; then
+ # fallback to checking $DESKTOP_SESSION
+ case "$DESKTOP_SESSION" in
+ gnome)
+ DE=gnome;
+ ;;
+ LXDE)
+ DE=lxde;
+ ;;
+ xfce|xfce4)
+ DE=xfce;
+ ;;
+ esac
+ fi
+
+ if [ x"$DE" = x"" ]; then
+ # fallback to uname output for other platforms
+ case "$(uname 2>/dev/null)" in
+ Darwin)
+ DE=darwin;
+ ;;
+ esac
+ fi
+
+ if [ x"$DE" = x"gnome" ]; then
+ # gnome-default-applications-properties is only available in GNOME 2.x
+ # but not in GNOME 3.x
+ which gnome-default-applications-properties > /dev/null 2>&1 || DE="gnome3"
+ fi
+}
+
+#----------------------------------------------------------------------------
+# kfmclient exec/openURL can give bogus exit value in KDE <= 3.5.4
+# It also always returns 1 in KDE 3.4 and earlier
+# Simply return 0 in such case
+
+kfmclient_fix_exit_code()
+{
+ version=`LC_ALL=C.UTF-8 kde-config --version 2>/dev/null | grep '^KDE'`
+ major=`echo $version | sed 's/KDE.*: \([0-9]\).*/\1/'`
+ minor=`echo $version | sed 's/KDE.*: [0-9]*\.\([0-9]\).*/\1/'`
+ release=`echo $version | sed 's/KDE.*: [0-9]*\.[0-9]*\.\([0-9]\).*/\1/'`
+ test "$major" -gt 3 && return $1
+ test "$minor" -gt 5 && return $1
+ test "$release" -gt 4 && return $1
+ return 0
+}
+
+update_mime_database()
+{
+ if [ x"$mode" = x"user" -a -n "$DISPLAY" ] ; then
+ detectDE
+ if [ x"$DE" = x"kde" ] ; then
+ DEBUG 1 "Running kbuildsycoca"
+ if [ x"$KDE_SESSION_VERSION" = x"4" ]; then
+ eval 'kbuildsycoca4'$xdg_redirect_output
+ else
+ eval 'kbuildsycoca'$xdg_redirect_output
+ fi
+ fi
+ fi
+ for x in `echo "$PATH:/opt/gnome/bin" | sed 's/:/ /g'`; do
+ if [ -x $x/update-mime-database ] ; then
+ DEBUG 1 "Running $x/update-mime-database $1"
+ eval '$x/update-mime-database $1'$xdg_redirect_output
+ return
+ fi
+ done
+}
+
+info_kde()
+{
+ if [ x"$KDE_SESSION_VERSION" = x"4" ]; then
+ DEBUG 1 "Running kmimetypefinder \"$1\""
+ kmimetypefinder "$1" 2>/dev/null | head -n 1
+ else
+ DEBUG 1 "Running kfile \"$1\""
+ kfile "$1" 2> /dev/null | head -n 1 | cut -d "(" -f 2 | cut -d ")" -f 1
+ fi
+
+ if [ $? -eq 0 ]; then
+ exit_success
+ else
+ exit_failure_operation_failed
+ fi
+}
+
+info_gnome()
+{
+ if gvfs-info --help 2>/dev/null 1>&2; then
+ DEBUG 1 "Running gvfs-info \"$1\""
+ gvfs-info "$1" 2> /dev/null | grep standard::content-type | cut -d' ' -f4
+ elif gnomevfs-info --help 2>/dev/null 1>&2; then
+ DEBUG 1 "Running gnomevfs-info \"$1\""
+ gnomevfs-info --slow-mime "$1" 2> /dev/null | grep "^MIME" | cut -d ":" -f 2 | sed s/"^ "//
+ else
+ # according to https://bugs.freedesktop.org/show_bug.cgi?id=33094#c5
+ # neither gvfs-info or gnomevfs-info are present in a default Ubuntu Natty
+ # install, so fallback to info_generic
+ info_generic "$1"
+ fi
+
+ if [ $? -eq 0 ]; then
+ exit_success
+ else
+ exit_failure_operation_failed
+ fi
+}
+
+info_generic()
+{
+ if mimetype --version >/dev/null 2>&1; then
+ DEBUG 1 "Running mimetype -b \"$1\""
+ mimetype -b "$1"
+ else
+ DEBUG 1 "Running file --mime-type \"$1\""
+ /usr/bin/file --mime-type "$1" 2> /dev/null | cut -d ":" -f 2 | sed s/"^ "//
+ fi
+
+ if [ $? -eq 0 ]; then
+ exit_success
+ else
+ exit_failure_operation_failed
+ fi
+}
+
+make_default_kde()
+{
+ # $1 is vendor-name.desktop
+ # $2 is mime/type
+ #
+ # On KDE 3, add to $KDE_CONFIG_PATH/profilerc:
+ # [$2 - 1]
+ # Application=$1
+ #
+ # Remove all [$2 - *] sections, or even better,
+ # renumber [$2 - *] sections and remove duplicate
+ #
+ # On KDE 4, add $2=$1 to $XDG_DATA_APPS/mimeapps.list
+ #
+ # Example file:
+ #
+ # [Added Associations]
+ # text/plain=kde4-kate.desktop;kde4-kwrite.desktop;
+ #
+ # [Removed Associations]
+ # text/plain=gnome-gedit.desktop;gnu-emacs.desktop;
+ vendor="$1"
+ mimetype="$2"
+ if [ x"$KDE_SESSION_VERSION" = x"4" ]; then
+ default_dir=`kde4-config --path xdgdata-apps 2> /dev/null | cut -d ':' -f 1`
+ default_file="$default_dir/mimeapps.list"
+ else
+ default_dir=`kde-config --path config 2> /dev/null | cut -d ':' -f 1`
+ default_file="$default_dir/profilerc"
+ fi
+ if [ -z "$default_dir" ]; then
+ DEBUG 2 "make_default_kde: No kde runtime detected"
+ return
+ fi
+ DEBUG 2 "make_default_kde $vendor $mimetype"
+ DEBUG 1 "Updating $default_file"
+ mkdir -p "$default_dir"
+ [ -f "$default_file" ] || touch "$default_file"
+ if [ x"$KDE_SESSION_VERSION" = x"4" ]; then
+ [ -f "$default_file" ] || touch "$default_file"
+ awk -v application="$vendor" -v mimetype="$mimetype" '
+ BEGIN {
+ prefix=mimetype "="
+ associations=0
+ found=0
+ blanks=0
+ }
+ {
+ suppress=0
+ if (index($0, "[Added Associations]") == 1) {
+ associations=1
+ } else if (index($0, "[") == 1) {
+ if (associations && !found) {
+ print prefix application
+ found=1
+ }
+ associations=0
+ } else if ($0 == "") {
+ blanks++
+ suppress=1
+ } else if (associations && index($0, prefix) == 1) {
+ value=substr($0, length(prefix) + 1, length)
+ split(value, apps, ";")
+ value=application ";"
+ count=0
+ for (i in apps) {
+ count++
+ }
+ for (i=0; i < count; i++) {
+ if (apps[i] != application && apps[i] != "") {
+ value=value apps[i] ";"
+ }
+ }
+ $0=prefix value
+ found=1
+ }
+ if (!suppress) {
+ while (blanks > 0) {
+ print ""
+ blanks--
+ }
+ print $0
+ }
+ }
+ END {
+ if (!found) {
+ if (!associations) {
+ print "[Added Associations]"
+ }
+ print prefix application
+ }
+ while (blanks > 0) {
+ print ""
+ blanks--
+ }
+ }
+' "$default_file" > "${default_file}.new" && mv "${default_file}.new" "$default_file"
+ eval 'kbuildsycoca4'$xdg_redirect_output
+ else
+ awk -v application="$vendor" -v mimetype="$mimetype" '
+ BEGIN {
+ header_start="[" mimetype " - "
+ suppress=0
+ }
+ {
+ if (index($0, header_start) == 1 )
+ suppress=1
+ else
+ if (/^\[/) { suppress=0 }
+
+ if (!suppress) {
+ print $0
+ }
+ }
+ END {
+ print ""
+ print "[" mimetype " - 1]"
+ print "Application=" application
+ print "AllowAsDefault=true"
+ print "GenericServiceType=Application"
+ print "Preference=1"
+ print "ServiceType=" mimetype
+ }
+' "$default_file" > "${default_file}.new" && mv "${default_file}.new" "$default_file"
+ fi
+}
+
+make_default_generic()
+{
+ # $1 is vendor-name.desktop
+ # $2 is mime/type
+ # Add $2=$1 to XDG_DATA_HOME/applications/mimeapps.list
+ xdg_user_dir="$XDG_DATA_HOME"
+ [ -n "$xdg_user_dir" ] || xdg_user_dir="$HOME/.local/share"
+ default_file="$xdg_user_dir/applications/mimeapps.list"
+ DEBUG 2 "make_default_generic $1 $2"
+ DEBUG 1 "Updating $default_file"
+ [ -f "$default_file" ] || touch "$default_file"
+ awk -v mimetype="$2" -v application="$1" '
+ BEGIN {
+ prefix=mimetype "="
+ indefault=0
+ added=0
+ blanks=0
+ found=0
+ }
+ {
+ suppress=0
+ if (index($0, "[Default Applications]") == 1) {
+ indefault=1
+ found=1
+ } else if (index($0, "[") == 1) {
+ if (!added && indefault) {
+ print prefix application
+ added=1
+ }
+ indefault=0
+ } else if ($0 == "") {
+ suppress=1
+ blanks++
+ } else if (indefault && !added && index($0, prefix) == 1) {
+ $0=prefix application
+ added=1
+ }
+ if (!suppress) {
+ while (blanks > 0) {
+ print ""
+ blanks--
+ }
+ print $0
+ }
+ }
+ END {
+ if (!added) {
+ if (!found) {
+ print ""
+ print "[Default Applications]"
+ }
+ print prefix application
+ }
+ while (blanks > 0) {
+ print ""
+ blanks--
+ }
+ }
+' "$default_file" > "${default_file}.new" && mv "${default_file}.new" "$default_file"
+}
+
+defapp_generic()
+{
+ MIME="$1"
+ xdg_user_dir="$XDG_DATA_HOME"
+ [ -n "$xdg_user_dir" ] || xdg_user_dir="$HOME/.local/share"
+ xdg_user_dir="$xdg_user_dir/$xdg_dir_name"
+ xdg_system_dirs="$XDG_DATA_DIRS"
+ [ -n "$xdg_system_dirs" ] || xdg_system_dirs=/usr/local/share/:/usr/share/
+
+ for x in `echo "$xdg_user_dir" | sed 's/:/ /g'`; do
+ mimeapps_list="$x/applications/mimeapps.list"
+ if [ -f "$mimeapps_list" ] ; then
+ DEBUG 2 "Checking $mimeapps_list"
+ trader_result=`awk -v mimetype="$MIME" '
+ BEGIN {
+ prefix=mimetype "="
+ indefault=0
+ found=0
+ }
+ {
+ if (index($0, "[Default Applications]") == 1) {
+ indefault=1
+ } else if (index($0, "[") == 1) {
+ indefault=0
+ } else if (!found && indefault && index($0, prefix) == 1) {
+ print substr($0, length(prefix) +1, length)
+ found=1
+ }
+ }
+' $mimeapps_list`
+ if [ -n "$trader_result" ] ; then
+ echo $trader_result
+ exit_success
+ fi
+ fi
+ done
+
+ for x in `echo "$xdg_system_dirs" | sed 's/:/ /g'`; do
+ DEBUG 2 "Checking $x/applications/defaults.list"
+ trader_result=`grep "$MIME=" $x/applications/defaults.list 2> /dev/null | cut -d '=' -f 2 | cut -d ';' -f 1`
+ if [ -n "$trader_result" ] ; then
+ echo $trader_result
+ exit_success
+ fi
+ done
+ exit_success
+}
+
+defapp_kde()
+{
+ MIME="$1"
+ if [ x"$KDE_SESSION_VERSION" = x"4" ]; then
+ KTRADER=`which ktraderclient 2> /dev/null`
+ MIMETYPE="--mimetype"
+ SERVICETYPE="--servicetype"
+ else
+ KTRADER=`which ktradertest 2> /dev/null`
+ fi
+ if [ -n "$KTRADER" ] ; then
+ DEBUG 1 "Running KDE trader query \"$MIME\" mimetype and \"Application\" servicetype"
+ trader_result=`$KTRADER $MIMETYPE "$MIME" $SERVICETYPE Application 2>/dev/null \
+ | grep DesktopEntryPath | head -n 1 | cut -d ':' -f 2 | cut -d \' -f 2`
+ if [ -n "$trader_result" ] ; then
+ basename "$trader_result"
+ exit_success
+ else
+ exit_failure_operation_failed
+ fi
+ else
+ defapp_generic "$1"
+ fi
+}
+
+[ x"$1" != x"" ] || exit_failure_syntax
+
+mode=
+action=
+filename=
+mimetype=
+
+case $1 in
+ install)
+ action=install
+ ;;
+
+ uninstall)
+ action=uninstall
+ ;;
+
+ query)
+ shift
+
+ if [ -z "$1" ] ; then
+ exit_failure_syntax "query type argument missing"
+ fi
+
+ case $1 in
+ filetype)
+ action=info
+
+ filename="$2"
+ if [ -z "$filename" ] ; then
+ exit_failure_syntax "FILE argument missing"
+ fi
+ case $filename in
+ -*)
+ exit_failure_syntax "unexpected option '$filename'"
+ ;;
+ esac
+ check_input_file "$filename"
+ filename=`readlink -f -- "$filename"`
+ ;;
+
+ default)
+ action=defapp
+ mimetype="$2"
+ if [ -z "$mimetype" ] ; then
+ exit_failure_syntax "mimetype argument missing"
+ fi
+ case $mimetype in
+ -*)
+ exit_failure_syntax "unexpected option '$mimetype'"
+ ;;
+
+ */*)
+ # Ok
+ ;;
+
+ *)
+ exit_failure_syntax "mimetype '$mimetype' is not in the form 'minor/major'"
+ ;;
+ esac
+ ;;
+
+ *)
+ exit_failure_syntax "unknown query type '$1'"
+ ;;
+ esac
+ ;;
+
+ default)
+ action=makedefault
+ shift
+
+ if [ -z "$1" ] ; then
+ exit_failure_syntax "application argument missing"
+ fi
+ case $1 in
+ -*)
+ exit_failure_syntax "unexpected option '$1'"
+ ;;
+
+ *.desktop)
+ filename="$1"
+ ;;
+
+ *)
+ exit_failure_syntax "malformed argument '$1', expected *.desktop"
+ ;;
+ esac
+ ;;
+
+ *)
+ exit_failure_syntax "unknown command '$1'"
+ ;;
+esac
+
+shift
+
+
+if [ "$action" = "makedefault" ]; then
+ if [ -z "$1" ] ; then
+ exit_failure_syntax "mimetype argument missing"
+ fi
+
+ while [ $# -gt 0 ] ; do
+ case $1 in
+ -*)
+ exit_failure_syntax "unexpected option '$1'"
+ ;;
+ esac
+ mimetype="$1"
+ shift
+
+ make_default_kde "$filename" "$mimetype"
+ make_default_generic "$filename" "$mimetype"
+ done
+ exit_success
+fi
+
+if [ "$action" = "info" ]; then
+ detectDE
+
+ if [ x"$DE" = x"" ]; then
+ if [ -x /usr/bin/file ]; then
+ DE=generic
+ fi
+ fi
+
+ case "$DE" in
+ kde)
+ info_kde "$filename"
+ ;;
+
+ gnome*)
+ info_gnome "$filename"
+ ;;
+
+ *)
+ info_generic "$filename"
+ ;;
+ esac
+ exit_failure_operation_impossible "no method available for quering MIME type of '$filename'"
+fi
+
+if [ "$action" = "defapp" ]; then
+ detectDE
+
+ case "$DE" in
+ kde)
+ defapp_kde "$mimetype"
+ ;;
+
+ *)
+ defapp_generic "$mimetype"
+ ;;
+ esac
+ exit_failure_operation_impossible "no method available for quering default application for '$mimetype'"
+fi
+
+vendor=true
+while [ $# -gt 0 ] ; do
+ parm="$1"
+ shift
+
+ case $parm in
+ --mode)
+ if [ -z "$1" ] ; then
+ exit_failure_syntax "mode argument missing for --mode"
+ fi
+ case "$1" in
+ user)
+ mode="user"
+ ;;
+
+ system)
+ mode="system"
+ ;;
+
+ *)
+ exit_failure_syntax "unknown mode '$1'"
+ ;;
+ esac
+ shift
+ ;;
+
+ --novendor)
+ vendor=false
+ ;;
+
+ -*)
+ exit_failure_syntax "unexpected option '$parm'"
+ ;;
+
+ *)
+ if [ -n "$filename" ] ; then
+ exit_failure_syntax "unexpected argument '$parm'"
+ fi
+
+ filename="$parm"
+ check_input_file "$filename"
+ ;;
+ esac
+done
+
+if [ -z "$action" ] ; then
+ exit_failure_syntax "command argument missing"
+fi
+
+if [ -n "$XDG_UTILS_INSTALL_MODE" ] ; then
+ if [ "$XDG_UTILS_INSTALL_MODE" = "system" ] ; then
+ mode="system"
+ elif [ "$XDG_UTILS_INSTALL_MODE" = "user" ] ; then
+ mode="user"
+ fi
+fi
+
+if [ -z "$mode" ] ; then
+ if [ `whoami` = "root" ] ; then
+ mode="system"
+ else
+ mode="user"
+ fi
+fi
+
+if [ -z "$filename" ] ; then
+ exit_failure_syntax "mimetypes-file argument missing"
+fi
+
+if [ "$vendor" = "true" -a "$action" = "install" ] ; then
+ check_vendor_prefix "$filename"
+fi
+
+xdg_base_dir=
+xdg_dir_name=mime/packages/
+
+xdg_user_dir="$XDG_DATA_HOME"
+[ -n "$xdg_user_dir" ] || xdg_user_dir="$HOME/.local/share"
+[ x"$mode" = x"user" ] && xdg_base_dir="$xdg_user_dir/mime"
+xdg_user_dir="$xdg_user_dir/$xdg_dir_name"
+
+xdg_system_dirs="$XDG_DATA_DIRS"
+[ -n "$xdg_system_dirs" ] || xdg_system_dirs=/usr/local/share/:/usr/share/
+for x in `echo $xdg_system_dirs | sed 's/:/ /g'`; do
+ if [ -w $x/$xdg_dir_name ] ; then
+ [ x"$mode" = x"system" ] && xdg_base_dir="$x/mime"
+ xdg_global_dir="$x/$xdg_dir_name"
+ break
+ fi
+done
+[ -w $xdg_global_dir ] || xdg_global_dir=
+DEBUG 3 "xdg_user_dir: $xdg_user_dir"
+DEBUG 3 "xdg_global_dir: $xdg_global_dir"
+
+# Find KDE3 mimelnk directory
+kde_user_dir=
+kde_global_dir=
+kde_global_dirs=`kde${KDE_SESSION_VERSION}-config --path mime 2> /dev/null`
+DEBUG 3 "kde_global_dirs: $kde_global_dirs"
+first=
+for x in `echo $kde_global_dirs | sed 's/:/ /g'` ; do
+ if [ -z "$first" ] ; then
+ first=false
+ kde_user_dir="$x"
+ elif [ -w $x ] ; then
+ kde_global_dir="$x"
+ fi
+done
+DEBUG 3 "kde_user_dir: $kde_user_dir"
+DEBUG 3 "kde_global_dir: $kde_global_dir"
+
+# TODO: Gnome legacy support
+# See http://forums.fedoraforum.org/showthread.php?t=26875
+gnome_user_dir="$HOME/.gnome/apps"
+gnome_global_dir=/usr/share/gnome/apps
+[ -w $gnome_global_dir ] || gnome_global_dir=
+DEBUG 3 "gnome_user_dir: $gnome_user_dir"
+DEBUG 3 "gnome_global_dir: $gnome_global_dir"
+
+if [ x"$mode" = x"user" ] ; then
+ xdg_dir="$xdg_user_dir"
+ kde_dir="$kde_user_dir"
+ gnome_dir="$gnome_user_dir"
+ my_umask=077
+else
+ xdg_dir="$xdg_global_dir"
+ kde_dir="$kde_global_dir"
+ gnome_dir="$gnome_global_dir"
+ my_umask=022
+ if [ -z "${xdg_dir}${kde_dir}${gnome_dir}" ] ; then
+ exit_failure_operation_impossible "No writable system mimetype directory found."
+ fi
+fi
+
+# echo "[xdg|$xdg_user_dir|$xdg_global_dir]"
+# echo "[kde|$kde_user_dir|$kde_global_dir]"
+# echo "[gnome|$gnome_user_dir|$gnome_global_dir]"
+# echo "[using|$xdg_dir|$kde_dir|$gnome_dir]"
+
+basefile=`basename "$filename"`
+#[ -z $vendor ] || basefile="$vendor-$basefile"
+
+mimetypes=
+if [ -n "$kde_dir" ] ; then
+ DEBUG 2 "KDE3 mimelnk directory found, extracting mimetypes from XML file"
+
+ mimetypes=`awk < "$filename" '
+# Strip XML comments
+BEGIN {
+ suppress=0
+}
+{
+ do
+ if (suppress) {
+ if (match($0,/-->/)) {
+ $0=substr($0,RSTART+RLENGTH)
+ suppress=0
+ }
+ else {
+ break
+ }
+ }
+ else {
+ if (match($0,//)) {
+ $0=substr($0,RSTART+RLENGTH)
+ suppress=0
+ }
+ else {
+ break
+ }
+ }
+ else {
+ if (match($0,/
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/assets/images/error.png b/frontend/src/assets/images/error.png
new file mode 100644
index 0000000..f084555
Binary files /dev/null and b/frontend/src/assets/images/error.png differ
diff --git a/frontend/src/assets/images/facebook.png b/frontend/src/assets/images/facebook.png
new file mode 100644
index 0000000..39315d0
Binary files /dev/null and b/frontend/src/assets/images/facebook.png differ
diff --git a/frontend/src/assets/images/kakao_login.png b/frontend/src/assets/images/kakao_login.png
new file mode 100644
index 0000000..c882acc
Binary files /dev/null and b/frontend/src/assets/images/kakao_login.png differ
diff --git a/frontend/src/assets/images/linkedIn.png b/frontend/src/assets/images/linkedIn.png
new file mode 100644
index 0000000..bf29427
Binary files /dev/null and b/frontend/src/assets/images/linkedIn.png differ
diff --git a/frontend/src/assets/images/logo.png b/frontend/src/assets/images/logo.png
new file mode 100644
index 0000000..9bd18e6
Binary files /dev/null and b/frontend/src/assets/images/logo.png differ
diff --git a/frontend/src/assets/images/naver.png b/frontend/src/assets/images/naver.png
new file mode 100644
index 0000000..061b76b
Binary files /dev/null and b/frontend/src/assets/images/naver.png differ
diff --git a/frontend/src/assets/images/not_value.png b/frontend/src/assets/images/not_value.png
new file mode 100644
index 0000000..7bfbe2e
Binary files /dev/null and b/frontend/src/assets/images/not_value.png differ
diff --git a/frontend/src/assets/images/toggle.png b/frontend/src/assets/images/toggle.png
new file mode 100644
index 0000000..0aba83d
Binary files /dev/null and b/frontend/src/assets/images/toggle.png differ
diff --git a/frontend/src/assets/images/youtube.png b/frontend/src/assets/images/youtube.png
new file mode 100644
index 0000000..84b9038
Binary files /dev/null and b/frontend/src/assets/images/youtube.png differ
diff --git a/frontend/src/components/CheckBox/CheckBox.module.css b/frontend/src/components/CheckBox/CheckBox.module.css
new file mode 100644
index 0000000..31228eb
--- /dev/null
+++ b/frontend/src/components/CheckBox/CheckBox.module.css
@@ -0,0 +1,66 @@
+.wrapper {
+ display: inline-flex;
+ align-items: center;
+ cursor: pointer;
+}
+
+.input {
+ display: none;
+}
+
+.box {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px solid rgba(0, 18, 39, 0.5);
+ transition: border-color 0.3s ease-in-out;
+ position: relative;
+ padding: 0; /* 중앙 정렬 정확도 높이기 */
+}
+
+/* 네모 */
+.square {
+ width: 1rem;
+ height: 1rem;
+ border-radius: 4px;
+}
+
+/* 원 */
+.circle {
+ width: 1.25rem;
+ height: 1.25rem;
+ border-radius: 50%;
+}
+
+.checked .box {
+ border-color: #0D52FF;
+}
+
+/* 체크 아이콘 (네모 전용) */
+.checkmark {
+ width: 1rem;
+ height: 1rem;
+ stroke-dasharray: 24;
+ stroke-dashoffset: 24;
+ transition: stroke-dashoffset 0.3s ease-in-out, opacity 0.2s ease;
+ opacity: 0;
+}
+
+.checked .checkmark {
+ stroke-dashoffset: 0;
+ opacity: 1;
+}
+
+/* 내부 원 (라디오 전용) */
+.innerDot {
+ width: 0.5rem;
+ height: 0.5rem;
+ background-color: #0D52FF;
+ border-radius: 50%;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+}
+
+.checked .innerDot {
+ opacity: 1;
+}
diff --git a/frontend/src/components/CheckBox/CheckBox.tsx b/frontend/src/components/CheckBox/CheckBox.tsx
new file mode 100644
index 0000000..15a12ed
--- /dev/null
+++ b/frontend/src/components/CheckBox/CheckBox.tsx
@@ -0,0 +1,36 @@
+// CheckBox.tsx
+import React from 'react';
+import styles from './CheckBox.module.css';
+
+interface CheckBoxProps {
+ checked: boolean;
+ type?: 'square' | 'circle';
+}
+
+const CheckBox: React.FC = ({ checked, type = 'square' }) => {
+ return (
+
+
+ {type === 'square' ? (
+
+
+
+ ) : (
+ // circle일 때: 내부 원 (radio 스타일)
+
+ )}
+
+
+ );
+};
+
+export default CheckBox;
diff --git a/frontend/src/components/CheckBox/index.tsx b/frontend/src/components/CheckBox/index.tsx
new file mode 100644
index 0000000..fc07339
--- /dev/null
+++ b/frontend/src/components/CheckBox/index.tsx
@@ -0,0 +1 @@
+export { default } from "./CheckBox";
\ No newline at end of file
diff --git a/frontend/src/components/Divider/Divider.module.css b/frontend/src/components/Divider/Divider.module.css
new file mode 100644
index 0000000..7794e27
--- /dev/null
+++ b/frontend/src/components/Divider/Divider.module.css
@@ -0,0 +1,19 @@
+.divider{
+ display: flex;
+ width: 100%;
+ justify-content: center;
+ align-items: center;
+ gap: 0.625rem;
+ box-sizing: border-box;
+}
+.dividerLine{
+ width: 100%;
+ height: 1px;
+ background: #E2E2E2;
+}
+.dividerText{
+ color: #001227;
+ font-size: 0.875rem;
+ font-weight: 500;
+ white-space: nowrap;
+}
\ No newline at end of file
diff --git a/frontend/src/components/Divider/Divider.tsx b/frontend/src/components/Divider/Divider.tsx
new file mode 100644
index 0000000..f652ebd
--- /dev/null
+++ b/frontend/src/components/Divider/Divider.tsx
@@ -0,0 +1,13 @@
+import styles from "./Divider.module.css";
+
+const Divider: React.FC = () => {
+ return (
+
+ );
+};
+
+export default Divider;
\ No newline at end of file
diff --git a/frontend/src/components/Divider/index.tsx b/frontend/src/components/Divider/index.tsx
new file mode 100644
index 0000000..02512bb
--- /dev/null
+++ b/frontend/src/components/Divider/index.tsx
@@ -0,0 +1 @@
+export { default } from "./Divider";
\ No newline at end of file
diff --git a/frontend/src/components/ErrorPopUp/ErrorPopUp.module.css b/frontend/src/components/ErrorPopUp/ErrorPopUp.module.css
new file mode 100644
index 0000000..9e5129f
--- /dev/null
+++ b/frontend/src/components/ErrorPopUp/ErrorPopUp.module.css
@@ -0,0 +1,83 @@
+.container {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(28, 28, 28, 0.60);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+/* Content */
+.content {
+ width: 27.75rem;
+ display: inline-flex;
+ padding: 1.875rem;
+ flex-direction: column;
+ align-items: center;
+ border-radius: 0.5rem;
+ border: 1px solid #000;
+ background: #FFF;
+ border: none;
+}
+.errorIcon {
+ width: 5.625rem;
+ height: 5.625rem;
+ margin-bottom: 1.12rem;
+}
+
+.title {
+ color: #001227;
+ font-size: 2.25rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ margin-top: 1.12rem;
+ margin-bottom: 1.12rem;
+}
+.description {
+ color: rgba(0, 18, 39, 0.50);
+ text-align: center;
+ font-size: 1.3125rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ white-space: pre-line !important;
+ word-wrap: break-word;
+ margin-bottom: 2.5rem;
+}
+
+/* Button Container */
+.buttonContainer {
+ display: flex;
+ width: 90%;
+ justify-content: center;
+}
+.multipleButtons {
+ display: flex;
+ width: 90%;
+ justify-content: space-between;
+}
+
+/* Button */
+.button {
+ width: 9.5rem;
+ height: 3.5rem;
+ display: inline-flex;
+ padding: 1.125rem 3.75rem;
+ justify-content: center;
+ align-items: center;
+ border-radius: 1.125rem;
+ border: none;
+
+ color: #FFF;
+ font-size: 1.125rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ white-space: nowrap;
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/frontend/src/components/ErrorPopUp/ErrorPopUp.tsx b/frontend/src/components/ErrorPopUp/ErrorPopUp.tsx
new file mode 100644
index 0000000..990eb2d
--- /dev/null
+++ b/frontend/src/components/ErrorPopUp/ErrorPopUp.tsx
@@ -0,0 +1,88 @@
+import React from "react";
+import styles from "./ErrorPopUp.module.css";
+import errorIcon from "@/assets/images/error.png";
+
+interface ButtonConfig {
+ text: string;
+ onClick: () => void;
+ buttonColor?: string;
+}
+
+interface ErrorPopUpProps {
+ isError: boolean;
+ title: string;
+ description: string[];
+ buttons: ButtonConfig[]; // 버튼 배열
+ setIsErrorPopUpOpen?: (isErrorPopUpOpen: boolean) => void; // 기본 닫기 기능용 (선택사항)
+}
+
+export const ErrorPopUp: React.FC = ({
+ isError,
+ title,
+ description,
+ buttons,
+ setIsErrorPopUpOpen
+}) => {
+ // 기본 닫기 핸들러 (버튼이 없을 때의 fallback)
+ const handleDefaultClose = () => {
+ if (setIsErrorPopUpOpen) {
+ setIsErrorPopUpOpen(false);
+ }
+ };
+
+ const ErrorIcon = () => {
+ return (
+
+
+
+
+
+
+
+
+ )
+ }
+ const AlertIcon = () => {
+ return (
+
+
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+ {isError ?
:
}
+
{title}
+
+ {description.map((line, index, array) => (
+
+ {line}
+ {index < array.length - 1 && }
+
+ ))}
+
+
1 ? styles.multipleButtons : ""}`}>
+ {buttons.map((button, index) => (
+
+ {button.text}
+
+ ))}
+
+
+
+ );
+};
+
+export default ErrorPopUp;
\ No newline at end of file
diff --git a/frontend/src/components/ErrorPopUp/index.tsx b/frontend/src/components/ErrorPopUp/index.tsx
new file mode 100644
index 0000000..a9224e8
--- /dev/null
+++ b/frontend/src/components/ErrorPopUp/index.tsx
@@ -0,0 +1 @@
+export { default } from "./ErrorPopUp";
\ No newline at end of file
diff --git a/frontend/src/components/Footer/Footer.module.css b/frontend/src/components/Footer/Footer.module.css
new file mode 100644
index 0000000..2261190
--- /dev/null
+++ b/frontend/src/components/Footer/Footer.module.css
@@ -0,0 +1,124 @@
+/* Footer */
+.footer {
+ background-color: var(--content1, #ffffff);
+ border-top: 1px solid var(--divider, #e2e8f0);
+ padding: 1.5rem 11.25rem;
+}
+
+/* Container */
+/* .container {
+ max-width: 72rem;
+ margin: 0 auto;
+ padding: 0 1rem;
+} */
+
+/* Grid */
+.grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ row-gap: 1.5rem; /* 수직 간격 */
+ column-gap: 6.12rem; /* 수평 간격 */
+}
+@media (min-width: 940px) {
+ .grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+@media (min-width: 1150px) {
+ .grid {
+ grid-template-columns: repeat(4, 1fr);
+ }
+}
+
+/* First Section */
+.firstSection{
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+.logoContainer{
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ width: 120px;
+ height: 49px;
+}
+.logo {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.description {
+ color: var(--foreground-500, #64748b);
+ font-size: 0.875rem;
+ line-height: 1.4;
+}
+
+/* Second Section */
+.secondSection{
+ display: flex;
+ flex-direction: column;
+ gap: 0.81rem;
+}
+.sectionTitle{
+ color: #001227;
+ font-size: 1.25rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ word-break: keep-all;
+}
+.link{
+ color: #71717A;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ word-break: keep-all;
+}
+.link:hover{
+ text-decoration: underline;
+}
+/* Third Section */
+.thirdSection{
+ display: flex;
+ flex-direction: column;
+ gap: 0.81rem;
+}
+
+
+/* Fourth Section */
+.fourthSection{
+ display: flex;
+ flex-direction: column;
+ gap: 0.81rem;
+}
+.contactItem{
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+.contactText{
+ color: #71717A;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+.socialLinks{
+ display: flex;
+ align-items: center;
+ gap: 1.25rem;
+}
+
+/* Copyright */
+.copyright {
+ border-top: 1px solid var(--divider, #e2e8f0);
+ margin-top: 2.38rem;
+ padding-top: 1.56rem;
+ text-align: center;
+ font-size: 0.875rem;
+ color: var(--foreground-500, #64748b);
+}
\ No newline at end of file
diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx
new file mode 100644
index 0000000..1b88248
--- /dev/null
+++ b/frontend/src/components/Footer/Footer.tsx
@@ -0,0 +1,90 @@
+import React, { useState } from "react";
+import styles from "./Footer.module.css";
+import logo from '../../assets/images/ADO2_Logo.svg';
+import mail from '../../assets/icons/mail.svg';
+import call from '../../assets/icons/call.svg';
+import linkedIn from '../../assets/images/linkedIn.png';
+import facebook from '../../assets/images/facebook.png';
+import youtube from '../../assets/images/youtube.png';
+import TermsOfService from "../Policies/TermsOfService/TermsOfService";
+import PrivacyPolicy from "../Policies/PrivacyPolicy/PrivacyPolicy";
+
+export const Footer: React.FC = () => {
+ const [isTermsOfServiceOpen, setIsTermsOfServiceOpen] = useState(false);
+ const [isPrivacyPolicyOpen, setIsPrivacyPolicyOpen] = useState(false);
+
+ const handleTermsOfServiceClick = () => {
+ setIsTermsOfServiceOpen(!isTermsOfServiceOpen);
+ }
+
+ const handlePrivacyPolicyClick = () => {
+ setIsPrivacyPolicyOpen(!isPrivacyPolicyOpen);
+ }
+
+ return (
+
+ {isTermsOfServiceOpen && setIsTermsOfServiceOpen(false)} buttonCount={1} />}
+ {isPrivacyPolicyOpen && setIsPrivacyPolicyOpen(false)} buttonCount={1} />}
+
+
+
+
+
+
+
+ AI 기술로 홍보영상과
+ 음악을 자동으로 생성하는 서비스입니다.
+
+
+
+
+
+
+
+
+
연락처
+
+
+
+
o2oteam@o2o.kr
+
+
+
+
+
070-4260-8310
+
+
+
+
+
+
+
+ © 2025 에이아이오투오 All rights reserved.
+
+
+
+ );
+};
+
+export default Footer;
\ No newline at end of file
diff --git a/frontend/src/components/Footer/index.tsx b/frontend/src/components/Footer/index.tsx
new file mode 100644
index 0000000..b12f605
--- /dev/null
+++ b/frontend/src/components/Footer/index.tsx
@@ -0,0 +1 @@
+export { default } from './Footer';
\ No newline at end of file
diff --git a/frontend/src/components/Header/Header.module.css b/frontend/src/components/Header/Header.module.css
new file mode 100644
index 0000000..1beec2a
--- /dev/null
+++ b/frontend/src/components/Header/Header.module.css
@@ -0,0 +1,101 @@
+.container {
+ display: flex;
+ align-items: center;
+ padding: 16px 9.69rem;
+ background-color: #ffffff;
+ border-bottom: 1px solid #e5e7eb;
+}
+.logoContainer{
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ width: 160px;
+ height: 49px;
+ cursor: pointer;
+}
+
+.logo {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+.content {
+ display: flex;
+ align-items: center;
+ white-space: nowrap;
+ flex-wrap: nowrap;
+ margin-left: auto;
+}
+
+.name,
+.myPage,
+.login,
+.logout {
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ white-space: nowrap;
+}
+
+.start {
+ margin-left: 2.29rem;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ cursor: pointer;
+ position: relative;
+ display: inline-block;
+ color: #001227;
+ transition: all 0.4s ease;
+ padding-bottom: 4px;
+ margin-top: 4px;
+ white-space: nowrap;
+ word-break: keep-all;
+}
+
+.start::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ width: 0;
+ height: 2px;
+ background: linear-gradient(90deg, rgba(192, 56, 255, 0.80) 0%, rgba(9, 210, 255, 0.80) 100%);
+ transition: all 0.4s ease;
+ transform: translateX(-50%);
+}
+
+.start:hover {
+ background: linear-gradient(90deg, rgba(192, 56, 255, 0.80) 0%, rgba(9, 210, 255, 0.80) 100%);
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+
+.start:hover::after {
+ width: 100%;
+}
+
+
+.icon{
+ margin-right: 0.5rem;
+}
+.name{
+ margin-right: 1.5rem;
+}
+.myPage{
+ margin-right: 1.5rem;
+ cursor: pointer;
+}
+.logout{
+ cursor: pointer;
+}
+.login{
+ margin-right: 2.25rem;
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx
new file mode 100644
index 0000000..b4f2fb4
--- /dev/null
+++ b/frontend/src/components/Header/Header.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import styles from './Header.module.css';
+import logo from '../../assets/images/ADO2_Logo.svg';
+import { useNavigate } from 'react-router-dom';
+import { useRecoilValue } from "recoil";
+import { authAtom } from "@/recoil/Atoms/authAtom";
+import { useLogout } from "@/hooks/useLogout";
+import Icon from '../../assets/icons/me.svg';
+
+const Header: React.FC = () => {
+ const navigate = useNavigate();
+ const authState = useRecoilValue(authAtom);
+
+ const goToHome = () => {
+ localStorage.removeItem("task_id");
+ localStorage.removeItem("order_id");
+ navigate('/');
+ if(localStorage.getItem("currentStep") === "2"){
+ localStorage.removeItem("currentStep");
+ }
+ }
+ const goToLogin = () => navigate('/login');
+ const goToMyPage = () => navigate('/mypage');
+ const goToStart = () => {
+ localStorage.removeItem("task_id");
+ localStorage.removeItem("order_id");
+ localStorage.removeItem("currentStep");
+ navigate('/process');
+ window.location.reload();
+ }
+ const goToLogout = useLogout();
+
+ return (
+
+
+
+
+
새 동영상 만들기
+
+ {authState.isLoggedIn ? (
+ <>
+
+
{authState.name}님
+
마이페이지
+
goToLogout()}>로그아웃
+ >
+ ) : (
+
로그인
+ )}
+
+
+ );
+}
+
+export default Header;
diff --git a/frontend/src/components/Header/index.tsx b/frontend/src/components/Header/index.tsx
new file mode 100644
index 0000000..579f1ac
--- /dev/null
+++ b/frontend/src/components/Header/index.tsx
@@ -0,0 +1 @@
+export { default } from './Header';
diff --git a/frontend/src/components/LabeledInput/LabeledInput.module.css b/frontend/src/components/LabeledInput/LabeledInput.module.css
new file mode 100644
index 0000000..c0caae5
--- /dev/null
+++ b/frontend/src/components/LabeledInput/LabeledInput.module.css
@@ -0,0 +1,93 @@
+.inputContainer{
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ width: 100%;
+ height: 5.19rem;
+}
+
+.inputGroup {
+ display: flex;
+ width: 100%;
+ padding: 0.75rem;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.25rem;
+ box-sizing: border-box;
+ border-radius: 0.75rem;
+ border: 1px solid rgba(0, 18, 39, 0.50);
+ background: #FFF;
+ cursor: text;
+}
+
+/* .inputGroup:hover {
+ background: #d1d5db;
+} */
+
+.inputLabel {
+ display: flex;
+ flex-direction: row;
+ gap: 0.25rem;
+}
+
+.labelText {
+ color: #001227;
+ font-size: 0.75rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+}
+
+.required {
+ font-size: 0.75rem;
+ font-weight: 400;
+ color: #FF0000;
+}
+
+.inputWrapper {
+ display: flex;
+ width: 100%;
+ flex-direction: row;
+ gap: 0.25rem;
+ align-items: center;
+}
+
+.inputIcon {
+ width: 1rem;
+ height: 1rem;
+}
+
+.input {
+ border: none;
+ background: transparent;
+ outline: none;
+ width: 100%;
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+}
+.input::placeholder {
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+}
+
+
+.errorMessage{
+ color: #FF1E1E;
+ font-size: 0.75rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+}
+
+.eyeIcon{
+ width: 1rem;
+ height: 1rem;
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/frontend/src/components/LabeledInput/LabeledInput.tsx b/frontend/src/components/LabeledInput/LabeledInput.tsx
new file mode 100644
index 0000000..028f105
--- /dev/null
+++ b/frontend/src/components/LabeledInput/LabeledInput.tsx
@@ -0,0 +1,90 @@
+import React, { useRef, useEffect, useState } from "react";
+import styles from "./LabeledInput.module.css";
+import eyeOn from "@/assets/icons/eye_on.svg";
+import eyeOff from "@/assets/icons/eye_off.svg";
+
+interface LabeledInputProps {
+ label: string;
+ required?: boolean;
+ icon?: React.ReactNode;
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ type?: string;
+ autoComplete?: string;
+ errorMessage?: string;
+ passwordVisible?: boolean;
+ onErrorClear?: () => void;
+}
+
+const LabeledInput: React.FC = ({
+ label,
+ required = false,
+ icon,
+ value,
+ onChange,
+ placeholder,
+ type = "text",
+ errorMessage,
+ passwordVisible,
+ onErrorClear,
+}) => {
+ const inputRef = useRef(null);
+ const [isPasswordVisible, setIsPasswordVisible] = useState(false);
+
+ const inputType =
+ type === "password" ? (isPasswordVisible ? "text" : "password") : type;
+
+ const handleInputChange = (newValue: string) => {
+ onChange(newValue);
+ // 값이 변경될 때 에러 메시지 초기화
+ if (onErrorClear) {
+ onErrorClear();
+ }
+ };
+
+ return (
+
+
inputRef.current?.focus()}
+ >
+
+
{label}
+ {required &&
*
}
+
+
+
+ {icon &&
{icon}
}
+
+
handleInputChange(e.target.value)}
+ />
+
+ {passwordVisible && type === "password" && (
+
{
+ e.stopPropagation();
+ setIsPasswordVisible((prev) => !prev);
+ }}
+ />
+ )}
+
+
+
+ {errorMessage && (
+
{errorMessage}
+ )}
+
+ );
+};
+
+export default LabeledInput;
diff --git a/frontend/src/components/LabeledInput/index.tsx b/frontend/src/components/LabeledInput/index.tsx
new file mode 100644
index 0000000..59bdef2
--- /dev/null
+++ b/frontend/src/components/LabeledInput/index.tsx
@@ -0,0 +1 @@
+export { default } from "./LabeledInput";
\ No newline at end of file
diff --git a/frontend/src/components/MenuTab/MenuTab.module.css b/frontend/src/components/MenuTab/MenuTab.module.css
new file mode 100644
index 0000000..b63de1b
--- /dev/null
+++ b/frontend/src/components/MenuTab/MenuTab.module.css
@@ -0,0 +1,67 @@
+.wrapper {
+ display: flex;
+ width: fit-content;
+ padding: 0.25rem;
+ gap: 0.25rem;
+ box-sizing: border-box;
+ border-radius: 0.75rem;
+ background: #F1F1F1;
+}
+
+.container {
+ display: flex;
+ position: relative;
+ border-radius: 0.75rem;
+ box-sizing: border-box;
+}
+
+.tab {
+ /* Layout & Display */
+ position: relative;
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ /* Box Model */
+ padding: 0.625rem;
+ box-sizing: border-box;
+
+ /* Typography */
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ text-align: center;
+ white-space: nowrap;
+
+ /* Visual */
+ color: #001227;
+
+ /* Interaction */
+ cursor: pointer;
+ transition: color 0.3s;
+
+ /* Layer */
+ z-index: 2;
+}
+
+.active {
+ color: #FFF;
+}
+
+.slider {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ border-radius: 0.5rem;
+ background: #4EA3FF;
+ transition: left 0.3s ease, width 0.3s ease;
+ z-index: 1;
+ box-sizing: border-box;
+ opacity: 0; /* 초기에 숨김 */
+}
+
+.slider.ready {
+ opacity: 1; /* 레이아웃 계산 완료 후 표시 */
+}
\ No newline at end of file
diff --git a/frontend/src/components/MenuTab/MenuTab.tsx b/frontend/src/components/MenuTab/MenuTab.tsx
new file mode 100644
index 0000000..0e5f815
--- /dev/null
+++ b/frontend/src/components/MenuTab/MenuTab.tsx
@@ -0,0 +1,65 @@
+import React, { useState, useRef, useEffect } from 'react';
+import styles from './MenuTab.module.css';
+
+interface MenuTabProps {
+ value: string[];
+ onSelect?: (index: number) => void;
+}
+
+const MenuTab: React.FC = ({ value, onSelect }) => {
+ const [activeIndex, setActiveIndex] = useState(0);
+ const [sliderStyle, setSliderStyle] = useState({ left: 0, width: 0 });
+ const [isReady, setIsReady] = useState(false);
+ const tabsRef = useRef<(HTMLDivElement | null)[]>([]);
+
+ useEffect(() => {
+ const updateSlider = () => {
+ const activeTab = tabsRef.current[activeIndex];
+ if (activeTab) {
+ setSliderStyle({
+ left: activeTab.offsetLeft,
+ width: activeTab.clientWidth,
+ });
+ setIsReady(true);
+ }
+ };
+
+ // 레이아웃 완료를 보장하기 위해 다음 프레임에서 실행
+ requestAnimationFrame(() => {
+ requestAnimationFrame(updateSlider);
+ });
+ }, [activeIndex, value]);
+
+ const handleClick = (index: number) => {
+ setActiveIndex(index);
+ if (onSelect) onSelect(index);
+ };
+
+ return (
+
+
+
+ {value.map((label, index) => (
+
{
+ tabsRef.current[index] = el;
+ }}
+ className={`${styles.tab} ${activeIndex === index ? styles.active : ''}`}
+ onClick={() => handleClick(index)}
+ >
+ {label}
+
+ ))}
+
+
+ );
+};
+
+export default MenuTab;
\ No newline at end of file
diff --git a/frontend/src/components/MenuTab/index.tsx b/frontend/src/components/MenuTab/index.tsx
new file mode 100644
index 0000000..48656eb
--- /dev/null
+++ b/frontend/src/components/MenuTab/index.tsx
@@ -0,0 +1 @@
+export { default } from './MenuTab';
\ No newline at end of file
diff --git a/frontend/src/components/NotValue/NotValue.module.css b/frontend/src/components/NotValue/NotValue.module.css
new file mode 100644
index 0000000..7c7a600
--- /dev/null
+++ b/frontend/src/components/NotValue/NotValue.module.css
@@ -0,0 +1,24 @@
+.container{
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ gap: 2.5rem;
+ flex: 1;
+}
+
+.image{
+ width: 6.25rem;
+ height: 7.5rem;
+ aspect-ratio: 5/6;
+}
+
+.text{
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 1.5rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
diff --git a/frontend/src/components/NotValue/NotValue.tsx b/frontend/src/components/NotValue/NotValue.tsx
new file mode 100644
index 0000000..c105647
--- /dev/null
+++ b/frontend/src/components/NotValue/NotValue.tsx
@@ -0,0 +1,18 @@
+import React from "react";
+import styles from "./NotValue.module.css";
+import notValue from "@/assets/images/not_value.png";
+
+interface NotValueProps {
+ text: string;
+}
+
+const NotValue:React.FC = ({text}) => {
+ return (
+
+
+
{text}
+
+ )
+}
+
+export default NotValue;
\ No newline at end of file
diff --git a/frontend/src/components/NotValue/index.tsx b/frontend/src/components/NotValue/index.tsx
new file mode 100644
index 0000000..0d298ae
--- /dev/null
+++ b/frontend/src/components/NotValue/index.tsx
@@ -0,0 +1 @@
+export { default } from "./NotValue";
\ No newline at end of file
diff --git a/frontend/src/components/Policies/MarketingConsent/MarketingConsent.module.css b/frontend/src/components/Policies/MarketingConsent/MarketingConsent.module.css
new file mode 100644
index 0000000..f12eefb
--- /dev/null
+++ b/frontend/src/components/Policies/MarketingConsent/MarketingConsent.module.css
@@ -0,0 +1,91 @@
+.container {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(28, 28, 28, 0.60);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+/* Box Container */
+.boxContainer {
+ display: flex;
+ flex-direction: column;
+ width: 27.75rem;
+ height: auto;
+ padding: 2.5rem;
+ border-radius: 0.75rem;
+ background: #FFF;
+ border: none;
+}
+
+/* header */
+.header {
+ color: #001227;
+ font-size: 1.5rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ margin-bottom: 1.25rem;
+}
+
+/* Content Container */
+.contentContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.text {
+ color: #000;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ word-break: keep-all;
+}
+
+/* Bold Text Container */
+.boldTextContainer {
+ display: flex;
+}
+
+.boldText {
+ color: #000;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ word-break: keep-all;
+}
+
+/* Button Container */
+.buttonContainer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 3.75rem;
+}
+
+.button {
+ display: flex;
+ padding: 1.125rem 3.75rem;
+ justify-content: center;
+ align-items: center;
+ gap: 1.125rem;
+ border-radius: 1.125rem;
+ background: #B8B5FF;
+ border: none;
+
+ color: #FFF;
+ font-size: 1.125rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/frontend/src/components/Policies/MarketingConsent/MarketingConsent.tsx b/frontend/src/components/Policies/MarketingConsent/MarketingConsent.tsx
new file mode 100644
index 0000000..a1ea570
--- /dev/null
+++ b/frontend/src/components/Policies/MarketingConsent/MarketingConsent.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import styles from './MarketingConsent.module.css';
+
+interface MarketingConsentProps {
+ setOK: (ok: boolean) => void;
+ setIsMarketingConsentOpen: (isMarketingConsentOpen: boolean) => void;
+}
+
+const MarketingConsent:React.FC = ({setIsMarketingConsentOpen, setOK}) => {
+ const handleClose = () => {
+ setIsMarketingConsentOpen(false);
+ }
+
+ const handleOK = () => {
+ setOK(true);
+ setIsMarketingConsentOpen(false);
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
광고성 정보 수신
+
+
+
귀하께서 제공하신 개인정보는 회사의 상품·서비스에 대한 이벤트, 혜택, 프로모션 안내 등 광고성 정보 전달을 목적으로 활용될 수 있습니다.
+
광고성 정보는 문자(SMS), 이메일, 서비스 알림 등 다양한 수단을 통해 발송될 수 있으며, 동의를 거부하실 수 있습니다. 다만, 동의하지 않으시는 경우 이벤트·혜택 안내 등의 제공이 제한될 수 있습니다.
+
+
+
- 수집 항목: 성명, 연락처(휴대전화번호, 이메일), 알림 수신 여부 등
+
+
+
- 이용 목적: 신상품 안내, 이벤트·혜택 정보 제공, 설문조사, 사전 알림 등
+
+
+
- 전송 방법: 문자, 이메일, 서비스 내 알림 등
+
+
+
- 보유 기간: 수신 동의 철회 또는 회원 탈퇴 시까지
+
+
+
+ 닫기
+ 확인
+
+
+
+ );
+};
+
+export default MarketingConsent;
\ No newline at end of file
diff --git a/frontend/src/components/Policies/MarketingConsent/index.tsx b/frontend/src/components/Policies/MarketingConsent/index.tsx
new file mode 100644
index 0000000..27ac0d3
--- /dev/null
+++ b/frontend/src/components/Policies/MarketingConsent/index.tsx
@@ -0,0 +1 @@
+export { default } from './MarketingConsent';
\ No newline at end of file
diff --git a/frontend/src/components/Policies/PrivacyPolicy/PrivacyPolicy.module.css b/frontend/src/components/Policies/PrivacyPolicy/PrivacyPolicy.module.css
new file mode 100644
index 0000000..268f2a3
--- /dev/null
+++ b/frontend/src/components/Policies/PrivacyPolicy/PrivacyPolicy.module.css
@@ -0,0 +1,88 @@
+.container {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(28, 28, 28, 0.60);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+/* Box Container */
+.boxContainer {
+ display: flex;
+ flex-direction: column;
+ width: 27.75rem;
+ height: auto;
+ padding: 2.5rem;
+ border-radius: 0.75rem;
+ background: #FFF;
+ border: none;
+}
+
+/* Title */
+.header {
+ color: #001227;
+ font-size: 1.5rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ margin-bottom: 1.25rem;
+}
+
+.contentContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+ height: 25rem;
+ overflow-y: auto;
+ scrollbar-width: thin;
+}
+
+.title {
+ color: #000;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+
+.text {
+ color: #000;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ word-break: keep-all;
+}
+
+/* Button Container */
+.buttonContainer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 3.75rem;
+}
+
+.button {
+ display: flex;
+ /* padding: 12px 25px; */
+ padding: 18px 60px;
+ justify-content: center;
+ align-items: center;
+ gap: 1.125rem;
+ border-radius: 1.125rem;
+ background: #B8B5FF;
+ border: none;
+
+ color: #FFF;
+ font-size: 1.125rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/frontend/src/components/Policies/PrivacyPolicy/PrivacyPolicy.tsx b/frontend/src/components/Policies/PrivacyPolicy/PrivacyPolicy.tsx
new file mode 100644
index 0000000..9402f3c
--- /dev/null
+++ b/frontend/src/components/Policies/PrivacyPolicy/PrivacyPolicy.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import styles from './PrivacyPolicy.module.css';
+
+interface PrivacyPolicyProps {
+ setOK: (ok: boolean) => void;
+ setIsPrivacyPolicyOpen: (isPrivacyPolicyOpen: boolean) => void;
+ buttonCount: number;
+}
+
+const PrivacyPolicy: React.FC = ({ setIsPrivacyPolicyOpen, setOK, buttonCount }) => {
+ const handleClose = () => {
+ setIsPrivacyPolicyOpen(false);
+ }
+
+ const handleOK = () => {
+ setOK(true);
+ setIsPrivacyPolicyOpen(false);
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
개인정보처리방침
+
+
+
1. 개인정보의 수집 및 이용 목적
+
회사는 다음의 목적을 위하여 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 개인정보 보호법 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.
+
회원 가입 및 관리: 회원 가입의사 확인, 회원제 서비스 제공에 따른 본인 식별·인증, 회원자격 유지·관리, 서비스 부정이용 방지, 각종 고지·통지 등을 목적으로 개인정보를 처리합니다.
+
서비스 제공: 콘텐츠 제공, 맞춤서비스 제공, 서비스 개선 등을 목적으로 개인정보를 처리합니다.
+
마케팅 및 광고에의 활용: 신규 서비스 개발 및 맞춤 서비스 제공, 이벤트 및 광고성 정보 제공 및 참여기회 제공, 서비스의 유효성 확인, 접속빈도 파악 또는 회원의 서비스 이용에 대한 통계 등을 목적으로 개인정보를 처리합니다.
+
+
2. 수집하는 개인정보의 항목
+
회사는 회원가입, 상담, 서비스 신청 등을 위해 아래와 같은 개인정보를 수집하고 있습니다.
+
수집항목: 이름, 이메일 주소, 비밀번호, 서비스 이용 기록, 접속 로그, 쿠키, 접속 IP 정보
+
개인정보 수집방법: 홈페이지(회원가입), 서비스 이용 과정에서 자동 생성
+
+
3. 개인정보의 보유 및 이용기간
+
회사는 법령에 따른 개인정보 보유·이용기간 또는 정보주체로부터 개인정보를 수집 시에 동의 받은 개인정보 보유·이용기간 내에서 개인정보를 처리·보유합니다.
+
회원 가입 및 관리: 회원탈퇴 시까지
+
다만, 다음의 사유에 해당하는 경우에는 해당 사유 종료 시까지
+
관계 법령 위반에 따른 수사·조사 등이 진행 중인 경우에는 해당 수사·조사 종료 시까지
+
서비스 이용에 따른 채권·채무관계 잔존 시에는 해당 채권·채무관계 정산 시까지
+
+
4. 개인정보의 파기절차 및 방법
+
회사는 원칙적으로 개인정보 처리목적이 달성된 경우에는 지체 없이 해당 개인정보를 파기합니다. 파기의 절차, 기한 및 방법은 다음과 같습니다.
+
파기절차: 이용자가 입력한 정보는 목적 달성 후 별도의 DB에 옮겨져 내부 방침 및 기타 관련 법령에 따라 일정기간 저장된 후 혹은 즉시 파기됩니다.
+
파기기한: 이용자의 개인정보는 개인정보의 보유기간이 경과된 경우에는 보유기간의 종료일로부터 5일 이내에, 개인정보의 처리 목적 달성, 해당 서비스의 폐지, 사업의 종료 등 그 개인정보가 불필요하게 되었을 때에는 개인정보의 처리가 불필요한 것으로 인정되는 날로부터 5일 이내에 그 개인정보를 파기합니다.
+
+ {buttonCount === 1 && (
+
+ 닫기
+
+ )}
+ {buttonCount === 2 && (
+
+ 닫기
+ 확인
+
+ )}
+
+
+ );
+};
+
+export default PrivacyPolicy;
\ No newline at end of file
diff --git a/frontend/src/components/Policies/PrivacyPolicy/index.tsx b/frontend/src/components/Policies/PrivacyPolicy/index.tsx
new file mode 100644
index 0000000..d551773
--- /dev/null
+++ b/frontend/src/components/Policies/PrivacyPolicy/index.tsx
@@ -0,0 +1 @@
+export { default } from './PrivacyPolicy';
\ No newline at end of file
diff --git a/frontend/src/components/Policies/TermsOfService/TermsOfService.module.css b/frontend/src/components/Policies/TermsOfService/TermsOfService.module.css
new file mode 100644
index 0000000..268f2a3
--- /dev/null
+++ b/frontend/src/components/Policies/TermsOfService/TermsOfService.module.css
@@ -0,0 +1,88 @@
+.container {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(28, 28, 28, 0.60);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+/* Box Container */
+.boxContainer {
+ display: flex;
+ flex-direction: column;
+ width: 27.75rem;
+ height: auto;
+ padding: 2.5rem;
+ border-radius: 0.75rem;
+ background: #FFF;
+ border: none;
+}
+
+/* Title */
+.header {
+ color: #001227;
+ font-size: 1.5rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ margin-bottom: 1.25rem;
+}
+
+.contentContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+ height: 25rem;
+ overflow-y: auto;
+ scrollbar-width: thin;
+}
+
+.title {
+ color: #000;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+
+.text {
+ color: #000;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ word-break: keep-all;
+}
+
+/* Button Container */
+.buttonContainer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 3.75rem;
+}
+
+.button {
+ display: flex;
+ /* padding: 12px 25px; */
+ padding: 18px 60px;
+ justify-content: center;
+ align-items: center;
+ gap: 1.125rem;
+ border-radius: 1.125rem;
+ background: #B8B5FF;
+ border: none;
+
+ color: #FFF;
+ font-size: 1.125rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/frontend/src/components/Policies/TermsOfService/TermsOfService.tsx b/frontend/src/components/Policies/TermsOfService/TermsOfService.tsx
new file mode 100644
index 0000000..f93b6fc
--- /dev/null
+++ b/frontend/src/components/Policies/TermsOfService/TermsOfService.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import styles from './TermsOfService.module.css';
+
+interface TermsOfServiceProps {
+ setOK: (ok: boolean) => void;
+ setIsTermsOfServiceOpen: (isTermsOfServiceOpen: boolean) => void;
+ buttonCount: number;
+}
+
+const TermsOfService: React.FC = ({ setIsTermsOfServiceOpen, setOK, buttonCount }) => {
+ const handleClose = () => {
+ setIsTermsOfServiceOpen(false);
+ }
+
+ const handleOK = () => {
+ setOK(true);
+ setIsTermsOfServiceOpen(false);
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
이용약관
+
+
+
제 1 조 (목적)
+
이 약관은 오투사운드 서비스(이하 "서비스")를 제공하는 회사(이하 "회사")와 이를 이용하는 회원(이하 "회원") 간의 권리, 의무 및 책임사항, 서비스 이용조건 및 절차 등 기본적인 사항을 규정함을 목적으로 합니다.
+
+
제 2 조 (정의)
+
이 약관에서 사용하는 용어의 정의는 다음과 같습니다.
+
"서비스"란 회사가 제공하는 펜션 홍보 영상 및 음악 생성 서비스를 의미합니다.
+
"회원"이란 회사와 서비스 이용계약을 체결하고 회사가 제공하는 서비스를 이용하는 자를 의미합니다.
+
"아이디(ID)"란 회원의 식별과 서비스 이용을 위하여 회원이 설정하고 회사가 승인하는 문자와 숫자의 조합을 의미합니다.
+
"비밀번호"란 회원이 부여 받은 아이디와 일치되는 회원임을 확인하고 비밀보호를 위해 회원 자신이 정한 문자 또는 숫자의 조합을 의미합니다.
+
+
제 3 조 (약관의 게시와 개정)
+
회사는 이 약관의 내용을 회원이 쉽게 알 수 있도록 서비스 초기 화면에 게시합니다. 회사는 필요한 경우 관련법령을 위배하지 않는 범위 내에서 이 약관을 개정할 수 있습니다.
+
+
제 4 조 (서비스의 제공 및 변경)
+
회사는 다음과 같은 서비스를 제공합니다.
+
- 펜션 이미지 자동 수집 서비스
+
- AI 기반 음악 생성 서비스
+
- 홍보 영상 제작 서비스
+
- 기타 회사가 추가 개발하거나 다른 회사와의 제휴계약 등을 통해 회원에게 제공하는 일체의 서비스
+
+
제 5 조 (서비스 이용시간)
+
서비스 이용은 회사의 업무상 또는 기술상 특별한 지장이 없는 한 연중무휴, 1일 24시간 운영을 원칙으로 합니다.
+
단, 회사는 시스템 정기점검, 증설 및 교체를 위해 회사가 정한 날이나 시간에 서비스를 일시 중단할 수 있으며, 예정되어 있는 작업으로 인한 서비스 일시 중단은 서비스 페이지를 통해 사전에 공지합니다.
+
이용자는 회사가 정한 가입 양식에 따라 회원정보를 기입한 후 이 약관에 동의한다는 의사표시를 함으로서 회원가입을 신청합니다.
+
+ {buttonCount === 1 && (
+
+ 닫기
+
+ )}
+ {buttonCount === 2 && (
+
+ 닫기
+ 확인
+
+ )}
+
+
+ );
+};
+
+export default TermsOfService;
\ No newline at end of file
diff --git a/frontend/src/components/Policies/TermsOfService/index.tsx b/frontend/src/components/Policies/TermsOfService/index.tsx
new file mode 100644
index 0000000..fdbd56b
--- /dev/null
+++ b/frontend/src/components/Policies/TermsOfService/index.tsx
@@ -0,0 +1 @@
+export { default } from './TermsOfService';
\ No newline at end of file
diff --git a/frontend/src/components/ProgressBar/ProgressBar.module.css b/frontend/src/components/ProgressBar/ProgressBar.module.css
new file mode 100644
index 0000000..6af6f4b
--- /dev/null
+++ b/frontend/src/components/ProgressBar/ProgressBar.module.css
@@ -0,0 +1,99 @@
+.container {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(28, 28, 28, 0.60);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+/* Content */
+.boxContainer {
+ display: flex;
+ flex-direction: column;
+ width: 55.9375rem;
+ padding: 2.5rem;
+ border-radius: 1rem;
+ background: #FFF;
+ gap: 2.5rem;
+ border: none;
+}
+
+/* Title Container */
+.titleContainer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1.5rem;
+}
+
+/* Title */
+.title {
+ color: #000;
+ text-align: center;
+ font-size: 2.25rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ user-select: none;
+}
+
+/* Subtitle */
+.subTitle {
+ color: #C2C2C2;
+ font-size: 1.5rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ user-select: none;
+}
+
+
+/* Description */
+.description {
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ justify-content: space-between;
+}
+
+/* Text */
+.text{
+ display: flex;
+ text-align: left;
+ color: #000;
+ font-size: 1.25rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ user-select: none;
+}
+.number {
+ display: flex;
+ text-align: right;
+ color: #000;
+ font-size: 1.25rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ user-select: none;
+}
+
+/* Progress Bar */
+.progressBar{
+ width: 100%;
+ height: 1.5rem;
+ border-radius: 1.125rem;
+ background: #F1F1F1;
+}
+
+.progressFill{
+ height: 100%;
+ border-radius: 1.125rem;
+ background: #B8B5FF;
+ transition: width 0.1s ease;
+}
\ No newline at end of file
diff --git a/frontend/src/components/ProgressBar/ProgressBar.tsx b/frontend/src/components/ProgressBar/ProgressBar.tsx
new file mode 100644
index 0000000..7ed8b02
--- /dev/null
+++ b/frontend/src/components/ProgressBar/ProgressBar.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import styles from './ProgressBar.module.css';
+
+interface ProgressBarProps {
+ onComplete: () => void;
+}
+
+const ProgressBar: React.FC = ({ onComplete }) => {
+ const [progress, setProgress] = React.useState(0);
+
+ React.useEffect(() => {
+ if (progress < 100) {
+ const timer = setTimeout(() => {
+ setProgress(prev => prev + 1);
+ }, 50); // 2초에 걸쳐 100%까지 진행
+ return () => clearTimeout(timer);
+ } else if (progress === 100) {
+ onComplete();
+ }
+ }, [progress, onComplete]);
+
+ return (
+
+
+
+
영상 생성 중
+
홍보 콘텐츠를 생성하고 있습니다. 잠시만 기다려 주세요.
+
+
+
영상 생성 중...
+
{progress}%
+
+
+
+
+ );
+};
+
+export default ProgressBar;
\ No newline at end of file
diff --git a/frontend/src/components/ProgressBar/index.tsx b/frontend/src/components/ProgressBar/index.tsx
new file mode 100644
index 0000000..fa15e6b
--- /dev/null
+++ b/frontend/src/components/ProgressBar/index.tsx
@@ -0,0 +1 @@
+export { default } from './ProgressBar';
\ No newline at end of file
diff --git a/frontend/src/components/SimpleInput/SimpleInput.module.css b/frontend/src/components/SimpleInput/SimpleInput.module.css
new file mode 100644
index 0000000..3aabc00
--- /dev/null
+++ b/frontend/src/components/SimpleInput/SimpleInput.module.css
@@ -0,0 +1,42 @@
+.container {
+ display: flex;
+ padding: 1rem 1.25rem;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ align-self: stretch;
+ border-radius: 0.75rem;
+ border: 1px solid rgba(0, 18, 39, 0.50);
+ background: #FFF;
+}
+
+.label {
+ color: #001227;
+ font-size: 0.75rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+.inputWrapper {
+ width: 100%;
+}
+
+.input {
+ width: 100%;
+ border: none;
+ border-radius: 0.5rem;
+ background-color: transparent;
+ outline: none;
+
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+.input:disabled {
+ background-color: transparent;
+ cursor: not-allowed;
+}
diff --git a/frontend/src/components/SimpleInput/SimpleInput.tsx b/frontend/src/components/SimpleInput/SimpleInput.tsx
new file mode 100644
index 0000000..a6bda05
--- /dev/null
+++ b/frontend/src/components/SimpleInput/SimpleInput.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import styles from './SimpleInput.module.css';
+
+interface SimpleInputProps {
+ label: string;
+ value: string;
+ onChange: (e: React.ChangeEvent) => void;
+ placeholder?: string;
+ disabled?: boolean;
+}
+
+export const SimpleInput: React.FC = ({
+ label,
+ value,
+ onChange,
+ placeholder = '',
+ disabled = false,
+}) => {
+ return (
+
+ );
+};
+
+export default SimpleInput;
diff --git a/frontend/src/components/SimpleInput/index.tsx b/frontend/src/components/SimpleInput/index.tsx
new file mode 100644
index 0000000..e206c3b
--- /dev/null
+++ b/frontend/src/components/SimpleInput/index.tsx
@@ -0,0 +1 @@
+export { SimpleInput } from './SimpleInput';
\ No newline at end of file
diff --git a/frontend/src/components/SimpleLoading/SimpleLoading.module.css b/frontend/src/components/SimpleLoading/SimpleLoading.module.css
new file mode 100644
index 0000000..c9a74ae
--- /dev/null
+++ b/frontend/src/components/SimpleLoading/SimpleLoading.module.css
@@ -0,0 +1,44 @@
+.container{
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(28, 28, 28, 0.60);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+.loadingContainer{
+ width: 4rem;
+ height: 4rem;
+}
+.loadingSpinner {
+ width: 100%;
+ height: 100%;
+ aspect-ratio: 1;
+ border-radius: 50%;
+ border: 0.25rem solid #fff;
+ animation:
+ spinnerClip 1.0s infinite linear alternate,
+ spinnerRotate 2.0s infinite linear;
+}
+
+@keyframes spinnerClip {
+ 0% {clip-path: polygon(50% 50%,0 0, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0% )}
+ 12.5% {clip-path: polygon(50% 50%,0 0, 50% 0%, 100% 0%, 100% 0%, 100% 0%, 100% 0% )}
+ 25% {clip-path: polygon(50% 50%,0 0, 50% 0%, 100% 0%, 100% 100%, 100% 100%, 100% 100% )}
+ 50% {clip-path: polygon(50% 50%,0 0, 50% 0%, 100% 0%, 100% 100%, 50% 100%, 0% 100% )}
+ 62.5% {clip-path: polygon(50% 50%,100% 0, 100% 0%, 100% 0%, 100% 100%, 50% 100%, 0% 100% )}
+ 75% {clip-path: polygon(50% 50%,100% 100%, 100% 100%, 100% 100%, 100% 100%, 50% 100%, 0% 100% )}
+ 100% {clip-path: polygon(50% 50%,50% 100%, 50% 100%, 50% 100%, 50% 100%, 50% 100%, 0% 100% )}
+}
+
+@keyframes spinnerRotate {
+ 0% {transform:scaleY(1) rotate(0deg)}
+ 49.99%{transform:scaleY(1) rotate(135deg)}
+ 50% {transform:scaleY(-1) rotate(0deg)}
+ 100% {transform:scaleY(-1) rotate(-135deg)}
+}
\ No newline at end of file
diff --git a/frontend/src/components/SimpleLoading/SimpleLoading.tsx b/frontend/src/components/SimpleLoading/SimpleLoading.tsx
new file mode 100644
index 0000000..ec51722
--- /dev/null
+++ b/frontend/src/components/SimpleLoading/SimpleLoading.tsx
@@ -0,0 +1,18 @@
+import React from "react";
+import styles from "./SimpleLoading.module.css";
+
+interface SimpleLoadingProps {
+ isLoading: boolean;
+}
+
+const SimpleLoading: React.FC = ({ isLoading }) => {
+ return (
+
+ );
+};
+
+export default SimpleLoading;
\ No newline at end of file
diff --git a/frontend/src/components/SimpleLoading/index.tsx b/frontend/src/components/SimpleLoading/index.tsx
new file mode 100644
index 0000000..f63baa9
--- /dev/null
+++ b/frontend/src/components/SimpleLoading/index.tsx
@@ -0,0 +1 @@
+export { default } from "./SimpleLoading";
\ No newline at end of file
diff --git a/frontend/src/components/SocialLogin/SocialLogin.module.css b/frontend/src/components/SocialLogin/SocialLogin.module.css
new file mode 100644
index 0000000..9478e67
--- /dev/null
+++ b/frontend/src/components/SocialLogin/SocialLogin.module.css
@@ -0,0 +1,9 @@
+.socialLoginContainer{
+ display: flex;
+ width: 100%;
+ justify-content: center;
+ align-items: center;
+}
+.socialLoginImage{
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/frontend/src/components/SocialLogin/SocialLogin.tsx b/frontend/src/components/SocialLogin/SocialLogin.tsx
new file mode 100644
index 0000000..f279d10
--- /dev/null
+++ b/frontend/src/components/SocialLogin/SocialLogin.tsx
@@ -0,0 +1,16 @@
+import styles from "./SocialLogin.module.css";
+import kakaoLogin from "../../assets/images/kakao_login.png";
+
+const SocialLogin: React.FC = () => {
+ const handleKakaoLogin = () => {
+ window.location.href = "http://localhost:8800/social_auth/kakao/login";
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default SocialLogin;
\ No newline at end of file
diff --git a/frontend/src/components/SocialLogin/index.tsx b/frontend/src/components/SocialLogin/index.tsx
new file mode 100644
index 0000000..2de0b8a
--- /dev/null
+++ b/frontend/src/components/SocialLogin/index.tsx
@@ -0,0 +1 @@
+export { default } from "./SocialLogin";
\ No newline at end of file
diff --git a/frontend/src/components/StepProgressBar/StepProgressBar.module.css b/frontend/src/components/StepProgressBar/StepProgressBar.module.css
new file mode 100644
index 0000000..2dfa5ff
--- /dev/null
+++ b/frontend/src/components/StepProgressBar/StepProgressBar.module.css
@@ -0,0 +1,177 @@
+/* Container */
+.container {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(28, 28, 28, 0.60);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+/* Box Container */
+.boxContainer {
+ display: inline-flex;
+ padding: 2.5rem;
+ flex-direction: column;
+ align-items: center;
+ gap: 2.5rem;
+ border-radius: 0.75rem;
+ background: #FFF;
+ border: none;
+}
+
+/* Content Container */
+.contentContainer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2.5rem;
+}
+
+/* Header Container */
+.headerContainer {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+/* Header Title */
+.headerTitle {
+ color: #000;
+ font-size: 1.5rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+
+/* Step Container */
+.stepContainer {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+/* Step Box */
+.stepBox {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+/* Step Circle */
+.stepCircle {
+ display: flex;
+ width: 1.75rem;
+ height: 1.75rem;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ border-radius: 2.5rem;
+ background: #ADADAD;
+}
+
+.stepCircle.active {
+ background: #8681FF;
+ color: #FFF;
+}
+
+/* Step Circle Text */
+.stepCircleText {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: #FFF;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Step Text */
+.stepText {
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ word-break: keep-all;
+}
+
+.stepText.active {
+ color: #8681FF;
+}
+
+/* Dot Group */
+.dotGroup {
+ display: flex;
+ flex-direction: row;
+ gap: 0.5rem;
+}
+
+/* Dot */
+.dot {
+ display: flex;
+ width: 0.25rem;
+ height: 0.25rem;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ border-radius: 2.5rem;
+ background: #000;
+}
+
+.dot.active {
+ background: #8681FF;
+}
+
+/* Step Bottom Text */
+.stepBottomText {
+ color: #C2C2C2;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+
+/* Loading Spinner */
+.loadingContainer {
+ width: 1.75rem;
+ height: 1.75rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.loadingSpinner {
+ width: 0.75rem;
+ height: 0.75rem;
+ aspect-ratio: 1;
+ border-radius: 50%;
+ border: 0.125rem solid #fff;
+ animation:
+ spinnerClip 1.0s infinite linear alternate,
+ spinnerRotate 2.0s infinite linear;
+}
+
+@keyframes spinnerClip {
+ 0% {clip-path: polygon(50% 50%,0 0, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0% )}
+ 12.5% {clip-path: polygon(50% 50%,0 0, 50% 0%, 100% 0%, 100% 0%, 100% 0%, 100% 0% )}
+ 25% {clip-path: polygon(50% 50%,0 0, 50% 0%, 100% 0%, 100% 100%, 100% 100%, 100% 100% )}
+ 50% {clip-path: polygon(50% 50%,0 0, 50% 0%, 100% 0%, 100% 100%, 50% 100%, 0% 100% )}
+ 62.5% {clip-path: polygon(50% 50%,100% 0, 100% 0%, 100% 0%, 100% 100%, 50% 100%, 0% 100% )}
+ 75% {clip-path: polygon(50% 50%,100% 100%, 100% 100%, 100% 100%, 100% 100%, 50% 100%, 0% 100% )}
+ 100% {clip-path: polygon(50% 50%,50% 100%, 50% 100%, 50% 100%, 50% 100%, 50% 100%, 0% 100% )}
+}
+
+@keyframes spinnerRotate {
+ 0% {transform:scaleY(1) rotate(0deg)}
+ 49.99%{transform:scaleY(1) rotate(135deg)}
+ 50% {transform:scaleY(-1) rotate(0deg)}
+ 100% {transform:scaleY(-1) rotate(-135deg)}
+}
\ No newline at end of file
diff --git a/frontend/src/components/StepProgressBar/StepProgressBar.tsx b/frontend/src/components/StepProgressBar/StepProgressBar.tsx
new file mode 100644
index 0000000..b41dfc1
--- /dev/null
+++ b/frontend/src/components/StepProgressBar/StepProgressBar.tsx
@@ -0,0 +1,201 @@
+import React, { useState, useEffect } from "react";
+import styles from "./StepProgressBar.module.css";
+import { progressWorkflow } from "@/api/temp/temp";
+
+interface StepProgressBarProps {
+ onComplete: () => void;
+}
+
+const StepProgressBar: React.FC = ({ onComplete }) => {
+ const [currentIndex, setCurrentIndex] = useState(1);
+ const [allStep, setAllStep] = useState(false);
+ const [firstStep, setFirstStep] = useState(false);
+ const [secondStep, setSecondStep] = useState(false);
+ const [thirdStep, setThirdStep] = useState(false);
+ const [fourthStep, setFourthStep] = useState(false);
+ const [fifthStep, setFifthStep] = useState(false);
+
+ const stepData = {
+ firstStep: ["URL 분석 중", "URL 분석 완료"],
+ secondStep: ["가사 생성 중", "가사 생성 완료"],
+ thirdStep: ["노래 생성 중", "노래 생성 완료"],
+ fourthStep: ["이미지 생성 중", "이미지 생성 완료"],
+ fifthStep: ["영상 생성 중", "영상 생성 완료"],
+ };
+
+ // 모든 스텝이 완료되면 onComplete 호출
+ useEffect(() => {
+ if (allStep) {
+ onComplete();
+ }
+ }, [allStep, onComplete]);
+
+ // 모든 스텝 상태를 확인하여 allStep 설정
+ useEffect(() => {
+ if (firstStep && secondStep && thirdStep && fourthStep && fifthStep) {
+ setAllStep(true);
+ }
+ }, [firstStep, secondStep, thirdStep, fourthStep, fifthStep]);
+
+ const Spinner = () => {
+ return (
+
+ );
+ };
+
+
+ const ProgressWorkflow = async () => {
+ try{
+ const taskId = localStorage.getItem("task_id");
+ if (!taskId) return;
+ const response = await progressWorkflow({
+ task_id: taskId,
+ });
+ if (localStorage.getItem("order_id") === null){
+ if(response.order_id){
+ localStorage.setItem("order_id", response.order_id);
+ }
+ }
+ const success_crawling = response.step_status.crawling;
+ const success_lyrics = response.step_status.lyrics;
+ const success_song = response.step_status.music;
+ const success_image = response.step_status.images;
+ const success_video = response.step_status.video;
+
+ if (success_crawling) {
+ setCurrentIndex(2);
+ setFirstStep(true);
+ }
+ if (success_lyrics) {
+ setCurrentIndex(3);
+ setSecondStep(true);
+ }
+ if (success_song) {
+ setCurrentIndex(4);
+ setThirdStep(true);
+ }
+ if (success_image) {
+ setCurrentIndex(5);
+ setFourthStep(true);
+ }
+ if (success_video) {
+ setFifthStep(true);
+ }
+ } catch (error){
+ alert("프로세스 생성에 문제가 발생했습니다. 다시 시도해주세요.");
+ }
+ };
+
+ useEffect(() => {
+ ProgressWorkflow(); // 첫 실행
+
+ // 2초마다 폴링 (모든 스텝이 완료되지 않았을 때만)
+ const interval = setInterval(() => {
+ if (!allStep) {
+ ProgressWorkflow();
+ }
+ }, 2000);
+
+ // 컴포넌트 언마운트시 인터벌 정리
+ return () => clearInterval(interval);
+ }, [allStep]);
+
+ const getCurrentStepText = () => {
+ if (allStep) return "영상 생성을 완료했습니다.";
+ if (currentIndex === 1) return "매장 URL을 통해 정보를 분석 중 입니다";
+ if (currentIndex === 2) return "매장 홍보를 위한 가사 생성 중 입니다";
+ if (currentIndex === 3) return "가사에 맞춰 노래를 생성 중 입니다";
+ if (currentIndex === 4) return "매장의 이미지를 수집하는 중 입니다";
+ if (currentIndex === 5) return "모든 정보를 바탕으로 영상을 생성 중 입니다";
+ return "준비중";
+ };
+
+ // 스텝 박스 렌더링 함수
+ const renderStepBox = (stepNumber: number, stepKey: keyof typeof stepData, isCompleted: boolean, showDots = true) => {
+ const isActive = currentIndex >= stepNumber;
+ const isInProgress = isActive && !isCompleted;
+ const stepCompleted = isActive && isCompleted;
+
+ return (
+
+ {stepCompleted && (
+ <>
+
+
+ {stepData[stepKey][1]}
+
+ {showDots && (
+
+ )}
+ >
+ )}
+ {isInProgress && (
+ <>
+
+
+
+
+ {stepData[stepKey][0]}
+
+ {showDots && (
+
+ )}
+ >
+ )}
+ {!isActive && (
+ <>
+
+
+ {stepData[stepKey][0]}
+
+ {showDots && (
+
+ )}
+ >
+ )}
+
+ );
+ };
+
+ return (
+
+
+
+
+
+ {renderStepBox(1, "firstStep", firstStep)}
+ {renderStepBox(2, "secondStep", secondStep)}
+ {renderStepBox(3, "thirdStep", thirdStep)}
+ {renderStepBox(4, "fourthStep", fourthStep)}
+ {renderStepBox(5, "fifthStep", fifthStep, false)}
+
+
+ {getCurrentStepText()}
+
+
+
+
+ );
+};
+
+export default StepProgressBar;
\ No newline at end of file
diff --git a/frontend/src/components/StepProgressBar/index.tsx b/frontend/src/components/StepProgressBar/index.tsx
new file mode 100644
index 0000000..a64be32
--- /dev/null
+++ b/frontend/src/components/StepProgressBar/index.tsx
@@ -0,0 +1 @@
+export { default } from "./StepProgressBar";
\ No newline at end of file
diff --git a/frontend/src/components/StepTracker/StepTracker.module.css b/frontend/src/components/StepTracker/StepTracker.module.css
new file mode 100644
index 0000000..aec4767
--- /dev/null
+++ b/frontend/src/components/StepTracker/StepTracker.module.css
@@ -0,0 +1,71 @@
+.container{
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.step{
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+
+.stepNumber{
+ display: flex;
+ width: 1.75rem;
+ height: 1.75rem;
+ padding: 0.625rem;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 0.625rem;
+ border-radius: 2.5rem;
+ background: #F1F1F1;
+ margin-right: 0.5rem;
+}
+
+.stepText{
+ font-family: Pretendard;
+ font-size: 0.875rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ user-select: none;
+ word-break: keep-all;
+}
+
+.stepLine{
+ display: flex;
+ width: 1.75rem;
+ height: 0.0625rem;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 0.625rem;
+ background: rgba(0, 18, 39, 0.50);
+ margin: 0 0.75rem;
+}
+
+/* Current Step */
+.currentStepNumber{
+ background: #E1BFFF;
+ color: #8100F3;
+}
+
+.currentStepText{
+ color: #8100F3;
+}
+
+/* Active Step */
+.prevStepNumber{
+ background: #E1BFFF;
+ color: #FFF;
+}
+
+.prevStepText{
+ color: #BA6BFF;
+}
+.prevStepLine{
+ background: #BA6BFF;
+}
\ No newline at end of file
diff --git a/frontend/src/components/StepTracker/StepTracker.tsx b/frontend/src/components/StepTracker/StepTracker.tsx
new file mode 100644
index 0000000..8184750
--- /dev/null
+++ b/frontend/src/components/StepTracker/StepTracker.tsx
@@ -0,0 +1,62 @@
+import React, { useState } from "react";
+import styles from "./StepTracker.module.css";
+
+const StepTracker: React.FC<{step: number}> = ({step}) => {
+ const steps = [
+ { number: 1, text: "URL 입력" },
+ { number: 2, text: "영상 확인" },
+ ];
+
+ // 최종 스탭
+ // const steps = [
+ // { number: 1, text: "URL 입력" },
+ // { number: 2, text: "이미지 확인" },
+ // { number: 3, text: "장르 확인" },
+ // { number: 4, text: "노래 확인" },
+ // { number: 5, text: "영상 확인" }
+ // ];
+
+ const getStepClasses = (stepNumber: number) => {
+ if (stepNumber < step) {
+ // 이전 단계
+ return {
+ stepNumber: `${styles.stepNumber} ${styles.prevStepNumber}`,
+ stepText: `${styles.stepText} ${styles.prevStepText}`,
+ stepLine: `${styles.stepLine} ${styles.prevStepLine}`
+ };
+ } else if (stepNumber === step) {
+ // 현재 단계
+ return {
+ stepNumber: `${styles.stepNumber} ${styles.currentStepNumber}`,
+ stepText: `${styles.stepText} ${styles.currentStepText}`,
+ stepLine: `${styles.stepLine}`
+ };
+ } else {
+ // 미래 단계
+ return {
+ stepNumber: styles.stepNumber,
+ stepText: styles.stepText,
+ stepLine: styles.stepLine
+ };
+ }
+ };
+
+ return (
+
+ {steps.map((stepItem, index) => {
+ const classes = getStepClasses(stepItem.number);
+ const isLastStep = index === steps.length - 1;
+
+ return (
+
+
{stepItem.number}
+
{stepItem.text}
+ {!isLastStep &&
}
+
+ );
+ })}
+
+ );
+};
+
+export default StepTracker;
\ No newline at end of file
diff --git a/frontend/src/components/StepTracker/index.ts b/frontend/src/components/StepTracker/index.ts
new file mode 100644
index 0000000..cac58f7
--- /dev/null
+++ b/frontend/src/components/StepTracker/index.ts
@@ -0,0 +1 @@
+export { default } from "./StepTracker";
\ No newline at end of file
diff --git a/frontend/src/components/UploadToggle/UploadToggle.module.css b/frontend/src/components/UploadToggle/UploadToggle.module.css
new file mode 100644
index 0000000..39c285a
--- /dev/null
+++ b/frontend/src/components/UploadToggle/UploadToggle.module.css
@@ -0,0 +1,142 @@
+/* Container */
+.container {
+ position: relative;
+ display: inline-flex;
+ padding: 0.625rem;
+ justify-content: center;
+ align-items: center;
+ flex-direction: column;
+ gap: 0;
+ border-radius: 0.75rem;
+ background: #F6F6F6;
+ transition: all 0.3s ease-in-out;
+ z-index: 1000;
+}
+
+/* contentContainer */
+.contentContainer {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ cursor: pointer;
+ width: 100%;
+ padding: 0;
+}
+
+/* contentText */
+.contentText {
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ margin-left: 0.62rem;
+ margin-right: 1rem;
+}
+
+/* Expandable Content */
+.expandableContent {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ width: 100%;
+ max-height: 0;
+ overflow: hidden;
+ background: #F6F6F6;
+ border-radius: 0 0 0.75rem 0.75rem;
+ transition: max-height 0.3s ease-in-out, padding 0.3s ease-in-out;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ z-index: 1001;
+}
+
+.expandableContent.expanded {
+ max-height: 200px; /* 충분한 높이로 설정 */
+}
+
+.expandableInner {
+ display: flex;
+ flex-direction: column;
+}
+
+/* Divider */
+.divider {
+ width: 100%;
+ display: flex;
+ height: 0.0625rem;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ background: #C8C8C8;
+}
+
+/* Platform Container */
+.platformContainer {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ gap: 0.5rem;
+ width: 100%;
+ text-align: left;
+ cursor: pointer;
+ padding: 0.625rem;
+}
+.platformContainer:hover {
+ background: #E0E0E0;
+}
+
+/* Platform Text */
+.platformText {
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Simple Pop Up */
+.SimplePopUp {
+ position: fixed;
+ top: 2rem;
+ left: 50%;
+ z-index: 10001;
+}
+
+.SimplePopUpContainer {
+ display: flex;
+ padding: 1rem 1.5rem;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ border-radius: 0.5rem;
+ background: #10B981;
+ box-shadow: 0 0.25rem 0.375rem rgba(0, 0, 0, 0.1);
+ animation: slideDown 0.4s ease-out;
+ transform: translateX(-50%);
+}
+
+.SimplePopUpText {
+ color: #FFF;
+ font-size: 1.25rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+.SimplePopUpSubText {
+ color: #FFF;
+ font-size: 0.875rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+}
+
+/* Slide Down Animation */
+@keyframes slideDown {
+ 0% {
+ opacity: 0;
+ transform: translateX(-50%) translateY(-0.3rem) scale(0.98);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0) scale(1);
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/components/UploadToggle/UploadToggle.tsx b/frontend/src/components/UploadToggle/UploadToggle.tsx
new file mode 100644
index 0000000..d94e87f
--- /dev/null
+++ b/frontend/src/components/UploadToggle/UploadToggle.tsx
@@ -0,0 +1,89 @@
+import React, { useState } from "react";
+import styles from "./UploadToggle.module.css";
+import youtube from "@/assets/images/youtube.png";
+import naver from "@/assets/images/naver.png";
+
+const uploadIcon = (
+
+
+
+)
+
+const toggleDownIcon = (
+
+
+
+)
+
+const toggleUpIcon = (
+
+
+
+)
+
+const UploadToggle: React.FC = () => {
+ const [isToggle, setIsToggle] = useState(false);
+ const [isUploading, setIsUploading] = useState(false);
+ const [uploadSuccess, setUploadSuccess] = useState(false);
+ const [uploadFailMessage, setUploadFailMessage] = useState("");
+
+ const renderUploadPopUp = (uploadSuccess: boolean) => {
+ if (!isUploading) return null;
+
+ return (
+
+
+
{uploadSuccess ? "업로드가 완료되었습니다!" : "업로드에 실패했습니다."}
+ {!uploadSuccess &&
{uploadFailMessage}
}
+
+
+ )
+ }
+
+ const handleYoutubeUpload = () => {
+ setIsUploading(true);
+ setUploadSuccess(true);
+ setTimeout(() => {
+ setIsUploading(false);
+ }, 2000);
+ };
+
+ const handleNaverUpload = () => {
+ setIsUploading(true);
+ setUploadSuccess(false);
+ setUploadFailMessage("네이버 클립 업로드 중 오류가 발생했습니다.");
+ setTimeout(() => {
+ setIsUploading(false);
+ }, 2000);
+ };
+
+ return (
+ <>
+ {renderUploadPopUp(uploadSuccess)}
+
+
setIsToggle(!isToggle)}>
+ {uploadIcon}
+
자동 업로드
+ {isToggle ? toggleUpIcon : toggleDownIcon}
+
+
+
+
+
+
+
+
Youtube
+
+
+
+
+
네이버 클립
+
+
+
+
+ >
+ )
+}
+
+export default UploadToggle;
\ No newline at end of file
diff --git a/frontend/src/components/UploadToggle/index.tsx b/frontend/src/components/UploadToggle/index.tsx
new file mode 100644
index 0000000..0aea45d
--- /dev/null
+++ b/frontend/src/components/UploadToggle/index.tsx
@@ -0,0 +1 @@
+export { default } from "./UploadToggle";
\ No newline at end of file
diff --git a/frontend/src/components/VideoUploadPopUp/VideoUploadPopUp.module.css b/frontend/src/components/VideoUploadPopUp/VideoUploadPopUp.module.css
new file mode 100644
index 0000000..83789cd
--- /dev/null
+++ b/frontend/src/components/VideoUploadPopUp/VideoUploadPopUp.module.css
@@ -0,0 +1,279 @@
+.container {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(28, 28, 28, 0.60);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 5;
+}
+
+/* Content */
+.content {
+ width: 36.525rem;
+ display: inline-flex;
+ padding: 2.5rem;
+ flex-direction: column;
+ justify-content: center;
+ gap: 1.5rem;
+ border-radius: 1rem;
+ background: #FFF;
+ z-index: 1000;
+}
+
+/* Title */
+.title {
+ color: #001227;
+ font-size: 1.5rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+
+/* Description */
+.description {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+.descriptionTitle{
+ color: #001227;
+ font-size: 1.25rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+.descriptionContent{
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 0.9375rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Video Wrapper */
+.videoWrapper {
+ display: flex;
+ width: 100%;
+ height: 14.0625rem;
+ background: #000;
+ border-radius: 0.5rem;
+}
+
+.video {
+ width: 100%;
+ height: 100%;
+ aspect-ratio: 16/9;
+ object-fit: cover;
+ border-radius: 0.75rem;
+}
+
+/* Channel Wrapper */
+.channelWrapper{
+ display: flex;
+ flex-direction: column;
+ padding: 0.5625rem;
+ border-radius: 0.5625rem;
+ border: 0.75px solid rgba(0, 18, 39, 0.50);
+ background: #FFF;
+ gap: 0.19rem;
+}
+.channelHeader{
+ color: #001227;
+ font-size: 0.6875rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+}
+.channelContent{
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+.channelName{
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Input Container */
+.inputContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+/* Input Wrapper */
+.inputWrapper {
+ display: flex;
+ flex-direction: column;
+ padding: 0.5625rem;
+ border-radius: 0.5625rem;
+ border: 0.75px solid rgba(0, 18, 39, 0.50);
+ background: #FFF;
+ gap: 0.19rem;
+}
+.icon{
+ display: flex;
+ align-self: center;
+ justify-self: center;
+ width: 0.875rem;
+ height: 0.875rem;
+}
+.inputTitle {
+ color: #001227;
+ font-size: 0.6875rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+}
+.inputContent{
+ display: flex;
+ align-items: center;
+ gap: 0.19rem;
+}
+.input{
+ width: 100%;
+ border: none;
+ outline: none;
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+.input:focus{
+ border: none;
+ outline: none;
+}
+
+/* Textarea Content */
+.textareaWrapper {
+ display: flex;
+ flex-direction: column;
+ padding: 0.5625rem;
+ border-radius: 0.5625rem;
+ border: 0.75px solid rgba(0, 18, 39, 0.50);
+ background: #FFF;
+ gap: 0.19rem;
+ height: auto;
+}
+.textareaTitle {
+ color: #001227;
+ font-size: 0.8rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+}
+.textareaContent{
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-start;
+ gap: 0.19rem;
+}
+.descriptionIcon{
+ display: flex;
+ width: 0.75rem;
+ height: 0.9375rem;
+ margin-top: 0.5rem;
+}
+.textarea {
+ width: 100%;
+ border: none;
+ resize: none;
+ outline: none;
+
+ color: #001227;
+ font-family: Pretendard;
+ font-size: 1.125rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: 1.5;
+
+ overflow-y: auto;
+
+ /* 스크롤바 스타일 */
+ scrollbar-width: thin; /* Firefox */
+ scrollbar-color: #bbb transparent; /* Firefox */
+
+}
+.textarea::-webkit-scrollbar {
+ width: 0.375rem;
+}
+
+.textarea::-webkit-scrollbar-thumb {
+ background-color: #bbb;
+ border-radius: 3px;
+}
+
+.textarea::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+/* Bottom Text Container */
+.bottomTextContainer{
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ background-color: #E5EFFF;
+ border-radius: 0.5625rem;
+ padding: 0.5625rem;
+ width: 100%;
+ height: 2.5rem;
+}
+.bottomText{
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Button Container */
+.buttonContainer{
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+}
+
+/* Button */
+.button{
+ display: flex;
+ padding: 0.5625rem 1.875rem;
+ justify-content: center;
+ align-items: center;
+ gap: 0.5625rem;
+ border-radius: 0.5625rem;
+ background: #B8B5FF;
+ border: none;
+ color: #FFF;
+ font-size: 0.9375rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ cursor: pointer;
+}
+
+/* Hashtags Container */
+.hashtagsContainer{
+ display: flex;
+ flex-direction: row;
+ gap: 0.5rem;
+}
+.hashtagText{
+ display: flex;
+ height: 2.3rem;
+ padding: 0.5rem 0.75rem;
+ justify-content: center;
+ align-items: center;
+ border-radius: 0.75rem;
+ background: #E0DFFF;
+ color: #001227;
+ font-size: 1.125rem;
+ white-space: nowrap;
+}
\ No newline at end of file
diff --git a/frontend/src/components/VideoUploadPopUp/VideoUploadPopUp.tsx b/frontend/src/components/VideoUploadPopUp/VideoUploadPopUp.tsx
new file mode 100644
index 0000000..95860a2
--- /dev/null
+++ b/frontend/src/components/VideoUploadPopUp/VideoUploadPopUp.tsx
@@ -0,0 +1,301 @@
+import { useState, useRef, useEffect } from "react";
+import styles from "./VideoUploadPopUp.module.css";
+import penIcon from "@/assets/icons/pen_underline.svg";
+import descriptionIcon from "@/assets/icons/description.svg";
+import tagIcon from "@/assets/icons/tag.svg";
+import blueErrorIcon from "@/assets/icons/blue_error.svg";
+import youtubeIcon from "@/assets/images/youtube.png";
+import { getYoutubeChannelInfo, uploadYoutubeVideo } from "@/api/social_login/google";
+import SimpleLoading from "@/components/SimpleLoading";
+
+interface ChannelData {
+ platform: string;
+ channelName: string;
+ channelId: string;
+ subscriberCount: number;
+ thumbnailUrl: string;
+}
+
+interface VideoUploadPopUpProps {
+ videoTitle: string;
+ videoPlaytime: string;
+ videoResolution: string;
+ videoExtension: string;
+ videoUrl: string;
+ setIsUploadPopUpOpen: (isUploadPopUpOpen: boolean) => void;
+}
+
+const VideoUploadPopUp: React.FC
= ({
+ videoTitle,
+ videoPlaytime,
+ videoResolution,
+ videoExtension,
+ videoUrl,
+ setIsUploadPopUpOpen
+}) => {
+ const [channelData, setChannelData] = useState(null);
+ const [uploading, setUploading] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [title, setTitle] = useState(videoTitle);
+ const [description, setDescription] = useState("");
+ const [hashtags, setHashtags] = useState("");
+ const [hashtagsList, setHashtagsList] = useState([]);
+
+ const textareaRef = useRef(null);
+
+ const closePopup = () => setIsUploadPopUpOpen(false);
+
+ const handleUpload = async () => {
+ if (!channelData) {
+ alert('채널 정보가 없습니다.');
+ return;
+ }
+
+ if (!title.trim()) {
+ alert('영상 제목을 입력해주세요.');
+ return;
+ }
+
+ try {
+ setUploading(true);
+ console.log(channelData.channelId);
+ const uploadData = {
+ channel_id: channelData.channelId,
+ title: title.trim(),
+ description: description.trim(),
+ hashtags: hashtagsList,
+ video_url: videoUrl,
+ privacy_status: 'private' as const,
+ category_id: '22',
+ default_language: 'ko'
+ };
+
+ console.log('업로드 시작:', uploadData);
+
+ const result = await uploadYoutubeVideo(uploadData);
+
+ console.log('업로드 성공:', result);
+ alert(`업로드 완료!\n동영상 ID: ${result.video_id}\nYouTube 링크: ${result.links.youtube_url}`);
+
+ closePopup();
+
+ } catch (error: any) {
+ console.error('업로드 실패:', error);
+
+ if (error.response?.status === 401) {
+ alert('인증이 만료되었습니다. 다시 로그인해주세요.');
+ } else if (error.response?.status === 403) {
+ alert('YouTube 업로드 권한이 없거나 할당량이 초과되었습니다.');
+ } else {
+ alert(`업로드 실패: ${error.response?.data?.detail || error.message}`);
+ }
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const handleHashTagInput = (e: React.ChangeEvent) => {
+ const value = e.target.value;
+
+ if (value.endsWith(" ")) {
+ const trimmedValue = value.trim();
+
+ if (trimmedValue.startsWith("#") && trimmedValue.length > 1) {
+ const newHashtag = trimmedValue.replace("#", "");
+
+ if (!hashtagsList.includes(newHashtag)) {
+ setHashtagsList([...hashtagsList, newHashtag]);
+ }
+ setHashtags("");
+ } else {
+ setHashtags("");
+ }
+ } else {
+ setHashtags(value);
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Backspace' && hashtags === '' && hashtagsList.length > 0) {
+ e.preventDefault();
+ setHashtagsList(hashtagsList.slice(0, -1));
+ }
+ };
+
+ const adjustTextareaHeight = () => {
+ const textarea = textareaRef.current;
+ if (!textarea) return;
+
+ textarea.style.height = "30px";
+ textarea.style.overflowY = "hidden";
+
+ const scrollHeight = textarea.scrollHeight;
+
+ if (scrollHeight <= 80) {
+ textarea.style.height = `${scrollHeight}px`;
+ textarea.style.overflowY = "hidden";
+ } else {
+ textarea.style.height = "80px";
+ textarea.style.overflowY = "auto";
+ }
+ };
+
+ const fetchChannelInfo = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const channelInfo = await getYoutubeChannelInfo();
+
+ if (channelInfo.data && channelInfo.data.length > 0) {
+ const channel = channelInfo.data[0];
+
+ const tempChannel: ChannelData = {
+ platform: "Youtube",
+ channelName: channel.title || "Youtube Channel",
+ channelId: channel.channel_id,
+ subscriberCount: channel.subscriber_count,
+ thumbnailUrl: channel.thumbnail_url
+ };
+
+ setChannelData(tempChannel);
+ console.log('채널 정보 저장됨:', tempChannel);
+
+ localStorage.setItem('youtubeChannelData', JSON.stringify(tempChannel));
+ } else {
+ setError('채널 정보가 없습니다.');
+ }
+
+ } catch (error: any) {
+ console.error('API 호출 실패:', error);
+ setError('채널 정보를 불러오는데 실패했습니다.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ adjustTextareaHeight();
+ }, [description]);
+
+ useEffect(() => {
+ fetchChannelInfo();
+ }, []);
+
+ const renderChannelInfo = () => {
+ if (channelData?.platform === "Youtube") {
+ return (
+
+
+
{channelData.channelName}
+
+ );
+ }
+ return {channelData?.channelName}
;
+ };
+
+ const renderHashtags = () => {
+ return hashtagsList.map((tag, index) => (
+ {tag}
+ ));
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
영상 업로드
+
+
+
{videoTitle}
+
+ {videoPlaytime} · {videoResolution} · {videoExtension}
+
+
+
+
+
+
+
+
+
+
연결 플랫폼
+ {renderChannelInfo()}
+
+
+
+
영상 제목
+
+
+
setTitle(e.target.value)}
+ />
+
+
+
+
+
영상 설명
+
+
+
+
+
+
+
해시태그
+
+
+
+ {renderHashtags()}
+
+
+
+
+
+
+
+
+
업로드된 영상은 마이페이지에서 확인해보세요.
+
+
+
+ 취소
+
+ {uploading ? '업로드 중...' : '업로드'}
+
+
+
+
+ );
+};
+
+export default VideoUploadPopUp;
\ No newline at end of file
diff --git a/frontend/src/components/VideoUploadPopUp/VideoUploadPopUp2.module.css b/frontend/src/components/VideoUploadPopUp/VideoUploadPopUp2.module.css
new file mode 100644
index 0000000..d503ec8
--- /dev/null
+++ b/frontend/src/components/VideoUploadPopUp/VideoUploadPopUp2.module.css
@@ -0,0 +1,522 @@
+.container {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(28, 28, 28, 0.60);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 5;
+}
+
+/* Content */
+.content {
+ width: 100%;
+ height: 100%;
+ max-width: 1200px;
+ max-height: 930px;
+ display: inline-flex;
+ padding: 2.5rem;
+ flex-direction: column;
+ gap: 2.5rem;
+ border-radius: 1rem;
+ background: #FFF;
+ z-index: 1000;
+ overflow-y: auto;
+}
+.content::-webkit-scrollbar{
+ background: transparent;
+}
+@media (max-height: 1000px) {
+ .content {
+ height: 40rem;
+ }
+}
+
+/* Header Container */
+.headerContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+}
+
+/* Header Title */
+.headerTitle {
+ color: #001227;
+ font-size: 1.5rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+
+/* Header Under Line */
+.headerUnderLine {
+ width: 100%;
+ height: 0.0625rem;
+ background: #D9D9D9;
+}
+
+/* Basic Info Container */
+.BasicInfoContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+/* Video Title */
+.videoTitle {
+ color: #001227;
+ font-size: 1.25rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+
+.videoSubInfo {
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Main Container */
+.mainContainer {
+ display: flex;
+ gap: 1.25rem;
+}
+
+/* Left Container */
+.leftContainer {
+ width: 50%;
+}
+
+/* Video */
+.video {
+ width: 100%;
+ aspect-ratio: 16/9;
+ object-fit: contain;
+}
+
+/* Right Container */
+.rightContainer {
+ width: 50%;
+ display: flex;
+ flex-direction: column;
+ gap: 2.5rem;
+}
+
+/* Upload Info Container */
+.uploadInfoContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+}
+
+/* Channel Container */
+.channelContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+}
+
+/* Channel Box */
+.channelBox {
+ display: flex;
+ width: 16.875rem;
+ padding: 1rem 1.25rem;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ border-radius: 0.75rem;
+ border: 1px solid rgba(0, 18, 39, 0.50);
+ background: #FFF;
+}
+
+/* Platform Container */
+.platformContainer {
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+/* Platform Box */
+.platformBox {
+ display: flex;
+ padding: 1rem 1.25rem;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ border-radius: 0.75rem;
+ border: 1px solid rgba(0, 18, 39, 0.50);
+ background: #FFF;
+ flex: 1;
+ height: 5.125rem;
+ cursor: pointer;
+}
+
+.platformBox:hover {
+ background: #F6F6F6;
+}
+
+/* Platform Header */
+.platformHeader {
+ color: #001227;
+ font-size: 0.75rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+}
+
+/* Platform Content */
+.platformContent {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+/* Platform Icon */
+.platformIcon {
+ width: 1.25rem;
+ height: 1.25rem;
+ aspect-ratio: 1/1;
+}
+
+/* Platform Name */
+.platformName {
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Dropdown Container */
+.dropdownContainer {
+ position: relative;
+ width: 100%;
+}
+
+/* Dropdown Toggle */
+.dropdownToggle {
+ width: 100%;
+ padding: 1rem 1.25rem;
+ background: #FFF;
+ border: 1px solid rgba(0, 18, 39, 0.50);
+ border-radius: 0.75rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ transition: background-color 0.2s ease;
+}
+
+.dropdownToggle:hover {
+ background: #F6F6F6;
+}
+
+.dropdownToggle.active {
+ border-color: #B8B5FF;
+}
+
+/* Dropdown Content */
+.dropdownContent {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+/* Dropdown Label */
+.dropdownLabel {
+ color: #001227;
+ font-size: 0.75rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+}
+
+/* Dropdown Value */
+.dropdownValue {
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Selected Channel Display */
+.selectedChannelDisplay {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+/* Selected Channel Icon */
+.selectedChannelIcon {
+ width: 1.25rem;
+ height: 1.25rem;
+ aspect-ratio: 1/1;
+}
+
+/* Selected Channel Name */
+.selectedChannelName {
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Dropdown Arrow */
+.dropdownArrow {
+ width: 1.25rem;
+ height: 1.25rem;
+ color: rgba(0, 18, 39, 0.50);
+ transition: transform 0.2s ease;
+}
+
+.dropdownToggle.active .dropdownArrow {
+ transform: rotate(180deg);
+}
+
+/* Dropdown Menu */
+.dropdownMenu {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ margin-top: 0.25rem;
+ background: #FFF;
+ border: 1px solid rgba(0, 18, 39, 0.50);
+ border-radius: 0.75rem;
+ box-shadow: 0 0.625rem 1.5625rem rgba(0, 0, 0, 0.1);
+ overflow: hidden;
+ z-index: 10;
+}
+
+/* Dropdown Item */
+.dropdownItem {
+ padding: 1rem 1.25rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ border-bottom: 1px solid #F3F4F6;
+ transition: background-color 0.2s ease;
+}
+
+.dropdownItem:last-child {
+ border-bottom: none;
+}
+
+.dropdownItem:hover {
+ background: #F6F6F6;
+}
+
+/* Channel Icon in Dropdown */
+.dropdownItem .channelIcon {
+ width: 1.25rem;
+ height: 1.25rem;
+ aspect-ratio: 1/1;
+}
+
+/* Channel Name in Dropdown */
+.dropdownItem .channelName {
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Input Container */
+.inputContainer {
+ width: 100%;
+}
+
+/* Input Box */
+.inputBox {
+ width: 100%;
+ padding: 1rem 1.25rem;
+ background: #FFF;
+ border: 1px solid rgba(0, 18, 39, 0.50);
+ border-radius: 0.75rem;
+ position: relative;
+}
+
+.inputBox:focus-within {
+ border-color: #B8B5FF;
+}
+
+/* Input Label */
+.inputLabel {
+ color: #001227;
+ font-size: 0.75rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ margin-bottom: 0.75rem;
+ display: block;
+}
+
+/* Text Input */
+.textInput {
+ width: 100%;
+ border: none;
+ outline: none;
+ background: transparent;
+
+ color: rgba(0, 18, 39, 0.50);
+ font-family: Pretendard;
+ font-size: 1.125rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+.textInput::placeholder {
+ color: rgba(0, 18, 39, 0.50);
+}
+
+/* Textarea Input */
+.textAreaInput {
+ width: 100%;
+ border: none;
+ outline: none;
+ background: transparent;
+ resize: none;
+ min-height: 4rem;
+
+ color: rgba(0, 18, 39, 0.50);
+ font-family: Pretendard;
+ font-size: 1.125rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+.textAreaInput::placeholder {
+ color: rgba(0, 18, 39, 0.50);
+}
+
+/* Counter Text */
+.counterText {
+ width: 100%;
+ text-align: right;
+ font-size: 0.75rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ color: rgba(0, 18, 39, 0.50);
+ margin-top: 0.5rem;
+}
+
+/* Button Container */
+.buttonContainer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+}
+
+/* Cancel Button */
+.cancelButton {
+ display: flex;
+ width: 5.9375rem;
+ padding: 0.5625rem 1.875rem;
+ justify-content: center;
+ align-items: center;
+ gap: 0.5625rem;
+ flex-shrink: 0;
+ border: none;
+ border-radius: 0.5625rem;
+ background: #A1A1A1;
+
+ color: #FFF;
+ font-family: Pretendard;
+ font-size: 1.125rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ word-break: keep-all;
+ cursor: pointer;
+}
+
+/* Upload Button */
+.uploadButton {
+ display: flex;
+ width: 5.9375rem;
+ padding: 0.5625rem 1.875rem;
+ justify-content: center;
+ align-items: center;
+ gap: 0.5625rem;
+ flex-shrink: 0;
+ border: none;
+ border-radius: 0.5625rem;
+ background: #B8B5FF;
+
+ color: #FFF;
+ font-family: Pretendard;
+ font-size: 1.125rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ word-break: keep-all;
+ cursor: pointer;
+}
+
+/* Disabled Button */
+.disabledButton {
+ background: #D1D5DB !important;
+ cursor: not-allowed !important;
+}
+
+.uploadButton:disabled {
+ background: #D1D5DB;
+ cursor: not-allowed;
+}
+
+
+/* Notification Text */
+.notificationText {
+ color: #FFF;
+ font-size: 0.875rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+/* Notification Container */
+.notificationContainer {
+ position: fixed;
+ top: 2rem;
+ left: 50%;
+ z-index: 10001;
+}
+
+/* Notification */
+.notification {
+ display: flex;
+ padding: 1rem 1.5rem;
+ align-items: center;
+ gap: 0.5rem;
+ border-radius: 0.5rem;
+ background: #10B981;
+ box-shadow: 0 0.25rem 0.375rem rgba(0, 0, 0, 0.1);
+ animation: slideDown 0.4s ease-out;
+ transform: translateX(-50%);
+}
+
+/* Slide Down Animation */
+@keyframes slideDown {
+ 0% {
+ opacity: 0;
+ transform: translateX(-50%) translateY(-0.3rem) scale(0.98);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0) scale(1);
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/components/VideoUploadPopUp/VideoUploadPopUp2.tsx b/frontend/src/components/VideoUploadPopUp/VideoUploadPopUp2.tsx
new file mode 100644
index 0000000..ca3d23f
--- /dev/null
+++ b/frontend/src/components/VideoUploadPopUp/VideoUploadPopUp2.tsx
@@ -0,0 +1,390 @@
+import React, { useState, useRef, useEffect } from "react";
+import styles from "./VideoUploadPopUp2.module.css";
+import youtube from "@/assets/images/youtube.png";
+import naver from "@/assets/images/naver.png";
+
+interface MyChannelInfo {
+ platform: string;
+ channelName: string;
+ channelId: string;
+}
+
+interface ChannelData {
+ platform: string;
+ channelName: string;
+ channelId: string;
+ subscriberCount: number;
+ thumbnailUrl: string;
+}
+
+interface VideoUploadPopUpProps {
+ videoTitle: string;
+ videoSubInfo: string;
+ videoDescription: string;
+ videoUrl: string;
+ videoHashtag: string[];
+ setIsUploadPopUpOpen: (isUploadPopUpOpen: boolean) => void;
+}
+
+const ChevronDownIcon: React.FC = () => (
+
+
+
+);
+
+const VideoUploadPopUp2: React.FC = ({
+ videoTitle,
+ videoSubInfo,
+ videoDescription,
+ videoUrl,
+ videoHashtag,
+ setIsUploadPopUpOpen
+}) => {
+ const privacy = ["공개", "비공개"];
+ const [postTitle, setPostTitle] = useState('');
+ const [postUrl, setPostUrl] = useState('');
+ const [postDescription, setPostDescription] = useState('');
+ const [myChannel, setMyChannel] = useState([]);
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [selectedChannel, setSelectedChannel] = useState(null);
+ const [isPrivacyDropdownOpen, setIsPrivacyDropdownOpen] = useState(false);
+ const [selectedPrivacy, setSelectedPrivacy] = useState('');
+ const [showNotification, setShowNotification] = useState(false);
+ const privacyDropdownRef = useRef(null);
+ const dropdownRef = useRef(null);
+
+ const closePopup = () => setIsUploadPopUpOpen(false);
+
+
+
+ // 외부 클릭시 드롭다운 닫기
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setIsDropdownOpen(false);
+ }
+ if (privacyDropdownRef.current && !privacyDropdownRef.current.contains(event.target as Node)) {
+ setIsPrivacyDropdownOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, []);
+
+ const handlePrivacyDropdownToggle = () => {
+ setIsPrivacyDropdownOpen(!isPrivacyDropdownOpen);
+ };
+
+ const handlePrivacySelect = (privacy: string) => {
+ setSelectedPrivacy(privacy);
+ setIsPrivacyDropdownOpen(false);
+ };
+
+ const handleYoutubeConnect = () => {
+ console.log("youtube connect");
+ const newChannel = {
+ platform: "youtube",
+ channelName: "Youtube Channel",
+ channelId: "1234567890"
+ };
+ setMyChannel([...myChannel, newChannel]);
+ };
+
+ const handleNaverConnect = () => {
+ console.log("naver connect");
+ const newChannel = {
+ platform: "naver",
+ channelName: "Naver Channel",
+ channelId: "1234567890"
+ };
+ setMyChannel([...myChannel, newChannel]);
+ };
+
+ const handleDropdownToggle = () => {
+ setIsDropdownOpen(!isDropdownOpen);
+ };
+
+ const handleChannelSelect = (channel: MyChannelInfo) => {
+ setSelectedChannel(channel);
+ setIsDropdownOpen(false);
+ };
+
+ const renderPlatformBox = (
+ platform: string,
+ icon: string,
+ name: string,
+ onClick: () => void
+ ) => (
+
+
채널 연결 플랫폼
+
+
+
{name}
+
+
+ );
+
+ const renderChannelDropdown = () => (
+
+
+
+
업로드 채널
+ {selectedChannel ? (
+
+
+
+ {selectedChannel.channelName}
+
+
+ ) : (
+
채널을 선택하세요
+ )}
+
+
+
+
+ {isDropdownOpen && (
+
+ {myChannel.map((channel, index) => (
+
handleChannelSelect(channel)}
+ >
+
+
{channel.channelName}
+
+ ))}
+
+ )}
+
+ );
+
+ // 입력 핸들러 함수들 추가
+ const handlePostTitleChange = (e: React.ChangeEvent) => {
+ if (e.target.value.length <= 100) {
+ setPostTitle(e.target.value);
+ }
+ };
+
+ const handlePostUrlChange = (e: React.ChangeEvent) => {
+ if (e.target.value.length <= 5000) {
+ setPostUrl(e.target.value);
+ }
+ };
+
+ const handlePostDescriptionChange = (e: React.ChangeEvent) => {
+ if (e.target.value.length <= 500) {
+ setPostDescription(e.target.value);
+ }
+ };
+
+ const renderInputFields = () => (
+ <>
+ {/* 게시글 제목 */}
+
+
+ 게시글 제목
+
+
+
{postTitle.length}/100
+
+
+
+ {/* 게시글 내용 */}
+
+
+ 게시글 내용
+
+
+
{postUrl.length}/5000
+
+
+ {/* 게시글 태그 */}
+
+
+ 게시글 태그
+
+
+
{postDescription.length}/500
+
+
+ >
+ );
+
+ const renderPrivacyDropdown = () => (
+
+
+
+ 공개 설정
+
+ {selectedPrivacy || "공개 설정을 선택하세요"}
+
+
+
+
+
+ {isPrivacyDropdownOpen && (
+
+ {privacy.map((item, index) => (
+
handlePrivacySelect(item)}
+ >
+ {item}
+
+ ))}
+
+ )}
+
+ );
+
+ // 업로드 버튼 활성화 조건 확인 함수
+ const isUploadEnabled = () => {
+ return postTitle.trim() !== '' &&
+ postUrl.trim() !== '' &&
+ postDescription.trim() !== '' &&
+ selectedPrivacy !== '' &&
+ selectedChannel !== null;
+ };
+
+ // 업로드 핸들러 함수 추가
+ const handleUpload = () => {
+ if (isUploadEnabled()) {
+ console.log("업로드 시작", {
+ channel: selectedChannel,
+ title: postTitle,
+ content: postUrl,
+ tags: postDescription,
+ privacy: selectedPrivacy
+ });
+
+ // 알림 표시
+ setShowNotification(true);
+
+ // 3초 후 알림 숨기기
+ setTimeout(() => {
+ setShowNotification(false);
+ }, 3000);
+ }
+ };
+
+ // 취소 핸들러 함수 추가
+ const handleCancel = () => {
+ closePopup();
+ };
+
+ // 알림 컴포넌트 추가 (renderPrivacyDropdown 함수 아래에)
+ const renderNotification = () => {
+ if (!showNotification) return null;
+
+ return (
+
+ );
+ };
+ return (
+
+ {renderNotification()}
+
e.stopPropagation()}>
+
+
업로드
+
+
+
+
+
+
+
+
+
+
+
{videoTitle}
+
{videoSubInfo}
+
+
+ {myChannel.length === 0 && (
+
+ {renderPlatformBox("youtube", youtube, "Youtube", handleYoutubeConnect)}
+ {renderPlatformBox("naver", naver, "네이버 클립", handleNaverConnect)}
+
+ )}
+
+ {myChannel.length > 0 && (
+
+ {renderChannelDropdown()}
+ {renderInputFields()}
+ {renderPrivacyDropdown()}
+
+
+ 취소
+
+
+ 업로드
+
+
+
+ )}
+
+
+
+
+ );
+};
+
+export default VideoUploadPopUp2;
\ No newline at end of file
diff --git a/frontend/src/components/VideoUploadPopUp/index.tsx b/frontend/src/components/VideoUploadPopUp/index.tsx
new file mode 100644
index 0000000..460912f
--- /dev/null
+++ b/frontend/src/components/VideoUploadPopUp/index.tsx
@@ -0,0 +1 @@
+export { default } from "./VideoUploadPopUp";
\ No newline at end of file
diff --git a/frontend/src/hooks/googleHooks.ts b/frontend/src/hooks/googleHooks.ts
new file mode 100644
index 0000000..834e75e
--- /dev/null
+++ b/frontend/src/hooks/googleHooks.ts
@@ -0,0 +1,136 @@
+// hooks/googleHooks.ts
+import { useRecoilCallback } from 'recoil';
+import { useSearchParams } from 'react-router-dom';
+import { useEffect, useRef } from 'react';
+import { googleLogin, getGoogleTokenInfo } from '@/api/social_login/google';
+import {
+ googleLoadingState,
+ googleErrorState,
+ googleUserInfoState,
+ googleProcessingState,
+ type GoogleUserInfo,
+} from '@/recoil/Atoms/googleAtom';
+
+// API 응답 타입 정의
+interface GoogleTokenResponse {
+ access_token: string;
+ refresh_token?: string;
+ user_info: GoogleUserInfo;
+}
+
+// Google 로그인 훅
+export const useGoogleLogin = () => {
+ return useRecoilCallback(() => () => {
+ const currentPath = window.location.href;
+ googleLogin(currentPath);
+ }, []);
+};
+
+// Google 로그아웃 훅
+export const useGoogleLogout = () => {
+ return useRecoilCallback(({ set, reset }) => () => {
+ // localStorage 정리
+ localStorage.removeItem('access_token');
+ localStorage.removeItem('refresh_token');
+ localStorage.removeItem('user_info');
+
+ // Recoil 상태 초기화
+ reset(googleUserInfoState);
+ reset(googleErrorState);
+ set(googleLoadingState, false);
+ set(googleProcessingState, false);
+ }, []);
+};
+
+// Google 에러 클리어 훅
+export const useGoogleClearError = () => {
+ return useRecoilCallback(({ reset }) => () => {
+ reset(googleErrorState);
+ }, []);
+};
+
+// Google OAuth 콜백 처리 훅
+export const useGoogleOAuthCallback = (
+ onLoginSuccess?: (userInfo: GoogleUserInfo) => void,
+ onLoginError?: (error: string) => void
+) => {
+ const [searchParams] = useSearchParams();
+ const processedRef = useRef(false);
+
+ const processCallback = useRecoilCallback(({ set }) => async () => {
+ const token = searchParams.get('token');
+ const authSuccess = searchParams.get('auth_success');
+ const errorParam = searchParams.get('error');
+
+ // 이미 처리했거나 기본 검증 실패
+ if (processedRef.current || !token || authSuccess !== 'true') {
+ return;
+ }
+
+ // 처리 시작 마킹
+ processedRef.current = true;
+
+ if (errorParam) {
+ const errorMessage = decodeURIComponent(errorParam);
+ set(googleErrorState, errorMessage);
+ onLoginError?.(errorMessage);
+ return;
+ }
+
+ // 처리 시작
+ set(googleProcessingState, true);
+ set(googleLoadingState, true);
+ set(googleErrorState, null);
+
+ try {
+ const response = await getGoogleTokenInfo(token);
+
+ // 타입 가드 함수
+ const isValidTokenResponse = (data: any): data is GoogleTokenResponse => {
+ return data &&
+ typeof data.access_token === 'string' &&
+ data.user_info &&
+ typeof data.user_info === 'object';
+ };
+
+ if (!isValidTokenResponse(response)) {
+ throw new Error('토큰 또는 사용자 정보가 없습니다.');
+ }
+
+ // 토큰 저장
+ localStorage.setItem('access_token', response.access_token);
+ if (response.refresh_token) {
+ localStorage.setItem('refresh_token', response.refresh_token);
+ }
+
+ // 사용자 정보 업데이트
+ set(googleUserInfoState, response.user_info);
+
+ // URL 정리
+ setTimeout(() => {
+ window.history.replaceState({}, '', window.location.pathname);
+ }, 100);
+
+ // 성공 콜백
+ onLoginSuccess?.(response.user_info);
+
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : '로그인 처리 중 오류가 발생했습니다.';
+ console.error('Google OAuth error:', error);
+
+ set(googleErrorState, errorMessage);
+ onLoginError?.(errorMessage);
+
+ // 에러 시 재시도 가능하도록 플래그 리셋
+ processedRef.current = false;
+
+ } finally {
+ set(googleLoadingState, false);
+ set(googleProcessingState, false);
+ }
+ }, [searchParams, onLoginSuccess, onLoginError]);
+
+ useEffect(() => {
+ processCallback();
+ }, [processCallback]);
+};
\ No newline at end of file
diff --git a/frontend/src/hooks/useGoogleAuth.ts b/frontend/src/hooks/useGoogleAuth.ts
new file mode 100644
index 0000000..23572f5
--- /dev/null
+++ b/frontend/src/hooks/useGoogleAuth.ts
@@ -0,0 +1,50 @@
+import { useRecoilValue } from 'recoil';
+import { type GoogleUserInfo } from '@/recoil/Atoms/googleAtom';
+import { googleOAuthStateSelector } from '@/recoil/Selectors/googleSelectors';
+import {
+ useGoogleLogin,
+ useGoogleLogout,
+ useGoogleClearError,
+ useGoogleOAuthCallback
+} from '@/hooks/googleHooks';
+
+interface UseGoogleOAuthRecoilReturn {
+ // 상태
+ isLoggedIn: boolean;
+ isLoading: boolean;
+ error: string | null;
+ userInfo: GoogleUserInfo | null;
+
+ // 액션
+ login: () => void;
+ logout: () => void;
+ clearError: () => void;
+}
+export const useGoogleOAuth = (
+ onLoginSuccess?: (userInfo: GoogleUserInfo) => void,
+ onLoginError?: (error: string) => void
+): UseGoogleOAuthRecoilReturn => {
+ // Recoil 상태 조회
+ const { isLoggedIn, isLoading, error, userInfo } = useRecoilValue(googleOAuthStateSelector);
+
+ // 액션 훅들
+ const login = useGoogleLogin();
+ const logout = useGoogleLogout();
+ const clearError = useGoogleClearError();
+
+ // OAuth 콜백 처리
+ useGoogleOAuthCallback(onLoginSuccess, onLoginError);
+
+ return {
+ // 상태
+ isLoggedIn,
+ isLoading,
+ error,
+ userInfo,
+
+ // 액션
+ login,
+ logout,
+ clearError,
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/hooks/useLogin.ts b/frontend/src/hooks/useLogin.ts
new file mode 100644
index 0000000..e87b457
--- /dev/null
+++ b/frontend/src/hooks/useLogin.ts
@@ -0,0 +1,70 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { useSetRecoilState } from "recoil";
+import { authAtom } from "@/recoil/Atoms/authAtom";
+import { login as loginApi } from "@/api/auth/auth";
+
+export const useLogin = () => {
+ const navigate = useNavigate();
+ const setAuth = useSetRecoilState(authAtom);
+
+ const [isLoading, setIsLoading] = useState(false);
+ const [errorCode, setErrorCode] = useState("");
+ const [errorMessage, setErrorMessage] = useState("");
+
+ const clearErrors = () => {
+ setErrorCode("");
+ setErrorMessage("");
+ };
+
+ const handleLoginSuccess = (id: string, name: string) => {
+ setAuth({
+ isLoggedIn: true,
+ id: id,
+ name: name,
+ platform: "local"
+ });
+ localStorage.setItem("id", id);
+ localStorage.setItem("name", name);
+ navigate("/");
+ };
+
+ const handleLoginFailure = (response: any) => {
+ const code = response.error_code ?? "LOGIN_FAILED";
+ const message = response.error_message ?? "로그인에 실패했습니다";
+
+ setErrorCode(code);
+ setErrorMessage(message);
+ };
+
+ const handleNetworkError = (error: any) => {
+ alert(error);
+ setErrorCode("NETWORK_ERROR");
+ setErrorMessage("네트워크 오류가 발생했습니다");
+ };
+
+ const login = async (user_id: string, password: string) => {
+ try {
+ setIsLoading(true);
+ clearErrors();
+
+ const response = await loginApi({ user_id, password });
+ if (response.ok) {
+ handleLoginSuccess(response.data?.id ?? "", response.data?.name ?? "");
+ } else {
+ handleLoginFailure(response);
+ }
+ } catch (error) {
+ handleNetworkError(error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return {
+ login,
+ errorCode,
+ errorMessage,
+ isLoading
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/hooks/useLogout.ts b/frontend/src/hooks/useLogout.ts
new file mode 100644
index 0000000..d979f14
--- /dev/null
+++ b/frontend/src/hooks/useLogout.ts
@@ -0,0 +1,26 @@
+import { useNavigate } from "react-router-dom";
+import { useSetRecoilState } from "recoil";
+import { authAtom } from "@/recoil/Atoms/authAtom";
+
+export const useLogout = () => {
+ const navigate = useNavigate();
+ const setAuth = useSetRecoilState(authAtom);
+
+ const handleLogout = async () => {
+ localStorage.removeItem('id');
+ localStorage.removeItem('name');
+ localStorage.removeItem('task_id');
+ localStorage.removeItem('currentStep');
+ localStorage.removeItem('order_id');
+
+ setAuth({
+ isLoggedIn: false,
+ id: "",
+ name: "",
+ platform: "local",
+ });
+ navigate('/');
+ }
+
+ return handleLogout;
+};
\ No newline at end of file
diff --git a/frontend/src/hooks/useSignUp.ts b/frontend/src/hooks/useSignUp.ts
new file mode 100644
index 0000000..b24f40b
--- /dev/null
+++ b/frontend/src/hooks/useSignUp.ts
@@ -0,0 +1,98 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { useSetRecoilState } from "recoil";
+import { authAtom } from "@/recoil/Atoms/authAtom";
+import { signUp as signUpApi } from "@/api/auth/auth";
+
+interface SignUpData {
+ user_id: string;
+ name: string;
+ password: string;
+ password_check: string;
+}
+
+export const useSignUp = () => {
+ const navigate = useNavigate();
+ const setAuth = useSetRecoilState(authAtom);
+
+ const [isLoading, setIsLoading] = useState(false);
+ const [errorCode, setErrorCode] = useState("");
+ const [errorMessage, setErrorMessage] = useState("");
+
+ const clearErrors = () => {
+ setErrorCode("");
+ setErrorMessage("");
+ };
+
+ const PasswordCheck = (password: string, password_check: string) => {
+ if (password !== password_check) {
+ setErrorCode("PASSWORD_NOT_MATCH");
+ setErrorMessage("비밀번호가 일치하지 않습니다");
+ return false;
+ }
+ return true;
+ };
+
+ const handleSignUpSuccess = (id: string, name: string) => {
+ setAuth({
+ isLoggedIn: true,
+ id: id,
+ name: name,
+ platform: "local"
+ });
+ localStorage.setItem("id", id);
+ localStorage.setItem("name", name);
+ navigate("/");
+ };
+
+ const handleSignUpFailure = (response: any) => {
+ const code = response.error_code ?? "SIGNUP_FAILED";
+ const message = response.error_message ?? "회원가입에 실패했습니다";
+
+ setErrorCode(code);
+ setErrorMessage(message);
+ };
+
+ const handleNetworkError = (error: any) => {
+ alert(error);
+ setErrorCode("NETWORK_ERROR");
+ setErrorMessage("네트워크 오류가 발생했습니다");
+ };
+
+ const singUp = async (data: SignUpData) => {
+ try {
+ setIsLoading(true);
+ clearErrors();
+
+ // 비밀번호 확인
+ if (!PasswordCheck(data.password, data.password_check)) {
+ setIsLoading(false);
+ return;
+ }
+
+ // 회원가입
+ const response = await signUpApi({
+ user_id: data.user_id,
+ name: data.name,
+ password: data.password,
+ });
+
+ if (response.ok) {
+ handleSignUpSuccess(response.data?.id ?? "", response.data?.name ?? "");
+ } else {
+ handleSignUpFailure(response);
+ }
+ } catch (error) {
+ handleNetworkError(error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ return {
+ singUp,
+ errorCode,
+ errorMessage,
+ isLoading,
+ };
+};
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..b991771
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,79 @@
+@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
+
+:root {
+ font-family: Pretendard, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+
+ --mycustom-color: linear-gradient(
+ 90deg,
+ rgba(174, 0, 255, 0.80) 0%,
+ rgba(60, 219, 255, 0.80) 100%
+ ), #3C9AFF;
+}
+*{
+ box-sizing: border-box;
+}
+html, body, #root {
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+}
+
+a {
+ text-decoration: inherit;
+}
+
+/*
+a:hover {
+ color: #535bf2;
+} */
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+/* button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+} */
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..a6601b3
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,13 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { RecoilRoot } from 'recoil'
+import './index.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+)
diff --git a/frontend/src/pages/Home/Home.tsx b/frontend/src/pages/Home/Home.tsx
new file mode 100644
index 0000000..c74f46b
--- /dev/null
+++ b/frontend/src/pages/Home/Home.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import Header from '@/components/Header';
+import Footer from '@/components/Footer';
+
+import { HomeHeroSection } from './HomeHeroSection';
+import { HomeFeatureSection } from './HomeFeatureSection';
+import { HomeGuideSection } from './HomeGuideSection';
+import { HomeActionSection } from './HomeActionSection';
+
+const Home: React.FC = () => {
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Home;
\ No newline at end of file
diff --git a/frontend/src/pages/Home/HomeActionSection.module.css b/frontend/src/pages/Home/HomeActionSection.module.css
new file mode 100644
index 0000000..f2a760f
--- /dev/null
+++ b/frontend/src/pages/Home/HomeActionSection.module.css
@@ -0,0 +1,103 @@
+/* Section */
+.section {
+ width: 100%;
+ padding-top: 6.25rem;
+ padding-bottom: 11.56rem;
+ background: #FBFAFF;
+}
+
+/* Container */
+.container {
+ max-width: 80rem;
+ margin: 0 auto;
+ padding: 0 1rem;
+}
+
+/* Header */
+.header {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ margin-bottom: 3.69rem;
+}
+
+.title {
+ color: #422047;
+ text-align: center;
+ font-size: 3rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+
+.subtitle {
+ color: rgba(0, 18, 39, 0.50);
+ text-align: center;
+ font-size: 1.25rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ word-break: keep-all;
+}
+
+/* Button Container */
+.buttonContainer {
+ display: flex;
+ justify-content: center;
+}
+
+/* Start Button */
+.buttonText {
+ color: #FFF;
+ font-family: Pretendard;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+/* .startButton {
+ display: flex;
+ padding: 0.875rem 1.25rem;
+ justify-content: center;
+ align-items: center;
+ gap: 0.625rem;
+ border-radius: 2.5rem;
+ background: linear-gradient(90deg, rgba(9, 210, 255, 0.80) 0%, rgba(192, 56, 255, 0.80) 100%), #3C9AFF;
+} */
+
+.startButton {
+ position: relative;
+ overflow: hidden;
+ display: flex;
+ padding: 0.875rem 1.25rem;
+ justify-content: center;
+ align-items: center;
+ gap: 0.625rem;
+ border-radius: 2.5rem;
+ background: linear-gradient(90deg, rgba(9, 210, 255, 0.80) 0%, rgba(192, 56, 255, 0.80) 100%);
+ color: white;
+ border: none;
+ cursor: pointer;
+ z-index: 0;
+}
+
+
+.startButton::before {
+ content: "";
+ position: absolute;
+ top: 0; left: -75%;
+ width: 50%;
+ height: 100%;
+ background: linear-gradient(120deg, transparent, rgba(255,255,255,0.4), transparent);
+ transform: skewX(-20deg);
+ transition: left 0.6s ease;
+ z-index: 1;
+ pointer-events: none;
+}
+
+
+.startButton:hover::before {
+ left: 125%;
+}
diff --git a/frontend/src/pages/Home/HomeActionSection.tsx b/frontend/src/pages/Home/HomeActionSection.tsx
new file mode 100644
index 0000000..2576cc4
--- /dev/null
+++ b/frontend/src/pages/Home/HomeActionSection.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+import styles from "./HomeActionSection.module.css";
+import playIcon from "../../assets/icons/play.svg";
+
+export const HomeActionSection: React.FC = () => {
+ return (
+
+
+
+
지금 바로 시작하세요
+
+ URL 입력으로 AI가 자동으로 홍보 음악과 영상을 제작해 드립니다.
+ 고객의 시선을 사로잡는 프로모션 콘텐츠를 쉽고 빠르게 만들어보세요.
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/pages/Home/HomeFeatureSection.module.css b/frontend/src/pages/Home/HomeFeatureSection.module.css
new file mode 100644
index 0000000..f274ba4
--- /dev/null
+++ b/frontend/src/pages/Home/HomeFeatureSection.module.css
@@ -0,0 +1,138 @@
+.section {
+ width: 100%;
+ padding: 3.75rem 0 1.25rem 0;
+}
+
+.header {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ margin-bottom: 3.69rem;
+}
+
+.title {
+ color: #422047;
+ text-align: center;
+ font-size: 3rem;
+ font-weight: 700;
+ letter-spacing: 0.15rem;
+}
+
+.subtitle {
+ color: rgba(0, 18, 39, 0.50);
+ text-align: center;
+ font-size: 1.25rem;
+ font-weight: 400;
+}
+
+.cardContainer {
+ display: flex;
+ width: 100%;
+ justify-content: center;
+ padding: 2.5rem;
+ background: linear-gradient(
+ 262deg,
+ rgba(193, 136, 255, 0.6) 0%,
+ rgba(125, 131, 255, 0.6) 50%,
+ rgba(122, 186, 255, 0.6) 100%
+ );
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 1.5rem;
+}
+
+@media (min-width: 840px) {
+ .grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (min-width: 1275px) {
+ .grid {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+.card {
+ max-width: 26.625rem;
+ background: #fff;
+ border-radius: 1.25rem;
+ box-shadow: 4px 12px 20px 0 rgba(189, 66, 255, 0.15);
+ transition: all 0.3s ease;
+ opacity: 1;
+ transform: translateY(0);
+}
+
+/* 기본 hover 효과 - 애니메이션이 없을 때 적용 */
+.card:hover {
+ transform: translateY(-6px) scale(1.01);
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
+}
+
+/* 애니메이션 중일 때는 hover 효과 비활성화 */
+.animate .card {
+ animation: fadeInUp 0.6s ease forwards;
+}
+
+.animate .card:hover {
+ transform: none; /* 애니메이션 중에는 hover 효과 제거 */
+ box-shadow: 4px 12px 20px 0 rgba(189, 66, 255, 0.15);
+}
+
+@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.cardBody {
+ display: flex;
+ flex-direction: column;
+ padding: 2.5rem;
+ gap: 2.5rem;
+}
+
+.cardHeader {
+ display: flex;
+ flex-direction: row;
+ gap: 1.5rem;
+ align-items: center;
+}
+
+.cardTitle {
+ color: #001227;
+ font-size: 1.75rem;
+ font-weight: 700;
+}
+
+.featureDescription {
+ color: #424242;
+ font-size: 1.25rem;
+ font-weight: 400;
+ word-break: keep-all;
+}
+
+.iconContainer {
+ background-color: #faf1ff;
+ padding: 0.25rem 0.125rem;
+ border-radius: 0.75rem;
+ width: 3rem;
+ height: 3rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.icon {
+ font-size: 1.5rem;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Home/HomeFeatureSection.tsx b/frontend/src/pages/Home/HomeFeatureSection.tsx
new file mode 100644
index 0000000..61ffb95
--- /dev/null
+++ b/frontend/src/pages/Home/HomeFeatureSection.tsx
@@ -0,0 +1,122 @@
+import React, { useEffect, useRef, useState } from "react";
+import styles from "./HomeFeatureSection.module.css";
+import imageIcon from "@/assets/icons/image.svg";
+import musicNoteIcon from "@/assets/icons/music_note.svg";
+import settingIcon from "@/assets/icons/setting.svg";
+import downloadIcon from "@/assets/icons/download.svg";
+import timeIcon from "@/assets/icons/time.svg";
+import video2Icon from "@/assets/icons/video2.svg";
+
+const features = [
+ {
+ icon: imageIcon,
+ title: "자동 이미지 수집",
+ description: "간단한 정보만 입력하면 AI가 자동으로 최적의 이미지를 수집합니다."
+ },
+ {
+ icon: musicNoteIcon,
+ title: "맞춤형 음악 생성",
+ description: "홍보 대상의 분위기와 특성에 맞는 독창적인 홍보용 노래를 AI가 생성합니다."
+ },
+ {
+ icon: video2Icon,
+ title: "프로모션 영상 제작",
+ description: "수집된 이미지와 생성된 음악을 결합하여 더 매력적인 홍보영상을 만듭니다."
+ },
+ {
+ icon: timeIcon,
+ title: "프로젝트 관리",
+ description: "마이페이지에서 이전에 만든 프로젝트를 확인하고 관리할 수 있습니다."
+ },
+ {
+ icon: settingIcon,
+ title: "커스터마이징 옵션",
+ description: "음악 장르와 분위기를 선택하여 원하는 스타일의 콘텐츠를 제작합니다."
+ },
+ {
+ icon: downloadIcon,
+ title: "손쉬운 다운로드",
+ description: "완성된 음악과 영상을 바로 다운로드하여 소셜 미디어에 공유할 수 있습니다."
+ }
+];
+
+export const HomeFeatureSection: React.FC = () => {
+ const [isVisible, setIsVisible] = useState(false);
+ const [animationComplete, setAnimationComplete] = useState(false);
+ const sectionRef = useRef
(null);
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ setIsVisible(true);
+ }
+ },
+ { threshold: 0.6, rootMargin: '-100px 0px' }
+ );
+
+ if (sectionRef.current) {
+ observer.observe(sectionRef.current);
+ }
+
+ return () => {
+ if (sectionRef.current) {
+ observer.unobserve(sectionRef.current);
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ if (isVisible) {
+ // 마지막 카드의 애니메이션이 완료되는 시점 계산
+ // 마지막 카드 delay + 애니메이션 duration
+ const lastCardDelay = (features.length - 1) * 150; // 0.15s * 1000
+ const animationDuration = 600; // 0.6s * 1000
+ const totalTime = lastCardDelay + animationDuration;
+
+ const timer = setTimeout(() => {
+ setAnimationComplete(true);
+ }, totalTime);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isVisible]);
+
+ return (
+
+
+
+
서비스 특징
+
+ ADO2는 AI 기술을 활용하여 홍보에 필요한 모든 콘텐츠를 자동으로 생성합니다.
+
+
+
+
+
+ {features.map((feature, index) => (
+
+
+
+
+
+
+
{feature.title}
+
+
{feature.description}
+
+
+ ))}
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/pages/Home/HomeGuideSection.module.css b/frontend/src/pages/Home/HomeGuideSection.module.css
new file mode 100644
index 0000000..7f4a5a2
--- /dev/null
+++ b/frontend/src/pages/Home/HomeGuideSection.module.css
@@ -0,0 +1,117 @@
+/* Section */
+.section {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding-top: 3.75rem;
+ padding-bottom: 1.25rem;
+ background-color: #FFF;
+}
+
+/* Container */
+/* .container {
+ max-width: 72rem;
+ margin: 0 auto;
+ padding: 0 1rem;
+} */
+
+/* Header */
+.header {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ margin-bottom: 3.75rem;
+}
+
+
+.title {
+ color: #422047;
+ text-align: center;
+ font-size: 3rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ letter-spacing: 0.15rem;
+}
+
+.subtitle {
+ color: rgba(0, 18, 39, 0.50);
+ text-align: center;
+ font-size: 1.25rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Card */
+.cardContainer {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ gap: 1.5rem;
+ background: #FAF1FF;
+ padding: 2.5rem 2rem;
+ overflow-x: auto; /* scroll → auto로 변경 */
+ scrollbar-width: none; /* Firefox */
+ -ms-overflow-style: none; /* IE 10+ */
+ cursor: grab;
+}
+.cardContainer::-webkit-scrollbar {
+ display: none; /* Chrome, Safari */
+}
+.card{
+ display: flex;
+ min-width: 21.5625rem;
+ min-height: 14.25rem;
+ padding: 1.25rem;
+ flex-direction: column;
+ justify-content: center;
+ gap: 2.5rem;
+ border-radius: 1rem;
+ background: #FFF;
+ box-shadow: 4px 12px 20px 0px rgba(180, 60, 255, 0.15);
+
+ /* 텍스트 드래그 방지 */
+ user-select: none;
+ -webkit-user-select: none; /* Safari */
+ -ms-user-select: none; /* IE/Edge */
+
+ transition: transform 0.3s ease;
+}
+.card:hover{
+ transform: translateY(-6px) scale(1.01);
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
+}
+.cardHeader{
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.cardNumber{
+ color: #C969FF;
+ font-size: 2rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+.cardTitle{
+ color: #001227;
+ font-size: 1.75rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+.cardDescription{
+ color: #424242;
+ font-size: 1.25rem;
+ font-style: normal;
+ font-weight: 400;
+ white-space: normal;
+ word-break: keep-all;
+}
diff --git a/frontend/src/pages/Home/HomeGuideSection.tsx b/frontend/src/pages/Home/HomeGuideSection.tsx
new file mode 100644
index 0000000..f520c66
--- /dev/null
+++ b/frontend/src/pages/Home/HomeGuideSection.tsx
@@ -0,0 +1,94 @@
+import React, { useEffect, useRef, useState } from "react";
+import styles from "./HomeGuideSection.module.css";
+
+const data = [
+ {
+ number: "01",
+ title: "URL 정보 입력",
+ description: "원하는 홍보 대상의 URL을 입력합니다."
+ },
+ {
+ number: "02",
+ title: "이미지 수집 및 확인",
+ description: "크롤링을 통해 메타데이터를 수집하고 원하는 이미지를 선택할 수 있습니다."
+ },
+ {
+ number: "03",
+ title: "음악 스타일 선택",
+ description: "홍보 대상의 분위기에 맞는 음악 장르와 음악 스타일을 지정합니다."
+ },
+ {
+ number: "04",
+ title: "음악 생성",
+ description: "선택한 스타일에 맞는 독창적인 음악을 AI가 만들어냅니다."
+ },
+ {
+ number: "05",
+ title: "영상 제작",
+ description: "수집된 이미지와 생성된 음악을 결합하여 홍보 영상을 만듭니다."
+ },
+ {
+ number: "06",
+ title: "다운로드 및 공유",
+ description: "완성된 콘텐츠를 활용하여 소셜 미디어에 공유합니다."
+ }
+];
+export const HomeGuideSection: React.FC = () => {
+ const scrollRef = useRef(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const [startX, setStartX] = useState(0);
+ const [scrollLeft, setScrollLeft] = useState(0);
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ if (!scrollRef.current) return;
+ setIsDragging(true);
+ setStartX(e.clientX);
+ setScrollLeft(scrollRef.current.scrollLeft);
+ };
+
+ const handleMouseMove = (e: React.MouseEvent) => {
+ if (!isDragging || !scrollRef.current) return;
+ const walk = e.clientX - startX;
+ scrollRef.current.scrollLeft = scrollLeft - walk;
+ };
+
+ useEffect(() => {
+ const handleWindowMouseUp = () => {
+ setIsDragging(false);
+ };
+
+ window.addEventListener('mouseup', handleWindowMouseUp);
+ return () => {
+ window.removeEventListener('mouseup', handleWindowMouseUp);
+ };
+ }, []);
+
+ return (
+
+
+
이용 방법
+
+ 간단한 절차로 홍보 컨텐츠를 만들어보세요.
+
+
+
+
+ {data.map((item, index) => (
+
+
+
{item.number}
+
{item.title}
+
+
{item.description}
+
+ ))}
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/pages/Home/HomeHeroSection.module.css b/frontend/src/pages/Home/HomeHeroSection.module.css
new file mode 100644
index 0000000..08bb4a2
--- /dev/null
+++ b/frontend/src/pages/Home/HomeHeroSection.module.css
@@ -0,0 +1,171 @@
+.heroSection {
+ width: 100%;
+ padding-top: 4rem;
+ padding-bottom: 6rem;
+ padding-left: 9.69rem;
+ padding-right: 9.69rem;
+ background: rgba(247, 247, 247, 0.50);
+}
+
+.container {
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ display: flex;
+
+ width: 100%;
+ height: 33.69rem;
+ justify-content: space-between;
+}
+
+@media (max-width: 1670px) {
+ .container {
+ flex-direction: column;
+ align-items: center;
+ height: 100%;
+ }
+}
+
+.textSection {
+ width: 32rem;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ margin-right: 5rem;
+}
+
+.title{
+ color: #422047;
+ font-size: 3rem;
+ font-weight: 700;
+ letter-spacing: -0.06rem;
+ word-break: keep-all;
+}
+.subtitle{
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 1.25rem;
+ font-weight: 500;
+ letter-spacing: normal;
+ word-break: keep-all;
+ margin-bottom: 2.56rem;
+ margin-top: auto;
+}
+
+.buttonGroup {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ margin-top: auto;
+}
+
+.startButton {
+ padding: 1rem;
+ border-radius: 2.5rem;
+ font-size: 1rem;
+ font-weight: 500;
+
+ position: relative;
+ overflow: hidden;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 0.625rem;
+ background: linear-gradient(90deg, rgba(174, 0, 255, 0.80) 0%, rgba(60, 219, 255, 0.80) 100%);
+ color: white;
+ border: none;
+ cursor: pointer;
+ z-index: 0;
+}
+
+
+.startButton::before {
+ content: "";
+ position: absolute;
+ top: 0; left: -75%;
+ width: 50%;
+ height: 100%;
+ background: linear-gradient(120deg, transparent, rgba(255,255,255,0.4), transparent);
+ transform: skewX(-20deg);
+ transition: left 0.6s ease;
+ z-index: 1;
+ pointer-events: none;
+}
+
+
+.startButton:hover::before {
+ left: 125%;
+}
+
+.infoButton{
+ width: 9.0625rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.625rem;
+ padding: 1rem;
+ text-decoration: none;
+ border-radius: 2.5rem;
+ border: 1px solid #001227;
+ background: #FFF;
+ color: #001227;
+ cursor: pointer;
+ font-size: 1rem;
+ font-weight: 500;
+ line-height: normal;
+ font-style: normal;
+}
+.icon {
+ width: 1rem;
+ height: 1rem;
+}
+
+.video{
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+ object-fit: cover;
+}
+@media (max-width: 1670px) {
+ .video{
+ width: 80%;
+ height: 100%;
+ max-height: 500px;
+ margin-top:5rem;
+ }
+ .textSection{
+ width: 80%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ margin-right: 0;
+ }
+}
+
+
+/* @media (max-width: 1380px) {
+ .title{
+ display: flex;
+ width: auto;
+ justify-content: center;
+ align-items: center;
+ }
+ .subtitle{
+ display: flex;
+ width: auto;
+ justify-content: center;
+ align-items: center;
+ }
+ .buttonGroup{
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+ .videoSection{
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-left: 0;
+ }
+} */
\ No newline at end of file
diff --git a/frontend/src/pages/Home/HomeHeroSection.tsx b/frontend/src/pages/Home/HomeHeroSection.tsx
new file mode 100644
index 0000000..2bd81d4
--- /dev/null
+++ b/frontend/src/pages/Home/HomeHeroSection.tsx
@@ -0,0 +1,34 @@
+import React from "react";
+import styles from "./HomeHeroSection.module.css";
+import videoIcon from "@/assets/icons/video.svg";
+import infoIcon from "@/assets/icons/info.svg";
+import tempVideo from "@/temp/temp1.mp4";
+
+export const HomeHeroSection: React.FC = () => {
+ return (
+
+
+
+
+
AI로 손쉽게 만드는 마케팅 콘텐츠
+
+ URL만 입력하면 AI가 정보를 수집하고 이에 맞는 최적의 맞춤형 음악과 영상을 제작해드립니다.
+ 어렵고 특별한 기술 없이도 전문적인 홍보 콘텐츠를 만들어보세요!
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Home/index.tsx b/frontend/src/pages/Home/index.tsx
new file mode 100644
index 0000000..14e2ff0
--- /dev/null
+++ b/frontend/src/pages/Home/index.tsx
@@ -0,0 +1 @@
+export { default } from './Home';
\ No newline at end of file
diff --git a/frontend/src/pages/Home/tempGuid.tsx b/frontend/src/pages/Home/tempGuid.tsx
new file mode 100644
index 0000000..6917ebf
--- /dev/null
+++ b/frontend/src/pages/Home/tempGuid.tsx
@@ -0,0 +1,86 @@
+import React, { useEffect, useRef, useState } from "react";
+import styles from "./tempGuide.module.css";
+
+const steps = [
+ {
+ number: "01",
+ title: "펜션 정보 입력",
+ description: "펜션 이름, 주소, 웹사이트 URL을 입력합니다."
+ },
+ {
+ number: "02",
+ title: "이미지 수집 및 확인",
+ description: "AI가 자동으로 이미지를 수집하고, 원하는 이미지를 선택할 수 있습니다."
+ },
+ {
+ number: "03",
+ title: "음악 스타일 선택",
+ description: "펜션 분위기에 맞는 음악 장르와 스타일을 선택합니다."
+ },
+ {
+ number: "04",
+ title: "음악 생성",
+ description: "AI가 선택한 스타일에 맞는 독창적인 음악을 작곡합니다."
+ },
+ {
+ number: "05",
+ title: "영상 제작",
+ description: "수집된 이미지와 생성된 음악을 결합하여 홍보 영상을 만듭니다."
+ },
+ {
+ number: "06",
+ title: "다운로드 및 공유",
+ description: "완성된 콘텐츠를 다운로드하고 소셜 미디어에 공유합니다."
+ }
+];
+
+export const TempGuide: React.FC = () => {
+ const [isVisible, setIsVisible] = useState(false);
+ const sectionRef = useRef(null);
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ setIsVisible(true);
+ }
+ },
+ { threshold: 0.1, rootMargin: '-50px' }
+ );
+
+ if (sectionRef.current) {
+ observer.observe(sectionRef.current);
+ }
+
+ return () => {
+ if (sectionRef.current) {
+ observer.unobserve(sectionRef.current);
+ }
+ };
+ }, []);
+
+ return (
+
+
+
+
이용 방법
+
+ 간단한 6단계로 펜션 홍보 콘텐츠를 만들어보세요.
+
+
+
+
+ {steps.map((step, index) => (
+
+
+
{step.number}
+
{step.title}
+
{step.description}
+
+
+ ))}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/pages/Home/tempGuide.module.css b/frontend/src/pages/Home/tempGuide.module.css
new file mode 100644
index 0000000..4c65f68
--- /dev/null
+++ b/frontend/src/pages/Home/tempGuide.module.css
@@ -0,0 +1,106 @@
+/* Section */
+.section {
+ width: 100%;
+ padding: 5rem 0;
+ background-color: var(--primary-50, #f0f9ff);
+ }
+
+ /* Container */
+ .container {
+ max-width: 72rem;
+ margin: 0 auto;
+ padding: 0 1rem;
+ }
+
+ /* Header */
+ .header {
+ text-align: center;
+ margin-bottom: 4rem;
+ }
+
+ .title {
+ font-size: 1.875rem;
+ font-weight: 700;
+ margin-bottom: 1rem;
+ }
+
+ .subtitle {
+ color: var(--foreground-500, #64748b);
+ max-width: 32rem;
+ margin: 0 auto;
+ }
+
+ /* Grid */
+ .grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 1.5rem;
+ }
+
+ @media (min-width: 768px) {
+ .grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+ }
+
+ @media (min-width: 1024px) {
+ .grid {
+ grid-template-columns: repeat(3, 1fr);
+ }
+ }
+
+ /* Card */
+ .card {
+ height: 100%;
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
+ transition: all 0.3s ease;
+ /* Initial state for animation */
+ opacity: 0;
+ transform: translateY(20px);
+ }
+
+ .card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+ }
+
+ /* Animated state */
+ .animate .card {
+ animation: fadeInUp 0.6s ease forwards;
+ }
+
+ /* Keyframe animation */
+ @keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ .cardBody {
+ padding: 1.5rem;
+ }
+
+ /* Step Content */
+ .stepNumber {
+ font-size: 2.25rem;
+ font-weight: 700;
+ color: var(--primary-300, #93c5fd);
+ margin-bottom: 1rem;
+ }
+
+ .stepTitle {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ }
+
+ .stepDescription {
+ color: var(--foreground-500, #64748b);
+ }
\ No newline at end of file
diff --git a/frontend/src/pages/ImageEditor/ImageEditor.module.css b/frontend/src/pages/ImageEditor/ImageEditor.module.css
new file mode 100644
index 0000000..ced16ff
--- /dev/null
+++ b/frontend/src/pages/ImageEditor/ImageEditor.module.css
@@ -0,0 +1,5 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/ImageEditor/ImageEditor.tsx b/frontend/src/pages/ImageEditor/ImageEditor.tsx
new file mode 100644
index 0000000..82008e8
--- /dev/null
+++ b/frontend/src/pages/ImageEditor/ImageEditor.tsx
@@ -0,0 +1,17 @@
+import React from "react";
+import styles from "./ImageEditor.module.css";
+import Header from "@/components/Header";
+import Footer from "@/components/Footer";
+import ImageEditorContent from "./ImageEditorContent";
+
+const ImageEditor: React.FC = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+export default ImageEditor;
\ No newline at end of file
diff --git a/frontend/src/pages/ImageEditor/ImageEditorContent.module.css b/frontend/src/pages/ImageEditor/ImageEditorContent.module.css
new file mode 100644
index 0000000..0b81908
--- /dev/null
+++ b/frontend/src/pages/ImageEditor/ImageEditorContent.module.css
@@ -0,0 +1,137 @@
+.content {
+ display: flex;
+ flex: 1;
+ justify-content: center;
+ align-items: center;
+ border-top: 1px solid #D9D9D9;
+ background: linear-gradient(262deg, rgba(193, 136, 255, 0.60) 0%, rgba(125, 131, 255, 0.60) 50%, rgba(122, 186, 255, 0.60) 100%);
+ padding: 2rem;
+}
+
+/* Box Container */
+.boxContainer {
+ width: 70.25rem;
+
+ display: flex;
+ justify-content: end;
+ flex-direction: column;
+ padding: 1.25rem;
+ flex-shrink: 1;
+
+ border-radius: 0.75rem;
+ background: #FFF;
+}
+
+/* Header Container */
+.headerContainer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ margin-bottom: 1.25rem;
+}
+
+/* Title Container */
+.titleContainer {
+ flex: 1;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+/* Title */
+.title {
+ color: #001227;
+ text-align: center;
+ font-size: 1.5rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+.subTitle{
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Photo Select Container */
+.photoSelectContainer {
+ flex-shrink: 0;
+
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+}
+
+
+/* Button Container */
+.buttonContainer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+}
+
+.prevButton {
+ border: none;
+ border-radius: 0.75rem;
+ background: #F6F6F6;
+
+ display: flex;
+ padding: 0.625rem 1rem;
+ justify-content: center;
+ align-items: center;
+}
+
+.musicNoteIcon {
+ width: 0.8333rem;
+ height: 0.8333rem;
+ margin-top: 4px;
+}
+
+.nextButton {
+ border: none;
+ display: flex;
+ padding: 0.625rem;
+ justify-content: center;
+ align-items: center;
+ align-content: center;
+ flex-wrap: wrap;
+ border-radius: 0.75rem;
+ background: linear-gradient(90deg, rgba(174, 0, 255, 0.80) 0%, rgba(60, 219, 255, 0.80) 100%);
+
+ color: #FFF;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ gap: 0.625rem;
+
+ position: relative;
+ z-index: 0;
+ overflow: hidden;
+}
+
+.nextButton::before {
+ content: "";
+ width: 50%;
+ height: 100%;
+
+ position: absolute;
+ top: 0;
+ left: -75%;
+ z-index: 1;
+
+ background: linear-gradient(120deg, transparent, rgba(255, 255, 255, 0.4), transparent);
+ transform: skewX(-20deg);
+ transition: left 0.6s ease;
+ pointer-events: none;
+}
+.nextButton:hover::before {
+ left: 125%;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/ImageEditor/ImageEditorContent.tsx b/frontend/src/pages/ImageEditor/ImageEditorContent.tsx
new file mode 100644
index 0000000..c18af10
--- /dev/null
+++ b/frontend/src/pages/ImageEditor/ImageEditorContent.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+import styles from "./ImageEditorContent.module.css";
+import ImageSelector from "./ImageSelector";
+import musicNote from "@/assets/icons/music_note_white.svg";
+
+const ImageEditorContent: React.FC = () => {
+ const tempNumber = 10;
+
+ return (
+
+
+
+
+
+
수집된 이미지 확인
+
수집된 이미지를 확인하고 원하는 이미지를 자유롭게 선택해주세요.
+
+
+ {tempNumber}장 선택됨 ( 최소 3장 )
+
+
+
+
+
+
+
이전
+
+
+ 음악 스타일 선택하기
+
+
+
+
+ );
+};
+
+export default ImageEditorContent;
\ No newline at end of file
diff --git a/frontend/src/pages/ImageEditor/ImageSelector.module.css b/frontend/src/pages/ImageEditor/ImageSelector.module.css
new file mode 100644
index 0000000..5daa85e
--- /dev/null
+++ b/frontend/src/pages/ImageEditor/ImageSelector.module.css
@@ -0,0 +1,38 @@
+.imageListContainer {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(21.75rem, 1fr));
+ gap: 1.25rem;
+ margin-bottom: 1.25rem;
+}
+
+.cardContainer {
+ display: flex;
+ width: 100%;
+ height: 11.63rem;
+ box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.1);
+ border-radius: 1rem;
+ background: #FFF;
+ transition: all 0.3s ease;
+ justify-content: space-between;
+ user-select: none;
+}
+
+.cardContainer:hover {
+ scale: 1.03;
+}
+
+.selected {
+ background: #E2EAFF;
+}
+
+.image {
+ width: 60%;
+ height: 100%;
+ border-radius: 1rem;
+ object-fit: cover;
+}
+
+.checkBoxContainer {
+ margin-top: 1rem;
+ margin-right: 1rem;
+}
diff --git a/frontend/src/pages/ImageEditor/ImageSelector.tsx b/frontend/src/pages/ImageEditor/ImageSelector.tsx
new file mode 100644
index 0000000..56c41b9
--- /dev/null
+++ b/frontend/src/pages/ImageEditor/ImageSelector.tsx
@@ -0,0 +1,54 @@
+import React, { useState } from "react";
+import styles from "./ImageSelector.module.css";
+import CheckBox from "@/components/CheckBox";
+
+// Sample crawled images
+const imageData = [
+ { id: 1, url: "https://img.heroui.chat/image/places?w=400&h=300&u=pension1" },
+ { id: 2, url: "https://img.heroui.chat/image/places?w=400&h=300&u=pension2" },
+ { id: 3, url: "https://img.heroui.chat/image/places?w=400&h=300&u=pension3" },
+ { id: 4, url: "https://img.heroui.chat/image/places?w=400&h=300&u=pension4" },
+ { id: 5, url: "https://img.heroui.chat/image/places?w=400&h=300&u=pension5" },
+ { id: 6, url: "https://img.heroui.chat/image/places?w=400&h=300&u=pension6" },
+ { id: 7, url: "https://img.heroui.chat/image/places?w=400&h=300&u=pension7" },
+ { id: 8, url: "https://img.heroui.chat/image/places?w=400&h=300&u=pension8" },
+ { id: 9, url: "https://img.heroui.chat/image/places?w=400&h=300&u=pension9" },
+];
+
+const ImageSelector: React.FC = () => {
+ const [selectedImages, setSelectedImages] = useState([1, 2, 3, 4, 5, 6]); // 초기 선택 상태
+
+ const handleImageClick = (id: number) => {
+ setSelectedImages((prevSelected) =>
+ prevSelected.includes(id)
+ ? prevSelected.filter((selectedId) => selectedId !== id)
+ : [...prevSelected, id]
+ );
+ };
+
+ return (
+
+
+ {imageData.map((image) => {
+ const isSelected = selectedImages.includes(image.id);
+
+ return (
+
handleImageClick(image.id)}
+ >
+
+
+
+
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default ImageSelector;
diff --git a/frontend/src/pages/ImageEditor/index.tsx b/frontend/src/pages/ImageEditor/index.tsx
new file mode 100644
index 0000000..dc8c886
--- /dev/null
+++ b/frontend/src/pages/ImageEditor/index.tsx
@@ -0,0 +1 @@
+export { default } from "./ImageEditor";
\ No newline at end of file
diff --git a/frontend/src/pages/Login/Login.module.css b/frontend/src/pages/Login/Login.module.css
new file mode 100644
index 0000000..bcda71a
--- /dev/null
+++ b/frontend/src/pages/Login/Login.module.css
@@ -0,0 +1,72 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+.contentContainer{
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-top: 1px solid #D9D9D9;
+ background: linear-gradient(262deg, rgba(193, 136, 255, 0.60) 0%, rgba(125, 131, 255, 0.60) 50%, rgba(122, 186, 255, 0.60) 100%);
+ flex: 1;
+ box-sizing: border-box;
+ padding-top: 5rem;
+ padding-bottom: 5.38rem;
+}
+
+.content {
+ display: flex;
+ width: 26.6875rem;
+ padding: 1.25rem;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+ background: #FFF;
+ border-radius: 0.75rem;
+}
+
+/* Title */
+.title{
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+}
+.titleText{
+ color: #001227;
+ font-size: 1.5rem;
+ font-weight: 700;
+ line-height: normal;
+}
+.titleDescription{
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 1rem;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Signup Container */
+.signupContainer{
+ display: flex;
+ width: 100%;
+ justify-content: center;
+ align-items: center;
+ gap: 0.25rem;
+}
+.signupText{
+ color: #001227;
+ font-size: 0.875rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+.signup{
+ color: #A647FE;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Login/Login.tsx b/frontend/src/pages/Login/Login.tsx
new file mode 100644
index 0000000..6ca5230
--- /dev/null
+++ b/frontend/src/pages/Login/Login.tsx
@@ -0,0 +1,40 @@
+import React from "react";
+import { useNavigate } from "react-router-dom";
+import styles from "./Login.module.css";
+import LoginForm from "./LoginForm";
+import Header from "@/components/Header";
+import Divider from "@/components/Divider";
+import SocialLogin from "@/components/SocialLogin";
+import Footer from "@/components/Footer";
+
+const Login: React.FC = () => {
+ const navigate = useNavigate();
+
+ const goToSignup = () => {
+ navigate("/signup");
+ };
+
+ return (
+
+
+
+
+
+
로그인
+
오투사운드를 이용하기 위해 로그인해주세요.
+
+
+
+ {/*
*/}
+
+
+
+
+
+ );
+}
+
+export default Login;
\ No newline at end of file
diff --git a/frontend/src/pages/Login/LoginForm.module.css b/frontend/src/pages/Login/LoginForm.module.css
new file mode 100644
index 0000000..3599d3b
--- /dev/null
+++ b/frontend/src/pages/Login/LoginForm.module.css
@@ -0,0 +1,79 @@
+/* Form Container */
+.formContainer{
+ display: flex;
+ width: 100%;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+}
+.contentContainer{
+ display: flex;
+ width: 100%;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1.5rem;
+}
+/* Find Password */
+.findPassword{
+ color: #A647FE;
+ font-size: 0.875rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ align-self: flex-end;
+ cursor: pointer;
+}
+
+/* Login Button */
+.loginButton {
+
+ width: 100%;
+ padding: 0.625rem 1rem;
+
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ overflow: hidden;
+ z-index: 0;
+
+ background: linear-gradient(90deg, rgba(174, 0, 255, 0.80) 0%, rgba(60, 219, 255, 0.80) 100%);
+ color: #FFF;
+ border: none;
+ border-radius: 0.75rem;
+
+ font-size: 1rem;
+ font-weight: 500;
+
+ cursor: pointer;
+}
+
+.loginButton::before {
+ /* Content */
+ content: "";
+ width: 50%;
+ height: 100%;
+
+ position: absolute;
+ top: 0;
+ left: -75%;
+ z-index: 1;
+
+ background: linear-gradient(120deg, transparent, rgba(255, 255, 255, 0.4), transparent);
+ transform: skewX(-20deg);
+ transition: left 0.6s ease;
+ pointer-events: none;
+}
+
+.loginButton:hover::before {
+ left: 125%;
+}
+
+.disabled{
+ background: #E2E2E2;
+ cursor: not-allowed;
+ user-select: none;
+}
+.disabled:hover::before {
+ left: -75%;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx
new file mode 100644
index 0000000..62efb10
--- /dev/null
+++ b/frontend/src/pages/Login/LoginForm.tsx
@@ -0,0 +1,97 @@
+
+import React, { useState, useEffect } from "react";
+import { useNavigate } from "react-router-dom";
+import { useLogin } from "@/hooks/useLogin";
+import LabeledInput from "@/components/LabeledInput";
+import styles from './LoginForm.module.css';
+import human from "@/assets/icons/human.svg";
+import lock from "@/assets/icons/lock.svg";
+
+const LoginForm: React.FC = () => {
+ const navigate = useNavigate();
+ const { login, errorCode, errorMessage } = useLogin();
+
+ const [id, setId] = useState("");
+ const [password, setPassword] = useState("");
+ const [isFormValid, setIsFormValid] = useState(false);
+
+ const [idErrorMessage, setIdErrorMessage] = useState("");
+ const [passwordErrorMessage, setPasswordErrorMessage] = useState("");
+
+ const handleLogin = async (e: React.FormEvent) => {
+ if (!isFormValid) return;
+
+ e.preventDefault();
+ await login(id, password);
+ };
+
+ const handleFindPassword = () => {
+ navigate("/");
+ };
+
+
+ useEffect(() => {
+ const isValid = id && password;
+ setIsFormValid(!!isValid);
+ }, [id, password]);
+
+ useEffect(() => {
+ if (!errorCode) return;
+
+ switch (errorCode) {
+ case "USER_NOT_FOUND":
+ setIdErrorMessage("존재하지 않은 유저입니다.");
+ break;
+ case "PASSWORD_INCORRECT":
+ setPasswordErrorMessage("비밀번호가 일치하지 않습니다.");
+ break;
+ case "NETWORK_ERROR":
+ alert(errorMessage);
+ break;
+ }
+ }, [errorCode, errorMessage]);
+
+ return (
+ <>
+
+ >
+ );
+};
+
+export default LoginForm;
\ No newline at end of file
diff --git a/frontend/src/pages/Login/index.tsx b/frontend/src/pages/Login/index.tsx
new file mode 100644
index 0000000..59b018c
--- /dev/null
+++ b/frontend/src/pages/Login/index.tsx
@@ -0,0 +1 @@
+export { default } from './Login';
\ No newline at end of file
diff --git a/frontend/src/pages/MyPage/Management/MyAccount.module.css b/frontend/src/pages/MyPage/Management/MyAccount.module.css
new file mode 100644
index 0000000..04b4e29
--- /dev/null
+++ b/frontend/src/pages/MyPage/Management/MyAccount.module.css
@@ -0,0 +1,120 @@
+/* Container */
+.container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ width: 100%;
+ padding: 0 1.25rem;
+}
+
+@media (max-width: 600px) {
+ .container {
+ width: 100%;
+ min-width: 0;
+ }
+}
+
+/* Content Container */
+.contentContainer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 55%;
+}
+
+/* Title */
+.title {
+ color: #001227;
+ font-size: 1.25rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ margin-bottom: 1.25rem;
+}
+
+/* Content */
+.content {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ width: 100%;
+ height: 100%;
+}
+
+/* Input Container */
+.inputContainer {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ gap: 1.5rem;
+}
+
+/* Input Box */
+.inputBox {
+ display: flex;
+ padding: 1rem 1.25rem;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ align-self: stretch;
+ border-radius: 0.75rem;
+ border: 1px solid rgba(0, 18, 39, 0.50);
+ background: #FFF;
+}
+
+/* Input Label */
+.inputLabel {
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Input */
+.input {
+ display: flex;
+ width: 100%;
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ background: transparent;
+ border: none;
+ outline: none;
+}
+
+.input.editable {
+ background: #F6F6F6;
+}
+
+/* Button Container */
+.buttonContainer {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ margin-top: 1.5rem;
+}
+
+/* Edit Button */
+.editButton {
+ display: flex;
+ padding: 0.625rem 1.25rem;
+ justify-content: center;
+ align-items: center;
+ gap: 0.625rem;
+ border-radius: 0.75rem;
+ background: #F6F6F6;
+ border: none;
+ cursor: pointer;
+}
+
+.editButton.saveButton {
+ background: #4EA3FF;
+ color: #FFF;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/MyPage/Management/MyAccount.tsx b/frontend/src/pages/MyPage/Management/MyAccount.tsx
new file mode 100644
index 0000000..50240b4
--- /dev/null
+++ b/frontend/src/pages/MyPage/Management/MyAccount.tsx
@@ -0,0 +1,192 @@
+import React, { useEffect, useState } from 'react';
+import styles from './MyAccount.module.css';
+import { getProfile, updateProfile } from '@/api/user/user';
+
+// Types
+interface EditIconProps {
+ color: string;
+}
+
+interface AccountInfo {
+ name: string;
+ email: string;
+ phone_number: string;
+ created_at: string;
+}
+
+// Edit Icon Component
+const EditIcon: React.FC = ({ color }) => (
+
+
+
+);
+
+// Account Input Component
+interface AccountInputProps {
+ label: string;
+ field: keyof AccountInfo;
+ value: string;
+ isEditable: boolean;
+ disabled?: boolean;
+ onChange: (field: keyof AccountInfo, value: string) => void;
+}
+
+const AccountInput: React.FC = ({
+ label,
+ field,
+ value,
+ isEditable,
+ disabled = false,
+ onChange
+}) => {
+ const inputClassName = `${styles.input} ${isEditable && !disabled ? styles.editable : ''}`;
+ const isInputDisabled = disabled || !isEditable;
+
+ return (
+
+
{label}
+
!disabled && onChange(field, e.target.value)}
+ />
+
+ );
+};
+
+// Edit Button Component
+interface EditButtonProps {
+ isEdit: boolean;
+ onClick: () => void;
+}
+
+const EditButton: React.FC = ({ isEdit, onClick }) => {
+ const buttonClassName = `${styles.editButton} ${isEdit ? styles.saveButton : ''}`;
+ const iconColor = isEdit ? "#FFF" : "#A1A1A1";
+ const buttonText = isEdit ? "정보 저장" : "정보 수정";
+
+ return (
+
+
+ {buttonText}
+
+ );
+};
+
+// Main Component
+export const MyAccount: React.FC = () => {
+ // States
+ const [isEdit, setIsEdit] = useState(false);
+ const [accountInfo, setAccountInfo] = useState({
+ name: "",
+ email: "",
+ phone_number: "",
+ created_at: ""
+ });
+
+ // Computed values
+ const userId = localStorage.getItem("id") ?? "";
+
+ // Input field configurations
+ const inputFields = [
+ { label: "이름", field: "name" as keyof AccountInfo, disabled: false },
+ { label: "이메일", field: "email" as keyof AccountInfo, disabled: false },
+ { label: "전화번호", field: "phone_number" as keyof AccountInfo, disabled: false },
+ { label: "가입 날짜", field: "created_at" as keyof AccountInfo, disabled: true }
+ ];
+
+ // Event handlers
+ const handleEdit = async () => {
+ if (isEdit) {
+ await saveAccountInfo();
+ }
+ setIsEdit(!isEdit);
+ };
+
+ const saveAccountInfo = async () => {
+ console.log("정보 저장");
+
+ try {
+ const response = await updateProfile({
+ id: userId,
+ name: accountInfo.name,
+ email: accountInfo.email ?? "",
+ phone_number: accountInfo.phone_number ?? ""
+ });
+
+ if (response.ok) {
+ setIsEdit(false);
+ } else {
+ console.error('계정 정보 수정 중 오류 발생');
+ }
+ } catch (error) {
+ console.error('계정 정보 수정 중 오류 발생:', error);
+ }
+ };
+
+ const handleInputChange = (field: keyof AccountInfo, value: string) => {
+ setAccountInfo(prev => ({
+ ...prev,
+ [field]: value
+ }));
+ };
+
+ // Data fetching
+ const fetchAccountInfo = async () => {
+ try {
+ const response = await getProfile({ id: userId });
+
+ if (response.ok) {
+ setAccountInfo({
+ name: response.data?.name ?? "",
+ email: response.data?.email ?? "",
+ phone_number: response.data?.phone_number ?? "",
+ created_at: response.data?.created_at ?? ""
+ });
+ } else {
+ console.error('계정 정보 조회 중 오류 발생');
+ }
+ } catch (error) {
+ console.error('계정 정보 조회 중 오류 발생:', error);
+ }
+ };
+
+ // Effects
+ useEffect(() => {
+ fetchAccountInfo();
+ }, []);
+
+ return (
+
+
+
계정 정보
+
+
+
+ {inputFields.map(({ label, field, disabled }) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default MyAccount;
\ No newline at end of file
diff --git a/frontend/src/pages/MyPage/Management/MyChannel.module.css b/frontend/src/pages/MyPage/Management/MyChannel.module.css
new file mode 100644
index 0000000..08cbe0e
--- /dev/null
+++ b/frontend/src/pages/MyPage/Management/MyChannel.module.css
@@ -0,0 +1,155 @@
+/* Container */
+.container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ width: 100%;
+ padding: 0 1.25rem;
+}
+
+@media (max-width: 600px) {
+ .container {
+ width: 100%;
+ min-width: 0;
+ }
+}
+
+/* Title */
+.title {
+ color: #001227;
+ font-size: 1.25rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ margin-bottom: 1.25rem;
+}
+
+/* Content */
+.content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 55%;
+ height: 100%;
+}
+
+/* Platform Container */
+.platformContainer {
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+/* Platform Box */
+.platformBox {
+ display: flex;
+ padding: 1rem 1.25rem;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ border-radius: 0.75rem;
+ border: 1px solid rgba(0, 18, 39, 0.50);
+ background: #FFF;
+ flex: 1;
+ height: 5.125rem;
+ cursor: pointer;
+}
+
+.platformBox:hover {
+ background: #F6F6F6;
+}
+
+/* Platform Header */
+.platformHeader {
+ color: #001227;
+ font-size: 0.75rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+}
+
+/* Platform Content */
+.platformContent {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+/* Platform Icon */
+.platformIcon {
+ width: 1.25rem;
+ height: 1.25rem;
+ aspect-ratio: 1/1;
+}
+
+/* Platform Name */
+.platformName {
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Channel Container */
+.channelContainer {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 23rem;
+ gap: 1.44rem;
+ overflow-y: auto;
+}
+
+/* Channel Box */
+.channelBox {
+ display: flex;
+ padding: 1rem 1.25rem;
+ flex-direction: row;
+ align-items: center;
+ align-self: stretch;
+ border-radius: 0.75rem;
+ border: 1px solid rgba(0, 18, 39, 0.50);
+ background: #FFF;
+}
+
+/* Channel Icon */
+.channelIcon {
+ width: 1.25rem;
+ height: 1.25rem;
+ aspect-ratio: 1/1;
+ margin-right: 0.5rem;
+}
+
+/* Channel Name */
+.channelName {
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Unlink Button */
+.unLinkButton {
+ display: flex;
+ padding: 0.5rem;
+ justify-content: center;
+ align-items: center;
+ gap: 0.625rem;
+ border-radius: 0.75rem;
+ background: #D29FFF;
+ border: none;
+ color: #FFF;
+ font-size: 0.875rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ cursor: pointer;
+ margin-left: auto;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/MyPage/Management/MyChannel.tsx b/frontend/src/pages/MyPage/Management/MyChannel.tsx
new file mode 100644
index 0000000..55af701
--- /dev/null
+++ b/frontend/src/pages/MyPage/Management/MyChannel.tsx
@@ -0,0 +1,193 @@
+
+import React, { useEffect, useState } from 'react';
+import styles from './MyChannel.module.css';
+import youtube from "@/assets/images/youtube.png";
+import naver from "@/assets/images/naver.png";
+import UnlinkPopup from './UnlinkPopUp';
+import { useGoogleOAuth } from "@/hooks/useGoogleAuth";
+import { getYoutubeChannelInfo } from "@/api/social_login/google";
+
+interface ChannelData {
+ platform: string;
+ channelName: string;
+ channelId: string;
+ subscriberCount: number;
+ thumbnailUrl: string;
+}
+
+interface MyChannelInfo {
+ platform: string;
+ channelName: string;
+ channelId: string;
+ channelUrl: string;
+}
+// UnLinkIcon
+const UnLinkIcon: React.FC = () => (
+
+
+
+
+
+
+
+
+);
+export const MyChannel: React.FC = () => {
+ const [myChannelInfo, setMyChannelInfo] = useState([]);
+ const [channelData, setChannelData] = useState([]);
+ const [isUnlinkPopUp, setIsUnlinkPopUp] = useState(false);
+ const [unlinkIndex, setUnlinkIndex] = useState(0);
+
+ const { isLoggedIn, isLoading, login } = useGoogleOAuth(
+ // 로그인 성공 콜백
+ (userInfo) => {
+ console.log('🎉 로그인 성공:', userInfo.email);
+ fetchChannelInfo();
+ },
+ // 로그인 에러 콜백
+ (error) => {
+ console.error('❌ 로그인 에러:', error);
+ }
+ );
+
+ // 임시 데이터 설정
+ useEffect(() => {
+ setMyChannelInfo([
+ {
+ platform: "youtube",
+ channelName: "Youtube",
+ channelId: "Youtube",
+ channelUrl: "Youtube"
+ },
+ {
+ platform: "naver",
+ channelName: "Naver",
+ channelId: "Naver",
+ channelUrl: "Naver"
+ },
+ ]);
+ }, []);
+
+ const handleYoutubeConnect = () => {
+ console.log("YoutubeConnect");
+ if (isLoggedIn) {
+ console.log('이미 로그인됨 - 공유 팝업 열기');
+ fetchChannelInfo();
+ } else {
+ console.log('로그인 필요 - Google 로그인 시작');
+ login();
+ }
+ };
+
+ const handleNaverConnect = () => {
+ console.log("NaverConnect");
+ };
+
+ const confirmUnlink = () => {
+ if (unlinkIndex >= 0) {
+ const updatedChannels = myChannelInfo.filter((_, i) => i !== unlinkIndex);
+ setMyChannelInfo(updatedChannels);
+ console.log(`채널 연결 해제: ${myChannelInfo[unlinkIndex]?.channelName}`);
+ }
+ setIsUnlinkPopUp(false);
+ };
+
+ const handleUnLink = (index: number) => {
+ setUnlinkIndex(index);
+ setIsUnlinkPopUp(true);
+ };
+
+ const closeUnlinkPopup = () => {
+ setIsUnlinkPopUp(false);
+ setUnlinkIndex(-1);
+ };
+
+ const fetchChannelInfo = async () => {
+ try {
+ const channelInfo = await getYoutubeChannelInfo();
+
+ for (const channel of channelInfo.data) {
+ console.log(channel);
+ const tempChannel: ChannelData = {
+ platform: "Youtube",
+ channelName: channel.title || "Youtube Channel",
+ channelId: channel.channel_id,
+ subscriberCount: channel.subscriber_count,
+ thumbnailUrl: channel.thumbnail_url
+ };
+
+ setMyChannelInfo(prev => [...prev, {
+ platform: "youtube",
+ channelName: channel.title || "Youtube Channel",
+ channelId: channel.channel_id,
+ channelUrl: channel.channel_url,
+ }]);
+ }
+ } catch (error) {
+ console.error('채널 정보 가져오기 실패:', error);
+ }
+ };
+
+ const renderPlatformBox = (
+ platform: string,
+ icon: string,
+ name: string,
+ onClick: () => void
+ ) => (
+
+
연결 플랫폼
+
+
+
{name}
+
+
+ );
+
+ const renderChannelItem = (channel: MyChannelInfo, index: number) => (
+
+
+
{channel.channelName}
+
handleUnLink(index)}
+ >
+
+ 연결 해제
+
+
+ );
+
+ return (
+
+
+
+
+
채널 정보
+
+
+ {renderPlatformBox("youtube", youtube, "Youtube", handleYoutubeConnect)}
+ {renderPlatformBox("naver", naver, "네이버 클립", handleNaverConnect)}
+
+
+
+ {myChannelInfo.map(renderChannelItem)}
+
+
+
+ );
+};
+
+export default MyChannel;
\ No newline at end of file
diff --git a/frontend/src/pages/MyPage/Management/MyItem.module.css b/frontend/src/pages/MyPage/Management/MyItem.module.css
new file mode 100644
index 0000000..b7cd845
--- /dev/null
+++ b/frontend/src/pages/MyPage/Management/MyItem.module.css
@@ -0,0 +1,181 @@
+/* Container */
+.container{
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1.25rem;
+ overflow: hidden;
+ width: 100%;
+ height: 100%;
+}
+@media (max-width: 600px) {
+ .container{
+ width: 100%;
+ min-width: 0;
+ }
+}
+
+/* Wrapper Container */
+.wrapperContainer{
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ height: 100%;
+ gap: 2.5rem;
+ padding: 2.5rem 0;
+ overflow-x: auto;
+
+ cursor: grab;
+ user-select: none;
+
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+.wrapperContainer::-webkit-scrollbar {
+ display: none;
+}
+
+/* Box Container */
+.boxContainer{
+ border-radius: 1rem;
+ background: rgba(250, 252, 254, 0.20);
+ box-shadow: 4px 12px 20px 0px rgba(0, 49, 129, 0.15);
+ display: flex;
+ padding: 1.25rem;
+ flex-direction: column;
+ align-items: center;
+ gap: 2.5rem;
+}
+
+/* Header */
+.header{
+ color: #001227;
+ font-size: 1.25rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+
+/* Content Container */
+.contentContainer{
+ display: flex;
+ align-items: flex-start;
+ gap: 3.75rem;
+}
+@media (max-width: 1040px) {
+ .contentContainer{
+ flex-direction: column;
+ align-items: center;
+ gap: 2rem;
+ }
+}
+/* Image */
+.image{
+ width: 13.375rem;
+ height: 12.25rem;
+ object-fit: cover;
+ pointer-events: none;
+ user-select: none;
+ -webkit-user-drag: none;
+}
+
+/* Info Container */
+.infoContainer{
+ display: flex;
+ flex-direction: column;
+ gap: 2.5rem;
+}
+
+/* Text Container */
+.textContainer{
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.label{
+ color: #001227;
+ font-size: 0.75rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ white-space: nowrap;
+}
+.value{
+ border: none;
+
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ width: 24.375rem;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+}
+.value:disabled {
+ background-color: transparent;
+ cursor: not-allowed;
+ opacity: 0.7;
+ pointer-events: none;
+ user-select: none;
+}
+
+.value:not(:disabled) {
+ background-color: #EFF5FB;
+}
+/* Button Container */
+.buttonContainer{
+ display: flex;
+ gap: 1.25rem;
+}
+
+/* Button */
+.button{
+ display: flex;
+ gap: 2.5rem;
+}
+
+.editButton{
+ display: flex;
+ padding: 0.625rem 1.25rem;
+ justify-content: center;
+ align-items: center;
+ gap: 0.625rem;
+ border-radius: 0.75rem;
+ background: #F6F6F6;
+ border: none;
+
+ color: #A1A1A1;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ cursor: pointer;
+ word-break: keep-all;
+}
+.saveButton {
+ background: #4EA3FF;
+ color: #FFF;
+}
+
+.deleteButton{
+ display: flex;
+ padding: 0.625rem 1.25rem;
+ justify-content: center;
+ align-items: center;
+ gap: 0.625rem;
+ border-radius: 0.75rem;
+ background: #FE6A6A;
+ border: none;
+
+ color: #FFF;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ cursor: pointer;
+ word-break: keep-all;
+}
diff --git a/frontend/src/pages/MyPage/Management/MyItem.tsx b/frontend/src/pages/MyPage/Management/MyItem.tsx
new file mode 100644
index 0000000..40ed2c2
--- /dev/null
+++ b/frontend/src/pages/MyPage/Management/MyItem.tsx
@@ -0,0 +1,336 @@
+import React, { useState, useEffect, useRef } from 'react';
+import styles from './MyItem.module.css';
+import deleteIcon from "@/assets/icons/delete.svg";
+import NotValue from '@/components/NotValue/NotValue';
+import ErrorPopUp from '@/components/ErrorPopUp';
+import { getItems, updateItem, deleteItem } from '@/api/user/user';
+
+// Types
+interface ItemInfo {
+ item_id: string;
+ name: string;
+ address: string;
+ url: string;
+ phone_number?: string;
+ thumbnail_url?: string;
+}
+
+interface IconProps {
+ color: string;
+}
+
+// Edit Icon Component
+const EditIcon: React.FC = ({ color }) => (
+
+
+
+);
+
+// Item Form Component
+interface ItemFormProps {
+ info: ItemInfo;
+ index: number;
+ isEditing: boolean;
+ onInputChange: (field: keyof ItemInfo, value: string) => void;
+}
+
+const ItemForm: React.FC = ({ info, isEditing, onInputChange }) => {
+ const fields = [
+ { label: '매장 이름', field: 'name' as keyof ItemInfo, value: info.name },
+ { label: '매장 주소', field: 'address' as keyof ItemInfo, value: info.address },
+ { label: '매장 URL', field: 'url' as keyof ItemInfo, value: info.url },
+ { label: '매장 연락처', field: 'phone_number' as keyof ItemInfo, value: info.phone_number }
+ ];
+
+ return (
+
+ {fields.map(({ label, field, value }) => (
+
+
{label}
+
onInputChange(field, e.target.value)}
+ />
+
+ ))}
+
+ );
+};
+
+// Item Card Component
+interface ItemCardProps {
+ info: ItemInfo;
+ index: number;
+ isEditing: boolean;
+ onEditToggle: () => void;
+ onDelete: () => void;
+ onInputChange: (field: keyof ItemInfo, value: string) => void;
+}
+
+const ItemCard: React.FC = ({
+ info,
+ index,
+ isEditing,
+ onEditToggle,
+ onDelete,
+ onInputChange
+}) => {
+ return (
+
+
매장 정보
+
+
+
+
+
+
+
+
+
+ {isEditing ? "정보 저장" : "정보 수정"}
+
+
+
+ 정보 삭제
+
+
+
+ );
+};
+
+// Main Component
+export const MyItem: React.FC = () => {
+ // Refs
+ const scrollRef = useRef(null);
+
+ // States
+ const [isLoading, setIsLoading] = useState(true);
+ const [itemInfo, setItemInfo] = useState([]);
+ const [editingIndex, setEditingIndex] = useState(null);
+ const [deleteIndex, setDeleteIndex] = useState(null);
+ const [errorPopUp, setErrorPopUp] = useState(false);
+
+ // Computed values
+ const userId = localStorage.getItem("id") ?? "";
+ const showEmptyState = !isLoading && itemInfo.length === 0;
+
+ // Data fetching
+ const fetchItems = async () => {
+ try {
+ const response = await getItems({ id: userId });
+ console.log(response);
+ setItemInfo(response.data?.items ?? []);
+ } catch (error) {
+ console.error('아이템을 가져오는 중 오류 발생:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Edit handlers
+ const handleEditToggle = async (index: number) => {
+ if (editingIndex === index) {
+ await saveItem(index);
+ } else {
+ setEditingIndex(index);
+ }
+ };
+
+ const saveItem = async (index: number) => {
+ try {
+ const item = itemInfo[index];
+ const response = await updateItem({
+ id: userId,
+ item_id: item.item_id,
+ name: item.name,
+ address: item.address,
+ url: item.url,
+ phone_number: item.phone_number ?? "",
+ thumbnail_url: item.thumbnail_url ?? "",
+ });
+
+ if (response.ok) {
+ setEditingIndex(null);
+ } else {
+ console.error('아이템 수정 중 오류 발생:', response.data?.message ?? "알 수 없는 오류");
+ }
+ } catch (error) {
+ console.error('아이템 저장 중 오류 발생:', error);
+ }
+ };
+
+ const handleInputChange = (index: number, field: keyof ItemInfo, value: string) => {
+ setItemInfo(prev =>
+ prev.map((item, i) =>
+ i === index ? { ...item, [field]: value } : item
+ )
+ );
+ };
+
+ // Delete handlers
+ const handleDelete = (index: number) => {
+ setDeleteIndex(index);
+ setErrorPopUp(true);
+ };
+
+ const handleConfirmDelete = async () => {
+ if (deleteIndex === null) return;
+
+ try {
+ const response = await deleteItem({
+ id: userId,
+ item_id: itemInfo[deleteIndex].item_id,
+ });
+
+ console.log(response);
+
+ if (response.ok) {
+ setItemInfo(prev => prev.filter((_, i) => i !== deleteIndex));
+ adjustEditingIndex();
+ } else {
+ console.error('아이템 삭제 중 오류 발생');
+ }
+ } catch (error) {
+ console.error('아이템 삭제 중 오류 발생:', error);
+ } finally {
+ closeDeletePopup();
+ }
+ };
+
+ const adjustEditingIndex = () => {
+ if (editingIndex === deleteIndex) {
+ setEditingIndex(null);
+ } else if (editingIndex !== null && editingIndex > deleteIndex!) {
+ setEditingIndex(editingIndex - 1);
+ }
+ };
+
+ const closeDeletePopup = () => {
+ setErrorPopUp(false);
+ setDeleteIndex(null);
+ };
+
+ // Style helpers
+ const getContainerStyle = () => {
+ if (itemInfo.length === 0) {
+ return { display: 'none' };
+ } else if (itemInfo.length === 1) {
+ return {
+ display: 'flex',
+ justifyContent: 'center'
+ };
+ } else {
+ return { display: 'flex' };
+ }
+ };
+
+ // Scroll functionality
+ useEffect(() => {
+ const wrapper = scrollRef.current;
+ if (!wrapper) return;
+
+ let isDown = false;
+ let startX: number;
+ let scrollLeft: number;
+
+ const handleMouseDown = (e: MouseEvent) => {
+ isDown = true;
+ startX = e.pageX - wrapper.offsetLeft;
+ scrollLeft = wrapper.scrollLeft;
+ wrapper.style.cursor = 'grabbing';
+ };
+
+ const handleMouseUp = () => {
+ isDown = false;
+ wrapper.style.cursor = 'grab';
+ };
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!isDown) return;
+ e.preventDefault();
+ const x = e.pageX - wrapper.offsetLeft;
+ const walk = (x - startX) * 2;
+ wrapper.scrollLeft = scrollLeft - walk;
+ };
+
+ const events = [
+ ['mousedown', handleMouseDown],
+ ['mouseup', handleMouseUp],
+ ['mousemove', handleMouseMove],
+ ['mouseleave', handleMouseUp]
+ ] as const;
+
+ events.forEach(([event, handler]) => {
+ wrapper.addEventListener(event, handler);
+ });
+
+ return () => {
+ events.forEach(([event, handler]) => {
+ wrapper.removeEventListener(event, handler);
+ });
+ };
+ }, []);
+
+ // Initial data fetch
+ useEffect(() => {
+ fetchItems();
+ }, []);
+
+ return (
+
+ {showEmptyState && (
+
+ )}
+
+ {errorPopUp && (
+
+ )}
+
+
+ {itemInfo.map((info, index) => (
+ handleEditToggle(index)}
+ onDelete={() => handleDelete(index)}
+ onInputChange={(field, value) => handleInputChange(index, field, value)}
+ />
+ ))}
+
+
+ );
+};
+
+export default MyItem;
\ No newline at end of file
diff --git a/frontend/src/pages/MyPage/Management/MyVideo.module.css b/frontend/src/pages/MyPage/Management/MyVideo.module.css
new file mode 100644
index 0000000..f461b53
--- /dev/null
+++ b/frontend/src/pages/MyPage/Management/MyVideo.module.css
@@ -0,0 +1,182 @@
+
+.projectsGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(18.75rem, 1fr));
+ gap: 1rem;
+ align-items: start;
+}
+.projectsGrid[data-count="1"],
+.projectsGrid[data-count="2"] {
+ justify-content: flex-start;
+ grid-template-columns: repeat(auto-fit, minmax(23.1875rem, max-content));
+}
+.projectCard {
+ display: flex;
+ flex-direction: column;
+ background: white;
+ border: 0.0625rem solid #e5e7eb;
+ border-radius: 0.75rem;
+ overflow: hidden;
+ box-shadow: 0 0.0625rem 0.1875rem rgba(0, 0, 0, 0.05);
+ transition: all 0.2s ease;
+ padding: 1.25rem;
+ container-type: inline-size;
+}
+
+.projectCard:hover {
+ box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
+ transform: translateY(-0.125rem);
+}
+/* Status Badge */
+.statusBadge{
+ width: fit-content;
+ height: fit-content;
+ display: flex;
+ padding: 0.375rem 0.5rem;
+ justify-content: center;
+ align-items: center;
+ border-radius: 1rem;
+ background: #7ABAFF;
+ color: #FFF;
+ font-size: 0.75rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ margin-right: auto;
+ margin-bottom: 1.25rem;
+}
+
+/* Card Header */
+.cardHeader{
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1.25rem;
+}
+.name{
+ color: #001227;
+ font-size: 1.5rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+
+/* Image Conatiner */
+.videoContainer{
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ aspect-ratio: 16/9;
+ margin-bottom: 1.25rem;
+}
+.video{
+ width: 100%;
+ aspect-ratio: 16/9;
+ border-radius: 1rem;
+}
+
+
+/* Info Container*/
+.infoContainer{
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+.date{
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+}
+
+/* Button Container */
+/* Button Container */
+.buttonContainer {
+ margin-top: 1.25rem;
+ display: grid;
+ grid-template-columns: repeat(3, 1fr); /* 버튼 3개에 맞게 수정 */
+ gap: 0.5rem;
+ /* overflow: hidden;
+ max-height: 0;
+ opacity: 0;
+ transition: max-height 0.4s ease, opacity 0.3s ease, margin-top 0.4s ease; */
+}
+
+/* .buttonContainer.show {
+ max-height: 2.5rem;
+ opacity: 1;
+}
+
+.buttonContainer.hide {
+ margin-top: 0;
+ max-height: 0;
+ opacity: 0;
+} */
+
+/* 버튼 공통 스타일 */
+.upload, .download, .delete {
+ display: flex;
+ padding: 0.5rem;
+ justify-content: center;
+ align-items: center;
+ gap: 0.75rem;
+ border-radius: 0.5rem;
+ border: none;
+ cursor: pointer;
+ transition: opacity 0.2s ease, transform 0.2s ease;
+ z-index: 2;
+}
+@container (max-width: 300px) {
+ .buttonContainer {
+ grid-template-columns: 1fr; /* 1열로 변경 */
+ }
+
+ .buttonContainer.show {
+ max-height: 8rem; /* 3개 버튼이 세로로 배치될 수 있도록 높이 증가 */
+ }
+}
+
+.upload {
+ background: #F6F6F6;
+ color: #001227;
+ font-size: 0.75rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+.upload:hover {
+ background: #001227;
+ color: #FFF;
+}
+
+.download {
+ background: #FFF;
+ color: #6AC0FE;
+ border: 1px solid rgba(106, 192, 254, 0.50);
+ font-size: 0.75rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+.download:hover {
+ background: #6AC0FE;
+ color: #FFF;
+}
+
+.delete {
+ background: #FFF;
+ color: #FE6A6A;
+ border: 1px solid rgba(254, 106, 106, 0.50);
+ font-size: 0.75rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+.delete:hover {
+ background: #FE6A6A;
+ color: #FFF;
+}
+
+
diff --git a/frontend/src/pages/MyPage/Management/MyVideo.tsx b/frontend/src/pages/MyPage/Management/MyVideo.tsx
new file mode 100644
index 0000000..2b4ef2d
--- /dev/null
+++ b/frontend/src/pages/MyPage/Management/MyVideo.tsx
@@ -0,0 +1,274 @@
+import React, { useState, useEffect } from 'react';
+import styles from './MyVideo.module.css';
+import ErrorPopUp from '@/components/ErrorPopUp';
+import NotValue from '@/components/NotValue';
+import { getVideoList, deleteVideo } from '@/api/user/user';
+
+// Types
+interface Video {
+ video_id: string;
+ title: string;
+ description: string;
+ video_url: string;
+ created_at: string;
+ is_uploaded: boolean;
+ thumbnail_url: string;
+ status: string;
+}
+
+interface IconProps {
+ className?: string;
+ color?: string;
+}
+
+// Icon Components
+const UploadIcon: React.FC = ({ className, color = "currentColor" }) => (
+
+
+
+);
+
+const DownloadIcon: React.FC = ({ className, color = "currentColor" }) => (
+
+
+
+);
+
+const DeleteIcon: React.FC = ({ className, color = "currentColor" }) => (
+
+
+
+);
+
+// Video Card Component
+interface VideoCardProps {
+ video: Video;
+ onUpload: () => void;
+ onDownload: () => void;
+ onDelete: () => void;
+}
+
+const VideoCard: React.FC = ({
+ video,
+ onUpload,
+ onDownload,
+ onDelete
+}) => {
+ return (
+
+
{video.status}
+
+
+
+
+
+
+
+
+
+
+
+
+ 업로드
+
+
+
+ 다운로드
+
+
+
+ 삭제
+
+
+
+ );
+};
+
+// Main Component
+export const MyVideo: React.FC = () => {
+ // Video data state
+ const [videos, setVideos] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ // Upload popup state
+ const [isUploadOpen, setIsUploadOpen] = useState(false);
+ const [selectedVideo, setSelectedVideo] = useState(null);
+
+ // Delete confirmation state
+ const [isErrorOpen, setIsErrorOpen] = useState(false);
+ const [videoToDelete, setVideoToDelete] = useState(null);
+
+ // Video data fetching
+ const fetchVideos = async () => {
+ try {
+ const response = await getVideoList({
+ id: localStorage.getItem("id") ?? ""
+ });
+
+ const videosData = response.data?.videos ?? [];
+ const formattedVideos: Video[] = videosData.map((video: any) => ({
+ video_id: video.id,
+ title: video.title,
+ description: video.description,
+ video_url: video.url,
+ created_at: video.created_at,
+ is_uploaded: video.is_uploaded,
+ status: video.status,
+ thumbnail_url: video.thumbnail_url ?? ""
+ }));
+ console.log("response.data", response.data);
+ console.log(formattedVideos);
+ setVideos(formattedVideos);
+ } catch (error) {
+ console.error('비디오 목록을 가져오는 중 오류 발생:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleUpload = (videoId: string) => {
+ const video = videos.find(v => v.video_id === videoId);
+ if (video) {
+ setSelectedVideo(video);
+ setIsUploadOpen(true);
+ }
+ };
+
+ const handleDownload = async (video_title: string, video_url: string) => {
+ try {
+ const response = await fetch(video_url);
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const blob = await response.blob();
+ const fileName = video_title + '.mp4';
+ const blobUrl = window.URL.createObjectURL(blob);
+
+ const downloadLink = document.createElement('a');
+ downloadLink.href = blobUrl;
+ downloadLink.download = fileName;
+
+ document.body.appendChild(downloadLink);
+ downloadLink.click();
+
+ document.body.removeChild(downloadLink);
+ window.URL.revokeObjectURL(blobUrl);
+
+ } catch (error) {
+ console.error('다운로드 중 오류가 발생했습니다:', error);
+ window.open(video_url, '_blank');
+ }
+ };
+
+ const handleDelete = (videoId: string) => {
+ setVideoToDelete(videoId);
+ setIsErrorOpen(true);
+ };
+
+ const confirmDelete = async () => {
+ // videoToDelete 대신 현재 state에서 확인
+ const currentVideoToDelete = videoToDelete;
+ console.log('삭제할 비디오 ID:', currentVideoToDelete);
+
+ if (currentVideoToDelete) {
+ try {
+ const response = await deleteVideo({
+ user_id: localStorage.getItem("id") ?? "",
+ video_id: currentVideoToDelete
+ });
+ if (response.ok) {
+ console.log("response.data", response.data);
+ setVideos(prev => prev.filter(video => video.video_id !== currentVideoToDelete));
+ } else {
+ console.error('삭제 중 오류 발생:', response.data?.message ?? "알 수 없는 오류");
+ }
+ } catch (error) {
+ console.error('삭제 중 오류 발생:', error);
+ }
+ }
+
+ // 항상 팝업 닫기
+ setVideoToDelete(null);
+ setIsErrorOpen(false);
+ };
+
+ const cancelDelete = () => {
+ setVideoToDelete(null);
+ setIsErrorOpen(false);
+ };
+
+ // Effects
+ useEffect(() => {
+ fetchVideos();
+ }, []);
+
+ // Computed values
+ const containerStyle = {
+ height: videos.length > 0 ? 'auto' : '100%'
+ };
+ const shouldShowEmptyState = !isLoading && videos.length === 0;
+
+ return (
+
+
+ {videos.map((video) => (
+ handleUpload(video.video_id)}
+ onDownload={() => handleDownload(video.title, video.video_url)}
+ onDelete={() => handleDelete(video.video_id)}
+ />
+ ))}
+
+
+ {/* Upload Popup */}
+ {isUploadOpen && selectedVideo && (
+ <>>
+ )}
+
+ {/* Delete Confirmation Popup */}
+ {isErrorOpen && (
+
+ )}
+
+ {/* Empty State */}
+ {shouldShowEmptyState && (
+
+ )}
+
+ );
+};
+
+export default MyVideo;
\ No newline at end of file
diff --git a/frontend/src/pages/MyPage/Management/UnlinkPopUp.module.css b/frontend/src/pages/MyPage/Management/UnlinkPopUp.module.css
new file mode 100644
index 0000000..9fd2e84
--- /dev/null
+++ b/frontend/src/pages/MyPage/Management/UnlinkPopUp.module.css
@@ -0,0 +1,96 @@
+/* Container */
+.container{
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(28, 28, 28, 0.60);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+/* Content */
+.content{
+ display: flex;
+ width: 27.75rem;
+ padding: 2.5rem;
+ flex-direction: column;
+ align-items: center;
+ border-radius: 0.75rem;
+ background: #FFF;
+}
+/* Title */
+.title{
+ color: #001227;
+ font-size: 2rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ margin-bottom: 1.25rem;
+}
+/* Message */
+.message{
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 1.5rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+}
+
+/* ButtonContainer */
+.buttonContainer{
+ display: flex;
+ justify-content: center;
+ gap: 3.75rem;
+ margin-top: 3.75rem;
+}
+/* CancelButton */
+.cancelButton{
+ width: 9.5rem;
+ height: 3.5rem;
+ display: inline-flex;
+ padding: 1.125rem 3.75rem;
+ justify-content: center;
+ align-items: center;
+ border-radius: 1.125rem;
+ border: none;
+
+ color: #FFF;
+ font-size: 1.125rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ white-space: nowrap;
+ cursor: pointer;
+
+ background: #A1A1A1;
+}
+
+.confirmButton{
+ width: 9.5rem;
+ height: 3.5rem;
+ display: inline-flex;
+ padding: 1.125rem 3.75rem;
+ justify-content: center;
+ align-items: center;
+ border-radius: 1.125rem;
+ border: none;
+
+ color: #FFF;
+ font-size: 1.125rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ white-space: nowrap;
+ cursor: pointer;
+
+ background: #60DCCC;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/MyPage/Management/UnlinkPopUp.tsx b/frontend/src/pages/MyPage/Management/UnlinkPopUp.tsx
new file mode 100644
index 0000000..5ee03f5
--- /dev/null
+++ b/frontend/src/pages/MyPage/Management/UnlinkPopUp.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import styles from './UnlinkPopUp.module.css';
+
+interface UnlinkPopUpProps {
+ isOpen: boolean;
+ channelName: string;
+ onClose: () => void;
+ onConfirm: () => void;
+}
+
+const UnlinkPopUp: React.FC = ({isOpen, channelName, onClose, onConfirm}) => {
+ if (!isOpen) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
채널 연결 해제
+
+ 연결 해제 시 복구가 불가능합니다.
+ '{channelName}' 채널과의
+ 연결을 해제하시겠습니까?
+
+
+ 취소
+ 해제
+
+
+
+ )
+}
+
+export default UnlinkPopUp;
\ No newline at end of file
diff --git a/frontend/src/pages/MyPage/Management/index.tsx b/frontend/src/pages/MyPage/Management/index.tsx
new file mode 100644
index 0000000..a3f71a6
--- /dev/null
+++ b/frontend/src/pages/MyPage/Management/index.tsx
@@ -0,0 +1,4 @@
+export { MyVideo } from './MyVideo';
+export { MyItem } from './MyItem';
+export { MyAccount } from './MyAccount';
+export { MyChannel } from './MyChannel';
\ No newline at end of file
diff --git a/frontend/src/pages/MyPage/MyPage.module.css b/frontend/src/pages/MyPage/MyPage.module.css
new file mode 100644
index 0000000..e642b93
--- /dev/null
+++ b/frontend/src/pages/MyPage/MyPage.module.css
@@ -0,0 +1,6 @@
+.container{
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/MyPage/MyPage.tsx b/frontend/src/pages/MyPage/MyPage.tsx
new file mode 100644
index 0000000..2e4889f
--- /dev/null
+++ b/frontend/src/pages/MyPage/MyPage.tsx
@@ -0,0 +1,29 @@
+import React, { useEffect } from 'react';
+import Header from '@/components/Header';
+import Footer from '@/components/Footer';
+import MyPageContent from './MyPageContent';
+import styles from './MyPage.module.css';
+import { authAtom } from '@/recoil/Atoms/authAtom';
+import { useRecoilValue } from 'recoil';
+import { useNavigate } from 'react-router-dom';
+
+const MyPage: React.FC = () => {
+ const navigate = useNavigate();
+ const {isLoggedIn} = useRecoilValue(authAtom);
+
+ useEffect(() => {
+ if (!isLoggedIn) {
+ navigate('/login');
+ }
+ }, [isLoggedIn]);
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default MyPage;
\ No newline at end of file
diff --git a/frontend/src/pages/MyPage/MyPageContent.module.css b/frontend/src/pages/MyPage/MyPageContent.module.css
new file mode 100644
index 0000000..ea1dec0
--- /dev/null
+++ b/frontend/src/pages/MyPage/MyPageContent.module.css
@@ -0,0 +1,66 @@
+.container{
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: auto;
+ flex: 1;
+ border-top: 1px solid #D9D9D9;
+ background: linear-gradient(262deg, rgba(193, 136, 255, 0.60) 0%, rgba(125, 131, 255, 0.60) 50%, rgba(122, 186, 255, 0.60) 100%);
+ padding-top: 5rem;
+ padding-bottom: 4.87rem;
+}
+
+.wrapper{
+ display: flex;
+ flex-direction: column;
+ width: 80%;
+ max-width: 77rem;
+ min-height: 39.875rem;
+ height: auto;
+ background-color: #fff;
+ padding: 2.5rem 1.25rem;
+ gap: 1.25rem;
+ border-radius: 0.75rem;
+ animation: fadeInUp 0.5s ease-out;
+}
+
+@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.header{
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ gap: 0.5rem;
+}
+.headerTitle{
+ color: #001227;
+ font-size: 1.5rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+.headerSubtitle{
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+.menuTab{
+ display: flex;
+ margin-right: auto;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/MyPage/MyPageContent.tsx b/frontend/src/pages/MyPage/MyPageContent.tsx
new file mode 100644
index 0000000..e1bb005
--- /dev/null
+++ b/frontend/src/pages/MyPage/MyPageContent.tsx
@@ -0,0 +1,36 @@
+import React, {useState, useEffect} from 'react';
+import styles from './MyPageContent.module.css';
+import MenuTab from '@/components/MenuTab';
+import { MyVideo, MyItem, MyAccount, MyChannel } from './Management';
+
+const tabItems = ["영상 관리", "매장 정보", "계정 관리"];
+
+const MyPageContent: React.FC = () => {
+ const [selectedTab, setSelectedTab] = useState(0);
+
+ const subTitle = ["생성된 영상을 확인하고 관리해보세요.", "펜션 정보를 확인하고 수정해보세요.", "계정 정보를 확인하고 수정해보세요."];
+
+ useEffect(() => {
+ console.log(selectedTab);
+ }, [selectedTab]);
+
+ return (
+
+
+
+
마이페이지
+
{subTitle[selectedTab]}
+
+
+
+
+ {selectedTab === 0 &&
}
+ {selectedTab === 1 &&
}
+ {selectedTab === 2 &&
}
+ {/* {selectedTab === 3 &&
} */}
+
+
+ );
+};
+
+export default MyPageContent;
\ No newline at end of file
diff --git a/frontend/src/pages/MyPage/index.tsx b/frontend/src/pages/MyPage/index.tsx
new file mode 100644
index 0000000..79d2b1b
--- /dev/null
+++ b/frontend/src/pages/MyPage/index.tsx
@@ -0,0 +1 @@
+export { default } from './MyPage';
\ No newline at end of file
diff --git a/frontend/src/pages/Process/Process.tsx b/frontend/src/pages/Process/Process.tsx
new file mode 100644
index 0000000..e3f707d
--- /dev/null
+++ b/frontend/src/pages/Process/Process.tsx
@@ -0,0 +1,14 @@
+import React from "react";
+import Header from "@/components/Header";
+import ProcessContent from "./ProcessContent";
+
+const Process: React.FC = () => {
+ return (
+
+ );
+};
+
+export default Process;
\ No newline at end of file
diff --git a/frontend/src/pages/Process/ProcessContent.module.css b/frontend/src/pages/Process/ProcessContent.module.css
new file mode 100644
index 0000000..3508a2f
--- /dev/null
+++ b/frontend/src/pages/Process/ProcessContent.module.css
@@ -0,0 +1,48 @@
+/* Container */
+.container{
+ display: flex;
+ flex: 1;
+ justify-content: center;
+ align-items: center;
+ border-top: 1px solid #D9D9D9;
+ background: linear-gradient(262deg, rgba(193, 136, 255, 0.60) 0%, rgba(125, 131, 255, 0.60) 50%, rgba(122, 186, 255, 0.60) 100%);
+ padding: 2.5rem 9.69rem;
+}
+
+/* Content Container */
+.contentContainer{
+ display: flex;
+ width: 100%;
+ height: 100%;
+ padding: 5rem;
+ flex-direction: column;
+ align-items: center;
+ gap: 2.5rem;
+ border-radius: 0.75rem;
+ background: #FFF;
+ animation: fadeInUp 0.5s ease-out;
+}
+
+@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@media (max-width: 1440px) {
+
+ .contentContainer{
+ overflow-y: auto;
+ max-height: 100%;
+ padding-bottom: 20rem;
+ min-width: 520px;
+ }
+ .contentContainer::-webkit-scrollbar{
+ background-color: transparent;
+ }
+}
diff --git a/frontend/src/pages/Process/ProcessContent.tsx b/frontend/src/pages/Process/ProcessContent.tsx
new file mode 100644
index 0000000..a040703
--- /dev/null
+++ b/frontend/src/pages/Process/ProcessContent.tsx
@@ -0,0 +1,51 @@
+import React, { useEffect, useState } from "react";
+import styles from "./ProcessContent.module.css";
+import StepTracker from "@/components/StepTracker";
+import StepOne from "./Step/StepOne";
+import StepFive from "./Step/StepFive";
+
+const ProcessContent: React.FC = () => {
+ // localStorage에서 저장된 단계를 불러오거나 기본값 1 사용
+ const [currentStep, setCurrentStep] = useState(() => {
+ if (typeof window !== 'undefined') {
+ const savedStep = localStorage.getItem('currentStep');
+ return savedStep ? parseInt(savedStep, 10) : 1;
+ }
+ return 1;
+ });
+
+ // 컴포넌트 마운트/언마운트 시 localStorage 관리
+ useEffect(() => {
+ // 컴포넌트가 마운트될 때는 이미 useState에서 처리했으므로
+ // cleanup 함수만 정의
+ return () => {
+ // 컴포넌트가 언마운트될 때 localStorage에서 제거
+ if (typeof window !== 'undefined') {
+ localStorage.removeItem('currentStep');
+ }
+ };
+ }, []);
+
+ // currentStep이 변경될 때마다 localStorage에 저장
+ useEffect(() => {
+ console.log(currentStep);
+ if (typeof window !== 'undefined') {
+ localStorage.setItem('currentStep', currentStep.toString());
+ }
+ }, [currentStep]);
+
+ return (
+
+
+
+ {currentStep === 1 && }
+ {currentStep === 2 && }
+
+
+ );
+};
+
+export default ProcessContent;
\ No newline at end of file
diff --git a/frontend/src/pages/Process/Step/StepFive.module.css b/frontend/src/pages/Process/Step/StepFive.module.css
new file mode 100644
index 0000000..17b4f8d
--- /dev/null
+++ b/frontend/src/pages/Process/Step/StepFive.module.css
@@ -0,0 +1,306 @@
+/* Container */
+.container{
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ height: 100%;
+ gap: 2.5rem;
+ max-width: 1920px;
+}
+
+/* Header */
+.header{
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ gap: 1.5rem;
+}
+
+.title{
+ color: #001227;
+ font-size: 1.5rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+.subtitle{
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Content Container */
+.contentContainer{
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 30rem;
+ gap: 8.5rem;
+}
+@media (max-width: 1670px) {
+ .contentContainer{
+ gap: 5rem;
+ }
+}
+@media (max-width: 1440px) {
+ .contentContainer{
+ width: 100%;
+ height: auto; /* 100%에서 auto로 변경 */
+ flex-direction: column;
+ }
+
+}
+/* Video Container */
+.videoContainer{
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ flex: 1;
+}
+
+/* Video */
+.video{
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 1rem;
+}
+
+/* Info Container */
+.infoContainer{
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: flex-start;
+ width: 100%;
+ height: 100%;
+ flex: 1;
+}
+
+
+.infoTitle {
+ color: #001227;
+ font-size: 1.75rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ margin: 0;
+ margin-bottom: 1.75rem;
+}
+.infoPhoneNumber{
+ color: #001227;
+ font-size: 1.25rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ margin-bottom: 1.25rem;
+}
+.infoAddress{
+ color: #001227;
+ font-size: 1.25rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ margin-bottom: 1.25rem;
+}
+
+.infoDescription{
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 1.25rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ word-break: keep-all;
+ white-space: pre-wrap;
+ margin-bottom: auto;
+ width: 100%;
+ height: 45%;
+ overflow-y: auto;
+ text-overflow: ellipsis;
+}
+
+.infoHashtagContainer{
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 0.5rem;
+}
+@media (max-width: 1440px) {
+ .infoHashtagContainer{
+ margin-top: 5rem;
+ }
+}
+.hashtag{
+ padding: 0.5rem 1rem;
+ border-radius: 0.75rem;
+ background: #E0DFFF;
+
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ white-space: nowrap;
+ word-break: keep-all;
+}
+
+/* Button Container */
+.buttonContainer{
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+}
+
+/* Left Button Container */
+.leftButtonContainer{
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ margin-right: auto;
+ gap: 0.75rem;
+}
+
+.simpleButton {
+ display: flex;
+ padding: 0.625rem 1rem;
+ justify-content: center;
+ align-items: center;
+ gap: 0.62rem;
+ border-radius: 0.75rem;
+ background: #F6F6F6;
+ border: none;
+ color: #001227;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ cursor: pointer;
+ transition: transform 0.3s ease-in-out;
+}
+
+.recycleButton{
+ display: flex;
+ padding: 0.625rem;
+ justify-content: center;
+ align-items: center;
+ gap: 0.625rem;
+ border-radius: 0.75rem;
+ background: #60DCCC;
+ border: none;
+
+ color: #FFF;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ cursor: pointer;
+ transition: transform 0.3s ease-in-out;
+}
+.recycleButton img {
+ transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
+ transform-origin: center;
+}
+
+.recycleButton:hover img {
+ transform: rotate(-360deg);
+}
+
+/* Right Button Container */
+.rightButtonContainer{
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+}
+
+.downloadButton{
+ position: relative;
+ display: flex;
+ padding: 0.625rem 1rem;
+ justify-content: center;
+ align-items: center;
+ gap: 0.5rem;
+ border-radius: 0.75rem;
+ background: linear-gradient(90deg, rgba(174, 0, 255, 0.80) 0%, rgba(60, 219, 255, 0.80) 100%);
+ border: none;
+ color: #FFF;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ cursor: pointer;
+ min-width: 8.9rem;
+ overflow: hidden;
+}
+
+.downloadButton::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 50%;
+ height: 100%;
+ background: linear-gradient(120deg, transparent, rgba(255,255,255,0.4), transparent);
+ transform: skewX(-20deg);
+ transition: left 0.6s ease;
+ z-index: 1;
+ pointer-events: none;
+}
+
+.downloadButton:hover::before {
+ left: 100%;
+}
+
+/* Simple Pop Up */
+.SimplePopUp{
+ position: fixed;
+ top: 2rem;
+ left: 50%;
+ z-index: 10001;
+}
+
+.SimplePopUpContainer{
+ display: flex;
+ padding: 1rem 1.5rem;
+ align-items: center;
+ gap: 0.5rem;
+ border-radius: 0.5rem;
+ background: #10B981;
+ box-shadow: 0 0.25rem 0.375rem rgba(0, 0, 0, 0.1);
+ animation: slideDown 0.4s ease-out;
+ transform: translateX(-50%);
+}
+
+.SimplePopUpText{
+ color: #FFF;
+ font-size: 1.25rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Slide Down Animation */
+@keyframes slideDown {
+ 0% {
+ opacity: 0;
+ transform: translateX(-50%) translateY(-0.3rem) scale(0.98);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0) scale(1);
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Process/Step/StepFive.tsx b/frontend/src/pages/Process/Step/StepFive.tsx
new file mode 100644
index 0000000..1ddedfb
--- /dev/null
+++ b/frontend/src/pages/Process/Step/StepFive.tsx
@@ -0,0 +1,153 @@
+import React, { useState, useEffect } from "react";
+import styles from "./StepFive.module.css";
+import recycle from "@/assets/icons/recycle.svg";
+import { useNavigate } from "react-router-dom";
+import ErrorPopUp from "@/components/ErrorPopUp";
+import UploadToggle from "@/components/UploadToggle";
+import tempVideo from "@/temp/temp1.mp4";
+import { getMyVideoResult } from "@/api/temp/temp";
+
+const StepFive: React.FC<{setCurrentStep: (step: number) => void}> = ({setCurrentStep}) => {
+ const navigate = useNavigate();
+
+ const [videoInfo, setVideoInfo] = useState(null);
+ const [isErrorPopUpOpen, setIsErrorPopUpOpen] = useState(false);
+ const [isLoading, setIsLoading] = useState(true);
+
+ // 재생성 로직
+ const handleRegenerate = () => {
+ setIsErrorPopUpOpen(true);
+ };
+
+ // 다운로드 로직
+ const handleDownload = () => {
+ console.log("다운로드");
+ };
+
+ const Regenerate = () => {
+ setIsErrorPopUpOpen(false);
+ localStorage.removeItem("task_id");
+ localStorage.removeItem("order_id");
+ localStorage.removeItem("currentStep");
+ window.location.reload();
+ };
+
+ useEffect(() => {
+ const videoResult = async () => {
+ try {
+ const request_id = localStorage.getItem("id");
+ const request_order_id = localStorage.getItem("order_id");
+ const response = await getMyVideoResult({id: request_id || "", order_id: request_order_id || ""});
+ console.log(response);
+ setVideoInfo({
+ title: response.data.title,
+ phone_number: response.data.phone_number,
+ address: response.data.address,
+ description: response.data.description,
+ hashtag: response.data.hashtags.slice(0, 5),
+ url: response.data.video_url,
+ thumbnail_url: response.data.thumbnail_url,
+ });
+ } catch (error) {
+ console.error("비디오 정보 로드 실패:", error);
+ // 에러 처리 (예: 기본값 설정 또는 에러 메시지 표시)
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ videoResult();
+ }, []);
+
+ // 로딩 중일 때 표시할 내용
+ if (isLoading) {
+ return (
+
+
+
홍보 영상 생성 결과
+
영상 정보를 불러오는 중...
+
+
+ );
+ }
+
+ // videoInfo가 없을 때 처리
+ if (!videoInfo) {
+ return (
+
+
+
홍보 영상 생성 결과
+
영상 정보를 불러올 수 없습니다.
+
+
+ );
+ }
+
+ return (
+
+
+
+
홍보 영상 생성 결과
+
생성이 완료된 영상 콘텐츠를 확인해보세요.
+
+
+
+
+
+
+
+
+
{videoInfo.title}
+
매장 번호 : {videoInfo.phone_number}
+
매장 주소 : {videoInfo.address}
+
{videoInfo.description}
+
+ {videoInfo.hashtag?.map((hashtag: string, index: number) => (
+
{hashtag}
+ ))}
+
+
+
+
+
+
+
+
+
+ 다시 생성하기
+
+
+
+
+
+ {/* 팝 업 */}
+ {isErrorPopUpOpen &&
setIsErrorPopUpOpen(false) }, { text: "재생성", onClick: () => Regenerate(), buttonColor: "#60DCCC" }]}
+ setIsErrorPopUpOpen={setIsErrorPopUpOpen}
+ />}
+
+ );
+};
+
+export default StepFive;
\ No newline at end of file
diff --git a/frontend/src/pages/Process/Step/StepOne.module.css b/frontend/src/pages/Process/Step/StepOne.module.css
new file mode 100644
index 0000000..6d7b4f4
--- /dev/null
+++ b/frontend/src/pages/Process/Step/StepOne.module.css
@@ -0,0 +1,96 @@
+/* Container */
+.container{
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ gap: 2.5rem;
+ max-width: 1920px;
+}
+
+/* Header */
+.header{
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ gap: 0.5rem;
+}
+
+.title{
+ color: #001227;
+ font-size: 1.5rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+}
+.subtitle{
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Input Container */
+.inputContainer{
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+}
+
+/* Button Container */
+
+/* Button */
+.button {
+ position: relative;
+ display: flex;
+ padding: 0.625rem 1rem;
+ justify-content: center;
+ align-items: center;
+ gap: 0.5rem;
+ border-radius: 0.75rem;
+ background: linear-gradient(90deg, rgba(174, 0, 255, 0.80) 0%, rgba(60, 219, 255, 0.80) 100%);
+ border: none;
+ color: #FFF;
+ font-size: 1rem;
+ font-style: normal;
+ font-weight: 500;
+ line-height: normal;
+ cursor: pointer;
+ min-width: 8.9rem;
+ overflow: hidden;
+}
+
+.button::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 50%;
+ height: 100%;
+ background: linear-gradient(120deg, transparent, rgba(255,255,255,0.4), transparent);
+ transform: skewX(-20deg);
+ transition: left 0.6s ease;
+ z-index: 1;
+ pointer-events: none;
+}
+
+/* 활성화된 버튼만 hover 효과 적용 */
+.button:hover:not(.notActive)::before {
+ left: 100%;
+}
+
+.notActive {
+ background: #E2E2E2 !important;
+ cursor: not-allowed;
+}
+
+/* notActive 상태일 때 ::before 요소 숨기기 (선택사항) */
+.button.notActive::before {
+ display: none;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Process/Step/StepOne.tsx b/frontend/src/pages/Process/Step/StepOne.tsx
new file mode 100644
index 0000000..2a48f8c
--- /dev/null
+++ b/frontend/src/pages/Process/Step/StepOne.tsx
@@ -0,0 +1,88 @@
+import React, { useEffect } from "react";
+import styles from "./StepOne.module.css";
+import LabeledInput from "@/components/LabeledInput";
+import earth from "@/assets/icons/earth.svg";
+import humanMusic from "@/assets/icons/human_music.svg";
+// import ProgressBar from "@/components/ProgressBar";
+import StepProgressBar from "@/components/StepProgressBar";
+import { startWorkflow } from "@/api/temp/temp";
+
+const StepOne: React.FC<{setCurrentStep: (step: number) => void}> = ({setCurrentStep}) => {
+
+ const [itemUrl, setItemUrl] = React.useState("");
+ const [itemUrlErrorMessage, setItemUrlErrorMessage] = React.useState("");
+ const [isLoading, setIsLoading] = React.useState(false);
+
+ const handleNextStep = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if(!itemUrl.startsWith("https://map.naver.com/p/")){
+ setItemUrlErrorMessage("네이버 플레이스 URL을 입력해주세요.");
+ return;
+ }
+ setItemUrlErrorMessage("");
+
+ try{
+ const response = await startWorkflow({
+ id: localStorage.getItem("id") || "",
+ url: itemUrl,
+ });
+ if (response.task_id){
+ localStorage.setItem("task_id", response.task_id);
+ setIsLoading(true);
+ }
+ } catch (error){
+ alert("영상 생성에 실패했습니다. 다시 시도해주세요.");
+ }
+ }
+
+ const handleProgressComplete = () => {
+ setTimeout(() => {
+ setIsLoading(false);
+ setCurrentStep(2);
+ }, 1000);
+ }
+
+ useEffect(() => {
+ if (itemUrl === ""){
+ setItemUrlErrorMessage("");
+ }
+ }, [itemUrl]);
+
+ return (
+
+ {/* {isLoading &&
} */}
+ {isLoading &&
}
+
+
+
홍보 대상 정보 입력
+
콘텐츠 생성을 원하는 대상에 대한 URL을 입력해주세요.
+
+
+
+ }
+ value={itemUrl}
+ onChange={setItemUrl}
+ placeholder="네이버 플레이스 URL을 입력해주세요."
+ errorMessage={itemUrlErrorMessage}
+ />
+
+
+
+
+
+ 영상 생성하기
+
+
+
+
+ );
+};
+
+export default StepOne;
\ No newline at end of file
diff --git a/frontend/src/pages/Process/index.tsx b/frontend/src/pages/Process/index.tsx
new file mode 100644
index 0000000..c826b99
--- /dev/null
+++ b/frontend/src/pages/Process/index.tsx
@@ -0,0 +1 @@
+export { default } from "./Process";
\ No newline at end of file
diff --git a/frontend/src/pages/SignUp/SignUp.module.css b/frontend/src/pages/SignUp/SignUp.module.css
new file mode 100644
index 0000000..34c9b09
--- /dev/null
+++ b/frontend/src/pages/SignUp/SignUp.module.css
@@ -0,0 +1,69 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+.contentContainer{
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-top: 1px solid #D9D9D9;
+ background: linear-gradient(262deg, rgba(193, 136, 255, 0.60) 0%, rgba(125, 131, 255, 0.60) 50%, rgba(122, 186, 255, 0.60) 100%);
+ flex: 1;
+ box-sizing: border-box;
+ padding-top: 1.69rem;
+ padding-bottom: 2.5rem;
+}
+
+.content {
+ display: flex;
+ width: 26.6875rem;
+ padding: 1.25rem;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+ background: #FFF;
+ border-radius: 0.75rem;
+}
+/* Title */
+.title{
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+}
+.titleText{
+ color: #001227;
+ font-size: 1.5rem;
+ font-weight: 700;
+ line-height: normal;
+}
+.titleDescription{
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 1rem;
+ font-weight: 500;
+ line-height: normal;
+}
+
+/* Login Container */
+.loginContainer{
+ display: flex;
+ width: 100%;
+ justify-content: center;
+ align-items: center;
+ gap: 0.25rem;
+}
+.loginText{
+ color: #001227;
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: normal;
+}
+.login{
+ color: #A647FE;
+ font-size: 1rem;
+ font-weight: 700;
+ line-height: normal;
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/SignUp/SignUp.tsx b/frontend/src/pages/SignUp/SignUp.tsx
new file mode 100644
index 0000000..e276fe7
--- /dev/null
+++ b/frontend/src/pages/SignUp/SignUp.tsx
@@ -0,0 +1,40 @@
+import React from "react";
+import { useNavigate } from "react-router-dom";
+import styles from "./SignUp.module.css";
+import Header from "@/components/Header";
+import Divider from "@/components/Divider";
+import SocialLogin from "@/components/SocialLogin";
+import SignUpForm from "./SignUpForm";
+import Footer from "@/components/Footer";
+
+const SignUp: React.FC = () => {
+ const navigate = useNavigate();
+
+ const goToLogin = () => {
+ navigate("/login");
+ };
+
+ return (
+
+
+
+
+
+
회원가입
+
오투사운드 서비스에 가입해보세요.
+
+
+
+ {/*
*/}
+
+
+
+
+
+ );
+};
+
+export default SignUp;
\ No newline at end of file
diff --git a/frontend/src/pages/SignUp/SignUpForm.module.css b/frontend/src/pages/SignUp/SignUpForm.module.css
new file mode 100644
index 0000000..93dfc6f
--- /dev/null
+++ b/frontend/src/pages/SignUp/SignUpForm.module.css
@@ -0,0 +1,103 @@
+/* Form Container */
+.formContainer{
+ display: flex;
+ width: 100%;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+}
+.contentContainer{
+ display: flex;
+ width: 100%;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1.5rem;
+}
+
+/* Agreement */
+.checkBox{
+ width: 1rem;
+ height: 1rem;
+ cursor: pointer;
+}
+
+.agreement{
+ display: flex;
+ width: 100%;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 0.5rem;
+}
+
+.agreementTextContainer{
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+}
+.agreementBoldText{
+ color: #A647FE;
+ font-size: 0.875rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ cursor: pointer;
+}
+.agreementText{
+ color: #001227;
+ font-size: 0.875rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+}
+
+.signupButton{
+ width: 100%;
+ padding: 0.625rem 1rem;
+
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ overflow: hidden;
+ z-index: 0;
+
+ background: linear-gradient(90deg, rgba(174, 0, 255, 0.80) 0%, rgba(60, 219, 255, 0.80) 100%);
+ color: #FFF;
+ border: none;
+ border-radius: 0.75rem;
+
+ font-size: 1rem;
+ font-weight: 500;
+
+ cursor: pointer;
+}
+
+.signupButton::before {
+ /* Content */
+ content: "";
+ width: 50%;
+ height: 100%;
+
+ position: absolute;
+ top: 0;
+ left: -75%;
+ z-index: 1;
+
+ background: linear-gradient(120deg, transparent, rgba(255, 255, 255, 0.4), transparent);
+ transform: skewX(-20deg);
+ transition: left 0.6s ease;
+ pointer-events: none;
+}
+
+.signupButton:hover::before {
+ left: 125%;
+}
+
+.disabled{
+ background: #E2E2E2;
+ cursor: not-allowed;
+ user-select: none;
+}
+.disabled:hover::before {
+ left: -75%;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/SignUp/SignUpForm.tsx b/frontend/src/pages/SignUp/SignUpForm.tsx
new file mode 100644
index 0000000..4d69387
--- /dev/null
+++ b/frontend/src/pages/SignUp/SignUpForm.tsx
@@ -0,0 +1,147 @@
+import { useState, useEffect } from "react";
+import { useNavigate } from "react-router-dom";
+import styles from "./SignUpForm.module.css";
+import LabeledInput from "@/components/LabeledInput";
+import humanSetting from "@/assets/icons/human_setting.svg";
+import human from "@/assets/icons/human.svg";
+import lock from "@/assets/icons/lock.svg";
+import checkOn from "@/assets/icons/checkbox_on.svg";
+import checkOff from "@/assets/icons/checkbox_off.svg";
+import SignUpPopUp from "./SignUpPopUp";
+import { useSignUp } from "@/hooks/useSignUp";
+
+const SignUpForm: React.FC = () => {
+ const navigate = useNavigate();
+ const { singUp, errorCode, errorMessage, isLoading } = useSignUp();
+
+ const [isFormValid, setIsFormValid] = useState(false);
+ const [isPopUpOpen, setIsPopUpOpen] = useState(false);
+
+ const [name, setName] = useState("");
+ const [id, setId] = useState("");
+ const [password, setPassword] = useState("");
+ const [passwordCheck, setPasswordCheck] = useState("");
+
+ const [nameErrorMessage, setNameErrorMessage] = useState("");
+ const [idErrorMessage, setIdErrorMessage] = useState("");
+ const [passwordErrorMessage, setPasswordErrorMessage] = useState("");
+ const [passwordCheckErrorMessage, setPasswordCheckErrorMessage] = useState("");
+
+ const [check, setCheck] = useState(false);
+
+
+ const handleSignUp = async () => {
+ if (!isFormValid) return;
+ await singUp({
+ user_id: id,
+ name: name,
+ password: password,
+ password_check: passwordCheck,
+ });
+ };
+
+ const handleSignUpPopUp = () => {
+ setIsPopUpOpen(true);
+ };
+
+ const handleCheckBox = () => {
+ if (check === false) {
+ setIsPopUpOpen(true);
+ } else {
+ setCheck(!check);
+ }
+ }
+
+ useEffect(() => {
+ if (name && id && password && passwordCheck && check) {
+ setIsFormValid(true);
+ } else {
+ setIsFormValid(false);
+ }
+ }, [name, id, password, passwordCheck, check]);
+
+ return (
+ <>
+
+ >
+ );
+};
+
+export default SignUpForm;
\ No newline at end of file
diff --git a/frontend/src/pages/SignUp/SignUpPopUp.module.css b/frontend/src/pages/SignUp/SignUpPopUp.module.css
new file mode 100644
index 0000000..6e65622
--- /dev/null
+++ b/frontend/src/pages/SignUp/SignUpPopUp.module.css
@@ -0,0 +1,115 @@
+.container{
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 10;
+}
+.boxContainer{
+ display: flex;
+ flex-direction: column;
+ width: 25.625rem;
+ padding: 2.5rem;
+ border-radius: 1rem;
+ background: #FFF;
+ gap: 1.25rem;
+ border: none;
+}
+
+.title{
+ color: #001227;
+ font-size: 2.25rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ text-align: center;
+}
+
+/* Line */
+.line{
+ width: 100%;
+ height: 1.5px;
+ background: #D9D9D9;
+}
+
+
+/* Select Container */
+.selectContainer{
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
+.selectTitle{
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 1.125rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ letter-spacing: 0.0675rem;
+}
+
+.required{
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 0.9375rem;
+ font-style: normal;
+ font-weight: 700;
+ line-height: normal;
+ margin-right: 0.75rem;
+}
+
+.text{
+ color: rgba(0, 18, 39, 0.50);
+ font-size: 0.9375rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ letter-spacing: 0.05625rem;
+ margin-right: 0.5rem;
+}
+
+.arrow{
+ cursor: pointer;
+}
+
+.checkBox{
+ margin-left: auto;
+ cursor: pointer;
+}
+
+/* Button Container */
+.buttonContainer{
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.button{
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 1.125rem 3.75rem;
+ gap: 1.125rem;
+ border-radius: 1.125rem;
+ background: #B8B5FF;
+
+ color: #FFF;
+ font-size: 1.125rem;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+
+ margin-top: 1.25rem;
+ cursor: pointer;
+}
+
+.disabled{
+ background: #E2E2E2;
+ cursor: not-allowed;
+ user-select: none;
+}
\ No newline at end of file
diff --git a/frontend/src/pages/SignUp/SignUpPopUp.tsx b/frontend/src/pages/SignUp/SignUpPopUp.tsx
new file mode 100644
index 0000000..a1e9810
--- /dev/null
+++ b/frontend/src/pages/SignUp/SignUpPopUp.tsx
@@ -0,0 +1,132 @@
+import React, { useState, useEffect } from 'react';
+import styles from './SignUpPopUp.module.css';
+import checkOn from "@/assets/icons/checkbox_on.svg";
+import checkOff from "@/assets/icons/checkbox_off.svg";
+import arrow from "@/assets/icons/arrow.svg";
+
+import TermsOfService from '@/components/Policies/TermsOfService';
+import PrivacyPolicy from '@/components/Policies/PrivacyPolicy';
+import MarketingConsent from '@/components/Policies/MarketingConsent';
+
+interface SignUpPopUpProps {
+ setInfoComplete: (infoComplete: boolean) => void;
+ setIsPopUpOpen: (isPopUpOpen: boolean) => void;
+}
+
+const SignUpPopUp: React.FC = ({setInfoComplete, setIsPopUpOpen}) => {
+ const [complete, setComplete] = useState(false);
+ const [allAgree, setAllAgree] = useState(false);
+ const [termsAgree, setTermsAgree] = useState(false);
+ const [privacyAgree, setPrivacyAgree] = useState(false);
+ const [marketingAgree, setMarketingAgree] = useState(false);
+
+ const [isTermsOpen, setIsTermsOpen] = useState(false);
+ const [isPrivacyOpen, setIsPrivacyOpen] = useState(false);
+ const [isMarketingOpen, setIsMarketingOpen] = useState(false);
+
+ // 전체 동의 처리
+ const handleAllAgree = () => {
+ const newState = !allAgree;
+ setAllAgree(newState);
+ setTermsAgree(newState);
+ setPrivacyAgree(newState);
+ setMarketingAgree(newState);
+ setComplete(newState);
+ };
+
+ // 개별 동의 처리
+ const handleTermsAgree = () => setTermsAgree(!termsAgree);
+ const handlePrivacyAgree = () => setPrivacyAgree(!privacyAgree);
+ const handleMarketingAgree = () => setMarketingAgree(!marketingAgree);
+
+ // 전체 동의 상태 업데이트
+ useEffect(() => {
+ const allChecked = termsAgree && privacyAgree && marketingAgree;
+ setAllAgree(allChecked);
+ setComplete(allChecked);
+ }, [termsAgree, privacyAgree, marketingAgree]);
+
+ // 체크박스 컴포넌트
+ const CheckBox: React.FC<{ checked: boolean; onClick: () => void }> = ({ checked, onClick }) => (
+
+ );
+
+ // 약관 모달 열기
+ const handleTermsOpen = () => setIsTermsOpen(true);
+ const handlePrivacyOpen = () => setIsPrivacyOpen(true);
+ const handleMarketingOpen = () => setIsMarketingOpen(true);
+
+ // 약관 목록
+ const agreementItems: {id: string, text: string, state: boolean, handler: () => void, openHandler: () => void}[] = [
+ {
+ id: 'terms',
+ text: '서비스 이용 약관',
+ state: termsAgree,
+ handler: handleTermsAgree,
+ openHandler: handleTermsOpen,
+ },
+ {
+ id: 'privacy',
+ text: '개인정보처리방침',
+ state: privacyAgree,
+ handler: handlePrivacyAgree,
+ openHandler: handlePrivacyOpen,
+ },
+ {
+ id: 'marketing',
+ text: '마케팅 활용/광고성 정보 수신동의',
+ state: marketingAgree,
+ handler: handleMarketingAgree,
+ openHandler: handleMarketingOpen,
+ }
+ ];
+
+ const handleComplete = () => {
+ if (!complete) return;
+ setInfoComplete(true);
+ setIsPopUpOpen(false);
+ };
+
+ return (
+ <>
+ {isTermsOpen && }
+ {isPrivacyOpen && }
+ {isMarketingOpen && }
+ setIsPopUpOpen(false)}>
+
e.stopPropagation()}>
+
회원가입
+
+ {/* 전체 동의 */}
+
+
+
+
+ {/* 개별 약관 동의 */}
+ {agreementItems.map((item) => (
+
+
필수
+
{item.text}
+
+
+
+ ))}
+
+
+
+
+
+ >
+ );
+};
+
+export default SignUpPopUp;
\ No newline at end of file
diff --git a/frontend/src/pages/SignUp/index.tsx b/frontend/src/pages/SignUp/index.tsx
new file mode 100644
index 0000000..0756ffc
--- /dev/null
+++ b/frontend/src/pages/SignUp/index.tsx
@@ -0,0 +1 @@
+export { default } from "./SignUp";
\ No newline at end of file
diff --git a/frontend/src/recoil/Atoms/authAtom.ts b/frontend/src/recoil/Atoms/authAtom.ts
new file mode 100644
index 0000000..7a69371
--- /dev/null
+++ b/frontend/src/recoil/Atoms/authAtom.ts
@@ -0,0 +1,25 @@
+import { atom } from "recoil";
+
+const getStoredUserId = () => {
+ return localStorage.getItem("id") ?? "";
+};
+
+// user_id가 있으면 true, 없으면 false
+const getIsLoggedIn = () => {
+ const userId = localStorage.getItem("id");
+ return !!userId;
+};
+
+const getStoredName = () => {
+ return localStorage.getItem("name") ?? "";
+};
+
+export const authAtom = atom({
+ key: "authState",
+ default: {
+ isLoggedIn: getIsLoggedIn(),
+ id: getStoredUserId(),
+ name: getStoredName(),
+ platform: "local",
+ },
+});
\ No newline at end of file
diff --git a/frontend/src/recoil/Atoms/googleAtom.ts b/frontend/src/recoil/Atoms/googleAtom.ts
new file mode 100644
index 0000000..4473059
--- /dev/null
+++ b/frontend/src/recoil/Atoms/googleAtom.ts
@@ -0,0 +1,57 @@
+// atoms/googleAtoms.ts
+import { atom } from 'recoil';
+
+export interface GoogleUserInfo {
+ provider: string;
+ provider_id: string;
+ email?: string;
+ name?: string;
+ picture?: string;
+ raw_data: Record;
+}
+
+// Google OAuth 로딩 상태
+export const googleLoadingState = atom({
+ key: 'googleLoadingState',
+ default: false,
+});
+
+// Google OAuth 에러 상태
+export const googleErrorState = atom({
+ key: 'googleErrorState',
+ default: null as string | null,
+});
+
+// Google 사용자 정보
+export const googleUserInfoState = atom({
+ key: 'googleUserInfoState',
+ default: null as GoogleUserInfo | null,
+ effects: [
+ ({ setSelf, onSet }) => {
+ // 초기 로드 시 localStorage에서 사용자 정보 복원
+ const savedUserInfo = localStorage.getItem('user_info');
+ if (savedUserInfo) {
+ try {
+ setSelf(JSON.parse(savedUserInfo));
+ } catch (e) {
+ console.error('Failed to parse user info:', e);
+ }
+ }
+
+ // 사용자 정보 변경 시 localStorage 업데이트
+ onSet((newValue, _, isReset) => {
+ if (isReset || newValue === null) {
+ localStorage.removeItem('user_info');
+ } else {
+ localStorage.setItem('user_info', JSON.stringify(newValue));
+ }
+ });
+ },
+ ],
+});
+
+// 중복 처리 방지용 플래그
+export const googleProcessingState = atom({
+ key: 'googleProcessingState',
+ default: false,
+});
\ No newline at end of file
diff --git a/frontend/src/recoil/Atoms/index.tsx b/frontend/src/recoil/Atoms/index.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/frontend/src/recoil/Atoms/orderAtom.ts b/frontend/src/recoil/Atoms/orderAtom.ts
new file mode 100644
index 0000000..ff10d6c
--- /dev/null
+++ b/frontend/src/recoil/Atoms/orderAtom.ts
@@ -0,0 +1,9 @@
+import { atom } from "recoil";
+
+export const orderAtom = atom({
+ key: "orderAtom",
+ default: {
+ order_id: "",
+ status: "",
+ },
+});
diff --git a/frontend/src/recoil/Selectors/googleSelectors.ts b/frontend/src/recoil/Selectors/googleSelectors.ts
new file mode 100644
index 0000000..4147d54
--- /dev/null
+++ b/frontend/src/recoil/Selectors/googleSelectors.ts
@@ -0,0 +1,29 @@
+import { selector } from 'recoil';
+import {
+ googleLoadingState,
+ googleErrorState,
+ googleUserInfoState,
+} from '@/recoil/Atoms/googleAtom';
+
+// Google 로그인 상태 확인
+export const googleIsLoggedInSelector = selector({
+ key: 'googleIsLoggedInSelector',
+ get: ({ get }) => {
+ const userInfo = get(googleUserInfoState);
+ const accessToken = localStorage.getItem('access_token');
+ return !!(userInfo && accessToken);
+ },
+});
+
+// Google OAuth 전체 상태
+export const googleOAuthStateSelector = selector({
+ key: 'googleOAuthStateSelector',
+ get: ({ get }) => {
+ return {
+ isLoggedIn: get(googleIsLoggedInSelector),
+ isLoading: get(googleLoadingState),
+ error: get(googleErrorState),
+ userInfo: get(googleUserInfoState),
+ };
+ },
+});
\ No newline at end of file
diff --git a/frontend/src/temp/temp1.mp4 b/frontend/src/temp/temp1.mp4
new file mode 100644
index 0000000..b96ccbb
Binary files /dev/null and b/frontend/src/temp/temp1.mp4 differ
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..0f87eed
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,9 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_BACKEND_BASE_URL: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
\ No newline at end of file
diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json
new file mode 100644
index 0000000..3f634cc
--- /dev/null
+++ b/frontend/tsconfig.app.json
@@ -0,0 +1,33 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ //"erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ /* ✅ alias 설정 */
+ "baseUrl": "src",
+ "paths": {
+ "@/*": ["*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..47d4734
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,31 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ //"erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ /* ✅ alias 설정 */
+ "baseUrl": "src",
+ "paths": {
+ "@/*": ["*"]
+ }
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..9659cf8
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import tsconfigPaths from 'vite-tsconfig-paths'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react(), tsconfigPaths()],
+ server: {
+ port: 3300,
+ host: true, // Docker에서 외부 접근을 위해 추가
+ },
+ preview: {
+ port: 3300, // preview 모드도 3000 포트 사용
+ host: true, // Docker에서 외부 접근을 위해 추가
+ },
+});
diff --git a/nginx/conf.d/o2sound_nginx.conf b/nginx/conf.d/o2sound_nginx.conf
new file mode 100644
index 0000000..692c607
--- /dev/null
+++ b/nginx/conf.d/o2sound_nginx.conf
@@ -0,0 +1,113 @@
+upstream sound_frontend {
+ server 127.0.0.1:12200;
+ keepalive 32;
+}
+
+# 프론트엔드 (메인 도메인)
+server {
+ server_name o2sound.ai.kr www.o2sound.ai.kr localhost 127.0.0.1;
+
+ # 프론트엔드
+ location / {
+ proxy_pass http://sound_frontend;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_cache_bypass $http_upgrade;
+ }
+
+ # listen 443 ssl; # managed by Certbot
+ # ssl_certificate /etc/letsencrypt/live/o2sound.ai.kr/fullchain.pem; # managed by Certbot
+ # ssl_certificate_key /etc/letsencrypt/live/o2sound.ai.kr/privkey.pem; # managed by Certbot
+ # include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
+ # ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
+
+
+}
+
+upstream sound_backend {
+ server 127.0.0.1:12300; # 또는 내부 포트
+ keepalive 32;
+}
+
+# 백엔드 API (api 서브도메인)
+server {
+ server_name api.o2sound.ai.kr;
+
+ # 최대 업로드 크기 (비디오 업로드용)
+ client_max_body_size 500M;
+
+ # 백엔드 API (FastAPI)
+ location / {
+ proxy_pass http://sound_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_cache_bypass $http_upgrade;
+
+ # 타임아웃 설정 (긴 작업 대응)
+ proxy_connect_timeout 600;
+ proxy_send_timeout 600;
+ proxy_read_timeout 600;
+ send_timeout 600;
+ }
+
+ # WebSocket 지원 (진행률 조회용)
+ location /ws {
+ proxy_pass http://127.0.0.1:12360;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_read_timeout 86400;
+ }
+
+ # listen 443 ssl; # managed by Certbot
+ # ssl_certificate /etc/letsencrypt/live/api.o2sound.ai.kr/fullchain.pem; # managed by Certbot
+ # ssl_certificate_key /etc/letsencrypt/live/api.o2sound.ai.kr/privkey.pem; # managed by Certbot
+ # include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
+ # ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
+
+}
+
+server {
+ if ($host = www.o2sound.ai.kr) {
+ return 301 https://$host$request_uri;
+ } # managed by Certbot
+
+
+ if ($host = o2sound.ai.kr) {
+ return 301 https://$host$request_uri;
+ } # managed by Certbot
+
+
+ listen 80;
+ server_name o2sound.ai.kr www.o2sound.ai.kr;
+ return 404; # managed by Certbot
+
+
+
+
+}
+server {
+ if ($host = api.o2sound.ai.kr) {
+ return 301 https://$host$request_uri;
+ } # managed by Certbot
+
+
+ listen 80;
+ server_name api.o2sound.ai.kr;
+ return 404; # managed by Certbot
+
+
+}
\ No newline at end of file
diff --git a/postgresql/data/.gitkeep b/postgresql/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/postgresql/init/01-init.sql b/postgresql/init/01-init.sql
new file mode 100644
index 0000000..38de37c
--- /dev/null
+++ b/postgresql/init/01-init.sql
@@ -0,0 +1,22 @@
+-- Initialize o2sound_v2 database
+-- This script runs automatically when the PostgreSQL container starts for the first time
+
+-- The database 'o2sound_v2' is already created by POSTGRES_DB environment variable
+-- The user 'postgres' is already created by POSTGRES_USER environment variable
+
+-- Set default configuration for the database
+ALTER DATABASE o2sound_v2 SET timezone TO 'Asia/Seoul';
+
+-- Grant all privileges to postgres user (already has them, but explicit)
+GRANT ALL PRIVILEGES ON DATABASE o2sound_v2 TO postgres;
+
+-- Connect to the o2sound_v2 database
+\c o2sound_v2
+
+-- Add any additional initialization here
+-- For example, create extensions if needed:
+-- CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+-- CREATE EXTENSION IF NOT EXISTS "pg_trgm";
+
+-- Log completion
+SELECT 'Database o2sound_v2 initialized successfully' AS status;
diff --git a/redis/data/.gitkeep b/redis/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/redis/redis.conf b/redis/redis.conf
new file mode 100644
index 0000000..e874157
--- /dev/null
+++ b/redis/redis.conf
@@ -0,0 +1,44 @@
+# Redis configuration file for o2sound
+
+# Network
+bind 0.0.0.0
+protected-mode no
+port 6379
+
+# General
+daemonize no
+supervised no
+pidfile /var/run/redis_6379.pid
+loglevel notice
+logfile ""
+
+# Snapshotting (RDB persistence)
+save 900 1
+save 300 10
+save 60 10000
+stop-writes-on-bgsave-error yes
+rdbcompression yes
+rdbchecksum yes
+dbfilename dump.rdb
+dir /data
+
+# Append Only File (AOF persistence)
+appendonly yes
+appendfilename "appendonly.aof"
+appendfsync everysec
+no-appendfsync-on-rewrite no
+auto-aof-rewrite-percentage 100
+auto-aof-rewrite-min-size 64mb
+
+# Memory management
+maxmemory 256mb
+maxmemory-policy allkeys-lru
+
+# Slow log
+slowlog-log-slower-than 10000
+slowlog-max-len 128
+
+# Client output buffer limits
+client-output-buffer-limit normal 0 0 0
+client-output-buffer-limit replica 256mb 64mb 60
+client-output-buffer-limit pubsub 32mb 8mb 60