프론트엔드 변경

This commit is contained in:
jaehwang 2026-09-15 17:00:17 +09:00
parent c6c84a10ff
commit ffaa512aee
7 changed files with 103 additions and 12 deletions

View File

@ -9,8 +9,8 @@ services:
- backend/.env - backend/.env
volumes: volumes:
# 소스를 그대로 물린다 — 호스트에서 고치고 exec으로 바로 돌린다. 산출물도 호스트에 남는다. # 소스를 그대로 물린다 — 호스트에서 고치고 exec으로 바로 돌린다. 산출물도 호스트에 남는다.
# 가상환경은 이미지의 /opt/venv에 있어 이 마운트에 가려지지 않는다.
- ./backend:/app - ./backend:/app
- /app/.venv # 컨테이너 안에서 만든 것을 덮지 않는다
# Higgsfield CLI 토큰을 호스트에서 참조한다. 만료 시 갱신해야 해서 읽기 전용이 아니다. # Higgsfield CLI 토큰을 호스트에서 참조한다. 만료 시 갱신해야 해서 읽기 전용이 아니다.
- ${HOME}/.config/higgsfield:/root/.config/higgsfield - ${HOME}/.config/higgsfield:/root/.config/higgsfield
restart: unless-stopped restart: unless-stopped

14
frontend/.dockerignore Normal file
View File

@ -0,0 +1,14 @@
# COPY . . 가 호스트 것을 가져오면 npm ci 로 깐 것을 덮어쓰고 빌드도 느려진다
node_modules
.next
out
build
.git
.gitignore
.env
.env.*
npm-debug.log*
*.tsbuildinfo
.DS_Store

View File

@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { apiFetch, type Job } from "@/lib/api"; import { apiFetch, type Job } from "@/lib/api";
import { useMe } from "@/lib/auth";
import { GATE_META, type PlayreelJob } from "@/lib/playreel"; import { GATE_META, type PlayreelJob } from "@/lib/playreel";
/* 무빙포스터 진입 — 두 갈래 (MOVING_POSTER_ENTRY_PLAN.md §1·§5) */ /* 무빙포스터 진입 — 두 갈래 (MOVING_POSTER_ENTRY_PLAN.md §1·§5) */
@ -35,10 +36,13 @@ function tone(status: string) {
} }
export default function HomePage() { export default function HomePage() {
const { me } = useMe();
const [recent, setRecent] = useState<Recent[]>([]); const [recent, setRecent] = useState<Recent[]>([]);
useEffect(() => { useEffect(() => {
// 두 갈래의 최근 작업을 합쳐 최신순 6개. 한쪽 API가 없어도(playreel 미구현) 다른 쪽은 보인다. // 잡 목록은 로그인해야 볼 수 있다. 로그인 전에는 부르지 않는다
if (!me) return;
// 두 갈래의 최근 작업을 합쳐 최신순 6개. 한쪽이 비어도 다른 쪽은 보인다.
Promise.all([ Promise.all([
apiFetch<Job[]>("/api/f1/jobs").catch(() => [] as Job[]), apiFetch<Job[]>("/api/f1/jobs").catch(() => [] as Job[]),
apiFetch<PlayreelJob[]>("/api/playreel/jobs").catch(() => [] as PlayreelJob[]), apiFetch<PlayreelJob[]>("/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)); setRecent(a.sort((x, y) => y.t - x.t).slice(0, 6));
}); });
}, []); }, [me]);
return ( return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: "1.5rem" }}> <div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: "1.5rem" }}>

View File

