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
7 changes: 7 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
{
"Header": {
"home": "Home",
"projects": "Projects",
"blog": "Blog",
"contact": "Contact",
"about": "About"
},
"Navigation": {
"back": "Back",
"moreIn": "More in {part}",
Expand Down
7 changes: 7 additions & 0 deletions messages/ko.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
{
"Header": {
"home": "소개",
"projects": "프로젝트",
"blog": "블로그",
"contact": "연락처",
"about": "소개(About)"
},
"Navigation": {
"back": "뒤로",
"moreIn": "{part}의 다른 글",
Expand Down
Binary file added public/store_promotional_tile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand All @@ -88,18 +77,17 @@ export default async function BlogPost({ params }: { params: Promise<{ slug: str
}

return (
<article className="min-h-screen bg-white dark:bg-neutral-950 font-sans selection:bg-blue-100 dark:selection:bg-blue-900">
<article className="selection:bg-blue-100 dark:selection:bg-blue-900">
<ViewTracker postId={post.id} />
<div className="max-w-7xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
<nav className="mb-8 flex items-center justify-between">
<Link
href="/"
href="/blog"
className="group inline-flex items-center text-sm font-medium text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors"
>
<span className="mr-2 group-hover:-translate-x-1 transition-transform">←</span>
{tNav('back')}
</Link>
<LanguageToggle translationSlug={translationSlug} />
</nav>

<div className="grid grid-cols-1 lg:grid-cols-4 gap-12">
Expand Down Expand Up @@ -162,7 +150,7 @@ export default async function BlogPost({ params }: { params: Promise<{ slug: str
</div>

<div className="mt-20 pt-10 border-t border-neutral-100 dark:border-neutral-800">
<Link href="/" className="text-blue-600 dark:text-blue-400 font-medium hover:underline">
<Link href="/blog" className="text-blue-600 dark:text-blue-400 font-medium hover:underline">
← {tNav('readMore')}
</Link>
</div>
Expand All @@ -182,7 +170,7 @@ export default async function BlogPost({ params }: { params: Promise<{ slug: str
{relatedPosts.length > 0 ? (
<div className="space-y-6">
{relatedPosts.map(relatedPost => (
<Link key={relatedPost.id} href={`/${relatedPost.slug}`} className="group block">
<Link key={relatedPost.id} href={`/blog/${relatedPost.slug}`} className="group block">
<h4 className="text-base font-medium text-neutral-900 dark:text-neutral-200 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors mb-2">
{relatedPost.title}
</h4>
Expand Down
110 changes: 110 additions & 0 deletions src/app/[locale]/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div className="max-w-7xl mx-auto px-4 py-12 sm:px-6 lg:px-8 relative">
<header className="mb-12 text-center space-y-4 pt-8">
<div className="inline-block p-3 rounded-2xl bg-white dark:bg-neutral-900 shadow-sm mb-4">
<Link href="/blog" aria-label="Blog"><span role="img" aria-label="writing" className="text-2xl">✍️</span></Link>
</div>
<h1 className="text-4xl font-bold tracking-tight text-neutral-900 dark:text-neutral-50 sm:text-5xl">
{"VXD Blog"}
</h1>
<p className="text-lg text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto leading-relaxed">
{t('description')}
</p>
</header>

<div className="grid grid-cols-1 lg:grid-cols-4 gap-12">
{/* Sidebar */}
<div className="lg:col-span-1">
<div className="sticky top-8">
<Sidebar
groups={groups}
topTags={topTags}
selectedGroup={selectedGroup}
selectedTags={selectedTags}
/>
</div>
</div>

{/* Main Content */}
<div className="lg:col-span-3">
{allPosts === null ? (
<div className="text-center py-32 bg-white dark:bg-neutral-900 rounded-2xl border border-dashed border-red-300 dark:border-red-900/30">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-50 dark:bg-red-900/20 mb-6">
<span className="text-3xl">📡</span>
</div>
<h3 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
No Internet Connection
</h3>
<p className="text-neutral-500 text-lg max-w-md mx-auto">
Please check your network settings and try again.
</p>
</div>
) : (
<PostList
initialPosts={initialPosts}
initialHasMore={initialHasMore}
tag={selectedTags[0]}
search={searchQuery}
group={selectedGroup}
locale={locale}
/>
)}
</div>
</div>

<Footer />
</div>
</>
);
}
91 changes: 91 additions & 0 deletions src/app/[locale]/canvassync-privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="min-h-screen bg-neutral-50 dark:bg-neutral-950 font-sans text-neutral-800 dark:text-neutral-200">
<div className="max-w-3xl mx-auto px-6 py-20">
<h1 className="text-4xl font-extrabold mb-4 tracking-tight">Privacy Policy for Canvas Sync</h1>
<p className="text-sm text-neutral-500 mb-16">Effective Date: April 10, 2026</p>

<section className="space-y-12">
<div className="group">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 flex items-center justify-center text-sm">1</span>
Data Access
</h2>
<p className="leading-relaxed opacity-90 pl-10">
Canvas Sync (&quot;the Extension&quot;) 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.
</p>
</div>

<div className="group">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-8 h-8 rounded-lg bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 flex items-center justify-center text-sm">2</span>
Storage
</h2>
<p className="leading-relaxed opacity-90 pl-10">
All data, including your Access Token and Canvas URL, is stored locally on your device via <code className="bg-neutral-200 dark:bg-neutral-800 px-1 rounded">chrome.storage</code>. This ensures that your sensitive information never leaves your local environment without your direct interaction.
</p>
</div>

<div className="group">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-8 h-8 rounded-lg bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 flex items-center justify-center text-sm">3</span>
Transmission
</h2>
<p className="leading-relaxed opacity-90 pl-10">
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.
</p>
</div>

<div className="group">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-8 h-8 rounded-lg bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 flex items-center justify-center text-sm">4</span>
Tracking
</h2>
<p className="leading-relaxed opacity-90 pl-10">
We do not use any tracking pixels, analytics, or telemetry. We do not monitor your usage patterns or collect any diagnostic data.
</p>
</div>

<div className="group">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-8 h-8 rounded-lg bg-pink-100 dark:bg-pink-900/30 text-pink-600 dark:text-pink-400 flex items-center justify-center text-sm">5</span>
Visual Assets
</h2>
<div className="leading-relaxed opacity-90 pl-10 space-y-4">
<p>For store listing and promotional purposes, the following assets are utilized:</p>
<ul className="list-disc pl-5 space-y-2">
<li><strong>Promotional Image</strong>: Utilizing <code className="bg-neutral-200 dark:bg-neutral-800 px-1 rounded">store_promotional_tile.png</code> for the Chrome Web Store.</li>
<li><strong>Screenshots</strong>: Localized screenshots of the popup in English and Korean are provided to ensure the best user experience and conversion.</li>
</ul>
</div>
</div>

<div className="mt-16 pt-12 border-t border-neutral-200 dark:border-neutral-800">
<div className="bg-white dark:bg-neutral-900 p-8 rounded-3xl border border-neutral-200 dark:border-neutral-800 shadow-xl shadow-neutral-200/50 dark:shadow-none text-center">
<h2 className="text-2xl font-bold mb-4">6. Contact</h2>
<p className="mb-6 opacity-80">
If you have any questions or concerns regarding this Privacy Policy, please contact the developer:
</p>
<button
onClick={handleEmailClick}
className="inline-flex items-center px-8 py-3 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 font-semibold rounded-full hover:scale-105 transition-transform active:scale-95 shadow-lg"
>
Send an Email
</button>
</div>
</div>
</section>
</div>
</main>
);
}

78 changes: 78 additions & 0 deletions src/app/[locale]/contact/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
export default function ContactPage() {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-20 min-h-[calc(100vh-64px)] flex flex-col justify-center">
<div className="text-center mb-16">
<h1 className="text-4xl md:text-6xl font-extrabold text-neutral-900 dark:text-white mb-6">
Get in Touch
</h1>
<p className="text-xl text-neutral-600 dark:text-neutral-400 max-w-2xl mx-auto">
I&apos;m always open to discussing product design work or partnership opportunities.
</p>
</div>

<div className="bg-white dark:bg-neutral-900 rounded-3xl p-8 md:p-12 shadow-xl border border-neutral-100 dark:border-neutral-800">
<form className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-2">
<label htmlFor="name" className="block text-sm font-semibold text-neutral-900 dark:text-neutral-200">
Name
</label>
<input
type="text"
id="name"
name="name"
className="w-full px-4 py-3 rounded-xl border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-950 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 transition-shadow"
placeholder="John Doe"
/>
</div>

<div className="space-y-2">
<label htmlFor="email" className="block text-sm font-semibold text-neutral-900 dark:text-neutral-200">
Email
</label>
<input
type="email"
id="email"
name="email"
className="w-full px-4 py-3 rounded-xl border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-950 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 transition-shadow"
placeholder="john@example.com"
/>
</div>
</div>

<div className="space-y-2">
<label htmlFor="message" className="block text-sm font-semibold text-neutral-900 dark:text-neutral-200">
Message
</label>
<textarea
id="message"
name="message"
rows={6}
className="w-full px-4 py-3 rounded-xl border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-950 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 transition-shadow resize-none"
placeholder="How can I help you?"
></textarea>
</div>

<button
type="button"
className="w-full md:w-auto px-10 py-4 bg-blue-600 dark:bg-blue-500 text-white font-bold rounded-full hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors shadow-lg hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0"
>
Send Message
</button>
</form>
</div>

<div className="mt-20 flex flex-col md:flex-row items-center justify-center gap-8 text-neutral-600 dark:text-neutral-400">
<a href="mailto:hello@example.com" className="flex items-center gap-2 hover:text-blue-600 dark:hover:text-blue-400 transition-colors font-medium">
<span>✉️</span> hello@vxd-blog.app
</a>
<a href="https://github.com" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 hover:text-neutral-900 dark:hover:text-white transition-colors font-medium">
<span>🐙</span> GitHub
</a>
<a href="https://linkedin.com" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 hover:text-blue-700 dark:hover:text-blue-400 transition-colors font-medium">
<span>💼</span> LinkedIn
</a>
</div>
</div>
);
}
6 changes: 5 additions & 1 deletion src/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -90,7 +91,10 @@ export default async function LocaleLayout({
disableTransitionOnChange
>
<ErrorBoundary>
{children}
<Header />
<main className="min-h-screen bg-neutral-50 dark:bg-neutral-950 font-sans selection:bg-neutral-200 dark:selection:bg-neutral-800 transition-colors duration-300">
{children}
</main>
</ErrorBoundary>
</ThemeProvider>
</NextIntlClientProvider>
Expand Down
Loading
Loading