From 42769e39b13eddd6a3243246808cfa4b05089117 Mon Sep 17 00:00:00 2001 From: kimtaewoo <70637743+kim3360@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:35:56 +0900 Subject: [PATCH 1/4] =?UTF-8?q?style=20:=20=EB=9E=9C=EB=94=A9=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=8A=A4=ED=81=AC=EB=A6=B0=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/main/HeroSection.tsx | 460 +++++++++++++++--- src/components/layout/main/MainContainer.tsx | 4 +- src/components/layout/main/OutputShowcase.tsx | 2 +- .../layout/main/PainPointsSection.tsx | 5 +- src/components/layout/main/PricingSection.tsx | 5 +- src/components/layout/main/ProcessSection.tsx | 5 +- src/components/layout/main/ServiceIntro.tsx | 5 +- src/components/layout/main/UserReviews.tsx | 2 +- .../onboarding/CloudOnboardingGuide.tsx | 20 +- .../layout/project/AgentClarificationForm.tsx | 7 +- .../layout/project/AgentConversationPanel.tsx | 13 +- .../layout/project/AgentSitePreviewPanel.tsx | 4 +- .../layout/project/CloudConnectGuidePanel.tsx | 4 +- .../layout/project/ProjectApprovalsPage.tsx | 9 +- .../layout/project/ProjectDatabaseSection.tsx | 134 +++-- .../layout/project/ProjectDetailPage.tsx | 110 ++--- .../layout/project/ProjectDomainsPage.tsx | 45 +- .../layout/project/ProjectRuntimeSection.tsx | 5 +- .../layout/project/ProjectServerSection.tsx | 4 +- .../layout/project/ServerLogViewer.tsx | 4 +- src/index.css | 134 +++++ 21 files changed, 727 insertions(+), 254 deletions(-) diff --git a/src/components/layout/main/HeroSection.tsx b/src/components/layout/main/HeroSection.tsx index 34a3a23..e41d013 100644 --- a/src/components/layout/main/HeroSection.tsx +++ b/src/components/layout/main/HeroSection.tsx @@ -1,104 +1,410 @@ -import { useNavigate } from '@tanstack/react-router'; -import { useCallback } from 'react'; -import { Button } from '@/components/ui/button'; -import githubIcon from '@/assets/icons/github.svg'; -import heroSectionImage from '@/assets/images/heroSection_img.svg'; -import heroSectionBgImage from '@/assets/images/heroSection_bg_img.svg'; +import { useCallback, useEffect, useState } from 'react'; +import { Link, useNavigate } from '@tanstack/react-router'; +import { ArrowRight, ChevronDown, X } from 'lucide-react'; import { useGitHubLogin } from '@/hooks/useGitHubLogin'; import { useIsLoggedIn } from '@/hooks/useIsLoggedIn'; +import { cn } from '@/lib/utils'; -const headlineGradientBg = - 'bg-[linear-gradient(90deg,#6D28D9_0%,#7C3AED_42%,#A855F7_100%)] bg-clip-text text-transparent'; +type Phase = + | 'logo' + | 'roll' + | 'dot' + | 'orbit' + | 'scatter' + | 'pill' + | 'type' + | 'cuts' + | 'headline' + | 'copy'; + +const LOGO_CHARS = ['Q', 'e', 'p', 'l', 'o', 'y'] as const; + +const PROMPT = 'IT 기업 홈페이지, 블루 계열로 만들어주세요'; + +const CUT_WORDS = ['랜딩', '포트폴리오', '쇼핑몰', '관리자', '미디어'] as const; + +const ROLES = ['웹에이전시', '디자인 스튜디오', '개발팀', '배포 파이프라인'] as const; + +const ORBIT_WORDS = [ + { word: 'GitHub', slot: 'top' }, + { word: 'Agent', slot: 'right' }, + { word: 'Preview', slot: 'bottom' }, + { word: 'Deploy', slot: 'left' }, +] as const; + +const NAV_ITEMS = [ + { label: '서비스 소개', id: 'intro' }, + { label: '고민', id: 'pain' }, + { label: '결과물', id: 'showcase' }, + { label: '진행 과정', id: 'process' }, + { label: '요금', id: 'pricing' }, + { label: '후기', id: 'reviews' }, +] as const; + +const PHASE_AT: Array<[Phase, number]> = [ + ['logo', 0], + ['roll', 900], + ['dot', 1900], + ['orbit', 2700], + ['scatter', 5000], + ['pill', 6600], + ['type', 7600], + ['cuts', 11200], + ['headline', 14200], + ['copy', 15600], +]; + +function scrollToSection(sectionId: string) { + const target = document.getElementById(sectionId); + if (!target) return; + const top = target.getBoundingClientRect().top + window.scrollY - 12; + window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); +} + +function scatterOffset(word: string, index: number, slot: string) { + const mid = (word.length - 1) / 2; + const x = (index - mid) * 34; + const spread = 18 + index * 12; + + if (slot === 'top') return { x, y: -36 - spread }; + if (slot === 'bottom') return { x, y: 36 + spread }; + if (slot === 'left') return { x: -40 - spread, y: (index - mid) * 22 }; + return { x: 40 + spread, y: (index - mid) * 22 }; +} function HeroSection() { - const { - startGitHubLogin, - isLoading: isLoggingIn, - errorMessage: loginErrorMessage, - } = useGitHubLogin(); - const [isLoggedIn] = useIsLoggedIn(); + const [bannerOpen, setBannerOpen] = useState(true); + const [phase, setPhase] = useState('logo'); + const [typed, setTyped] = useState(''); + const [cutIndex, setCutIndex] = useState(0); + const [roleIndex, setRoleIndex] = useState(0); + const [reduceMotion] = useState( + () => + typeof window !== 'undefined' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches, + ); + const navigate = useNavigate(); + const { startGitHubLogin, isLoading: isLoggingIn } = useGitHubLogin(); + const [isLoggedIn] = useIsLoggedIn(); + + const showLogo = phase === 'logo' || phase === 'roll'; + const showDot = phase === 'dot' || phase === 'orbit' || phase === 'scatter'; + const showOrbit = phase === 'orbit' || phase === 'scatter'; + const showPill = phase === 'pill' || phase === 'type'; + const showCuts = phase === 'cuts'; + const showHeadline = phase === 'headline' || phase === 'copy'; + const showCopy = phase === 'copy'; - const handleGitHubLogin = useCallback(() => { + const handleAuth = useCallback(() => { if (isLoggingIn) return; if (isLoggedIn) { void navigate({ to: '/home' }); return; } void startGitHubLogin(); - }, [isLoggingIn, isLoggedIn, navigate, startGitHubLogin]); + }, [isLoggedIn, isLoggingIn, navigate, startGitHubLogin]); + + useEffect(() => { + if (reduceMotion) { + setPhase('copy'); + setTyped(PROMPT); + return; + } + + const timers = PHASE_AT.filter(([, at]) => at > 0).map(([nextPhase, at]) => + window.setTimeout(() => setPhase(nextPhase), at), + ); + + return () => { + timers.forEach((timer) => window.clearTimeout(timer)); + }; + }, [reduceMotion]); + + useEffect(() => { + if (phase !== 'type' || reduceMotion) return; + + setTyped(''); + let index = 0; + const timer = window.setInterval(() => { + index += 1; + setTyped(PROMPT.slice(0, index)); + if (index >= PROMPT.length) window.clearInterval(timer); + }, 48); + + return () => window.clearInterval(timer); + }, [phase, reduceMotion]); + + useEffect(() => { + if (phase !== 'cuts' || reduceMotion) return; + + setCutIndex(0); + const timer = window.setInterval(() => { + setCutIndex((current) => (current + 1) % CUT_WORDS.length); + }, 620); + + return () => window.clearInterval(timer); + }, [phase, reduceMotion]); + + useEffect(() => { + if (!showHeadline) return; + + const timer = window.setInterval(() => { + setRoleIndex((current) => (current + 1) % ROLES.length); + }, 2200); + + return () => window.clearInterval(timer); + }, [showHeadline]); return ( -
- 배경이미지 -
-
-

AI 웹 제작 · 프롬프트부터 배포까지

-

- - 기획서 없이도 괜찮아요, - - - - 말로 설명하면 사이트가 완성 +

+
+ + Qeploy + + +
+ + +
+
+ + {bannerOpen ? ( +
+
+

+ 쓰던 GitHub에 Qeploy를 연결하세요. 말로 설명하면 홈페이지가 만들어집니다. +

+ + +
+
+ ) : null} + +
+
+ + + +
+ +
+

+ {LOGO_CHARS.map((char, index) => ( + + {char} - 됩니다 + ))} +

+ + + AI + Agent + +
+ +
+ {showDot ? ( + Q + ) : null} + {showPill ? ( +

+ {typed} + {phase === 'type' ? ( + + ) : null} +

+ ) : null} +
+ + {showOrbit + ? ORBIT_WORDS.map((item) => ( +

+ {item.word.split('').map((char, index) => { + const offset = scatterOffset(item.word, index, item.slot); + const scattered = phase === 'scatter'; + return ( + + {char} + + ); + })} +

+ )) + : null} + + {showCuts ? ( +

+ {CUT_WORDS[cutIndex]} +

-

- 디자인·카피·레이아웃을 AI가 한 번에 제안하고, 에이전트와 대화하며 계속 다듬을 수 - 있습니다. + ) : null} + +

+

+ 당신의 아이디어는 오늘부터 +
+ + {ROLES[roleIndex]} + + 입니다 + +

+

+ Qeploy를 연결하면 말로 설명한 서비스가
- 혼자 붙잡고 있던 랜딩·포트폴리오를{' '} - 실제 URL까지 이어 보세요. + 디자인되고, 개발되고, 배포까지 이어집니다.

- - {loginErrorMessage ?

{loginErrorMessage}

: null} -
- - - + +
-
- -
+ +
); } diff --git a/src/components/layout/main/MainContainer.tsx b/src/components/layout/main/MainContainer.tsx index 96d96e6..130ab5c 100644 --- a/src/components/layout/main/MainContainer.tsx +++ b/src/components/layout/main/MainContainer.tsx @@ -1,4 +1,3 @@ -import HeaderContainer from '@/components/layout/header/HeaderContainer'; import HeroSection from './HeroSection'; import HighlightSection from './HighlightSection'; import PainPointsSection from './PainPointsSection'; @@ -14,8 +13,7 @@ import Footer from '../footer/Footer'; function MainContainer() { return ( -
- +
diff --git a/src/components/layout/main/OutputShowcase.tsx b/src/components/layout/main/OutputShowcase.tsx index ad9e028..b0c81f4 100644 --- a/src/components/layout/main/OutputShowcase.tsx +++ b/src/components/layout/main/OutputShowcase.tsx @@ -84,7 +84,7 @@ function OutputShowcase() { }, [categoryFilter]); return ( -
+

diff --git a/src/components/layout/main/PainPointsSection.tsx b/src/components/layout/main/PainPointsSection.tsx index c998b27..87c3fd3 100644 --- a/src/components/layout/main/PainPointsSection.tsx +++ b/src/components/layout/main/PainPointsSection.tsx @@ -4,7 +4,10 @@ const infoCardClassName = 'w-[330px] shadow-[0_10px_40px_-8px_rgba(15,23,42,0.12 function PainPointsSection() { return ( -
+

이런 고민 없으신가요?

혼자 만들기엔 막막한 순간들

diff --git a/src/components/layout/main/PricingSection.tsx b/src/components/layout/main/PricingSection.tsx index 630736c..79421ac 100644 --- a/src/components/layout/main/PricingSection.tsx +++ b/src/components/layout/main/PricingSection.tsx @@ -75,7 +75,10 @@ const pricingCardItems: PricingCardProps[] = [ function PricingSection() { return ( -
+

요금 안내

팀 규모에 맞는 플랜

diff --git a/src/components/layout/main/ProcessSection.tsx b/src/components/layout/main/ProcessSection.tsx index c73df1a..26e8ef5 100644 --- a/src/components/layout/main/ProcessSection.tsx +++ b/src/components/layout/main/ProcessSection.tsx @@ -49,7 +49,10 @@ const processCards = [ function ProcessSection() { return ( -
+

프로그램 진행 과정

이렇게 이어집니다

diff --git a/src/components/layout/main/ServiceIntro.tsx b/src/components/layout/main/ServiceIntro.tsx index 54e5a45..d01d6db 100644 --- a/src/components/layout/main/ServiceIntro.tsx +++ b/src/components/layout/main/ServiceIntro.tsx @@ -5,7 +5,10 @@ const descriptionClassName = 'typo-b5-rg'; function ServiceIntro() { return ( -
+

Qeploy란?

아이디어부터 출시까지, AI와 함께하는 웹 제작

diff --git a/src/components/layout/main/UserReviews.tsx b/src/components/layout/main/UserReviews.tsx index 0da662b..c3d3a15 100644 --- a/src/components/layout/main/UserReviews.tsx +++ b/src/components/layout/main/UserReviews.tsx @@ -2,7 +2,7 @@ import InfoCard from '@/components/common/InfoCard'; function UserReviews() { return ( -
+

이용 후기

팀에서 남긴 한 줄 평가

diff --git a/src/components/layout/onboarding/CloudOnboardingGuide.tsx b/src/components/layout/onboarding/CloudOnboardingGuide.tsx index 20c7165..33f1acb 100644 --- a/src/components/layout/onboarding/CloudOnboardingGuide.tsx +++ b/src/components/layout/onboarding/CloudOnboardingGuide.tsx @@ -270,8 +270,8 @@ function CloudOnboardingGuide() { 내 클라우드에 백엔드를 올리기 위한 딱 한 번의 준비

- Qeploy가 당신의 백엔드 서버를 직접 띄우려면, 당신 소유의 AWS 계정 한 개가 - 필요합니다. AWS를 처음 써봐도 괜찮습니다 — 순서대로만 따라오면 됩니다. + Qeploy가 당신의 백엔드 서버를 직접 띄우려면, 당신 소유의 AWS 계정 한 개가 필요합니다. + AWS를 처음 써봐도 괜찮습니다 — 순서대로만 따라오면 됩니다.

@@ -292,7 +292,9 @@ function CloudOnboardingGuide() {

그 대신 딱 한 번,{' '} - 당신의 AWS 계정을 Qeploy에 연결 + + 당신의 AWS 계정을 Qeploy에 연결 + 해 주면 됩니다. 그다음부터는 “백엔드 올려줘”라고 요청하면 Qeploy가 알아서 빌드하고 서버를 띄웁니다.

@@ -336,9 +338,7 @@ function CloudOnboardingGuide() {

첫 12개월은 AWS 프리티어로 더 저렴할 수 있고,{' '} - - 서버를 종료하면 그 순간부터 청구가 멈춥니다. - {' '} + 서버를 종료하면 그 순간부터 청구가 멈춥니다.{' '} 만들기 전에 Qeploy가 예상 비용을 보여주고, 당신이 승인해야만 진행합니다.

@@ -415,13 +415,13 @@ function CloudOnboardingGuide() { 한 곳에서 멈춰도 괜찮습니다

- 어느 단계든 화면 안내를 그대로 따르면 됩니다. 계정을 만드는 1단계가 가장 낯설 수 - 있는데, 그건 당신 명의의 계정이라 우리가 대신 만들어 드릴 수는 없는 부분입니다 — 대신 + 어느 단계든 화면 안내를 그대로 따르면 됩니다. 계정을 만드는 1단계가 가장 낯설 수 있는데, + 그건 당신 명의의 계정이라 우리가 대신 만들어 드릴 수는 없는 부분입니다 — 대신 준비물(이메일·카드·휴대폰)만 있으면 화면을 따라 충분히 하실 수 있습니다.

- 이 안내는 백엔드(서버형) 배포를 위한 것입니다. 정적 사이트만 올릴 때는 AWS 연결 - 없이도 배포됩니다. + 이 안내는 백엔드(서버형) 배포를 위한 것입니다. 정적 사이트만 올릴 때는 AWS 연결 없이도 + 배포됩니다.

diff --git a/src/components/layout/project/AgentClarificationForm.tsx b/src/components/layout/project/AgentClarificationForm.tsx index 83e817b..890a67e 100644 --- a/src/components/layout/project/AgentClarificationForm.tsx +++ b/src/components/layout/project/AgentClarificationForm.tsx @@ -40,7 +40,8 @@ function AgentClarificationForm({ */ const recommended = clarification.options.filter((option) => option.recommended === true); if (recommended.length === 0) return []; - const picked = clarification.inputType === 'MULTI_SELECT' ? recommended : recommended.slice(0, 1); + const picked = + clarification.inputType === 'MULTI_SELECT' ? recommended : recommended.slice(0, 1); return picked.map((option) => option.value); }); const [otherText, setOtherText] = useState(''); @@ -80,8 +81,8 @@ function AgentClarificationForm({ {clarification.question}

- {isMulti ? '해당하는 것을 모두 고르세요.' : '하나를 고르세요.'} 답하면 하던 작업을 - 이어서 진행합니다. + {isMulti ? '해당하는 것을 모두 고르세요.' : '하나를 고르세요.'} 답하면 하던 작업을 이어서 + 진행합니다.

diff --git a/src/components/layout/project/AgentConversationPanel.tsx b/src/components/layout/project/AgentConversationPanel.tsx index a033d84..da8d08a 100644 --- a/src/components/layout/project/AgentConversationPanel.tsx +++ b/src/components/layout/project/AgentConversationPanel.tsx @@ -508,9 +508,7 @@ function AgentConversationPanel({ conversationId: targetConversationId, taskId: result.taskId, }); - setAlertMessage( - '메시지는 저장됐지만 작업이 시작되지 않았습니다. 다시 요청해 주세요.', - ); + setAlertMessage('메시지는 저장됐지만 작업이 시작되지 않았습니다. 다시 요청해 주세요.'); } setIsAssistantReplying(false); @@ -767,7 +765,9 @@ function AgentConversationPanel({ setAwaitingInput(null); if (context?.userMessage) { setOverlayMessages((prev) => { - const next = prev.filter((message) => message.messageId !== context.userMessage.messageId); + const next = prev.filter( + (message) => message.messageId !== context.userMessage.messageId, + ); writeSessionMessages(context.targetConversationId, next); return next; }); @@ -990,10 +990,7 @@ function AgentConversationPanel({ if (!open) setAlertMessage(null); }; - const handleDecideApproval = ( - action: 'approve' | 'reject', - payload?: Record, - ) => { + const handleDecideApproval = (action: 'approve' | 'reject', payload?: Record) => { if (decideApprovalMutation.isPending || isAssistantReplying || !activeApproval) { return; } diff --git a/src/components/layout/project/AgentSitePreviewPanel.tsx b/src/components/layout/project/AgentSitePreviewPanel.tsx index 6d81ab9..506b554 100644 --- a/src/components/layout/project/AgentSitePreviewPanel.tsx +++ b/src/components/layout/project/AgentSitePreviewPanel.tsx @@ -101,8 +101,8 @@ function AgentSitePreviewPanel({ {didReattach ? (

- 이미 떠 있는 컨테이너에 다시 연결했습니다 — 새로 빌드하지 않았습니다. 저장소를 - 막 연결했거나 그 뒤로 코드가 바뀌었다면 아직 옛 화면입니다. + 이미 떠 있는 컨테이너에 다시 연결했습니다 — 새로 빌드하지 않았습니다. 저장소를 막 + 연결했거나 그 뒤로 코드가 바뀌었다면 아직 옛 화면입니다.

+ )} @@ -211,8 +207,8 @@ function ProjectDatabaseSection({ projectId }: { projectId: number }) {

승인을 기다리고 있습니다

- 과금되는 자원이라 승인 절차를 거칩니다. 승인 탭에서 결정하면 생성이 시작되고, 5~10분 - 뒤 접속정보가 아래 목록에 나타납니다. + 과금되는 자원이라 승인 절차를 거칩니다. 승인 탭에서 결정하면 생성이 시작되고, 5~10분 뒤 + 접속정보가 아래 목록에 나타납니다.

-
- - - ))} + +

{row.message}

+ + + {formatActivityTime(row.occurredAt)} + + +
+ + {row.type} + + +
+ + + )) + )}
diff --git a/src/components/layout/project/ProjectDomainsPage.tsx b/src/components/layout/project/ProjectDomainsPage.tsx index 2ca741b..2921a61 100644 --- a/src/components/layout/project/ProjectDomainsPage.tsx +++ b/src/components/layout/project/ProjectDomainsPage.tsx @@ -11,7 +11,12 @@ import { FALLBACK_HOSTING_TARGETS, } from '@/api/domains'; import type { Domain, GetDomainVerificationGuideResType } from '@/types/domain.type'; -import type { DomainStatus, DomainType, HostingTarget, VerificationMethod } from '@/types/common.enum'; +import type { + DomainStatus, + DomainType, + HostingTarget, + VerificationMethod, +} from '@/types/common.enum'; import { useApprovalTaskWatchQuery } from '@/api/agent'; import { toSafeHttpUrl } from '@/lib/safeUrl'; @@ -100,7 +105,9 @@ function CopyField({ labelText, value }: { labelText: string; value: string }) { return (
-

{labelText}

+

+ {labelText} +

{value}

))} {guide.records.length === 0 ? ( -

레코드를 준비 중입니다. 잠시 후 다시 열어주세요.

+

+ 레코드를 준비 중입니다. 잠시 후 다시 열어주세요. +

) : null}
@@ -180,7 +189,10 @@ function ProjectDomainsPage({ projectId }: ProjectDomainsPageProps) { const [bindTaskId, setBindTaskId] = useState(null); const queryClient = useQueryClient(); - const { data: domains = [], isLoading } = useProjectDomainListQuery('project-domains-page', projectId); + const { data: domains = [], isLoading } = useProjectDomainListQuery( + 'project-domains-page', + projectId, + ); const { data: guide } = useQuery({ queryKey: ['domain-verification-guide', selectedDomainId], queryFn: () => getDomainVerificationGuide(selectedDomainId as number), @@ -233,7 +245,10 @@ function ProjectDomainsPage({ projectId }: ProjectDomainsPageProps) { const bindFailureMessage = bindTask?.status === 'FAILED' ? bindTask.error?.trim() || '도메인 연결에 실패했습니다.' : null; - const verifyMutation = useMutation({ mutationFn: postDomainVerificationCheck, onSuccess: invalidateDomains }); + const verifyMutation = useMutation({ + mutationFn: postDomainVerificationCheck, + onSuccess: invalidateDomains, + }); /* 해제를 눌렀지만 아직 승인을 기다리는 도메인. @@ -402,7 +417,9 @@ function ProjectDomainsPage({ projectId }: ProjectDomainsPageProps) { DNS 검증 방식