From b283e8ce6bc2caceede83ee60a4602a9f8b32c46 Mon Sep 17 00:00:00 2001 From: Whale0928 Date: Mon, 20 Jul 2026 14:53:12 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20FE=20=EA=B2=80=EC=83=89=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20=EA=B8=B0=EB=B0=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 29 ++++++++ ui/app/docs/index.html | 67 +++++++++++++++++ ui/app/index.html | 84 +++++++++++++++++++++- ui/app/nginx.conf | 8 +++ ui/app/package.json | 3 +- ui/app/public/llms.txt | 19 +++++ ui/app/public/robots.txt | 14 ++++ ui/app/public/sitemap.xml | 9 +++ ui/app/scripts/verify-public-discovery.mjs | 65 +++++++++++++++++ ui/app/src/App.tsx | 84 ++++++++++++++++++---- ui/app/src/main.tsx | 2 + ui/app/src/styles.css | 27 +++++-- ui/app/vite.config.ts | 10 +++ 13 files changed, 399 insertions(+), 22 deletions(-) create mode 100644 ui/app/docs/index.html create mode 100644 ui/app/public/llms.txt create mode 100644 ui/app/public/robots.txt create mode 100644 ui/app/public/sitemap.xml create mode 100644 ui/app/scripts/verify-public-discovery.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3b4696..9ea7048 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,35 @@ concurrency: cancel-in-progress: true jobs: + ui: + name: ui + runs-on: ubuntu-24.04-arm + defaults: + run: + working-directory: ui/app + steps: + - name: checkout code + uses: actions/checkout@v6 + + - name: setup node + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + cache-dependency-path: ui/app/package-lock.json + + - name: install ui dependencies + run: npm ci + + - name: run ui type check + run: npm run typecheck + + - name: build ui + run: npm run build + + - name: verify public discovery artifacts + run: npm run verify:discovery + test: name: ci runs-on: ubuntu-24.04-arm diff --git a/ui/app/docs/index.html b/ui/app/docs/index.html new file mode 100644 index 0000000..df2943b --- /dev/null +++ b/ui/app/docs/index.html @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + 한국어 텍스트 처리 API 문서 | 말조심하세욧 + + +
+
+ +
+ +
+
+
+ + + diff --git a/ui/app/index.html b/ui/app/index.html index bf27980..9c21d10 100644 --- a/ui/app/index.html +++ b/ui/app/index.html @@ -4,11 +4,89 @@ - - 말조심하세욧 + + + + + + + + + + + + + + 한국어 욕설·비속어 필터 API | 말조심하세욧 -
+
+
+ +
+
+
+

Korean profanity filter API

+

한국어 욕설·비속어
필터 API

+

한국어 문장의 욕설과 비속어를 검출하고 필요한 방식으로 확인하거나 마스킹하는 API입니다.

