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/src/app/blog/[...slug]/page.tsx b/apps/web/src/app/blog/[...slug]/page.tsx index 2af8158..e02127f 100644 --- a/apps/web/src/app/blog/[...slug]/page.tsx +++ b/apps/web/src/app/blog/[...slug]/page.tsx @@ -9,7 +9,8 @@ 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 { buildArticleMetadata, @@ -86,14 +87,18 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) { slug={slug.join("/")} title={content.frontmatter.title} /> -
+
- + +
+ +
+
+

요청 흐름

+

본문

+ + ); +} + +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..a02c8dc --- /dev/null +++ b/apps/web/src/widgets/blog-audio-player/BlogAudioPlayer.test.tsx @@ -0,0 +1,306 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BlogAudioPlayer } from "./BlogAudioPlayer"; + +function stubDesktopMedia(matches: boolean) { + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); +} + +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(); + stubDesktopMedia(true); + 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("docks the player to the viewport after the inline area scrolls away", async () => { + const originalOffsetHeight = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "offsetHeight", + ); + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + value: 320, + }); + + const rectSpy = vi + .spyOn(HTMLDivElement.prototype, "getBoundingClientRect") + .mockReturnValue({ + top: -500, + bottom: 40, + left: 0, + right: 800, + width: 800, + height: 540, + x: 0, + y: -500, + toJSON: () => ({}), + }); + + render(); + await screen.findByLabelText("블로그 오디오 플레이어"); + + fireEvent.scroll(window); + + await waitFor(() => + expect(screen.getByLabelText("블로그 오디오 플레이어")).toHaveAttribute( + "data-docked", + "true", + ), + ); + expect(screen.getByLabelText("블로그 오디오 플레이어")).toHaveClass( + "fixed", + ); + + rectSpy.mockRestore(); + if (originalOffsetHeight) { + Object.defineProperty( + HTMLElement.prototype, + "offsetHeight", + originalOffsetHeight, + ); + } + }); + + 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..abe8d66 --- /dev/null +++ b/apps/web/src/widgets/blog-audio-player/BlogAudioPlayer.tsx @@ -0,0 +1,523 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +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"; +const DESKTOP_QUERY = "(min-width: 1280px)"; + +/** + * 블로그별 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 wrapperRef = useRef(null); + const playerRef = useRef(null); + const [isDocked, setIsDocked] = useState(false); + const [placeholderHeight, setPlaceholderHeight] = useState< + number | undefined + >(); + const [isDesktop, setIsDesktop] = useState( + () => + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia(DESKTOP_QUERY).matches, + ); + const lastPositionSaveRef = useRef(0); + const storageBaseKey = `${STORAGE_PREFIX}:${slug}`; + + useEffect(() => { + if (typeof window.matchMedia !== "function") return; + + const mediaQuery = window.matchMedia(DESKTOP_QUERY); + const updateDesktop = () => { + setIsDesktop(mediaQuery.matches); + if (!mediaQuery.matches) { + setIsDocked(false); + setPlaceholderHeight(undefined); + } + }; + mediaQuery.addEventListener("change", updateDesktop); + return () => mediaQuery.removeEventListener("change", updateDesktop); + }, []); + + useEffect(() => { + if (!isDesktop) return; + + 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(); + }; + }, [isDesktop]); + + 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; + const currentSecond = Math.floor(audio.currentTime); + if (Math.abs(currentSecond - lastPositionSaveRef.current) < 5) return; + lastPositionSaveRef.current = currentSecond; + writeStoredString( + positionKey(storageBaseKey, selectedArtifact), + String(currentSecond), + ); + }, [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", + ); + + const sectionClass = isDocked + ? "fixed bottom-5 right-6 z-40 max-h-[calc(100vh-2.5rem)] w-[min(22rem,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" + : "my-8 rounded-3xl border border-gray-200 bg-white/70 p-5 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-950/60"; + + const player = ( +
+
+
+

+ Listen +

+

+ {selectedArtifact.title} +

+

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

+
+ +
+
+ ); + + return ( +
+ {isDesktop && isDocked && typeof document !== "undefined" + ? createPortal(player, document.body) + : player} +
+ ); +} + +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-gray-400"; + if (active) { + 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-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[]) { + 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/post-qa/PostQaPanel.test.tsx b/apps/web/src/widgets/post-qa/PostQaPanel.test.tsx index 0873355..8930205 100644 --- a/apps/web/src/widgets/post-qa/PostQaPanel.test.tsx +++ b/apps/web/src/widgets/post-qa/PostQaPanel.test.tsx @@ -1,4 +1,10 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PostQaPanel } from "./PostQaPanel"; @@ -242,6 +248,53 @@ describe("PostQaPanel", () => { ).toHaveValue(""); }); + it("prefills section context from floating section prompts", async () => { + Element.prototype.scrollIntoView = vi.fn(); + const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, "focus"); + + render( + , + ); + + act(() => { + window.dispatchEvent( + new CustomEvent("seojing:qa-context", { + detail: { sectionTitle: "2. 백엔드는 요청과 응답을 다룬다" }, + }), + ); + }); + + expect( + await screen.findByText( + /「2\. 백엔드는 요청과 응답을 다룬다」 부분을 기준으로 먼저 답해볼게요/, + ), + ).toBeInTheDocument(); + await waitFor(() => expect(focusSpy).toHaveBeenCalled()); + + fireEvent.change( + screen.getByRole("textbox", { name: "이 글에 대해 질문하기" }), + { target: { value: "요청과 응답이 뭐야?" } }, + ); + fireEvent.click(screen.getByRole("button", { name: "질문 보내기" })); + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); + expect(fetch).toHaveBeenCalledWith( + "/api/rag/query", + expect.objectContaining({ + body: JSON.stringify({ + slug: "study/backend/day1", + question: + "[2. 백엔드는 요청과 응답을 다룬다 부분에 대한 질문] 요청과 응답이 뭐야?", + }), + }), + ); + expect( + screen.getByText(/이 내용을 댓글로 달아서 서징에게도 물어볼까요/), + ).toBeInTheDocument(); + + focusSpy.mockRestore(); + }); + it("shows a degraded message when the API fails", async () => { vi.stubGlobal( "fetch", 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({ 이 글에 대해 질문하기