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
42 changes: 41 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,45 @@
"hire": "Work with me"
}
},
"privacy": {
"title": "Privacy Policy",
"description": "What this site collects, who collects it, and how to switch it off.",
"metaTitle": "Privacy Policy",
"metaDescription": "How writingdeveloper.blog handles analytics, advertising and comment data through Google Analytics, Google AdSense and giscus, and how to opt out.",
"updatedLabel": "LAST UPDATED",
"updated": "20 August 2026",
"intro": "This site has no accounts, no sign-up and no contact form, so it asks you for nothing directly. Everything described below is collected by third-party services the site loads.",
"direct": {
"heading": "What I collect directly",
"body": "Nothing. There is no login, no newsletter, no form and no database of visitors. I never see your name, email address or postal address through this site."
},
"analytics": {
"heading": "Analytics",
"body": "Google Analytics 4 uses cookies to record page views, referrers, approximate location at country level, and which outbound links get clicked. Vercel Analytics and Speed Insights record loading performance without cookies and without identifying you.",
"idLabel": "MEASUREMENT ID"
},
"ads": {
"heading": "Advertising",
"body": "This site participates in Google AdSense. Third-party vendors, including Google, use cookies to serve ads based on your prior visits to this site or other sites. Google's use of advertising cookies enables it and its partners to serve ads to you based on your visit to this site and/or other sites on the internet. You can opt out of personalised advertising in <ads>Google Ads Settings</ads>, or opt out of third-party vendors' use of cookies at <aboutads>aboutads.info</aboutads>.",
"idLabel": "PUBLISHER ID"
},
"comments": {
"heading": "Comments",
"body": "Blog posts carry a comment box powered by <giscus>giscus</giscus>. Comments are stored as GitHub Discussions in the public repository writingdeveloper/portfolio. Leaving one requires signing in to GitHub and is handled entirely by GitHub under <policy>their privacy policy</policy>. Reading a page never requires an account."
},
"hosting": {
"heading": "Hosting",
"body": "The site runs on Vercel, which keeps standard server logs including IP addresses for security and abuse prevention."
},
"choices": {
"heading": "Your choices",
"body": "Blocking cookies in your browser, or using any content blocker, stops the analytics and advertising scripts entirely. Nothing on this site breaks if you do."
},
"contact": {
"heading": "Questions",
"body": "Email me and I will answer."
}
},
"accessibility": {
"toggleMenu": "Toggle menu",
"toggleTheme": "Toggle theme",
Expand Down Expand Up @@ -248,7 +287,8 @@
"tags": "Tags"
},
"footer": {
"copyright": "All rights reserved."
"copyright": "All rights reserved.",
"privacy": "Privacy"
},
"play": {
"title": "Explore",
Expand Down
42 changes: 41 additions & 1 deletion messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,45 @@
"hire": "함께 일하기"
}
},
"privacy": {
"title": "개인정보처리방침",
"description": "이 사이트가 무엇을 수집하고, 누가 수집하며, 어떻게 끌 수 있는지.",
"metaTitle": "개인정보처리방침",
"metaDescription": "writingdeveloper.blog가 Google Analytics, Google AdSense, giscus를 통해 다루는 분석·광고·댓글 데이터와 수집을 거부하는 방법을 정리합니다.",
"updatedLabel": "최종 수정",
"updated": "2026년 8월 20일",
"intro": "이 사이트에는 계정도, 가입도, 문의 양식도 없습니다. 그래서 방문자에게 직접 요구하는 정보가 없습니다. 아래 내용은 모두 사이트가 불러오는 외부 서비스가 수집하는 것입니다.",
"direct": {
"heading": "직접 수집하는 정보",
"body": "없습니다. 로그인도, 뉴스레터도, 입력 양식도, 방문자 데이터베이스도 없습니다. 이 사이트를 통해 여러분의 이름이나 이메일, 주소를 보는 일은 없습니다."
},
"analytics": {
"heading": "분석",
"body": "Google Analytics 4가 쿠키를 사용해 페이지 조회수, 유입 경로, 국가 단위의 대략적인 위치, 외부 링크 클릭을 기록합니다. Vercel Analytics와 Speed Insights는 쿠키 없이, 개인을 식별하지 않고 로딩 성능만 기록합니다.",
"idLabel": "측정 ID"
},
"ads": {
"heading": "광고",
"body": "이 사이트는 Google AdSense에 참여합니다. Google을 포함한 제3자 공급업체는 쿠키를 사용해 이 사이트 또는 다른 사이트의 이전 방문 기록을 바탕으로 광고를 게재합니다. Google이 광고 쿠키를 사용함으로써 Google과 그 파트너는 이 사이트 및 인터넷상의 다른 사이트 방문 기록을 근거로 광고를 게재할 수 있습니다. 맞춤 광고는 <ads>Google 광고 설정</ads>에서 사용 중지할 수 있고, 제3자 공급업체의 쿠키 사용은 <aboutads>aboutads.info</aboutads>에서 거부할 수 있습니다.",
"idLabel": "게시자 ID"
},
"comments": {
"heading": "댓글",
"body": "블로그 글에는 <giscus>giscus</giscus>로 만든 댓글창이 있습니다. 댓글은 공개 저장소인 writingdeveloper/portfolio의 GitHub Discussions에 저장됩니다. 댓글을 남기려면 GitHub 로그인이 필요하고, 이 과정은 전적으로 GitHub가 <policy>자체 개인정보처리방침</policy>에 따라 처리합니다. 글을 읽는 데는 계정이 필요하지 않습니다."
},
"hosting": {
"heading": "호스팅",
"body": "사이트는 Vercel에서 구동되며, Vercel은 보안과 남용 방지를 위해 IP 주소를 포함한 일반적인 서버 로그를 보관합니다."
},
"choices": {
"heading": "선택권",
"body": "브라우저에서 쿠키를 차단하거나 콘텐츠 차단기를 사용하면 분석과 광고 스크립트는 완전히 멈춥니다. 그렇게 해도 이 사이트의 기능은 아무것도 망가지지 않습니다."
},
"contact": {
"heading": "문의",
"body": "메일 주시면 답하겠습니다."
}
},
"accessibility": {
"toggleMenu": "메뉴 열기/닫기",
"toggleTheme": "테마 변경",
Expand Down Expand Up @@ -248,7 +287,8 @@
"tags": "태그"
},
"footer": {
"copyright": "모든 권리 보유."
"copyright": "모든 권리 보유.",
"privacy": "개인정보처리방침"
},
"play": {
"title": "탐험",
Expand Down
15 changes: 12 additions & 3 deletions src/app/[locale]/LedgerHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,18 @@ export function LedgerHome({ projects, tombstones, posts, locale }: {
</div>
<div className="mt-8 flex flex-col gap-2 border-t pt-6 sm:flex-row sm:items-center sm:justify-between" style={{ borderColor: BORDER }}>
<span className="ledger-mono text-xs" style={{ color: DIM }}>© 2026 WRITINGDEVELOPER · LEDGER NO. 001</span>
<Link href="/play" className="ledger-mono text-xs tracking-widest transition-colors hover:opacity-80" style={{ color: MUTED }}>
EXPLORE 3D →
</Link>
{/* The immersive home renders without the site Footer, so the
policy link has to be repeated here — AdSense requires it to
be reachable from every page, and this is the likeliest
landing page of the lot. */}
<div className="flex items-center gap-5">
<Link href="/privacy" className="ledger-mono text-xs tracking-widest transition-colors hover:opacity-80" style={{ color: MUTED }}>
PRIVACY
</Link>
<Link href="/play" className="ledger-mono text-xs tracking-widest transition-colors hover:opacity-80" style={{ color: MUTED }}>
EXPLORE 3D →
</Link>
</div>
</div>
</div>
</footer>
Expand Down
200 changes: 200 additions & 0 deletions src/app/[locale]/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import type { ReactNode } from 'react'
import { useTranslations, useLocale } from 'next-intl'
import { setRequestLocale, getTranslations } from 'next-intl/server'
import type { Metadata } from 'next'
import { PageTransition } from '@/components/ui/PageTransition'
import {
SITE_URL,
CONTACT_EMAIL,
ADSENSE_CLIENT,
GA_MEASUREMENT_ID,
} from '@/lib/constants'
import { generateBreadcrumbJsonLd, safeJsonLd } from '@/lib/seo'

// The copy is static: it only moves when the set of third-party scripts moves,
// and that is a code change anyway. Same cadence as /hire and /about.
export const revalidate = 3600

/**
* Opt-out and policy destinations named in the disclosure.
*
* AdSense requires the policy to *link* the opt-out, not merely mention it, so
* these are part of the compliance surface rather than decoration — if one dies
* the page stops satisfying the requirement it exists for.
*/
const ADS_SETTINGS_URL = 'https://myadcenter.google.com/'
const ABOUT_ADS_URL = 'https://www.aboutads.info/choices/'
const GISCUS_URL = 'https://giscus.app'
const GITHUB_PRIVACY_URL =
'https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement'

export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'privacy' })
const localePath = locale === 'ko' ? '' : `/${locale}`
const pageUrl = `${SITE_URL}${localePath}/privacy`

return {
title: t('metaTitle'),
description: t('metaDescription'),
openGraph: {
url: pageUrl,
title: t('metaTitle'),
description: t('metaDescription'),
locale: locale === 'ko' ? 'ko_KR' : 'en_US',
alternateLocale: locale === 'ko' ? ['en_US'] : ['ko_KR'],
type: 'article',
images: [
{
url: `${SITE_URL}/api/og?title=${encodeURIComponent(t('title'))}&description=${encodeURIComponent(t('description'))}`,
width: 1200,
height: 630,
alt: t('title'),
},
],
},
alternates: {
canonical: pageUrl,
languages: {
ko: `${SITE_URL}/privacy`,
en: `${SITE_URL}/en/privacy`,
'x-default': `${SITE_URL}/privacy`,
},
},
}
}