@ -4,10 +4,12 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import PosterDropzone from "@/components/poster-dropzone"; import PosterDropzone from "@/components/poster-dropzone";
import { useRequireLogin } from "@/components/auth-gate";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
export default function HomePage() { export default function HomePage() {
const router = useRouter(); const router = useRouter();
const requireLogin = useRequireLogin();
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -15,6 +17,8 @@ export default function HomePage() {
const submit = async () => { const submit = async () => {
if (!file) return; if (!file) return;
// 여기서 처음으로 로그인이 필요해진다
if (!requireLogin()) return;
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {

View File

@ -6,6 +6,8 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { GATE_META, parseGoodsId, type PlayreelJob } from "@/lib/playreel"; import { GATE_META, parseGoodsId, type PlayreelJob } from "@/lib/playreel";
import { AutoApprovePanel } from "@/components/auto-approve"; import { AutoApprovePanel } from "@/components/auto-approve";
import { useRequireLogin } from "@/components/auth-gate";
import { useMe } from "@/lib/auth";
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
queued: "대기 중", running: "만드는 중", awaiting_review: "검수 대기", failed: "실패", done: "완료", queued: "대기 중", running: "만드는 중", awaiting_review: "검수 대기", failed: "실패", done: "완료",
@ -20,6 +22,8 @@ function statusTone(s: string) {
export default function PlayreelStartPage() { export default function PlayreelStartPage() {
const router = useRouter(); const router = useRouter();
const requireLogin = useRequireLogin();
const { me } = useMe();
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -27,13 +31,17 @@ export default function PlayreelStartPage() {
// 독립 서비스가 되면서 ADO2 홈의 "최근 작업"을 잃는다. 여기서 대신 보여준다. // 독립 서비스가 되면서 ADO2 홈의 "최근 작업"을 잃는다. 여기서 대신 보여준다.
const [recent, setRecent] = useState<PlayreelJob[]>([]); const [recent, setRecent] = useState<PlayreelJob[]>([]);
useEffect(() => { useEffect(() => {
// 잡 목록은 로그인해야 볼 수 있다. 로그인 전에는 부르지 않는다
if (!me) return;
apiFetch<PlayreelJob[]>("/api/playreel/jobs") apiFetch<PlayreelJob[]>("/api/playreel/jobs")
.then((j) => setRecent(j.slice(0, 6))) .then((j) => setRecent(j.slice(0, 6)))
.catch(() => setRecent([])); .catch(() => setRecent([]));
}, []); }, [me]);
const submit = async () => { const submit = async () => {
if (!goodsId) return; if (!goodsId) return;
// 여기서 처음으로 로그인이 필요해진다
if (!requireLogin()) return;
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {

View File

@ -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<string | null>(null);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center",
gap: "1.5rem", paddingTop: "8rem" }}>
<h1 className="page-title">로그인</h1>
<p className="page-subtitle">구글 계정으로 들어와 주세요.</p>
<GoogleSignIn onCredential={(credential) => {
setError(null);
signIn(credential)
.then(() => router.replace(next))
.catch((failure) => setError((failure as Error).message));
}} />
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)" }}>{error}</p>}
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<p style={{ color: "var(--color-text-gray-400)" }}>불러오는 중…</p>}>
<LoginForm />
</Suspense>
);
}

View File

@ -1,23 +1,47 @@
"use client"; "use client";
/** 로그인하지 않았으면 화면 대신 로그인만 보여준다 */ /**
* 로그인하지 않았으면 화면 대신 로그인만 보여준다.
*
* 들어오는 화면과 아카이브는 열어 둔다 — 로그인은 실제로 만들려고 누를 때 요구한다.
* 그 요구는 여기가 아니라 시작 화면의 제출 버튼이 한다(useRequireLogin).
*/
import { useState } from "react"; import { usePathname, useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import GoogleSignIn from "@/components/google-sign-in"; import GoogleSignIn from "@/components/google-sign-in";
import { signIn, useMe } from "@/lib/auth"; 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 }) { export default function AuthGate({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const { me, setMe } = useMe(); const { me, setMe } = useMe();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
if (OPEN_PATHS.some((open) => pathname === open || pathname.startsWith(`${open}/`))) { if (isOpen(pathname)) return <>{children}</>;
return <>{children}</>;
}
if (me === null) { if (me === null) {
return <p style={{ color: "var(--color-text-gray-400)" }}>확인하는 중…</p>; return <p style={{ color: "var(--color-text-gray-400)" }}>확인하는 중…</p>;