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
2 changes: 2 additions & 0 deletions deploy/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ map $uri $cache_control {
# pagefind-entry.json 이 해시된 색인 파일을 가리키는데, 배포하면 옛 색인은 삭제된다.
# 캐시되면 사라진 파일을 요청해 검색이 깨지므로 재검증시킨다.
~^/pagefind/ "no-cache";
# 서비스 워커 및 HTML/RSC/매니페스트는 새 배포가 즉시 감지되어야 하므로 no-cache 로 재검증한다.
~^/sw\.js$ "no-cache";
~*\.(?:html|txt|xml|webmanifest)$ "no-cache";
}

Expand Down
104 changes: 104 additions & 0 deletions public/offline.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>오프라인 상태 - True Log</title>
<style>
:root {
--bg: #ffffff;
--fg: #171717;
--muted: #737373;
--card-bg: #f5f5f5;
--border: #e5e5e5;
--btn-bg: #171717;
--btn-fg: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #171717;
--fg: #f5f5f5;
--muted: #a3a3a3;
--card-bg: #262626;
--border: #404040;
--btn-bg: #f5f5f5;
--btn-fg: #171717;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--bg);
color: var(--fg);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.offline-card {
background-color: var(--card-bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2.5rem 2rem;
max-width: 440px;
width: 100%;
text-align: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}
.icon {
width: 48px;
height: 48px;
margin: 0 auto 1.25rem;
color: var(--muted);
}
h1 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.75rem;
letter-spacing: -0.02em;
}
p {
font-size: 0.95rem;
color: var(--muted);
line-height: 1.6;
margin-bottom: 1.75rem;
}
.actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
button {
background-color: var(--btn-bg);
color: var(--btn-fg);
border: none;
border-radius: 6px;
padding: 0.75rem 1.25rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
button:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<main class="offline-card">
<svg aria-hidden="true" class="icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 4.243a9 9 0 01-1.414-1.414m-1.414-1.414a9 9 0 010-12.728m2.829 2.829a5 5 0 011.414 1.414M3 3l18 18" />
</svg>
<h1>오프라인 상태입니다</h1>
<p>인터넷 연결이 원활하지 않습니다. 네트워크 연결을 확인한 후 다시 시도해 주세요.</p>
<div class="actions">
<button type="button" onclick="window.location.reload()">다시 시도하기</button>
</div>
</main>
</body>
</html>
106 changes: 106 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
const CACHE_VERSION = "true-log-v1"
const CACHE_NAME = `true-log-cache-${CACHE_VERSION}`
const OFFLINE_FALLBACK = "/offline.html"

// 설치(install): 오프라인 폴백 페이지를 사전 캐싱(pre-cache)
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll([OFFLINE_FALLBACK]))
.then(() => self.skipWaiting()),
)
})

// 활성화(activate): 구버전 캐시 정리 및 클라이언트 즉시 제어
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((cacheNames) =>
Promise.all(
cacheNames.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name)),
),
)
.then(() => self.clients.claim()),
)
})

// 요청 가로채기(fetch)
self.addEventListener("fetch", (event) => {
const { request } = event
const url = new URL(request.url)

// 1. GET 요청 및 동일 출처(same-origin)만 처리 (서드파티는 NetworkOnly)
if (request.method !== "GET" || url.origin !== self.location.origin) {
return
}

// 2. 불변 정적 에셋: CacheFirst
// /_next/static/, /fonts/, /icons/, /katex/
const isStaticAsset =
url.pathname.startsWith("/_next/static/") ||
url.pathname.startsWith("/fonts/") ||
url.pathname.startsWith("/icons/") ||
url.pathname.startsWith("/katex/")

if (isStaticAsset) {
event.respondWith(
caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse
}
return fetch(request).then((networkResponse) => {
if (networkResponse.ok) {
const clone = networkResponse.clone()
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone))
}
return networkResponse
})
}),
)
return
}

// 3. HTML 페이지 탐색 (Navigation): NetworkFirst + Offline Fallback
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((networkResponse) => {
if (networkResponse.ok) {
const clone = networkResponse.clone()
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone))
}
return networkResponse
})
.catch(async () => {
const cachedResponse = await caches.match(request)
if (cachedResponse) {
return cachedResponse
}
const offlinePage = await caches.match(OFFLINE_FALLBACK)
return (
offlinePage ||
new Response("오프라인 상태입니다.", {
status: 503,
headers: { "Content-Type": "text/plain; charset=utf-8" },
})
)
}),
)
return
}

