diff --git a/messages/en.json b/messages/en.json index ad7c8de..93e7b1a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,4 +1,11 @@ { + "Header": { + "home": "Home", + "projects": "Projects", + "blog": "Blog", + "contact": "Contact", + "about": "About" + }, "Navigation": { "back": "Back", "moreIn": "More in {part}", diff --git a/messages/ko.json b/messages/ko.json index e20fb1a..085758d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1,4 +1,11 @@ { + "Header": { + "home": "소개", + "projects": "프로젝트", + "blog": "블로그", + "contact": "연락처", + "about": "소개(About)" + }, "Navigation": { "back": "뒤로", "moreIn": "{part}의 다른 글", diff --git a/public/store_promotional_tile.png b/public/store_promotional_tile.png new file mode 100644 index 0000000..801c1fe Binary files /dev/null and b/public/store_promotional_tile.png differ diff --git a/src/app/[locale]/[slug]/page.tsx b/src/app/[locale]/blog/[slug]/page.tsx similarity index 89% rename from src/app/[locale]/[slug]/page.tsx rename to src/app/[locale]/blog/[slug]/page.tsx index 39be8a2..2db9e98 100644 --- a/src/app/[locale]/[slug]/page.tsx +++ b/src/app/[locale]/blog/[slug]/page.tsx @@ -4,13 +4,11 @@ import { notFound } from "next/navigation"; import { getTranslations } from 'next-intl/server'; import { CommentSection } from "@/widgets/comment-section"; -import { LanguageToggle } from "@/features/language"; import { ViewTracker } from "@/features/track-views"; import { getPageContent, - getPostBySlug, - getPublishedPosts, - getPostById + getPostBySlug, + getPublishedPosts } from "@/entities/lib/services"; import { PostEngagement } from "@/entities/post"; import { BlockRenderer } from "@/entities/notion"; @@ -67,15 +65,6 @@ export default async function BlogPost({ params }: { params: Promise<{ slug: str notFound(); } - // Get translated post slug if it exists - let translationSlug = null; - if (post.translationId) { - const translatedPost = await getPostById(post.translationId); - if (translatedPost) { - translationSlug = translatedPost.slug; - } - } - const blocks = await getPageContent(post.id); // Fetch related posts (same part) @@ -88,18 +77,17 @@ export default async function BlogPost({ params }: { params: Promise<{ slug: str } return ( -
+
@@ -162,7 +150,7 @@ export default async function BlogPost({ params }: { params: Promise<{ slug: str
- + ← {tNav('readMore')}
@@ -182,7 +170,7 @@ export default async function BlogPost({ params }: { params: Promise<{ slug: str {relatedPosts.length > 0 ? (
{relatedPosts.map(relatedPost => ( - +

{relatedPost.title}

diff --git a/src/app/[locale]/blog/page.tsx b/src/app/[locale]/blog/page.tsx new file mode 100644 index 0000000..5a10ea3 --- /dev/null +++ b/src/app/[locale]/blog/page.tsx @@ -0,0 +1,110 @@ +import { getTranslations } from 'next-intl/server'; +import Link from "next/link"; + +import { Sidebar } from "@/widgets/sidebar"; +import { PostList } from "@/widgets/post-list"; +import { Footer } from "@/widgets/footer"; +import { SortOption } from "@/entities/lib/types"; +import { + getPublishedPosts, + getAllGroups, + getTopTags +} from "@/entities/lib/services"; + +export const revalidate = 3600; // Revalidate every 1 hour + +export default async function Home({ + searchParams, + params +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + params: Promise<{ locale: string }>; +}) { + const resolvedSearchParams = await searchParams; + const { locale } = await params; + const t = await getTranslations('Common'); + + // Extract and deduplicate multiple tags from URL parameters + const rawTags = resolvedSearchParams.tag; + const selectedTags: string[] = Array.isArray(rawTags) + ? [...new Set(rawTags)] // Remove duplicates + : rawTags ? [rawTags] : []; + + const selectedGroup = typeof resolvedSearchParams.group === 'string' ? resolvedSearchParams.group : undefined; + const searchQuery = typeof resolvedSearchParams.search === 'string' ? resolvedSearchParams.search : undefined; + const sortBy = (typeof resolvedSearchParams.sort === 'string' ? resolvedSearchParams.sort : 'published_date') as SortOption; + + const allPosts = await getPublishedPosts({ + tags: selectedTags, + searchQuery, + group: selectedGroup, + locale, + sortBy + }); + const groups = await getAllGroups(); + const topTags = await getTopTags(); + + const POSTS_PER_PAGE = 6; + const initialPosts = allPosts ? allPosts.slice(0, POSTS_PER_PAGE) : []; + const initialHasMore = allPosts ? allPosts.length > POSTS_PER_PAGE : false; + + return ( + <> +
+
+
+ ✍️ +
+

+ {"VXD Blog"} +

+

+ {t('description')} +

+
+ +
+ {/* Sidebar */} +
+
+ +
+
+ + {/* Main Content */} +
+ {allPosts === null ? ( +
+
+ 📡 +
+

+ No Internet Connection +

+

+ Please check your network settings and try again. +

+
+ ) : ( + + )} +
+
+ +
+
+ + ); +} diff --git a/src/app/[locale]/canvassync-privacy/page.tsx b/src/app/[locale]/canvassync-privacy/page.tsx new file mode 100644 index 0000000..ca925c6 --- /dev/null +++ b/src/app/[locale]/canvassync-privacy/page.tsx @@ -0,0 +1,91 @@ +"use client"; + +export default function CanvasSyncPrivacyPage() { + const handleEmailClick = (e: React.MouseEvent) => { + e.preventDefault(); + const user = "wjd516"; + const domain = "gmail.com"; + window.location.href = `mailto:${user}@${domain}`; + }; + + return ( +
+
+

Privacy Policy for Canvas Sync

+

Effective Date: April 10, 2026

+ +
+
+

+ 1 + Data Access +

+

+ Canvas Sync ("the Extension") values your privacy. The extension accesses your Canvas LMS data (courses, assignments, calendar) using your provided Access Token to provide a unified timeline and notifications. +

+
+ +
+

+ 2 + Storage +

+

+ All data, including your Access Token and Canvas URL, is stored locally on your device via chrome.storage. This ensures that your sensitive information never leaves your local environment without your direct interaction. +

+
+ +
+

+ 3 + Transmission +

+

+ No data is ever sent to our servers or any third-party. All API calls are made directly from your browser to your specified Canvas instance. Your credentials and academic data remain private to your browser session. +

+
+ +
+

+ 4 + Tracking +

+

+ We do not use any tracking pixels, analytics, or telemetry. We do not monitor your usage patterns or collect any diagnostic data. +

+
+ +
+

+ 5 + Visual Assets +

+
+

For store listing and promotional purposes, the following assets are utilized:

+
    +
  • Promotional Image: Utilizing store_promotional_tile.png for the Chrome Web Store.
  • +
  • Screenshots: Localized screenshots of the popup in English and Korean are provided to ensure the best user experience and conversion.
  • +
+
+
+ +
+
+

6. Contact

+

+ If you have any questions or concerns regarding this Privacy Policy, please contact the developer: +

+ +
+
+
+
+
+ ); +} + diff --git a/src/app/[locale]/contact/page.tsx b/src/app/[locale]/contact/page.tsx new file mode 100644 index 0000000..1814c4e --- /dev/null +++ b/src/app/[locale]/contact/page.tsx @@ -0,0 +1,78 @@ +export default function ContactPage() { + return ( +
+
+

+ Get in Touch +

+

+ I'm always open to discussing product design work or partnership opportunities. +

+
+ +
+
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ + +
+
+ + +
+ ); +} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 378f6b9..1749e7a 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -11,6 +11,7 @@ import { routing } from '@/shared/i18n/routing'; import { ThemeProvider } from "@/shared/providers"; import { ErrorBoundary } from "@/shared/ui"; import { GoogleAdSense } from "@/shared/lib/analytics"; +import { Header } from "@/widgets/header"; import "../globals.css"; @@ -90,7 +91,10 @@ export default async function LocaleLayout({ disableTransitionOnChange > - {children} +
+
+ {children} +
diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index e4f060d..c1d072c 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -1,119 +1,63 @@ -import { getTranslations } from 'next-intl/server'; -import Link from "next/link"; -import { Sidebar } from "@/widgets/sidebar"; -import { PostList } from "@/widgets/post-list"; -import { Footer } from "@/widgets/footer"; -import { ModeToggle } from "@/features/theme"; -import { LanguageToggle } from "@/features/language"; -import { Search } from "@/features/search-posts"; -import { SortOption } from "@/entities/lib/types"; -import { - getPublishedPosts, - getAllGroups, - getTopTags -} from "@/entities/lib/services"; +import { Link } from '@/shared/i18n/routing'; -export const revalidate = 3600; // Revalidate every 1 hour - -export default async function Home({ - searchParams, - params -}: { - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; - params: Promise<{ locale: string }>; -}) { - const resolvedSearchParams = await searchParams; - const { locale } = await params; - const t = await getTranslations('Common'); - - // Extract and deduplicate multiple tags from URL parameters - const rawTags = resolvedSearchParams.tag; - const selectedTags: string[] = Array.isArray(rawTags) - ? [...new Set(rawTags)] // Remove duplicates - : rawTags ? [rawTags] : []; - - const selectedGroup = typeof resolvedSearchParams.group === 'string' ? resolvedSearchParams.group : undefined; - const searchQuery = typeof resolvedSearchParams.search === 'string' ? resolvedSearchParams.search : undefined; - const sortBy = (typeof resolvedSearchParams.sort === 'string' ? resolvedSearchParams.sort : 'published_date') as SortOption; - - const allPosts = await getPublishedPosts({ - tags: selectedTags, - searchQuery, - group: selectedGroup, - locale, - sortBy - }); - const groups = await getAllGroups(); - const topTags = await getTopTags(); - - const POSTS_PER_PAGE = 6; - const initialPosts = allPosts ? allPosts.slice(0, POSTS_PER_PAGE) : []; - const initialHasMore = allPosts ? allPosts.length > POSTS_PER_PAGE : false; +export default function HomePage() { return ( -
-
-
- - - -
+
+ {/* Hero Section */} +
+
-
-
- ✍️ -
-

- {"VXD Blog"} +
+

+ Hi, I'm VXD

-

- {t('description')} + +

+ Software Engineer creating exceptional digital experiences. + Passionate about modern web technologies and building scalable applications.

-

-
- {/* Sidebar */} -
-
- -
+
+ + View Projects + + + Read Blog +
- - {/* Main Content */} -
- {allPosts === null ? ( -
-
- 📡 +
+
+ + {/* Experience / Resume Section Placeholder */} +
+
+

Experience

+ +
+ {[1, 2, 3].map((i) => ( +
+
+
+

Software Engineer

+ 2023 - Present
-

- No Internet Connection -

-

- Please check your network settings and try again. +

Tech Company

+

+ Developed scalable web applications using React, Next.js, and TypeScript. Improved performance by 40% and led a team of 3 developers in a major migration project.

- ) : ( - - )} + ))}
- -
-
-
+ +
); } diff --git a/src/app/[locale]/projects/page.tsx b/src/app/[locale]/projects/page.tsx new file mode 100644 index 0000000..7fa0ec0 --- /dev/null +++ b/src/app/[locale]/projects/page.tsx @@ -0,0 +1,71 @@ +export default function ProjectsPage() { + const projects = [ + { + title: "VXD Blog", + description: "A minimalistic blog and portfolio built with Next.js, Notion API, and Tailwind CSS. Features dynamic content loading, multilingual support, and responsive design.", + tags: ["Next.js", "React", "TypeScript", "Notion API", "Tailwind"], + link: "https://vxd-blog.vercel.app" + }, + { + title: "E-Commerce Platform", + description: "A full-stack e-commerce solution with real-time inventory tracking, secure payments processing, and an intuitive admin dashboard.", + tags: ["React", "Express", "PostgreSQL", "Stripe", "Docker"], + link: "#" + }, + { + title: "Task Management App", + description: "A collaborative project management tool featuring real-time updates, kanban boards, and progress tracking visualizations.", + tags: ["Vue", "Node.js", "MongoDB", "Socket.io"], + link: "#" + } + ]; + + return ( +
+
+

+ Projects +

+

+ A showcase of things I've built, focusing on performance, elegant user interfaces, and robust architectures. +

+
+ +
+ {projects.map((project, idx) => ( +
+
+ +

+ {project.title} +

+ +

+ {project.description} +

+ +
+ {project.tags.map(tag => ( + + {tag} + + ))} +
+ + + View Project + +
+ ))} +
+
+ ); +} diff --git a/src/app/[locale]/tamago-privacy/page.tsx b/src/app/[locale]/tamago-privacy/page.tsx index c311714..c230e05 100644 --- a/src/app/[locale]/tamago-privacy/page.tsx +++ b/src/app/[locale]/tamago-privacy/page.tsx @@ -1,18 +1,75 @@ -export default async function TamagoPrivacyPage() { +"use client"; + +export default function TamagoPrivacyPage() { + const handleEmailClick = (e: React.MouseEvent) => { + e.preventDefault(); + const user = "wjd516"; + const domain = "gmail.com"; + window.location.href = `mailto:${user}@${domain}`; + }; + return ( -
-
- Effective Date: March 19, 2026 +
+
+

Privacy Policy

+

Effective Date: March 19, 2026

- 1. Data Collection "Tamago-bot" respects your privacy. We do not collect, transmit, distribute, or sell your personal data or personally identifiable information (PII). +
+
+

+ 1 + Data Collection +

+

+ "Tamago-bot" respects your privacy. We do not collect, transmit, distribute, or sell your personal data or personally identifiable information (PII). +

+
- 2. Data Usage The extension uses the chrome.storage.local API exclusively to save the state of your virtual pixel-art pet locally on your device. This saved data includes the pet's current life stage, hunger, mood, energy, and cleanliness stats. This data is required solely to keep the virtual pet persisting across your browser sessions. +
+

+ 2 + Data Usage +

+

+ The extension uses the chrome.storage.local API exclusively to save the state of your virtual pixel-art pet locally on your device. This saved data includes the pet's current life stage and stats. This data is required solely to keep the virtual pet persisting across sessions. +

+
- 3. Third-Party Sharing No data is ever sent to external servers, cloud services, or third parties. Your data never leaves your personal device. +
+

+ 3 + Third-Party Sharing +

+

+ No data is ever sent to external servers, cloud services, or third parties. Your data never leaves your personal device. +

+
- 4. Data Retention and Deletion The saved data remains on your local browser. If you wish to delete your data, you can simply uninstall the Tamago-bot extension from your Chrome browser, which will permanently clear all associated local storage data. +
+

+ 4 + Data Retention and Deletion +

+

+ The saved data remains on your local browser. If you wish to delete your data, you can simply uninstall the extension, which will permanently clear all associated local storage data. +

+
- 5. Contact If you have any questions or concerns regarding this Privacy Policy, please contact the developer at: [EMAIL_ADDRESS]. +
+
+

5. Contact

+

+ If you have any questions or concerns regarding this Privacy Policy, please contact the developer: +

+ +
+
+
); diff --git a/src/entities/post/ui/PostCard.tsx b/src/entities/post/ui/PostCard.tsx index 7187131..7f6b227 100644 --- a/src/entities/post/ui/PostCard.tsx +++ b/src/entities/post/ui/PostCard.tsx @@ -7,11 +7,11 @@ import { BlogPost } from "@/entities/lib/types"; import { usePrefetch } from "../hooks"; export function PostCard({ post }: { post: BlogPost }) { - const { prefetch, cancel } = usePrefetch(`/${post.slug}`); + const { prefetch, cancel } = usePrefetch(`/blog/${post.slug}`); return (
Go to homepage diff --git a/src/widgets/header/index.ts b/src/widgets/header/index.ts new file mode 100644 index 0000000..43c1c4b --- /dev/null +++ b/src/widgets/header/index.ts @@ -0,0 +1 @@ +export { Header } from './ui/Header'; diff --git a/src/widgets/header/ui/Header.tsx b/src/widgets/header/ui/Header.tsx new file mode 100644 index 0000000..6a325ab --- /dev/null +++ b/src/widgets/header/ui/Header.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { usePathname } from '@/shared/i18n/routing'; +import { Link } from '@/shared/i18n/routing'; +import { cn } from '@/shared/lib/utils'; +import { ModeToggle } from '@/features/theme'; +import { LanguageToggle } from '@/features/language'; +import { Search } from '@/features/search-posts'; + +export function Header() { + const t = useTranslations('Header'); + const pathname = usePathname(); + + const navItems = [ + { name: t('home'), href: '/' }, + { name: t('about'), href: '/about' }, + { name: t('projects'), href: '/projects' }, + { name: t('blog'), href: '/blog' }, + { name: t('contact'), href: '/contact' }, + ]; + + const isNavActive = (href: string) => { + if (href === '/') { + return pathname === '/'; + } + return pathname.startsWith(href); + }; + + return ( +
+
+
+
+ + VXD + +
+ + + +
+ + + +
+
+
+ + {/* Mobile nav placeholder - can expand later if needed */} +
+ {navItems.map((item) => ( + + {item.name} + + ))} +
+
+ ); +}