export default async function PrivacyPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
setRequestLocale(locale)
return <PrivacyContent />
}

/** An outbound link inside body copy. */
function Ext({ href, children }: { href: string; children: ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--accent-text)] underline underline-offset-2 transition-opacity hover:opacity-80"
>
{children}
</a>
)
}

/** One disclosure block: a heading, the prose, and any identifier it names. */
function Section({
heading,
children,
idLabel,
idValue,
}: {
heading: string
children: ReactNode
idLabel?: string
idValue?: string
}) {
return (
<section className="border-t border-[var(--border-default)] pt-6">
<h2 className="ledger-display text-lg font-bold mb-3">{heading}</h2>
<p className="text-[var(--text-secondary)] leading-relaxed">{children}</p>
{idLabel && idValue && (
<p className="mt-3">
<span className="ledger-mono text-[10px] tracking-[0.25em] uppercase text-[var(--text-muted)]">
{idLabel}
</span>{' '}
<span className="ledger-mono text-sm text-[var(--text-primary)]">{idValue}</span>
</p>
)}
</section>
)
}

function PrivacyContent() {
const t = useTranslations('privacy')
const locale = useLocale()
const localePath = locale === 'ko' ? '' : '/en'

const breadcrumbJsonLd = generateBreadcrumbJsonLd([
{ name: locale === 'ko' ? '홈' : 'Home', url: `${SITE_URL}${localePath}` },
{ name: t('title'), url: `${SITE_URL}${localePath}/privacy` },
])

return (
<PageTransition>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }}
/>