// 4. RSC 페이로드 (*.txt) 및 기타 정적 페이지 데이터: NetworkFirst
event.respondWith(
fetch(request)
.then((networkResponse) => {
if (networkResponse.ok) {
const clone = networkResponse.clone()
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone))
}
return networkResponse
})
.catch(() => caches.match(request)),
)
})
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AppShell } from "@/components/layout"
import { Toaster } from "@/components/ui/sonner"
import { getPublicIntegrations } from "@/config/integrations"
import { siteConfig, themeColors } from "@/config/site"
import { PwaRegister } from "@/features/pwa/pwa-register"
import { ThemeScript } from "@/features/theme/theme-script"
import { createPageMetadata } from "@/lib/seo"
import "./globals.css"
Expand Down Expand Up @@ -45,6 +46,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
<ThemeScript />
<GoogleAnalytics config={integrations.ga4} />
<AppShell>{children}</AppShell>
<PwaRegister />
<Toaster />
</body>
</html>
Expand Down
65 changes: 62 additions & 3 deletions src/app/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,89 @@ import { siteConfig, themeColors } from "@/config/site"

export const dynamic = "force-static"

function getBasePath(): string {
// biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access
const rawBasePath = process.env["NEXT_PUBLIC_BASE_PATH"]?.trim()

return rawBasePath && rawBasePath !== "/"
? (rawBasePath.startsWith("/") ? rawBasePath : `/${rawBasePath}`).replace(/\/+$/, "")
: ""
}

// biome-ignore lint/style/noDefaultExport: Next.js metadata files require default exports.
export default function manifest(): MetadataRoute.Manifest {
const basePath = getBasePath()
const prefix = (path: string) => `${basePath}${path}`

return {
name: siteConfig.name,
short_name: siteConfig.name,
description: siteConfig.description,
start_url: "/",
id: prefix("/"),
start_url: prefix("/"),
scope: prefix("/"),
display: "standalone",
orientation: "portrait",
lang: siteConfig.language,
categories: ["blog", "technology", "development"],
// 웹 매니페스트는 라이트/다크를 구분하지 못하므로, viewport 메타 태그와 같은 다크 값을 쓴다.
background_color: themeColors.dark,
theme_color: themeColors.dark,
icons: [
{
src: "/icons/icon-192.png",
src: prefix("/icons/icon-192.png"),
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/icons/icon-512.png",
src: prefix("/icons/icon-192.png"),
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: prefix("/icons/icon-512.png"),
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: prefix("/icons/icon-512.png"),
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
shortcuts: [
{
name: "전체 글 목록",
short_name: "글 목록",
description: "최신 기술 블로그 포스트 목록을 확인합니다.",
url: prefix("/posts/"),
icons: [{ src: prefix("/icons/icon-192.png"), sizes: "192x192" }],
},
{
name: "카테고리",
short_name: "카테고리",
description: "주제별 분류 목록을 확인합니다.",
url: prefix("/categories/"),
icons: [{ src: prefix("/icons/icon-192.png"), sizes: "192x192" }],
},
{
name: "태그 목록",
short_name: "태그",
description: "관심 태그별 글을 탐색합니다.",
url: prefix("/tags/"),
icons: [{ src: prefix("/icons/icon-192.png"), sizes: "192x192" }],
},
{
name: "검색",
short_name: "검색",
description: "블로그 콘텐츠를 빠르게 검색합니다.",
url: prefix("/search/"),
icons: [{ src: prefix("/icons/icon-192.png"), sizes: "192x192" }],
},
],
}
}
55 changes: 55 additions & 0 deletions src/features/pwa/pwa-register.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"use client"

import { useEffect } from "react"
import { toast } from "sonner"

export function PwaRegister() {
useEffect(() => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return
}

// LCP 및 초기 렌더링 성능 저하를 방지하기 위해 load 이벤트 이후 지연 등록
const handleLoad = async () => {
// biome-ignore lint/complexity/useLiteralKeys: tsconfig noPropertyAccessFromIndexSignature requires bracket access
const basePath = process.env["NEXT_PUBLIC_BASE_PATH"]?.trim() ?? ""
const normalizedBasePath = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath
const swUrl = `${normalizedBasePath}/sw.js`
const scope = `${normalizedBasePath}/`

try {
const registration = await navigator.serviceWorker.register(swUrl, { scope })

// 새 버전의 Service Worker 가 대기(waiting) 중일 때 업데이트 알림 제공
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing
if (newWorker === null) {
return
}

newWorker.addEventListener("statechange", () => {
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
toast("새로운 글 또는 업데이트가 있습니다.", {
action: {
label: "새로고침",
onClick: () => window.location.reload(),
},
duration: 8000,
})
}
})
})
} catch {
// Service Worker 등록 실패는 조용히 넘어가 정상 웹 렌더링에 영향을 주지 않는다.
}
}

if (document.readyState === "complete") {
handleLoad()
} else {
window.addEventListener("load", handleLoad, { once: true })
}
}, [])

return null
}
Loading