Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 45 additions & 30 deletions src/components/LanguageSuggestionBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,57 +2,63 @@ import { useEffect, useState } from "react"
import { useLocation, useNavigate } from "react-router-dom"
import { Languages } from "lucide-react"
import { Button } from "@/components/ui/button"
import { en } from "@/i18n/translations"
import { en, ko, type Language } from "@/i18n/translations"
import { analytics } from "@/lib/analytics"
import {
detectSupportedBrowserLanguage,
getRouteLanguage,
getStoredLanguage,
localizePath,
prefersEnglishBrowser,
setStoredLanguage,
stripLanguagePrefix,
} from "@/lib/i18n-routing"

const SESSION_DISMISS_KEY = "language-suggestion-dismissed"
const koreanPostFiles = import.meta.glob("/content/posts/ko/*.md", {
query: "?raw",
import: "default",
})
const englishPostFiles = import.meta.glob("/content/posts/en/*.md", {
query: "?raw",
import: "default",
})
const localizedPostFiles = { ko: koreanPostFiles, en: englishPostFiles } satisfies Record<Language, Record<string, () => Promise<unknown>>>
const localizedStaticPaths = new Set(["/", "/posts", "/tags", "/series", "/analytics", "/about", "/privacy"])

function isSessionDismissed() {
function sessionDismissKey(source: Language, target: Language) {
return `language-suggestion-dismissed:${source}-${target}`
}

function isSessionDismissed(source: Language, target: Language) {
try {
return window.sessionStorage.getItem(SESSION_DISMISS_KEY) === "true"
return window.sessionStorage.getItem(sessionDismissKey(source, target)) === "true"
} catch {
return false
}
}

function dismissForSession() {
function dismissForSession(source: Language, target: Language) {
try {
window.sessionStorage.setItem(SESSION_DISMISS_KEY, "true")
window.sessionStorage.setItem(sessionDismissKey(source, target), "true")
} catch {
// Restricted browser storage should not prevent dismissing the current render.
}
}

async function hasEnglishAlternative(pathname: string) {
if (getRouteLanguage(pathname) === "en") return false

async function hasLanguageAlternative(pathname: string, targetLanguage: Language) {
const normalizedPath = stripLanguagePrefix(pathname).replace(/\/+$/, "") || "/"
if (localizedStaticPaths.has(normalizedPath)) return true

const projectSlug = normalizedPath.match(/^\/about\/projects\/([^/]+)$/)?.[1]
if (projectSlug) {
const { getResumeData } = await import("@/data/resume-i18n")
return getResumeData("en").projects.some((project) => project.slug === decodeURIComponent(projectSlug))
return getResumeData(targetLanguage).projects.some((project) => project.slug === decodeURIComponent(projectSlug))
}

const postSlug = normalizedPath.match(/^\/posts\/([^/]+)$/)?.[1]
if (!postSlug) return false

try {
return Boolean(englishPostFiles[`/content/posts/en/${decodeURIComponent(postSlug)}.md`])
return Boolean(localizedPostFiles[targetLanguage][`/content/posts/${targetLanguage}/${decodeURIComponent(postSlug)}.md`])
} catch {
return false
}
Expand All @@ -61,22 +67,27 @@ async function hasEnglishAlternative(pathname: string) {
export function LanguageSuggestionBanner() {
const location = useLocation()
const navigate = useNavigate()
const [visible, setVisible] = useState(false)
const copy = en.components
const [targetLanguage, setTargetLanguage] = useState<Language | null>(null)

useEffect(() => {
let active = true

async function updateVisibility() {
if (isSessionDismissed()) {
if (active) setVisible(false)
const sourceLanguage = getRouteLanguage(location.pathname)
const preferredLanguage = getStoredLanguage() ?? detectSupportedBrowserLanguage()

if (!preferredLanguage || preferredLanguage === sourceLanguage) {
if (active) setTargetLanguage(null)
return
}

const storedLanguage = getStoredLanguage()
const prefersEnglish = storedLanguage ? storedLanguage === "en" : prefersEnglishBrowser()
const hasAlternative = prefersEnglish && await hasEnglishAlternative(location.pathname)
if (active) setVisible(hasAlternative)
if (isSessionDismissed(sourceLanguage, preferredLanguage)) {
if (active) setTargetLanguage(null)
return
}

const hasAlternative = await hasLanguageAlternative(location.pathname, preferredLanguage)
if (active) setTargetLanguage(hasAlternative ? preferredLanguage : null)
}

void updateVisibility()
Expand All @@ -85,21 +96,25 @@ export function LanguageSuggestionBanner() {
}
}, [location.pathname])

if (!visible) return null
if (!targetLanguage) return null

const sourceLanguage = getRouteLanguage(location.pathname)
const target = targetLanguage
const copy = (target === "en" ? en : ko).components

function viewInEnglish() {
setStoredLanguage("en")
analytics.changeLanguage("en")
setVisible(false)
function viewInPreferredLanguage() {
setStoredLanguage(target)
analytics.changeLanguage(target)
setTargetLanguage(null)
navigate(
localizePath(`${location.pathname}${location.search}${location.hash}`, "en"),
localizePath(`${location.pathname}${location.search}${location.hash}`, target),
{ viewTransition: true },
)
}

function dismiss() {
dismissForSession()
setVisible(false)
dismissForSession(sourceLanguage, target)
setTargetLanguage(null)
}

return (
Expand All @@ -117,8 +132,8 @@ export function LanguageSuggestionBanner() {
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button type="button" size="sm" className="flex-1 sm:flex-none" onClick={viewInEnglish}>
{copy.viewInEnglish}
<Button type="button" size="sm" className="flex-1 sm:flex-none" onClick={viewInPreferredLanguage}>
{copy.viewInLanguage}
</Button>
<Button type="button" size="sm" variant="outline" className="flex-1 bg-background sm:flex-none" onClick={dismiss}>
{copy.notNow}
Expand Down
10 changes: 5 additions & 5 deletions src/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export interface Translations {
shortcutHelp: string
languageSuggestionTitle: string
languageSuggestionDescription: string
viewInEnglish: string
viewInLanguage: string
notNow: string
general: string
}
Expand Down Expand Up @@ -421,9 +421,9 @@ export const ko: Translations = {
shortcutSearch: "검색 열기",
shortcutHelp: "단축키 안내 열기",
shortcutSidebar: "사이드바 토글",
languageSuggestionTitle: "영문 페이지도 제공됩니다.",
languageSuggestionDescription: "현재 페이지를 유지하면서 영어로 전환할 수 있습니다.",
viewInEnglish: "영어로 보기",
languageSuggestionTitle: "이 페이지는 한국어로도 제공됩니다.",
languageSuggestionDescription: "현재 페이지를 유지하면서 한국어로 전환할 수 있습니다.",
viewInLanguage: "한국어로 보기",
notNow: "나중에",
general: "일반",
},
Expand Down Expand Up @@ -640,7 +640,7 @@ export const en: Translations = {
shortcutSidebar: "Toggle sidebar",
languageSuggestionTitle: "This page is available in English.",
languageSuggestionDescription: "Switch without losing your current page.",
viewInEnglish: "View in English",
viewInLanguage: "View in English",
notNow: "Not now",
general: "General",
},
Expand Down
17 changes: 5 additions & 12 deletions src/lib/i18n-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,15 @@ export function setStoredLanguage(language: Language) {
}
}

export function detectBrowserLanguage(): Language {
if (typeof navigator === "undefined") return "ko"
export function detectSupportedBrowserLanguage(): Language | null {
if (typeof navigator === "undefined") return null

const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
const primaryLanguage = languages.find((language) => language.trim().length > 0)?.toLowerCase()

return primaryLanguage?.startsWith("ko") ? "ko" : "en"
}

export function prefersEnglishBrowser(): boolean {
if (typeof navigator === "undefined") return false

const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
const primaryLanguage = languages.find((language) => language.trim().length > 0)?.toLowerCase()

return primaryLanguage?.startsWith("en") ?? false
if (primaryLanguage?.startsWith("ko")) return "ko"
if (primaryLanguage?.startsWith("en")) return "en"
return null
}

export function getRouteLanguage(pathname: string): Language {
Expand Down
Loading