<div className="max-w-3xl mx-auto">
<header className="mb-10">
<h1 className="ledger-display text-3xl sm:text-4xl font-extrabold tracking-tight mb-3">
{t('title')}
</h1>
<p className="text-lg text-[var(--text-secondary)]">{t('description')}</p>
<p className="mt-4 ledger-mono text-[10px] tracking-[0.25em] uppercase text-[var(--text-muted)]">
{t('updatedLabel')} · {t('updated')}
</p>
</header>

<p className="mb-10 text-[var(--text-secondary)] leading-relaxed">{t('intro')}</p>

<div className="space-y-8">
<Section heading={t('direct.heading')}>{t('direct.body')}</Section>

<Section
heading={t('analytics.heading')}
idLabel={t('analytics.idLabel')}
idValue={GA_MEASUREMENT_ID}
>
{t('analytics.body')}
</Section>

{/* The two opt-out links are what AdSense actually requires here. */}
<Section
heading={t('ads.heading')}
idLabel={t('ads.idLabel')}
idValue={ADSENSE_CLIENT}
>
{t.rich('ads.body', {
ads: (chunks) => <Ext href={ADS_SETTINGS_URL}>{chunks}</Ext>,
aboutads: (chunks) => <Ext href={ABOUT_ADS_URL}>{chunks}</Ext>,
})}
</Section>

