From a373284beaa55ea26e6f3bd17f7da76a01896619 Mon Sep 17 00:00:00 2001 From: seoJing Date: Mon, 8 Jun 2026 13:18:41 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feature:=20=EC=A4=91=EA=B0=84=20=EC=A0=90?= =?UTF-8?q?=EA=B2=80=EC=9A=A9=20=ED=95=99=EC=8A=B5=20UX=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=AC=B6=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation-scale-stabilization.mdx | 6 + .../viewport-units-comparison-01.svg | 59 +++ apps/web/scripts/insert-content-image.mjs | 9 +- apps/web/src/app/blog/[...slug]/page.tsx | 2 + .../useArticleAnalytics.test.tsx | 99 ++++ .../article-analytics/useArticleAnalytics.ts | 105 ++++- .../BlogAudioPlayer.test.tsx | 240 ++++++++++ .../blog-audio-player/BlogAudioPlayer.tsx | 439 ++++++++++++++++++ .../widgets/presentation/PresentationView.tsx | 156 ++++++- .../presentation/presentation.utils.test.ts | 39 ++ .../presentation/presentation.utils.ts | 13 +- docs/seojing-adsense-readiness-review.md | 194 ++++++++ docs/seojing-content-asset-policy.md | 13 + 13 files changed, 1358 insertions(+), 16 deletions(-) create mode 100644 apps/web/public/images/content/seojing/presentation-scale-stabilization/viewport-units-comparison-01.svg create mode 100644 apps/web/src/widgets/article-analytics/useArticleAnalytics.test.tsx create mode 100644 apps/web/src/widgets/blog-audio-player/BlogAudioPlayer.test.tsx create mode 100644 apps/web/src/widgets/blog-audio-player/BlogAudioPlayer.tsx create mode 100644 docs/seojing-adsense-readiness-review.md diff --git a/apps/web/content/SEOJing/presentation-scale-stabilization.mdx b/apps/web/content/SEOJing/presentation-scale-stabilization.mdx index 5716ce0..80d5323 100644 --- a/apps/web/content/SEOJing/presentation-scale-stabilization.mdx +++ b/apps/web/content/SEOJing/presentation-scale-stabilization.mdx @@ -65,6 +65,12 @@ description: 모바일 Safari에서 100vh가 화면을 넘치는 이유, vh/svh/ 브라우저 UI가 나타나고 사라질 때마다 값이 바뀐다. + + 어떤 단위를 써야 하는가 diff --git a/apps/web/public/images/content/seojing/presentation-scale-stabilization/viewport-units-comparison-01.svg b/apps/web/public/images/content/seojing/presentation-scale-stabilization/viewport-units-comparison-01.svg new file mode 100644 index 0000000..dd3af90 --- /dev/null +++ b/apps/web/public/images/content/seojing/presentation-scale-stabilization/viewport-units-comparison-01.svg @@ -0,0 +1,59 @@ + + Mobile viewport unit comparison + Comparison of vh, svh, lvh, and dvh against visible mobile browser chrome. + + + + + + + + + + + 모바일 뷰포트 단위 비교 + 주소창/하단 바가 보이는 순간에는 100vh와 실제 보이는 높이가 달라진다. + + + + + + + + dvh + 현재 보이는 영역 + 브라우저 UI가 보이는 상태 + + + + + + + 100vh + 주소창을 넘어설 수 있음 + + + + 100svh + 가장 안전한 최소 높이 + + + + 100dvh + 현재 상태에 맞춰 변함 + + + + + 선택 기준 + + • 전체화면 모달/슬라이드: dvh + • 히어로/랜딩 첫 화면: svh + • 배경 채움: lvh + • legacy vh는 모바일 UI를 + 고려하지 못할 수 있음 + + + 프레젠테이션 모드 → 100dvh + + diff --git a/apps/web/scripts/insert-content-image.mjs b/apps/web/scripts/insert-content-image.mjs index 3696ebc..3405ef2 100644 --- a/apps/web/scripts/insert-content-image.mjs +++ b/apps/web/scripts/insert-content-image.mjs @@ -10,7 +10,14 @@ const contentRoot = path.join(webRoot, "content"); const publicRoot = path.join(webRoot, "public"); const contentImageRoot = path.join(publicRoot, "images/content"); -const allowedExtensions = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"]); +const allowedExtensions = new Set([ + ".png", + ".jpg", + ".jpeg", + ".webp", + ".svg", + ".gif", +]); const sectionRules = [ { prefix: "okayJing/", section: "okayjing" }, diff --git a/apps/web/src/app/blog/[...slug]/page.tsx b/apps/web/src/app/blog/[...slug]/page.tsx index 2af8158..d406cfe 100644 --- a/apps/web/src/app/blog/[...slug]/page.tsx +++ b/apps/web/src/app/blog/[...slug]/page.tsx @@ -10,6 +10,7 @@ import { PostExplorer } from "@/widgets/post-explorer/PostExplorer"; import { ArticleToolbar } from "@/widgets/article-toolbar/ArticleToolbar"; import { ArticleAnalytics } from "@/widgets/article-analytics"; import { PostQaPanel } from "@/widgets/post-qa"; +import { BlogAudioPlayer } from "@/widgets/blog-audio-player/BlogAudioPlayer"; import type { Metadata } from "vinext/shims/metadata"; import { buildArticleMetadata, @@ -95,6 +96,7 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) { /> + +

요청 흐름

+

본문