+ + +
+
+
+
+
diff --git a/ui/app/nginx.conf b/ui/app/nginx.conf index 6d4bdc7..b3d28ea 100644 --- a/ui/app/nginx.conf +++ b/ui/app/nginx.conf @@ -5,6 +5,14 @@ server { root /usr/share/nginx/html; index index.html; + location = /docs { + try_files /docs/index.html =404; + } + + location = /docs/ { + return 308 /docs; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/ui/app/package.json b/ui/app/package.json index ee83038..f73020a 100644 --- a/ui/app/package.json +++ b/ui/app/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "verify:discovery": "node scripts/verify-public-discovery.mjs" }, "dependencies": { "@fontsource-variable/noto-sans-kr": "^5.2.10", diff --git a/ui/app/public/llms.txt b/ui/app/public/llms.txt new file mode 100644 index 0000000..0da741b --- /dev/null +++ b/ui/app/public/llms.txt @@ -0,0 +1,19 @@ +# 말조심하세욧 + +> 한국어 문장의 욕설과 비속어를 검출하고 필요한 방식으로 확인하거나 마스킹하는 REST API입니다. + +## Public pages + +- [서비스 소개](https://developers.kr-filter.com/): 현재 제공 기능과 API Key 연동 흐름 +- [API 문서](https://developers.kr-filter.com/docs): 인증, 처리 모드, 응답 정책과 API별 사용법 + +## Machine-readable API documents + +- [API 개요](https://api.kr-filter.com/overview.md): 인증, 처리 모드와 오류 모델 +- [OpenAPI 명세](https://api.kr-filter.com/openapi.json): 전체 공개 API 계약 + +## Current capabilities + +- API Key를 사용하는 REST API +- QUICK, NORMAL, FILTER 처리 모드 +- 동기 처리와 callback URL 기반 비동기 처리 diff --git a/ui/app/public/robots.txt b/ui/app/public/robots.txt new file mode 100644 index 0000000..0b8aafc --- /dev/null +++ b/ui/app/public/robots.txt @@ -0,0 +1,14 @@ +User-agent: * +Allow: / +Disallow: /app +Disallow: /login + +User-agent: OAI-SearchBot +Allow: / +Disallow: /app +Disallow: /login + +User-agent: GPTBot +Disallow: / + +Sitemap: https://developers.kr-filter.com/sitemap.xml diff --git a/ui/app/public/sitemap.xml b/ui/app/public/sitemap.xml new file mode 100644 index 0000000..f50559c --- /dev/null +++ b/ui/app/public/sitemap.xml @@ -0,0 +1,9 @@ + + + + https://developers.kr-filter.com/ + + + https://developers.kr-filter.com/docs + + diff --git a/ui/app/scripts/verify-public-discovery.mjs b/ui/app/scripts/verify-public-discovery.mjs new file mode 100644 index 0000000..cb7e27f --- /dev/null +++ b/ui/app/scripts/verify-public-discovery.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const appDirectory = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const distDirectory = resolve(appDirectory, "dist"); + +async function readDistFile(path) { + return readFile(resolve(distDirectory, path), "utf8"); +} + +function titleOf(html) { + return html.match(/([^<]+)<\/title>/)?.[1] ?? ""; +} + +function canonicalOf(html) { + return html.match(/<link[^>]+rel="canonical"[^>]+href="([^"]+)"/)?.[1] ?? ""; +} + +const [landing, docs, robots, sitemap, llms] = await Promise.all([ + readDistFile("index.html"), + readDistFile("docs/index.html"), + readDistFile("robots.txt"), + readDistFile("sitemap.xml"), + readDistFile("llms.txt"), +]); + +assert.equal(titleOf(landing), "한국어 욕설·비속어 필터 API | 말조심하세욧"); +assert.equal(titleOf(docs), "한국어 텍스트 처리 API 문서 | 말조심하세욧"); +assert.notEqual(titleOf(landing), titleOf(docs)); +assert.equal(canonicalOf(landing), "https://developers.kr-filter.com/"); +assert.equal(canonicalOf(docs), "https://developers.kr-filter.com/docs"); +assert.match(landing, /<h1>한국어 욕설·비속어<br\s*\/?\s*>필터 API<\/h1>/); +assert.match(docs, /<h1>한국어 텍스트 처리 API 문서<\/h1>/); +assert.match(docs, /href="https:\/\/api\.kr-filter\.com\/overview\.md"/); +assert.match(docs, /href="https:\/\/api\.kr-filter\.com\/openapi\.json"/); + +const jsonLdBlocks = [...landing.matchAll(/<script type="application\/ld\+json">\s*([\s\S]*?)\s*<\/script>/g)]; +assert.ok(jsonLdBlocks.length > 0, "landing JSON-LD is required"); +const jsonLdTypes = jsonLdBlocks + .flatMap((match) => { + const document = JSON.parse(match[1]); + return Array.isArray(document["@graph"]) ? document["@graph"] : [document]; + }) + .map((item) => item["@type"]); +assert.ok(jsonLdTypes.includes("WebSite")); +assert.ok(jsonLdTypes.includes("SoftwareApplication")); + +assert.doesNotMatch(robots, /<html/i); +assert.match(robots, /User-agent: OAI-SearchBot[\s\S]*?Allow: \//); +assert.match(robots, /User-agent: GPTBot\s+Disallow: \//); +assert.match(robots, /Sitemap: https:\/\/developers\.kr-filter\.com\/sitemap\.xml/); + +assert.match(sitemap, /^<\?xml version="1\.0" encoding="UTF-8"\?>/); +const sitemapLocations = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1]); +assert.deepEqual(sitemapLocations, ["https://developers.kr-filter.com/", "https://developers.kr-filter.com/docs"]); + +assert.doesNotMatch(llms, /<html/i); +assert.match(llms, /https:\/\/developers\.kr-filter\.com\/docs/); +assert.match(llms, /https:\/\/api\.kr-filter\.com\/overview\.md/); +assert.match(llms, /https:\/\/api\.kr-filter\.com\/openapi\.json/); +assert.doesNotMatch(`${landing}\n${docs}\n${llms}`, /입력을 저장하지|익명 학습|privacy-first|private/i); + +console.log("Public discovery artifacts verified: 5 files"); diff --git a/ui/app/src/App.tsx b/ui/app/src/App.tsx index 4869b7f..a285707 100644 --- a/ui/app/src/App.tsx +++ b/ui/app/src/App.tsx @@ -15,7 +15,7 @@ import { UserCircle, X, } from "@phosphor-icons/react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react"; import { exchangeLoginCode, restoreLoginSession, startSocialLogin, type LoginUser } from "./auth"; import ApiKeysPage from "./ApiKeysPage"; @@ -28,6 +28,19 @@ type AuthStatus = "checking" | "anonymous" | "exchanging" | "authenticated" | "f const ROUTES: RoutePath[] = ["/", "/docs", "/login", "/app", "/app/credentials", "/app/account", "/app/keys"]; +const PUBLIC_PAGE_METADATA = { + "/": { + title: "한국어 욕설·비속어 필터 API | 말조심하세욧", + description: "한국어 문장의 욕설과 비속어를 검출하고 QUICK, NORMAL, FILTER 모드로 확인하거나 마스킹하는 REST API입니다.", + canonical: "https://developers.kr-filter.com/", + }, + "/docs": { + title: "한국어 텍스트 처리 API 문서 | 말조심하세욧", + description: "한국어 욕설·비속어 검출 API의 인증, 요청 형식, 처리 모드, 오류 모델과 OpenAPI 명세를 확인하세요.", + canonical: "https://developers.kr-filter.com/docs", + }, +} as const; + function currentPath(): RoutePath { const pathname = window.location.pathname; return ROUTES.includes(pathname as RoutePath) ? (pathname as RoutePath) : "/"; @@ -39,6 +52,27 @@ function preferredTheme(): Theme { return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } +function updatePageMetadata(path: RoutePath) { + const isPublicPage = path === "/" || path === "/docs"; + const metadata = isPublicPage + ? PUBLIC_PAGE_METADATA[path] + : { + title: path === "/login" ? "로그인 | 말조심하세욧" : "개발자 포털 | 말조심하세욧", + description: "말조심하세욧 개발자 포털입니다.", + canonical: `https://developers.kr-filter.com${path}`, + }; + + document.title = metadata.title; + document.querySelector<HTMLMetaElement>('meta[name="description"]')?.setAttribute("content", metadata.description); + document.querySelector<HTMLMetaElement>('meta[name="robots"]')?.setAttribute("content", isPublicPage ? "index, follow" : "noindex, nofollow"); + document.querySelector<HTMLLinkElement>('link[rel="canonical"]')?.setAttribute("href", metadata.canonical); + document.querySelector<HTMLMetaElement>('meta[property="og:title"]')?.setAttribute("content", metadata.title); + document.querySelector<HTMLMetaElement>('meta[property="og:description"]')?.setAttribute("content", metadata.description); + document.querySelector<HTMLMetaElement>('meta[property="og:url"]')?.setAttribute("content", metadata.canonical); + document.querySelector<HTMLMetaElement>('meta[name="twitter:title"]')?.setAttribute("content", metadata.title); + document.querySelector<HTMLMetaElement>('meta[name="twitter:description"]')?.setAttribute("content", metadata.description); +} + export default function App() { const [path, setPath] = useState<RoutePath>(currentPath); const [theme, setTheme] = useState<Theme>(preferredTheme); @@ -55,6 +89,10 @@ export default function App() { window.localStorage.setItem("pf-theme", theme); }, [theme]); + useEffect(() => { + updatePageMetadata(path); + }, [path]); + useEffect(() => { const onPopState = () => setPath(currentPath()); window.addEventListener("popstate", onPopState); @@ -170,17 +208,17 @@ function GlobalHeader({ authenticated, loginUser, mobileOpen, onMenu, onNavigate }; return ( <header className="global-header"> - <button className="brand" onClick={() => onNavigate("/")} type="button"> + <InternalLink className="brand" onNavigate={onNavigate} to="/"> <strong>말조심하세욧</strong> <span>한국어 욕설 필터 API</span> - </button> + </InternalLink> <button aria-expanded={mobileOpen} aria-label="메뉴 열기" className="mobile-menu" onClick={onMenu} type="button"> {mobileOpen ? <X size={22} /> : <List size={22} />} </button> <div className={mobileOpen ? "global-actions open" : "global-actions"}> <nav aria-label="공개 메뉴"> - <NavButton active={path === "/"} label="소개" onClick={() => onNavigate("/")} /> - <NavButton active={path === "/docs"} label="API 문서" onClick={() => onNavigate("/docs")} /> + <NavLink active={path === "/"} label="소개" onNavigate={onNavigate} to="/" /> + <NavLink active={path === "/docs"} label="API 문서" onNavigate={onNavigate} to="/docs" /> </nav> <button aria-label={`${theme === "dark" ? "라이트" : "다크"} 모드로 전환`} className="theme-toggle" onClick={onTheme} type="button"> {theme === "dark" ? <Moon size={17} weight="fill" /> : <Sun size={18} weight="fill" />} @@ -202,15 +240,37 @@ function GlobalHeader({ authenticated, loginUser, mobileOpen, onMenu, onNavigate ) : null} </div> ) : ( - <button className="login-link" onClick={() => onNavigate("/login")} type="button">로그인</button> + <InternalLink className="login-link" onNavigate={onNavigate} to="/login">로그인</InternalLink> )} </div> </header> ); } -function NavButton({ active, label, onClick }: { active: boolean; label: string; onClick: () => void }) { - return <button aria-current={active ? "page" : undefined} onClick={onClick} type="button">{label}</button>; +function InternalLink({ + children, + className, + current, + onNavigate, + to, +}: { + children: ReactNode; + className?: string; + current?: boolean; + onNavigate: (path: RoutePath) => void; + to: RoutePath; +}) { + const navigateInternally = (event: MouseEvent<HTMLAnchorElement>) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + onNavigate(to); + }; + + return <a aria-current={current ? "page" : undefined} className={className} href={to} onClick={navigateInternally}>{children}</a>; +} + +function NavLink({ active, label, onNavigate, to }: { active: boolean; label: string; onNavigate: (path: RoutePath) => void; to: RoutePath }) { + return <InternalLink current={active} onNavigate={onNavigate} to={to}>{label}</InternalLink>; } function OverviewPage({ @@ -228,15 +288,15 @@ function OverviewPage({ <div className="overview-page"> <section className="intro-page page-width"> <p className="eyebrow">Korean profanity filter API</p> - <h1>사용자 입력을<br />더 안전하게 다룹니다.</h1> - <p className="lead">한국어 문장의 비속어를 검출하고 필요한 방식으로 마스킹하는 API입니다.</p> + <h1>한국어 욕설·비속어<br />필터 API</h1> + <p className="lead">한국어 문장의 욕설과 비속어를 검출하고 필요한 방식으로 확인하거나 마스킹하는 API입니다.</p> <div className="intro-actions"> {authenticated ? ( <button className="primary-action" onClick={showCredentials} type="button">자격 증명 선택 <ArrowRight size={18} /></button> ) : ( <button className="primary-action" onClick={() => onNavigate("/login")} type="button">로그인하여 시작 <ArrowRight size={18} /></button> )} - <button className="text-action" onClick={() => onNavigate("/docs")} type="button">API 문서 보기</button> + <InternalLink className="text-action" onNavigate={onNavigate} to="/docs">API 문서 보기</InternalLink> </div> <div className="auth-summary"> <p>{authenticated ? "API Key로 바로 연동할 수 있습니다" : "로그인 후 선택할 수 있습니다"}</p> @@ -261,7 +321,7 @@ function OverviewPage({ </div> <div className="next-actions"> <button onClick={authenticated ? showCredentials : () => onNavigate("/login")} type="button"><Key size={22} /><span><b>자격 증명 선택</b>API Key와 OAuth2 방식을 비교합니다.</span><ArrowRight size={19} /></button> - <button onClick={() => onNavigate("/docs")} type="button"><BookOpen size={22} /><span><b>API 문서 보기</b>인증과 요청 형식을 확인합니다.</span><ArrowRight size={19} /></button> + <InternalLink onNavigate={onNavigate} to="/docs"><BookOpen size={22} /><span><b>API 문서 보기</b>인증과 요청 형식을 확인합니다.</span><ArrowRight size={19} /></InternalLink> </div> </section> diff --git a/ui/app/src/main.tsx b/ui/app/src/main.tsx index 1294298..aca79f7 100644 --- a/ui/app/src/main.tsx +++ b/ui/app/src/main.tsx @@ -10,6 +10,8 @@ if (!rootElement) { throw new Error("root element not found"); } +rootElement.replaceChildren(); + createRoot(rootElement).render( <StrictMode> <App /> diff --git a/ui/app/src/styles.css b/ui/app/src/styles.css index b1597d4..821211e 100644 --- a/ui/app/src/styles.css +++ b/ui/app/src/styles.css @@ -50,13 +50,17 @@ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visib .app-shell { min-height: 100vh; background: var(--bg); } .global-header { min-height: 92px; display: flex; align-items: center; justify-content: space-between; gap: 28px; padding-inline: max(24px, calc((100vw - var(--content-rail)) / 2)); border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--bg) 95%, transparent); } -.brand { display: flex; align-items: baseline; gap: 18px; border: 0; background: transparent; padding: 0; cursor: pointer; text-align: left; } +.brand { display: flex; align-items: baseline; gap: 18px; border: 0; background: transparent; padding: 0; color: inherit; text-align: left; text-decoration: none; } .brand strong { font-size: 22px; font-weight: 820; letter-spacing: -0.04em; white-space: nowrap; } .brand span { color: var(--text-muted); font-size: 14px; white-space: nowrap; } .global-actions { display: flex; align-items: center; gap: 28px; } .global-actions nav { display: flex; align-items: center; gap: 8px; } -.global-actions nav button, .login-link { min-height: 42px; border: 0; background: transparent; padding: 0 12px; font-size: 14px; font-weight: 620; cursor: pointer; } -.global-actions nav button[aria-current="page"] { color: var(--accent); } +.global-actions nav a, .login-link { min-height: 42px; display: inline-flex; align-items: center; border: 0; background: transparent; padding: 0 12px; color: inherit; font-size: 14px; font-weight: 620; text-decoration: none; } +.global-actions nav a[aria-current="page"] { color: var(--accent); } +.static-public-actions { display: flex; align-items: center; gap: 20px; } +.static-public-actions nav { display: flex; gap: 8px; } +.static-public-actions nav a { min-height: 42px; display: inline-flex; align-items: center; padding: 0 12px; color: inherit; font-size: 14px; font-weight: 620; text-decoration: none; } +.static-public-actions nav a[aria-current="page"] { color: var(--accent); } .theme-toggle { min-width: 64px; min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--line-strong); border-radius: 999px; background: var(--surface); padding: 0 8px; cursor: pointer; } .theme-toggle i { position: relative; width: 32px; height: 20px; border-radius: 999px; background: var(--line-strong); } .theme-toggle i::after { content: ""; position: absolute; top: 3px; left: 3px; width: 14px; height: 14px; border-radius: 50%; background: var(--surface); transition: transform 180ms ease; } @@ -74,7 +78,7 @@ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visib .page-width { width: var(--content-rail); margin: 0 auto; } .eyebrow { margin: 0 0 20px; color: var(--accent); font-size: 13px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } .lead { max-width: 680px; margin: 18px 0 0; color: var(--text-muted); font-size: clamp(15px, 1.25vw, 18px); line-height: 1.7; } -.primary-action, .secondary-action, .text-action { min-height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 10px; border-radius: 7px; padding: 0 20px; font-weight: 700; cursor: pointer; } +.primary-action, .secondary-action, .text-action { min-height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 10px; border-radius: 7px; padding: 0 20px; font-weight: 700; text-decoration: none; cursor: pointer; } .primary-action { border: 1px solid var(--accent); background: var(--accent); color: #fff; } :root[data-theme="dark"] .primary-action { color: #0c1710; } .secondary-action { border: 1px solid var(--line-strong); background: var(--surface); } @@ -83,6 +87,8 @@ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visib .intro-page { padding-top: clamp(72px, 10vw, 140px); padding-bottom: clamp(88px, 10vw, 132px); } .intro-page h1 { max-width: 820px; margin: 0; font-size: clamp(44px, 5.4vw, 72px); line-height: 1.08; letter-spacing: -0.05em; } .intro-actions { display: flex; gap: 12px; margin-top: 38px; } +.static-api-links { display: flex; gap: 24px; margin-top: 34px; } +.static-api-links a { color: var(--text-muted); font-size: 13px; font-weight: 650; } .auth-summary { max-width: 920px; display: grid; grid-template-columns: 180px 1fr 1.3fr; gap: 28px; align-items: center; margin-top: 100px; border-top: 1px solid var(--line); padding-top: 28px; } .auth-summary > p { margin: 0; color: var(--text-muted); font-size: 13px; } .auth-summary > div { display: flex; gap: 14px; align-items: center; } @@ -103,7 +109,7 @@ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visib .overview-start h2 { margin: 0; font-size: clamp(32px, 3.2vw, 44px); letter-spacing: -0.04em; } .overview-start .lead { max-width: 540px; } .next-actions { display: grid; border-top: 1px solid var(--line); } -.next-actions button { min-height: 96px; display: grid; grid-template-columns: 32px 1fr 24px; gap: 18px; align-items: center; border: 0; border-bottom: 1px solid var(--line); background: transparent; padding: 0 8px; text-align: left; cursor: pointer; } +.next-actions button, .next-actions a { min-height: 96px; display: grid; grid-template-columns: 32px 1fr 24px; gap: 18px; align-items: center; border: 0; border-bottom: 1px solid var(--line); background: transparent; padding: 0 8px; color: inherit; text-align: left; text-decoration: none; cursor: pointer; } .next-actions span, .next-actions b { display: block; } .next-actions span { color: var(--text-muted); line-height: 1.6; } .next-actions b { margin-bottom: 4px; color: var(--text); font-size: 15px; } @@ -180,6 +186,13 @@ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visib @keyframes docs-spin { to { transform: rotate(360deg); } } .docs-error { border: 1px solid var(--line-strong); border-radius: 8px; background: var(--surface-subtle); padding: 24px; font-weight: 700; } .docs-error small { display: block; margin-top: 8px; color: var(--text-muted); font-weight: 400; } +.static-docs-page { min-height: calc(100vh - 92px); padding-top: clamp(72px, 9vw, 120px); padding-bottom: 120px; } +.static-docs-page h1 { max-width: 840px; margin: 0; font-size: clamp(40px, 4.8vw, 64px); line-height: 1.12; letter-spacing: -0.05em; } +.static-docs-links { max-width: 820px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-top: 48px; } +.static-docs-links a { display: grid; gap: 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); padding: 24px; color: inherit; text-decoration: none; } +.static-docs-links a:hover { border-color: var(--accent); } +.static-docs-links strong { color: var(--accent); } +.static-docs-links span { color: var(--text-muted); font-size: 14px; line-height: 1.6; } .markdown-document h1 { margin: 0 0 26px; font-size: clamp(34px, 3.5vw, 44px); letter-spacing: -0.04em; } .markdown-document h2 { margin: 46px 0 16px; font-size: 23px; } .markdown-document h3 { margin: 30px 0 10px; font-size: 18px; } @@ -307,7 +320,8 @@ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visib .global-actions { display: none; position: absolute; top: 76px; right: 0; left: 0; z-index: 20; align-items: stretch; border-bottom: 1px solid var(--line); background: var(--surface); padding: 20px 24px; } .global-actions.open { display: grid; } .global-actions nav { display: grid; } - .global-actions nav button, .login-link { text-align: left; } + .global-actions nav a, .login-link { justify-content: flex-start; text-align: left; } + .static-public-actions nav { display: none; } .identity-menu { width: 100%; } .identity-popover { position: static; margin-top: 8px; box-shadow: none; } .theme-toggle { width: max-content; } @@ -315,6 +329,7 @@ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visib .login-page { grid-template-columns: 1fr; gap: 48px; } .auth-summary { grid-template-columns: 1fr; gap: 20px; } .overview-start { grid-template-columns: 1fr; gap: 48px; } + .static-docs-links { grid-template-columns: 1fr; } .key-row { grid-template-columns: 1fr auto; gap: 24px; } .key-row dl { grid-column: 1 / -1; grid-row: 2; } .docs-page { width: 100%; grid-template-columns: 1fr; border-right: 0; border-left: 0; } diff --git a/ui/app/vite.config.ts b/ui/app/vite.config.ts index c54333b..890b73a 100644 --- a/ui/app/vite.config.ts +++ b/ui/app/vite.config.ts @@ -1,8 +1,18 @@ +import { resolve } from "node:path"; + import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], + build: { + rollupOptions: { + input: { + landing: resolve(__dirname, "index.html"), + docs: resolve(__dirname, "docs/index.html"), + }, + }, + }, server: { host: "0.0.0.0", allowedHosts: ["terminal.local"], From 0279dac0d49e0a7b616af229b27e739ad0f8bcd9 Mon Sep 17 00:00:00 2001 From: Whale0928 <rlagusrl928@gmail.com> Date: Mon, 20 Jul 2026 14:56:32 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20GitHub=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EB=A7=81=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/app/src/App.tsx | 9 +++++++++ ui/app/src/styles.css | 2 ++ 2 files changed, 11 insertions(+) diff --git a/ui/app/src/App.tsx b/ui/app/src/App.tsx index a285707..4b35917 100644 --- a/ui/app/src/App.tsx +++ b/ui/app/src/App.tsx @@ -220,6 +220,15 @@ function GlobalHeader({ authenticated, loginUser, mobileOpen, onMenu, onNavigate <NavLink active={path === "/"} label="소개" onNavigate={onNavigate} to="/" /> <NavLink active={path === "/docs"} label="API 문서" onNavigate={onNavigate} to="/docs" /> </nav> + <a + aria-label="GitHub 저장소 열기" + className="github-link" + href="https://github.com/Whale0928/profanity-filter-api" + rel="noopener noreferrer" + target="_blank" + > + <GithubLogo aria-hidden="true" size={20} weight="fill" /> + </a> <button aria-label={`${theme === "dark" ? "라이트" : "다크"} 모드로 전환`} className="theme-toggle" onClick={onTheme} type="button"> {theme === "dark" ? <Moon size={17} weight="fill" /> : <Sun size={18} weight="fill" />} <i aria-hidden="true" /> diff --git a/ui/app/src/styles.css b/ui/app/src/styles.css index 821211e..722edb0 100644 --- a/ui/app/src/styles.css +++ b/ui/app/src/styles.css @@ -61,6 +61,8 @@ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visib .static-public-actions nav { display: flex; gap: 8px; } .static-public-actions nav a { min-height: 42px; display: inline-flex; align-items: center; padding: 0 12px; color: inherit; font-size: 14px; font-weight: 620; text-decoration: none; } .static-public-actions nav a[aria-current="page"] { color: var(--accent); } +.github-link { width: 42px; height: 42px; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; border: 1px solid var(--line-strong); border-radius: 50%; background: var(--surface); color: var(--text); text-decoration: none; } +.github-link:hover { border-color: var(--accent); color: var(--accent); } .theme-toggle { min-width: 64px; min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--line-strong); border-radius: 999px; background: var(--surface); padding: 0 8px; cursor: pointer; } .theme-toggle i { position: relative; width: 32px; height: 20px; border-radius: 999px; background: var(--line-strong); } .theme-toggle i::after { content: ""; position: absolute; top: 3px; left: 3px; width: 14px; height: 14px; border-radius: 50%; background: var(--surface); transition: transform 180ms ease; } From 626359c6ee889793a17758b2ff3ee7cd5c68203b Mon Sep 17 00:00:00 2001 From: Whale0928 <rlagusrl928@gmail.com> Date: Mon, 20 Jul 2026 15:08:05 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20CI=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=AA=85=ED=99=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ea7048..e351b58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,8 +45,8 @@ jobs: - name: verify public discovery artifacts run: npm run verify:discovery - test: - name: ci + backend: + name: backend runs-on: ubuntu-24.04-arm steps: - name: checkout code