<Section heading={t('comments.heading')}>
{t.rich('comments.body', {
giscus: (chunks) => <Ext href={GISCUS_URL}>{chunks}</Ext>,
policy: (chunks) => <Ext href={GITHUB_PRIVACY_URL}>{chunks}</Ext>,
})}
</Section>

<Section heading={t('hosting.heading')}>{t('hosting.body')}</Section>

<Section heading={t('choices.heading')}>{t('choices.body')}</Section>

<Section heading={t('contact.heading')}>
{t('contact.body')}{' '}
<a
href={`mailto:${CONTACT_EMAIL}`}
className="ledger-mono text-[var(--accent-text)] underline underline-offset-2 transition-opacity hover:opacity-80"
>
{CONTACT_EMAIL}
</a>
</Section>
</div>
</div>
</PageTransition>
)
}
2 changes: 1 addition & 1 deletion src/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const koPosts = getAllPosts('ko')
const enPosts = getAllPosts('en')

const staticPages = ['', '/blog', '/projects', '/hire', '/studio', '/graveyard', '/about', '/play']
const staticPages = ['', '/blog', '/projects', '/hire', '/studio', '/graveyard', '/about', '/play', '/privacy']

// 프로젝트 스크린샷을 /projects 엔트리에 붙여 이미지 사이트맵에 노출한다.
const projectImages = (projectsData.projects as Project[])
Expand Down
9 changes: 9 additions & 0 deletions src/components/layout/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useTranslations } from 'next-intl'
import { Github, Linkedin } from 'lucide-react'
import { Link } from '@/i18n/navigation'

export function Footer() {
const t = useTranslations('footer')
Expand All @@ -13,6 +14,14 @@ export function Footer() {
<span className="text-[var(--accent-text)]" aria-hidden="true">▪</span> &copy; {year} WRITINGDEVELOPER · {t('copyright')}
</p>
<div className="flex items-center gap-4">
{/* AdSense requires the privacy policy to be reachable from every
page, so it lives in the global footer rather than one page. */}
<Link
href="/privacy"
className="ledger-mono text-xs tracking-[0.1em] text-[var(--text-muted)] hover:text-[var(--accent-text)] transition-colors"
>
{t('privacy')}
</Link>
<a href="https://github.com/writingdeveloper" target="_blank" rel="noopener noreferrer" className="text-[var(--text-muted)] hover:text-[var(--accent-text)] transition-colors" aria-label={ta('github')}>
<Github size={18} aria-hidden="true" />
</a>
Expand Down
Loading