+ + ); +} + +describe("useArticleAnalytics TTS bridge", () => { + beforeEach(() => { + window.sessionStorage.clear(); + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 204 })), + ); + Object.defineProperty(window.navigator, "sendBeacon", { + configurable: true, + value: undefined, + }); + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn(() => ({ matches: false })), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + window.sessionStorage.clear(); + }); + + it("forwards privacy-safe TTS player events into the article analytics endpoint", async () => { + render(); + + await waitFor(() => expect(fetch).toHaveBeenCalled()); + vi.mocked(fetch).mockClear(); + + window.dispatchEvent( + new CustomEvent("seojing:tts-interaction", { + detail: { + action: "pause", + artifact_id: "study__backend__day1__section-001", + artifact_kind: "section", + section_id: "요청-흐름", + section_heading: "요청 흐름", + playback_rate: 1.8, + audio_status: "ready", + available_artifact_kinds: ["summary-2m", "core-5m", "section"], + section_artifact_count: 1, + duration_seconds_bucket: "60-179", + position_seconds_bucket: "30-59", + progress_percent_bucket: "25-49", + }, + }), + ); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + const call = vi.mocked(fetch).mock.calls[0]; + expect(call).toBeDefined(); + const init = call?.[1]; + const body = JSON.parse(String(init?.body)); + + expect(body.events[0]).toMatchObject({ + event_type: "tts_interaction", + content: { + content_slug: "study/backend/day1", + content_kind: "study_post", + section_id: "요청-흐름", + section_heading: "요청 흐름", + }, + event: { + action: "pause", + artifact_id: "study__backend__day1__section-001", + artifact_kind: "section", + playback_rate: 1.8, + audio_status: "ready", + available_artifact_kinds: ["summary-2m", "core-5m", "section"], + section_artifact_count: 1, + duration_seconds_bucket: "60-179", + position_seconds_bucket: "30-59", + progress_percent_bucket: "25-49", + }, + }); + expect(JSON.stringify(body)).not.toContain("raw"); + }); +}); diff --git a/apps/web/src/widgets/article-analytics/useArticleAnalytics.ts b/apps/web/src/widgets/article-analytics/useArticleAnalytics.ts index 83e9989..f242135 100644 --- a/apps/web/src/widgets/article-analytics/useArticleAnalytics.ts +++ b/apps/web/src/widgets/article-analytics/useArticleAnalytics.ts @@ -42,6 +42,27 @@ declare global { action: "answer_shown" | "insufficient_context" | "invalid_request"; question_length_bucket: "1-40" | "41-120" | "121+"; }>; + "seojing:tts-interaction": CustomEvent<{ + action: + | "manifest_loaded" + | "artifact_select" + | "play" + | "pause" + | "ended" + | "speed_change"; + slug?: string; + artifact_id?: string; + artifact_kind?: "summary-2m" | "core-5m" | "section"; + section_id?: string; + section_heading?: string; + playback_rate?: number; + audio_status?: string; + available_artifact_kinds?: string[]; + section_artifact_count?: number; + duration_seconds_bucket?: string; + position_seconds_bucket?: string; + progress_percent_bucket?: string; + }>; } } @@ -141,6 +162,33 @@ function codeBlockId(block: Element | null, contentSlug: string) { return `code_${contentSlug}_${index + 1}`.replace(/[^a-zA-Z0-9_-]/g, "_"); } +function safeShortString(value: unknown) { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed.slice(0, 120) : undefined; +} + +function safePlaybackRate(value: unknown) { + return typeof value === "number" && Number.isFinite(value) + ? Math.round(value * 100) / 100 + : undefined; +} + +function safeCount(value: unknown) { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : undefined; +} + +function safeStringArray(value: unknown) { + if (!Array.isArray(value)) return undefined; + const safeValues = value + .filter((item): item is string => typeof item === "string") + .map((item) => item.slice(0, 40)) + .slice(0, 12); + return safeValues.length > 0 ? safeValues : undefined; +} + export function useArticleAnalytics({ slug, title, @@ -170,7 +218,9 @@ export function useArticleAnalytics({ useEffect(() => { const storage = safeSessionStorage(); - const dnt = navigator.doNotTrack ?? window.doNotTrack; + const dnt = + (navigator as Navigator & { doNotTrack?: string }).doNotTrack ?? + (window as Window & { doNotTrack?: string }).doNotTrack; if (!storage || isAnalyticsDisabled(storage, dnt)) { if (storage) clearAnalyticsSession(storage); return; @@ -323,6 +373,54 @@ export function useArticleAnalytics({ }); }; + const handleTtsInteraction = (event: Event) => { + if (!(event instanceof CustomEvent)) return; + const detail = event.detail ?? {}; + const validAction = [ + "manifest_loaded", + "artifact_select", + "play", + "pause", + "ended", + "speed_change", + ].includes(detail.action); + const validKind = + detail.artifact_kind === undefined || + ["summary-2m", "core-5m", "section"].includes(detail.artifact_kind); + if (!validAction || !validKind) return; + + emit( + "tts_interaction", + { + action: detail.action, + artifact_id: safeShortString(detail.artifact_id), + artifact_kind: detail.artifact_kind, + playback_rate: safePlaybackRate(detail.playback_rate), + audio_status: safeShortString(detail.audio_status), + available_artifact_kinds: safeStringArray( + detail.available_artifact_kinds, + ), + section_artifact_count: safeCount(detail.section_artifact_count), + duration_seconds_bucket: safeShortString( + detail.duration_seconds_bucket, + ), + position_seconds_bucket: safeShortString( + detail.position_seconds_bucket, + ), + progress_percent_bucket: safeShortString( + detail.progress_percent_bucket, + ), + }, + typeof detail.section_id === "string" + ? { + id: detail.section_id, + heading: + safeShortString(detail.section_heading) ?? detail.section_id, + } + : undefined, + ); + }; + const handleClick = (event: MouseEvent) => { const target = event.target instanceof Element ? event.target : null; const link = target?.closest( @@ -344,6 +442,7 @@ export function useArticleAnalytics({ window.addEventListener("scroll", handleScroll, { passive: true }); window.addEventListener("seojing:code-copy", handleCodeCopy); window.addEventListener("seojing:qa-interaction", handleQaInteraction); + window.addEventListener("seojing:tts-interaction", handleTtsInteraction); article.addEventListener("click", handleClick); window.addEventListener("pagehide", handlePageHide, { once: true }); handleScroll(); @@ -352,6 +451,10 @@ export function useArticleAnalytics({ window.removeEventListener("scroll", handleScroll); window.removeEventListener("seojing:code-copy", handleCodeCopy); window.removeEventListener("seojing:qa-interaction", handleQaInteraction); + window.removeEventListener( + "seojing:tts-interaction", + handleTtsInteraction, + ); article.removeEventListener("click", handleClick); window.removeEventListener("pagehide", handlePageHide); window.clearInterval(heartbeat); diff --git a/apps/web/src/widgets/blog-audio-player/BlogAudioPlayer.test.tsx b/apps/web/src/widgets/blog-audio-player/BlogAudioPlayer.test.tsx new file mode 100644 index 0000000..51688f1 --- /dev/null +++ b/apps/web/src/widgets/blog-audio-player/BlogAudioPlayer.test.tsx @@ -0,0 +1,240 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BlogAudioPlayer } from "./BlogAudioPlayer"; + +const manifest = { + version: 1, + slug: "study/backend/day1", + canonicalUrl: "https://seojing.com/blog/study/backend/day1", + title: "백엔드 스터디 Day 1", + generatedAt: "2026-06-08T00:00:00.000Z", + cacheKey: "tts:v1:study/backend/day1", + artifacts: [ + { + id: "study__backend__day1__summary-2m", + kind: "summary-2m", + slug: "study/backend/day1", + canonicalUrl: "https://seojing.com/blog/study/backend/day1", + sectionId: null, + chunkId: "summary-2m", + title: "백엔드 스터디 Day 1 — 2분 요약", + locale: "ko-KR", + targetDurationSeconds: 120, + text: "요약", + audioPath: "/tts-artifacts/study/backend/day1/summary-2m.mp3", + transcriptPath: "/tts-artifacts/study/backend/day1/summary-2m.txt", + status: "ready", + degradedReason: null, + }, + { + id: "study__backend__day1__core-5m", + kind: "core-5m", + slug: "study/backend/day1", + canonicalUrl: "https://seojing.com/blog/study/backend/day1", + sectionId: null, + chunkId: "core-5m", + title: "백엔드 스터디 Day 1 — 5분 핵심", + locale: "ko-KR", + targetDurationSeconds: 300, + text: "핵심", + audioPath: "/tts-artifacts/study/backend/day1/core-5m.mp3", + transcriptPath: "/tts-artifacts/study/backend/day1/core-5m.txt", + status: "ready", + degradedReason: null, + }, + { + id: "study__backend__day1__section-001", + kind: "section", + slug: "study/backend/day1", + canonicalUrl: "https://seojing.com/blog/study/backend/day1", + sectionId: "요청-흐름", + chunkId: "요청-흐름", + title: "백엔드 스터디 Day 1 — 요청 흐름", + locale: "ko-KR", + targetDurationSeconds: null, + text: "섹션", + audioPath: "/tts-artifacts/study/backend/day1/section-001.mp3", + transcriptPath: "/tts-artifacts/study/backend/day1/section-001.txt", + status: "ready", + degradedReason: null, + }, + ], + sections: [ + { id: "요청-흐름", title: "요청 흐름", level: 2, text: "섹션", order: 1 }, + ], +}; + +describe("BlogAudioPlayer", () => { + beforeEach(() => { + window.localStorage.clear(); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify(manifest), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + window.localStorage.clear(); + }); + + it("loads the article TTS manifest and renders speed presets", async () => { + render(); + + expect( + await screen.findByLabelText("블로그 오디오 플레이어"), + ).toBeInTheDocument(); + expect(fetch).toHaveBeenCalledWith( + "/tts-artifacts/study/backend/day1/manifest.json", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(screen.getByRole("button", { name: "1.2x" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "1.5x" })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.getByRole("button", { name: "2.0x" })).toBeInTheDocument(); + }); + + it("switches modes, jumps to section headings, and remembers the selected artifact", async () => { + document.body.insertAdjacentHTML("beforeend", "

요청 흐름

"); + Element.prototype.scrollIntoView = vi.fn(); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "5분 핵심" })); + expect( + window.localStorage.getItem( + "seojing_blog_audio_player_v1:study/backend/day1:artifactId", + ), + ).toBe("study__backend__day1__core-5m"); + + fireEvent.click( + screen.getByRole("button", { name: "오디오 구간 이동: 요청 흐름" }), + ); + + await waitFor(() => + expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ + behavior: "smooth", + block: "start", + }), + ); + expect( + window.localStorage.getItem( + "seojing_blog_audio_player_v1:study/backend/day1:artifactId", + ), + ).toBe("study__backend__day1__section-001"); + }); + + it("stores playback rate and restores saved current time on metadata load", async () => { + window.localStorage.setItem( + "seojing_blog_audio_player_v1:study/backend/day1:position:study__backend__day1__summary-2m", + "75", + ); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "1.8x" })); + expect( + window.localStorage.getItem( + "seojing_blog_audio_player_v1:study/backend/day1:rate", + ), + ).toBe("1.8"); + + const audio = document.querySelector("audio")!; + Object.defineProperty(audio, "duration", { + configurable: true, + value: 180, + }); + fireEvent.loadedMetadata(audio); + + expect(Math.floor(audio.currentTime)).toBe(75); + expect(screen.getByRole("status")).toHaveTextContent( + "1:15부터 이어듣기 준비됨", + ); + }); + + it("dispatches TTS analytics for manifest, mode, speed, and stop-point events", async () => { + const listener = vi.fn(); + window.addEventListener("seojing:tts-interaction", listener); + + render(); + + await screen.findByLabelText("블로그 오디오 플레이어"); + await waitFor(() => expect(listener).toHaveBeenCalled()); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ + action: "manifest_loaded", + available_artifact_kinds: ["summary-2m", "core-5m", "section"], + section_artifact_count: 1, + }), + }), + ); + + fireEvent.click(screen.getByRole("button", { name: "1.8x" })); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ + action: "speed_change", + artifact_kind: "summary-2m", + playback_rate: 1.8, + }), + }), + ); + + fireEvent.click( + screen.getByRole("button", { name: "오디오 구간 이동: 요청 흐름" }), + ); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ + action: "artifact_select", + artifact_kind: "section", + section_id: "요청-흐름", + section_heading: "요청 흐름", + }), + }), + ); + + const audio = document.querySelector("audio")!; + Object.defineProperty(audio, "duration", { + configurable: true, + value: 120, + }); + Object.defineProperty(audio, "currentTime", { + configurable: true, + value: 45, + }); + fireEvent.pause(audio); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ + action: "pause", + position_seconds_bucket: "30-59", + progress_percent_bucket: "25-49", + }), + }), + ); + + window.removeEventListener("seojing:tts-interaction", listener); + }); + + it("stays hidden when a post has no manifest", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("not found", { status: 404 })), + ); + + const { container } = render(); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/apps/web/src/widgets/blog-audio-player/BlogAudioPlayer.tsx b/apps/web/src/widgets/blog-audio-player/BlogAudioPlayer.tsx new file mode 100644 index 0000000..9f3c673 --- /dev/null +++ b/apps/web/src/widgets/blog-audio-player/BlogAudioPlayer.tsx @@ -0,0 +1,439 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { + TtsArticleManifest, + TtsArtifact, + TtsSection, +} from "@/shared/tts/tts-artifacts"; + +interface BlogAudioPlayerProps { + slug: string; +} + +type TtsAnalyticsAction = + | "manifest_loaded" + | "artifact_select" + | "play" + | "pause" + | "ended" + | "speed_change"; + +const PLAYBACK_RATES = [1.2, 1.5, 1.8, 2.0] as const; +const DEFAULT_PLAYBACK_RATE = 1.5; +const STORAGE_PREFIX = "seojing_blog_audio_player_v1"; + +/** + * 블로그별 TTS manifest가 있을 때만 노출되는 로컬 우선 오디오 플레이어. + * 재생 속도, 섹션 단위 점프, 현재 위치 복원을 localStorage로 처리한다. + */ +export function BlogAudioPlayer({ slug }: BlogAudioPlayerProps) { + const audioRef = useRef(null); + const [manifest, setManifest] = useState(null); + const [selectedArtifactId, setSelectedArtifactId] = useState( + null, + ); + const [playbackRate, setPlaybackRate] = useState( + DEFAULT_PLAYBACK_RATE, + ); + const [resumeNotice, setResumeNotice] = useState(null); + + const storageBaseKey = `${STORAGE_PREFIX}:${slug}`; + + useEffect(() => { + const controller = new AbortController(); + + async function loadManifest() { + try { + const response = await fetch(buildManifestPath(slug), { + signal: controller.signal, + }); + if (!response.ok) { + setManifest(null); + return; + } + const nextManifest = (await response.json()) as TtsArticleManifest; + if (!Array.isArray(nextManifest.artifacts)) { + setManifest(null); + return; + } + setManifest(nextManifest); + dispatchTtsAnalytics("manifest_loaded", { + manifest: nextManifest, + slug, + playbackRate: DEFAULT_PLAYBACK_RATE, + }); + setSelectedArtifactId( + (current) => + current ?? readStoredString(`${storageBaseKey}:artifactId`), + ); + const storedRate = readStoredNumber(`${storageBaseKey}:rate`); + if (storedRate && PLAYBACK_RATES.includes(storedRate as never)) { + setPlaybackRate(storedRate); + } + } catch { + if (!controller.signal.aborted) setManifest(null); + } + } + + void loadManifest(); + return () => controller.abort(); + }, [slug, storageBaseKey]); + + const playableArtifacts = useMemo( + () => + manifest?.artifacts.filter( + (artifact) => artifact.status !== "failed" && artifact.audioPath, + ) ?? [], + [manifest], + ); + + const selectedArtifact = useMemo(() => { + if (playableArtifacts.length === 0) return null; + return ( + playableArtifacts.find( + (artifact) => artifact.id === selectedArtifactId, + ) ?? + playableArtifacts[0] ?? + null + ); + }, [playableArtifacts, selectedArtifactId]); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + audio.playbackRate = playbackRate; + writeStoredString(`${storageBaseKey}:rate`, String(playbackRate)); + }, [playbackRate, storageBaseKey]); + + const handleLoadedMetadata = useCallback(() => { + if (!selectedArtifact) return; + const audio = audioRef.current; + if (!audio) return; + + audio.playbackRate = playbackRate; + const savedTime = readStoredNumber( + positionKey(storageBaseKey, selectedArtifact), + ); + if (savedTime && savedTime > 3 && Number.isFinite(audio.duration)) { + const safeTime = Math.min(savedTime, Math.max(0, audio.duration - 2)); + audio.currentTime = safeTime; + setResumeNotice(`${formatTime(safeTime)}부터 이어듣기 준비됨`); + } else { + setResumeNotice(null); + } + }, [playbackRate, selectedArtifact, storageBaseKey]); + + const handleTimeUpdate = useCallback(() => { + const audio = audioRef.current; + if (!audio || !selectedArtifact) return; + writeStoredString( + positionKey(storageBaseKey, selectedArtifact), + String(Math.floor(audio.currentTime)), + ); + }, [selectedArtifact, storageBaseKey]); + + const emitAudioEvent = useCallback( + (action: Extract) => { + if (!selectedArtifact || !manifest) return; + dispatchTtsAnalytics(action, { + artifact: selectedArtifact, + manifest, + audio: audioRef.current, + slug, + playbackRate, + }); + }, + [manifest, playbackRate, selectedArtifact, slug], + ); + + const selectArtifact = useCallback( + (artifact: TtsArtifact) => { + setSelectedArtifactId(artifact.id); + writeStoredString(`${storageBaseKey}:artifactId`, artifact.id); + if (manifest) { + dispatchTtsAnalytics("artifact_select", { + artifact, + manifest, + audio: audioRef.current, + slug, + playbackRate, + }); + } + if (artifact.sectionId && manifest) { + scrollToSection(manifest.sections, artifact.sectionId); + } + }, + [manifest, playbackRate, slug, storageBaseKey], + ); + + const selectPlaybackRate = useCallback( + (rate: number) => { + setPlaybackRate(rate); + if (selectedArtifact && manifest) { + dispatchTtsAnalytics("speed_change", { + artifact: selectedArtifact, + manifest, + audio: audioRef.current, + slug, + playbackRate: rate, + }); + } + }, + [manifest, selectedArtifact, slug], + ); + + if (!manifest || playableArtifacts.length === 0 || !selectedArtifact) { + return null; + } + + const sectionArtifacts = playableArtifacts.filter( + (artifact) => artifact.kind === "section", + ); + + return ( +
+
+
+

+ Listen +

+

+ {selectedArtifact.title} +

+

+ 속도·구간·이어듣기는 이 브라우저에만 저장돼요. + {selectedArtifact.status === "pending" + ? " 오디오 파일이 아직 생성 중이면 재생이 지연될 수 있어요." + : null} +

+
+ +
+
+ ); +} + +function buildManifestPath(slug: string) { + const safeSlug = slug + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); + return `/tts-artifacts/${safeSlug}/manifest.json`; +} + +function buttonClass(active: boolean) { + const base = + "rounded-full border px-3 py-1.5 text-xs font-medium transition focus:outline-none focus:ring-2 focus:ring-blue-400"; + if (active) { + return `${base} border-blue-600 bg-blue-600 text-white shadow-sm`; + } + return `${base} border-gray-300 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-300 dark:hover:border-blue-500 dark:hover:text-blue-200`; +} + +function sectionTitle(artifact: TtsArtifact, sections: TtsSection[]) { + return ( + sections.find((section) => section.id === artifact.sectionId)?.title ?? + artifact.title.split(" — ").at(-1) ?? + "섹션" + ); +} + +function scrollToSection(sections: TtsSection[], sectionId: string) { + const title = sections.find((section) => section.id === sectionId)?.title; + if (!title) return; + const headings = Array.from( + document.querySelectorAll("h2, h3, h4"), + ); + const target = headings.find( + (heading) => heading.textContent?.trim() === title.trim(), + ); + target?.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +function positionKey(storageBaseKey: string, artifact: TtsArtifact) { + return `${storageBaseKey}:position:${artifact.id}`; +} + +function dispatchTtsAnalytics( + action: TtsAnalyticsAction, + options: { + manifest: TtsArticleManifest; + slug: string; + playbackRate: number; + artifact?: TtsArtifact; + audio?: HTMLAudioElement | null; + }, +) { + const { action: _action, ...detail } = { + action, + slug: options.slug, + artifact_id: options.artifact?.id, + artifact_kind: options.artifact?.kind, + section_id: options.artifact?.sectionId ?? undefined, + section_heading: options.artifact?.sectionId + ? sectionTitle(options.artifact, options.manifest.sections) + : undefined, + playback_rate: options.playbackRate, + audio_status: options.artifact?.status, + available_artifact_kinds: Array.from( + new Set(options.manifest.artifacts.map((artifact) => artifact.kind)), + ), + section_artifact_count: options.manifest.artifacts.filter( + (artifact) => artifact.kind === "section" && artifact.audioPath, + ).length, + duration_seconds_bucket: secondsBucket(options.audio?.duration), + position_seconds_bucket: secondsBucket(options.audio?.currentTime), + progress_percent_bucket: progressBucket(options.audio), + }; + + window.dispatchEvent( + new CustomEvent("seojing:tts-interaction", { + detail: { action: _action, ...removeUndefined(detail) }, + }), + ); +} + +function secondsBucket(value: number | undefined) { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return undefined; + } + if (value < 30) return "0-29"; + if (value < 60) return "30-59"; + if (value < 180) return "60-179"; + if (value < 300) return "180-299"; + if (value < 600) return "300-599"; + return "600+"; +} + +function progressBucket(audio: HTMLAudioElement | null | undefined) { + if (!audio || !Number.isFinite(audio.duration) || audio.duration <= 0) { + return undefined; + } + const progress = Math.min( + 100, + Math.max(0, Math.round((audio.currentTime / audio.duration) * 100)), + ); + if (progress < 25) return "0-24"; + if (progress < 50) return "25-49"; + if (progress < 75) return "50-74"; + if (progress < 90) return "75-89"; + if (progress < 100) return "90-99"; + return "100"; +} + +function removeUndefined(values: Record) { + return Object.fromEntries( + Object.entries(values).filter(([, value]) => value !== undefined), + ); +} + +function readStoredString(key: string) { + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +} + +function readStoredNumber(key: string) { + const value = readStoredString(key); + if (!value) return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +function writeStoredString(key: string, value: string) { + try { + window.localStorage.setItem(key, value); + } catch { + // localStorage may be blocked; the player should still work without resume. + } +} + +function formatTime(seconds: number) { + const minute = Math.floor(seconds / 60); + const second = Math.floor(seconds % 60) + .toString() + .padStart(2, "0"); + return `${minute}:${second}`; +} diff --git a/apps/web/src/widgets/presentation/PresentationView.tsx b/apps/web/src/widgets/presentation/PresentationView.tsx index 7de93f4..98e49bf 100644 --- a/apps/web/src/widgets/presentation/PresentationView.tsx +++ b/apps/web/src/widgets/presentation/PresentationView.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, + useMemo, useRef, type RefObject, } from "react"; @@ -14,9 +15,16 @@ import { IoRemoveOutline, IoPhonePortraitOutline, IoDesktopOutline, + IoListOutline, + IoCodeSlashOutline, + IoImageOutline, } from "react-icons/io5"; import { FullscreenView } from "@app/ui"; -import { extractSlides, getFillRatio } from "./presentation.utils"; +import { + extractSlideOutlineFromSlides, + extractSlides, + getFillRatio, +} from "./presentation.utils"; const LONG_PRESS_MS = 1500; const LONG_PRESS_THRESHOLD_MS = 300; // 롱프레스 판단 최소 시간 @@ -97,6 +105,9 @@ export function PresentationView({ : BOTTOM_BAR_HEIGHT_PC; const [slides, setSlides] = useState([]); + const [outlineFilter, setOutlineFilter] = useState<"all" | "code" | "image">( + "all", + ); useEffect(() => { if (!articleRef.current) return; @@ -128,6 +139,21 @@ export function PresentationView({ ]); const totalSlides = slides.length; + const slideOutline = useMemo( + () => extractSlideOutlineFromSlides(slides), + [slides], + ); + const filteredSlideOutline = useMemo(() => { + if (outlineFilter === "code") { + return slideOutline.filter((item) => item.hasCode); + } + if (outlineFilter === "image") { + return slideOutline.filter((item) => item.hasImage); + } + return slideOutline; + }, [outlineFilter, slideOutline]); + const codeSlideCount = slideOutline.filter((item) => item.hasCode).length; + const imageSlideCount = slideOutline.filter((item) => item.hasImage).length; const closingRef = useRef(false); const safeClose = useCallback(() => { @@ -150,6 +176,10 @@ export function PresentationView({ setCurrentSlide((p) => Math.max(p - 1, 0)); }, []); + const goToSlide = useCallback((slideIndex: number) => { + setCurrentSlide(slideIndex); + }, []); + useEffect(() => { const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; @@ -171,6 +201,14 @@ export function PresentationView({ e.preventDefault(); setCurrentSlide((p) => Math.max(p - 1, 0)); } + if (e.key === "Home") { + e.preventDefault(); + setCurrentSlide(0); + } + if (e.key === "End") { + e.preventDefault(); + setCurrentSlide(Math.max(totalSlides - 1, 0)); + } }; document.addEventListener("keydown", handleKey); @@ -283,21 +321,113 @@ export function PresentationView({ style={{ height: "100dvh" }} >
- {/* 슬라이드 콘텐츠 */} -
-
+ {/* 덱 레이아웃: PC는 좌측 목차/탐색 + 우측 16:9 슬라이드, 모바일은 슬라이드만 */} +
+ {!isMobile && ( + + )} + +
+
+
+ Deck {currentSlide + 1} / {totalSlides} +
+
+
+
+
+
{/* 네비게이션 영역 (양쪽 사이드만, 가운데는 콘텐츠 클릭 가능) */}
- ))} -
- -
- {filteredSlideOutline.map((item) => ( - - ))} -
- - )} - -
-
-
- Deck {currentSlide + 1} / {totalSlides} -
-
-
-
-
-
+ {/* 슬라이드 콘텐츠 */} +
+
{/* 네비게이션 영역 (양쪽 사이드만, 가운데는 콘텐츠 클릭 가능) */}
+ ))} +
+ +
+ {filteredSlideOutline.map((item) => ( + + ))} +
+ + +
+
+
+ Deck {currentSlide + 1} / {totalSlides} +
+
+
+
+
+
+
+ )} {/* 네비게이션 영역 (양쪽 사이드만, 가운데는 콘텐츠 클릭 가능) */}
+ )} {pcScale.toFixed(1)} {/* 표시 영역 조절 (미러링 등으로 화면 잘릴 때 사용) */} - + {Math.round( @@ -450,7 +662,7 @@ export function PresentationView({
-
- {[ - { key: "all" as const, label: "전체", count: totalSlides }, - { - key: "code" as const, - label: "코드", - count: codeSlideCount, - }, - { - key: "image" as const, - label: "이미지", - count: imageSlideCount, - }, - ].map((filter) => ( - - ))} +
+ H1/H2 목차 {headingOutline.length}개 · 본문 슬라이드{" "} + {totalSlides}장
- {filteredSlideOutline.map((item) => ( + {headingOutline.map((item) => ( diff --git a/apps/web/src/widgets/presentation/presentation.utils.test.ts b/apps/web/src/widgets/presentation/presentation.utils.test.ts index 48b9d5c..05bac04 100644 --- a/apps/web/src/widgets/presentation/presentation.utils.test.ts +++ b/apps/web/src/widgets/presentation/presentation.utils.test.ts @@ -154,8 +154,8 @@ describe("extractSlideOutlineFromSlides", () => { expect(outline).toMatchObject([ { title: "Deck title", - kind: "content", - level: 0, + kind: "heading", + level: 1, slideIndex: 0, elementCount: 1, hasCode: false, diff --git a/apps/web/src/widgets/presentation/presentation.utils.ts b/apps/web/src/widgets/presentation/presentation.utils.ts index 41196e3..b536c6c 100644 --- a/apps/web/src/widgets/presentation/presentation.utils.ts +++ b/apps/web/src/widgets/presentation/presentation.utils.ts @@ -59,10 +59,16 @@ function getHeadingLevel(element: Element): number | null { return Number(match[1]); } +function getPrimaryHeadingElement(element: Element): Element | null { + if (element.matches("h1,h2,h3,h4,h5,h6")) { + return element; + } + + return element.querySelector("h1,h2,h3,h4,h5,h6"); +} + function getElementTitle(element: Element, fallback: string): string { - const heading = element.matches("h1,h2,h3,h4,h5,h6") - ? element - : element.querySelector("h1,h2,h3,h4,h5,h6"); + const heading = getPrimaryHeadingElement(element); const text = (heading ?? element).textContent?.replace(/\s+/g, " ").trim(); return text || fallback; } @@ -97,7 +103,10 @@ function buildSlideOutlineItem( slideIndex: number, ): PresentationSlideOutlineItem { const firstElement = elements[0]; - const headingLevel = firstElement ? getHeadingLevel(firstElement) : null; + const primaryHeading = firstElement + ? getPrimaryHeadingElement(firstElement) + : null; + const headingLevel = primaryHeading ? getHeadingLevel(primaryHeading) : null; const kind: PresentationOutlineItemKind = headingLevel ? "heading" : "content"; From c62c8052f15917e01722297e4dd346e52606ff77 Mon Sep 17 00:00:00 2001 From: seoJing Date: Mon, 8 Jun 2026 14:21:27 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20=EA=B8=80=20=EC=9D=BD=EA=B8=B0=20TTS?= =?UTF-8?q?=EC=99=80=20=EC=A7=88=EB=AC=B8=20UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/blog/[...slug]/page.tsx | 11 +- .../blog-audio-player/BlogAudioPlayer.tsx | 72 ++++++++++-- apps/web/src/widgets/post-qa/PostQaPanel.tsx | 63 +++++++++-- .../src/widgets/post-qa/SectionQaPrompts.tsx | 106 ++++++++++++++++++ apps/web/src/widgets/post-qa/index.ts | 1 + 5 files changed, 233 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/widgets/post-qa/SectionQaPrompts.tsx diff --git a/apps/web/src/app/blog/[...slug]/page.tsx b/apps/web/src/app/blog/[...slug]/page.tsx index d406cfe..5d11875 100644 --- a/apps/web/src/app/blog/[...slug]/page.tsx +++ b/apps/web/src/app/blog/[...slug]/page.tsx @@ -9,7 +9,7 @@ import { RecentlyRead } from "@/widgets/recently-read/RecentlyRead"; import { PostExplorer } from "@/widgets/post-explorer/PostExplorer"; import { ArticleToolbar } from "@/widgets/article-toolbar/ArticleToolbar"; import { ArticleAnalytics } from "@/widgets/article-analytics"; -import { PostQaPanel } from "@/widgets/post-qa"; +import { PostQaPanel, SectionQaPrompts } from "@/widgets/post-qa"; import { BlogAudioPlayer } from "@/widgets/blog-audio-player/BlogAudioPlayer"; import type { Metadata } from "vinext/shims/metadata"; import { @@ -87,16 +87,19 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) { slug={slug.join("/")} title={content.frontmatter.title} /> -
+
- + +
+ +
+
- (null); - + const wrapperRef = useRef(null); + const playerRef = useRef(null); + const [isDocked, setIsDocked] = useState(false); + const [placeholderHeight, setPlaceholderHeight] = useState< + number | undefined + >(); const storageBaseKey = `${STORAGE_PREFIX}:${slug}`; + useEffect(() => { + const updateDocking = () => { + const wrapper = wrapperRef.current; + const player = playerRef.current; + if (!wrapper || !player) return; + const rect = wrapper.getBoundingClientRect(); + const nextDocked = rect.bottom < 96; + setIsDocked(nextDocked); + setPlaceholderHeight(player.offsetHeight || undefined); + }; + + updateDocking(); + window.addEventListener("scroll", updateDocking, { passive: true }); + window.addEventListener("resize", updateDocking); + + const resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(() => updateDocking()); + if (playerRef.current) resizeObserver?.observe(playerRef.current); + + return () => { + window.removeEventListener("scroll", updateDocking); + window.removeEventListener("resize", updateDocking); + resizeObserver?.disconnect(); + }; + }, []); + useEffect(() => { const controller = new AbortController(); @@ -191,14 +225,20 @@ export function BlogAudioPlayer({ slug }: BlogAudioPlayerProps) { (artifact) => artifact.kind === "section", ); - return ( + const sectionClass = isDocked + ? "fixed bottom-5 right-5 z-40 max-h-[calc(100vh-2.5rem)] w-[min(26rem,calc(100vw-2.5rem))] overflow-y-auto rounded-3xl border border-gray-200 bg-white/95 p-4 text-sm shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 xl:right-[max(1.25rem,calc((100vw-68rem)/2-22rem))]" + : "mt-4 rounded-3xl border border-gray-200 bg-transparent p-4 text-sm shadow-sm dark:border-gray-800"; + + const player = (
-

+

Listen

@@ -284,13 +324,29 @@ export function BlogAudioPlayer({ slug }: BlogAudioPlayerProps) { ) : null} {resumeNotice ? ( -

+

{resumeNotice}

) : null}

); + + return ( +
+ {isDocked && typeof document !== "undefined" + ? createPortal(player, document.body) + : player} +
+ ); } function buildManifestPath(slug: string) { @@ -303,11 +359,11 @@ function buildManifestPath(slug: string) { function buttonClass(active: boolean) { const base = - "rounded-full border px-3 py-1.5 text-xs font-medium transition focus:outline-none focus:ring-2 focus:ring-blue-400"; + "rounded-full border px-3 py-1.5 text-xs font-medium transition focus:outline-none focus:ring-2 focus:ring-gray-400"; if (active) { - return `${base} border-blue-600 bg-blue-600 text-white shadow-sm`; + return `${base} border-gray-900 bg-gray-900 text-white shadow-sm dark:border-gray-100 dark:bg-gray-100 dark:text-gray-950`; } - return `${base} border-gray-300 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-300 dark:hover:border-blue-500 dark:hover:text-blue-200`; + return `${base} border-gray-300 bg-white text-gray-700 hover:border-gray-500 hover:text-gray-950 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-300 dark:hover:border-gray-500 dark:hover:text-gray-100`; } function sectionTitle(artifact: TtsArtifact, sections: TtsSection[]) { diff --git a/apps/web/src/widgets/post-qa/PostQaPanel.tsx b/apps/web/src/widgets/post-qa/PostQaPanel.tsx index 3d20d9c..8af009f 100644 --- a/apps/web/src/widgets/post-qa/PostQaPanel.tsx +++ b/apps/web/src/widgets/post-qa/PostQaPanel.tsx @@ -10,6 +10,10 @@ type QuestionLogEntry = { createdAt: string; }; +type SectionQaContext = { + sectionTitle: string; +}; + interface PostQaPanelProps { slug: string; title: string; @@ -27,6 +31,7 @@ declare global { interface WindowEventMap { "seojing:qa-interaction": CustomEvent; "seojing:open-comments": CustomEvent<{ source: "post_qa" }>; + "seojing:qa-context": CustomEvent; } } @@ -105,7 +110,12 @@ export function PostQaPanel({ const [error, setError] = useState(null); const [pending, setPending] = useState(false); const [log, setLog] = useState([]); + const [sectionContext, setSectionContext] = useState( + null, + ); const requestSeq = useRef(0); + const panelRef = useRef(null); + const textareaRef = useRef(null); useEffect(() => { requestSeq.current += 1; @@ -113,9 +123,30 @@ export function PostQaPanel({ setResult(null); setError(null); setPending(false); + setSectionContext(null); setLog(safeReadLog(storageKey).filter((entry) => entry.slug === slug)); }, [slug, storageKey]); + useEffect(() => { + const handleContext = (event: WindowEventMap["seojing:qa-context"]) => { + const nextContext = event.detail; + setSectionContext(nextContext); + setResult(null); + setError(null); + window.setTimeout(() => { + panelRef.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + textareaRef.current?.focus(); + }, 0); + }; + + window.addEventListener("seojing:qa-context", handleContext); + return () => + window.removeEventListener("seojing:qa-context", handleContext); + }, []); + const canSubmit = useMemo( () => question.trim().length > 0 && question.trim().length <= 500 && !pending, @@ -125,6 +156,9 @@ export function PostQaPanel({ const submitQuestion = async () => { const trimmedQuestion = question.trim(); if (!trimmedQuestion || pending) return; + const apiQuestion = sectionContext + ? `[${sectionContext.sectionTitle} 부분에 대한 질문] ${trimmedQuestion}` + : trimmedQuestion; requestSeq.current += 1; const currentRequestSeq = requestSeq.current; @@ -136,7 +170,7 @@ export function PostQaPanel({ const response = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ slug, question: trimmedQuestion }), + body: JSON.stringify({ slug, question: apiQuestion }), }); if (!response.ok) throw new Error(`qa request failed: ${response.status}`); @@ -159,6 +193,7 @@ export function PostQaPanel({ ].slice(0, MAX_LOG_ENTRIES); safeWriteLog(storageKey, nextLog); setLog(nextLog.filter((entry) => entry.slug === slug)); + setSectionContext(null); const analyticsDetail = safeAnalyticsDetail(body.analytics?.event); if (analyticsDetail) { @@ -190,6 +225,7 @@ export function PostQaPanel({ return (
@@ -201,11 +237,14 @@ export function PostQaPanel({ id="post-qa-title" className="text-xl font-semibold text-gray-900 dark:text-gray-100" > - 이 글에 대해 질문하기 + 오케이징에게 물어보기

- {title} 안의 섹션 인덱스를 먼저 찾고, 필요하면 관련 글 출처도 함께 - 보여줘요. + {sectionContext + ? `「${sectionContext.sectionTitle}」 부분을 기준으로 먼저 답해볼게요.` + : `${title} 전체를 기준으로 질문과 피드백을 받아요.`} + 답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. + 작성자가 직접 볼 수 있어요!

@@ -214,12 +253,17 @@ export function PostQaPanel({ 이 글에 대해 질문하기