From ffaa512aee780732f8d0fd3137833c427b44a361 Mon Sep 17 00:00:00 2001 From: jaehwang Date: Tue, 15 Sep 2026 17:00:17 +0900 Subject: [PATCH] =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94=EB=93=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 2 +- frontend/.dockerignore | 14 ++++++++ frontend/app/(ado2)/page.tsx | 8 +++-- frontend/app/(ado2)/poster/page.tsx | 4 +++ frontend/app/(playreel)/playreel/page.tsx | 10 +++++- frontend/app/login/page.tsx | 37 +++++++++++++++++++++ frontend/components/auth-gate.tsx | 40 ++++++++++++++++++----- 7 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 frontend/.dockerignore create mode 100644 frontend/app/login/page.tsx diff --git a/docker-compose.yml b/docker-compose.yml index 1094ca3..478e1ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,8 @@ services: - backend/.env volumes: # 소스를 그대로 물린다 — 호스트에서 고치고 exec으로 바로 돌린다. 산출물도 호스트에 남는다. + # 가상환경은 이미지의 /opt/venv에 있어 이 마운트에 가려지지 않는다. - ./backend:/app - - /app/.venv # 컨테이너 안에서 만든 것을 덮지 않는다 # Higgsfield CLI 토큰을 호스트에서 참조한다. 만료 시 갱신해야 해서 읽기 전용이 아니다. - ${HOME}/.config/higgsfield:/root/.config/higgsfield restart: unless-stopped diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..45037b6 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,14 @@ +# COPY . . 가 호스트 것을 가져오면 npm ci 로 깐 것을 덮어쓰고 빌드도 느려진다 +node_modules +.next +out +build + +.git +.gitignore +.env +.env.* + +npm-debug.log* +*.tsbuildinfo +.DS_Store diff --git a/frontend/app/(ado2)/page.tsx b/frontend/app/(ado2)/page.tsx index f5bebae..c70fb68 100644 --- a/frontend/app/(ado2)/page.tsx +++ b/frontend/app/(ado2)/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import { apiFetch, type Job } from "@/lib/api"; +import { useMe } from "@/lib/auth"; import { GATE_META, type PlayreelJob } from "@/lib/playreel"; /* 무빙포스터 진입 — 두 갈래 (MOVING_POSTER_ENTRY_PLAN.md §1·§5) */ @@ -35,10 +36,13 @@ function tone(status: string) { } export default function HomePage() { + const { me } = useMe(); const [recent, setRecent] = useState([]); useEffect(() => { - // 두 갈래의 최근 작업을 합쳐 최신순 6개. 한쪽 API가 없어도(playreel 미구현) 다른 쪽은 보인다. + // 잡 목록은 로그인해야 볼 수 있다. 로그인 전에는 부르지 않는다 + if (!me) return; + // 두 갈래의 최근 작업을 합쳐 최신순 6개. 한쪽이 비어도 다른 쪽은 보인다. Promise.all([ apiFetch("/api/f1/jobs").catch(() => [] as Job[]), apiFetch("/api/playreel/jobs").catch(() => [] as PlayreelJob[]), @@ -50,7 +54,7 @@ export default function HomePage() { ]; setRecent(a.sort((x, y) => y.t - x.t).slice(0, 6)); }); - }, []); + }, [me]); return (
diff --git a/frontend/app/(ado2)/poster/page.tsx b/frontend/app/(ado2)/poster/page.tsx index 50b7a44..f15b45f 100644 --- a/frontend/app/(ado2)/poster/page.tsx +++ b/frontend/app/(ado2)/poster/page.tsx @@ -4,10 +4,12 @@ import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import PosterDropzone from "@/components/poster-dropzone"; +import { useRequireLogin } from "@/components/auth-gate"; import { apiFetch } from "@/lib/api"; export default function HomePage() { const router = useRouter(); + const requireLogin = useRequireLogin(); const [file, setFile] = useState(null); const [name, setName] = useState(""); const [busy, setBusy] = useState(false); @@ -15,6 +17,8 @@ export default function HomePage() { const submit = async () => { if (!file) return; + // 여기서 처음으로 로그인이 필요해진다 + if (!requireLogin()) return; setBusy(true); setError(null); try { diff --git a/frontend/app/(playreel)/playreel/page.tsx b/frontend/app/(playreel)/playreel/page.tsx index f1f0dc9..753af82 100644 --- a/frontend/app/(playreel)/playreel/page.tsx +++ b/frontend/app/(playreel)/playreel/page.tsx @@ -6,6 +6,8 @@ import { useRouter } from "next/navigation"; import { apiFetch } from "@/lib/api"; import { GATE_META, parseGoodsId, type PlayreelJob } from "@/lib/playreel"; import { AutoApprovePanel } from "@/components/auto-approve"; +import { useRequireLogin } from "@/components/auth-gate"; +import { useMe } from "@/lib/auth"; const STATUS_LABEL: Record = { queued: "대기 중", running: "만드는 중", awaiting_review: "검수 대기", failed: "실패", done: "완료", @@ -20,6 +22,8 @@ function statusTone(s: string) { export default function PlayreelStartPage() { const router = useRouter(); + const requireLogin = useRequireLogin(); + const { me } = useMe(); const [url, setUrl] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -27,13 +31,17 @@ export default function PlayreelStartPage() { // 독립 서비스가 되면서 ADO2 홈의 "최근 작업"을 잃는다. 여기서 대신 보여준다. const [recent, setRecent] = useState([]); useEffect(() => { + // 잡 목록은 로그인해야 볼 수 있다. 로그인 전에는 부르지 않는다 + if (!me) return; apiFetch("/api/playreel/jobs") .then((j) => setRecent(j.slice(0, 6))) .catch(() => setRecent([])); - }, []); + }, [me]); const submit = async () => { if (!goodsId) return; + // 여기서 처음으로 로그인이 필요해진다 + if (!requireLogin()) return; setBusy(true); setError(null); try { diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000..604490b --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import GoogleSignIn from "@/components/google-sign-in"; +import { signIn } from "@/lib/auth"; +import { useState } from "react"; + +function LoginForm() { + const router = useRouter(); + // 로그인 전에 가려던 곳. 없으면 홈으로 보낸다 + const next = useSearchParams().get("next") || "/"; + const [error, setError] = useState(null); + + return ( +
+

로그인

+

구글 계정으로 들어와 주세요.

+ { + setError(null); + signIn(credential) + .then(() => router.replace(next)) + .catch((failure) => setError((failure as Error).message)); + }} /> + {error &&

{error}

} +
+ ); +} + +export default function LoginPage() { + return ( + 불러오는 중…

}> + +
+ ); +} diff --git a/frontend/components/auth-gate.tsx b/frontend/components/auth-gate.tsx index f324c66..71100b4 100644 --- a/frontend/components/auth-gate.tsx +++ b/frontend/components/auth-gate.tsx @@ -1,23 +1,47 @@ "use client"; -/** 로그인하지 않았으면 화면 대신 로그인만 보여준다 */ +/** + * 로그인하지 않았으면 화면 대신 로그인만 보여준다. + * + * 들어오는 화면과 아카이브는 열어 둔다 — 로그인은 실제로 만들려고 누를 때 요구한다. + * 그 요구는 여기가 아니라 시작 화면의 제출 버튼이 한다(useRequireLogin). + */ -import { useState } from "react"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import GoogleSignIn from "@/components/google-sign-in"; import { signIn, useMe } from "@/lib/auth"; +import { useState } from "react"; -// 완성본 구경은 로그인 없이 연다. 백엔드의 /api/archive 와 같은 규칙 -const OPEN_PATHS = ["/archive", "/playreel/archive"]; +// 이 주소들은 그대로 보여준다. 하위 경로까지 여는 것은 아카이브뿐이다 +const OPEN_PAGES = ["/", "/poster", "/playreel", "/login"]; +const OPEN_TREES = ["/archive", "/playreel/archive"]; + +function isOpen(pathname: string): boolean { + return OPEN_PAGES.includes(pathname) + || OPEN_TREES.some((open) => pathname === open || pathname.startsWith(`${open}/`)); +} + +/** 시작 화면이 제출 직전에 부른다. 로그인 안 돼 있으면 로그인 화면으로 보낸다 */ +export function useRequireLogin() { + const router = useRouter(); + const pathname = usePathname(); + const { me } = useMe(); + + return () => { + if (me === false) { + router.push(`/login?next=${encodeURIComponent(pathname)}`); + return false; + } + return true; + }; +} export default function AuthGate({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const { me, setMe } = useMe(); const [error, setError] = useState(null); - if (OPEN_PATHS.some((open) => pathname === open || pathname.startsWith(`${open}/`))) { - return <>{children}; - } + if (isOpen(pathname)) return <>{children}; if (me === null) { return

확인하는 중…

;