Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/demo-day/restore1.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/demo-day/restore2.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/demo-day/restore3.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/demo-day/restore4.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
70 changes: 70 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,76 @@
animation: toast-out 0.1s ease-in forwards;
}

/* DEMO-DAY 애니메이션 (제거 시 이 블록 전체 삭제) */
@keyframes demoday-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

@keyframes demoday-slide-up {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}

@keyframes demoday-reveal {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

@keyframes demoday-tint {
0% {
opacity: 0.75;
}
60% {
opacity: 0.3;
}
100% {
opacity: 0;
}
}

.animate-demoday-fade-in {
animation: demoday-fade-in 0.6s ease-out both;
}

.animate-demoday-slide-up {
animation: demoday-slide-up 0.5s ease-out both;
}

.animate-demoday-reveal {
animation: demoday-reveal 1.8s ease-out both;
will-change: opacity;
}

.animate-demoday-tint {
animation: demoday-tint 2.4s ease-out both;
will-change: opacity;
}

.film-strip {
background: linear-gradient(180deg, #1c1810 0%, #1a1712 50%, #1c1810 100%);
border-left: 1px solid rgba(80, 70, 50, 0.3);
border-right: 1px solid rgba(80, 70, 50, 0.3);
box-shadow:
inset 0 0 20px rgba(0, 0, 0, 0.5),
0 2px 12px rgba(0, 0, 0, 0.4);
}
/* DEMO-DAY 애니메이션 끝 */

.bg-splash-gradient {
background:
radial-gradient(
Expand Down
7 changes: 6 additions & 1 deletion src/pages/auth/KakaoCallbackPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect } from "react";
import { useNavigate, useLocation } from "react-router";
import { useKakaoOauth } from "@/hooks/auth/login";
import { consumeRedirectAfterLogin } from "@/pages/demoDay/redirectAfterLogin"; // DEMO-DAY

export function KakaoCallbackPage() {
const navigate = useNavigate();
Expand Down Expand Up @@ -32,7 +33,11 @@ export function KakaoCallbackPage() {
//2. 신규 회원: 온보딩 화면으로 리다이렉
//3. 실패시 login 페이지로 리다이렉
const { isPending } = useKakaoOauth({
onExistingMember: () => navigate("/mainpage", { replace: true }),
onExistingMember: () => {
// DEMO-DAY: 원래는 navigate("/mainpage", { replace: true })
const redirect = consumeRedirectAfterLogin();
navigate(redirect ?? "/mainpage", { replace: true });
},
onNewMember: () => navigate("/auth/onboarding", { replace: true }),
onFail: () => navigate("/auth/login", { replace: true }),
});
Expand Down
235 changes: 235 additions & 0 deletions src/pages/demoDay/DemoDayPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router";

import Header from "@/components/common/Header";
import { RestoraionSparkleIcon } from "@/assets/icon";
import { useAuthStore } from "@/store/useAuth.store";
import { useLoginModalStore } from "@/store/useLoginModal.store";

import { setRedirectAfterLogin } from "./redirectAfterLogin";

// ─── 데모 이미지 설정 (파일 추가 후 여기만 수정) ───
const DEMO_IMAGES = [
{ src: "/demo-day/restore1.jpg", label: "사진 1" },
{ src: "/demo-day/restore2.jpg", label: "사진 2" },
{ src: "/demo-day/restore3.jpg", label: "사진 3" },
{ src: "/demo-day/restore4.jpg", label: "사진 4" },
] as const;

const FRAME_CODES = ["15", "16", "17", "18"];
Comment on lines +12 to +19
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

DEMO_IMAGESFRAME_CODES가 별도의 배열로 관리되고 있어, 향후 이미지 추가/삭제 시 두 배열을 함께 수정해야 하는 번거로움이 있고 실수로 동기화가 맞지 않을 경우 버그가 발생할 수 있습니다.

데이터의 일관성을 유지하고 유지보수성을 높이기 위해, DEMO_IMAGES 배열에 frameCode를 포함시키는 것을 제안합니다. 이렇게 하면 관련 데이터를 하나의 객체로 묶어 관리할 수 있습니다.

const DEMO_IMAGES = [
  { src: "/demo-day/restore1.jpg", label: "사진 1", frameCode: "15" },
  { src: "/demo-day/restore2.jpg", label: "사진 2", frameCode: "16" },
  { src: "/demo-day/restore3.jpg", label: "사진 3", frameCode: "17" },
  { src: "/demo-day/restore4.jpg", label: "사진 4", frameCode: "18" },
] as const;

이렇게 변경하면 FRAME_CODES 상수는 더 이상 필요하지 않으며, FilmFrame 컴포넌트에 frameCode를 전달하는 부분(102행)과 FilmFrameProps 타입(135행)도 함께 수정해야 합니다.

Suggested change
const DEMO_IMAGES = [
{ src: "/demo-day/restore1.jpg", label: "사진 1" },
{ src: "/demo-day/restore2.jpg", label: "사진 2" },
{ src: "/demo-day/restore3.jpg", label: "사진 3" },
{ src: "/demo-day/restore4.jpg", label: "사진 4" },
] as const;
const FRAME_CODES = ["15", "16", "17", "18"];
const DEMO_IMAGES = [
{ src: "/demo-day/restore1.jpg", label: "사진 1", frameCode: "15" },
{ src: "/demo-day/restore2.jpg", label: "사진 2", frameCode: "16" },
{ src: "/demo-day/restore3.jpg", label: "사진 3", frameCode: "17" },
{ src: "/demo-day/restore4.jpg", label: "사진 4", frameCode: "18" },
] as const;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

한번쓰고 지울거라..


const PENDING_KEY = "finders:demoday-pending-image";

export default function DemoDayPage() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const openLoginModal = useLoginModalStore((s) => s.openLoginModal);

// ─── 로그인 후 자동 복원 진행 ───
useEffect(() => {
const pendingRaw = sessionStorage.getItem(PENDING_KEY);
if (!pendingRaw) return;
if (!user) return;

sessionStorage.removeItem(PENDING_KEY);
const index = Number(pendingRaw);
const image = DEMO_IMAGES[index];
if (!image) return;

navigate("/restore/editor", {
state: { imageUrl: image.src },
});
}, [user, navigate]);

const handleRestore = useCallback(
(index: number) => {
const image = DEMO_IMAGES[index];
if (!image) return;

const isAuthed = Boolean(user && user.memberId > 0);

if (isAuthed) {
navigate("/restore/editor", {
state: { imageUrl: image.src },
});
} else {
sessionStorage.setItem(PENDING_KEY, String(index));
setRedirectAfterLogin("/demo-day");
openLoginModal();
}
},
[user, navigate, openLoginModal],
);

return (
<div className="relative flex w-full flex-col bg-neutral-900">
{/* 히어로 그라데이션 */}
<div className="pointer-events-none absolute -inset-x-4 top-0 z-0 h-80 bg-[radial-gradient(ellipse_at_15%_55%,rgba(233,78,22,0.18)_0%,transparent_55%)] sm:-inset-x-6 lg:-inset-x-8" />

<Header title="파인더스 데모데이" showBack={false} />

<div className="scrollbar-hide relative z-10 flex-1 overflow-y-auto">
{/* 히어로 영역 */}
<section className="animate-demoday-fade-in relative px-2 pt-4 pb-5">
<div className="relative">
<h2 className="text-[1.5rem] leading-tight font-bold text-neutral-100">
<span className="animate-demoday-slide-up inline-block">
타버린 사진도
</span>
<br />
<span className="animate-demoday-slide-up inline-block [animation-delay:0.1s]">
AI로 다시
</span>{" "}
<span className="animate-demoday-slide-up inline-block text-orange-500 [animation-delay:0.15s]">
살려보세요
</span>
</h2>
<p className="animate-demoday-fade-in mt-2 text-[0.875rem] text-neutral-400 [animation-delay:0.3s]">
사진을 꾹 눌러 저장하고, 복원해 보세요
</p>
</div>
</section>

{/* 필름 스트립 */}
<div className="relative mx-1 mb-10">
{/* 필름 베이스 — 양쪽 스프로킷 레일 포함 */}
<div className="film-strip animate-demoday-fade-in rounded-sm">
{DEMO_IMAGES.map((image, index) => (
<FilmFrame
key={image.src}
image={image}
index={index}
frameCode={FRAME_CODES[index]}
onRestore={() => handleRestore(index)}
/>
))}
</div>
</div>
</div>
</div>
);
}

// ─── 양쪽 스프로킷 홀 ───
function SprocketRail({ count }: { count: number }) {
return (
<>
{Array.from({ length: count }, (_, i) => (
<div
key={i}
className="h-[0.625rem] w-[0.375rem] rounded-[1px] bg-neutral-900"
style={{
boxShadow: "inset 0 1px 2px rgba(0,0,0,0.6)",
}}
/>
))}
</>
);
}

// ─── 필름 프레임 ───

interface FilmFrameProps {
image: (typeof DEMO_IMAGES)[number];
index: number;
frameCode: string | undefined;
onRestore: () => void;
}

function FilmFrame({ image, index, frameCode, onRestore }: FilmFrameProps) {
const [imgError, setImgError] = useState(false);
const developDelay = 0.4 + index * 0.8;
const ctaDelay = developDelay + 1.6;

return (
<div>
{/* 프레임 한 줄: 좌 스프로킷 | 이미지 | 우 스프로킷 */}
<div className="flex">
{/* 좌측 스프로킷 레일 */}
<div className="flex w-5 shrink-0 flex-col items-center justify-evenly">
<SprocketRail count={6} />
</div>

{/* 이미지 + 프레임 정보 */}
<div className="min-w-0 flex-1 py-1.5">
{/* 사진 */}
<div className="relative overflow-hidden bg-[#15120a]">
{imgError ? (
<div className="bg-neutral-850 flex aspect-[3/2] items-center justify-center text-sm text-neutral-600">
이미지 준비 중
</div>
) : (
<>
<img
src={image.src}
alt={image.label}
onError={() => setImgError(true)}
className="aspect-[3/2] w-full object-cover"
style={{
WebkitTouchCallout: "default",
WebkitUserSelect: "auto",
userSelect: "auto",
}}
/>
{/* 현상 오버레이 — 세피아 톤 */}
<div
className="animate-demoday-tint pointer-events-none absolute inset-0"
style={{
animationDelay: `${developDelay}s`,
backgroundColor: "#3a1800",
}}
/>
{/* 현상 오버레이 — 암흑 */}
<div
className="animate-demoday-reveal pointer-events-none absolute inset-0 bg-black"
style={{ animationDelay: `${developDelay}s` }}
/>
</>
)}
{/* 힌트 */}
<div
className="animate-demoday-fade-in pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-end bg-gradient-to-t from-black/40 to-transparent p-2 opacity-0"
style={{ animationDelay: `${developDelay + 1.5}s` }}
>
<span className="text-[0.625rem] text-white/50">
꾹 눌러서 저장
</span>
</div>
</div>

{/* 프레임 정보 (필름 리베이트 영역) */}
<div className="flex items-center justify-between px-0.5 pt-1.5 pb-0.5">
<div className="flex items-center gap-1.5">
<span className="font-mono text-[0.625rem] font-bold text-orange-400/60">
{frameCode}
</span>
<span className="text-[0.5rem] text-orange-400/30">{"▶ "}</span>
<span className="font-mono text-[0.5rem] tracking-[0.15em] text-orange-400/30">
FINDERS 400
</span>
</div>
<span className="font-mono text-[0.5rem] tracking-wider text-orange-400/30">
{frameCode}A
</span>
</div>

{/* 복원하러 가기 CTA */}
<button
type="button"
onClick={onRestore}
className="animate-demoday-fade-in mt-1 flex w-full items-center justify-center gap-1.5 rounded-lg bg-orange-500 py-2.5 text-[0.8125rem] font-semibold text-white opacity-0 transition-all active:scale-[0.98] active:brightness-90"
style={{ animationDelay: `${ctaDelay}s` }}
>
<RestoraionSparkleIcon className="h-4 w-4" strokeWidth={0} />
복원하러 가기
</button>
</div>

{/* 우측 스프로킷 레일 */}
<div className="flex w-5 shrink-0 flex-col items-center justify-evenly">
<SprocketRail count={6} />
</div>
</div>
</div>
);
}
24 changes: 24 additions & 0 deletions src/pages/demoDay/redirectAfterLogin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const REDIRECT_KEY = "finders:redirectAfterLogin";

type RedirectData = { path: string; timestamp: number };

export function setRedirectAfterLogin(path: string): void {
sessionStorage.setItem(
REDIRECT_KEY,
JSON.stringify({ path, timestamp: Date.now() }),
);
}

export function consumeRedirectAfterLogin(): string | null {
const raw = sessionStorage.getItem(REDIRECT_KEY);
sessionStorage.removeItem(REDIRECT_KEY);
if (!raw) return null;
try {
const data: RedirectData = JSON.parse(raw);
const TTL = 30 * 60 * 1000; // 30분
if (Date.now() - data.timestamp > TTL) return null;
return data.path;
} catch {
return null;
}
}
2 changes: 2 additions & 0 deletions src/router/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import DevelopmentHistoryPage from "@/pages/developmentHistory/DevelopmentHistor
import FilmCameraGuideDetailPage from "@/pages/filmCameraGuide/FilmCameraGuideDetailPage";
import FilmCameraGuidePage from "@/pages/filmCameraGuide/FilmCameraGuidePage";
import PhotoRestorationPage from "@/pages/photoRestoration/PhotoRestorationPage";
import DemoDayPage from "@/pages/demoDay/DemoDayPage"; // DEMO-DAY

// 현상관리 페이지
import { DetailInfoPage } from "@/pages/photoManage/DetailInfoPage";
Expand Down Expand Up @@ -86,6 +87,7 @@ const guideRoutes = [
{ path: "film-camera-guide", Component: FilmCameraGuidePage },
{ path: "film-camera-guide/:id", Component: FilmCameraGuideDetailPage },
{ path: "restore/editor", Component: PhotoRestorationPage },
{ path: "demo-day", Component: DemoDayPage }, // DEMO-DAY
];

const photoFeedStandaloneRoutes = [
Expand Down